Skip to content

Commit 1893765

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

9 files changed

Lines changed: 131 additions & 38 deletions

File tree

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
@@ -399,7 +399,7 @@ defmodule Ecto.Adapters.MyXQL do
399399
{:ok, last_insert_id(key, last_insert_id)}
400400

401401
{:error, err} ->
402-
case Ecto.Adapters.SQL.to_constraints(adapter_meta, opts, err, source: source) do
402+
case Ecto.Adapters.SQL.to_constraints(adapter_meta, err, opts, source: source) do
403403
[] -> raise err
404404
constraints -> {:invalid, constraints}
405405
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
@@ -398,6 +401,45 @@ defmodule Ecto.Adapters.SQL do
398401
{"SELECT p.id, p.title, p.inserted_at, p.created_at FROM posts as p", []}
399402
"""
400403

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

676-
@doc false
677-
def to_constraints(adapter_meta, opts, err, err_opts) do
718+
@doc @to_constraints_doc
719+
@spec to_constraints(
720+
pid() | Ecto.Repo.t() | Ecto.Adapter.adapter_meta(),
721+
exception :: Exception.t(),
722+
options :: Keyword.t(),
723+
error_options :: Keyword.t()
724+
) :: Keyword.t()
725+
def to_constraints(repo, err, opts, err_opts) when is_atom(repo) or is_pid(repo) do
726+
to_constraints(Ecto.Adapter.lookup_meta(repo), err, opts, err_opts)
727+
end
728+
729+
def to_constraints(adapter_meta, err, opts, err_opts) do
678730
%{constraint_handler: constraint_handler} = adapter_meta
679-
constraint_handler = Keyword.get(opts, :constraint_handler) || constraint_handler
680-
constraint_handler.to_constraints(err, err_opts)
731+
{constraint_mod, fun, args} = Keyword.get(opts, :constraint_handler) || constraint_handler
732+
args = [err, err_opts | args]
733+
apply(constraint_mod, fun, args)
681734
end
682735

683736
defp sql_call(adapter_meta, callback, args, params, opts) do
@@ -799,6 +852,7 @@ defmodule Ecto.Adapters.SQL do
799852
query_many_doc = @query_many_doc
800853
query_many_bang_doc = @query_many_bang_doc
801854
to_sql_doc = @to_sql_doc
855+
to_constraints_doc = @to_constraints_doc
802856
explain_doc = @explain_doc
803857
disconnect_all_doc = @disconnect_all_doc
804858

@@ -838,6 +892,16 @@ defmodule Ecto.Adapters.SQL do
838892
Ecto.Adapters.SQL.to_sql(operation, get_dynamic_repo(), queryable)
839893
end
840894

895+
@doc unquote(to_constraints_doc)
896+
@spec to_constraints(
897+
exception :: Exception.t(),
898+
options :: Keyword.t(),
899+
error_options :: Keyword.t()
900+
) :: Keyword.t()
901+
def to_constraints(err, opts, err_opts) do
902+
Ecto.Adapters.SQL.to_constraints(get_dynamic_repo(), err, opts, err_opts)
903+
end
904+
841905
@doc unquote(explain_doc)
842906
@spec explain(:all | :update_all | :delete_all, Ecto.Queryable.t(), opts :: Keyword.t()) ::
843907
String.t() | Exception.t() | list(map)
@@ -895,7 +959,9 @@ defmodule Ecto.Adapters.SQL do
895959
"""
896960
end
897961

898-
constraint_handler = Keyword.get(config, :constraint_handler, connection)
962+
constraint_handler =
963+
Keyword.get(config, :constraint_handler, {connection, :to_constraints, []})
964+
899965
stacktrace = Keyword.get(config, :stacktrace)
900966
telemetry_prefix = Keyword.fetch!(config, :telemetry_prefix)
901967
telemetry = {config[:repo], log, telemetry_prefix ++ [:query]}
@@ -1205,7 +1271,7 @@ defmodule Ecto.Adapters.SQL do
12051271
operation: operation
12061272

12071273
{:error, err} ->
1208-
case to_constraints(adapter_meta, opts, err, source: source) do
1274+
case to_constraints(adapter_meta, err, opts, source: source) do
12091275
[] -> raise_sql_call_error(err)
12101276
constraints -> {:invalid, constraints}
12111277
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
@@ -6,6 +6,7 @@ if Code.ensure_loaded?(Tds) do
66
alias Ecto.Adapters.SQL
77

88
@behaviour Ecto.Adapters.SQL.Connection
9+
@behaviour Ecto.Adapters.SQL.Constraint
910

1011
@impl true
1112
def child_spec(opts) do

0 commit comments

Comments
 (0)