查看源代码 Ecto

要求:本指南假设您已阅读过入门指南并已成功运行 Phoenix 应用程序启动并运行.

如今,大多数 Web 应用程序都需要某种形式的数据验证和持久化。在 Elixir 生态系统中,我们有Ecto来实现这一点。在我们开始构建数据库支持的 Web 功能之前,我们将重点关注 Ecto 的更细致的细节,为构建我们的 Web 功能奠定坚实的基础。让我们开始吧!

Phoenix 使用 Ecto 为以下数据库提供内置支持

新生成的 Phoenix 项目默认包含 Ecto 和 PostgreSQL 适配器。您可以传递 --database 选项进行更改,或传递 --no-ecto 标志将其排除。

Ecto 还支持其他数据库,并且有许多学习资源可用。有关一般信息,请查看 Ecto 的自述文件

本指南假设我们已经使用 Ecto 集成生成了新的应用程序,并且将使用 PostgreSQL。入门指南介绍了如何启动第一个应用程序并运行它。有关使用其他数据库的信息,请参阅使用其他数据库部分。

使用 Schema 和迁移生成器

安装和配置好 Ecto 和 PostgreSQL 后,使用 Ecto 最简单的方法是通过 phx.gen.schema 任务生成 Ecto schema。Ecto schema 是一种方法,用于指定 Elixir 数据类型如何映射到外部源(例如数据库表)以及从外部源映射回 Elixir 数据类型。让我们生成一个包含 nameemailbionumber_of_pets 字段的 User schema。

$ mix phx.gen.schema User users name:string email:string \
bio:string number_of_pets:integer

* creating ./lib/hello/user.ex
* creating priv/repo/migrations/20170523151118_create_users.exs

Remember to update your repository by running migrations:

   $ mix ecto.migrate

此任务生成了一些文件。首先,我们有一个 user.ex 文件,其中包含我们的 Ecto schema,以及我们传递给任务的字段的 schema 定义。其次,在 priv/repo/migrations/ 中生成了一个迁移文件,该文件将创建我们的 schema 映射到的数据库表。

有了这些文件,让我们按照说明运行迁移

$ mix ecto.migrate
Compiling 1 file (.ex)
Generated hello app

[info] == Running Hello.Repo.Migrations.CreateUsers.change/0 forward

[info] create table users

[info] == Migrated in 0.0s

Mix 假设我们处于开发环境,除非我们使用 MIX_ENV=prod mix ecto.migrate 明确指定。

如果我们登录数据库服务器并连接到我们的 hello_dev 数据库,我们应该看到我们的 users 表。Ecto 假设我们希望有一个名为 id 的整数列作为我们的主键,因此我们也应该看到为此生成的序列。

$ psql -U postgres

Type "help" for help.

postgres=# \connect hello_dev
You are now connected to database "hello_dev" as user "postgres".
hello_dev=# \d
                List of relations
 Schema |       Name        |   Type   |  Owner
--------+-------------------+----------+----------
 public | schema_migrations | table    | postgres
 public | users             | table    | postgres
 public | users_id_seq      | sequence | postgres
(3 rows)
hello_dev=# \q

如果我们查看 phx.gen.schemapriv/repo/migrations/ 中生成的迁移,我们会看到它将添加我们指定的列。它还将添加 inserted_atupdated_at 的时间戳列,这些列来自timestamps/1 函数。

defmodule Hello.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string
      add :email, :string
      add :bio, :string
      add :number_of_pets, :integer

      timestamps()
    end
  end
end

以下是它在实际 users 表中的呈现方式。

$ psql
hello_dev=# \d users
Table "public.users"
Column         |            Type             | Modifiers
---------------+-----------------------------+----------------------------------------------------
id             | bigint                      | not null default nextval('users_id_seq'::regclass)
name           | character varying(255)      |
email          | character varying(255)      |
bio            | character varying(255)      |
number_of_pets | integer                     |
inserted_at    | timestamp without time zone | not null
updated_at     | timestamp without time zone | not null
Indexes:
"users_pkey" PRIMARY KEY, btree (id)

请注意,我们确实默认获得了 id 列作为我们的主键,即使它没有列出为我们迁移中的字段。

Repo 配置

我们的 Hello.Repo 模块是在 Phoenix 应用程序中使用数据库的基础。Phoenix 为我们生成了它,位于 lib/hello/repo.ex 中,它看起来像这样。

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

它首先定义存储库模块。然后它配置我们的 otp_app 名称和 adapter(在本例中为 Postgres)。

我们的存储库有三个主要任务:从 [Ecto.Repo] 中引入所有常见的查询函数,将 otp_app 名称设置为我们的应用程序名称,以及配置我们的数据库适配器。我们将在稍后讨论如何使用 Hello.Repo

phx.new 生成我们的应用程序时,它也包含了一些基本的存储库配置。让我们看一下 config/dev.exs

...
# Configure your database
config :hello, Hello.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "hello_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10
...

我们还在 config/test.exsconfig/runtime.exs(以前称为 config/prod.secret.exs)中拥有类似的配置,这些配置也可以更改为匹配您的实际凭据。

Schema

Ecto schema 负责将 Elixir 值映射到外部数据源,以及将外部数据映射回 Elixir 数据结构。我们还可以定义与应用程序中其他 schema 的关系。例如,我们的 User schema 可能有很多帖子,每个帖子都属于一个用户。Ecto 还通过 changeset 处理数据验证和类型转换,我们将在稍后讨论。

以下是 Phoenix 为我们生成的 User schema。

defmodule Hello.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :bio, :string
    field :email, :string
    field :name, :string
    field :number_of_pets, :integer

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :email, :bio, :number_of_pets])
    |> validate_required([:name, :email, :bio, :number_of_pets])
  end
end

Ecto schema 的核心仅仅是 Elixir 结构体。我们的 schema 块告诉 Ecto 如何将我们的 %User{} 结构体字段转换为外部 users 表以及从外部表转换回结构体字段。通常,仅将数据转换到数据库或从数据库转换回来是不够的,还需要额外的验证。这就是 Ecto changeset 的作用。让我们深入探讨!

Changeset 和验证

Changeset 定义了一个管道,我们的数据需要经过该管道才能准备好供我们的应用程序使用。这些转换可能包括类型转换、用户输入验证和过滤掉任何无关参数。我们通常使用 changeset 来验证用户输入,然后再将其写入数据库。Ecto 存储库也支持 changeset,这不仅使它们能够拒绝无效数据,还可以通过检查 changeset 来了解哪些字段已更改,从而执行尽可能少的数据库更新。

让我们仔细看一下默认的 changeset 函数。

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email, :bio, :number_of_pets])
  |> validate_required([:name, :email, :bio, :number_of_pets])
end

目前,我们在管道中执行了两个转换。在第一个调用中,我们调用了Ecto.Changeset.cast/3,传递我们的外部参数并标记哪些字段是验证所需的。

cast/3 首先接受一个结构体,然后是参数(建议的更新),然后是最后一个字段是要更新的列的列表。cast/3 还将只接受 schema 中存在的字段。

接下来,Ecto.Changeset.validate_required/3 检查此字段列表是否出现在 cast/3 返回的 changeset 中。默认情况下,使用生成器,所有字段都是必需的。

我们可以在IEx 中验证此功能。让我们通过运行 iex -S mix 在 IEx 中启动我们的应用程序。为了减少输入并使其更易于阅读,让我们为我们的 Hello.User 结构体添加别名。

$ iex -S mix

iex> alias Hello.User
Hello.User

接下来,让我们使用空的 User 结构体和空的参数映射,从我们的 schema 生成一个 changeset。

iex> changeset = User.changeset(%User{}, %{})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [
    name: {"can't be blank", [validation: :required]},
    email: {"can't be blank", [validation: :required]},
    bio: {"can't be blank", [validation: :required]},
    number_of_pets: {"can't be blank", [validation: :required]}
  ],
  data: #Hello.User<>,
  valid?: false
>

有了 changeset 后,我们可以检查它是否有效。

iex> changeset.valid?
false

由于它无效,我们可以询问它错误是什么。

iex> changeset.errors
[
  name: {"can't be blank", [validation: :required]},
  email: {"can't be blank", [validation: :required]},
  bio: {"can't be blank", [validation: :required]},
  number_of_pets: {"can't be blank", [validation: :required]}
]

现在,让我们使 number_of_pets 可选。为此,我们只需从 Hello.User 中的 changeset/2 函数中的列表中删除它。

    |> validate_required([:name, :email, :bio])

现在,转换 changeset 应该告诉我们只有 nameemailbio 不能为空。我们可以通过在 IEx 中运行 recompile() 然后重建我们的 changeset 来测试这一点。

iex> recompile()
Compiling 1 file (.ex)
:ok

