
Серия: redb ecosystem (инженерный разбор после анонса 3.2.1)
О чём это
рекомендую перед прочтением кратко просмотреть redb.ru
Когда вышел SQLite-провайдер 3.2.1, анонс был на пару абзацев: «тот же LINQ, одна строка в DI». Эта статья — противоположность анонса. Здесь не «что вышло», а как оно устроено и где у нас потекло. Конкретно: как движок запросов redb переехал в нативное C-расширение там, где у базы нет хранимок; как мы храним DateTimeOffset в базе, у которой нет типа «дата»; и три бага из этого релиза, разобранные с фильтр-JSON, сгенерированным SQL и фиксом.
Это длинно и с кодом. Если хочется коротко — читайте анонс по ссылке выше. Если интересно, что под капотом «одной строки в DI», — устраивайтесь.
Контекст для тех, кто про redb впервые (дальше предполагается, что вы это представляете):
redb — типизированное хранилище для .NET поверх Postgres/MSSQL: без миграций, без Include, с полным LINQ — что это и зачем.
REDB изнутри, статья 1: 13 таблиц, на которых работает всё — модель хранения. Критично для этой статьи: SQLite-провайдеру её пришлось воспроизвести один-в-один.
REDB: индексы, или почему на любую схему — это быстро — индексы.
SQLite-провайдер для RedBase — анонс 3.2.1 — короткая версия того, что здесь разбирается вглубь.
И сразу два дисклеймера, чтобы потом не спотыкаться.
Первый — про термин. Да, у redb «гибкая» модель: класс раскладывается по строкам таблицы значений. Нет, это не EAV в том смысле, в каком это слово бросают как ругательство. В базе redb лежит RTTI — настоящая информация о типах: схемы, структуры, типы полей, связи. БД знает, что EmployeeProps.HireDate — это DateTime, что Contacts — массив объектов, а CurrentProject — ссылка на другую схему. Это рантайм-система типов на уровне хранилища, а не «ключ-значение, разбирайся сам». Ниже будет видно, почему без этого ни материализатор, ни компилятор запросов физически не собрались бы — им на каждом шаге нужно знать тип того, что они материализуют или фильтруют.
Второй — про область применения, чтобы не было разочарований. redb — для сложных бизнес-классов: графы объектов, вложенность, ссылки между схемами, деревья, словари, массивы объектов. Складывать в него плоские потоковые данные — поток координат, телеметрию датчиков, метрики тысячами в секунду — технически можно, но это антипаттерн. Под такое есть time-series и колоночные базы; redb платит хранением и типизацией ровно там, где данные богатые и связные, а не там, где одна табличка (timestamp, value) на миллиарды строк. Коротко: redb силён там, где есть настоящая доменная модель.
Часть 0. Развилка, которая определяет всё остальное
У SQLite нет процедурного языка. Ни PL/pgSQL, ни T-SQL — ничего, во что можно положить серверную логику. А «бесплатный» тир redb на Postgres и MSSql устроен именно так: тяжёлая машинерия (компилятор запросов, материализатор, soft-delete, view прав) живёт внутри БД как серверные функции. Это и есть граница Free/Pro: где материализуется JSON и кто генерит SQL.
Из «нет хранимок» следуют ровно два пути, и мы пошли обоими — на два разных тира:
Pro — чистый C#. SQL запроса генерит ProSqlBuilder в коде, props материализуются в коде, ноль вызовов функций БД. Следствие: работает везде, где есть Microsoft.Data.Sqlite — включая Blazor WebAssembly и мобилки (MAUI/iOS/Android), где нативное расширение SQLite загрузить нельзя в принципе.
Free — нативное C-расширение. Тот же подход, что на Postgres/MSSql: движок живёт в базе. На SQLite «в базе» означает загружаемое расширение на C (redb.dll / .so / .dylib) поверх sqlite3ext.h. Работает там, где грузится нативный код: десктоп, сервер, CI — и это же дёрнет не-.NET хост (Python, sqlite3 CLI), если захочет говорить с базой redb напрямую.
Дальше — обе истории по-крупному. Сначала нативка (она интереснее), потом дата (она важнее), потом баги, потом грабли.
Часть 0.5. Что именно воспроизводит провайдер: objects и values
Прежде чем нырять в нативку — что она вообще читает и пишет. Модель redb — это ~13 таблиц (подробный разбор — в статье «13 таблиц»), но для SQLite-истории несущих две, и обе провайдеру пришлось воссоздать колонка-в-колонку.
_objects — «шапка» каждого объекта: идентичность, дерево, владение, даты, ссылка на схему (то есть на тип):
CREATE TABLE _objects( _id INTEGER NOT NULL PRIMARY KEY, _id_parent INTEGER NULL, -- дерево (родитель) _id_scheme INTEGER NOT NULL, -- RTTI: какого КЛАССА объект _id_owner INTEGER NOT NULL, _id_who_change INTEGER NOT NULL, _date_create REAL NOT NULL DEFAULT (julianday('now')), -- UTC Julian day (REAL) _date_modify REAL NOT NULL DEFAULT (julianday('now')), -- UTC Julian day (REAL) _name TEXT NULL, _hash TEXT NULL, -- хэш состава пропсов (для дельты/кэша) -- слоты для RedbPrimitive<T> (когда Props — это сам примитив, без вложенной структуры) _value_long INTEGER NULL, _value_string TEXT NULL, _value_bool INTEGER NULL, -- bool = 0/1 _value_double REAL NULL, _value_numeric REAL NULL, -- NUMERIC(38,18): REAL по умолчанию ... );
_values — построчное хранилище свойств. Одна строка на (объект, структура, [индекс массива]). Ключевая идея — типизированные колонки-слоты: значение лежит не в одной «универсальной» текстовой колонке, а в колонке своего типа:
CREATE TABLE _values( _id INTEGER NOT NULL PRIMARY KEY, _id_structure INTEGER NOT NULL, -- RTTI: КАКОЕ это поле _id_object INTEGER NOT NULL, _String TEXT NULL, _Long INTEGER NULL, _Guid TEXT NULL, _Double REAL NULL, _DateTimeOffset REAL NULL, -- DateTime/DateTimeOffset/DateOnly как UTC Julian _Boolean INTEGER NULL, -- 0/1 _ByteArray BLOB NULL, _Numeric REAL NULL, _ListItem INTEGER NULL, _Object INTEGER NULL, -- ссылка на другой объект _array_parent_id INTEGER NULL, -- массивы/словари — реляционно _array_index INTEGER NULL );
Два следствия, на которых держится всё остальное:
Типизированные слоты, а не «всё строкой».
Boolean— это0/1,DateTimeOffset— REAL Julian,Long— целое. Поэтому сравнения и сортировки в SQL идут по нативному типу колонки (и индексируются), а не через строковый каст. Это и есть «не EAV»:values— типизированное props-хранилище, аidstructure→_structuresнесёт RTTI о том, какое это поле и какого оно типа. Компилятору запросов на каждом шаге нужно знать тип поля — чтобы выбрать колонку-слот дляMAX(...) FILTERв пивоте; без RTTI он бы не знал, из какой колонки доставатьLastName.Массивы и словари — реляционно, через
arrayparent_id/_array_index, а не JSON-блобом. Поэтому материализатор собирает ихGROUP BY-ом по индексу, а компилятор умеет по ним фильтровать (_array_index IS NULLв пивоте как раз отсекает скалярную строку поля от его массивных элементов).
Дальше «движок в базе» = функции, которые читают/пишут ровно эти две таблицы, сверяясь с метаданными из schemes/structures.
Часть 1. Нативное расширение: анатомия
Точка входа
Загружаемое расширение SQLite — это .so/.dll/.dylib с одной экспортируемой функцией-инициализатором. Имя по умолчанию выводится из basename файла: для redb.dll это sqlite3_redb_init. Внутри — регистрация всех наших SQL-функций:
SQLITE_EXTENSION_INIT1 int sqlite3_redb_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi){ SQLITE_EXTENSION_INIT2(pApi); int rc; rc = sqlite3_create_function(db, "get_object_json", 1, SQLITE_UTF8, 0, getObjectJsonFunc, 0, 0); if(rc != SQLITE_OK) return rc; rc = sqlite3_create_function(db, "get_object_json", 2, SQLITE_UTF8, 0, getObjectJsonFunc, 0, 0); // overload: (_id, max_depth) if(rc != SQLITE_OK) return rc; rc = sqlite3_create_function(db, "save_object_json", 1, SQLITE_UTF8, 0, saveObjectJsonFunc, 0, 0); if(rc != SQLITE_OK) return rc; rc = redbRegisterPvt(db); // весь pvt_*-компилятор return rc; }
SQLITE_EXTENSION_INIT1 / INIT2 — это макросы из sqlite3ext.h, которые подменяют прямые вызовы sqlite3_* на вызовы через таблицу указателей pApi. Нюанс, который легко проглядеть: после INIT2 всё API SQLite внутри расширения идёт через эту таблицу. Забыли INIT2 — расширение собирается, но падает на первом же вызове sqlite3_* с мусорным указателем.
Загрузка — на каждый коннект
Главная особенность загружаемых расширений: они не персистентны. SQLite забывает зарегистрированные функции при новом соединении. А Microsoft.Data.Sqlite пулит соединения. Значит грузить расширение надо на каждый коннект, после PRAGMA. У нас это делает обёртка над SqliteConnection: открыли → выставили foreign_keys=ON и busy_timeout → загрузили расширение → отдали в пул.
Путь к бинарнику ищется так:
Явный
SqliteDataSource.NativeExtensionPath(если выставлен в коде).Переменная окружения
REDB_SQLITE_EXTENSION.Иначе —
redb.{dll,so,dylib}изruntimes/<rid>/native/NuGet-пакета; в деве — проходом вверх по дереву каталогов доredb.SQLite/native/build.
Pro путь не ставит вообще: ему нативка не нужна, и в WASM/мобилке её и не было бы.
Доставка бинарника: почему он кросс-компилируется даром
Расширение — загружаемый модуль, и у этого есть неожиданный упаковочный бонус: оно не линкуется ни с чем. sqlite3ext.h отдаёт API таблицей указателей, которая резолвится у хоста в момент загрузки (это и делает SQLITE_EXTENSION_INIT2) — значит, нет ни libsqlite3, который надо искать, ни import-библиотеки, ни target-sysroot. Кросс-компиляция схлопывается до «укажи CMake кросс-компилятор»:
# linux-arm64 с x64-машины, в одноразовом контейнере — sysroot не нужен docker run --rm -v "$PWD:/work" debian:bookworm bash -c ' apt-get update && apt-get install -y cmake make gcc-aarch64-linux-gnu cd /work/redb.SQLite/native cmake -S . -B build-linux-arm64 -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc cmake --build build-linux-arm64' # → build-linux-arm64/redb.so: ELF 64-bit LSB shared object, ARM aarch64
Нативной библиотеке, которая линкуется с libsqlite3, понадобился бы arm64-билд этой библиотеки для линковки; этой — нет, потому что она дёргает SQLite только через таблицу указателей хоста. Один кросс-gcc — один валидный arm64-бинарь.
У доставки свой поворот. SQLite грузит расширение по явному пути (conn.LoadExtension(...)), а не как P/Invoke-нативку, которую хост резолвит из NuGet-кеша — поэтому файл должен физически лежать в output приложения. RID-таргет dotnet publish -r <rid> разворачивает runtimes/<rid>/native/ за вас; framework-dependent сборка (без RID) — нет, поэтому пакет везёт buildTransitive .targets, который копирует подходящий под ОС бинарь в output потребителя, с гейтом по Exists на каждый RID. Поэтому «добавить ещё платформу» = «собрать ещё один redb.{so,dylib} и положить в native/build-<rid>/» — csproj и .targets уже перечисляют все пять RID.
War story #5: тот самый .targets, что сломал всех потребителей (3.2.0 → 3.2.1)
И именно этот доставочный .targets в этом релизе пустил кровь — а раз у нас честный разбор, вот он. Мы выпустили 3.2.1 с баннер-комментарием в redb.SQLite.targets, где внутри <!-- … --> стояла строка из дефисов. XML-комментарий не может содержать --, поэтому MSBuild отказывался даже загрузить файл: любой потребитель, подтянувший пакет, получал
error MSB4024: An XML comment cannot contain '--', and '-' cannot be the last character.
и не собирался вообще. И это ехало транзитивно — redb.SQLite.Pro зависит от redb.SQLite, а buildTransitive-ассеты пробрасываются зависимым, так что Pro-пакет был сломан ровно так же.
Почему 200/200 тестов не поймали? Потому что buildTransitive импортируется только когда пакет потребляют как NuGet-пакет. Наше собственное решение ссылается на проекты через ProjectReference, а он импорт build/ пропускает целиком — поэтому битый файл лежал в каждой зелёной сборке невидимым, пока не случился первый настоящий dotnet add package redb.SQLite. Мы нашли это ровно в момент, когда собрали пакетного потребителя (сэмпл в публичном репо), ни секундой раньше.
Фикс — удаление одной строки (и да — я воспроизвёл ровно тот же баг в комментарии csproj, пока писал фикс; -- — упрямая маленькая мина). Перезаписать опубликованную версию нельзя, поэтому исправленные пакеты ушли как redb.SQLite / redb.SQLite.Pro 3.2.1, а битая пара 3.2.1 — unlisted. Так что точности ради: движок, работа с датами, баги выше — всё 3.2.1; два SQLite-NuGet-пакета — 3.2.1. Неприглядный урок: build/ и buildTransitive пакета — это тоже отгружаемый код, валидируйте его из пакетного потребителя в CI, потому что ProjectReference с радостью вам соврёт.
Что внутри: материализатор и компилятор
Внутри расширения две большие подсистемы.
get_object_json(_id [, max_depth]) — рекурсивный материализатор. Берёт id, читает values, собирает JSON-объект, который дальше десериализует System.Text.Json в типизированный RedbObject<TProps>. Собирает всё: base-поля из objects, скаляры, массивы и словари (реляционно, через array_index / arrayparent_id), вложенные Class-поля, ссылки на объекты, ListItem (включая ListItem, который сам несёт Object). Именно тут нужна RTTI: чтобы собрать вложенный объект, материализатору надо знать его схему, типы его полей и то, что вот это поле — массив, а вон то — ссылка. Без метаданных типов это была бы просто куча строк в _values.
pvt_*-компилятор — порт ~9 тысяч строк PL/pgSQL-логики в C как генератор SQL-строк: pvt_build_query_sql, pvt_build_aggregate_sql, pvt_build_groupby_sql, pvt_build_window_sql, pvt_build_projection_sql, pvt_build_array_groupby_sql. Конвейер такой:
LINQ-выражение → (C#) фасет-фильтр в JSON + список полей → (нативка) pvt_build_query_sql(scheme, filterJson, ...) → готовая строка SQL-SELECT → (C#) выполнили, материализовали через get_object_json
То есть нативка — это транслятор фильтр-JSON в SQL. C# не строит SQL сам (это сделал бы Pro), он строит фильтр-JSON и просит базу собрать SQL. Звучит наизнанку, но именно это даёт паритет: один и тот же фильтр-JSON и на Postgres, и на SQLite Free превращается в SQL одним и тем же движком — просто на разных диалектах.
Чуть глубже про get_object_json: рекурсия, массивы, ссылки
«Собирает JSON» звучит просто, пока не вспомнишь, что собирать надо граф. Материализатор обходит структуру схемы и для каждого поля решает, что это, по RTTI из _structures:
скаляр — берёт значение из соответствующей колонки-слота
_values(для даты — оборачивает вstrftime, чтобы наружу ушла ISO-строка);массив/словарь — собирает все строки с тем же
arrayparent_id, упорядочивая поarrayindex, в JSON-массив/объект (вот зачем в пивотеarrayindex IS NULL— отделить «скалярную» строку поля от его элементов);вложенный
Class— рекурсивно зовёт сборку поддерева;ссылка (
_Object) — поmax_depthлибо разворачивает целевой объект (рекурсия с уменьшением глубины), либо оставляет id-ссылку;ListItem— пункт справочника; причёмListItemсам может нестиObject, так что это ещё один уровень разворота.
Параметр max_depth (вторая перегрузка функции) — предохранитель от бесконечной рекурсии на циклических ссылках и одновременно бюджет на то, как глубоко тащить связанные объекты в один JSON. Без RTTI ни один из этих переходов невозможен: материализатор обязан знать, что вот это поле — массив, а вот это — ссылка, иначе на руках просто строки _values без смысла.
Обратная сторона: save_object_json — путь записи
У чтения есть симметричный близнец — save_object_json(json). Принимает JSON объекта, по схеме раскладывает его обратно в objects (base) и values (props). Две тонкости, специфичные для SQLite:
Даты на запись. В приходящем JSON даты — ISO-строки (так их сериализует System.Text.Json из
DateTimeOffset). Нативная запись оборачивает их вjulianday('<iso>'), чтобы вDateTimeOffsetлёг REAL Julian. Это зеркало read-sidestrftime— и ровно то, что чинилось в этом релизе под «saveobject_json тоже правь»: до фикса дата на запись уходила строкой и не сходилась с REAL-колонкой.Стратегия сохранения пропсов. На Free действует
PropsSaveStrategy.DeleteInsert: сохранение = удалить существующие строкиvaluesобъекта и вставить новый набор (ChangeTracking — дельта поhash— это Pro-фича). Просто, предсказуемо, без следящего слоя — за счёт лишних перезаписей; для embedded-нагрузки приемлемо.
Загрузить правильный тип: CLR-реестр и полиморфизм
get_object_json отдаёт JSON — но во что его десериализовать? Для LoadAsync<EmployeeProps>(id) ответ известен из дженерика. А для полиморфной загрузки (GetChildren дерева, где дети — объекты разных схем; LoadDynamicObject) тип на этапе компиляции неизвестен: нужно по idscheme объекта понять, в какой *Props-класс материализовать.
Это разворачивалось в этом же цикле в двухслойный CLR-реестр (чинили полиморфную LoadAsync, которая на ровном месте отдавала не тот тип):
глобальный индекс
имя схемы ↔ Type— самовосстанавливающийся: подписан наAppDomain.AssemblyLoad, так что подгрузка сборки с новыми*Propsподнимает «поколение» и индекс перестраивается лениво (никакого «зарегистрируйте все типы на старте»);пер-доменный кэш
scheme_id → Type— где «домен» =SHA256(sanitized connection string)или явныйCacheDomain. Партиционирование по домену нужно, потому что один процесс может держать несколько баз (в т.ч. несколько SQLite-файлов), иscheme_id=1000010в одной — это не тот же класс, что в другой.
К SQLite это привязано напрямую: материализатор отдаёт JSON + idscheme, а реестр превращает idscheme в Type для десериализации полиморфного потомка. Ошибись здесь — и дерево разнотипных детей материализуется в один (неверный) тип.
Словарь Postgres → SQLite
Порт — это не «перепечатать на C», это перевод диалекта. Самые частые замены (всё — реальные строки из SqliteDialect/нативки):
Postgres |
SQLite |
где |
|---|---|---|
|
|
агрегация массивов в пивоте |
|
|
IN-списки (SQLite не умеет массивы-параметры) |
|
|
компоненты даты |
|
|
регистронезависимый LIKE |
|
|
удаление по списку id |
|
|
DistinctBy (об этом ниже отдельно) |
Про ESCAPE '\' стоит сказать пару слов, потому что это классическая мина. PG по умолчанию использует \ как escape-символ в LIKE. SQLite (и MSSQL) — нет. А UserProviderBase экранирует пользовательский ввод обратным слешем (50% → 50\%), рассчитывая на PG-семантику. Если на SQLite не дописать ESCAPE '\', экранированный \% начинает матчить буквальный бэкслеш + что угодно — тихая порча поиска. Поэтому в SQLite-диалекте LIKE всегда идёт с явным ESCAPE '\'. Мелочь, на которой легко потерять полдня.
Минимальная версия и почему
SQLite 3.44.0+ (ноябрь 2023). Мы намеренно опираемся на FILTER (WHERE …), современные оконные функции, RETURNING, JSON1 и рекурсивные CTE. Цель — чтобы SQLite-SQL оставался структурно близок к Postgres-SQL, а не превращался в переписывание с нуля. Чем ближе диалекты, тем меньше мест, где они разъезжаются в поведении (а не в синтаксисе) — а именно поведенческие расхождения ловятся в проде, а не в компиляторе.
Идентификаторы: AUTOINCREMENT вместо sequences
В SQLite нет sequences. А redb нужны глобально-уникальные id, которые умеет раздавать и .NET-генератор, и нативка (для не-.NET хостов). Решение: нативная AUTOINCREMENT-таблица и sqlite_sequence как общий high-water mark. И C-расширение, и C#-генератор ключей двигают один и тот же счётчик и резервируют блоки id из него. В результате id уникальны независимо от того, кто их раздал — .NET или Python, пишущие в один файл.
Боевая байка №1: %% в sqlite3_mprintf
Чтобы «порт PL/pgSQL на C» не звучал стерильно — вот тип бага, который этот порт дарит бесплатно.
В материализаторе есть макрос со списком колонок VCOLS, который подставляется в формат-строку sqlite3_mprintf:
char *sql = sqlite3_mprintf("SELECT" VCOLS "FROM _values WHERE _id_structure=?1 " "AND _id_object=?2 AND %s LIMIT 1", cond);
Когда я переводил datetime-колонки на вывод через strftime('%Y-%m-%dT%H:%M:%fZ', _DateTimeOffset), я честно положил этот strftime прямо в VCOLS. И всё развалилось молча: объекты начали грузиться с пустыми properties, а base-даты — нормально.
Полчаса я смотрел не туда. Разгадка: VCOLS уходит в формат-строку sqlite3_mprintf, а %Y %m %d %H %M %f для mprintf — это спецификаторы формата. Он начал «съедать» аргумент cond на первом же %Y, дальше форматирование поехало, SQL получился битый, sqlite3_prepare_v2 молча вернул ошибку, функция пропсов вернула пусто. Base-даты при этом уцелели, потому что их SELECT идёт через prepare_v2 напрямую, без mprintf.
Лечится экранированием — %% (mprintf свернёт %%→% до того, как строку увидит SQLite):
// было: ... strftime('%Y-%m-%dT%H:%M:%fZ',_DateTimeOffset) ... // ломает mprintf // стало: #define VCOLS " _id,_String,_Long,_Guid,_Double, " \ "strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ',_DateTimeOffset), " \ "_Boolean,_ByteArray,_Numeric,_ListItem,_Object,_array_parent_id,_array_index "
Мораль на будущее: когда генерируешь SQL через printf-подобный форматтер, любая % в данных — мина. Особенно коварно, что падение тихое: prepare не кидает, он возвращает код ошибки, который легко проглядеть, и наружу это выходит как «почему-то пустые пропсы».
Часть 2. У SQLite нет типа «дата». Как мы с этим прожили релиз
Это центральная инженерная история выпуска, и на момент анонса её ещё не было.
Три класса хранения, и ни одного типа
В SQLite дата-время — это соглашение поверх трёх классов хранения, а не тип:
TEXT — ISO-8601 строкой (
'2024-06-15 13:45:30').REAL — Julian day, число с плавающей точкой (астрономический счёт дней).
INTEGER — Unix-эпоха в секундах.
DateTimeOffset из .NET сюда не ложится сам собой. Нужно выбрать представление и держаться его везде: на записи (биндер параметров), на чтении (материализатор + конвертеры скаляров), в сравнениях фильтра, в агрегатах. Один промах в любой из этих точек — и даты «как бы работают», но врут на границах.
Почему не TEXT (хотя так и начиналось)
Первая версия хранила даты строкой ISO. И сломалась ровно так, как ломается строковое сравнение дат.
datetime('now') в SQLite возвращает строку с пробелом между датой и временем: 2024-06-15 13:45:30. А литерал, который C#-слой подставляет в сравнение, приходит с T: 2024-06-15T13:45:30. Сравнение TEXT в SQLite — лексикографическое, побайтовое. И вот что происходит на позиции 10:
'2024-06-15 13:45:30' байт[10] = 0x20 (пробел) '2024-06-15T13:45:30' байт[10] = 0x54 ('T') 0x20 < 0x54 → строка с пробелом ВСЕГДА «меньше» строки с 'T'
То есть любое сравнение «хранимое (с пробелом) ⟷ литерал (с T)» уходит в одну сторону всегда, безотносительно реального времени. Диапазонные фильтры начали тихо врать. В кластере это однажды пометило живую ноду мёртвой: heartbeat-сравнение last_seen < cutoff было «всегда истинно», потому что хранимое last_seen (с пробелом) лексикографически меньше cutoff-литерала (с T). Нода жива, а мониторинг считает её трупом.
Можно было бы нормализовать сепаратор. Но это лечение симптома: TEXT-сравнение остаётся лексикографическим, и любой другой формат-дрейф (миллисекунды, таймзонный суффикс, ведущие нули) снова вскроет ту же дыру. Нужно было уйти от строк.
Решение: REAL Julian day, всё в UTC
Перешли на REAL Julian day, всё в UTC — ровно как Postgres держит timestamptz в UTC. Три причины, почему это не «ещё одно соглашение», а правильный выбор:
1. Родные функции SQLite едят Julian-число напрямую. julianday(), strftime(), datetime(), date() принимают REAL Julian как есть, без обёрток:
sqlite> SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 2460477.0732638887); 2024-06-15T13:45:30.000Z sqlite> SELECT datetime(2460477.0732638887); 2024-06-15 13:45:30 sqlite> SELECT julianday('2024-06-15T13:45:30Z'); 2460477.0732638887
То есть для вывода даты в JSON материализатор просто оборачивает колонку в strftime — и получает ISO, который System.Text.Json парсит штатно.
2. Сравнение становится численным. col < X по double — корректно и однозначно. Никакого лексикографического сюрприза, потому что сравниваются числа, а не байты.
3. И, что важно для прода, — sargable (дружит с индексом). Вот тонкость, ради которой всё и затевалось. Если обернуть колонку под функцию — julianday(col) < X — индекс по col умирает: оптимизатор не может использовать индекс по выражению от колонки. А вот сравнение сырой REAL-колонки с константой — col < julianday('2024-06-15') — индексируемо: слева голая колонка, справа константа. Поэтому в генерации SQL мы вешаем julianday(...) на сторону литерала, никогда на колонку:
-- НЕ так (убивает индекс по _date_create): WHERE julianday(o._date_create) >= julianday('2023-01-01') -- а так (sargable): WHERE o._date_create >= julianday('2023-01-01')
Колонка хранит Julian-число → её и сравниваем с Julian-числом, посчитанным из литерала на стороне константы.
Конвертация: без магии, на встроенном .NET
Перевод DateTime/DateTimeOffset ⟷ Julian — это арифметика на встроенных ToOADate/FromOADate. OLE Automation date (эпоха 1899-12-30) отличается от Julian day ровно на константу 2415018.5:
internal static class SqliteJulian { // Julian = OADate + 2415018.5. ToOADate/FromOADate встроены и lossless // в пределах точности double — той же, в которой живёт julianday() SQLite. private const double OADateToJulianOffset = 2415018.5; // DateTimeOffset → UTC Julian. .UtcDateTime ПРИМЕНЯЕТ смещение → истинный момент в UTC. public static double ToJulian(DateTimeOffset dto) => dto.UtcDateTime.ToOADate() + OADateToJulianOffset; // DateTime → UTC Julian. Clock-значение трактуется как UTC по контракту redb // (NormalizeForStorage ставит Kind=Utc без конвертации). ToOADate игнорит Kind — совпадает. public static double ToJulian(DateTime dt) => dt.ToOADate() + OADateToJulianOffset; // REAL Julian → DateTimeOffset(+00:00) public static DateTimeOffset FromJulian(double julian) { var utc = DateTime.SpecifyKind(DateTime.FromOADate(julian - OADateToJulianOffset), DateTimeKind.Utc); return new DateTimeOffset(utc, TimeSpan.Zero); } }
Где это втыкается: четыре точки
Чтобы даты не врали, REAL-Julian-представление надо соблюсти во всех точках, где значение пересекает границу C# ⟷ SQLite:
Запись — центральный биндер параметров. Все записи (и base-даты, и props) идут через одну точку — CreateCommand в SqliteRedbConnection. Там DateTimeOffset/DateTime превращаются в double:
switch (param) { case DateTimeOffset dto: // REAL Julian day (UTC) — родной формат SQLite-дат. sqliteParam.Value = SqliteJulian.ToJulian(dto); break; case DateTime dt2: sqliteParam.Value = SqliteJulian.ToJulian(dt2); break; // ... }
Чтение скаляров — ConvertScalar. Значение из SQLite приходит как double; для темпоральной цели конвертим обратно:
if (targetType == typeof(DateTimeOffset)) return (T)(object)(value is double jdo ? SqliteJulian.FromJulian(jdo) : value is DateTimeOffset d ? d : value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.Parse(value.ToString()!, ...));
Чтение строк — MapRow. Тот же double → DateTimeOffset/DateTime/DateOnly при маппинге колонок в свойства.
Нативка. get_object_json выводит дату через strftime(ISO, col) (см. байку про %%), а pvt-сравнения оборачивают литерал в julianday('<iso>').
Три CLR-типа на одной колонке
Колонка values.DateTimeOffset (REAL) обслуживает три CLR-типа:
DateTimeOffset— напрямую.DateTime— clock-значение как UTC (контракт redb).DateOnly— полночь UTC.
(TimeOnly/TimeSpan едут в _String.) Различает их RTTI в схеме: db-тип поля говорит материализатору, во что разворачивать double.
Таймзоны: почему +4 резолвится правильно
Частый вопрос из комментов: «я напишу в LINQ DateTimeOffset с зоной +04:00 — оно сравнится правильно с тем, что в базе?» Да, и вот по двум причинам:
C#-сторона:
ToJulian(DateTimeOffset)берётdto.UtcDateTime— а.UtcDateTimeприменяет смещение и даёт истинный UTC-момент. То есть+04:00сворачивается в UTC ещё до Julian.Нативная сторона: даже если в литерал просочился offset, сравнение оборачивается в
julianday('<iso-с-offset>'), аjulianday()сам парсит смещение:
sqlite> SELECT julianday('2026-06-25T20:00:00+04:00') = julianday('2026-06-25T16:00:00Z'); 1
Хранили UTC → сравниваем по UTC-моменту → всё сходится с любым входным смещением.
Боевая байка №2: аналитика и FormatException из ниоткуда
Самая каверзная часть datetime-истории.
Обычная загрузка объекта идёт через get_object_json — он отдаёт дату строкой ISO (через strftime), и C# её парсит штатно. Но аналитика — MinRedbAsync/MaxRedbAsync, AggregateRedbAsync, оконные, группировки — get_object_json минует. Она тащит дату-колонку прямо в SELECT и отдаёт сырое Julian-число в общий конвертер ядра. А конвертер ждал строку или DateTime. Результат — FormatException на ровном месте (а ещё веселее: elem.GetInt64() на дробном 2460477.07 — потому что код предполагал целочисленный Unix-timestamp).
Развилка фикса была идеологическая. Можно залатать SQLite-костылём прямо в ядре — но у redb.Core нет (и не должно быть) знаний про Julian: это деталь хранилища SQLite, а ядро обслуживает три диалекта. PG/MSSql отдают дату нормально, и тащить в общий конвертер слово «Julian» — это протечь деталью одного провайдера во все.
Сделали через нейтральную точку расширения. В ядре — опциональный хук «число → темпоральный тип», по умолчанию пустой:
// redb.Core: ядро НЕ знает слова "Julian". Только: "если ЧИСЛО метит в дату // и зарегистрирован декодер — спроси его". public static class TemporalDecoder { public static Func<double, Type, object?>? NumericDecoder; public static bool IsTemporal(Type t) => t == typeof(DateTime) || t == typeof(DateTimeOffset) || t == typeof(DateOnly); // Convert.ChangeType, который сначала даёт числу-в-дату пройти через декодер. public static object ChangeType(object value, Type targetType) => TryDecode(value, targetType, out var d) && d != null ? d : System.Convert.ChangeType(value, targetType); }
А регистрирует декодер сам SQLite-провайдер — в статическом конструкторе SqliteDataSource, который дёргается и для Free, и для Pro:
TemporalDecoder.NumericDecoder = static (julian, targetType) => { var dto = SqliteJulian.FromJulian(julian); if (targetType == typeof(DateTimeOffset)) return dto; if (targetType == typeof(DateOnly)) return DateOnly.FromDateTime(dto.UtcDateTime); return dto.UtcDateTime; // DateTime };
Дальше — два места в ядре, через которые сходится вся материализация аналитики (и для Free, и для Pro, потому что у Pro нет своего материализатора — он переиспользует конвертеры ядра):
JsonValueConverter— веткаNumber→ темпоральный тип (group-by, window, проекции).Обёртка
TemporalDecoder.ChangeTypeв скалярных точках (MinRedbAsync/MaxRedbAsync,AggregateResult.Get<T>).
PG/MSSql число для даты не отдают — у них NumericDecoder так и остаётся null, поведение не меняется ни на байт. Константа 2415018.5 и слово «Julian» остаются внутри redb.SQLite, а ядро — storage-agnostic.
Это, на мой вкус, и есть форма правильного фикса: проблема локальная (SQLite хранит дату числом), а лечится не размазыванием SQLite-специфики по ядру, а одной обобщённой точкой расширения, которую дёргает только тот, кому надо.
Часть 3. pvt-компилятор: как фильтр становится SQL
Раз уж движок — транслятор фильтр-JSON в SQL, разберём кусок этого транслятора. Это самая «база данных в базе данных» часть.
Фильтр-JSON
LINQ-Where C# сворачивает в фасет-фильтр — JSON, который понимает нативка. Скажем, Where(e => e.LastName == "NullableTest") для prop-поля даёт:
{ "LastName": { "$eq": "NullableTest" } }
А WhereRedb(o => o.ParentId == null) для base-поля (маркер 0$: — «это base, не prop»):
{ "0$:ParentId": null }
Комбинация двух условий — неявный $and по ключам объекта:
{ "0$:ParentId": null, "LastName": { "$eq": "NullableTest" } }
Расщепление: push vs residual
Ключевая функция — pvtSplitFilter. Она делит фильтр на две части:
push — условия по base-полям и пропсам, которые можно затолкать внутрь CTE (в подзапрос по
objects/values).residual — то, что применяется снаружи, поверх собранного пивота.
Это разделение и определяет, какой из «форм» запроса соберётся. Их три:
Shape A — pure-base flat: фильтр только по base-полям, пропсов нет. Тогда CTE не нужен вообще:
SELECT id FROM objects o WHERE o._id_scheme=? AND <push>.narrow — есть пропсы, фильтр сводится к пивоту: строим CTE
pvtcte(пивотим нужные структуры черезMAX(...) FILTER (WHERE idstructure=? AND arrayindex IS NULL)), джойним с_objects.non-narrow — есть нерасщепимые проверки (например, по присутствию), нужен внешний WHERE.
Реальный собранный SQL для Where(LastName) + WhereRedb(ParentId IS NULL) (narrow-форма) выглядит так:
WITH _pvt_cte AS ( SELECT v._id_object, MAX(v._String) FILTER (WHERE v._id_structure = 1000012 AND v._array_index IS NULL) AS "LastName" FROM _values v WHERE v._id_structure IN (1000012) AND v._id_object IN ( SELECT o._id FROM _objects o WHERE o._id_scheme = 1000010 AND o._id_parent IS NULL -- ← push base-условия ) GROUP BY v._id_object ) SELECT o._id FROM _pvt_cte JOIN _objects o ON o._id = _pvt_cte._id_object WHERE "LastName" = 'NullableTest' -- ← residual prop-условие
Обратите внимание: base-условие o._id_parent IS NULL уехало внутрь подзапроса по _objects (push), а prop-условие по LastName осталось снаружи (residual). Это не случайность — это ровно то, что делает pvtSplitFilter, и ровно то место, где у нас был баг (см. ниже).
Боевая байка №3: мульти-ключевой фильтр терял null
WhereRedb(o => o.ParentId == null).Where(e => e.LastName == "X") на Free возвращал строки, у которых родитель есть. То есть условие IS NULL молча терялось — но только в комбинации с prop-фильтром. Base-only WhereRedb(o => o.ParentId == null) работал.
Диагностика. Прямой вызов нативки на трёх фильтрах:
-- 1) только base IS NULL — РАБОТАЕТ: SELECT pvt_build_query_sql(1000010, '{"0$:ParentId":null}', ...); → ... WHERE o._id_scheme = 1000010 AND o._id_parent IS NULL -- 2) base equality — РАБОТАЕТ: SELECT pvt_build_query_sql(1000010, '{"0$:ParentId":5}', ...); → ... WHERE o._id_scheme = 1000010 AND o._id_parent = 5 -- 3) base IS NULL + prop — base-условие ИСЧЕЗЛО: SELECT pvt_build_query_sql(1000010, '{"0$:ParentId":null,"LastName":{"$eq":"X"}}', ...); → ... (только LastName в CTE, никакого o._id_parent IS NULL)
То есть сам по себе {"0$:ParentId":null} нативка обрабатывает, а в составе нескольких ключей — теряет. Корень — в мульти-ключевой ветке pvtSplitFilter. Чтобы расщепить каждый ключ по отдельности, она пересобирает из ключа одиночный фильтр-объект через pvtSingleton:
static char *pvtSingleton(sqlite3 *db, const char *k, const char *v_json){ sqlite3_stmt *st = 0; char *r = 0; sqlite3_prepare_v2(db, "SELECT json_object(?1, json(?2))", -1, &st, 0); // ... }
А значение каждого ключа цикл брал из json_each:
// БЫЛО: "SELECT key, value FROM json_each(?1)"
И вот ловушка: для JSON-null колонка value в json_each — это SQL NULL. То есть v_json приходил пустой строкой, json("") — ошибка парсинга, json_object(...) возвращал NULL, синглтон получался NULL → pvtSplitFilter на NULL-фильтре отдавал «ничего» → условие тихо исчезало. (То же самое случилось бы с голым текстовым значением: json_each.value отдаёт текст без кавычек, json("NullableTest") — снова ошибка.) В base-only случае работал другой путь — там тип значения берётся из отдельной колонки type, и null детектится корректно.
Фикс — пересобирать значение в валидный JSON-атом по типу прямо в SQL, до pvtSingleton:
// СТАЛО: "SELECT key, CASE type " " WHEN 'text' THEN json_quote(value) " // "X" с кавычками " WHEN 'null' THEN 'null' " // валидный JSON null " WHEN 'true' THEN 'true' " " WHEN 'false' THEN 'false' " " ELSE value END " // integer/real/object/array — уже валидный JSON "FROM json_each(?1)"
После пересборки {"0$:ParentId":null} остаётся валидным JSON, синглтон собирается, условие доезжает до push и приклеивается к подзапросу по objects. Урок: jsoneach.value — лоссовый источник, он теряет тип (null → SQL NULL, text → без кавычек); если из него реконструируешь JSON, делай это по колонке type.
DistinctBy: эмуляция DISTINCT ON через ROW_NUMBER()
У Postgres есть DISTINCT ON (col) — «по одной строке на каждое значение col». У SQLite такого нет. На Pro это уже было решено в ProSqlBuilder через ROW_NUMBER(); на Free нативка distinct_on игнорировала (был явный TODO), и DistinctBy(e => e.Department) возвращал дубликаты.
Доводили до паритета. Сама pvt_build_query_sql принимает 12-й аргумент distinct_on — но функция-обёртка читала только до 11-го, так что параметр C# слал, а нативка выбрасывала. Починка тройная:
Прочитать 12-й аргумент и пробросить внутрь.
Затащить distinct-поле в пивот. Если поле не упомянуто в фильтре/сортировке, его нет в собранных полях → нет в CTE → не по чему партиционировать. Поэтому distinct-поле подмешивается в сбор полей (
pvtCollectFields) тем же механизмом, что иORDER BY.Завернуть результат в
ranked-CTE сROWNUMBER()и оставитьrn=1:
WITH _pvt_cte AS ( ... пивот Department ... ), _ranked AS ( SELECT o._id AS _id, ROW_NUMBER() OVER (PARTITION BY _pvt_cte."Department" ORDER BY o._id) AS _rn FROM _pvt_cte JOIN _objects o ON o._id = _pvt_cte._id_object ) SELECT _id FROM _ranked WHERE _rn = 1
Выражение партиции резолвится из метаданных поля: base → o.<column>, prop → pvtcte."<FieldName>" (пивотная колонка). Представитель группы — строка с минимальным o._id (как и у Pro). Всё это включается только при наличии distinct_on; обычные запросы идут прежним путём — нулевой риск регресса для 99% запросов.
Часть 4. Free vs Pro: где Pro случайно лез во Free
Архитектурно Free и Pro делят базовые провайдеры (redb.Core), но расходятся в материализации:
Free зовёт
get_object_json(нативка) для сборки объектов.Pro материализует в C# (
ProLazyPropsLoader,ProSqlBuilder) и ни разу не должен дёргать нативные функции.
И вот тут вылез принципиальный баг. DeleteSubtreeAsync (удаление поддерева) собирает id потомков через базовый TreeProviderBase.CollectDescendantIds. Pro переопределяет загрузочные tree-методы (GetChildren, GetPolymorphicChildren, LoadDynamicObject) на C#-материализацию — а вот CollectDescendantIds не переопределял, и он использовал рецепт с get_object_json:
-- Tree_SelectPolymorphicChildren — рецепт, который дёргал нативку: SELECT o._id as ObjectId, o._id_scheme as SchemeId, get_object_json(o._id, 1) as JsonData FROM _objects o WHERE o._id_parent = $1
На PG/MSSql это проходит молча: get_object_json там — серверная функция, есть в любом тире. На SQLite Pro функции нет (нативку Pro не грузит) → жёсткий креш no such function: get_object_json. А на PG/MSSql Pro было тихое расточительство: материализовали полный JSON каждого узла поддерева — ради того, чтобы выкинуть JSON и взять _id.
Фикс не в том, чтобы добавить Pro-override (это оставило бы базу дёргать get_object_json ради метода, которому JSON не нужен), а в том, чтобы убрать JSON из самого базового метода: ему нужен только список id. Добавили id-only рецепт во все три диалекта:
// ISqlDialect + PostgreSqlDialect / MsSqlDialect / SqliteDialect: public string Tree_SelectChildrenIds() => "SELECT o._id FROM _objects o WHERE o._id_parent = $1 ORDER BY o._name, o._id";
// CollectDescendantIds — было QueryAsync<ChildObjectInfo>(Tree_SelectPolymorphicChildren), стало: var childIds = await Context.QueryScalarListAsync<long>(Sql.Tree_SelectChildrenIds(), parentId); foreach (var childId in childIds) { ids.Add(childId); await CollectDescendantIds(childId, ids, ...); }
Теперь течь закрыта в источнике для всех тиров: Free не считает лишний JSON ради списка id, Pro не лезет в нативку, PG/MSSql Pro перестают зря материализовать. В исходниках Pro get_object_json теперь ровно ноль вызовов. И, кстати, SQLite Pro оказался идеальным детектором таких протечек: он крешится на любом нативном вызове из Pro — то, что на PG/MSSql молчит.
Заодно: DeleteSubtree и каскад
Тот же DeleteSubtree возвращал неверное число удалённых. На SQLite в схеме есть FK idparent ... ON DELETE CASCADE — удаляешь родителя, дети уходят каскадом. А changes() (rows-affected) каскадно-удалённые строки не считает. Поэтому DELETE WHERE id IN (родитель, дети) мог вернуть 1 (только родитель удалён напрямую, дети — каскадом). Починили семантикой: метод возвращает размер собранного поддерева (objectIds.Count), а не cascade-зависимый rows-affected. На PG/MSSql (где каскада на id_parent нет) это то же число — никакого расхождения.
Bool — это INTEGER
Ещё одна мелочь, всплывшая в группировках. SQLite не имеет булева типа — хранит 0/1 как INTEGER. В пивоте/проекции значение bool прилетает в общий конвертер как JSON-число, а ветка bool в JsonValueConverter ловила только true/строку → число 1 давало false. Группировка по bool-ключу схлопывалась (все «false»). Фикс — принять Number в bool-ветке:
Type t when t == typeof(bool) => elem.ValueKind == JsonValueKind.True || (elem.ValueKind == JsonValueKind.Number && elem.TryGetDouble(out var bn) && bn != 0) || (elem.ValueKind == JsonValueKind.String && bool.TryParse(elem.GetString(), out var bl) && bl),
PG/MSSql шлют true/false — их не задевает.
Часть 5. Как пощупать самому
Это не псевдокод из статьи. В репозитории лежат два инструмента, которыми всё проверяется руками.
redb.Examples — ~150 запускаемых примеров, которые гоняются на любом провайдере, включая SQLite:
dotnet run --project redb.Examples -- E021 E146 E148 # фильтр по дате, агрегаты, оконные
Переключаете AddRedb/AddRedbPro + UseSqlite — и тот же набор бежит вживую на SQLite Free или Pro. Тот же код пойдёт на Postgres/MSSql без единого изменения.
redb.CLI — глобальный .NET-тул для управления схемой и данными, поддерживает sqlite во всех командах:
redb schema -p sqlite -o redb_sqlite.sql # выгрузить весь SQL-скрипт схемы (для ревью/CI) redb init -p sqlite -c "Data Source=app.db" # создать таблицы в пустой базе redb export -p sqlite -c "Data Source=app.db" -o data.redb --compress redb import -p sqlite -c "Data Source=app.db" -i data.redb --clean
И главное — доверие проверяется тестами. SQLite Free и Pro проходят интеграционный набор по 200/200 — тот же, что гейтит Postgres и MSSql. Для нового провайдера это весомее любых слов: тот же набор, та же планка.
Боевая байка №4: CLI, который «поддерживал» sqlite, но молча — нет
Готовя эту статью, я хотел показать redb schema -p sqlite — и наткнулся на собственную мину в тулинге. Код redb.CLI sqlite поддерживал: был полноценный SqliteProvider, фабрика ProviderFactory.Create("sqlite"), ресурс схемы redbSqlite.sql. А csproj — нет:
<!-- redb.CLI.csproj — тянул движок из NuGet старой версии: --> <PackageReference Include="redb.SQLite" Version="1.2.*" />
1.2.* — это версия, в которой SQLite-провайдера не существовало вообще (он новый, с 3.2.1). То есть typeof(redb.SQLite.RedbService).Assembly и встроенный ресурс redbSqlite.sql резолвились в сборку, где их нет, и любая -p sqlite-команда тихо разъезжалась с реальным кодом. Фикс — перевести ссылки на локальные проекты (как уже было сделано в redb.Examples):
<ProjectReference Include="..\redb.SQLite\redb.SQLite.csproj" />
Мораль из той же серии, что и %%-байка: «в коде поддержка есть» ≠ «сборка её видит». Версионный пин — это тоже часть контракта, и устаревший пин ломает фичу так же глухо, как опечатка в SQL. Проверяется тривиально — прогоном самой команды: redb schema -p sqlite теперь выгружает настоящую схему (REAL Julian, _DateTimeOffset REAL), а не падает на пустом ресурсе.
Часть 6. Грабли, на которые наступите вы
Серия честная про «что не готово и обо что споткнётесь», так что без приукрашивания:
Путь к
.dbзависит от рабочей директории. Относительная строка (Data Source=app.db) создаёт файл относительно cwd процесса, а не папки проекта. Я сам на этом потерял пару часов:dotnet runиз разных директорий писал в разные файлы, и тесты «проходили/падали» по разной БД. Берите абсолютный путь или фиксируйте cwd.:memory:— per-connection. Чтобы пул соединений видел одну in-memory базу, нуженMode=Memory;Cache=Sharedплюс один удерживаемый коннект. Это жизненный цикл SQLite, не redb: закрыли последний коннект — база испарилась.NUMERIC→REALпо умолчанию. Быстро, но лоссово за пределами double. Точный вариант черезTEXT— запланированная настройка. Известное слабое место SQLite.SQLite — single-writer. Один писатель на файл; на конкурентную запись redb ставит
busy_timeout, но Postgres-уровня параллелизма ждать не надо. Для embedded/локальных сценариев — норма.Нативные бинарники Free идут под Windows x64, Linux x64 и Linux arm64. Все три лежат в
runtimes/<rid>/native/и доставляются во framework-dependent сборки черезbuildTransitive.targets — расширение грузится по явному пути, поэтому файл должен физически попасть в ваш output, а для сборки без RID NuGet самruntimes/не разворачивает. macOS (osx-x64/osx-arm64.dylib) собирается из того же CMake-проекта, но требует macOS-раннера — единственный оставшийся пробел, его закрывает CI-матрица. У Pro нативной зависимости нет — он уже сегодня везде, и это ровно то, что нужно WASM/мобилкам.boolв сыром виде —0/1. Помните при дебагеBoolean/value_bool:trueлежит как1.
Ни один из этих пунктов не торчит в обычном коде — но в дебаге каждый экономит вечер.
Часть 7. Pro на мобилке и в браузере — и да, бесплатно
Возвращаюсь к тому, ради чего весь SQLite и затевался.
Пишете Blazor WebAssembly, MAUI или standalone-клиент — вам нужен SQLite Pro: чистый C#, нативку не грузит, работает в браузерной песочнице и на телефоне. Типизированное LINQ-хранилище в одном файле внутри приложения.
И ключевое, что вызывает недоверие, поэтому прямо: Pro для этого — бесплатный.
Идёте на redbase.app (он же redb.ru), регистрируетесь и отправляете запрос ключа на почту — в ответ присылают бесплатный лицензионный ключ.
Никаких банковских/платёжных реквизитов. Карту не спрашивают. Регистрация — механизм выдачи ключа, а не воронка продаж.
Ключ — в
.WithLicense(...), инструкция по подключению там же, сразу после регистрации.
Барьер на вход в клиентский сценарий — нулевой.
Итог
SQLite-провайдер заставил нас сделать две неочевидные вещи и поймать три бага, которые в проде стоили бы дороже любого ревью.
Неочевидные вещи: перенести весь движок запросов в C-расширение там, где у базы нет хранимок (и где % в данных ломает mprintf), и заново ответить на вопрос «как хранить дату» для базы, у которой типа «дата» нет — REAL Julian в UTC, sargable-сравнения, julianday() на стороне литерала, и нейтральный хук в ядре вместо протечки SQLite-специфики.
Баги: тихо терявшийся IS NULL в мульти-ключевом фильтре (json_each.value лоссов по типу), DISTINCT ON через ROW_NUMBER() вместо игнора, и Pro, случайно лезущий в Free-функцию get_object_json на пути удаления поддерева.
Всё это — ровно те места, где абстракция «один LINQ для всех баз» либо держит удар, либо течёт. У нас держит: Free и Pro зелёные по 200/200 на том же наборе, что и остальные диалекты, а пощупать можно redb.Examples и redb.CLI из репозитория.
Репозиторий, доки, пакеты — redbase.app. Версия стека — 3.2.1; два SQLite-NuGet-пакета (redb.SQLite / redb.SQLite.Pro) — 3.2.1 (хотфикс buildTransitive .targets, см. War story #5). Вопросы, «а делает ли оно X на SQLite», баг-репорты — несите; провайдер свежий, обратная связь открыта.
d3d14
А чем это лучше sqlite3.dll? Быстрее? Совместимо/взаимозаменяемо с ним?
grelikt Автор
Тут небольшая путаница — redb.dll это не замена sqlite3.dll и не «другой SQLite», а загружаемое расширение SQLite (.load / sqlite3_load_extension). Оно не заменяет движок, а грузится поверх обычного SQLite: при загрузке через sqlite3ext.h резолвит API хоста и добавляет в базу функции redb (get_object_json, pvt_*-компилятор запросов, soft-delete, view прав).
Поэтому по пунктам:
— Быстрее? Некорректное сравнение: redb.dll использует ваш sqlite3 как есть и в его скорости ничего не меняет. Ценность не в скорости SQLite, а в том, что он превращает SQLite в типизированное объектное хранилище (серверный материализатор + компилятор запросов внутри БД — ровно то, что у Free-тира на Postgres/MSSql живёт как PL/pgSQL).
— Совместимо/взаимозаменяемо? Совместимо с SQLite (грузится в любой sqlite3 ≥ 3.44 — Microsoft.Data.Sqlite, python sqlite3, sqlite3 CLI), но не взаимозаменяемо: сам sqlite3.dll по-прежнему нужен, redb.dll к нему добавляется, а не вместо.
Если совсем коротко: sqlite3.dll — это движок, redb.dll — плагин к нему.
смотри на redb.ru
d3d14
Теперь ясно. Спасибо.