DataMaps. Рассказ о нашей ORM на Kotlin

31b4986f179500f5bcd7e29a541cf117.png

Всем привет!

Меня зовут Александр, я ведущий программист в БФТ-Холдинге. Уже несколько лет мы с командой трудимся над платформой, которая официально называется БФТ.Платформа, а менее официально — Ice. В основном наша платформа используется в органах власти и государственных учреждениях.
Сегодня хотелось бы рассказать не обо всей платформе, а только об одном из ее компонентов — ORM (object-relational mapping) — фреймворке DataMaps.

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

Внутри БФТ-Холдинга Datamaps зарекомендовали с одной стороны, как легкий, наглядный и понятный для разработчиков фреймворк, с другой стороны, мощный по своим возможностям.

Идеи для создания собственного ORM

Мы работали с Hibernate с 2005 года, и к 2018 году достаточно устали от него.
На тот момент особенно раздражало:

  1. Отсутствие нормального типизированного Api для запросов (HSQL, Criteria Api — не то). Кроме того, многие Api запросов (Hibernate — взять тот же JOOQ) находятся в объектном мире, но пытаются сделать нечто похожее на SQL. Получалось не очень.

  2. Общая негибкость при загрузке и сохранении данных:

    • логика загрузки объекта задается почти полностью маппингом, хотя на практике, в зависимости от задачи, нам требуется загружать те или иные группы полей сущности;

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

  3. Конфигурация создается один раз на время жизни приложения. С учетом того, что БФТ.Платформа — Low-Code-платформа предполагает, что пользователи, аналитики могут изменять структуру приложения, базы данных, менять формы, и опять же логику и структуру загрузки данных на эти формы. Приложение должно подхватывать изменения без перекомпиляции и передеплоя. С хибернейтом тут определенные сложности.

С другой стороны, в 2018 вовсю гремел GraphQL — API в очень наглядной форме описывающий, какие данные требуются. А Kotlin, в отличие от Java, позволял создавать DSL (domain-specific language), которые максимально-близко похожи по структуре на GraphQL.
Так появилась идея применить GraphQL-подобное API для запросов к БД, и в целом идея своего ORM.

Вот так выглядит GraphQL-запрос:

query {
  queryAuthor(id: 1) {
    name
    posts() {
      title
      text
      datePublished
    }
  }
}

Вот так выглядит запрос на DataMaps к БД:

Author.slice {
    withId(1)
    name
    posts {
        title
        text
        datePublish
    }
}

Поскольку GraphQL появился в мире js, где все очень динамично и «ликвидно» (liquid), нам изначально пришла идея отказаться от классических классов и объектов как целевых сущностей для маппинга таблиц и строк из БД. Мы взяли за основу ассоциативные массивы («карты», «мапы»), поскольку они гораздо лучше подходят для различных вариантов загрузки и передачи объектов между слоями приложения. В карту, в отличие от объекта класса, всегда можно добавить любое требуемое прямо здесь и сейчас количество дополнительных полей. И в карте не требуется возить неинициализированными 98 полей объекта класса в 100 полей, если нам нужны только пара.

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

  • Не требуется создавать несколько наборов классов вида Entity, DTO. В нашем случае всё есть карта — и Entity, и DTО.

  • Изменения в БД отсылаются дельтами, в которых есть только то, что реально поменялось в транзакции. Дельты собираются автоматически при изменении состояния карт. Изменения применяются в БД автоматически по завершении транзакции или вручную командой flush ().

  • Поскольку карта сама по себе не типизирована, вводятся «филдсеты» — наборы полей, описывающих сущность. Филдсеты одновременно служат и операторами типизированного доступа к карте с данными, и примитивами для построения GraphQL-подобных запросов, и маппингами, описывающими объектно-реляционное отражение.

  • Вводится операция «апгрейда» — явная догрузка части объектного графа при необходимости (сравните с lazy loading).

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

Далее изложим чуть более систематично.

Основные концепции DataMaps

  • Entity («сущность») — единица модели данных, имеющая разное представление в разных слоях приложения (таблица на уровне БД; тип, класс на уровне приложений);

  • DataMap («карта данных», «датамап») — ассоциативный массив, соответствующий экземпляру одной конкретной сущности;

  • FieldSet («набор полей», «филдсет») — наборы полей сущности, отражающие связь между «объектной» и реляционной моделью;

  • Projection («проекция», «слайс») — GraphQL-подобный DSL для выгрузки графа из БД;

  • DataService («сервис данных», «датасервис») — интерфейс для работы с БД, осуществляющий CRUD операции.

DataMap — карта значений

Карта данных — карта с интерфейсом Map. Помимо собственно ключей-значений, карты имеют несколько системных свойств, среди которых два обязательных –entity и id, наименование объекта и его первичный ключ.

Типовой пример работы с любым объектом данных: получить из БД, прочитать поля, изменить поля и сохранить изменения.
Покажем как это делается в Datamaps.

Запрашиваем в БД Пушкина:

val pushkin = dataService.find(Person.slice {
    withId(1000L)
    name
    lastName
    birthday
    email
    gender {
        name
    }
    city
    works {
        work
    }
})

Печатаем Пушкина в json:

println(pushkin.toJson())
{
  "entity": "person",
  "id": 1,
  "name": "Александр",
  "lastname": "Пушкин",
  "email": "pushkin.a.s@mail.ru",
  "birthday": "1799-05-25T00:00:00",
  "gender": {
    "entity": "gender",
    "id": 2,
    "name": "man"
  },
  "city": {
    "entity": "city",
    "id": 1,
    "name": "Москва",
    "population": 13104177
  },
  "works": [
    {
      "entity": "personwork",
      "id": 1,
      "work": {
        "entity": "work",
        "id": 1,
        "name": "Руслан и Людмила"
      }
    },
    {
      "entity": "personwork",
      "id": 2,
      "work": {
        "entity": "work",
        "id": 2,
        "name": "Пиковая дама"
      }
    }
  ]
}

Читаем отдельные свойства Пушкина разными способами:

val name = pushkin["name"]  
val lastName = pushkin[{lastName}]
val birthCity  = pushkin[{city().name}]
val birthday = pushkin[Person.birthday]

Пишем в отдельные свойства Пушкина разными способами и сохраняем:

pushkin["name"] = "Alex"
pushkin[{lastName}] = "Pushkin"
pushkin[{city().name}] = "Moscow"
pushkin[Person.birthday] = Date(1799, 6, 6)

dataService.flush()

В приведенным примерах Person — это «филдсет».

FieldSet — наборы полей

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

Филдсет, который описывает предыдущий пример:

object Person : MappingFieldSet() {
    val id by Fields.id()
    val name by Fields.stringNN() { caption = "Имя" }
    val lastName by Fields.stringNN() { caption = "Фамилия" }
    val email by Fields.string() { caption = "Почта"; pattern = emailRegex }
    val birthdate by Fields.date() { caption = "Дата рождения" }
    val gender by Fields.referenceNN(Gender) { caption = "Пол" }
    val city by Fields.reference(City) { caption = "Город" }
    val works by Fields.list(PersonWork) { caption = "Произведения" }

    override val nativeKey = listOf(name, lastName)
}

В данном случае мы определили несколько полей:

  • id — обязательное поле для всех сущностей. Может быть разных типов. В данном случае int.

  • name, lastName, email, birthdate — скалярные (простые) поля разных типов. Поля с окончанием NN — ненулевые (not null) обязательные к заполнению.

  • gender и city — ссылочные поля на другие сущности.

  • works — список объектов сущности PersonWork.

Для каждого поля указали необязательный атрибут caption. Он удобен для построения форм редактирования, списочных форм.
Для поля email указали паттерн, которому должно соответствовать поле.
Можно было бы ещё указать длину строк, ограничения для поля даты, подсказки, значения по умолчанию и многое другое.
В конце филдсета указываем естественный ключ — набор полей name и lastName.

Структура БД из примеров

Структура таблиц БД, с которыми мы работает приведена ниже.

