[Перевод] SQLx: raw SQL в Rust
Когда речь заходит об использовании 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-разработчиков на ближайшие открытые уроки: