Как стать автором
Обновить

Пишем на Rust расширение для SQLite, чтобы научить его работать с файлами Excel

Время на прочтение11 мин
Количество просмотров7K
Автор оригинала: Sergey Khabibullin

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

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

В статье будет некоторое количество небезопасного (unsafe) кода. Для SQLite уже существуют обертки на Rust, которые сводят к минимуму количество необходимого небезопасного кода, но мы не сможем их использовать, т.к. они не предоставляют весь объем необходимых возможностей, которые нам понадобятся. К тому же мы будем использовать unsafe только в местах, где требуется "склейка" Rust и сишных API SQLite. В проекте используется rust-bindgen, который позволит не писать вручную FFI-код, и возьмет на себя генерацию Rust кода из заголовочных файлов SQLite.

Я не буду останавливаться на том, как происходит работа с Excel-файлами - это мы доверим замечательной библиотеке под названием Calamine. Для желающих ознакомиться с этой библиотекой, в репозитории есть документация и некоторое количество примеров.

По очевидным причинам невозможно разобрать каждую строчку кода в этой статье, но весь код доступен на GitHub.

Постановка задачи

Давайте представим, что у нас есть некоторые данные в Excel, и нам хочется обработать их используя SQL. Задача понятная и не уникальная: совершенно точно существуют методы импорта CSV-фалов в SQLite, а сам CSV может быть получен из XLS стандартными средствами экспотра из Excel.

Есть и другой метод работы с такими данными - виртуальные таблицы. Определение виртуальных таблиц на сайте sqlite.org гласит: "это объекты, которые выглядят подобно любым другим таблицам или представлениям, но на самом деле не читают и не пишут данные стандартными средствами SQLite в файл базы данных. Вместо этого, виртуальные таблицы испозуют механизм обратных вызовов, чтобы работать с данными". Другими словами, виртуальные таблицы позволяют абстрагировать некий источник данных таким образом, чтобы для конечного потребителя данные выглядели как обычная таблица.

Если это выглядит как таблица, плавает как таблица и крякает как таблица, то это, вероятно, и есть таблица.

В SQLite уже существует расширение, которое позволяет работать с CSV-файлами как с виртуальными таблицами. Давайте посмотрим на типичный пример использования:

CREATE VIRTUAL TABLE csv_data USING csv(
    FILENAME='/path/to/file.csv'
);

SELECT * FROM csv_data;

Довольно простой и понятный пример. Создаем виртуальную таблицу на основе файла, затем используем ее будто это обычная таблица.

Что, если мы реализуем что-то подобное для таблиц Excel? Как по мне, довольно интересная задача - давайте приступим.

Структура проекта

SQLite может динамически загружать расширения из библиотек. Чтобы расширение могло быть загруженно, оно должно объявить точку входа в виде функции со специальным именем. Давайте создадим проект Rust, назовем его xlite, и укажем, что проект должен компилироваться в динамическую библиотеку.

[package]
name = "xlite"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

Если запустить сборку этого проекта, то результатом ее выполнения будет файл с одним из следующих имен: libxlite.so, libxlite.dylib, xlite.dll. Это важно, т.к. от имени файла будет зависеть то, какую функцию будет искать SQLite в качестве точки входа.

Разные языки имеют разные библиотеки для работы с SQLite, и они по-разному загружают расширения. Для простоты мы будем использовать интерфейс командной строки для SQLite - давайте попробуем загрузить расширение (нужна скомпилированная библиотека в текущей директории).

sqlite3
> .load 'libxlite'

Это команда заставит SQLite искать в файловой системе библиотеку с именем libxlite.so (или эквивалент для вашей ОС). Затем будет сделана попытка найти функцию с именем sqlite3_xlite_init, чтобы инициализировать расширение. Думаю, читатель уже приметил паттерн наименования точки входа: sqlite3_{EXTENSION_NAME}_init.

Если вы попытались выполнить эту команду, то, вероятно, знаете, что она завершится с ошибкой прямо сейчас. Это не удивительно - мы еще не определили входную точку в нашей библиотеке. Мы начнем работу над ней прямо сейчас, но, прежде, мы должны сгенерировать код Rust из заголовочного файла для того, чтобы пользоваться структурами данных и функциями из самой SQLite. Нам с этим поможет rust-bindgen, но мы не будем давать инструкцию к нему в этой статье - у проекта есть замечательная документация. С этого момента статья предполагает что из файла sqlite3.h был сгенерирован соответствующий файл на Rust. Далее мы будем часто использовать определения из этого файла.

Входная точка и модули

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

#[no_mangle]
pub unsafe extern "C" fn sqlite3_xlite_init(
   db: *mut sqlite3,
   pz_err_msg: *mut *mut c_char,
   p_api: *mut sqlite3_api_routines,
) -> c_int

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

Нам нужно создать модуль для того, чтобы можно было создавать виртуальные таблицы, но перед тем, как мы займемся этим, необходимо написать код, который требуется по протоколу расширений SQLite. Пояснение: в C, перед тем, как расширение может выполнить полезную работу оно обязано вызвать макрос под названием SQLITE_EXTENSION_INIT2. Понятное дело, что вызвать этот макрос из Rust не получится, поэтому мы напишем код, который возпроизводит поведение макроса.

Первое, что нужно сделать - объявить глобальную переменную, которая будет иметь тип указателя на sqlite3_api_routines:

#[no_mangle]
static mut sqlite3_api: *mut sqlite3_api_routines = std::ptr::null_mut();

Далее, внутри входной точки, перед тем, как делать что-либо еще, мы инициализируем глобальную переменную значением, которое было нам передено:

// inside sqlite3_xlite_init
sqlite3_api = p_api;

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

Теперь мы можем зарегистрировать наш модуль.

Давайте вспомним виртуальные таблицы CSV - мы адаптируем тот же подход для нашего расширения, и выделим важные аспекты.

CREATE VIRTUAL TABLE xlsx_data USING xlite(
    FILENAME='/path/to/file.xlsx'
);

Синтаксис CREATE VIRTUAL TABLE ... USING xlite говорит SQLite о том, что данная таблица должна быть создана с использованием модуля. Еще один элемент синтаксиса - параметр FILENAME, который будет передан аргументом в функцию создания виртуальной таблицы (об этом ниже). Самое важное, что требуется здесь понять - виртуальные таблицы управляются модулями.

Теперь давайте создадим сам модуль. Один из способов - объявить структуру, которая инкапсулирует данные модуля. Нам не обязательно объявлять эту структуру, т.к. наш модуль по сути не содержит дополнительных данных по отношению к стандартной струтуре sqlite3_module. Но мы это сделаем из соображений единообразия, и чтобы держать имя модуля вместе с остальными данными.

#[repr(C)]
pub struct Module {
   // must be at the beginning
   base: sqlite3_module,
   name: &'static [u8],
}

Один аспект этого требует отдельного пояснения: базовая структура sqlite3_module должна быть объявлена первым полем нашей структуры. Это нужно, чтобы мы могли преобразовывать указатели от базового типа к производному и наоборот. Дело в том, что при такой компановке память под структуры этих типов будет выделена одинаковым образом, исключая только данные в конце (в этом случае - имя), так что SQLite не узнает разницы, когда мы передадим экземпляр нашей структуры вместо оригинальной. Мы еще не раз применим эту технику.

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

pub const XLITE_MODULE: Module = Module {
   base: sqlite3_module {
       iVersion: 0,
       xCreate: Some(x_create),
       xConnect: Some(x_connect),
       xBestIndex: Some(x_best_index),
       xDisconnect: Some(x_disconnect),
       xDestroy: Some(x_destroy),
       xOpen: Some(x_open),
       xClose: Some(x_close),
       xFilter: Some(x_filter),
       xNext: Some(x_next),
       xEof: Some(x_eof),
       xColumn: Some(x_column),
       xRowid: Some(x_rowid),
       xUpdate: None,
       xBegin: None,
       xSync: None,
       xCommit: None,
       xRollback: None,
       xFindFunction: None,
       xRename: None,
       xSavepoint: None,
       xRelease: None,
       xRollbackTo: None,
       xShadowName: None,
   },
   name: b"xlite\0",
};

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

x_create

Функция вызывается, когда SQLite решает создать новый экземпляр виртуальной таблицы. Эта функция должна вызвать рутины, которые объявят таблицу и ее структуру

x_connect

Для наших целей то же самое, что x_create, т.к. наше расширение будет поддерживать только чтение

x_destroy

Функция будет вызвана, когда SQLite удаляет таблицу. Место, где должны быть высвобождены ресурсы под таблицу

x_disconnect

Для наших целей то же, что x_destroy

x_open

Открывает курсор для чтения данных из виртуальной таблицы

x_close

Закрывает курсор для чтения данных из виртуальной таблицы

x_next

Передвигает курсор на следующую строку

x_eof

Проверяет вышел ли курсор за пределы данных доступных для чтения

x_column

Возвращает данные для колонки N в текущей строке

x_rowid

Возвращает стабильный и уникальный идентификатор текущей строки

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

Теперь, мы объявили и описали модуль - давайте вызовем рутину create_module чтобы его зарегистрировать:

let name = XLITE_MODULE.name;
((*p_api).create_module.unwrap())(
   db,
   name.as_ptr() as *const c_char,
   &XLITE_MODULE as *const Module as *const sqlite3_module,
   std::ptr::null_mut(),
);

Этот код преобразует ссылку на модуль в ссылку на базовую структуру, и передает ее в рутину, чтобы зарегистрировать модуль в SQLite.

Виртуальная таблица

Для начала, давайте обобщим то, что мы узнали о модулях, и соединим это знание с жизненным циклом виртуальных таблиц. Мы напишем серию SQL-выражений и соответствующих им функций на псевдокоде. В этом примере мы посмотрим на таблицу, у которой есть 2 строки и 2 столбца:

CREATE VIRTUAL TABLE... 
-- x_create() -> VirtualTable
SELECT * FROM...
-- x_open(VirtualTable) -> VirtualCursor

-- x_eof(VirtualCursor) -> false
-- x_column(VirtualCursor, 1) -> [0,0]
-- x_column(VirtualCursor, 2) -> [0,1]
-- x_next(VirtualCursor)
-- x_eof(VirtualCursor) -> false
-- x_column(VirtualCursor, 1) -> [1,0]
-- x_column(VirtualCursor, 2) -> [1,1]
-- x_next(VirtualCursor)
-- x_eof(VirtualCursor) -> true

-- x_close(VirtualCursor)
DROP TABLE...
-- x_destroy(VirtualTable) 

Код выше можно обобщить так: модуль создает виртуальную таблицу, используя агрументы из синтаксиса CREATE VIRTUAL TABLE, запрос SELECT дает команду модулю, чтобы тот создал для ранее упомянутой таблицы курсор, в зоне ответственности которого находится чтение данных строка за строкой.

Что касается реализации виртуальных таблиц, мы должны определить две функции: x_create и x_destroy. SQLite еще дает возможность определить x_connect и x_disconnect, но мы не воспользуемся этой возможностью, т.к. нам не пригодится пока продвинутый сценарий, при котором существует некоторое хранилище данных, которое должно быть инициализированно только один раз, и, затем, переиспользовано.

Реализацию виртуальной таблицы начнем с объявления структуры:

#[repr(C)]
pub struct VirtualTable {
   // must be at the beginning
   base: sqlite3_vtab,
   manager: Arc<Mutex<DataManager>>,
}

Это определение должно напоминать нам структуру, которую мы написали для модуля. Базовая струтура снова объвлена первым полем производной структуры, а в конце мы добавили ссылку на некоторую сущность DataManager, которая реализует непосредственно чтение данных из Excel-файлов.

Теперь давайте посмотрим на функцию x_create :

#[no_mangle]
unsafe extern "C" fn x_create(
   db: *mut sqlite3,
   _p_aux: *mut c_void,
   argc: c_int,
   argv: *const *const c_char,
   pp_vtab: *mut *mut sqlite3_vtab,
   pz_err: *mut *mut c_char,
) -> c_int

Разберем важные аргументы этой функции.

Когда виртуальная таблица создается, некоторое количество аргументов может быть передано для конфигурации (таковым является аргумент FILENAME). Эти данные передаются в виде двух параметров: argc - количество аргументов, argv - массив строк, содержащих сами аргументы.

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

