Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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