Разработка макроса Rust для автоматического написания шаблонного кода SQL

Эта статья посвящена написанию макроса для Rust, который автоматически реализует оберточные методы в отношении SQL-операторов, чтобы уменьшить требования к шаблонному коду при использовании баз данных SQL. Макрос доступен на сайте crates.io, но на данном этапе является незавершенной разработкой с многочисленными ограничениями. Среди прочего, он включает только поддержку rusqlite-обертки. Исходный код процедурного макроса доступен тут на условиях лицензии свободного программного обеспечения, разработанной Массачусетским технологическим институтом.

Я написал этот код и статью, поскольку хотел лучше разобраться в процедурных макросах. Я решил разработать что-то, имеющее отношение к SQL, поскольку предполагаю, что это понадобится мне для моего сайд-проекта по защите электронной почты — https://1-ml.com — для хранения и получения статистики использования.

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


Мое обучение началось с раздела “Как написать пользовательский производный макрос” в руководстве “Язык программирования Rust”. А первым этапом путешествия по программированию стало создание библиотечного крейта под названием derive-sql с последующим изучением proc-macro=true в Cargo.toml, что необходимо для объявления его как процедурного макроса.

Цель

Цель процедурного макроса  —  облегчить хранение и извлечение struct в базе данных SQL. В качестве примера в этой статье используется следующая структура данных для хранения контакта:

struct Contact {
name: String,
phone_number: String,
email: String,
}

Макрос DeriveSql должен реализовать следующие методы для Contact

impl Contact {
pub create_table(conn: &rusqlite::Connection) -> Result<(), Box<dyn Error>>;
pub select(conn: &rusqlite::Connection) -> Result<Vec<Self>, Box<dyn Error>>;
pub insert(self, conn: &rusqlite::Connection) -> Result<Self, Box<dyn Error>>;
pub update_to(self, conn: &rusqlite::Connection, update: Self) -> Result<Self, Box<dyn Error>>;
pub delete(self, conn: &rusqlite::Connection) -> Result<Self, Box<dyn Error>>;
}

Хочу отметить несколько аспектов.

  • Я решил, что методы insert, update и delete будут потреблять объект при вызове. Для этого выбора нет особых причин кроме возможности создать цепочку методов, что может быть актуально, а может и не быть.
  • Реализация основана на крейте rusqlite, который оборачивается вокруг SQLite. В долгосрочной перспективе я попробую сделать его независимым от движка SQL, чтобы он был совместим с крейтом rust-postgres и, возможно, добавить совместимость с IndexedDB API для клиентских веб-приложений (это отдаленная цель).

Разработка, ориентированная на тестирование

Для этого проекта я выбрал разработку на основе тестов с использованием тестирования документации. При таком подходе документацию и тесты нужно писать одновременно. Это один из аспектов Rust, который мне нравится: он позволяет легко применять эффективную практику (и даже подталкивает к этому).

