View, выбирающий похожие данные из нескольких таблиц, разделенных по типу

У меня есть несколько таблиц, реализующих схему БД некоторой CMS:

  • cms.CatalogEntry обозначает любую (всех типов) запись в каталоге.
  • cms.CatalogEntryType описывает тип сущности (Artist/Genre/Release/Product/другой), связан с первой с помощью внешнего ключа CatalogEntryTypeId в ней.
  • dbo.Artist для полей, специфичных для исполнителя (Name, Description).
  • dbo.Release для полей, специфичных для музыкального релиза (Title, Description, CatalogNumber).
  • Несколько других таблиц, практически повторяющих две последние. Каждая из подобных таблиц имеет свой собственный Id и связывается через CatalogEntryId с упомянутой выше cms.CatalogEntry.Id.

Теперь мне нужно создать View, который возвращал бы мне пары (EntityId, CatalogEntryId), где EntityId может быть любым из ключей таблиц-расширений dbo.Artist.Id/dbo.Release.Id/другим, а также некоторую другую общую информацию (тип и DisplayName). У меня есть две основные идеи, как это можно реализовать:

Первая идея - собрать через LEFT JOIN все (~15) типизированные по сущностям таблицы расширений и потом использовать COALESCE, чтобы выбрать колонку, значение из которой использовать.

CREATE VIEW [cms].[CatalogEntryToEntityMap]
WITH SCHEMABINDING
AS
SELECT
    [catalogEntry].[Id] AS [CatalogEntryId],
    [catalogEntry].[CatalogEntryTypeId] AS [CatalogEntryTypeId],
    COALESCE(
        [genre].[Id],
        [artist].[Id],
        N'00000000-0000-0000-0000-000000000000') AS [EntityId],
    COALESCE(
        [genre].[Name],
        [artist].[Name],
        N'N/A') AS [EntityDisplayName]
FROM [cms].[CatalogEntry] AS [catalogEntry]
LEFT JOIN [dbo].[Genre] AS [genre] ON [genre].[CatalogEntryId] = [catalogEntry].[Id]
LEFT JOIN [dbo].[Artist] AS [artist] ON [artist].[CatalogEntryId] = [catalogEntry].[Id];

1st plan

Вторая идея - использовать UNION ALL, чтобы собрать конечный результат из типизированных по сущности подмножеств:

CREATE VIEW [cms].[CatalogEntryToEntityMap]
WITH SCHEMABINDING
AS
SELECT
    [genre].[Id] AS [EntityId],
    [genre].[CatalogEntryId],
    [genre].[Name] AS [EntityDisplayName],
    [catalogEntry].[CatalogEntryTypeId]
FROM [dbo].[Genre] AS [genre]
INNER JOIN [cms].[CatalogEntry] AS [catalogEntry]
    ON [catalogEntry].[Id] = [genre].[CatalogEntryId]
    AND [catalogEntry].[CatalogEntryTypeId] = N'DEVELOPMENT-TIME KNOWN GUID'
UNION ALL
SELECT
    [artist].[Id] AS [EntityId],
    [artist].[CatalogEntryId],
    [artist].[Name] AS [EntityDisplayName],
    [catalogEntry].[CatalogEntryTypeId]
FROM [dbo].[Artist] AS [artist]
INNER JOIN [cms].[CatalogEntry] AS [catalogEntry]
    ON [catalogEntry].[Id] = [artist].[CatalogEntryId]
    AND [catalogEntry].[CatalogEntryTypeId] = N'ANOTHER DEVELOPMENT-TIME KNOWN GUID';

2nd plan

Или по-другому:

CREATE VIEW [cms].[CatalogEntryToEntityMap]
WITH SCHEMABINDING
AS
SELECT
    [entity].[Id] AS [EntityId],
    [entity].[DisplayName] AS [EntityDisplayName],
    [catalogEntry].[Id] AS [CatalogEntryId],
    [catalogEntry].[CatalogEntryTypeId] AS [CatalogEntryTypeId]
FROM [cms].[CatalogEntry] AS [catalogEntry]
INNER JOIN
(
    SELECT [genre].[Id], [genre].[CatalogEntryId], [genre].[Name] AS [DisplayName] FROM [dbo].[Genre] AS [genre]
    UNION ALL
    SELECT [artist].[Id], [artist].[CatalogEntryId], [artist].[Name] AS [DisplayName] FROM [dbo].[Artist] AS [artist]
)
AS [entity] ON [entity].[CatalogEntryId] = [catalogEntry].[Id];

3rd plan

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

Меня немного беспокоит, что я не могу создатть индексы ни на одном из трех (на первом из-за LEFT JOIN и на вторых из-за UNION ALL и вложенных селектов), количество данных может быть довольно большим, и мне придется делать запросы к этому VIEW на регулярной основе практически на каждом GET-запросе сущности из каталога. Я не планирую писать какие-то сложные селекты, основаные на этом представлении, но я должен иметь возможность INNER JOIN-ить его на множества ключей (CatalogEntryId и EntityId), по которым мне нужны пары.

Видит ли кто-то какие-то дополнительные проблемы с представленными решениями? Меня по большей части интересует производительность.

Я также открыт к другим решениям, если кому-то придет в голову способ сделать это эффективнее.

P.S. Перевод моего вопроса c англоязычного SO.


Ответы (1 шт):

Автор решения: aa_talanin

Сомневаюсь, что кто-то за меня напишет полноценный ответ, поэтому...

В конечном итоге остановился на варианте 3:

CREATE VIEW [cms].[CatalogEntryToEntityMap]
WITH SCHEMABINDING
AS
SELECT
    [entity].[Id] AS [EntityId],
    [entity].[DisplayName] AS [EntityDisplayName],
    [catalogEntry].[Id] AS [CatalogEntryId],
    [catalogEntry].[CatalogEntryTypeId] AS [CatalogEntryTypeId]
FROM [cms].[CatalogEntry] AS [catalogEntry]
INNER JOIN
(
    SELECT [genre].[Id], [genre].[CatalogEntryId], [genre].[Name] AS [DisplayName] FROM [dbo].[Genre] AS [genre]
    UNION ALL
    SELECT [artist].[Id], [artist].[CatalogEntryId], [artist].[Name] AS [DisplayName] FROM [dbo].[Artist] AS [artist]
)
AS [entity] ON [entity].[CatalogEntryId] = [catalogEntry].[Id];

В плане, сгенерированном для него, отсутствуют повторяющиеся LEFT JOIN-ы, как в первом, и при этом он более лаконичный, чем второй.

Для тестирования создал 3 типа сущностей, заполнив таблицы тестовыми данными по 100к записей в каждой.

Фактический план выполнения запроса, выгребающего все представление:

SELECT * FROM [cms].[CatalogEntryToEntityMap];

введите сюда описание изображения

То же самое, но с дополнительной фильтрацией через INNER JOIN на список CatalogEntryId:

DECLARE @CatalogEntryIds AS [dbo].[GuidArray];

INSERT INTO @CatalogEntryIds ([Value])
SELECT [Id]
FROM [cms].[CatalogEntry]
WHERE [Id] LIKE N'00%'; -- Все GUID-ы, начинающиеся с 00

SELECT *
FROM [cms].[CatalogEntryToEntityMap] AS [map]
INNER JOIN @CatalogEntryIds AS [id] ON [id].[Value] = [map].[CatalogEntryId];

введите сюда описание изображения

Как можно заметить, предварительная фильтрация для уменьшения объема данных применяется как на cms.CatalogEntry, так и на присоединяемых dbo.Artist/Genre/Release. Также видно, что везде применяются индексы используемых для формирования представления таблиц, полных сканов таблиц не происходит.

Отдельно хочу выразить благодарность @AlexanderPetrov за ссылку на реализацию наследования средствами EF Core. В версии 6.x доступны только маппинги Table Per Hierarchy и Table Per Type, оба из которых мне не очень подходили, поскольку первый превращает таблицу в "мусорку" из всего возможного для иерархии сущностей набора полей, а второй имеет страдающую производительность. В версии 7.x также появилась Table Per Concrete type, которая должна генерировать схему БД, примерно схожую с моей.

→ Ссылка