Strong Typing — Real C# Classes, Not Just JSON blobs
Strong Typing — Real C# Classes, Not Just JSON blobs

Проблема

Возьмём типичный enterprise-объект — скажем, заказ. Он связан с клиентом, позициями, каждая позиция — с товаром, у товара — категория, у заказа — доставка с адресом, оплата с транзакциями. Итого 10–30 связанных сущностей. EF Core:

var order = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items).ThenInclude(i => i.Product)
        .ThenInclude(p => p.Category)
    .Include(o => o.Items).ThenInclude(i => i.Discounts)
    .Include(o => o.Shipping).ThenInclude(s => s.Address)
    .Include(o => o.Payment).ThenInclude(p => p.Transactions)
    // ... ещё строк 30 ...
    .FirstOrDefaultAsync(o => o.Id == orderId);

Забыл один Include — runtime. Добавить поле в модель — миграция → заявка на DBA → staging → deploy. Три дня на ALTER TABLE ADD COLUMN.

Хотелось: описать модель как C#-класс, и чтобы движок сам разобрался как это хранить. Без миграций, без маппинга, без Include.

Написал. Выложил под Apache 2.0.

Production case

Работает в проде в крупном HoReCa-дистрибьюторе (~150k заказов/мес, ~20k B2B-клиентов, собственный автопарк). Внутренняя TMS — ~500 водителей + ~50 диспетчеров, 3-нодовый кластер (Xeon, 4 ядра / 8 ГБ / 50 ГБ SSD на ноду), ~3 месяца стабильной работы, 10–15% CPU под полной нагрузкой. Интеграции через redb.Route: SAP, Kafka, RabbitMQ, GPS-фиды, Меркурий, ЕГАИС, Честный ЗНАК, ФГИС Зерно.

Второй production-продукт: аналитическая платформа (~672k объектов, ~8M свойств). Ни одной миграции за весь срок эксплуатации. Добавить поле в модель — добавить свойство в C#-класс → SyncSchemeAsync() → готово.

Как выглядит в коде

Вот реальная модель из redb.Examples:

[RedbScheme("Employee")]
public class EmployeeProps
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Age { get; set; }
    public decimal Salary { get; set; }
    public string Department { get; set; } = "";
    public DateTime HireDate { get; set; }
    public string[]? Skills { get; set; }
    public Address? HomeAddress { get; set; }
    public Contact[]? Contacts { get; set; }
    public RedbObject<ProjectMetricsProps>? CurrentProject { get; set; }
    public RedbObject<ProjectMetricsProps>[]? PastProjects { get; set; }
    public Dictionary<int, decimal>? BonusByYear { get; set; }
    public Dictionary<string, Department>? DepartmentHistory { get; set; }
}

Это вся схема. Вложенные классы, массивы, словари, ссылки на другие RedbObject — всё хранится и загружается автоматически.

Сохранить:

var employee = new RedbObject<EmployeeProps>
{
    name = "Alice Johnson",
    Props = new EmployeeProps
    {
        FirstName = "Alice",
        LastName = "Johnson",
        Age = 28,
        Salary = 85000m,
        Skills = ["C#", "React", "SQL"]
    }
};
await redb.SaveAsync(employee);

Загрузить:

var loaded = await redb.LoadAsync<EmployeeProps>(id);
// loaded.Props.FirstName → "Alice"
// loaded.Props.CurrentProject.Props — загружен автоматически
// loaded.Props.Contacts[0].Value — загружен автоматически

Запросить:

var results = await redb.Query<EmployeeProps>()
    .Where(e => e.Salary > 75000m && e.Skills.Contains("C#"))
    .OrderByDescending(e => e.Salary)
    .Take(100)
    .ToListAsync();

Забыть Include невозможно — Props всегда загружен целиком. Нужна проекция? Тогда .Select():

var projected = await redb.Query<EmployeeProps>()
    .Select(x => new { x.Props.FirstName, x.Props.Salary })
    .ToListAsync();

Сравнение: тот самый объект из 28 таблиц

В EF Core:

var order = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items).ThenInclude(i => i.Product)
        .ThenInclude(p => p.Category)
    .Include(o => o.Items).ThenInclude(i => i.Discounts)
    .Include(o => o.Shipping).ThenInclude(s => s.Address)
    .Include(o => o.Payment).ThenInclude(p => p.Transactions)
    // ... ещё 35 строк Include ...
    .FirstOrDefaultAsync(o => o.Id == orderId);

В RedBase:

var order = await redb.LoadAsync<OrderProps>(orderId);

Одна строка. Все вложенные объекты, массивы, словари — загружены. И быстро: движок собирает Props одним запросом к плоской структуре, без JOIN-каскада по 28 таблицам. В Pro — ещё быстрее: материализация через Parallel.ForEach, каждая ветка графа собирается параллельно.

А что с деревьями?

Встроено из коробки. Не нужен ни closure table вручную, ни рекурсивные CTE в raw SQL:

// Создать дерево
await redb.CreateChildAsync(department, parentDepartment);

// Загрузить всё дерево (до 5 уровней)
var tree = await redb.LoadTreeAsync<DepartmentProps>(rootId, maxDepth: 5);

// LINQ по дереву
var bigDepts = await redb.TreeQuery<DepartmentProps>()
    .Where(d => d.Budget > 500000m)
    .WhereLevel(2)
    .ToListAsync();

// Найти всех, у кого предок с бюджетом > 1M
var rich = await redb.TreeQuery<DepartmentProps>()
    .WhereHasAncestor<DepartmentProps>(a => a.Budget > 1_000_000m)
    .ToListAsync();

Оконные функции? GroupBy? Агрегации?

Тоже через LINQ:

// ROW_NUMBER() PARTITION BY Department ORDER BY Salary DESC
var ranked = await redb.Query<EmployeeProps>()
    .WithWindow(w => w
        .PartitionBy(x => x.Department)
        .OrderByDesc(x => x.Salary))
    .SelectAsync(x => new
    {
        Name = x.Props.FirstName,
        Department = x.Props.Department,
        Rank = Win.RowNumber()
    });

// GroupBy + агрегация
var stats = await redb.Query<EmployeeProps>()
    .GroupBy(x => x.Department)
    .SelectAsync(g => new
    {
        g.Key,
        Total = Agg.Count(),
        AvgSalary = Agg.Average(g, x => x.Salary)
    });

Что генерирует движок под капотом

Обычно SQL за LINQ-запросами скучный. Но вот пример, который показывает почему здесь нужен специализированный query engine, а не «просто ORM».

Модель:

public class Address
{
    public string City   { get; set; } = string.Empty;
    public string Street { get; set; } = string.Empty;
}

[RedbScheme("Employee")]
public class EmployeeProps
{
    // ...
    public Dictionary<string, Address>? OfficeLocations { get; set; }
}

LINQ-запрос — найти всех сотрудников, у кого HQ-офис в Нью-Йорке:

var result = await redb.Query<EmployeeProps>()
    .Where(e => e.OfficeLocations!["HQ"].City == "New York")
    .Take(100)
    .ToListAsync();

Что генерируется для PostgreSQL:

-- PVT CTE (nested-only optimization): OfficeLocations[HQ].City
WITH pvt_cte AS (
  WITH
  nested_dict_0 AS (
    SELECT dp._id_object
         , (array_agg(nv._string)
              FILTER (WHERE nv._id_structure = $5))[1] AS "OfficeLocations[HQ].City"
    FROM _values dp
    LEFT JOIN _values nv
           ON nv._array_parent_id = dp._id
          AND nv._id_structure = $5
    WHERE dp._id_structure = $3       -- структура словаря OfficeLocations
      AND dp._array_index  = $4       -- ключ "HQ"
      AND dp._id_object IN (
            SELECT _id FROM _objects WHERE _id_scheme = $1
          )
    GROUP BY dp._id_object
  )
  SELECT nd0._id_object
       , nd0."OfficeLocations[HQ].City"
  FROM nested_dict_0 nd0
  WHERE nd0."OfficeLocations[HQ].City" = $2   -- "New York"
)
SELECT o.*
FROM _objects o
JOIN pvt_cte ON pvt_cte._id_object = o._id
WHERE o._id_scheme = $1
LIMIT 100

Что генерируется для MSSQL:

-- PVT CTE (nested MAX CASE WHEN): OfficeLocations[HQ].City
;WITH
raw_values AS (
  SELECT nv._array_parent_id AS _parent_id
       , MAX(CASE WHEN nv._id_structure = 1010067
                  THEN nv._string END) AS [OfficeLocations$LHQ$R$DCity]
  FROM _values nv
  WHERE nv._id_structure IN (1010067)
    AND nv._array_parent_id IS NOT NULL
  GROUP BY nv._array_parent_id
),
pvt_cte AS (
  SELECT dp._id_object
       , rv.[OfficeLocations$LHQ$R$DCity]
  FROM _values dp
  JOIN raw_values rv ON rv._parent_id = dp._id
  WHERE dp._id_structure = @p2      -- структура словаря OfficeLocations
    AND dp._array_index  = @p3      -- ключ "HQ"
    AND dp._id_object IN (
          SELECT _id FROM _objects WHERE _id_scheme = @p0
        )
    AND rv.[OfficeLocations$LHQ$R$DCity] = @p1   -- "New York"
)
SELECT o.*
FROM _objects o
JOIN pvt_cte ON pvt_cte._id_object = o._id
WHERE o._id_scheme = @p0
ORDER BY o._id
OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY

Одна строка LINQ — два диалекта, два разных подхода к оптимизации (PostgreSQL использует array_agg FILTER, MSSQL — MAX CASE WHEN). SQL генерируется под конкретный диалект автоматически. Посмотреть что именно сгенерировалось всегда можно через .ToSqlStringAsync().

Настройка — 5 строк

PostgreSQL или MSSQL — выбирается одной строкой:

// PostgreSQL + Pro
// jit=off — отключаем JIT-компиляцию PostgreSQL, на коротких запросах она только замедляет
builder.Services.AddRedbPro(options => options
    .UsePostgres("Host=localhost;Database=mydb;Username=postgres;Password=pass;Options=-c jit=off")
    .WithLicense("YOUR-LICENSE-KEY")
    .Configure(c =>
    {
        c.EnablePropsCache = true;
        c.EnableLazyLoadingForProps = false;
    }));

// MSSQL + Pro
builder.Services.AddRedbPro(options => options
    .UseMsSql("Server=localhost;Database=mydb;Trusted_Connection=true")
    .WithLicense("YOUR-LICENSE-KEY"));

// Free (Apache 2.0) — PostgreSQL
builder.Services.AddRedb(options => options
    .UsePostgres("Host=localhost;Database=mydb;Username=postgres;Password=pass;Options=-c jit=off"));

// Free (Apache 2.0) — MSSQL
builder.Services.AddRedb(options => options
    .UseMsSql("Server=localhost;Database=mydb;Trusted_Connection=true"));

Free vs Pro

Ядро — open source, Apache 2.0. PostgreSQL и MSSQL. Полный LINQ, деревья, списки, пользователи, экспорт/импорт.

Оба варианта работают и с PostgreSQL, и с MSSQL. У движка единый SQL-абстрактный слой — переезд между базами это смена одной строки (.UsePostgres().UseMsSql()). А redb.Export позволяет экспортировать данные из одной базы и импортировать в другую — PostgreSQL → MSSQL и обратно.

Pro добавляет производительность:

  • Compiled queries — LINQ компилируется в нативный SQL, без JSON-интерпретатора

  • Parallel materialization — загрузка Props через Parallel.ForEach

  • Change tracking — умное сохранение: строятся два дерева ValueTreeNode (память vs БД), diff с пропуском по хешу, только изменённые узлы → SQL. Никакого delete-all/re-insert

  • Window functions, глубокие вложенные запросы, арифметика в WHERE

Без лицензии Pro работает полностью — 1,024 запроса на запуск приложения, счётчик сбрасывается при перезапуске. Для разработки ограничений практически нет.

Raw SQL и свои таблицы — тоже можно

RedBase не закрытый ящик. Если надо — работай с БД напрямую.

Посмотреть сгенерированный SQL — аналог EF Core .ToQueryString():

var sql = await redb.Query<EmployeeProps>()
    .Where(e => e.Salary > 75000m && e.Department == "Engineering")
    .ToSqlStringAsync();
// вернёт реальный SQL с параметрами — удобно для отладки и оптимизации

Или использовать встроенную SQL-функцию get_object_json напрямую — она есть и в PostgreSQL, и в MSSQL, возвращает объект целиком как JSON, включая все вложенные Props и связанные объекты на заданную глубину:

-- PostgreSQL
SELECT get_object_json(42, 3);       -- объект 42, глубина 3

SELECT get_object_json(o._id, 5)
FROM _objects o
WHERE o._id_scheme = 123;

-- MSSQL
SELECT dbo.get_object_json(42, 3);

SELECT dbo.get_object_json(o._id, 5)
FROM _objects o
WHERE o._id_scheme = 123;

Полезно для отладки и диагностики прямо в psql/DataGrip/SSMS, или когда нужен JSON на SQL-стороне — без C# кода.

Выполнить произвольный SQL через redb.Context.Db:

// SELECT — список объектов
var rows = await redb.Context.Db.QueryAsync<MyDto>(
    "SELECT _id, _name FROM _objects WHERE _id_scheme = $1", schemeId);

// SELECT — скаляр
var count = await redb.Context.Db.ExecuteScalarAsync<int>(
    "SELECT COUNT(*) FROM _objects WHERE _id_scheme = $1", schemeId);

// INSERT / UPDATE / DELETE
await redb.Context.Db.ExecuteAsync(
    "UPDATE my_custom_table SET synced = true WHERE object_id = $1", objectId);

Свои таблицы — создавай через тот же ExecuteAsync, хоть при старте приложения:

await redb.Context.Db.ExecuteAsync("""
    CREATE TABLE IF NOT EXISTS logistics_routes (
        id BIGSERIAL PRIMARY KEY,
        object_id BIGINT REFERENCES _objects(_id) ON DELETE CASCADE,
        route_json TEXT,
        created_at TIMESTAMPTZ DEFAULT NOW()
    )
    """);

FK на _objects(_id) — и твоя таблица привязана к redb-объекту. Cascade delete работает.

Структура _values — если нужно писать raw SQL по хранилищу Props, колонки типизированы:

-- Каждое свойство объекта — одна строка в _values
-- _id_structure → какое поле схемы (id структуры из _structures)
-- _id_object    → какой объект (_objects._id)
-- Значение в типизированной колонке по типу свойства:
--   _String          text
--   _Long            bigint        (int, long, enum)
--   _Numeric         numeric(38,18) (decimal — без потерь точности)
--   _Double          float
--   _DateTimeOffset  timestamptz
--   _Boolean         boolean
--   _Guid            uuid
--   _Object          bigint → FK на _objects (RedbObject<T> reference)
--   _ListItem        bigint → FK на _list_items (справочник)
--   _ByteArray       bytea
-- Для массивов/словарей: _array_parent_id + _array_index

Строковые значения не varchar(max) — не «всё в строку». Каждый тип в своей колонке с правильным SQL-типом. decimal — это NUMERIC(38,18) без потерь точности. DateTimetimestamptz. Поэтому WHERE по числам, датам, uuid работает через обычные индексы.

Когда RedBase НЕ нужен

RedBase хранит свойства объекта в строках таблицы _values — одна строка на каждое свойство. Это даёт гибкость: вложенные объекты, массивы, словари, изменение схемы без миграций. Но за гибкость есть цена.

Если ваша модель — плоская таблица без связей (Users с 10 колонками, лог событий, очередь задач), то:

  • Dapper + SELECT * FROM users WHERE id = @id будет быстрее — один запрос, один маппинг, без overhead

  • EF Core тоже справится — для одной таблицы Include не нужны, миграции тривиальны

RedBase выигрывает там, где объект — это граф: 3+ вложенных структур, массивы, словари, ссылки между объектами, частые изменения схемы. Чем сложнее модель — тем больше разница с классическим подходом.

Причём порог ниже, чем кажется. Попробуйте в EF Core добавить в модель string[] Skills — и вот вам уже отдельная таблица UserSkills, FK, индексы, миграция, .Include(u => u.Skills). А в RedBase — просто public string[]? Skills { get; set; } и всё. Объявил — работает.

За рамками статьи

В одну статью не влезло. Кратко что ещё есть:

  • Деревья — полный функционал: CreateChildAsync, MoveAsync, LoadTreeAsync, WhereLevel, WhereHasAncestor, WhereHasDescendant. Closure table и рекурсивные CTE генерируются автоматически.

  • Экспорт / импортredb.Export выгружает объекты в JSON-файлы и загружает обратно. Работает между PostgreSQL и MSSQL: переехать с одной БД на другую — одна команда.

  • Права доступа — встроенная модель пользователей, ролей и разрешений на уровне объектов.

  • Справочники_list_items, типизированные через RedbListItem, LINQ по справочным значениям.

  • Change tracking (Pro) — PropsSaveStrategy.ChangeTracking: при SaveAsync строятся два дерева ValueTreeNode (память vs БД), сравниваются с пропуском по хешу — генерируется только минимальный набор SQL-операций. Никакого delete-all/re-insert.

  • redb.Identity (в активной разработке, ещё не опубликован на NuGet) — OAuth 2.1 / OIDC Identity Server поверх redb.Core и redb.Route. Ключевая идея: каждый endpoint — это direct-vm://-маршрут, а не HTTP-middleware. Вызвать token из Worker Service или из соседнего модуля в том же процессе — To("direct-vm://identity-token"), без loopback, без TLS, без WebApplicationFactory в тестах. HTTP / gRPC / RabbitMQ — подключаемые facade-пакеты. Из коробки: все флоузы OAuth 2.1 (Code+PKCE, Client Credentials, Device Code), PAR (RFC 9126), DPoP (RFC 9449), Dynamic Client Registration (RFC 7591/7592), SCIM 2.0 (RFC 7643/7644), FIDO2/WebAuthn + TOTP + SMS OTP, backchannel logout (RFC 8417), федерация (OIDC / GitHub). Хранение через redb — без миграций. Шарит signing keys и DataProtection key-ring между нодами через redb object store. Деплоится как .tpkg в redb.Tsak. 1751 проходящий тест. Apache 2.0.

  • 195+ примеров в redb.Examples — деревья, окна, группировки, экспорт, raw SQL и т.д.

Всё это в архитектурной документации и примерах

redb.Route: интеграции без hand-rolled-кода

redb.Core закрывает хранение. Для интеграций есть redb.Route — .NET-аналог Apache Camel. Маршрут описывается fluent C# DSL:

// HTTP-вход: принять заказ, валидировать, передать в очередь
From("http:0.0.0.0:5090/api/orders?inOut=true&cors=true&corsOrigins=*")
    .Validate(e => e.In.Body is not null, "Body required")
    .Choice()
        .When(e => e.In.Headers.ContainsKey("redbHttp.ResponseCode"))
            .To("direct://error-response")
        .Otherwise()
            .To("seda://orders-pending?concurrentConsumers=4")
    .EndChoice();

// Фоновая обработка: сохранить через redb, опубликовать событие
From("seda://orders-pending?concurrentConsumers=4")
    .ProcessWithRedb(async (redb, exchange, ct) =>
    {
        var dto = (OrderDto)exchange.In.Body!;
        var order = new RedbObject<OrderProps> { Props = Map(dto) };
        await redb.SaveAsync(order, ct);
        exchange.In.Headers["order.id"] = order.id;
    })
    .To("rabbitmq://orders-created");

22 внешних транспорта + 5 встроенных компонентов:

Категория

Транспорты

Очереди сообщений

RabbitMQ, Kafka, IBM MQ, MQTT, Azure Service Bus

HTTP / WebSocket

HTTP (in/out), WebSocket, gRPC (client)

Файлы / хранилища

SFTP, S3, FTP, File

Базы данных

SQL (polling outbox)

Встроенные

Direct, SEDA, Timer, Cron, Mock

30+ EIP-паттернов — Split, Aggregate, Choice, Filter, WireTap, Retry, DeadLetterChannel, CircuitBreaker, IdempotentConsumer, Saga, Multicast, RecipientList, DynamicRouter, Resequence, Throttle, Delay, Loop, Enrich, Validate, Transacted, и другие.

Expression DSL — предикаты компилируются в Func<IExchange, T> через System.Linq.Expressions, без интерпретатора:

// Предикаты в Choice.When, Filter, Retry — все через один DSL
.When(Header("priority").isEqualTo("high"))
.Filter(Header("score").isGreaterThan(50))
.Filter(Header("tag").regex(@"^urgent-.*-x\d+$"))
.When(Header("active").and(Header("role").isEqualTo("admin")))

// String templates → компилируются в лямбды
.SetHeader("reply", "${header.orderId}-confirmed")

Обработка ошибок:

From("kafka://payments?groupId=billing")
    .OnException<TimeoutException>()
        .Retry(3, TimeSpan.FromSeconds(2))
    .OnException<ValidationException>()
        .To("direct://dlq")
    .End()
    .Retry(5)
        .BackOff(TimeSpan.FromSeconds(1), multiplier: 2)
    .Process(async (e, ct) => { /* обработка */ })
    .To("rabbitmq://billing-confirmed");

Транзакционные маршруты.Transacted() оборачивает pipeline в TransactionScope, SQL-транспорт биндит ADO.NET-транзакцию к каждому шагу.

Apache 2.0, NuGet: dotnet add package redb.Route.

redb.Tsak: runtime-контейнер для маршрутов

redb.Route описывает что делает пайплайн. redb.Tsak — это где, когда и сколько копий его запустить.

Классическая проблема: несколько несвязанных интеграционных пайплайнов живут в одном Program.cs. Добавил новый — пересобрал и передеплоил всё. Нужно остановить один маршрут — перезапускай весь процесс.

Tsak решает это: каждый RouteBuilder упаковывается в модуль (.dll или .tpkg-бандл), Tsak загружает его в изолированный AssemblyLoadContext и управляет жизненным циклом независимо от остальных.

Деплой нового маршрута:

# Скопировать DLL — Tsak подхватит автоматически
cp Orders.dll /tsak/Libs/

# Или через CLI:
tsak module upload orders --file Orders.tpkg
tsak context start orders

# Остановить один маршрут без рестарта процесса:
tsak route stop orders order-pipeline

# Посмотреть что сейчас работает:
tsak context list
tsak route list orders

Три режима деплоя:

Режим

Когда использовать

Standalone

Разработка, тесты — in-memory, без БД

Single-node + redb

Продакшн на одной машине с персистентным состоянием

Cluster

Несколько нод — leader election + автоперераспределение контекстов

Кластер без Redis и etcd. В кластерном режиме Tsak не тянет внешний координатор — всё хранится в той же redb-базе, которую уже использует приложение. Leader election, список нод, назначение контекстов по нодам, DataProtection key-ring, JWKS signing keys — всё это redb-объекты в _objects/_values. Маршруты, помеченные как кластерные (cluster=true в URI), идут через тот же координатор: состояние маршрута, partitioning, балансировка между нодами — через redb. Quartz при этом использует свои AdoJobStore-таблицы в той же БД, но создаёт их сам. Добавить ноду в кластер = запустить ещё один экземпляр Tsak с той же строкой подключения к БД. Никакого отдельного ZooKeeper, Consul или Redis.

Что есть из коробки:

  • REST API — 32 endpoint’а: управление контекстами, маршрутами, модулями, кластером, scheduler’ом, логами, пользователями

  • CLI — 30 команд с профилями и JSON-выводом (удобно для CI/CD)

  • Blazor Server dashboard — 10 страниц: метрики CPU/RAM/GC, per-route latency, ring-buffer логи, watchdog-статус

  • Watchdog — детектирует зависшие или упавшие маршруты, опционально перезапускает

  • Quartz scheduler — инжектируется в каждый контекст, RAMJobStore для standalone, AdoJobStore для кластера — схема создаётся автоматически

  • OpenTelemetry — Activities и Meters на каждый маршрут и шаг, Prometheus scrape

  • API Key + HMAC-SHA256 — роли, expiry, revocation, constant-time comparison

Код подключения Tsak — один метод в InitRoute.cs:

// Единственный Tsak-специфичный файл в проекте с маршрутами
public static class InitRoute
{
    public static IRouteContext main(IRouteContext context)
    {
        // Обычный redb.Route — тот же код, что и без Tsak
        ((RouteContext)context).AddRoutes(new OrderRoutes());
        ((RouteContext)context).AddRoutes(new ShipmentRoutes());
        return context;
    }
}

RouteBuilder, написанный для обычного IHostedService, работает в Tsak без изменений — тот же Configure(), тот же IExchange, те же OnException и .Transacted().

351 проходящий тест. Apache 2.0.


Итого

EF Core

redb

Базы данных

Много провайдеров

PostgreSQL + MSSQL

Схема

DbContext + Fluent API + миграции

C#-класс + [RedbScheme]

Загрузить граф из 28 сущностей

40 Include, 200 строк

LoadAsync<T>(id)

Добавить поле

Миграция → DBA → staging → deploy

Добавить свойство → SyncSchemeAsync()

Деревья

closure table вручную

TreeQuery<T>(), CTE, уровни, предки

Оконные функции

Raw SQL

WithWindow(), Win.RowNumber()