CREATE TABLE GENDER
(
    id   SERIAL PRIMARY KEY,
    name VARCHAR(30) UNIQUE NOT NULL
);

CREATE TABLE CITY
(
    id         SERIAL PRIMARY KEY,
    name       VARCHAR(30) UNIQUE NOT NULL,
    population INTEGER
);

CREATE TABLE WORK
(
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(30) UNIQUE NOT NULL
);

CREATE TABLE PERSON
(
    id        SERIAL PRIMARY KEY,
    name      VARCHAR(30) NOT NULL,
    lastName  VARCHAR(30) NOT NULL,
    email     VARCHAR(50),
    birthdate DATE,
    genderId  INTEGER     NOT NULL,
    cityId    INTEGER,
    FOREIGN KEY (genderId) REFERENCES GENDER (id),
    FOREIGN KEY (cityId) REFERENCES CITY (id),
    CONSTRAINT name_lastName_uq unique (name, lastName)
);

CREATE TABLE PERSONWORK
(
    id       SERIAL PRIMARY KEY,
    workId   INTEGER,
    personId INTEGER,
    FOREIGN KEY (workId) REFERENCES WORK (id) ON DELETE CASCADE,
    FOREIGN KEY (personId) REFERENCES PERSON (id) ON DELETE CASCADE
)

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

Создание сущности

Пример создания и сохранения новых сущностей в БД:

Person {
    it[name] = "Александр"
    it[lastName] = "Пушкин"
    it[email] = "pushkin.a.s@mail.ru"
    it[birthdate] = Date(1799, 6, 6)
    it[gender] =
        dataService.find_(Gender.filter { name eq "man" }) // Найдём имеющуюся запись man и сразу её присвоим полю.
                                                           // Метод find_ ищет ровно одну запись. 
                                                           // Если находится 0 или более 1 - exception.
    it[city] = City { // Город создадим "на лету" и сразу присвоим свойству в Пушкине
        [name] = "Москва"
        [population] = 13_104_177
    }
    // А тут добавим книги, которые создали ранее
    it[works].addAll(
        PersonWork {
            it[work] = ruslanILudmila
        },
        PersonWork {
            it[work] = pikovayaDama
        }
    )
}

dataService.flush() // Сохраняем в БД. Обычно этот метод явно не вызывается, поскольку конец границ действия 
                    // @Transactional вызовет flush автоматически

Посмотрим, какой SQL код будет сгенерирован:

INSERT INTO city (name, population)
VALUES (?, ?)
with params {_name=Москва, _population=13104177};

INSERT INTO person (name, lastname, email, birthdate, genderid, cityid)
VALUES (?, ?, ?, ?, ?, ?)
with params {_name=Александр, _lastname=Пушкин, _email=pushkin.a.s@mail.ru, _birthdate=1899-05-26, _gender=2, _city=1};

INSERT INTO personwork (workid, personid)
VALUES (?, ?)
with params {_work=1, _person=1};

INSERT INTO personwork (workid, personid)
VALUES (?, ?)
with params {_work=2, _person=1};

Изменение

Изменение данных такое же лёгкое, как и создание.

val famousAuthor = dataService.find_( Person.withId(1).full())

famousAuthor[email] = "sukin.syn@mail.ru"
famousAuthor[city] = dataServce.find_(City.filter { name eq "Тегусигальпа" })
famousAuthor[works].add(PersonWork {
    it[work] = metel
})
famousAuthor[works[0].work().name] = "Руслан и Людмила (1820 г.)"

При завершении транзакции или принудительном вызове dataService.flush () данные записываются в БД.

Projection — проекция

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

Person.slice {
    name
    lastName
    email 
    
    city {
        name
    }
   
    works {
        work {
           name
        }
    }
}

Полученные данные

{
   "entity": "person",
   "id": 1,
   "name": "Александр",
   "lastname": "Пушкин",
   "email": "sukin.syn@mail.ru",
   "birthday": "1799-05-25T00:00:00",
   "gender": {
      "entity": "gender",
      "id": 2,
      "name": "man"
   },
   "city": {
      "entity": "city",
      "id": 1,
      "name": "Тегусигальпа",
      "population": 1682725
   },
   "works": [
      {
         "entity": "personwork",
         "id": 1,
         "work": {
            "entity": "work",
            "id": 1,
            "name": "Руслан и Людмила (1820 г.)"
         }
      },
      {
         "entity": "personwork",
         "id": 2,
         "person": {
            "entity": "work",
            "id": 1,
            "name": "Пиковая дама"
         }
      },
      {
         "entity": "personwork",
         "id": 3,
         "person": {
            "entity": "work",
            "id": 3,
            "name": "Метель"
         }
      }
   ]
}

Cгенерированный SQL запрос

SELECT person1.id        AS id1,
       person1.name      AS name1,
       person1.lastName  AS lastName1,
       person1.email     AS email1,
       person1.birthdate AS birthdate1,
       gender1.id        AS id2,
       gender1.name      AS name2,
       city1.id          AS id3,
       city1.name        AS name2,
       city1.population  AS population1,
       personwork1.id    AS id4,
       personwork1.name  AS name3
FROM person person1
         LEFT JOIN gender gender1 ON person1.genderid = gender1.id
         LEFT JOIN city city1 ON person1.cityid = city1.id
         LEFT JOIN personwork personwork1 ON person1.id = personwork1.personid

Группы полей.

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

  • все скалярные поля сущности. Добавляются методом withScalars ()

  • все ссылки многий-к-одному (связи М-1). Добавляются методом withRefs ()

  • все коллекции (связи 1-N). Добавляются методом withCollections ()

  • все блобы. withBlobs ()

  • почти все поля: full () — равносильно вызову withScalars ().withRefs ().withCollections ().

Пример проекции с использованием групп:

Person.slice {
    withScalars().withRefs()
    
    works {
        full()
    }
}

Что будет, если на уровне вообще не указать поля или группы?
В этом случае будут выбраны поля из группы-по-умолчанию. По умолчанию — это скалярные поля (но, конечно, группу-по-умолчанию можно переопределить).

Важное следствие: так как на любом уровне по умолчанию выбираются только скалярные поля, это предотвращает случайную выгрузки по запросу «половины БД», за счет «рекурсивного спуска» по связям в объектном дереве. Чтобы выгрузить половину БД, программист должен сделать явные действия, описав сложную проекцию. В данном случае лень работает на нас.

Композиции проекций

Куски проекций можно запоминать и композировать в большие проекции.
Для примера создадим базовую проекцию человека:

val personBaseDp = Person.slice {
   name
   lastName
   gender
}

А теперь сделаем запрос, но добавим в него ещё перечень произведений:

dataService.findAll(PersonBaseDp.slice {
    works {
        work
    }
})

Фильтрация, сортировка, лимитирование, оффсет.

Person.slice {
   name
   lastName
   filter {
      (birthdate le Date(1980, 1, 1))
   }
}.order(f(Person.name), f(Person.lastName))
   .limit(10).offset(10)

А можно несколько фильтров за раз:

Person.slice {
   name
   lastName
   filter {
      (gender.name eq "man") or ((gender.name eq "woman") and (name like value("Ахма%")))
   }
   filter {
      (birthdate eq Date(2000, 0, 0)) or (birthdate le Date(1980, 1, 1))
   }
}

Фильтрацию мы можем применять не только к полям первого уровня, но и для любых вложенных коллекций:

Person.slice {
   name
   works {
      work
   }
   filter { works().work().name like value("Метель%") }
}.limit(1).offset(1)

Обратите внимание на цепочку вызова полей works ().work ().name. Такой запрос выведет только одну запись, начиная со второй, но в произведениях будет перечень только произведений, начинающихся на слово «Метель».
SQL запрос для такой проекции:

SELECT person1.id     AS id1,
       person1.name   AS name1,
       personwork1.id AS id2,
       work1.id       AS id3,
       work1.name     AS name2
FROM person person1
         LEFT JOIN personwork personwork1 ON person1.id = personwork1.personid
         LEFT JOIN work work1 ON personwork1.workid = work1.id
