Do you want to pick up from where you left of?
Take me there

Язык запросов

В этом уроке мы будем работать над приложением Friends для каталогизации фильмов из предыдущего урока.

Получение записей из Ecto.Repo

Напомним, что “репозиторием” в Ecto называется хранилище данных, такое как наша база данных Postgres. Всё взаимодействие с базой будет происходить посредством этого репозитория.

Для начала мы можем выполнять простые запросы напрямую через Friends.Repo с помощью пары полезных функций.

Получение записей по ID

Мы можем использовать функцию Repo.get/3, чтобы получить запись из базы по ID. Эта функция принимает два обязательных аргумента: структуру данных, пригодную для запросов, и ID искомой записи. В качестве результата она возвращает запись в виде структуры, если таковая была найдена. В противном случае возвращается nil.

В примере ниже мы получаем фильм с ID = 1:

iex> alias Friends.{Repo, Movie}
iex> Repo.get(Movie, 1)
%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
  actors: %Ecto.Association.NotLoaded<association :actors is not loaded>,
  characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
  distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}

Обратите внимание, что первый аргумент, передаваемый в Repo.get/3, – это наш модуль Movie. Мы называем Movie “пригодным для запросов”, потому что он определяет схему данных при помощи Ecto.Schema. За счёт этого Movie реализует протокол Ecto.Queryable. Этот протокол позволяет преобразовывать структуры данных в запросы Ecto.Query, которые затем используются для получения данных из репозитория. Дальше мы подробнее остановимся на запросах.

Получение записей по атрибуту

Мы также можем получать данные по заданным критериям при помощи функции Repo.get_by/3. Она принимает два значения: подходящую структуру и условие для запроса. Repo.get_by/3 в качестве результата возвращает одну запись из репозитория. Вот пример:

iex> Repo.get_by(Movie, title: "Ready Player One")
%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
  actors: %Ecto.Association.NotLoaded<association :actors is not loaded>,
  characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
  distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}

Однако если нам нужно использовать более сложные запросы, либо получать все подходящие под условие записи, нам понадобится модуль Ecto.Query.

Написание запросов с Ecto.Query

Модуль Ecto.Query предоставляет собственный язык написания запросов для доступа к данным репозиториев.

Создание запросов при помощи Ecto.Query.from/2

Запрос можно создавать при помощи макроса Ecto.Query.from/2. Эта функция принимает два аргумента: выражение и необязательный ключевой список. Попробуем создать максимально простой запрос для получения всех фильмов из нашего репозитория:

iex> import Ecto.Query
iex> query = from(Movie)
#Ecto.Query<from m0 in Friends.Movie>

Чтобы выполнить запрос, воспользуемся функцией Repo.all/2. Она принимает структуру запроса Ecto в качестве обязательного аргумента и возвращает все записи, удовлетворяющие условиям.

iex> Repo.all(query)

14:58:03.187 [debug] QUERY OK source="movies" db=1.7ms decode=4.2ms
[
  %Friends.Movie{
    __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
    actors: %Ecto.Association.NotLoaded<association :actors is not loaded>,
    characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
    distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
    id: 1,
    tagline: "Something about video games",
    title: "Ready Player One"
  }
]

Запросы без привязок

Пример выше не включает в себя самую мякотку языка SQL. Очень часто мы хотим получить из базы только определённые поля или отфильтровать записи по какому-то критерию. Давайте получим только значения полей title и tagline всех фильмов с названием "Ready Player One":

iex> query = from(Movie, where: [title: "Ready Player One"], select: [:title, :tagline])
#Ecto.Query<from m0 in Friends.Movie, where: m0.title == "Ready Player One",
 select: [:title, :tagline]>

iex> Repo.all(query)
SELECT m0."title", m0."tagline" FROM "movies" AS m0 WHERE (m0."title" = 'Ready Player One') []
[
  %Friends.Movie{
    __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
    actors: %Ecto.Association.NotLoaded<association :actors is not loaded>,
    characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
    id: nil,
    tagline: "Something about video games",
    title: "Ready Player One"
  }
]

Обратите внимание, что в результирующих структурах заполнены только поля tagline и title – это прямое следствие использования блока select:.

Запросы вроде этого называют запросами без привязки (bindingless), потому что они достаточно просты и не нуждаются в привязках.

Привязки в запросах

До этого момента в качестве первого аргумента макроса from мы использовали исключительно модуль, реализующий протокол Ecto.Queryable (т.е. Movie). Но помимо него, мы могли бы использовать особое выражение с in:

iex> query = from(m in Movie)
#Ecto.Query<from m0 in Friends.Movie>

В этом случае мы называем m привязкой. Привязки нам очень пригодятся, т.к. с их помощью можно ссылаться на структуру в других частях запроса. Например, мы можем достать из базы названия всех фильмов с id меньше 2:

iex> query = from(m in Movie, where: m.id < 2, select: m.title)
#Ecto.Query<from m0 in Friends.Movie, where: m0.id < 2, select: m0.title>

iex> Repo.all(query)
SELECT m0."title" FROM "movies" AS m0 WHERE (m0."id" < 2) []
["Ready Player One"]

Очень важный момент здесь это как изменился результат выполнения запроса. Использование выражения с привязкой в select: части запроса позволяет нам явным образом указать, в каком виде мы хотим получить данные. С таким же успехом мы можем попросить функцию вернуть нам кортеж:

iex> query = from(m in Movie, where: m.id < 2, select: {m.title})

iex> Repo.all(query)
[{"Ready Player One"}]

В целом хорошей идеей будет всегда начинать с простого запроса и добавлять привязки только когда появляется необходимость сослаться на структуру. Больше про привязки в запросах можно прочитать в документации

Запросы на основе макросов

В предыдущих примерах, чтобы сконструировать запрос, мы использовали ключи select: и where: в параметрах макроса from. Про такой способ говорят, что он основан на ключах (keyword-based). Но существует также ещё один способ конструировать запросы – основанный на макросах. Ecto предоставляет макросы для каждого ключевого слова, например select/3 или where/3. Каждый макрос принимает сущность, пригодную для запросов, явный список привязок и точно такое же выражение, какое мы использовали бы в предыдущем подходе:

iex> query = select(Movie, [m], m.title)
#Ecto.Query<from m0 in Friends.Movie, select: m0.title>

iex> Repo.all(query)
SELECT m0."title" FROM "movies" AS m0 []
["Ready Player One"]

Что хорошо в макросах, так это то, что они отлично объединяются в конвейер:

iex> Movie \
...>  |> where([m], m.id < 2) \
...>  |> select([m], {m.title}) \
...>  |> Repo.all
[{"Ready Player One"}]

Обратите внимание, чтобы продолжить запись после разрыва строки, используйте символ \.

Интерполяция в where

Чтобы интерполировать значения в WHERE-части запроса, необходимо использовать ^ или, как его ещё называют, оператор фиксации (pin). Это позволяет нам зафиксировать значение в переменной и обратиться к нему после, вместо того, чтобы перезаписать переменную.

iex> title = "Ready Player One"
"Ready Player One"
iex> query = from(m in Movie, where: m.title == ^title, select: m.tagline)
%Ecto.Query<from m in Friends.Movie, where: m.title == ^"Ready Player One",
 select: m.tagline>
iex> Repo.all(query)

15:21:46.809 [debug] QUERY OK source="movies" db=3.8ms
["Something about video games"]

Получение первой и последней записей

Можно получить первую или последнюю запись из репозитория при помощи функций Ecto.Query.first/2 и Ecto.Query.last/2.

Для начала сконструируем запрос при помощи функции first/2:

iex> first(Movie)
#Ecto.Query<from m0 in Friends.Movie, order_by: [asc: m0.id], limit: 1>

Потом передадим его в Repo.one/2, чтобы получить результат:

iex> Movie |> first() |> Repo.one()

SELECT m0."id", m0."title", m0."tagline" FROM "movies" AS m0 ORDER BY m0."id" LIMIT 1 []
%Friends.Movie{
  __meta__: #Ecto.Schema.Metadata<:loaded, "movies">,
  actors: #Ecto.Association.NotLoaded<association :actors is not loaded>,
  characters: #Ecto.Association.NotLoaded<association :characters is not loaded>,
  distributor: #Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}

Функция Ecto.Query.last/2 используется аналогично:

iex> Movie |> last() |> Repo.one()

Получение связанных данных

Предзагрузка

Чтобы иметь доступ к записям, связанным при помощи макросов belongs_to, has_many и has_one, нам нужно предзагрузить соответствующие схемы.

Давайте посмотрим, что случится, если мы попытаемся получить актёров из фильма:

iex> movie = Repo.get(Movie, 1)
iex> movie.actors
%Ecto.Association.NotLoaded<association :actors is not loaded>

Без предзагрузки этого сделать не получится. Существует несколько способов выполнить предзагрузку в Ecto.

Предзагрузка двумя запросами

Следующий запрос предзагрузит связанные записи отдельным запросом.

iex> Repo.all(from m in Movie, preload: [:actors])

13:17:28.354 [debug] QUERY OK source="movies" db=2.3ms queue=0.1ms
13:17:28.357 [debug] QUERY OK source="actors" db=2.4ms
[
  %Friends.Movie{
    __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
    actors: [
      %Friends.Actor{
        __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
        id: 1,
        movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
        name: "Tyler Sheridan"
      },
      %Friends.Actor{
        __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
        id: 2,
        movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
        name: "Gary"
      }
    ],
    characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
    distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
    id: 1,
    tagline: "Something about video games",
    title: "Ready Player One"
  }
]

Видно, что код выше сделал два запроса к базе данных. Один для всех фильмов, и ещё один для актёров, связанных с фильмами, с определёнными ID.

Предзагрузка одним запросом

Можно избавиться от лишнего запроса следующим способом:

iex> query = from(m in Movie, join: a in assoc(m, :actors), preload: [actors: a])
iex> Repo.all(query)

13:18:52.053 [debug] QUERY OK source="movies" db=3.7ms
[
  %Friends.Movie{
    __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
    actors: [
      %Friends.Actor{
        __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
        id: 1,
        movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
        name: "Tyler Sheridan"
      },
      %Friends.Actor{
        __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
        id: 2,
        movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
        name: "Gary"
      }
    ],
    characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
    distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
    id: 1,
    tagline: "Something about video games",
    title: "Ready Player One"
  }
]

Как видим, это позволило вместить всё в один запрос к базе. Это также позволит нам фильтровать в одном запросе как фильмы, так и актёров. Например, при помощи join можно получить все фильмы, где актёры удовлетворяют определённому условию. Что-то в этом роде:

Repo.all from m in Movie,
  join: a in assoc(m, :actors),
  where: a.name == "John Wayne",
  preload: [actors: a]

Подробнее на join остановимся чуть дальше.

Предзагрузка уже полученных записей

Мы также можем предзагрузить связанные схемы для записей, уже полученных из базы.

iex> movie = Repo.get(Movie, 1)
%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
  actors: %Ecto.Association.NotLoaded<association :actors is not loaded>, # actors are NOT LOADED!!
  characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
  distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}
iex> movie = Repo.preload(movie, :actors)
%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [
    %Friends.Actor{
      __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
      id: 1,
      movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
      name: "Tyler Sheridan"
    },
    %Friends.Actor{
      __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
      id: 2,
      movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
      name: "Gary"
    }
  ], # актёры ЗАГРУЖЕНЫ!!
  characters: [],
  distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}

Теперь можно получить актёров фильма:

iex> movie.actors
[
  %Friends.Actor{
    __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
    id: 1,
    movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
    name: "Tyler Sheridan"
  },
  %Friends.Actor{
    __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
    id: 2,
    movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
    name: "Gary"
  }
]

Использование операции соединения

Функция Ecto.Query.join/5 позволяет создавать запросы с использованием SQL-оператора JOIN.

iex> alias Friends.Character
iex> query = from m in Movie,
              join: c in Character,
              on: m.id == c.movie_id,
              where: c.name == "Wade Watts",
              select: {m.title, c.name}
iex> Repo.all(query)
15:28:23.756 [debug] QUERY OK source="movies" db=5.5ms
[{"Ready Player One", "Wade Watts"}]

Выражение on также может быть в виде ключевого списка:

from m in Movie,
  join: c in Character,
  on: [id: c.movie_id], # ключевой список
  where: c.name == "Wade Watts",
  select: {m.title, c.name}

В примере выше мы выполняем соединение с Ecto-схемой — m in Movie. Но мы также можем соединять с Ecto-запросом. Предположим, что в нашей таблице с фильмами есть столбец stars, где мы храним среднюю оценку фильма в “звёздах” — от одной до пяти.

movies = from m in Movie, where: [stars: 5]
from c in Character,
  join: ^movies,
  on: [id: c.movie_id], # ключевой список
  where: c.name == "Wade Watts",
  select: {m.title, c.name}

Язык запросов Ecto — мощный инструмент, обладающий всем необходимым для построения даже самых сложных запросов к базам данных. В этом уроке мы познакомились с базовыми элементами, необходимыми для конструирования запросов.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!