let sql = String::new("CREATE TABLE sheet(A,B,C)");
let cstr = CString::new(sql).unwrap();
((*api).declare_vtab.unwrap())(db, cstr.as_ptr() as _)

На самом деле, это упрощенная версия того, что в реальности происходит при создании таблицы. В проекте используется код, который выполняет CREATE TABLE с количеством колонок, соответствующим количеству колонок в Excel-файле. Но в данном контексте нам всего лишь важно понять, что необходимо выполнить рутину declare_vtab, передав ей указатель на строку с SQL.

Теперь можно создать экземпляр VirtualTable:

let p_new: Box<VirtualTable> = Box::new(VirtualTable {
   base: sqlite3_vtab {
       pModule: std::ptr::null_mut(),
       nRef: 0,
       zErrMsg: std::ptr::null_mut(),
   },
   manager: Arc::new(Mutex::new(manager)),
});

*pp_vtab = Box::into_raw(p_new) as *mut sqlite3_vtab;

Этот код выделяет место в куче для виртуальной таблицы. Чтобы предотвратить автоматическое высвобождение памяти, используется метод into_raw, он делает из Box указатель, и возлагает на нас ответственность за управление памятью. Результат преобразуется к указателю на базовую структуру sqlite3_vtab.

В этот момент можно считать, что мы сделали всё, что нужно было сделать в x_create. Теперь необходимо обратить внимание на x_destroy.

#[no_mangle]
unsafe extern "C" fn x_destroy(p_vtab: *mut sqlite3_vtab) -> c_int

Выглядит достаточно просто. И сама реализация будет тоже несложной:

let table = Box::from_raw(p_vtab as *mut VirtualTable);
drop(table);

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

Курсор

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

Определим курсор следующим образом:

#[repr(C)]
struct VirtualCursor {
   // must be at the beginning
   base: sqlite3_vtab_cursor,
   reader: Arc<Mutex<DataReader>>,
}

Мы снова опустим определение DataReader - это сущность, которая отвечает непосредственно за чтение данных из Excel. Сосредоточимся на курсоре.

Как всегда, есть место, где создается экземпляр структуры. Это место - x_open:

#[no_mangle]
unsafe extern "C" fn x_open(
   p_vtab: *mut sqlite3_vtab,
   pp_cursor: *mut *mut sqlite3_vtab_cursor,
) -> c_int

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

Давайте посмотрим теперь на определение остальных функций относящихся к курсору.

#[no_mangle]
unsafe extern "C" fn x_close(p_cursor: *mut sqlite3_vtab_cursor) -> c_int

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

#[no_mangle]
unsafe extern "C" fn x_next(p_cursor: *mut sqlite3_vtab_cursor) -> c_int

Эта функция получает экземпляр курсора, и мутирует его передвигая указатель на следующую строку.

#[no_mangle]
unsafe extern "C" fn x_eof(p_cursor: *mut sqlite3_vtab_cursor) -> c_int

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

#[no_mangle]
unsafe extern "C" fn x_column(
   p_cursor: *mut sqlite3_vtab_cursor,
   p_context: *mut sqlite3_context,
   column: c_int,
) -> c_int

Наиболее интересная функция, которая касается взаимодействия с курсором. Её работа заключается в том, чтобы вернуть данные для колонки N текущей строки курсора. Остановимся немного подробнее на этой функции.

SQLite определяет несколько рутин для того, чтобы вернуть данные: result_text, result_int, result_double, result_null и result_blob. По названию можно догадаться, что разница между ними заключается в том, какой тип данных мы хотим вернуть.

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

let cstr = CString::new(s.as_bytes()).unwrap();
let len = cstr.as_bytes().len();
let raw = cstr.into_raw();

unsafe extern "C" fn destructor(raw: *mut c_void) {
   drop(CString::from_raw(raw as *mut c_char));
}

((*api).result_text.unwrap())(
   p_context,
   raw,
   len as c_int,
   Some(destructor),
);

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

Заключение

В этой статье я попытался изложить основные шаги для создания расширения SQLite на Rust.

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

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

Теги:
Хабы:
Всего голосов 23: ↑23 и ↓0+23
Комментарии0

Публикации

Истории

Работа

Rust разработчик
9 вакансий
Программист С
32 вакансии

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань