Skip to content

Commit 4b41c2e

Browse files
committed
swap to mfargs for :constraint_handler option
- adds initial docs - updates behaviours and built-in connections
1 parent d32d15e commit 4b41c2e

File tree

9 files changed

+131
-38
lines changed

9 files changed

+131
-38
lines changed

integration_test/myxql/constraints_test.exs

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ defmodule Ecto.Integration.ConstraintsTest do
55
alias Ecto.Integration.PoolRepo
66

77
defmodule CustomConstraintHandler do
8+
@behaviour Ecto.Adapters.SQL.Constraint
9+
810
@quotes ~w(" ' `)
911

12+
@impl Ecto.Adapters.SQL.Constraint
1013
# An example of a custom handler a user might write
1114
def to_constraints(%MyXQL.Error{mysql: %{name: :ER_SIGNAL_EXCEPTION}, message: message}, opts) do
1215
# Assumes this is the only use-case of `ER_SIGNAL_EXCEPTION` the user has implemented custom errors for
@@ -41,7 +44,7 @@ defmodule Ecto.Integration.ConstraintsTest do
4144
end
4245
end
4346

44-
defmodule ConstraintMigration do
47+
defmodule CheckConstraintMigration do
4548
use Ecto.Migration
4649

4750
@table table(:constraints_test)
@@ -52,7 +55,7 @@ defmodule Ecto.Integration.ConstraintsTest do
5255
end
5356
end
5457

55-
defmodule ProcedureEmulatingConstraintMigration do
58+
defmodule TriggerEmulatingConstraintMigration do
5659
use Ecto.Migration
5760

5861
@table_name :constraints_test
@@ -70,11 +73,14 @@ defmodule Ecto.Integration.ConstraintsTest do
7073
drop_triggers(@table_name)
7174
end
7275

76+
# FOR EACH ROW, not a great example performance-wise,
77+
# but demonstrates the feature
7378
defp trigger_sql(table_name, before_type) do
7479
~s"""
7580
CREATE TRIGGER #{table_name}_#{String.downcase(before_type)}_overlap
7681
BEFORE #{String.upcase(before_type)}
77-
ON #{table_name} FOR EACH ROW
82+
ON #{table_name}
83+
FOR EACH ROW
7884
BEGIN
7985
DECLARE v_rowcount INT;
8086
DECLARE v_msg VARCHAR(200);
@@ -112,7 +118,6 @@ defmodule Ecto.Integration.ConstraintsTest do
112118
ExUnit.CaptureLog.capture_log(fn ->
113119
num = @base_migration + System.unique_integer([:positive])
114120
up(PoolRepo, num, ConstraintTableMigration, log: false)
115-
up(PoolRepo, num + 1, ProcedureEmulatingConstraintMigration, log: false)
116121
end)
117122

118123
:ok
@@ -121,8 +126,9 @@ defmodule Ecto.Integration.ConstraintsTest do
121126
@tag :create_constraint
122127
test "check constraint" do
123128
num = @base_migration + System.unique_integer([:positive])
129+
124130
ExUnit.CaptureLog.capture_log(fn ->
125-
:ok = up(PoolRepo, num, ConstraintMigration, log: false)
131+
:ok = up(PoolRepo, num, CheckConstraintMigration, log: false)
126132
end)
127133

128134
# When the changeset doesn't expect the db error
@@ -131,9 +137,7 @@ defmodule Ecto.Integration.ConstraintsTest do
131137
exception =
132138
assert_raise Ecto.ConstraintError,
133139
~r/constraint error when attempting to insert struct/,
134-
fn ->
135-
PoolRepo.insert(changeset)
136-
end
140+
fn -> PoolRepo.insert(changeset) end
137141

138142
assert exception.message =~ "\"positive_price\" (check_constraint)"
139143
assert exception.message =~ "The changeset has not defined any constraint."
@@ -184,7 +188,14 @@ defmodule Ecto.Integration.ConstraintsTest do
184188
assert is_integer(result.id)
185189
end
186190

191+
@tag :constraint_handler
187192
test "custom handled constraint" do
193+
num = @base_migration + System.unique_integer([:positive])
194+
195+
ExUnit.CaptureLog.capture_log(fn ->
196+
:ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false)
197+
end)
198+
188199
changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10)
189200
{:ok, item} = PoolRepo.insert(changeset)
190201

@@ -211,42 +222,67 @@ defmodule Ecto.Integration.ConstraintsTest do
211222
|> Ecto.Changeset.exclusion_constraint(:from)
212223
|> PoolRepo.insert()
213224
end
225+
214226
assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)"
215227

216228
# When the changeset does expect the db error, but doesn't give a custom message
217229
{:error, changeset} =
218230
overlapping_changeset
219231
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
220232
|> PoolRepo.insert()
221-
assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
233+
234+
assert changeset.errors == [
235+
from:
236+
{"violates an exclusion constraint",
237+
[constraint: :exclusion, constraint_name: "cannot_overlap"]}
238+
]
239+
222240
assert changeset.data.__meta__.state == :built
223241

224242
# When the changeset does expect the db error and gives a custom message
225243
{:error, changeset} =
226244
overlapping_changeset
227-
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap")
245+
|> Ecto.Changeset.exclusion_constraint(:from,
246+
name: :cannot_overlap,
247+
message: "must not overlap"
248+
)
228249
|> PoolRepo.insert()
229-
assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
230-
assert changeset.data.__meta__.state == :built
231250

251+
assert changeset.errors == [
252+
from:
253+
{"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}
254+
]
255+
256+
assert changeset.data.__meta__.state == :built
232257

233258
# When the changeset does expect the db error, but a different handler is used
234259
exception =
235260
assert_raise MyXQL.Error, fn ->
236261
overlapping_changeset
237262
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
238-
|> PoolRepo.insert(constraint_handler: Ecto.Adapters.MyXQL.Connection)
263+
|> PoolRepo.insert(
264+
constraint_handler: {Ecto.Adapters.MyXQL.Connection, :to_constraints, []}
265+
)
239266
end
267+
240268
assert exception.message =~ "Overlapping values for key 'constraints_test.cannot_overlap'"
241269

242270
# When custom error is coming from an UPDATE
243271
overlapping_update_changeset = Ecto.Changeset.change(item, from: 0, to: 9)
244272

245273
{:error, changeset} =
246274
overlapping_update_changeset
247-
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap")
275+
|> Ecto.Changeset.exclusion_constraint(:from,
276+
name: :cannot_overlap,
277+
message: "must not overlap"
278+
)
248279
|> PoolRepo.insert()
249-
assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
280+
281+
assert changeset.errors == [
282+
from:
283+
{"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}
284+
]
285+
250286
assert changeset.data.__meta__.state == :loaded
251287
end
252288
end

integration_test/myxql/test_helper.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Application.put_env(:ecto_sql, PoolRepo,
5959
pool_count: String.to_integer(System.get_env("POOL_COUNT", "1")),
6060
show_sensitive_data_on_connection_error: true,
6161
# Passes through into adapter_meta
62-
constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler
62+
constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}
6363
)
6464

6565
defmodule Ecto.Integration.PoolRepo do
@@ -88,7 +88,7 @@ _ = Ecto.Adapters.MyXQL.storage_down(TestRepo.config())
8888
{:ok, _pid} = TestRepo.start_link()
8989

9090
# Passes through into adapter_meta, overrides Application config
91-
# {:ok, _pid} = PoolRepo.start_link([constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler])
91+
# {:ok, _pid} = PoolRepo.start_link([constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}])
9292
{:ok, _pid} = PoolRepo.start_link()
9393

9494
%{rows: [[version]]} = TestRepo.query!("SELECT @@version", [])

lib/ecto/adapters/myxql.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ defmodule Ecto.Adapters.MyXQL do
341341
{:ok, last_insert_id(key, last_insert_id)}
342342

343343
{:error, err} ->
344-
case Ecto.Adapters.SQL.to_constraints(adapter_meta, opts, err, source: source) do
344+
case Ecto.Adapters.SQL.to_constraints(adapter_meta, err, opts, source: source) do
345345
[] -> raise err
346346
constraints -> {:invalid, constraints}
347347
end

lib/ecto/adapters/myxql/connection.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ if Code.ensure_loaded?(MyXQL) do
44
alias Ecto.Adapters.SQL
55

66
@behaviour Ecto.Adapters.SQL.Connection
7+
@behaviour Ecto.Adapters.SQL.Constraint
78

89
## Connection
910

lib/ecto/adapters/postgres/connection.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ if Code.ensure_loaded?(Postgrex) do
44

55
@default_port 5432
66
@behaviour Ecto.Adapters.SQL.Connection
7+
@behaviour Ecto.Adapters.SQL.Constraint
78
@explain_prepared_statement_name "ecto_explain_statement"
89

910
## Module and Options

lib/ecto/adapters/sql.ex

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ defmodule Ecto.Adapters.SQL do
3737
* `to_sql(type, query)` -
3838
shortcut for `Ecto.Adapters.SQL.to_sql/3`
3939
40+
* `to_constraints(exception, opts, error_opts)` -
41+
shortcut for `Ecto.Adapters.SQL.to_constraints/4`
42+
4043
Generally speaking, you must invoke those functions directly from
4144
your repository, for example: `MyApp.Repo.query("SELECT true")`.
4245
You can also invoke them directly from `Ecto.Adapters.SQL`, but
@@ -393,6 +396,45 @@ defmodule Ecto.Adapters.SQL do
393396
{"SELECT p.id, p.title, p.inserted_at, p.created_at FROM posts as p", []}
394397
"""
395398

399+
# TODO - add docs here and/or somewhere for how to pass `constraint_handler` per call
400+
@to_constraints_doc """
401+
Handles adapter-specific exceptions, converting them to
402+
the corresponding contraint errors.
403+
404+
The constraints are in the keyword list and must return the
405+
constraint type, like `:unique`, and the constraint name as
406+
a string, for example:
407+
408+
[unique: "posts_title_index"]
409+
410+
Returning an empty list signifies the error does not come
411+
from any constraint, and should continue with the default
412+
exception handling path (i.e. raise or further handling).
413+
414+
## Options
415+
* `:constraint_handler` - A module, function, and list of arguments (`mfargs`)
416+
417+
The `:constraint_handler` option defaults to the adapter's connection module.
418+
For the built-in adapters this would be:
419+
420+
* `Ecto.Adapters.Postgres.Connection.to_constraints/2`
421+
* `Ecto.Adapters.MyXQL.Connection.to_constraints/2`
422+
* `Ecto.Adapters.Tds.Connection.to_constraints/2`
423+
424+
See `Ecto.Adapters.SQL.Constraint` if you need to fully
425+
customize the handling of constraints for all operations.
426+
427+
## Examples
428+
429+
# Postgres
430+
iex> MyRepo.to_constraints(%Postgrex.Error{code: :unique, constraint: "posts_title_index"}, [])
431+
[unique: "posts_title_index"]
432+
433+
# MySQL
434+
iex> MyRepo.to_constraints(%MyXQL.Error{mysql: %{name: :ER_CHECK_CONSTRAINT_VIOLATED}, message: "Check constraint 'positive_price' is violated."}, [])
435+
[check: "positive_price"]
436+
"""
437+
396438
@explain_doc """
397439
Executes an EXPLAIN statement or similar for the given query according to its kind and the
398440
adapter in the given repository.
@@ -668,11 +710,22 @@ defmodule Ecto.Adapters.SQL do
668710
sql_call(adapter_meta, :query_many, [sql], params, opts)
669711
end
670712

671-
@doc false
672-
def to_constraints(adapter_meta, opts, err, err_opts) do
713+
@doc @to_constraints_doc
714+
@spec to_constraints(
715+
pid() | Ecto.Repo.t() | Ecto.Adapter.adapter_meta(),
716+
exception :: Exception.t(),
717+
options :: Keyword.t(),
718+
error_options :: Keyword.t()
719+
) :: Keyword.t()
720+
def to_constraints(repo, err, opts, err_opts) when is_atom(repo) or is_pid(repo) do
721+
to_constraints(Ecto.Adapter.lookup_meta(repo), err, opts, err_opts)
722+
end
723+
724+
def to_constraints(adapter_meta, err, opts, err_opts) do
673725
%{constraint_handler: constraint_handler} = adapter_meta
674-
constraint_handler = Keyword.get(opts, :constraint_handler) || constraint_handler
675-
constraint_handler.to_constraints(err, err_opts)
726+
{constraint_mod, fun, args} = Keyword.get(opts, :constraint_handler) || constraint_handler
727+
args = [err, err_opts | args]
728+
apply(constraint_mod, fun, args)
676729
end
677730

678731
defp sql_call(adapter_meta, callback, args, params, opts) do
@@ -794,6 +847,7 @@ defmodule Ecto.Adapters.SQL do
794847
query_many_doc = @query_many_doc
795848
query_many_bang_doc = @query_many_bang_doc
796849
to_sql_doc = @to_sql_doc
850+
to_constraints_doc = @to_constraints_doc
797851
explain_doc = @explain_doc
798852
disconnect_all_doc = @disconnect_all_doc
799853

@@ -833,6 +887,16 @@ defmodule Ecto.Adapters.SQL do
833887
Ecto.Adapters.SQL.to_sql(operation, get_dynamic_repo(), queryable)
834888
end
835889

890+
@doc unquote(to_constraints_doc)
891+
@spec to_constraints(
892+
exception :: Exception.t(),
893+
options :: Keyword.t(),
894+
error_options :: Keyword.t()
895+
) :: Keyword.t()
896+
def to_constraints(err, opts, err_opts) do
897+
Ecto.Adapters.SQL.to_constraints(get_dynamic_repo(), err, opts, err_opts)
898+
end
899+
836900
@doc unquote(explain_doc)
837901
@spec explain(:all | :update_all | :delete_all, Ecto.Queryable.t(), opts :: Keyword.t()) ::
838902
String.t() | Exception.t() | list(map)
@@ -890,7 +954,9 @@ defmodule Ecto.Adapters.SQL do
890954
"""
891955
end
892956

893-
constraint_handler = Keyword.get(config, :constraint_handler, connection)
957+
constraint_handler =
958+
Keyword.get(config, :constraint_handler, {connection, :to_constraints, []})
959+
894960
stacktrace = Keyword.get(config, :stacktrace)
895961
telemetry_prefix = Keyword.fetch!(config, :telemetry_prefix)
896962
telemetry = {config[:repo], log, telemetry_prefix ++ [:query]}
@@ -1200,7 +1266,7 @@ defmodule Ecto.Adapters.SQL do
12001266
operation: operation
12011267

12021268
{:error, err} ->
1203-
case to_constraints(adapter_meta, opts, err, source: source) do
1269+
case to_constraints(adapter_meta, err, opts, source: source) do
12041270
[] -> raise_sql_call_error(err)
12051271
constraints -> {:invalid, constraints}
12061272
end

lib/ecto/adapters/sql/connection.ex

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,6 @@ defmodule Ecto.Adapters.SQL.Connection do
5252
@callback stream(connection, statement, params, options :: Keyword.t()) ::
5353
Enum.t()
5454

55-
@doc """
56-
Receives the exception returned by `c:query/4`.
57-
58-
The constraints are in the keyword list and must return the
59-
constraint type, like `:unique`, and the constraint name as
60-
a string, for example:
61-
62-
[unique: "posts_title_index"]
63-
64-
Must return an empty list if the error does not come
65-
from any constraint.
66-
"""
67-
@callback to_constraints(exception :: Exception.t(), options :: Keyword.t()) :: Keyword.t()
68-
6955
## Queries
7056

7157
@doc """

lib/ecto/adapters/sql/constraint.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defmodule Ecto.Adapters.SQL.Constraint do
2+
# TODO - add more docs around setting `:constraint_handler` globally
3+
24
@moduledoc """
35
Specifies the constraint handling API
46
"""

lib/ecto/adapters/tds/connection.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ if Code.ensure_loaded?(Tds) do
88
require Ecto.Schema
99

1010
@behaviour Ecto.Adapters.SQL.Connection
11+
@behaviour Ecto.Adapters.SQL.Constraint
1112

1213
@impl true
1314
def child_spec(opts) do

0 commit comments

Comments
 (0)