WHERE ltrim(work1.name) like :param0
ORDER BY person1.id ASC
LIMIT :_limit OFFSET :_offset
with params param0='Метель%', _limit=1, _offset=1

Фильтр withId

Для поиска конкретной записи можно воспользоваться фильтром, а удобнее функцией withId():

dataService.find(Person.withId(1).field(name))

Группировки и агрегатные функции:


FooBar.slice {
    name
    count(name)
    sum(value, "xxx")
    max(value); min(value); avg(value)
}.groupBy(FooBar.name).order(FooBar.name)

И соответствующий запрос в БД:

SELECT foo_bar1.name          AS name1,
       (count(foo_bar1.name)) AS count_name,
       (sum(foo_bar1.value))  AS xxx,
       (max(foo_bar1.value))  AS max_value,
       (min(foo_bar1.value))  AS min_value,
       (avg(foo_bar1.value))  AS avg_value
FROM foo_bar foo_bar1
GROUP BY foo_bar1.name
ORDER BY foo_bar1.name ASC

OQL

OQL («Object SQL») позволяет делать в проекциях вставки SQL-выражений. OQL — это вставки объектного пути внутри SQL, что дает возможность обращаться к сущностям запросов не зная конкретных алиасов в SQL. Объектный путь — цепочка полей, начиная от корня.

Person.slice {
   name
   lastName
   //OQL-строка помещена внутри where, в отличие от функции filter, которая принимает типизированное выражение
   where(
      "{{birthdate}} > DATE '1917-02-15'"
   )
}

SQL запрос по проекции:

SELECT person1.id                      AS id1,
       person1.name                    AS name1,
       person1.lastname                AS lastname1
FROM person person1
WHERE person1.birthdate > DATE '2023-11-01'

Или вот нашёл пример из нашего кода с проекта:

dataService.exists(
   Project.where(
      "{{name}} = 'QDP' AND EXISTS (SELECT j.id FROM CHECKLIST j WHERE j.TASK_ID = {{Tasks.id}})"
   )
)

А можно вариант с указанием параметров:

var dp = Department.slice {
   city
   name
   boss {
      scalars().withRefs()
   }
}.where(
   """
      ({{boss.city.id}} = {{city.id}} OR {{boss.n}} = :param0)
         AND
      ({{boss.email}} = :param1 OR {{n}} = 'zzz')
   """
)
   .param("param0", "nanyr")
   .param("param1", "gazman@google.com")

Вызов внешних сервисов из проекций.

Если не хватает проекций и OQL запросов, можно вызвать внешние сервисы прямо из проекции. Это уже отдельный разговор, который выходит за рамки статьи. Но небольшой пример из тестов приведу:

Person.slice {
   name
   favoriteGame { game ->
      externalService {
         registry(GameService::class).loadGame(game.id)
      }
      name
      episodes {
         name
      }
   }
}

Вычисляемые поля

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

Пример с полями

Добавим поле с инициалами Александра Сергеевича:

object Person : MappingFieldSet() {
    (...)
    val initials by Fields.string() {
        caption = "Инициалы",
        oqlFormula = "LEFT({{name}}, 1) || '.' || LEFT({{lastName}}, 1) || '.'"
    }
}
famousAuthor[initials] // "А.П."

Пример с проекцией

То же самое в проекции и без создания дополнительного поля в Person:

val famousAuthor = dataService.find(
    on(Person).formula(
        "initials",
        "LEFT({{name}}, 1) || '.' || LEFT({{lastName}}, 1) || '.'"
    )
)

Результат будет тот же. В одном случае удобно одно, в другом другое. Свобода действий.
А можно, например, сделать инициалы для полов и сразу отфильтровать только прекрасный:

val gender = dataService.find(
    Gender
        .formula(
            "caption", """
                case when {{id}}=1 then 'Ж'
                     when {{id}}=2 then 'М'
                     else 'О' end
            """
        )
        .filter(f("caption") eq "Ж")
)!!

println(gender["caption"]) // Ж 

При этом в БД улетит запрос следующего вида:

SELECT gender1.id         AS id1,
       gender1.name       AS name1,
       gender1.is_classic AS is_classic1,
       (
          case
             when gender1.id = 1 then 'Ж'
             when gender1.id = 2 then 'М'
             else 'О' end
          )               AS caption
FROM gender gender1
WHERE (
         case
            when gender1.id = 1 then 'Ж'
            when gender1.id = 2 then 'М'
            else 'О' end
         ) = 'Ж'

Upgrade полей датамапы

Операция апгрейда нужна для дозагрузки данных в карты по определенным веткам объектного дерева. Это в некотором роде аналог «lazy loading» Hibernate, но выполняется явно, а не прячется в маппингах. В отличие от «ленивой загрузки», мы не ограничены только коллекциями, но можем догружать любые ветви деревьев, например, догрузить пачку из 200 скалярных полей верхнего уровня и две ссылки, или пару блобов с фотками.

Предположим, что у нас есть датамапа следующего содержания:

val famousAuthor = dataService.find(Person.slice {
   name
   lastName
   works {
      work
   }
})

Полученные данные

{
   "entity": "person",
   "id": 1,
   "name": "Александр",
   "lastname": "Пушкин",
   "works": [
      {
         "entity": "personwork",
         "id": 1,
         "work": {
            "entity": "work",
            "id": 1,
            "name": "Руслан и Людмила"
         }
      },
      {
         "entity": "personwork",
         "id": 2,
         "work": {
            "entity": "work",
            "id": 2,
            "name": "Пиковая дама"
         }
      },
      {
         "entity": "personwork",
         "id": 3,
         "work": {
            "entity": "work",
            "id": 3,
            "name": "Метель"
         }
      }
   ]
}

Давайте добавим в неё информацию о городе:

dataService.upgrade(listOf(famousAuthor), Person.slice {
   city
})

В итоге получим то, что хотели (добавилось поле city)

:

{
   "entity": "person",
   "id": 1,
   "name": "Александр",
   "lastname": "Пушкин",
   "city": {
      "entity": "city",
      "id": 1,
      "name": "Тегусигальпа"
   },
   "works": [
      {
         "entity": "personwork",
         "id": 1,
         "work": {
            "entity": "work",
            "id": 1,
            "name": "Руслан и Людмила"
         }
      },
      {
         "entity": "personwork",
         "id": 2,
         "work": {
            "entity": "work",
            "id": 2,
            "name": "Пиковая дама"
         }
      },
      {
         "entity": "personwork",
         "id": 3,
         "work": {
            "entity": "work",
            "id": 3,
            "name": "Метель"
         }
      }
   ]
}

Как видно, итоговый JSON содержит информацию, полученную и в первом, и во втором запросах.
Давайте посмотрим, что же происходит «под капотом», какие SQL запросы улетают в БД в обоих случаях:

Запрос 1

SELECT person1.id       AS id1,
       person1.name     AS name1,
       person1.lastName AS lastName1,
       personwork1.id   AS id2,
       work1.id         AS id3,
       work1.name       AS name2
FROM person person1
        LEFT JOIN personwork personwork1 ON person1.id = personwork1.personid
        LEFT JOIN work work1 ON personwork1.workid = work1.id

Запрос 2

SELECT person1.id AS id1,
       city1.id   AS id2,
       city1.name AS name1
FROM person person1
        LEFT JOIN city city1 ON person1.cityid = city1.id
WHERE city1.id in (:param0)
with params param0=[1]

Динамические поля

JSON-поля баз данных очень хорошо ложатся на DataMaps. Это позволяет хранить и работать с динамическими, изменяемыми (в т.ч. от записи к записи) структурами.

Давайте добавим в нашего человека дополнительное поле:

object Person : MappingFieldSet() {
    (...)
    val famousWords by Fields.jsonObj(WriterQuotes)
}

object WriterQuotes : MappingFieldSet(Dynamic) {
    val quotes by Fields.list(Quote)
}

object Quote : MappingFieldSet(Dynamic) {
    val content by Fields.stringNN() { caption = "Цитата" }
}

