[Перевод] SQLx: raw SQL в Rust

37182a2d4ce178809838bc3e5887a7a6.jpeg

Когда речь заходит об использовании SQL, экосистеме Rust есть что нам предложить: к счастью, нам доступны несколько отличных готовых к использованию решений. SQLx — это чисто асинхронный, независящий от рантайма SQL-крейт, позволяющий использовать проверяемые во время компиляции запросы без какого-либо DSL. Являясь одним из наиболее популярных способов использования SQL в Rust, он обладает следующими преимуществами:

  • Он совместим со всеми вашими любимыми разновидностями SQL (MySQL, SQLite, Postgres).

  • Проверка запросов во время компиляции обеспечивает корректность этих самых запросов и типов, которые в них задействованы.

  • Поддержка дополнительных функций, таких как listen/notify из Postgres.

  • Множество различных способов построения и использования запросов.

  • С помощью SQLx вы также можете создать свой собственный конструктор запросов!

Давайте же посмотрим на SQLx в действии!

Начало работы

Для начала вам необходимо добавить sqlx в вашу Rust-программу:

cargo add sqlx

Также необходимо установить sqlx-cli, официальный CLI SQLx, который, помимо всего прочего, облегчает управление миграциями. Установить его можно, выполнив приведенную ниже команду:

cargo install sqlx-cli

Миграции

Первый шаг — миграция. При желании можно просто создать таблицы вручную —, но это потребует много времени и усилий… и нужно будет постоянно помнить, что конкретно мы сделали на этом этапе! К счастью, мы можем написать .sql-файлы, представляющие наши миграции, и затем переносить их в любую используемую нами базу данных либо с помощью sqlx-cli, либо с помощью команды sqlx::execute. Простая SQL-схема может выглядеть следующим образом:

--  таблица здесь будет создана только в том случае, если она еще не существует, что позволяет избежать проблемы с перезатиранием таблиц
CREATE TABLE IF NOT EXISTS foo (
  id SERIAL PRIMARY KEY,
  message TEXT
);

Если это корректный SQL, то какой бы метод вы ни использовали, он успешно выполнится, создав в вашей базе данных таблицу _sqlx_migrations с примененным списком миграций.

Ваша встроенная команда миграции может выглядеть следующим образом:

В качестве рекомендации скажу вам, что сам я использую sqlx-cli, где выполняю команду sqlx migrate -r add . Эта команда, по сути, добавляет новую миграцию, но флаг -r позволяет в любой момент отменить при возникновении каких-либо ошибок. Это удобный способ, позволяющий откатить все назад, если что-то пойдет не так после развертывания новой миграции в продакшене.

Запросы

Мы по умолчанию можем использовать чистые SQL-запросы, просто выполняя запрос на нашем пуле соединений:

let query = sqlx::query("SELECT * FROM TABLE")
  .execute(&pool)
  .await
  .unwrap();

По умолчанию SQLx поощряет использование связанных параметров, которые очень важны для предотвращения SQL-инъекций — достаточно просто добавить их в запрос (подробнее об этом можно прочитать здесь):

sqlx::query("INSERT INTO TABLE (foo) VALUES ($1)")
  .bind("bar".to_string())
  .execute(&pool)
  .await
  .unwrap();

Теперь предположим, что вы пишете запрос, который возвращает что-то. Когда вы получаете строки из этого запроса, вам, скорее всего, придется брать каждое значение по отдельности — в небольших масштабах это еще куда ни шло, но когда вы используете fetch_all, вам придется задействовать итератор, чтобы получить то, что вам нужно из каждой строки. К счастью, создатели SQLx понимали это и предусмотрели макрос для извлечения вектора со структурами из вектора строк SQL — вы можете использовать query_as для привязки возвращаемых результатов к структуре, которая использует #[derive(Sqlx::FromRow)].

Выглядеть это будет следующим образом:

#[derive(sqlx::FromRow)]
struct Foo {
  id: i32,
  message: String
}

async fn foo(pool: PgPool) -> Vec {
let res = sqlx::query_as::<_, Foo>("SELECT * FROM FOO")
  .fetch_all(&pool).await.unwrap();
  
  Ok(res)
}

Вам нужно сделать что-то более сложное? Для построения запросов можно использовать тип QueryBuilder. Хоть он отлично подходит для программного добавления динамических фрагментов в запросы, следует быть осторожным при его использовании, поскольку в нем есть методы для добавления значений, не являющихся связанными параметрами, — в идеале, если вы не уверены в безопасности используемого метода, лучше использовать push_bind.

Пример использования:

const BIND_LIMIT: usize = 65535;

// Значения здесь могут выдаваться бесконечно!
let records = (0..).map(|i| Foo {
    id: i,
    message: format!("This is note {i}"),
});

let mut query_builder: QueryBuilder = QueryBuilder::new(
    // Обратите внимание на завершающий пробел; большинство вызовов QueryBuilder не добавляют пробелы
    // вам бы понадобился итератор, который бы выдавал ссылки, живущие столько же, сколько и query_builder,
    // быть важны точные значения.
    "SELECT * FROM users WHERE (id, username, email, password) in"
);

// Отмечу, что .into_iter() здесь не понадобилось, поскольку users уже является итератором.
    // Если бы вы хотели связать их по ссылке, а не по значению,
    // вам бы понадобился итератор, который бы выдавал ссылки, живущие столько же, сколько и `query_builder`,
    // для чего, например, можно было бы сначала собрать их в `Vec`.
    bound.push_bind(foo.id)
    .push_bind(foo.username);
});

let mut query = query_builder.build();

let res = query.fetch_all(&pool).await.unwrap();

Если вы попробуете выполнить этот запрос, то вы получите вектор структур Foo! Однако следует помнить, что этот метод имеет свои недостатки, о которых будет сказано ниже: вы не сможете воспользоваться макросами проверки времени компиляции SQLx, а также этот метод генерации запросов может быть несколько небезопасным, если вы не будете проявлять особую осторожность. Однако в рамках задач, предполагающих динамическую генерацию запросов с использованием SQL в Rust, этот метод является достаточно мощным.

Последний тип запроса, который мы также можем использовать, — это скалярный подзапрос, который возвращает результат в виде кортежа. Если при выполнении запроса (например) SELECT * FROM TABLE мы не знаем, сколько в нем полей, мы можем использовать query_scalar, что позволит нам ссылаться на столбцы просто по порядку их следования, а не по заданному имени. Смотрите пример ниже:

let query = sqlx::query_scalar("SELECT * FROM FOO LIMIT 1").fetch_one(&pool).await.unwrap();

println!("{:?}", query.0);

Макросы

Мы наконец подошли к одному из достоинств SQLx: проверке запросов во время компиляции. Если вы используете чистый (raw) SQL, наличие некоторой гарантии того, что ваш SQL корректен, практически никогда не будет лишним: если вы не администратор базы данных, то при выполнении запроса с несколькими джоинами вам обязательно захочется убедиться в его корректности до того, как он будет запущен. Следует отметить, что для использования этой возможности необходимо установить sqlx-cli: если его нет, то вам придется полагаться только на предыдущие методы.

Простой запрос с использованием макроса query! может выглядеть следующим образом:

// обратите внимание, что в макрос query добавляются связанные параметры
let query = query!("SELECT * FROM FOO WHERE ID = $1", 1).fetch_one(&pool).await.unwrap();

Аналогично, эквивалентный запрос, использующий созданную ранее структуру Foo, может быть использован для прямого связывания результатов с вектором структур:

#[derive(sqlx::FromRow)]
struct Foo {
  id: i32,
  message: String
}

let query = query_as!(Foo, "SELECT * FROM FOO").fetch_all(&pool).await.unwrap();

При использовании макроса query! или query_as! необходимо использовать программу cargo sqlx prepare, которая будет генерировать JSON-файлы для ваших запросов. Когда вы компилируете свою программу, она будет автоматически проверять ее во время компиляции: если что-то не так, она автоматически проверит это за вас.

Существует одна особенность, которая может поставить вас в тупик при использовании макросов проверки во время компиляции, особенно в Postgres: если вы используете as _ для переименования полей SQL, то тип будет автоматически обернут в Option, если вы не зададите его явно как ненулевое (non-nullable) значение. В SQLx есть ответ на этот вопрос — возможность использования чистых строк (raw strings) для явного объявления значений в качестве ненулевых столбцов. Например, возьмем следующий оператор:

let query = query_as!(Foo, "SELECT id, message as message from foo").fetch_all(&pool).await.unwrap();

Если бы у нас по-прежнему String был в качестве типа Message, то этот запрос не скомпилировался бы, поскольку message теперь является типом Option, а не String. Однако, преобразовав приведенный выше запрос в чистую строку, мы можем заставить поле снова стать ненулевым:

// // обратите внимание, что теперь message —— это "message!"
let query = query_as!(Foo, r#"SELECT id, message as "message!" from foo"#).fetch_all(&pool).await.unwrap();

Подробнее об этом можно прочитать здесь.

Подобным образом, конечно, query_scalar также имеет связанный макрос, который можно использовать аналогично макросу query!, возвращая при этом кортежи.

Еще одна замечательная возможность — хранить SQL-запрос в файле и запускать макрос для выполнения содержимого SQL-файла, сохраняя при этом привязку наших параметров. Смотрите пример ниже:

SELECT * FROM FOO WHERE id = $1;
let query = query_file!("query.sql", 1i32).fetch_one(&pool).await.unwrap();

Разумеется, этот макрос также поддерживает связывание со структурами и скалярные подзапросы с помощью query_file_as! и query_file_scalar!.

Следует отметить, что если вам требуется только проверка синтаксиса во время компиляции, а корректность входов и выходов базы данных для макроса запроса не важна, то в конце макроса можно добавить unchecked. Например: query! превратится в query_unchecked!. Это полезно в тех случаях, когда база данных еще не настроена или нет удобного способа получения URL базы данных (или в других подобных случаях, когда вы не хотите предоставлять SQLx прямой доступ к вашей базе данных).

Listen/Notify PostgreSQL 

При таком количестве фич, как у Postgres, впечатляет, что SQLx способен их поддерживать — хотя SQLx в первую очередь предназначен для написания чистого SQL, нет каких-либо ограничений, заставляющих нас писать все только на нем. SQLx поддерживает каналы, LISTEN и, что еще важнее, pg_notify, который является для нас отличным способом обработки уведомлений от Postgres при обновлении записей. Давайте посмотрим на примере ниже, как мы можем настроить слушатель событий:

// заранее подготавливаем пул

let mut listener = PgListener::connect_with(&pool).await.unwrap();
listener.listen("testNotify").await.unwrap();

// создаем цикл для получения уведомлений
tokio::spawn(async move || {
  while let Some(notification) = listener.try_recv().await.unwrap() {
  println!("{notification:?}");
  }
});

loop {
  sqlx::query("SELECT pg_notify('testNotify', 'Hello world!')").execute(&pool).await;
}

Как вы можете видеть здесь, мы породили задачу Tokio для асинхронного цикла получения уведомлений и их последующей печати — тем временем внутри основного потока выполнения мы также создали цикл для непрерывной отправки запроса «Hello world!» по каналу, который получает наш PgListener.

Для более продвинутой реализации в веб-сервисе, реализующем в качестве конечной точки поток изменений базы данных, следует использовать метод .into_stream(), поскольку фреймворки обычно принимают поток данных, который затем оборачивается в соответствующий тип из фреймворка. Например, в Axum вы бы использовали тип axum::response::Sse (обратите внимание, что это предполагает, что у вас уже есть настроенный веб-сервис):

use axum::{Extension, response::{Sse, sse::Event}};
use tokio_stream::StreamExt as _ ;
use futures_util::stream::{self, Stream};
use std::convert::Infallbile;
async fn return_stream(Extension(listener): Extension) -> Sse>> {
  let stream = listener.into_stream();

  Sse::new(stream
    .map(|msg| {
      let msg = msg.uwnrap();
    
      let json = json!(msg).to_string();
     Event::default().data(json)
    }).map(Ok),
   ).keep_alive(KeepAlive::default())
}

При настройке веб-сервиса мы можем создавать уведомления одним из двух способов:

Сама работа с pg_notify довольно проста, хотя тут можно обойтись и без SQL, просто используя каналы Tokio. Давайте поднимемся на ступеньку выше и используем SQL для настройки наших каналов, чтобы нам не пришлось вручную генерировать их в коде.

CREATE TABLE IF NOT EXISTS test_table (
  id SERIAL PRIMARY KEY,
  message TEXT NOT NULL
);

CREATE TRIGGER "testNotify"
AFTER INSERT ON test_table
FOR EACH ROW EXECUTE PROCEDURE testNotify();

CREATE OR REPLACE FUNCTION testNotify()
  RETURNS TRIGGER AS $$
DECLARE
BEGIN
  PERFORM pg_notify('testNotify', ROW_TO_JSON(NEW)::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Теперь, если мы добавим это в файл миграции SQL, затем запустим приложение и обратимся к конечной точке, которую мы используем для нашего потока, то мы сможем получать поток уведомлений!

Использование SQLx вместе с Shuttle

В настоящее время Shuttle предлагает SQLx в качестве соединения по умолчанию через наши аннотационные макросы, которые экономят время, позволяя создавать инфраструктуру прямо из кода. Все, что вам нужно сделать, это объявить макрос в коде, как, например, здесь:

use sqlx::PgPool;

#[shuttle_runtime::main]
async fn main(
  #[shuttle_shared_db::Postgres] db: PgPool // gets объявляется здесь
) -> shuttle_axum::ShuttleAxum {
  sqlx::migrate!().run(&db).await.map_err(|e| format!("Oh no! Migrations failed :( {e}");
  
  ... the rest of your code
}

Наши бесплатные базы данных предоставляются через общий сервер баз данных (при этом пользователи имеют отдельные базы данных для каждого приложения). Однако теперь мы предлагаем 100% изолированные базы данных AWS RDS в нашем плане Pro, о котором вы можете узнать здесь, с поддержкой MySQL, Postgres и MariaDB.

Пару слов в завершение

Спасибо, что прочитали эту статью! Я надеюсь, что вы получили хорошее представление о том, как можно использовать SQL в Rust, а также о том, насколько полезным инструментом является SQLx, когда нам нужно заставить мощь чистых, проверяемых во время компиляции SQL-запросов работать на вас при использовании SQL в Rust.

Если эта статья была для вас полезна, то не стесняйтесь поставить нам звезду на GitHub.

В заключение приглашаем всех Rust-разработчиков на ближайшие открытые уроки:

© Habrahabr.ru