Skip to content

Commit

Permalink
Add validate_unique_tentatively/3 (#2162)
Browse files Browse the repository at this point in the history
  • Loading branch information
nathanl authored and josevalim committed Aug 17, 2017
1 parent 9580f53 commit 696686a
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 6 deletions.
31 changes: 31 additions & 0 deletions integration_test/cases/repo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 550,37 @@ defmodule Ecto.Integration.RepoTest do
assert %Ecto.Changeset{} = changeset.changes.item
end

test "validate_unique_tentatively/3" do
{:ok, inserted_post} = TestRepo.insert(%Post{title: "Greetings", text: "hi"})

new_post_changeset =
%Post{}
|> Post.changeset(%{title: "Greetings", text: "ho"})

assert Ecto.Changeset.validate_unique_tentatively(
new_post_changeset,
[:title],
TestRepo
).errors[:title] ==
{"has already been taken", [validation: [:validate_unique_tentatively, [:title]]]}

assert Ecto.Changeset.validate_unique_tentatively(
new_post_changeset,
[:title, :text],
TestRepo
).errors[:title] == nil

update_changeset =
inserted_post
|> Post.changeset(%{text: "ho"})

assert Ecto.Changeset.validate_unique_tentatively(
update_changeset,
[:title],
TestRepo
).errors[:title] == nil # cannot conflict with itself
end

test "get(!)" do
post1 = TestRepo.insert!(%Post{title: "1", text: "hai"})
post2 = TestRepo.insert!(%Post{title: "2", text: "hai"})
Expand Down
75 changes: 71 additions & 4 deletions lib/ecto/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 31,10 @@ defmodule Ecto.Changeset do
Ecto changesets provide both validations and constraints which
are ultimately turned into errors in case something goes wrong.
The difference between them is that validations can be executed
without a need to interact with the database and, therefore, are
always executed before attempting to insert or update the entry
in the database.
The difference between them is that validations (with the exception of
`validate_unique_tentatively/3`) can be executed without a need to interact
with the database and, therefore, are always executed before attempting to
insert or update the entry in the database.
However, constraints can only be checked in a safe way when
performing the operation in the database. As a consequence,
Expand Down Expand Up @@ -219,6 219,7 @@ defmodule Ecto.Changeset do
"""

require Ecto.Query
alias __MODULE__
alias Ecto.Changeset.Relation

Expand Down Expand Up @@ -1392,6 1393,72 @@ defmodule Ecto.Changeset do
end
end

@doc """
Validates (tentatively) that no existing record with a different primary key
has the same values for these fields.
This is tentative because a race condition may occur. A unique constraint
should also be used to ensure uniqueness.
However, in most cases where conflicting data exists, it will have been
inserted prior to the current validation phase. Noticing those conflicts
during validation gives the user a chance to correct them at the same time
as other validation errors.
## Examples
validate_unique_tentatively(changeset, [:email], repo)
validate_unique_tentatively(changeset, [:city_name, :state_name], repo)
validate_unique_tentatively(changeset, [:city_name, :state_name], repo, "city must be unique within state")
"""
def validate_unique_tentatively(changeset, field_names, repo, error_message \\ "has already been taken") do
field_names = List.wrap(field_names)
where_clause = Enum.map(field_names, fn (field_name) ->
{field_name, get_field(changeset, field_name)}
end)

# If we don't have values for all fields, we can't query for uniqueness
if Enum.any?(where_clause, fn (tuple) -> is_nil(elem(tuple, 1)) end) do
changeset
else
dups_query = Ecto.Query.from q in changeset.data.__struct__, where: ^where_clause

# For updates, don't flag a record as a dup of itself
pk_fields_and_vals = pk_fields_and_vals(changeset)
incomplete_pk? = Enum.any?(
pk_fields_and_vals,
fn {_field, val} -> is_nil(val) end
)
dups_query = if incomplete_pk? do
dups_query
else
Enum.reduce(
pk_fields_and_vals,
dups_query, fn ({field_name, val}, query) ->
Ecto.Query.from q in query, where: field(q, ^field_name) != ^val
end)
end

dups_exist_query = Ecto.Query.from q in dups_query, select: true, limit: 1
case repo.one(dups_exist_query) do
true -> add_error(
changeset,
hd(field_names),
error_message,
[validation: [:validate_unique_tentatively, field_names]]
)
nil -> changeset
end
end
end

defp pk_fields_and_vals(%Changeset{} = changeset) do
primary_key_field_names = changeset.data.__struct__.__schema__(:primary_key)
Enum.map(primary_key_field_names, fn(field_name) ->
{field_name, get_field(changeset, field_name)}
end)
end

defp ensure_field_exists!(%Changeset{types: types, data: data}, field) do
unless Map.has_key?(types, field) do
raise ArgumentError, "unknown field #{inspect field} for changeset on #{inspect data}"
Expand Down
58 changes: 57 additions & 1 deletion test/ecto/changeset_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 37,7 @@ defmodule Ecto.ChangesetTest do
use Ecto.Schema

schema "posts" do
field :id_part_2, :integer, primary_key: true
field :title, :string, default: ""
field :body
field :uuid, :binary_id
Expand All @@ -54,7 55,7 @@ defmodule Ecto.ChangesetTest do
end

defp changeset(schema \\ %Post{}, params) do
cast(schema, params, ~w(title body upvotes decimal topics virtual))
cast(schema, params, ~w(id id_part_2 title body upvotes decimal topics virtual))
end

## cast/4
Expand Down Expand Up @@ -1044,6 1045,61 @@ defmodule Ecto.ChangesetTest do
assert changeset.errors == [terms_of_service: {"must be abided", [validation: :acceptance]}]
end

alias Ecto.TestRepo

test "validate_unique_tentatively/3" do
dup_result = {1, [true]}
no_dup_result = {0, []}
base_changeset = changeset(%Post{}, %{"title" => "Hello World", "body" => "hi"})

Process.put(:test_repo_all_results, dup_result)
# validate uniqueness of one field
changeset = validate_unique_tentatively(base_changeset, :title, TestRepo)
assert changeset.errors == [
title: {
"has already been taken",
[validation: [:validate_unique_tentatively, [:title]]],
}
]
Process.put(:test_repo_all_results, no_dup_result)
changeset = validate_unique_tentatively(base_changeset, :title, TestRepo)
assert changeset.valid?

Process.put(:test_repo_all_results, dup_result)
# validate uniqueness of multiple fields
changeset = validate_unique_tentatively(
base_changeset, [:title, :body], TestRepo
)
assert changeset.errors == [
title: {
"has already been taken",
[validation: [:validate_unique_tentatively, [:title, :body]]],
}
]
Process.put(:test_repo_all_results, no_dup_result)
changeset = validate_unique_tentatively(
base_changeset, [:title, :body], TestRepo
)
assert changeset.valid?

Process.put(:test_repo_all_results, dup_result)
# custom error message
changeset = validate_unique_tentatively(
base_changeset, [:title], TestRepo, "is taken"
)
assert changeset.errors == [
title: {
"is taken",
[validation: [:validate_unique_tentatively, [:title]]],
}
]
Process.put(:test_repo_all_results, no_dup_result)
changeset = validate_unique_tentatively(
base_changeset, [:title], TestRepo, "is taken"
)
assert changeset.valid?
end

## Locks

test "optimistic_lock/3 with changeset" do
Expand Down
2 changes: 1 addition & 1 deletion test/support/test_repo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 41,7 @@ defmodule Ecto.TestAdapter do
end

def execute(_repo, _, {:nocache, {:all, _}}, _, _, _) do
{1, [[1]]}
Process.get(:test_repo_all_results) || {1, [[1]]}
end

def execute(_repo, _meta, {:nocache, {:delete_all, %{from: {_, SchemaMigration}}}}, [version], _, _) do
Expand Down

0 comments on commit 696686a

Please sign in to comment.