Мы добавили поле в сущность Person. Это поле нужно отобразить в БД:

ALTER TABLE person
    ADD famousWords JSONB;

Несмотря на то, что помимо нового поля qoutes мы добавили дополнительные сущности, больше ничего в БД добавлять не требуется.
Пример создания писателя:

Person {
    (...)
    it[famousWords] = WriterQuotes {
        it[quotes].add(Quote {
            it[content] = "Мы почитаем всех нулями, А единицами – себя."
        })
        (...)
    }
}

И прочитаем:

famousAuthor[famousWords().quotes[0].content] // "Мы почитаем всех нулями, А единицами – себя."

Внесём изменения:

famousAuthor[famousWords().quotes[0].content] = "Мы все ленивы и нелюбопытны."

А теперь сделаем запрос с фильтрацией:

dataService.findAll(Person
    .filter { famousWords.quotes.content eq "Мы все учились понемногу Чему-нибудь и как-нибудь" })

Как видно из примеров, работа с данными, хранимыми в JSON полях, ничем не отличается от работы с полями, хранимыми в отдельных столбцах. Хотя, безусловно, запросы, которые улетают в БД уже сильно разнятся.

Опции проекций

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

val projects = dataService
   .findAll(Project.scalars().slice {
      tasks { //загружаем коллекцию тасков
         scalars()
         option().withSubSelect()
      }
      checklists { //загружаем коллекцию чеклистов
         scalars()
         option().withSubSelect()
      }
      where("{{name}} = 'QDP'")
   })

Опция withSubSelect(), которая отвечает за то, что коллекция будет подгружена не через join, а отдельным запросом через знакомый уже функционал апгрейда датамапы.
Вот такие запросы в итоге улетают в БД:

SELECT project1.id   AS id1,
       project1.name AS name1
FROM project project1
WHERE project1.name = 'QDP';

SELECT task1.id           AS id1,
       task1.name         AS name1,
       (task1.project_id) AS backRefId__
FROM task task1
WHERE task1.project_id in [2];

SELECT checklist1.id        AS id1,
       checklist1.name      AS name1,
       (checklist1.task_id) AS backRefId__
FROM checklist checklist1
WHERE checklist1.task_id in [3, 4];

Или можно добавить хинты. Например, для ограничения времени запроса:

dataService.count(Person.slice { option().withHints("SET local statement_timeout = 5000;") })

Опции филдсетов.

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

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

object AppObject1 : MFS("AppObject1") {
    init {
        table = "AppObject"
        addOption(HardFilter(f(objectType) eq 1))
    }

    val id by Fields.id()
    val name by Fields.stringNN()
    val description by Fields.string()
    val objectType by Fields.intNN()
}

Простой запрос:

dataService.findAll(AppObject2.scalars())

Превращается в SQL запрос:

SELECT appobject1.id           AS id1,
       appobject1.name         AS name1,
       appobject1.serialNumber AS serialNumber1,
       appobject1.objectType   AS objectType1
FROM appobject appobject1
WHERE appobject1.objectType = 1

Подводим итоги

DataMaps как идея о работе с БД на основе graphql-like проекций и карт данных показала свою жизнеспособность, плодотворность и адаптивность. Уже несколько лет Datamaps успешно используется на крупных государственных и корпоративных проектах.

Сами по себе datamaps являются очень небольшой по размерам библиотекой. Но на базе datamaps построен другой наш фреймворк — Combinator.
Комбинатор, имея такой же DSL запросов и тот же интерфейс dataServiсe, способен работать одновременно с несколькими источниками данных. При этом не просто запрашивать, но и объединять (джойнить, или, собственно, комбинировать) данные из разных источников. При этом в качестве источников выступают не только СУБД (реляционные и нереляционные), но и любые другие провайдеры данных (REST, grpc, RMI, космос). Комбинатор позволяет строить единое пространство данных на базе нескольких источников, доступ в котором осуществляется по единому графу с прослеживаемостью всех связей.

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

Спасибо!

© Habrahabr.ru