README.md

# EctoCooler

![Ecto Cooler Logo](https://github.com/daytonn/ecto_cooler/blob/2d89eeaf1de6b4c9f754a2f48756e4da5ff9015f/mascott.png)

- [About](#about)
- [Features](#features)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
  - [Basic usage](#basic-usage---generate-all-ectocooler-functions)
  - [Explicit usage](#explicit-usage---generate-only-given-functions)
  - [Exclusive usage](#exclusive-usage---generate-all-but-the-given-functions)
  - [Alias :read](#alias-read---generate-data-access-functions)
  - [Alias :read_write](#alias-read_write---generate-data-access-and-manipulation-functions-excluding-delete)
  - [Alias :write](#alias-write---generate-data-mutation-functions)
  - [Alias :delete](#alias-delete---generate-only-delete-helpers)
  - [Resource functions](#resource-functions)
  - [Generators](#generators)
    - [mix ectc.gen.repo](#mix-ectcgenrepo)
    - [mix ectc.gen.schema](#mix-ectcgenschema)
    - [mix ectc.gen.migration](#mix-ectcgenmigration)
    - [mix ecto_cooler.resources](#mix-ecto_coolerresources)
    - [Attribute format](#attribute-format)
- [Caveats](#caveats)
- [Contribution](#contribution)
  - [Bug reports](#bug-reports)
  - [Pull requests](#pull-requests)
- [Release Workflow](#release-workflow)
  - [Branch Naming Convention](#branch-naming-convention)
  - [What Happens on Merge](#what-happens-on-merge)
  - [Skipping Releases](#skipping-releases)
  - [Manual Releases](#manual-releases)
- [License](#license)

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

## About

When using [Context modules](https://hexdocs.pm/phoenix/contexts.html) 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.Schema` referenced 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.Repo` and `Ecto.Schema`

  - `EctoCooler` can be used to generate CRUD functions for a given `Ecto.Repo` and `Ecto.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.

- ### Automatic pluralization

  - `EctoCooler` uses `DropsInflector` when generating functions to create readable english function names automatically. For example, given the schema `Person`, a function named `all_people/1` is 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 `EctoCooler` was designed for [Phoenix Contexts](https://hexdocs.pm/phoenix/contexts.html) in mind, It can be used in any Elixir module to access Ecto-based back-ends.

## Installation

This package is available in [Hex](https://hex.pm/), the package can be installed by adding ecto_cooler to your list of dependencies in mix.exs:

```elixir
    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.

```elixir
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:

```elixir
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

```elixir
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/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`

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

```elixir
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/1`
- `MyApp.Repo.Posts.change_post/2`
- `MyApp.Repo.Posts.post_changeset/0`
- `MyApp.Repo.Posts.create_post/1`
- `MyApp.Repo.Posts.create_post!/1`
- `MyApp.Repo.Posts.delete_post/1`
- `MyApp.Repo.Posts.delete_post!/1`
- `MyApp.Repo.Posts.get_post/2`
- `MyApp.Repo.Posts.get_post!/2`
- `MyApp.Repo.Posts.get_post_by/2`
- `MyApp.Repo.Posts.get_post_by!/2`
- `MyApp.Repo.Posts.update_post/2`
- `MyApp.Repo.Posts.update_post!/2`

### Explicit usage - generate only given functions

```elixir
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:

- `MyApp.Repo.Posts.create/1`
- `MyApp.Repo.Posts.delete!/1`

### Exclusive usage - generate all but the given functions

```elixir
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:

- `MyApp.Repo.Posts.all/1`
- `MyApp.Repo.Posts.change/2`
- `MyApp.Repo.Posts.changeset/0`
- `MyApp.Repo.Posts.create!/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`

### Alias `:read` - generate data access functions

```elixir
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:

- `MyApp.Repo.Posts.all/1`
- `MyApp.Repo.Posts.get/2`
- `MyApp.Repo.Posts.get!/2`
- `MyApp.Repo.Posts.get_by/2`
- `MyApp.Repo.Posts.get_by!/2`

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

```elixir
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/1`
- `MyApp.Repo.Posts.change/2`
- `MyApp.Repo.Posts.changeset/0`
- `MyApp.Repo.Posts.create/1`
- `MyApp.Repo.Posts.create!/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`

### Alias `:write` - generate data mutation functions

```elixir
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:

- `MyApp.Repo.Posts.change/2`
- `MyApp.Repo.Posts.changeset/0`
- `MyApp.Repo.Posts.create/1`
- `MyApp.Repo.Posts.create!/1`
- `MyApp.Repo.Posts.update/2`
- `MyApp.Repo.Posts.update!/2`
- `MyApp.Repo.Posts.delete/1`
- `MyApp.Repo.Posts.delete!/1`

### Alias `:delete` - generate only delete helpers

```elixir
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:

- `MyApp.Repo.Posts.delete/1`
- `MyApp.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 preload
- `order_by` - an Ecto order_by clause
- `where` - a keyword list of field/value pairs to filter by

```elixir
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.

```elixir
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.

```elixir
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.

```elixir
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`.

```elixir
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.

```elixir
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`.

```elixir
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`.

```elixir
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`.

```elixir
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`.

```elixir
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`.

```elixir
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.

```elixir
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`.

```elixir
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](#configuration) for details.

### mix ectc.gen.repo

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

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

**Example:**

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

This creates three files:

**`lib/my_app/repo/posts.ex`** — Repo context module:
```elixir
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:
```elixir
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:
```elixir
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.

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

**Example:**

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

This creates two files:

- `lib/my_app/schema/post.ex` — Ecto schema
- `priv/repo/migrations/TIMESTAMP_create_posts.exs` — Ecto migration

### mix ectc.gen.migration

Generates only a migration file.

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

**Example:**

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

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

```bash
mix ecto_cooler.resources [module_name]
```

**Example:**

```bash
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: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](https://github.com/daytonn/ecto_cooler/issues). 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](https://github.com/daytonn/ecto_cooler/issues)

### 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/description` or `breaking/description`
  - Example: `major/breaking-api-changes`
- **Minor version bump**: `minor/description` or `feature/description`
  - Example: `feature/add-new-generator`
- **Patch version bump**: `patch/description`, `fix/description`, `bugfix/description`, or `hotfix/description`
  - Example: `fix/resolve-version-parsing-issue`

### 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:
- 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:
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](https://raw.githubusercontent.com/daytonn/ecto_cooler/main/LICENSE.txt)