Забыл Include

Runtime crash

Невозможно

Переезд между БД

Ручная переписка

.UsePostgres().UseMsSql() + Export

GitHub org (все репозитории)
Репозиторий redb.Core
Документация и примеры (EN)
Документация и примеры (RU)
43 NuGet-пакета
Архитектура (индексы, query engine)
195+ рабочих примеров

Комментарии (4)


  1. Granulex
    01.06.2026 12:36

    Вся архитектура _objects/_values/_structures – классический EAV. Он убирает боль миграций, но создаёт другую: база непрозрачна для всего, что живёт вне приложения – BI-инструменты, ad-hoc запросы DBA, pg_stat_statements. В production это обнаруживается в первый инцидент, когда нужно быстро залезть в базу руками.


    1. grelikt Автор
      01.06.2026 12:36

      Granulex, спасибо за комментарий, но по существу — это не EAV. Похоже визуально, но архитектурно другое. Давайте на конкретике.

      Classic EAV vs то, что под капотом у redb

      Classic EAV — это (entity_id, attribute_name, value), где value — одна-единственная varchar/text колонка, куда сериализуется всё подряд. Тип «зашит» в строку attribute_name либо вообще нигде не записан. Отсюда боль: WHERE value > '100' сравнивает строки, индексов по типам нет, BI видит мусор.

      В redb всё иначе. Это типизированный column store + полноценное RTTI на уровне БД + сеть FK. Разбираю по пунктам.

      1. Типизированные колонки в _values — не “всё в строку”

      В _values для каждого .NET-типа отдельная колонка с правильным SQL-типом:

      _String          text
      _Long            bigint           -- int, long, enum
      _Numeric         numeric(38,18)   -- decimal без потерь точности
      _Double          double precision
      _DateTimeOffset  timestamptz
      _Boolean         boolean
      _Guid            uuid
      _Object          bigint           -- FK на _objects (ссылки на RedbObject<T>)
      _ListItem        bigint           -- FK на _list_items (справочники)
      _ByteArray       bytea
      

      Каждое свойство пишется в свою типизированную колонку. WHERE Long > 1000 идёт по обычному B-tree, WHERE DateTimeOffset >= '2026-01-01' — то же самое. Никакого CAST(value AS bigint) поверх text-колонки, как в classic EAV.

      2. FK везде, не только индексы — целостность на уровне БД

      Это, наверное, самое важное и редко встречающееся в EAV-системах. Реальный DDL из redbPostgre.sql:

      CREATE TABLE _values (
          ...
          CONSTRAINT FK__values__objects      FOREIGN KEY (_id_object)        REFERENCES _objects   (_id) ON DELETE CASCADE,
          CONSTRAINT FK__values__structures   FOREIGN KEY (_id_structure)     REFERENCES _structures(_id) ON DELETE CASCADE,
          CONSTRAINT FK__values__array_parent FOREIGN KEY (_array_parent_id)  REFERENCES _values    (_id) ON DELETE CASCADE,
          CONSTRAINT FK__values__list_items   FOREIGN KEY (_ListItem)         REFERENCES _list_items(_id),
          CONSTRAINT FK__values__objects_ref  FOREIGN KEY (_Object)           REFERENCES _objects   (_id)
      );
      
      CREATE TABLE _structures (
          ...
          CONSTRAINT FK__structures__structures      FOREIGN KEY (_id_parent)       REFERENCES _structures(_id) ON DELETE CASCADE,
          CONSTRAINT FK__structures__schemes         FOREIGN KEY (_id_scheme)       REFERENCES _schemes(_id),
          CONSTRAINT FK__structures__types           FOREIGN KEY (_id_type)         REFERENCES _types(_id),
          CONSTRAINT FK__structures__lists           FOREIGN KEY (_id_list)         REFERENCES _lists(_id),
          CONSTRAINT FK__structures__collection_type FOREIGN KEY (_collection_type) REFERENCES _types(_id),
          CONSTRAINT FK__structures__key_type        FOREIGN KEY (_key_type)        REFERENCES _types(_id)
      );
      
      CREATE TABLE _objects (
          ...
          CONSTRAINT FK__objects__objects FOREIGN KEY (_id_parent) REFERENCES _objects(_id) ON DELETE CASCADE,
          CONSTRAINT FK__objects__schemes FOREIGN KEY (_id_scheme) REFERENCES _schemes(_id) ON DELETE CASCADE,
          CONSTRAINT FK__objects__users1  FOREIGN KEY (_id_owner)  REFERENCES _users  (_id),
          ...
      );
      

      Удалили объект — каскадом удалились все его свойства. Удалили scheme — каскадом всё, что на ней висит. Удалили scheme-родителя — children почистились (_id_parent → structures.id ON DELETE CASCADE). Поссылочный RedbObject<T>_Object тоже FK. В classic EAV ничего такого нет, потому что там нет понятия «структура» как first-class сущности.

      И всё это покрыто индексами:

      CREATE INDEX "IX__values__objects"           ON _values    (_id_object);
      CREATE INDEX "IX__values__structures"        ON _values    (_id_structure);
      CREATE INDEX "IX__values__array_parent_id"   ON _values    (_array_parent_id);
      CREATE INDEX "IX__values__array_parent_index" ON _values   (_array_parent_id, _array_index);
      CREATE INDEX "IX__values__array_key"         ON _values    (_id_structure, _array_index) WHERE _array_index IS NOT NULL;
      CREATE INDEX "IX__objects__schemes"          ON _objects   (_id_scheme);
      CREATE INDEX "IX__objects__objects"          ON _objects   (_id_parent);
      CREATE INDEX "IX__objects__hash"             ON _objects   (_hash);
      CREATE INDEX "IX__objects__value_long"       ON _objects   (_value_long)    WHERE _value_long    IS NOT NULL;
      CREATE INDEX "IX__objects__value_datetime"   ON _objects   (_value_datetime) WHERE _value_datetime IS NOT NULL;
      -- и т.д.
      

      То есть план запроса с idscheme + idstructure лочит ровно нужные строки через covering-индекс, а не делает seq scan на гигантский _values.

      3. Полноценное RTTI прямо в БД — C# не обязателен

      Это вторая часть, которой нет в EAV вообще. У redb схема описана как first-class данные:

      • _types — таблица типов (Long, String, Numeric, DateTimeOffset, Guid, Boolean, Object, ListItem, ByteArray, Array, Dictionary, Class, JsonDocument, XDocument). Это отдельная справочная таблица, не C#-enum.

      • schemes — схемы (имя, тип через FK на types, версия, родительская схема для наследования).

      • structures — поля схем (имя, тип через FK на types, признак коллекции через FK на types, тип ключа словаря через FK на types, ссылка на child-scheme для вложенных POCO).

      То есть БД сама знает, что EmployeeProps.OfficeLocations — это Dictionary<string, Address>, где Address — отдельная scheme с полями City: text и Street: text. И всё это видно одним SELECT:

      SELECT
          s._name         AS field,
          t._name         AS type,
          ct._name        AS collection_type,   -- Array / Dictionary / NULL
          kt._name        AS key_type,          -- для Dictionary
          cs._name        AS child_scheme       -- для вложенных POCO / RedbObject<T>
      FROM _structures s
      JOIN _schemes sc      ON sc._id = s._id_scheme
      JOIN _types t         ON t._id  = s._id_type
      LEFT JOIN _types ct   ON ct._id = s._collection_type
      LEFT JOIN _types kt   ON kt._id = s._key_type
      LEFT JOIN _schemes cs ON cs._id = s._id_parent
      WHERE sc._name = 'Employee';
      

      DBA, BI-инженер, аудитор — открыл psql, получил полное описание схемы без единой строки .NET-кода. C# вообще не обязателен — фасеты и схемы можно строить из Python / Node / Go / Java / raw SQL. Schema-as-data, без хождения в приложение.

      4. Теперь про DBA-инструменты — четыре конкретных ответа

      4.1. get_object_json(id, depth) — встроенная SQL-функция в обоих диалектах:

      SELECT get_object_json(42, 3);                           -- объект 42, глубина 3
      SELECT get_object_json(o._id, 5) FROM _objects o WHERE _id_scheme = 123;
      

      Полный граф объекта как JSON: вложенные структуры, массивы, словари, ссылки. «Дай мне этот заказ как есть» — одна строка SQL.

      4.2. Полиглот-API. Free-движок строит SQL внутри БД, фильтры можно слать как JSON-фасеты из любого языка:

      SELECT get_objects_json(
          42::bigint,
          '{"$and":[{"Age":{"$gt":30}},{"City":{"$eq":"London"}}]}'::jsonb,
          100, 0
      );
      

      Python (psycopg), Node (pg), Go (pgx), Java (JDBC), raw psql — .NET не нужен.

      4.3. pg_stat_statements работает. Pro-движок генерирует параметризованный SQL с $1..$N placeholders — стабильная форма, статистика агрегируется нормально. Free-движок строит SQL внутри plpgsql — в статистике видно вызовы pvt_build_query_sql() / get_objects_json() (менее гранулярно, но не ломается).

      4.4. Свои таблицы рядом — без ограничений. ExecuteAsync с произвольным DDL, FK на objects(id) с cascade delete, материализованные view поверх get_object_json для аналитики. Это типичный паттерн для аналитического слоя.

      Что не получите

      Power BI Direct Query с авто-маппингом всех колонок «прозрачно как реляционная схема» — да, не получите, и не должны. Для аналитического слоя строятся свои витрины (как и в любой системе с EAV-подобным storage, и в большинстве микросервисных архитектур тоже). redb это не запрещает — просто это твой код, а не автогенерация.

      Подытог

      EAV — это (entity, attribute, value::text) без типов, без FK, без RTTI. У redb:

      • типизированные колонки под каждый .NET-тип,

      • FK через всю схему с CASCADE — целостность гарантирует сама БД,

      • типы как first-class данные в types/schemes/_structures — схема видна из SQL без C#,

      • встроенные функции для DBA-сценариев get_object_json / get_objects_json.

      Два года в проде у двух заказчиков (HoReCa-дистрибьютор + аналитическая платформа, ~672k объектов / ~8M свойств). Инциденты были, лазили руками — get_object_json ровно для этого и пишется. Не сказал бы, что больнее, чем дебажить реляционную базу с 30 таблицами через .Include().


    1. grelikt Автор
      01.06.2026 12:36

      GroupBy + HAVING теперь в Pro (был только во Free с 1.2.x).

      Казалось бы — обычный SQL HAVING, что тут писать статью. Но в redb это не «прокинуть строку в БД», а склеить три разнородных слоя:

      1. Пивот — собрать Department (ссылка на listitems) и Age из _values в плоские колонки CTE.

      2. JOIN на справочникDepartment.Value это уже listitems._value, не listitem-id, поэтому добавляется LEFT JOIN list_items.

      3. Группировка + HAVING — поверх плоских колонок CTE, но с агрегатами по объектам, а не по строкам _values.

      LINQ:

      var stats = await service.Query<EmployeeProps>()
          .GroupBy(p => p.Department.Value)                            // 1. ключ — алиас списка
          .Where(g => g.Count() > 10 && g.Average(p => p.Age) < 40)    // 2. HAVING
          .Select(g => new { Dept = g.Key, N = g.Count() })            // 3. проекция
          .ToListAsync();
      

      Что генерируется на PG.Pro:

      WITH pvt_cte AS (
        SELECT v._id_object,
          (array_agg(v._listitem) FILTER (WHERE v._id_structure = $1))[1] AS "Department_id",
          (array_agg(v._Long)     FILTER (WHERE v._id_structure = $2))[1] AS "Age"
        FROM _values v
        JOIN (SELECT _id FROM _objects WHERE _id_scheme = $3) o ON v._id_object = o._id
        WHERE v._id_structure = ANY($4)
        GROUP BY v._id_object                              -- группировка по объекту в CTE
      )
      SELECT li._value AS "Dept", COUNT(*) AS "N"
      FROM _objects o
      JOIN pvt_cte pvt   ON o._id = pvt._id_object
      LEFT JOIN _list_items li ON li._id = pvt."Department_id"   -- единственный JOIN к справочнику
      GROUP BY li._value                                   -- группировка по value, не по id
      HAVING COUNT(*) > $5 AND AVG(pvt."Age") < $6;        -- HAVING на flat-колонках CTE
      
      -- $1 = 130   (structure id of Department)
      -- $2 = 101   (structure id of Age)
      -- $3 = 42    (scheme id Employee)
      -- $4 = {130, 101}
      -- $5 = 10
      -- $6 = 40
      

      Заметные детали:

      • Department в values хранится как listitem (FK на listitems) — не строка. Поэтому Department.Value требует JOIN, а не текстового сравнения, и индекс по listitems._id отрабатывает.

      • GROUP BY идёт по li._value, не по pvt.Department_id — потому что одинаковое значение могут шарить несколько list-item’ов, и пользователь группирует по бизнес-смыслу, а не по id.

      • HAVING живёт на плоских колонках CTEAVG(pvt."Age"), а не AVG(_Long). Это позволяет SQL-оптимизатору переставлять предикаты как обычно.

      • Параметризация полная$1..$6, форма запроса стабильна → план кэшируется PostgreSQL.

      То же самое теперь работает на MSSql.Pro (через MAX(CASE WHEN …)), на PG Free и MSSql Free (с инлайн-литералами через format %L / QUOTENAME соответственно). GroupByHavingTestsBase: 33/33 HAVING + 6/6 без HAVING на всех четырёх тирах, identical row counts везде.

      Если LINQ вам нравится больше, чем сырой SQL с CTE — этот один пример хорошо показывает, во что компилятор разворачивает три строчки .GroupBy(...).Where(...).Select(...) и почему просто «прокинуть в Postgres» тут не получилось бы.


  1. grelikt Автор
    01.06.2026 12:36

    Почему сложные графы быстрее — не маркетинг, а механика

    Три конкретных причины:

    1. Чтение — всегда из двух таблиц, не из N

    В EF Core граф из 28 сущностей = 28 таблиц = JOIN-каскад или N+1 запросов. Планировщик PostgreSQL/MSSQL должен соединить 28 источников, построить план, выбрать стратегию join для каждой пары.

    В redb — всегда objects + values, независимо от глубины графа. Планировщик работает с двумя таблицами с предсказуемыми индексами: (id_scheme), (id_object), (id_structure). Один CTE, один проход.

    2. Запись — bulk COPY, не каскад INSERT/UPDATE

    EF Core при сохранении графа генерирует цепочку INSERT/UPDATE по каждой таблице в правильном порядке (из-за FK). 28 сущностей = минимум 28 round-trip'ов или сложный батч с соблюдением порядка зависимостей.

    redb при SaveAsync собирает все изменённые _values-строки в один список и пишет их через COPY (PostgreSQL) или SqlBulkCopy (MSSQL) за один вызов. Один network round-trip на весь граф любой глубины.

    3. Материализация — параллельно по веткам графа

    Pro-режим: при LoadAsync граф загружается через Parallel.ForEach — каждая ветка объектного дерева материализуется в своём потоке. Ветка order.Items и ветка order.Shipping читаются одновременно, не последовательно.

    В EF Core с .Include() это в принципе невозможно без ручного разбиения на несколько запросов — DbContext не thread-safe.