iex> changeset = User.changeset(%User{}, %{})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [
    name: {"can't be blank", [validation: :required]},
    email: {"can't be blank", [validation: :required]},
    bio: {"can't be blank", [validation: :required]}
  ],
  data: #Hello.User<>,
  valid?: false
>

iex> changeset.errors
[
  name: {"can't be blank", [validation: :required]},
  email: {"can't be blank", [validation: :required]},
  bio: {"can't be blank", [validation: :required]}
]

如果我们传递一个既未定义在 schema 中也未作为必需字段的键值对会发生什么?

在我们现有的 IEx shell 中,让我们创建一个 params 映射,其中包含有效值以及额外的 random_key: "random value"

iex> params = %{name: "Joe Example", email: "[email protected]", bio: "An example to all", number_of_pets: 5, random_key: "random value"}
%{
  bio: "An example to all",
  email: "[email protected]",
  name: "Joe Example",
  number_of_pets: 5,
  random_key: "random value"
}

接下来,让我们使用新的 params 映射创建另一个 changeset。

iex> changeset = User.changeset(%User{}, params)
#Ecto.Changeset<
  action: nil,
  changes: %{
    bio: "An example to all",
    email: "[email protected]",
    name: "Joe Example",
    number_of_pets: 5
  },
  errors: [],
  data: #Hello.User<>,
  valid?: true
>

我们的新 changeset 有效。

iex> changeset.valid?
true

我们还可以检查 changeset 的更改 - 我们在所有转换完成后获得的映射。

iex(9)> changeset.changes
%{bio: "An example to all", email: "[email protected]", name: "Joe Example",
  number_of_pets: 5}

请注意,我们的 random_key 键和 "random_value" 值已从最终的 changeset 中删除。Changeset 使我们能够将外部数据(例如 Web 表单上的用户输入或来自 CSV 文件的数据)转换为系统中的有效数据。无效参数将被删除,无法根据我们的 schema 转换的错误数据将在 changeset 错误中突出显示。

我们可以验证的不仅仅是字段是否必需。让我们看一下更细粒度的验证。

如果我们有一个要求,即系统中所有简历都必须至少有两个字符长,该怎么办?我们可以通过在 changeset 中向管道添加另一个转换来轻松实现这一点,该转换将验证 bio 字段的长度。

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email, :bio, :number_of_pets])
  |> validate_required([:name, :email, :bio, :number_of_pets])
  |> validate_length(:bio, min: 2)
end

现在,如果我们尝试转换包含用户 bio 的值为 "A" 的数据,我们应该看到 changeset 错误中显示的验证失败。

iex> recompile()

iex> changeset = User.changeset(%User{}, %{bio: "A"})

iex> changeset.errors[:bio]
{"should be at least %{count} character(s)",
 [count: 2, validation: :length, kind: :min, type: :string]}

如果我们还有关于简历最大长度的要求,我们可以简单地添加另一个验证。

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email, :bio, :number_of_pets])
  |> validate_required([:name, :email, :bio, :number_of_pets])
  |> validate_length(:bio, min: 2)
  |> validate_length(:bio, max: 140)
end

假设我们希望对 email 字段执行至少一些基本的格式验证。我们想要检查的只是 @ 的存在。 Ecto.Changeset.validate_format/3 函数正是我们需要的。

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email, :bio, :number_of_pets])
  |> validate_required([:name, :email, :bio, :number_of_pets])
  |> validate_length(:bio, min: 2)
  |> validate_length(:bio, max: 140)
  |> validate_format(:email, ~r/@/)
end

如果我们尝试转换电子邮件为 "example.com" 的用户,我们应该看到类似于以下内容的错误消息

iex> recompile()

iex> changeset = User.changeset(%User{}, %{email: "example.com"})

iex> changeset.errors[:email]
{"has invalid format", [validation: :format]}

在 changeset 中,我们可以执行更多验证和转换。有关更多信息,请参阅Ecto Changeset 文档

数据持久化

我们已经探讨了迁移和 schema,但还没有持久化任何 schema 或 changeset。我们之前简要介绍了 lib/hello/repo.ex 中的存储库模块,现在是时候使用它了。

Ecto 存储库是存储系统的接口,无论是 PostgreSQL 等数据库还是 RESTful API 等外部服务。 Repo 模块的目的是为我们处理持久化和数据查询的更细致的细节。作为调用者,我们只关心获取和持久化数据。 Repo 模块负责底层数据库适配器通信、连接池和数据库约束违规的错误翻译。

让我们使用 iex -S mix 返回 IEx,并将一些用户插入数据库。

iex> alias Hello.{Repo, User}
[Hello.Repo, Hello.User]

iex> Repo.insert(%User{email: "[email protected]"})
[debug] QUERY OK db=6.5ms queue=0.5ms idle=1358.3ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["[email protected]", ~N[2021-02-25 01:58:55], ~N[2021-02-25 01:58:55]]
{:ok,
 %Hello.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   bio: nil,
   email: "[email protected]",
   id: 1,
   inserted_at: ~N[2021-02-25 01:58:55],
   name: nil,
   number_of_pets: nil,
   updated_at: ~N[2021-02-25 01:58:55]
 }}

iex> Repo.insert(%User{email: "[email protected]"})
[debug] QUERY OK db=1.3ms idle=1402.7ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["[email protected]", ~N[2021-02-25 02:03:28], ~N[2021-02-25 02:03:28]]
{:ok,
 %Hello.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   bio: nil,
   email: "[email protected]",
   id: 2,
   inserted_at: ~N[2021-02-25 02:03:28],
   name: nil,
   number_of_pets: nil,
   updated_at: ~N[2021-02-25 02:03:28]
 }}

我们首先对 UserRepo 模块进行了别名,以便于访问。接下来,我们使用 Repo.insert/2 方法插入一个 User 结构。由于我们处于 dev 环境,我们可以看到存储库在插入底层 %User{} 数据时执行的查询的调试日志。我们收到了一个包含 {:ok, %User{}} 的二元元组,这表明插入成功。

我们也可以通过将 changeset 传递给 Repo.insert/2 来插入用户。如果 changeset 有效,存储库将使用优化的数据库查询插入记录,并返回一个二元元组,如上所述。如果 changeset 无效,我们将收到一个由 :error 和无效 changeset 组成的二元元组。

插入了几个用户后,让我们将它们从存储库中取回。

iex> Repo.all(User)
[debug] QUERY OK source="users" db=5.8ms queue=1.4ms idle=1672.0ms
SELECT u0."id", u0."bio", u0."email", u0."name", u0."number_of_pets", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[
  %Hello.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    bio: nil,
    email: "[email protected]",
    id: 1,
    inserted_at: ~N[2021-02-25 01:58:55],
    name: nil,
    number_of_pets: nil,
    updated_at: ~N[2021-02-25 01:58:55]
  },
  %Hello.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    bio: nil,
    email: "[email protected]",
    id: 2,
    inserted_at: ~N[2021-02-25 02:03:28],
    name: nil,
    number_of_pets: nil,
    updated_at: ~N[2021-02-25 02:03:28]
  }
]

这很简单!Repo.all/1 接受一个数据源,在本例中是我们的 User 模式,并将其转换为针对数据库的底层 SQL 查询。在获取数据后,Repo 将使用我们的 Ecto 模式将数据库值映射回 Elixir 数据结构,根据我们的 User 模式。我们不仅限于基本的查询 - Ecto 包含一个完整的查询 DSL,用于高级 SQL 生成。除了自然的 Elixir DSL 之外,Ecto 的查询引擎还为我们提供了多个强大的功能,例如 SQL 注入保护和查询的编译时优化。让我们尝试一下。

iex> import Ecto.Query
Ecto.Query

iex> Repo.all(from u in User, select: u.email)
[debug] QUERY OK source="users" db=0.8ms queue=0.9ms idle=1634.0ms
SELECT u0."email" FROM "users" AS u0 []
["[email protected]", "[email protected]"]

首先,我们导入了 [Ecto.Query],它导入了 Ecto 查询 DSL 的 from/2 宏。接下来,我们构建了一个查询,该查询选择用户表中的所有电子邮件地址。让我们尝试另一个例子。

iex> Repo.one(from u in User, where: ilike(u.email, "%1%"),
                               select: count(u.id))
[debug] QUERY OK source="users" db=1.6ms SELECT count(u0."id") FROM "users" AS u0 WHERE (u0."email" ILIKE '%1%') []
1

现在我们开始了解 Ecto 丰富的查询功能。我们使用 Repo.one/2 获取包含 1 的所有用户的数量,并获得了预期的计数。这仅仅触及了 Ecto 查询接口的表面,它还支持更多功能,例如子查询、间隔查询和高级选择语句。例如,让我们构建一个查询,以获取所有用户 ID 到其电子邮件地址的映射。

iex> Repo.all(from u in User, select: %{u.id => u.email})
[debug] QUERY OK source="users" db=0.9ms
SELECT u0."id", u0."email" FROM "users" AS u0 []
[
  %{1 => "[email protected]"},
  %{2 => "[email protected]"}
]

这个小查询非常强大。它同时从数据库中获取所有用户电子邮件,并高效地构建了一个结果映射。您应该浏览 Ecto.Query 文档 以查看支持的查询功能的范围。

除了插入外,我们还可以使用 Repo.update/2Repo.delete/2 来更新或删除单个模式。Ecto 还支持使用 Repo.insert_all/3Repo.update_all/3Repo.delete_all/2 函数进行批量持久化。

Ecto 可以做的事情还有很多,我们只是触及了表面。有了坚实的 Ecto 基础,我们现在就可以继续构建我们的应用程序,并将面向 Web 的应用程序与我们的后端持久化集成。在此过程中,我们将扩展我们的 Ecto 知识,并学习如何将我们的 Web 界面与系统的底层细节正确隔离。请查看 Ecto 文档 以了解故事的其余部分。

在我们的 上下文指南 中,我们将了解如何将 Ecto 访问和业务逻辑封装在模块中,这些模块将相关功能分组在一起。我们将看到 Phoenix 如何帮助我们设计易于维护的应用程序,并在此过程中了解其他一些 Ecto 功能。

使用其他数据库

Phoenix 应用程序默认配置为使用 PostgreSQL,但如果我们想使用其他数据库,例如 MySQL,该怎么办?在本节中,我们将逐步介绍如何更改默认设置,无论我们是要创建一个新应用程序,还是已经有一个为 PostgreSQL 配置的现有应用程序。

如果我们要创建一个新的应用程序,将应用程序配置为使用 MySQL 很容易。我们只需将 --database mysql 标志传递给 phx.new,所有内容将被正确配置。

$ mix phx.new hello_phoenix --database mysql

这将自动为我们设置所有正确的依赖项和配置。安装这些依赖项后,使用 mix deps.get,我们将准备好开始在应用程序中使用 Ecto。

如果我们有一个现有的应用程序,我们只需要切换适配器并进行一些小的配置更改。

要切换适配器,我们需要删除 Postgrex 依赖项,并添加一个用于 MyXQL 的新依赖项。

让我们打开 mix.exs 文件并立即执行此操作。

defmodule HelloPhoenix.MixProject do
  use Mix.Project

  . . .
  # Specifies your project dependencies.
  #
  # Type `mix help deps` for examples and options.
  defp deps do
    [
      {:phoenix, "~> 1.4.0"},
      {:phoenix_ecto, "~> 4.4"},
      {:ecto_sql, "~> 3.10"},
      {:myxql, ">= 0.0.0"},
      ...
    ]
  end
end

接下来,我们需要通过更新 config/dev.exs 来配置适配器以使用默认的 MySQL 凭据。

config :hello_phoenix, HelloPhoenix.Repo,
  username: "root",
  password: "",
  database: "hello_phoenix_dev"

如果我们有一个现有 HelloPhoenix.Repo 的配置块,我们可以简单地将值更改为与我们的新值匹配。您还需要在 config/test.exsconfig/runtime.exs(以前为 config/prod.secret.exs)文件中配置正确的值。

最后一个更改是打开 lib/hello_phoenix/repo.ex,并确保将 :adapter 设置为 Ecto.Adapters.MyXQL

现在我们只需要获取新的依赖项,我们就可以开始了。

$ mix deps.get

安装并配置了新的适配器后,我们就可以创建数据库了。

$ mix ecto.create

HelloPhoenix.Repo 的数据库已创建。我们还可以运行任何迁移,或执行我们可能选择的其他任何 Ecto 操作。

$ mix ecto.migrate
[info] == Running HelloPhoenix.Repo.Migrations.CreateUser.change/0 forward
[info] create table users
[info] == Migrated in 0.2s

其他选项

虽然 Phoenix 使用 Ecto 项目来与数据访问层交互,但还有许多其他数据访问选项,其中一些甚至内置于 Erlang 标准库中。ETS(通过 etso 在 Ecto 中可用)和 DETS 是内置于 OTP 的键值数据存储。OTP 还提供了一个名为 Mnesia 的关系数据库,它有自己的查询语言,称为 QLC。Elixir 和 Erlang 也都有许多库可用于处理各种流行的数据存储。

数据世界是你的牡蛎,但我们不会在这些指南中介绍这些选项。