使用 Ecto 正确设置检查约束

Properly setting up a check constraint with Ecto

我的模型中有这个 check_constraint。

  def changeset(struct, params \ %{}) do
    struct
    |> cast(params, @all_fields)
    |> validate_required(@required_fields)
    |> check_constraint(:stars, name: :stars_range, message: "stars must be between 1 and 5")
  end

创建约束已成功迁移。

create constraint("reviews", "stars_range", check: "stars>=1 and stars<=5")

但是当我运行这个测试时变更集有效吗?我希望它无效,因为我将整数 7 传递给 stars 列。它具有 1 through 5 的约束。有人知道这里出了什么问题吗?

test "requires stars to be within range of 1-5" do
    user = insert(:user)
    project = insert(:project, owner: user)
    user_project_map = %{project_id: project.id, user_id: user.id}
    review_map = Map.merge(@valid_attrs, user_project_map)

    attrs = %{review_map | stars: 7}
    changeset = Review.changeset(%Review{}, attrs)
    refute changeset.valid?
  end

引自docs

(...) Now, when invoking Repo.insert/2 or Repo.update/2, if the price is not positive, it will be converted into an error and {:error, changeset} returned by the repository. Note that the error will occur only after hitting the database so it will not be visible until all other validations pass.

这意味着 check_constraint 只有在查询命中数据库时才会发生。因此,当您在实际调用数据库之前检查验证时,您的 changeset.valid? 返回 true。您创建的约束是在数据库内部创建的,因此 Ecto 在调用它之前实际上无法知道该约束实际检查的内容。通常此类约束用于更复杂的检查,或者如果您在数据库中已经定义了约束(可能是因为您从另一个系统迁移了数据库?)。如果你想看到你的约束在行动,你应该在你的测试中写:

attrs = %{review_map | stars: 7}
changeset = Review.changeset(attrs)
{:error, changeset} = Repo.insert(changeset)
refute changeset.valid?

如果您需要 Changeset 在调用数据库之前检查一些条件,那么您应该使用像 validate_inclusion/4validate_subset/4 这样的函数。您甚至可以使用 validate_change/4 编写自己的检查器(如果您需要更多说明如何操作,请告诉我)。如果您使用这些验证器,那么您的变更集将在调用数据库之前工作。

my answer to your previous question, 中,如果我在为插入创建变更集时添加一些输出:

defmodule Foo do
  alias Foo.Review
  require Logger

  @repo Foo.Repo

  def list_reviews do
    @repo.all(Review)
  end

  def insert_review(attrs) do
    changeset = Review.changeset(%Review{}, attrs)

    ##   HERE ###
    Logger.debug("changeset.valid? => #{changeset.valid?}")

    @repo.insert(changeset)
  end

  def delete_book(%Book{}=book) do
    @repo.delete(book)
  end

end

这是 iex 中的输出:

ex(3)> reviews = Foo.list_reviews                         
[debug] QUERY OK source="reviews" db=3.4ms
SELECT r0."id", r0."title", r0."contents", r0."stars", r0."inserted_at", r0."updated_at" FROM "reviews" AS r0 []
[]


## VALID DATA ###

iex(4)> Foo.insert_review(%{title: "book", contents: "good", stars: 4})  
[debug] changeset.valid? => true
[debug] QUERY OK db=2.3ms queue=2.0ms
INSERT INTO "reviews" ("contents","stars","title","inserted_at","updated_at") VALUES (,,,,) RETURNING "id" ["good", 4, "book", ~N[2019-07-10 17:23:06], ~N[2019-07-10 17:23:06]]
{:ok,
 %Foo.Review{
   __meta__: #Ecto.Schema.Metadata<:loaded, "reviews">,
   contents: "good",
   id: 4,
   inserted_at: ~N[2019-07-10 17:23:06],
   stars: 4,
   title: "book",
   updated_at: ~N[2019-07-10 17:23:06]
 }}


## INVALID DATA ##

iex(5)> Foo.insert_review(%{title: "movie", contents: "shite", stars: 0})
[debug] changeset.valid? => true
[debug] QUERY ERROR db=6.1ms queue=1.5ms
INSERT INTO "reviews" ("contents","stars","title","inserted_at","updated_at") VALUES (,,,,) RETURNING "id" ["shite", 0, "movie", ~N[2019-07-10 17:23:16], ~N[2019-07-10 17:23:16]]
{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{contents: "shite", stars: 0, title: "movie"},
   errors: [
     stars: {"stars must be between 1 and 5 (inclusive)",
      [constraint: :check, constraint_name: "stars_range"]}
   ],
   data: #Foo.Review<>, 
   valid?: false
 >}

对于无效的数据,可以看到在调用@repo.insert(changeset)之前changeset是有效的,然后插入失败后Ecto returns一个无效的changeset。

那是因为检查约束是数据库规则——而不是验证器。 changeset() 函数应用您指定的所有验证器,从而确定变更集是否有效。如果变更集有效,那么 Ecto 实际上会尝试在数据库中插入。此时,数据库执行检查约束以确定插入是否成功。如果检查约束失败,数据库将抛出错误。 Ecto 捕获该错误然后添加您在此处指定的消息:

   |> check_constraint(
        :stars,
        name: :stars_range,
        message: "stars must be between 1 and 5 (inclusive)"
      )

针对变更集中的错误,将 changeset.valid? 设置为 false,然后 returns {:error, changeset}

验证器失败与检查约束失败时的输出不同。如果我将验证更改为:

  def changeset(%Foo.Review{}=review, attrs \ %{}) do
    review
    |> cast(attrs, [:title, :contents, :stars])
    |> validate_required(:title)  ##<==== ADDED THIS VALIDATION
    |> check_constraint(
        :stars,
        name: :stars_range,
        message: "stars must be between 1 and 5 (inclusive)"
      )
  end

然后尝试做一个没有标题的插入,这是输出:

iex(6)> Foo.insert_review(%{contents: "crowded", stars: 1})
[debug] changeset.valid? => false
{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{contents: "crowded", stars: 1},
   errors: [title: {"can't be blank", [validation: :required]}],
   data: #Foo.Review<>,
   valid?: false
 >}

比较:

## INVALID DATA ##

iex(5)> Foo.insert_review(%{title: "movie", contents: "shite", stars: 0})
[debug] changeset.valid? => true
[debug] QUERY ERROR db=6.1ms queue=1.5ms
INSERT INTO "reviews" ("contents","stars","title","inserted_at","updated_at") VALUES (,,,,) RETURNING "id" ["shite", 0, "movie", ~N[2019-07-10 17:23:16], ~N[2019-07-10 17:23:16]]
{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{contents: "shite", stars: 0, title: "movie"},
   errors: [
     stars: {"stars must be between 1 and 5 (inclusive)",
      [constraint: :check, constraint_name: "stars_range"]}
   ],
   data: #Foo.Review<>, 
   valid?: false
 >}

在后面的输出中,注意:

 [debug] QUERY ERROR db=6.1ms queue=1.5ms

输出的差异表明,只有在所有验证都通过后,Ecto 才会尝试执行插入。当真正执行插入时,数据库会应用检查约束,这会导致插入失败,并且 ecto 会记录一个 QUERY ERROR.

底线是:变更集有效并不意味着插入会成功。如果 changeset() 函数将 constraints 添加到 db,那么在调用 @repo.insert(changeset) 实际执行插入之前,您无法知道插入变更集是否成功。