Работа со связями (ссылками) между записями heap-таблиц

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

  • Heap.RefLink — ссылка на запись заранее заданной таблицы.
  • Heap.GenericLink — ссылка на запись любой таблицы.

Использование этих типов предоставляет следующие удобства:

  • Принимают на вход heap-объекты, а не только идентификаторы.
  • Валидидация идентификатора и типа (для RefLink) записи таблицы.
  • Контроль целостности (отключаемый): невозможно удалить запись, на которую "ссылается" другая запись.
  • В runtime значения таких полей представлены экземплярами специальных классов с поддержкой метода  .get(ctx) , который позволяет удобно прочитать запись, на которую ссылаемся.
  • Упрощает создание автоматических интерфейсов редактирования записей таблицы (админок).
  • Облегчает чтение кода за счёт точного семантического обозначения предназначения поля.

Описание и способы объявления RefLink

RefLink - полный аналог внешних ключей (foreign keys) в реляционных СУБД и позволяет моделировать связи между сущностями. В отличие от реляционных БД, поле типа RefLink может быть не только на верхнем уровне таблицы, но и на любой глубине во вложенных объектах и массивах. При объявлении поля с помощью функции Heap.RefLink необходимо указать heap-таблицу, на записи которой будут указывать значения поля. Это можно сделать одним из двух способов:

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

    import { Heap } from '@app/heap'
    import { Products } from '@external/heap'
    const Deals = Heap.Table('deals', {
      product: Heap.RefLink(Products),
    })
    
  • Указать строку с названием целевой таблицы. Этот способ рекомендуется использовать только в случае циклических ссылок, например, когда необходимо смоделировать иерархию с помощью поля parent, которое "смотрит" на свою же таблицу.

    import { Heap } from '@app/heap'
    const Tree = Heap.Table('tree', {
      parent: Heap.Nullable(Heap.RefLink('tree')),
    })
    

В силу своей динамической природы, поля-ссылки не поддерживают значения по умолчанию. При добавлении в существующие непустые heap-таблицы, их следует оборачивать в Heap.Optional или Heap.Nullable.

При объявлении RefLink важно использовать непосредственно импортированный объект Heap, то есть нельзя использовать производную переменную с другим именем или переданную как аргумент в функцию.
Это необходимо, т.к. на этапе сборки исходных текстов специальный трансформер находит такие поля и "дописывает" служебную информацию для корректной работы.

Контроль ссылочной целостности

Использование полей типа RefLink и GenericLink автоматически включает контроль ссылочной целостности. Это означает, что система по умолчанию не даст удалить запись, на которую ссылается какое-либо RefLink- или GenericLink-поле. Если по каким-либо причинам такое поведение мешает и разработчик "понимает что делает", то его можно выключить для каждого поля в отдельности, передав опцию  onDelete: 'none'  в последнем аргументе при объявлении поля (подробнее об опции см. Heap.RefLink и Heap.GenericLink). Следует понимать, что при выключении контроля целостности значения поля могут содержать ссылки на несуществующие записи, и использование метода RefLink.get или getById будут бросать runtime-исключения, которые, скорее всего, будут неожиданными.

Поддержка  onDelete: 'cascade'  и  onDelete: 'set-null' , которые обычно присутствуют в реляционных СУБД, для heap-таблиц не реализована.

GenericLink и его отличия от RefLink

GenericLink во многом похож на RefLink, но применим в гораздо более узком наборе сценариев. Примеры:

  • Поле в таблице инцидентов службы поддержки, которое может содержать ссылку на объект в информационной системе компании, для которого создан этот инцидент.
  • Поле "набор ссылок" в таблица документов в системе документооборота компании, которое содержит список ссылок на разные объекты в информационной системе, относящиеся к данному документу.

Особенности записи значений GenericLink

В отличие от RefLink, у которого информация о типе (таблице) записи, на которую ссылаемся, содержится в схеме таблицы, в GenericLink эта информация хранится непосредственно в значении поля рядом с идентификатором записи. Поэтому в операциях create/update значения поля типа GenericLink нельзя передавать в виде простого идентификатора, поддерживается только передача самого объекта, на который ссылаемся, либо экземпляра класса GenericLink, полученного при выборке ранее.

const Products = Heap.Table('products', {
  refLink: Heap.RefLink(SomeTable),
  genLink: Heap.GenericLink(),
})
const someRecord = await SomeTable.findOneBy(ctx)
await Products.create(ctx, {
  // OK
  refLink: someRecord.id,
  // ошибка!
  genLink: someRecord.id,
  // правильно:
  genLink: someRecord,
})

Особенности работы со значениями GenericLink

Поскольку при выборке поля типа GenericLink в общем случае код "не знает", какого типа запись, на которую ссылаемся, работа со значениями GenericLink всегда несколько сложнее, чем со значениями RefLink, и содержит ветвление в зависимости от типа конкретной записи.

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

  • С помощью свойств GenericLink.type и HeapTableRepo.type. Сравнивая значения свойства  type  GenericLink-поля со свойством type одной из возможных целевых таблиц, можно определить таблицу, к которой относится идентификатор, хранящийся в поле GenericLink и использовать метод getById для получения целевой записи:
import { Table1, Table2 } from './tables'
const Products = Heap.Table('products', {
  genLink: Heap.GenericLink(),
})
const product1 = await Products.findOneBy(ctx)
if (product1.genLink.type === Table1.type) {
  const linkRecord = await Table1.getById(ctx, product1.genLink.id)
  // ... Table1 related logic
} else if (product1.genLink.type === Table2.type){
  const linkRecord = await Table2.getById(ctx, product1.genLink.id)
  // ... Table2 related logic
}
  • С помощью методов GenericLink.get() и HeapTableRepo.isMyRecord(). Метод GenericLink.get() достаточно "умный", чтобы найти нужную таблицу и осуществить выборку записи этой таблицы. Однако, чтобы "понять", запись какого типа в итоге возвращена, всё равно необходимо "вручную" перебирать варианты, используя метод isMyRecord, который является ещё и type-предикатом и "приводит" запись к правильному типу на уровне typescript-кода:
import { Table1, Table2 } from './tables'
const Products = Heap.Table('products', {
  genLink: Heap.GenericLink(),
})
const product1 = await Products.findOneBy(ctx)
const linkRecord = await product1.genLink.get(ctx)
if (Table1.isMyRecord(linkRecord)) {
  // ... Table1 related logic
} else if (Table2.isMyRecord(linkRecord)){
  // ... Table2 related logic
}

Моделирование связей много-ко-многим

В отличие от реляционных баз данных, для моделирования отношений многие-ко-многим не обязательно создавать отдельную heap-таблицу. В большинстве случаев гораздо проще будет объявить "массив ссылок":

import { Heap } from '@app/heap'
const Tags = Heap.Table('tags', {
  name: Heap.String(),
})
const Products = Heap.Table('products', {
  tags: Heap.Array(Heap.RefLink(tags)),
})
const product1 = await Products.findOneBy(ctx)
const product1Tags = await Promise.all(
  product1.tags.map(link => link.get(ctx))
)

Фильтрация

Поля-ссылки поддерживают фильтрацию:

  • По идентификатору целевой записи:

    import { Table1, Table2 } from './tables'
    const Products = Heap.Table('products', {
      refLink: Heap.RefLink(Table1),
      genLink: Heap.GenericLink(),
    })
    const table1Record = await Table1.findOneBy(ctx)
    const table2Record = await Table2.findOneBy(ctx)
    
    await Products.findBy(ctx, { 
      refLink: table1Record.id,
      genLink: table2Record.id,
    })
    
  • По списку идентификаторов (как IN в SQL):

    await Products.findBy(ctx, { 
      refLink: [table1Record.id],
      genLink: [table1Record.id, table2Record.id],
    })
    
  • Для GenericLink поддерживается ещё и фильтрация по типу (целевой таблице) и по списку типов:

    await Products.findBy(ctx, { 
      genLink: { type: Table1.type },
    })
    await Products.findBy(ctx, { 
      genLink: { type: [Table1.type, Table2.type] },
    })
    

При фильтрации по идентификатору в качестве значений фильтра принимаются:

  • Непосредственно строковой идентификатор (примеры выше).

  • Экземпляр RefLink или GenericLink.

    const product = await Table1.findOneBy(ctx)
    
    await Products.findBy(ctx, { 
      refLink: product.refLink,
      genLink: product.genLink,
    })
    
  • Целевая запись целиком.

    await Products.findBy(ctx, { 
      refLink: table1Record,
      genLink: table2Record,
    })
    
  • Любую комбинацию вышеперечисленного в списке.

    await Products.findBy(ctx, { 
      refLink: [table1Record.id, product.refLink, table1Record],
      genLink: [table1Record.id, product.refLink, table2Record],
    })
    

Операторы сравнения для полей-ссылок не поддерживаются.
Операторы $not, $and, $or и $noop работают как и везде.

❤️ Made with love on Chatium

ООО "Чатиум"

Информация о компании