Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 19.
В предыдущей статье у задач появились номера видаBRD-42. Дальше в Boards начали появляться новые функции, и первой из них стали атрибуты — поля, которые пользователь задаёт сам.
Краткое содержание
Как мы добавили в Boards кастомные поля:
- Атрибут — это поле, которое организация создаёт сама: «Приоритет», «Клиент», «Оценка». У атрибута есть название, цвет и тип.
- Значения хранятся по модели EAV (entity–attribute–value): строка «задача — атрибут — значение» в отдельной таблице.
- Для каждого типа значений — своя таблица: текст, значение из списка, целое число, дробное число, дата, дата и время.
- От колонки JSONB отказались: это возможность конкретного провайдера базы данных, индексировать её можно только GIN-индексами, а внешние ключи нам были нужны, чтобы правила целостности работали на уровне базы.
- Сначала сделали два типа, текст и список, проверили, как всё работает, и только потом расширили набор типов атрибутов.
- Фильтр по атрибуту — это
JOINс таблицей значений нужного типа.
Зачем задаче кастомные поля
У issue в Boards есть фиксированный набор полей: текст, статус, исполнитель. Но у каждой команды свои данные, которые хочется хранить в задаче: приоритет, клиент, оценка в часах, срок. Добавлять в таблицу issues колонку под каждую такую потребность нельзя — их бесконечно много, и у разных организаций они разные.
Поэтому поля задаёт сама организация. Атрибут создаётся один раз и дальше доступен во всех спейсах и на всех досках организации:
public class Attribute
{
public long Id { get; set; }
public long OrganizationId { get; set; }
public Organization? Organization { get; set; }
[MaxLength(255)]
public required string Name { get; set; }
[MaxLength(7)]
public required string Color { get; set; }
public required AttributeType AttributeType { get; set; }
public List<AttributeListValue>? AttributeListValues { get; set; }
}
public enum AttributeType
{
Text,
List,
}
У атрибута типа «список» есть набор вариантов (AttributeListValue), и в задаче выбирается один из них.
Где хранить значения: EAV или JSONB
Для кастомных полей есть два распространённых способа хранения.
JSONB. У задачи появляется колонка attributes jsonb, в которой лежит объект вида {"12": "Высокий", "15": "ООО Ромашка"}. Одна колонка, никаких новых таблиц, а PostgreSQL умеет индексировать JSONB и искать по нему.
EAV (entity–attribute–value). Значения лежат в отдельной таблице, по строке на каждую пару «задача — атрибут». Таблиц больше, а чтобы получить задачу со всеми полями, нужны дополнительные запросы.
Мы выбрали EAV. У JSONB для нас было три недостатка.
- Это возможность конкретного провайдера. JSONB есть в PostgreSQL, а мы стараемся не завязывать модель данных на возможности одной СУБД. Таблицы с внешними ключами работают в любой реляционной базе.
- Индексы. JSONB индексируется только GIN-индексом, а мы, когда есть выбор, предпочитаем GiST. К тому же значение JSONB хранится как один документ: если поменять одно поле, индекс обновляется для всего документа.
- Нет внешних ключей. Внутри JSONB нельзя сослаться на другую таблицу, поэтому правила целостности пришлось бы держать в коде. Нам было важно, чтобы они работали на уровне базы: в EAV значение — это обычная строка с внешними ключами на задачу, атрибут и вариант списка, и поведение при их удалении задаётся в самой базе.
Отдельная таблица для каждого типа
Значения можно хранить в одной таблице с колонкой под каждый тип: text_value, int_value, date_value и так далее. Но тогда в каждой строке заполнена только одна колонка, а остальные — null, и таблица растёт за счёт пустых значений. Мы сделали по таблице на каждый тип, чтобы не хранить пачки null и держать таблицы компактными.
В первой версии таких таблиц было две:
public class IssueAttributeTextValue
{
public long IssueId { get; set; }
public Issue? Issue { get; set; }
public long AttributeId { get; set; }
public Attribute? Attribute { get; set; }
[MaxLength(255)]
public required string Text { get; set; }
}
public class IssueAttributeListValue
{
public long Id { get; set; }
public long IssueId { get; set; }
public Issue? Issue { get; set; }
public long AttributeId { get; set; }
public Attribute? Attribute { get; set; }
public long AttributeListValueId { get; set; }
public AttributeListValue? AttributeListValue { get; set; }
}
Первичный ключ в обеих таблицах — пара (IssueId, AttributeId): у задачи может быть только одно значение каждого атрибута. Все внешние ключи — с каскадным удалением. Если удалить задачу, атрибут или вариант списка, база сама удалит все связанные значения, и в коде для этого ничего делать не нужно.
Сначала два типа
В первой версии были только текст и список. Мы хотели сначала проверить, что модель работает: значения сохраняются, отображаются в задачах и на досках, по ним работает фильтр. Числа и даты добавили позже, когда стало понятно, что подход себя оправдал.
К тому моменту стало видно, что логика сохранения у всех простых типов одинаковая: найти старые значения задачи, обновить изменившиеся, добавить новые, удалить убранные и записать изменения в историю. Её вынесли в общий базовый класс, а для каждого типа осталось описать только, как создать строку и как показать значение:
public class IntegerAttributeUpdater
: ScalarAttributeUpdater<IssueAttributeIntegerValue, long, SetIssueIntegerAttributeRequest>
{
protected override IssueAttributeIntegerValue CreateEntity(long issueId, long attributeId, long value) => new()
{
IssueId = issueId,
AttributeId = attributeId,
Value = value,
};
protected override string FormatValue(long value) => value.ToString(CultureInfo.InvariantCulture);
}
Чтобы добавить новый тип атрибута, теперь достаточно создать таблицу для его значений, модель и такой же короткий класс. Исключение — список: в задаче хранится не само значение, а ссылка на один из вариантов, заданных заранее, поэтому для него написана отдельная логика.
Фильтрация по атрибутам
На доске можно отфильтровать задачи по значениям атрибутов. Каждый фильтр превращается в JOIN с таблицей значений нужного типа:
if (filterType == AttributeType.Text)
{
query = query.InnerJoin(
context.IssueAttributeTextValues,
(i, a) => i.Id == a.IssueId
&& a.AttributeId == filter.Key
&& a.Text.ILike(filterValue.AsSearchable()),
(i, a) => i);
}
if (filterType == AttributeType.List)
{
query = query.InnerJoin(
context.IssueAttributeListValues,
(i, a) => i.Id == a.IssueId
&& a.AttributeId == filter.Key
&& ((IEnumerable<long>)listAndValues).Contains(a.AttributeListValueId),
(i, a) => i);
}
Несколько фильтров — несколько JOIN, и задача попадает в результат, только если подходит под все. Текст ищется по подстроке, а для списка можно выбрать несколько вариантов.
Под фильтры добавили индексы. Для текста — триграммный GIN-индекс (gin_trgm_ops), чтобы поиск по подстроке через ILIKE не просматривал всю таблицу. Для триграмм есть и GiST-вариант: он быстрее обновляется, а GIN быстрее ищет. Значения атрибутов читают намного чаще, чем меняют, поэтому здесь мы выбрали GIN. Для списка — обычный индекс по выбранному варианту. Первичный ключ начинается с IssueId, поэтому значения одной задачи тоже читаются по индексу.
Итоги
- Поля задаёт организация. Атрибут создаётся один раз и доступен во всех спейсах организации.
- EAV вместо JSONB. Значения лежат в обычных таблицах с внешними ключами, а не в документе внутри задачи.
- Таблица на каждый тип. Так в базе нет строк, где из нескольких колонок заполнена одна.
- Каскадное удаление в базе. Удаление задачи, атрибута или варианта списка убирает значения без кода.
- Сначала два типа. Числа и даты добавили, когда модель проверили на тексте и списке.
- Фильтр — это
JOIN. Каждое условие соединяет задачи с таблицей значений нужного типа.
Что дальше
Вслед за атрибутами у задач появились комментарии, вложения и история изменений, а в проект пришёл второй разработчик. Об этом — в следующих статьях.