REDB изнутри, статья 1: 13 таблиц, на которых работает всё

от автора

REDB SQL

REDB SQL

В предыдущей статье я разобрал что умеет REDB на практике: code-first схемы, LoadAsync без Include, LINQ с оконными функциями, деревья, redb.Route и redb.Tsak. Если не читали — начните с неё, она даёт общую картину.

Теперь начинаю цикл «REDB изнутри» — серия статей про устройство хранилища: как хранятся объекты и свойства, как работают схемы и кэш структур, как LINQ превращается в SQL, как устроены деревья, права, оконные функции. Всё с реальным SQL под капотом.

Первая статья цикла — про саму базу данных. Без понимания схемы таблиц всё остальное будет висеть в воздухе.

Вся магия REDB делается в 13 таблицах. Никакого JSON-блоба, никакого NVARCHAR(MAX) для всего подряд — каждый тип данных в своей типизированной колонке. Разберём как это работает и почему это не классический EAV.


Почему не «EAV»

Прежде чем смотреть на таблицы — важное терминологическое замечание.

EAV (Entity–Attribute–Value) — это паттерн хранения данных, при котором строки таблицы выглядят так:

object_id | attribute_name | value----------|----------------|-------42        | "FirstName"    | "Alice"42        | "Age"          | "28"42        | "Salary"       | "85000"

Всё в одной таблице, типы стёрты, имена атрибутов — просто строки. Любой JOIN адский. Запрос «найди сотрудников с зарплатой > 80000» — это самоджойн или pivot. EXPLAIN выдаёт Seq Scan размером с диссертацию.

REDB использует другую схему. В values — не имена строками, а ссылки на structures (_id_structure bigint NOT NULL). Тип значения известен в момент записи. Для каждого C#-типа — своя колонка:

_String         text_Long           bigint_Guid           uuid_Double         float_DateTimeOffset timestamptz_Boolean        boolean_ByteArray      bytea_Numeric        NUMERIC(38, 18)_ListItem       bigint   -- FK → _list_items_Object         bigint   -- FK → _objects (ссылка на объект)

Это ближе к RTTI (Run-Time Type Information): тип поля известен из structures.id_type, и значение кладётся в правильную колонку. При чтении — CASE WHEN db_type = 'Long' THEN Long WHEN dbtype = 'String' THEN _String ... — один проход по строкам, никаких самоджойнов.

Разница принципиальная:

EAV

REDB (_values)

Тип поля

строка в attribute_name

FK → structures → types

Хранение значения

всё в одну value text колонку

в типизированную колонку (_Long_String, …)

Фильтр Salary > 80000

WHERE attribute_name='Salary' AND value::int > 80000

WHERE idstructure = @struct_id AND _Long > 80000

Индекс по значению

по строке, cast на лету

по типизированной колонке (partial index WHERE _Long IS NOT NULL)

Поддержка массивов

дополнительная таблица / JSON

arrayparent_id + arrayindex в той же таблице


Обзор таблиц

Полная схема — 13 таблиц + 2 служебных, которые живут в отдельных SQL-файлах.

_types          — справочник типов (~37 записей)_schemes        — схемы (C# классы → строки в БД)_structures     — поля схем (с поддержкой вложенности и коллекций)_objects        — объекты (строки данных, дерево через self-FK)_values         — значения полей (RTTI-хранение + коллекции)_lists          — справочники_list_items     — элементы справочников_users          — пользователи_roles          — роли_users_roles    — M2M: пользователь ↔ роль_permissions    — права на объекты (с наследованием по дереву)_links          — M2M связи между объектами_functions      — хранимые выражения для схем_dependencies   — межсхемные зависимости---_scheme_metadata_cache  — денормализованный кэш structures+types_migrations             — история миграций Props

Разберём послойно.


Слой типов: _types

CREATE TABLE _types (    _id     bigint NOT NULL,    _name   text NOT NULL UNIQUE,    _db_type text NULL,   -- колонка в _values: 'Long', 'String', 'Guid', ...    _type   text NULL,    -- C#-тип: 'long', 'string', 'Guid', ...    CONSTRAINT PK__types PRIMARY KEY (_id));

Все ID — отрицательные константы из диапазона около Long.MinValue. Примеры:

_id

_name

dbtype

C# тип

-9223372036854775709

Boolean

Boolean

bool

-9223372036854775708

DateTime

DateTimeOffset

DateTime

-9223372036854775704

Long

Long

long

-9223372036854775700

String

String

string

-9223372036854775695

Decimal

Numeric

decimal

-9223372036854775675

Class

вложенный класс (маркер)

-9223372036854775668

Array

T[] / List<T> (маркер)

-9223372036854775667

Dictionary

Dictionary<K,V> (маркер)

Числа выглядят страшно — это просто константы, гарантирующие, что системные типы никогда не пересекутся с пользовательскими данными. У Class/Array/Dictionary нет своей колонки в _values — это маркерные типы, значения хранятся в дочерних строках.

Всего ~37 типов: числовые (Int/Short/Byte/Float → хранятся в Long/Double), строковые (Email/Url/Phone → String), временны́е (DateOnly/TimeOnly/TimeSpan), географические (Latitude/Longitude), файловые (FilePath/MimeType), Enum/EnumInt, коллекционные (Array/Dictionary/JsonDocument/XDocument).


Слой схем: schemes + structures

CREATE TABLE _schemes (    _id             bigint NOT NULL,    _id_parent      bigint NULL,          -- для пространств имён    _name           text NOT NULL UNIQUE, -- полное имя: 'MyApp.Models.EmployeeProps'    _alias          text NULL,    _structure_hash uuid NULL,            -- hash полей для быстрой проверки изменений    _type           bigint NOT NULL       -- FK → _types (Class по умолчанию));CREATE TABLE _structures (    _id             bigint NOT NULL,    _id_parent      bigint NULL,     -- вложенный Props-класс    _id_scheme      bigint NOT NULL, -- FK → _schemes    _id_type        bigint NOT NULL, -- FK → _types    _id_list        bigint NULL,     -- FK → _lists (для ListItem-полей)    _name           text NOT NULL,   -- имя поля (C# property name)    _alias          text NULL,    _order          bigint NULL,    _collection_type bigint NULL,    -- NULL=скаляр, Array_ID / Dictionary_ID    _key_type       bigint NULL,     -- тип ключа для Dictionary<K,V>    _readonly       boolean NULL,    _allow_not_null boolean NULL,    _is_compress    boolean NULL,    _store_null     boolean NULL,    _default_value  bytea NULL);

Как схемы связаны с C#

Когда вы пишете:

[RedbScheme("Сотрудник")]public class EmployeeProps{    public string FirstName { get; set; }    public int Age { get; set; }    public decimal Salary { get; set; }    public string[] Skills { get; set; }    public Address HomeAddress { get; set; }   // вложенный класс}

И вызываете await redb.SyncSchemeAsync<EmployeeProps>(), система:

  1. Создаёт строку в _schemes с именем MyApp.EmployeeProps

  2. Создаёт строки в _structures для каждого поля

  3. Для Skills (массив) — collectiontype = Array_ID

  4. Для HomeAddress (вложенный класс) — idtype = Class_ID, и рекурсивно создаёт дочерние structures с id_parent = HomeAddress._structure_id

Триггер на уровне БД проверяет имена полей: запрещены системные (_idnamedate_create и т.д.), C#-зарезервированные (classintstring…), имена не должны начинаться с цифры.


Слой объектов: _objects

CREATE TABLE _objects (    _id             bigint NOT NULL,    _id_parent      bigint NULL,          -- родитель в дереве (self-FK)    _id_scheme      bigint NOT NULL,      -- FK → _schemes    _id_owner       bigint NOT NULL,      -- FK → _users    _id_who_change  bigint NOT NULL,      -- FK → _users    _date_create    timestamptz NOT NULL,    _date_modify    timestamptz NOT NULL,    _date_begin     timestamptz NULL,    _date_complete  timestamptz NULL,    _key            bigint NULL,    _name           text NULL,    _note           text NULL,    _hash           uuid NULL,    -- value-колонки для RedbPrimitive<T>    _value_long     bigint NULL,    _value_string   text NULL,    _value_guid     uuid NULL,    _value_bool     boolean NULL,    _value_double   float NULL,    _value_numeric  NUMERIC(38, 18) NULL,    _value_datetime timestamptz NULL,    _value_bytes    bytea NULL);

Несколько нетривиальных вещей:

Дерево через idparent — ON DELETE CASCADE. Удаление объекта автоматически удаляет всё поддерево, любой глубины. Это основная структура для организации данных в REDB: разделы, категории, папки, оргструктура — всё одним механизмом.

value* колонки — для RedbPrimitive<T>. Когда объект сам является примитивным значением (например, RedbObject<long> для счётчика или RedbObject<string> для токена), значение хранится прямо в строке объекта, без _values. Это 8 колонок — по одной на каждый db-тип.

Мягкое удаление — специальная схема @@__deleted (_id = -10). Функция mark_for_deletion() рекурсивно находит все потомки через CTE и атомарно перемещает их под TRASH-контейнер (_id_scheme = -10). Физическое удаление — отдельная purge_trash() батч-операция.


Слой значений: _values

Это самая насыщенная таблица. Здесь живут все значения всех полей всех объектов.

CREATE TABLE _values (    _id              bigint NOT NULL,    _id_structure    bigint NOT NULL,   -- FK → _structures    _id_object       bigint NOT NULL,   -- FK → _objects    -- типизированные value-колонки    _String          text NULL,    _Long            bigint NULL,    _Guid            uuid NULL,    _Double          float NULL,    _DateTimeOffset  timestamptz NULL,    _Boolean         boolean NULL,    _ByteArray       bytea NULL,    _Numeric         NUMERIC(38, 18) NULL,    _ListItem        bigint NULL,       -- FK → _list_items    _Object          bigint NULL,       -- FK → _objects (ссылка на другой объект)    -- поля для реляционного хранения коллекций    _array_parent_id bigint NULL,       -- FK → _values (родительский элемент)    _array_index     text NULL          -- '0','1','2' для массивов, ключ для словарей);

Как хранятся коллекции

Это самое нетривиальное место. Коллекции хранятся реляционно прямо в _values, без дополнительных таблиц:

Скалярное поле (например, Age = 28):

_id_structure=struct_Age  _id_object=42  _Long=28  _array_parent_id=NULL  _array_index=NULL

Массив строк (Skills = ["C#", "SQL", "React"]):

-- маркер: "массив присутствует" (без него свойство = null, а не [])_id=100  _id_structure=struct_Skills  _id_object=42  _array_index=NULL  _array_parent_id=NULL-- элементы с индексами_id=101  _id_structure=struct_Skills  _id_object=42  _String="C#"     _array_index='0'  _array_parent_id=100_id=102  _id_structure=struct_Skills  _id_object=42  _String="SQL"    _array_index='1'  _array_parent_id=100_id=103  _id_structure=struct_Skills  _id_object=42  _String="React"  _array_index='2'  _array_parent_id=100

Словарь (PhoneDir = {"home": "+7...", "work": "+7..."}):

-- маркер словаря_id=200  _id_structure=struct_PhoneDir  _id_object=42  _array_index=NULL  _array_parent_id=NULL-- записи с строковыми ключами_id=201  _id_structure=struct_PhoneDir  _id_object=42  _String="+7 999..."  _array_index='home'  _array_parent_id=200_id=202  _id_structure=struct_PhoneDir  _id_object=42  _String="+7 495..."  _array_index='work'  _array_parent_id=200

Вложенный класс (Address.City = "Москва") — дочерние поля Address имеют idparent в structures, а их values имеют arrayparent_id указывающий на маркер-строку Address-поля.

Три уникальных индекса в _values обеспечивают консистентность всех этих случаев:

  • UIX__values__structure_object — WHERE arrayindex IS NULL AND arrayparent_id IS NULL

  • UIX__values__structure_object_parent — WHERE arrayindex IS NULL AND arrayparent_id IS NOT NULL

  • UIX__values__structure_object_array_index — WHERE arrayindex IS NOT NULL


Права: _permissions

CREATE TABLE _permissions (    _id      bigint NOT NULL,    _id_role bigint NULL,   -- XOR с _id_user (CHECK constraint)    _id_user bigint NULL,    _id_ref  bigint NOT NULL,  -- 0 = глобальные права, иначе ID объекта    _select  boolean NULL,    _insert  boolean NULL,    _update  boolean NULL,    _delete  boolean NULL);

Права наследуются по дереву объектов — рекурсивный CTE идёт вверх до 50 уровней в поисках ближайшего предка с явно заданными правами. idref = 0 — глобальный fallback (права на всё). Приоритет: user > role, конкретный объект > глобальные.

Есть автотриггер: при создании дочернего объекта — если у родителя ещё нет собственных прав — триггер автоматически создаёт ему запись, копируя права ближайшего предка. Это сокращает глубину рекурсии при каждом чтении прав.


schememetadata_cache — зачем нужен кэш

Каждый запрос к данным требует знать: для объекта с idscheme = X, какие поля (_structures) существуют и какого типа? Это JOIN structures → types, который повторяется на каждом обращении.

Решение — денормализованная таблица schememetadata_cache. Она содержит все колонки structures + typenamedb_typetype_semanticcollection_type_name из _types — плоской строкой, без джойна.

CREATE TABLE _scheme_metadata_cache (    _scheme_id       bigint NOT NULL,    _structure_id    bigint NOT NULL,    _parent_structure_id bigint,    _name            text NOT NULL,    type_name        text NOT NULL,  -- 'Long', 'String', 'Guid', ...    db_type          text NOT NULL,  -- 'Long', 'String', 'Guid', ...    type_semantic    text NOT NULL,  -- 'Object', '_RObject', 'Array', ...    _collection_type bigint,    collection_type_name text,    _key_type        bigint,    key_type_name    text,    -- ... остальные атрибуты _structures);

Кэш автоматически обновляется триггером при изменении schemes.structure_hash. При запросе к схеме, чьего кэша нет — вызывается sync_metadata_cache_for_scheme(scheme_id). Вся функция build_hierarchical_properties_optimized() (которая собирает JSON объекта) работает исключительно через этот кэш.


Два SQL-запроса: заглянуть внутрь объекта

Запрос 1 — без кэша (прямые join)

Для объекта с заданным ID — плоский список всех полей с именем схемы, именем поля, типом и значением.

-- PostgresSELECT    s._name                                          AS scheme_name,    st._name                                         AS field_name,    t._name                                          AS type_name,    CASE t._db_type        WHEN 'String'        THEN v._String        WHEN 'Long'          THEN v._Long::text        WHEN 'Guid'          THEN v._Guid::text        WHEN 'Double'        THEN v._Double::text        WHEN 'Boolean'       THEN v._Boolean::text        WHEN 'DateTimeOffset' THEN v._DateTimeOffset::text        WHEN 'Numeric'       THEN v._Numeric::text        WHEN 'ListItem'      THEN v._ListItem::text        WHEN 'Object'        THEN v._Object::text        WHEN 'ByteArray'     THEN encode(v._ByteArray, 'base64')        ELSE                      NULL    END                                              AS value_text,    CASE        WHEN v._array_parent_id IS NULL         AND v._array_index  IS NULL         AND t._name IN ('Array','Dictionary','Class') THEN 'collection_marker'        WHEN v._array_parent_id IS NULL         AND v._array_index  IS NULL                  THEN 'scalar'        WHEN v._array_parent_id IS NOT NULL           THEN 'element[' || v._array_index || ']'        ELSE                                               'scalar'    END                                              AS slot,    v._array_index,    v._array_parent_idFROM _values      vJOIN _structures  st ON st._id = v._id_structureJOIN _schemes     s  ON s._id  = st._id_schemeJOIN _types       t  ON t._id  = st._id_typeWHERE v._id_object = :object_id   -- подставить нужный IDORDER BY st._order NULLS LAST, v._array_index NULLS FIRST;
-- MS SQL (те же данные, синтаксис параметра @object_id)SELECT    s._name                                       AS scheme_name,    st._name                                      AS field_name,    t._name                                       AS type_name,    CASE t._db_type        WHEN 'String'         THEN v._String        WHEN 'Long'           THEN CAST(v._Long AS nvarchar(MAX))        WHEN 'Guid'           THEN CAST(v._Guid AS nvarchar(MAX))        WHEN 'Double'         THEN CAST(v._Double AS nvarchar(MAX))        WHEN 'Boolean'        THEN CASE v._Boolean WHEN 1 THEN 'true' WHEN 0 THEN 'false' END        WHEN 'DateTimeOffset' THEN CAST(v._DateTimeOffset AS nvarchar(MAX))        WHEN 'Numeric'        THEN CAST(v._Numeric AS nvarchar(MAX))        WHEN 'ListItem'       THEN CAST(v._ListItem AS nvarchar(MAX))        WHEN 'Object'         THEN CAST(v._Object AS nvarchar(MAX))        WHEN 'ByteArray'      THEN CAST('' AS nvarchar(MAX))  -- base64 через приложение        ELSE                       NULL    END                                           AS value_text,    CASE        WHEN v._array_parent_id IS NULL AND v._array_index IS NULL THEN 'scalar'        WHEN v._array_parent_id IS NOT NULL THEN 'element[' + ISNULL(v._array_index,'') + ']'        ELSE 'scalar'    END                                           AS slot,    v._array_index,    v._array_parent_idFROM [dbo].[_values]      vJOIN [dbo].[_structures]  st ON st._id = v._id_structureJOIN [dbo].[_schemes]     s  ON s._id  = st._id_schemeJOIN [dbo].[_types]       t  ON t._id  = st._id_typeWHERE v._id_object = @object_idORDER BY st._order, v._array_index;

Этот запрос — диагностический. Он даёт полную картину: что именно хранится для объекта, в каком слоте, с каким типом. Маркерные строки массивов (_array_parent_id IS NULL AND arrayindex IS NULL) тоже видны — именно их наличие/отсутствие отличает null от [].


Запрос 2 — через schememetadata_cache

Это то, что в реальности делает движок при чтении. Кэш уже содержит денормализованные type_name/db_type, поэтому JOIN с types и structures не нужен:

-- Postgres (через _scheme_metadata_cache)SELECT    c._scheme_id                                      AS scheme_id,    c._name                                           AS field_name,    c.type_name                                       AS type_name,    c.db_type                                         AS db_type,    c.collection_type_name                            AS collection_type,    CASE c.db_type        WHEN 'String'         THEN v._String        WHEN 'Long'           THEN v._Long::text        WHEN 'Guid'           THEN v._Guid::text        WHEN 'Double'         THEN v._Double::text        WHEN 'Boolean'        THEN v._Boolean::text        WHEN 'DateTimeOffset' THEN v._DateTimeOffset::text        WHEN 'Numeric'        THEN v._Numeric::text        WHEN 'ListItem'       THEN v._ListItem::text        WHEN 'Object'         THEN v._Object::text        WHEN 'ByteArray'      THEN encode(v._ByteArray, 'base64')        ELSE                       NULL    END                                               AS value_text,    v._array_index,    v._array_parent_id,    c._orderFROM _values                  vJOIN _scheme_metadata_cache   c ON c._structure_id = v._id_structureJOIN _objects                 o ON o._id = v._id_objectWHERE v._id_object = :object_idORDER BY c._order NULLS LAST, v._array_index NULLS FIRST;
-- MS SQL (через _scheme_metadata_cache)SELECT    c.[_scheme_id]                                     AS scheme_id,    c.[_name]                                          AS field_name,    c.[type_name]                                      AS type_name,    c.[db_type]                                        AS db_type,    c.[collection_type_name]                           AS collection_type,    CASE c.[db_type]        WHEN 'String'         THEN v._String        WHEN 'Long'           THEN CAST(v._Long AS nvarchar(MAX))        WHEN 'Guid'           THEN CAST(v._Guid AS nvarchar(MAX))        WHEN 'Double'         THEN CAST(v._Double AS nvarchar(MAX))        WHEN 'Boolean'        THEN CASE v._Boolean WHEN 1 THEN 'true' WHEN 0 THEN 'false' END        WHEN 'DateTimeOffset' THEN CAST(v._DateTimeOffset AS nvarchar(MAX))        WHEN 'Numeric'        THEN CAST(v._Numeric AS nvarchar(MAX))        WHEN 'ListItem'       THEN CAST(v._ListItem AS nvarchar(MAX))        WHEN 'Object'         THEN CAST(v._Object AS nvarchar(MAX))        WHEN 'ByteArray'      THEN N'<binary>'        ELSE                       NULL    END                                                AS value_text,    v._array_index,    v._array_parent_id,    c.[_order]FROM [dbo].[_values]                  vJOIN [dbo].[_scheme_metadata_cache]   c ON c.[_structure_id] = v.[_id_structure]JOIN [dbo].[_objects]                 o ON o.[_id] = v.[_id_object]WHERE v.[_id_object] = @object_idORDER BY c.[_order], v.[_array_index];

Чем второй лучше первого в продакшне:

  1. Нет JOIN на structures и types — два тяжёлых джойна убраны

  2. Кэш уже отсортирован по order + проиндексирован по structure_id

  3. Сам кэш заполняется один раз при изменении схемы (_structure_hash), а не при каждом запросе

  4. Функция build_hierarchical_properties_optimized идёт ещё дальше: она загружает все values объекта в массив values[] одним запросом и потом работает только с памятью через unnest() — ни одного повторного обращения к таблице


Как это связано с C#

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

[RedbScheme("Сотрудник")]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[] Skills  { get; set; }}// InitializeAsync сканирует сборку → создаёт строки в _schemes + _structuresawait redb.InitializeAsync(typeof(EmployeeProps).Assembly);// SaveAsync → строка в _objects + строки значений в _valuesvar employee = new RedbObject<EmployeeProps>{    name  = "Алиса Иванова",    Props = new EmployeeProps    {        FirstName = "Алиса",        LastName  = "Иванова",        Age       = 28,        Salary    = 120_000m,        Skills    = ["C#", "PostgreSQL", "Redis"]  // → маркер + 3 строки в _values    }};long id = await redb.SaveAsync(employee);// LoadAsync → SELECT _objects + SELECT _values WHERE _id_object = id// → рекурсивная сборка через build_hierarchical_properties_optimizedvar loaded = await redb.LoadAsync<EmployeeProps>(id);

SaveAsync обращается к schememetadata_cache чтобы узнать структуру полей, генерирует ключи через sequence global_identity, кладёт строку в objects и строки значений в valuesLoadAsync читает objects, затем все values одним SELECT в массив values[], рекурсивно собирает иерархию через buildhierarchical_properties_optimized — исключительно через unnest() в памяти, без повторных обращений к таблице.


Почему именно так

Несколько решений, которые не очевидны с первого взгляда:

Отрицательные константы для системных ID — гарантия, что пользовательские данные (через global_identity, который стартует с 1_000_000) никогда не пересекутся с системными типами/схемами/пользователями.

structurehash в schemes — позволяет не пересчитывать кэш при каждом запросе. Хеш меняется только при изменении полей схемы. Триггер на schemes инвалидирует кэш точечно, без перестройки всего.

Маркерные строки в values — нетривиальный выбор. Для коллекций нет отдельной таблицы. Маркер (array_parent_id = NULL, arrayindex = NULL) отличает null (нет маркера) от [] (маркер есть, но дочерних элементов нет). Это позволяет хранить произвольно вложенные структуры без дополнительных таблиц.

_Numeric NUMERIC(38, 18) — сознательный выбор точности для финансовых расчётов. double для денег не подходит из-за накапливаемой ошибки с плавающей точкой.

Postgres vs MSSQL — реальное отличие в cascade delete: в Postgres values имеет ON DELETE CASCADE по FK на structures. В MSSQL это невозможно (ограничение «multiple cascade paths»), поэтому вместо FK — INSTEAD OF DELETE триггер, который делает то же самое вручную.


Цикл «REDB изнутри»

  • Вводная статья — что умеет REDB: LINQ, деревья, redb.Route, redb.Tsak, production cases (уже опубликована)

  • Статья 1 (эта) — структура БД: 13 таблиц, RTTI vs EAV, values, коллекции, scheme_metadata_cache, SQL под капотом

  • Статья 2 — Code-first схемы: как SyncSchemeAsync<T> преобразует C#-класс в строки schemes + structuresstructurehash, автоматический онбординг

  • Статья 3 — CRUD: SaveAsync и LoadAsync: change tracking (TreeDiff), bulk insert через COPY protocol, lazy loading

  • Статья 4 — LINQ-запросы: как Where(x => x.Salary > 80000) превращается в SQL с CASE WHEN idstructure = X THEN _Long END > 80000

  • Статья 5 — Дерево объектов: LoadTreeAsync, GetDescendantsAsync, WhereHasAncestor

  • Статья 6 — Оконные функции: Win.RowNumber()Win.Rank()PartitionBy/OrderBy поверх REDB-объектов


Ссылки

ссылка на оригинал статьи https://habr.com/ru/articles/1043790/