Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

* Ensure field/assoc/embed exists when listing errors in `flat_errors_on/3`. This prevents accidental test passes on typos in assertions like `refute_errors_on(cs, :sommtypo)`.
* Add ability to disable "tagged" not found errors in `Repo.fetch/2` and friends (local to calls or global option).

## [1.0.0] - 2023-12-21

Expand Down
26 changes: 17 additions & 9 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import Config

config = [
migration_timestamps: [type: :utc_datetime_usec],
migration_primary_key: [name: :id, type: :binary_id],
database: "bitcrowd_ecto_#{config_env()}",
username: "postgres",
password: "postgres",
hostname: "localhost",
priv: "test/support/test_repo"
]

if config_env() in [:dev, :test] do
config :bitcrowd_ecto, BitcrowdEcto.TestRepo,
migration_timestamps: [type: :utc_datetime_usec],
migration_primary_key: [name: :id, type: :binary_id],
database: "bitcrowd_ecto_#{config_env()}",
username: "postgres",
password: "postgres",
hostname: "localhost",
priv: "test/support/test_repo"
config :bitcrowd_ecto, BitcrowdEcto.TestRepo, config
config :bitcrowd_ecto, BitcrowdEcto.TestRepoWithUntaggedNotFoundErrors, config

config :bitcrowd_ecto, ecto_repos: [BitcrowdEcto.TestRepo]
config :bitcrowd_ecto,
ecto_repos: [BitcrowdEcto.TestRepo, BitcrowdEcto.TestRepoWithUntaggedNotFoundErrors]

config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
end
Expand All @@ -21,6 +26,9 @@ if config_env() == :test do

config :bitcrowd_ecto, BitcrowdEcto.TestRepo, pool: Ecto.Adapters.SQL.Sandbox

config :bitcrowd_ecto, BitcrowdEcto.TestRepoWithUntaggedNotFoundErrors,
pool: Ecto.Adapters.SQL.Sandbox

config :ex_cldr,
default_backend: BitcrowdEcto.TestCldr,
default_locale: "en"
Expand Down
59 changes: 47 additions & 12 deletions lib/bitcrowd_ecto/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ defmodule BitcrowdEcto.Repo do
@type fetch_option ::
{:lock, lock_mode | false}
| {:preload, atom | list}
| {:error_tag, any}
| {:error_tag, false | any}
| {:raise_cast_error, boolean()}
| ecto_option

@type fetch_result :: {:ok, Ecto.Schema.t()} | {:error, {:not_found, Ecto.Queryable.t() | any}}
@type fetch_result ::
{:ok, Ecto.Schema.t()}
| {:error, {:not_found, Ecto.Queryable.t() | any}}
| {:error, :not_found}

@ecto_options [:prefix, :timeout, :log, :telemetry_event, :telemetry_options]

Expand All @@ -39,46 +42,62 @@ defmodule BitcrowdEcto.Repo do
| {:telemetry_options, any}

@doc """
Fetches a record by primary key or returns a "tagged" error tuple.
Fetches a record by primary key or returns an error tuple.

See `c:fetch_by/3`.
"""
@doc since: "0.1.0"
@callback fetch(schema :: module, id :: any) :: fetch_result()

@doc """
Fetches a record by given clauses or returns a "tagged" error tuple.
Fetches a record by given clauses or returns an error tuple.

See `c:fetch_by/3` for options.
"""
@doc since: "0.1.0"
@callback fetch(schema :: module, id :: any, [fetch_option()]) :: fetch_result()

@doc """
Fetches a record by given clauses or returns a "tagged" error tuple.
Fetches a record by given clauses or returns an error tuple.

See `c:fetch_by/3` for options.
"""
@doc since: "0.1.0"
@callback fetch_by(queryable :: Ecto.Queryable.t(), clauses :: map | keyword) :: fetch_result()

@doc """
Fetches a record by given clauses or returns a "tagged" error tuple.
Fetches a record by given clauses or returns an error tuple.

- On success, the record is wrapped in a `:ok` tuple.
- On error, a "tagged" error tuple is returned that contains the *original* queryable or module
as the tag, e.g. `{:error, {:not_found, Account}}` for a `fetch_by(Account, id: 1)` call.
- On error, an error tuple is returned

## Tagged error tuples

By default, the error tuple will be a "tagged" `:not_found` tuple, e.g.
`{:error, {:not_found, Account}}` for a `fetch_by(Account, id: 1)` call, where the "tag" is
the unmodified `queryable` parameter. The idea behind this is to avoid mix-ups of
naked `:not_found` errors, particularly in `with` clauses.

Tagging behaviour may be disabled by passing the `error_tag: false` option to return
naked `{:error, :not_found}` tuples instead. For existing applications where untagged errors
are the norm, one may set the `tagged_not_found_errors: false` option when using this module.

use BitcrowdEcto.Repo, tagged_not_found_errors: false

## Automatic conversion of `CastError`

Passing invalid values that would normally result in an `Ecto.Query.CastError` will result in
a `:not_found` error tuple as well.
a `:not_found` error tuple. This is useful for low-level handling of invalid UUIDs passed
from a hand-edited URL to the domain layer.

This function can also apply row locks.
This behaviour can be disabled by passing `raise_cast_error: false`.

## Options

* `lock` any of `[:no_key_update, :update]` (defaults to `false`)
* `preload` allows to preload associations
* `error_tag` allows to specify a custom "tag" value (instead of the queryable)
or `false` to disabled tagged error tuples
* `raise_cast_error` raise `CastError` instead of converting to `:not_found` (defaults to `false`)

## Ecto options
Expand Down Expand Up @@ -149,12 +168,18 @@ defmodule BitcrowdEcto.Repo do
@doc since: "0.1.0"
@callback advisory_xact_lock(atom | binary) :: :ok

defmacro __using__(_) do
defmacro __using__(opts) do
tagged_not_found_errors = Keyword.get(opts, :tagged_not_found_errors, true)

quote do
alias BitcrowdEcto.Repo, as: BER

@behaviour BER

@doc false
@spec __tagged_not_found_errors__() :: boolean
def __tagged_not_found_errors__, do: unquote(tagged_not_found_errors)

@impl BER
def fetch(module, id, opts \\ []) when is_atom(module) do
BER.fetch(__MODULE__, module, id, opts)
Expand Down Expand Up @@ -206,7 +231,7 @@ defmodule BitcrowdEcto.Repo do
end)

case result do
nil -> {:error, {:not_found, Keyword.get(opts, :error_tag, queryable)}}
nil -> handle_not_found_error(repo, queryable, opts)
value -> {:ok, value}
end
end
Expand Down Expand Up @@ -247,6 +272,16 @@ defmodule BitcrowdEcto.Repo do
end
end

defp handle_not_found_error(repo, queryable, opts) do
tag = Keyword.get(opts, :error_tag, queryable)

if repo.__tagged_not_found_errors__() == false or tag == false do
{:error, :not_found}
else
{:error, {:not_found, tag}}
end
end

@doc false
@spec count(module, Ecto.Queryable.t(), keyword) :: non_neg_integer
def count(repo, queryable, opts) do
Expand Down
15 changes: 15 additions & 0 deletions test/bitcrowd_ecto/repo_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
defmodule BitcrowdEcto.RepoTest do
use BitcrowdEcto.TestCase, async: true
require Ecto.Query
alias BitcrowdEcto.TestRepoWithUntaggedNotFoundErrors

defp insert_test_schema(_) do
%{resource: insert(:test_schema)}
Expand Down Expand Up @@ -161,4 +162,18 @@ defmodule BitcrowdEcto.RepoTest do
assert TestRepo.fetch_by(TestSchema, [id: resource.id], prefix: prefix) == {:ok, resource}
end
end

describe "error tagging can be disabled" do
test "error tagging can be disabled on fetch/2, fetch/3, fetch_by/3 calls" do
assert TestRepo.fetch(TestSchema, Ecto.UUID.generate(), error_tag: false) ==
{:error, :not_found}
end

test "error tagging can be disabled globally" do
start_supervised!(TestRepoWithUntaggedNotFoundErrors)

assert TestRepoWithUntaggedNotFoundErrors.fetch(TestSchema, Ecto.UUID.generate()) ==
{:error, :not_found}
end
end
end
11 changes: 11 additions & 0 deletions test/support/test_repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ defmodule BitcrowdEcto.TestRepo do

use BitcrowdEcto.Repo
end

defmodule BitcrowdEcto.TestRepoWithUntaggedNotFoundErrors do
@moduledoc false

use Ecto.Repo,
otp_app: :bitcrowd_ecto,
adapter: Ecto.Adapters.Postgres,
priv: "test/support/test_repo"

use BitcrowdEcto.Repo, tagged_not_found_errors: false
end
Loading