Рекурсивная сумма в древовидной структуре

#sql-server #tsql #common-table-expression

#sql-сервер #tsql #common-table-expression

Вопрос:

У меня есть структура дерева в одной таблице. Таблица представляет собой дерево категорий, которые могут быть вложены бесконечно. Каждая категория имеет столбец productCount, в котором указывается, сколько продуктов находится непосредственно в категории (не суммируя дочерние категории).

 Id  | ParentId | Name      | ProductCount
------------------------------------
1   | -1       | Cars      | 0
2   | -1       | Bikes     | 1
3   | 1        | Ford      | 10
4   | 3        | Mustang   | 7
5   | 3        | Focus     | 4
 

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

Вывод для приведенной выше таблицы должен быть

 Id  | ParentId | Name      | ProductCount | ProductCountIncludingChildren
--------------------------------------------------------------------------
1   | -1       | Cars      | 0            | 21
2   | -1       | Bikes     | 1            | 1
3   | 1        | Ford      | 10           | 21
4   | 3        | Mustang   | 7            | 7
5   | 3        | Focus     | 4            | 4
 

Я знаю, что, вероятно, мне следует использовать CTE, но не могу заставить его работать так, как нужно.

Любая помощь приветствуется!

Комментарии:

1. Что вы пробовали до сих пор? Отправьте свой запрос…

2. Пробовал CTE, но не смог заставить его правильно суммировать

Ответ №1:

Вы можете использовать рекурсивный CTE, где вы в привязочной части получаете все строки, а в рекурсивной части объединяетесь, чтобы получить дочерние строки. Запомните исходный Id псевдоним RootID из привязочной части и выполните агрегирование суммы в основном запросе, сгруппированном по RootID .

SQL скрипка

Настройка схемы MS SQL Server 2012:

 create table T
(
  Id int primary key,
  ParentId int,
  Name varchar(10),
  ProductCount int
);

insert into T values
(1, -1, 'Cars',    0),
(2, -1, 'Bikes',   1),
(3,  1, 'Ford',    10),
(4,  3, 'Mustang', 7),
(5,  3, 'Focus',   4);

create index IX_T_ParentID on T(ParentID) include(ProductCount, Id);
 

Запрос 1:

 with C as
(
  select T.Id,
         T.ProductCount,
         T.Id as RootID
  from T
  union all
  select T.Id,
         T.ProductCount,
         C.RootID
  from T
    inner join C 
      on T.ParentId = C.Id
)
select T.Id,
       T.ParentId,
       T.Name,
       T.ProductCount,
       S.ProductCountIncludingChildren
from T
  inner join (
             select RootID,
                    sum(ProductCount) as ProductCountIncludingChildren
             from C
             group by RootID
             ) as S
    on T.Id = S.RootID
order by T.Id
option (maxrecursion 0)
 

Результаты:

 | ID | PARENTID |    NAME | PRODUCTCOUNT | PRODUCTCOUNTINCLUDINGCHILDREN |
|----|----------|---------|--------------|-------------------------------|
|  1 |       -1 |    Cars |            0 |                            21 |
|  2 |       -1 |   Bikes |            1 |                             1 |
|  3 |        1 |    Ford |           10 |                            21 |
|  4 |        3 | Mustang |            7 |                             7 |
|  5 |        3 |   Focus |            4 |                             4 |
 

Комментарии:

1. Этот рекурсивный CTE имеет очень плохое масштабирование, поскольку он, по сути, копирует конечное значение для всех родителей, непосредственно и далее по дереву (например, копирует productCount из Mustang в каждый из Ford и Cars). Я попробовал это на наборе данных около 200, и результирующий набор CTE увеличился примерно до 100 тыс. строк, и это заняло около полминуты.

2. @Elaskanator спасибо за попытку, я хочу сделать что-то подобное примерно для 3 миллионов наборов. Просто мурашки по коже, думая о моем наборе результатов CTE.

Ответ №2:

Это та же концепция, что и ответ Тома, но меньше кода (и намного быстрее).

 with cte as
(
  select v.Id, v.ParentId, v.Name, v.ProductCount, 
  cast('/'   cast(v.Id as varchar)   '/' as varchar) Node
  from Vehicle v
  where ParentId = -1
  union all
  select v.Id, v.ParentId, v.Name, v.ProductCount,  
  cast(c.Node   CAST(v.Id as varchar)   '/' as varchar)
  from Vehicle v
  join cte c on v.ParentId = c.Id
)

select c1.Id, c1.ParentId, c1.Name, c1.ProductCount, 
c1.ProductCount   SUM(isnull(c2.ProductCount, 0)) ProductCountIncludingChildren
from cte c1
left outer join cte c2 on c1.Node <> c2.Node and left(c2.Node, LEN(c1.Node)) = c1.Node
group by c1.Id, c1.ParentId, c1.Name, c1.ProductCount
order by c1.Id
 

SQL Fiddle (я добавил несколько дополнительных строк данных для тестирования)

Комментарии:

1. При приведении к varchar без указания длины строки вы получите значение по умолчанию в 30 символов. Этого может быть достаточно, но я думаю, что лучше четко указать, какую длину строки вы на самом деле хотите использовать.

2. Это правда. Я не знаю, как выглядят его фактические данные, поэтому я не интересовался подобными деталями.

3. Ну, он сказал, что «таблица — это дерево категорий, которые могут быть вложены бесконечно». Что, конечно, не буквально верно, но это может сделать дерево довольно глубоким .

4. Я признаю, что это не идеальное решение. Ваш ответ пока лучший.

Ответ №3:

На самом деле это может быть хорошим использованием HIERARCHYID в SQL Server..

 CREATE TABLE [dbo].[CategoryTree]
(
    [Id] INT,
    [ParentId] INT,
    [Name] VARCHAR(100),
    [ProductCount] INT
)
GO

INSERT [dbo].[CategoryTree]
VALUES
    (1, -1, 'Cars', 0),
    (2, -1, 'Bikes', 1),
    (3, 1, 'Ford', 10),
    (4, 3, 'Mustang', 7),
    (5, 3, 'Focus', 4)
    --,(6, 1, 'BMW', 100)
GO
 

Запрос

 WITH [cteRN] AS (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY [ParentId] ORDER BY [ParentId]) AS [ROW_NUMBER]
    FROM  [dbo].[CategoryTree]
),
[cteHierarchy] AS (
    SELECT CAST(
            CAST(hierarchyid::GetRoot() AS VARCHAR(100))
              CAST([ROW_NUMBER] AS VARCHAR(100))
              '/' AS HIERARCHYID
        ) AS [Node],
        *
    FROM [cteRN]
    WHERE [ParentId] = -1
    UNION ALL
    SELECT CAST(
            hierarchy.Node.ToString()
              CAST(RN.[ROW_NUMBER] AS VARCHAR(100)
        )   '/' AS HIERARCHYID),
        rn.*
    FROM [cteRN] rn
    INNER JOIN [cteHierarchy] hierarchy
        ON rn.[ParentId] = hierarchy.[Id]
)
SELECT x.[Node].ToString() AS [Node],
    x.[Id], x.[ParentId], x.[Name], x.[ProductCount],
    x.[ProductCount]   SUM(ISNULL(child.[ProductCount],0))
        AS [ProductCountIncludingChildren]
FROM [cteHierarchy] x
LEFT JOIN [cteHierarchy] child
    ON child.[Node].IsDescendantOf(x.[Node]) = 1
    AND child.[Node] <> x.[Node]
GROUP BY x.[Node], x.[Id], x.[ParentId], x.[Name], x.[ProductCount]
ORDER BY x.[Id]
 

Результат

Скриншот результатов

Комментарии:

1. Обратите внимание, что большая часть запроса касается только настройки столбца HierarchyId «Node». Если бы вы могли хранить данные в столбце HierarchyId, то окончательный запрос должен быть довольно быстрым..

2. Для актуальной проблемы в этом сообщении приведенное выше решение работает так же хорошо и намного проще, но использование HierarchyId позволяет суммировать по уровню, что намного лучше imo.

Ответ №4:

Это не будет оптимальным, но оно работает, однако оно включает в себя 2 CTE. 1 основной CTE и CTE в табличной функции для суммирования значений для каждого поддерева.

Первый CTE

 ;WITH cte 
AS 
(
SELECT 
   anchor.Id,
   anchor.ParentId,
   anchor.Name,
   anchor.ProductCount,
   s.Total AS ProductCountIncludingChildren
FROM
testTable anchor 
    CROSS APPLY SumChild(anchor.id) s
WHERE anchor.parentid = -1
UNION ALL
SELECT 
   child.Id,
   child.ParentId,
   child.Name,
   child.ProductCount,
   s.Total AS ProductCountIncludingChildren
  FROM
cte 
  INNER JOIN testTable child on child.parentid = cte.id
  CROSS APPLY SumChild(child.id) s
 )
 SELECT * from cte 
 

И функция

 CREATE FUNCTION SumChild 
(
@id int

)
RETURNS TABLE
AS
 RETURN  
 (
 WITH cte 
 AS 
 (
   SELECT 
     anchor.Id,
     anchor.ParentId,
     anchor.ProductCount
   FROM
      testTable anchor 
   WHERE anchor.id = @id 
   UNION ALL
SELECT 
      child.Id,
      child.ParentId,
      child.ProductCount
    FROM
   cte 
     INNER JOIN testTable child on child.parentid = cte.id
)
SELECT SUM(ProductCount) AS Total from CTE
 )
GO
 

Что приводит к:

Результаты в SSMS

из исходной таблицы

Исходная таблица

Извиняюсь за форматирование.

Ответ №5:

Я не смог придумать хороший ответ на основе набора на основе T-SQL, но я придумал ответ: временная таблица имитирует структуру вашей таблицы. Табличная переменная является рабочей таблицей.

 --Initial table
CREATE TABLE #products (Id INT, ParentId INT, NAME VARCHAR(255), ProductCount INT)
INSERT INTO #products
        ( ID,ParentId, NAME, ProductCount )
VALUES  ( 1,-1,'Cars',0),(2,-1,'Bikes',1),(3,1,'Ford',10),(4,3,'Mustang',7),(5,3,'Focus',4)

--Work table
DECLARE @products TABLE (ID INT, ParentId INT, NAME VARCHAR(255), ProductCount INT, ProductCountIncludingChildren INT)
INSERT INTO @products
        ( ID ,
          ParentId ,
          NAME ,
          ProductCount ,
          ProductCountIncludingChildren
        )
SELECT  Id ,
        ParentId ,
        NAME ,
        ProductCount,
        0
FROM #products

DECLARE @i INT
SELECT @i = MAX(id) FROM @products

--Stupid loop - loops suck
WHILE @i > 0
    BEGIN
        WITH cte AS (SELECT ParentId, SUM(ProductCountIncludingChildren) AS ProductCountIncludingChildren FROM @products GROUP BY ParentId)
        UPDATE p1
        SET p1.ProductCountIncludingChildren = p1.ProductCount   isnull(p2.ProductCountIncludingChildren,0)
        FROM @products p1
        LEFT OUTER JOIN cte p2 ON p1.ID = p2.ParentId
        WHERE p1.ID = @i

        SELECT @i = @i - 1
    END

SELECT *
FROM @products

DROP TABLE #products
 

Мне было бы очень интересно увидеть лучший подход, основанный на множестве. Проблема, с которой я столкнулся, заключается в том, что при использовании рекурсивных cte вы начинаете с родительского элемента и переходите к дочерним элементам — на самом деле это не работает для получения суммы на родительских уровнях. Вам нужно будет выполнить какой-то обратный рекурсивный cte.

Комментарии:

1. Вы можете начать с нижней части дерева и работать в рекурсивном CTE, используя что-то вроде SELECT leafNodes.* FROM [dbo].[CategoryTree] leafNodes LEFT JOIN [dbo].[CategoryTree] children ON children.[ParentId] = leafNodes.[Id] WHERE children.[Id] IS NULL в качестве привязки

2. Проблема в том, что вы не можете использовать GROUP BY и aggregation в рекурсивном элементе CTE. Единственное, что я мог придумать, это рекурсивный CTE в скалярной функции, которая по сути такая же, как при использовании цикла.

3. Я думаю, что у меня была та же идея, что и у вас, но я использовал функцию табличного значения (что не нужно, см. Выше — я также отметил, что это не оптимально). Я также думал о том, чтобы идти снизу вверх, суммируя по ходу дела, но не мог понять, как это сделать быстро.