3

I have a table in a Postgres 11.4 database with a self-referencing tree structure:

+------------+
| account    |  
+------------+
| id         |
| code       | 
| type       |
| parentId   | -- references account.id
+------------+

Each child can have another child, there is no limit on the nesting level.

I want to generate a single JSON object from it, nesting all children (resursivly).

Is it possible to solve this with single query? Or any other solution using typeORM with one table?
Else I'll have to bind the data manually at server side.

I tried this query:

SELECT account.type, json_agg(account) as accounts
FROM account
-- LEFT JOIN "account" "child" ON "child"."parentId"="account"."id" -- tried to make one column child
GROUP BY account.type   

Result:

[
  ...
  {
    "type": "type03",
    "accounts": [
      {
        "id": 28,
        "code": "acc03.001",
        "type": "type03",
        "parentId": null
      },
      {
        "id": 29,
        "code": "acc03.001.001",
        "type": "type03",
        "parentId": 28
      },
      {
        "id": 30,
        "code": "acc03.001.002",
        "type": "type03",
        "parentId": 28
      }
    ]
  }
  ...
]

I expect this instead:

[
  ...
  {
    "type": "type03",
    "accounts": [
      {
        "id": 28,
        "code": "acc03.001",
        "type": "type03",
        "parentId": null,
        "child": [
          {
            "id": 29,
            "code": "acc03.001.001",
            "type": "type03",
            "parentId": 28
          },
          {
            "id": 30,
            "code": "acc03.001.002",
            "type": "type03",
            "parentId": 28
          }
        ]
      }
    ]
  }
  ...
]
Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
DreaMy
  • 43
  • 5
  • Can it have more level of nesting (like, for example ""acc03.001.001.0001" which parent would be "acc03.001.001")? Can it have multiple roots in the table (with `parentId=null`) per type? – Adam Tokarski Jun 09 '20 at 11:45
  • Your version of Postgres (always)? How many levels of nesting are possible? – Erwin Brandstetter Jun 09 '20 at 12:09
  • ya, the child can have another nesting. the code just example, can be random. Sorry the parentId is null and have nested child. i already edited. – DreaMy Jun 09 '20 at 12:10
  • 1
    So this is for simpler version without multi-level nesting: ``` ;with nested_accounts as ( SELECT account.type, account.parentId, json_agg(account) as accounts from account group by type, parentId ) select a.type, na.accounts from nested_accounts na inner join account a on a.id = na.parentId; ``` For multi-nested, there should be some recurrency added probably. – Adam Tokarski Jun 09 '20 at 12:19
  • @Adam: Yes, the right track. But "some recurrency" turned out to be tricky. – Erwin Brandstetter Jun 09 '20 at 18:17

1 Answers1

4

This is tricky. It's a recursive problem, but standard recursive CTEs are ill-equipped to deal with it, as we need to aggregate on every level and CTEs do not allow aggregation in the recursive term.

I solved it with a PL/pgSQL function:

CREATE OR REPLACE FUNCTION f_build_jsonb_tree(_type text = NULL)
  RETURNS jsonb
  LANGUAGE plpgsql AS
$func$
DECLARE
   _nest_lvl int;

BEGIN
   -- add level of nesting recursively
   CREATE TEMP TABLE t ON COMMIT DROP AS
   WITH RECURSIVE t AS (
      SELECT *, 1 AS lvl
      FROM   account
      WHERE  "parentId" IS NULL
      AND   (type = _type OR _type IS NULL) -- default: whole table

      UNION ALL
      SELECT a.*, lvl + 1
      FROM   t
      JOIN   account a ON a."parentId" = t.id
      )
   TABLE t;
   
   -- optional idx for big tables with many levels of nesting
   -- CREATE INDEX ON t (lvl, id);

   _nest_lvl := (SELECT max(lvl) FROM t);

   -- no nesting found, return simple result
   IF _nest_lvl = 1 THEN 
      RETURN (  -- exits functions
      SELECT jsonb_agg(sub) -- AS result
      FROM  (
         SELECT type
              , jsonb_agg(sub) AS accounts
         FROM  (
            SELECT id, code, type, "parentId", NULL AS children
            FROM   t
            ORDER  BY type, id
            ) sub
         GROUP BY 1
         ) sub
      );
   END IF;

   -- start collapsing with leaves at highest level
   CREATE TEMP TABLE j ON COMMIT DROP AS
   SELECT "parentId" AS id
        , jsonb_agg (sub) AS children
   FROM  (
      SELECT id, code, type, "parentId"  -- type redundant?
      FROM   t
      WHERE  lvl = _nest_lvl
      ORDER  BY id
      ) sub
   GROUP  BY "parentId";

   -- optional idx for big tables with many levels of nesting
   -- CREATE INDEX ON j (id);

   -- iterate all the way down to lvl 2
   -- write to same table; ID is enough to identify
   WHILE _nest_lvl > 2
   LOOP
      _nest_lvl := _nest_lvl - 1;

      INSERT INTO j(id, children)
      SELECT "parentId"     -- AS id
           , jsonb_agg(sub) -- AS children
      FROM  (
         SELECT id, t.code, t.type, "parentId", j.children  -- type redundant?
         FROM   t
         LEFT   JOIN j USING (id)  -- may or may not have children
         WHERE  t.lvl = _nest_lvl
         ORDER  BY id
         ) sub
      GROUP  BY "parentId";
   END LOOP;

   -- nesting found, return nested result
   RETURN ( -- exits functions
   SELECT jsonb_agg(sub) -- AS result
   FROM  (
      SELECT type
           , jsonb_agg (sub) AS accounts
      FROM  (
         SELECT id, code, type, "parentId", j.children
         FROM   t
         LEFT   JOIN j USING (id)
         WHERE  t.lvl = 1
         ORDER  BY type, id
         ) sub
      GROUP  BY 1
      ) sub
   );
END
$func$;

Call (returns desired result exactly):

SELECT jsonb_pretty(f_build_jsonb_tree());

db<>fiddle here - with extended test case

I chose the key name children instead of child, as multiple may be nested.

jsonb_pretty() to prettify the display is optional.

This is assuming referential integrity; should be implemented with a FK constraint.

The solution might be simpler for your particular case, utilizing the code column - if it exhibits (undisclosed) useful properties. Like we might derive the nesting level without rCTE and added temporary table t. But I am aiming for a general solution based on ID references only.

There is a lot going on in the function. I added inline comments. Basically, it does this:

  1. Create a temporary table with added nesting level (lvl)
  2. If no nesting is found, return simple result
  3. If nesting is found, collapse to jsonb from the top nesting level down.
    Write all intermediary results to a second temp table j.
  4. Once we reach the second nesting level, return full result.

The function takes _type as parameter to return only the given type. Else, the whole table is processed.

Aside: avoid mixed-case identifiers like "parentId" in Postgres if at all possible. See:


Related later answer using a recursive function:

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228