Тестирование документации имеет следующий вид (для удобочитаемости были удалены символы //!, обычно предшествующие комментариям):

use rusqlite;
use derive_sql::DeriveSql;

#[derive(DeriveSql)]
struct Person {
name: String,
age: u32,
}

let conn = rusqlite::Connection::open_in_memory().unwrap();

// Создание таблицы в базе данных SQL
Person::create_table(&conn).unwrap();

// Вставка человека в базу данных SQL
let person = Person { name: "Jo".to_string(), age: 24 };
let person = person.insert(&conn).unwrap();

// Получение списка лиц из базы данных SQL
let persons: Vec<Person> = Person::select(&conn).unwrap();
assert!(persons.len() == 1);
assert!(persons[0].name.eq("Jo"));

// Вставка Джейн
let jane = Person { name: "Jane".to_string(), age: 27 };
let jane = jane.insert(&conn).unwrap();

// Проверка возраста Джейн
let p: Person = Person::select(&conn).unwrap()
.into_iter().find(|p| p.name.eq("Jane")).unwrap();
assert!(p.age == 27);

// Обновление Джейн
let jane: Person = Person::select(&conn).unwrap()
.into_iter().find(|p| p.name.eq("Jane")).unwrap();
let update_to_jane = Person { name: jane.name.clone(), age: jane.age+1 };
let updated_jane = jane.update_to(&conn, update_to_jane).unwrap();
assert!(updated_jane.age == 28);

// Проверка возраста Джейн
let p: Person = Person::select(&conn).unwrap()
.into_iter().find(|p| p.name.eq("Jane")).unwrap();
assert!(p.age == 28);

// Удаление Джо
let jo: Person = Person::select(&conn).unwrap()
.into_iter().find(|p| p.name.eq("Jo")).unwrap();
let jo = jo.delete(&conn).unwrap();
assert!(jo.name.eq("Jo"));

// Проверка того, что база данных содержит только Джейн
let persons: Vec<Person> = Person::select(&conn).unwrap();
assert!(persons.len() == 1);
assert!(persons[0].name.eq("Jane"));

Начав с реализации метода create_table, я постепенно добавил другие методы и связанные с ними тесты. На данном этапе ни один из тестов не проходит успешно, так как ничего еще не реализовано.

Реализация

Устранение неполадок

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

Решать проблемы, связанные с процедурными макросами,  —  дело непростое. Написание процедурного макроса  —  это упражнение типа “рассуждай с позиции компилятора”. Человек пишет код, который затем генерирует код, подлежащий компиляции. Это приводит к ошибкам компиляции, которые “невидимы”:

error[E0308]: mismatched types
--> samples/src/main.rs:4:10
|
4 | #[derive(DeriveSql)]
| ^^^^^^^^^ expected `u32`, found `&str`
|
= note: this error originates in the derive macro `DeriveSql` (in Nightly builds, run with -Z macro-backtrace for more info)

Для устранения таких проблем требуется либо быть отличным разработчиком, каковым я не являюсь, либо действовать методом проб и ошибок, либо сделать “невидимый” код видимым. Последнего легко добиться, используя либо крейт cargo-expand, либо команду компилятора (которую оборачивает cargo-expand). Команда компилятора, показанная ниже, доступна только в nightly, т.е. необходимо изменить инструментарий Rust со stable на nightly с помощью rustup default nightly.

cargo rustc --profile=check -- -Zunpretty=expanded

Я предпочитаю использовать команду компилятора напрямую, так как это позволяет избежать добавления еще одной зависимости. Использование ее в описанной выше ситуации привело к следующему фрагменту кода, где моя ошибка очевидна: я пытаюсь использовать r.get(0)? (строки 8 и 9), но вместо кода происходит расширение в строку:

impl Person {
pub fn select(conn: &rusqlite::Connection)
-> Result<Vec<Person>, Box<dyn std::error::Error>> {
let r =
conn.prepare("SELECT name, age FROM person")?.query_map([],
|r|
Ok(Person {
name: "r.get(0)?.try_into()?",
age: "r.get(1)?.try_into()?",
}))?.collect()?;
Ok(r)
}
}

Иногда полезно выводить расширенный код в отдельный файл. После небольшой доработки во избежание ошибок компиляции, как показано ниже, сгенерированный код можно отредактировать. Это позволит изучить изменения, которые необходимо сделать, и посмотреть, как выглядит удачный код.

error[E0658]: use of unstable library feature 'fmt_internals': internal to format_args!
--> src/main.rs:44:27
|
44 | ::std::io::_print(::core::fmt::Arguments::new_v1(&["Hello, world!\n"],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: add `#![feature(fmt_internals)]` to the crate attributes to enable

Структура реализации

Я структурировал реализацию макроса DeriveSql в:

  • lib.rs, который содержит документацию и функцию входа derive_sql;
  • struct ImplDerive в файле implderive.rs, который обеспечивает генерацию кода.

Генерация кода выполняется с помощью метода generate, показанного ниже. Он поддерживается отдельными методами, каждый из которых соответствует реализуемому методу. Например, метод под названием impl_create_table генерирует код для метода create_table.

impl<'a> ImplDerive<'a> {
pub fn generate(&'a self) -> Result<proc_macro2::TokenStream, Box<dyn std::error::Error>> {
self.validate()?;
let create_table = self.impl_create_table()?;
let select = self.impl_select()?;
let insert = self.impl_insert()?;
let update_to = self.impl_update_to()?;
let delete = self.impl_delete()?;
let r = quote::quote! {
#create_table
#select
#insert
#update_to
#delete
};
Ok(r)
}
}

В туториале по Rust, вероятно, лучше объяснено, как работает процедурный макрос. Я лишь попробую изложить собственное понимание этого процесса своими словами.

Процедурный макрос пишет код, который компилируется на основе кода, к которому применяется процедурный макрос. В случае производного макроса процедурный макрос применяется к struct, enum или union. Код, считываемый процедурным макросом, предоставляется в виде TokenStream  —  в соответствии с названием, это поток токенов.

TokenStream парсится с помощью библиотеки syn для создания в сценарии производного макроса пригодной для использования структуры данных DeriveInput, представляющей типы struct (enum или union), к которым применяется процедурный макрос, т. е. тип struct, которому предшествует #[derive(...)].

Используя информацию, имеющуюся в структуре данных DeriveInput, процедурный макрос должен создать поток токенов, представляющих сгенерированный код. Для этого используется крейт quote.

Моей задачей было (и остается) понять и правильно использовать структуру данных DeriveInput и макрос quote::quote!.

В моей реализации ImplDerive struct хранит ссылку на объект DeriveInput, созданный из входного TokenStream, как показано ниже:

pub struct ImplDerive<'a> {
pub ast: &'a syn::DeriveInput,
}

В этой статье я рассматриваю реализацию двух методов: create_table, поскольку он относительно прост, и insert, поскольку он немного сложнее. Реализацию остальных методов можно посмотреть на GitHub.

Реализация create_table

Для примера с использованием Contact реализация метода create_table будет выглядеть следующим образом:

pub fn create_table(conn: &rusqlite::Connection) -> Result<(), Box<dyn std::error::Error>> {
conn.execute("CREATE TABLE Contact ( name TEXT, phone_number TEXT, email TEXT )", ())?;
Ok(())
}

Следующий код показывает процедурный макрос, генерирующий реализацию метода create_table. Для простоты объяснения были расширены два вызова методов self.name() и self.get_fields_named().

/*
* вывод реализации функции "create_table"
* pub fn create_table(conn: &Connection) -> Result<(), Box<dyn Error>>
*/
fn impl_create_table(&'a self) -> Result<proc_macro2::TokenStream, Box<dyn std::error::Error>> {
// let name = self.name();
let name = &self.ast.ident;

// let fields_statement = self.get_fields_named()
let fields = if let syn::Data::Struct(syn::DataStruct { fields, .. }) = &self.ast.data {
Some(fields)
} else {
None
};
let fields_named = if let Some(syn::Fields::Named(fields_named)) = fields {
Some(fields_named)
} else {
None
};
let fields_statement = fields_named
.ok_or("Unable to retrieve FieldsNamed")?
.named
.iter()
.fold(String::default(),
|statement, field| {
let sql_type = SqlType::from_type(&field.ty);
if matches!(sql_type, SqlType::Unsupported) {
statement
} else if statement.is_empty() {
// format!("{} {} PRIMARY KEY", field.ident.as_ref().unwrap(), SqlType::to_string(&sql_type))
format!("{} {}", field.ident.as_ref().unwrap(), SqlType::to_string(&sql_type))
} else {
format!("{}, {} {}", statement, field.ident.as_ref().unwrap(), SqlType::to_string(&sql_type))
}
});

let create_table_statement = format!("CREATE TABLE {} ( {} )", self.get_table_name(), fields_statement);

let r = quote::quote! {
impl #name {
pub fn create_table(conn: &rusqlite::Connection) -> Result<(), Box<dyn std::error::Error>> {
conn.execute(#create_table_statement, ())?;
Ok(())
}
}
};
Ok(r)
}

Реализация разбита на два раздела: 

  • подготовка информации и операторов (строки 6 и 37);
  • генерация потока токенов с помощью макроса quote! (строки 39-45).

Понимание того, как генерируются потоки токенов, на мой взгляд, является ключевым. quote! интерпретирует код Rust, используя поток токенов и, следовательно, идентификаторы токенов. В приведенном выше коде строка 49, #name, ссылается на содержимое переменной name, определенной в строке 7 (это имя struct), к которой применяется производный макрос, а именно Contact в примере.

Читая вышеизложенное, я могу предположить (ошибочно), что Contact можно изменить на MyContact, написав impl My#name. К сожалению, это так не работает. Чтобы сделать такое изменение, нужно определить новый Ident, используя нечто подобное syn::Ident::new(format!("My{}", name), name.span())

Сложность в генерации метода create_table заключается в получении членов struct, к которому применяется производный макрос. Эти члены используются для генерации SQL-оператора, который создает таблицу. Это достигается путем:

  • проверки того, что макрос применяется к struct данных (строки 10–14) и что поля struct являются именованными полями (строки 15–19);
  • построения оператора запроса SQL на основе списка именованных полей с использованием их имени и преобразования их типа в SQL-типы (строки 24–25).

Реализация select

Для нашего примера, использующего Contact, реализация метода select будет выглядеть следующим образом:

pub fn select(conn: &rusqlite::Connection) -> Result<Vec<Contact>, Box<dyn std::error::Error>> {
let mut s = conn.prepare("SELECT name, phone_number, email FROM Contact")?;
let i = s.query_map([],
|r| Ok(Contact {
name: r.get(0)?,
phone_number: r.get(1)?,
email: r.get(2)?,
})
)?;
let r = i.collect::<Result<Vec<Contact>, rusqlite::Error>>()?;
Ok(r)
}

Следующий код показывает процедурный макрос, генерирующий реализацию метода select:

/*
* вывод реализации
* pub fn select(conn: &Connection) -> Result<Vec<Self>, Box<dyn Error>>
*/
fn impl_select(&'a self) -> Result<proc_macro2::TokenStream, Box<dyn std::error::Error>> {
// let name = self.name();
let name = &self.ast.ident;

// let fields_statement = self.get_fields_named()
let fields = if let syn::Data::Struct(syn::DataStruct { fields, .. }) = &self.ast.data {
Some(fields)
} else {
None
};
let fields_named = if let Some(syn::Fields::Named(fields_named)) = fields {
Some(fields_named)
} else {
None
};

let fields_named = &fields_named.ok_or("Unable to retrieve fields named")?.named;

let statement = format!("SELECT {} FROM {}",
fields_named.iter()
.map(|f| format!("{}", f.ident.as_ref().unwrap()))
.fold(String::default(), |a, f| if a.is_empty() { f } else { a + ", " + f.as_str() }),
self.get_table_name());
let fields: Vec<&syn::Ident> = fields_named.iter()
.map(|f| f.ident.as_ref().unwrap())
.collect();
let fields_assignment: Vec<proc_macro2::TokenStream> = fields_named.iter().enumerate()
.map(|(i, _)| quote::quote! { r.get(#i)? } )
.collect();

let q = quote::quote! {
impl #name {
pub fn select(conn: &rusqlite::Connection) -> Result<Vec<#name>, Box<dyn std::error::Error>> {
let mut s = conn.prepare(#statement)?;
let i = s.query_map([], |r| Ok( #name { #( #fields : #fields_assignment ),* } ) )?;
let r = i.collect::<Result<Vec<#name>, rusqlite::Error>>()?;
Ok(r)
}
}
};
Ok(q)
}

Основное различие между select и функцией create_table лежит в строке 39. Строка 39 генерирует объект Contact из полей, возвращенных SQL-оператором. В результате #( #fields: #fields_assignment ), * происходит расширение двух векторов: fields и fields_assignement с разделением символов ,. В результате получается оператор name: r.get(0)?, phone_number: r.get(1)?. Это объясняется в разделе Interpolation (Интерполяция) из документации по крейту quote.

Вектор fields  —  это вектор, содержащий именованный идентификатор поля, а именно name, phone_number и т. д. в виде Ident.

Вектор fields_assignment содержит сторону назначения. Возвращаясь к разделу об устранении неполадок, замечу, что мой первоначальный подход заключался в использовании вектора строк. Но, как упоминалось в предыдущем разделе, макрос quote! работает с потоком токенов, и поэтому каждая строка в векторе строк рассматривается как строковый токен.

В строке 32 приведенного выше кода показан избранный подход, при котором вектор fields_assignment является вектором потоков токенов. Каждый из них сгенерирован с помощью макроса quote!. Потребовалось некоторое время, чтобы понять этот принцип, но в конце концов у меня получилось.


Это был очень краткий обзор моего пути в разработке процедурных макросов. Надеюсь, он будет полезен тем, кто интересуется подобными экспериментами. Несмотря на то, что, на первый взгляд, создание относительно простого кода требует много работы, преимущество этого кода, на мой взгляд, заключается в том, что его нужно написать только один раз, а затем можно применять где угодно.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Julien de Charentenay: Develop a Rust Macro To Automatically Write SQL Boilerplate Code

Предыдущая статья8 советов по разработке на JavaScript, которые освободят вас от переработок
Следующая статьяКак создать бота в стиле Alexa и Siri с помощью Python и OpenAI