Search code examples
javascriptnode.jsreducehierarchical-dataltree

How to improve my ltree dot notation hierarchy to nested JSON script?


I receive a set of rows from a database with a parentPath column formatted as ltree string (dot-notation) representing a hierarchy structure. I want to return the same data with nested categories from my nodejs server.

The data I have :

const categories = [
  {
    id: 1,
    name: "ALL",
    parentPath: "categories",
    translationFr: "Toutes les catégories"
  },
  {
    id: 2,
    name: "LEISURE",
    parentPath: "categories.leisure",
    translationFr: "Loisirs"
  },
  {
    id: 35,
    name: "AUTO",
    parentPath: "categories.services.auto",
    translationFr: "Auto-écoles"
  },
  {
    id: 3,
    name: "MUSEUMS",
    parentPath: "categories.leisure.museums",
    translationFr: "Musées"
  },
  {
    id: 4,
    name: "MONUMENTS",
    parentPath: "categories.leisure.monuments",
    translationFr: "Monuments et patrimoine"
  },
  {
    id: 5,
    name: "OPERAS",
    parentPath: "categories.leisure.operas",
    translationFr: "Opéras"
  },
  {
    id: 6,
    name: "THEATER",
    parentPath: "categories.leisure.theater",
    translationFr: "Théâtres"
  },
  {
    id: 7,
    name: "CINEMAS",
    parentPath: "categories.leisure.cinemas",
    translationFr: "Cinémas"
  },
  {
    id: 8,
    name: "CONCERT",
    parentPath: "categories.leisure.concert",
    translationFr: "Salles de concert"
  },
  {
    id: 9,
    name: "FITNESS",
    parentPath: "categories.leisure.fitness",
    translationFr: "Salles de fitness"
  },
  {
    id: 10,
    name: "CLUBS",
    parentPath: "categories.leisure.clubs",
    translationFr: "Clubs de sport"
  },
  {
    id: 11,
    name: "SENSATIONAL",
    parentPath: "categories.leisure.sensational",
    translationFr: "Sensationnel"
  },
  {
    id: 12,
    name: "HEALTH",
    parentPath: "categories.health",
    translationFr: "Bien-être et beauté"
  },
  {
    id: 13,
    name: "HAIR",
    parentPath: "categories.health.hair",
    translationFr: "Salons de coiffure"
  },
  {
    id: 14,
    name: "CARE",
    parentPath: "categories.health.care",
    translationFr: "Soins"
  },
  {
    id: 15,
    name: "SPAS",
    parentPath: "categories.health.spas",
    translationFr: "Spas"
  },
  {
    id: 16,
    name: "MASSAGE",
    parentPath: "categories.health.massage",
    translationFr: "Massage"
  },
  {
    id: 17,
    name: "FOOD",
    parentPath: "categories.food",
    translationFr: "Restaurants et Bars"
  },
  {
    id: 18,
    name: "NIGHTCLUB",
    parentPath: "categories.food.nightclub",
    translationFr: "Boîtes de nuit"
  },
  {
    id: 19,
    name: "RESTAURANTS",
    parentPath: "categories.food.restaurants",
    translationFr: "Restaurants"
  },
  {
    id: 20,
    name: "BARS",
    parentPath: "categories.food.bars",
    translationFr: "Bars"
  },
  {
    id: 21,
    name: "FASTFOOD",
    parentPath: "categories.food.fastfood",
    translationFr: "Sur le pouce"
  }
]

What I want :

[
  {
    name: "LEISURE",
    translationFr: "Loisirs",
    children: [
      { name: "MUSEUMS", translationFr: "Musées" },
      ...
    ]
  },
  {
    name: "HEALTH",
    translationFr: "Santé et bien-être",
    children: [...]
  },
  ...
]

I succeeded in having what I want with the following code :

const result = categories
  .filter(c => c.name != "ALL") // remove the root of the tree from array
  .reduce((r, s) => {
    // Select everything before the last "." AKA parent category
    const parentCategory = s.parentPath.substring(
      0,
      s.parentPath.lastIndexOf(".")
    );
    let object = r.find(o => o.parentPath === parentCategory);
    if (!object) {
      r.push({ ...s, children: [] });
    } else {
      object.children.push(s);
    }
    return r;
  }, []);

But this script has several weaknesses :

  • It won't work with 4 or more levels of nesting
  • It won't work if rows are rendered in this order (3-level nesting before 2-level nesting) :
categories.leisure.sensational
categories.leisure
categories.leisure.museums

Is there a genius around here who has an idea how we could proceed ?


Solution

  • You can accumulate them like this:

    depth or order of the items does not matter, but if parts of the path are missing (like categories.services) that subtree will not be included into the output.

    var children = function (key) {
      return this[key] || (this[key] = [])
    }.bind({});
    
    categories.forEach((item) => {
      children(item.parentPath.replace(/(^|\.)\w+$/g, "")).push({
        ...item,
        children: children(item.parentPath)
      });
    });
    
    console.log(children(""));
    

    or inline (as you can't reuse the children() function):

    var root = categories.reduce((children, item) => {
      children(item.parentPath.replace(/(^|\.)\w+$/g, "")).push({
        ...item,
        children: children(item.parentPath)
      });
    
      return children;
    }, function (key) {
      return this[key] || (this[key] = [])
    }.bind({}))("");
    
    console.log(root);
    

    const categories = [
      {
        id: 1,
        name: "ALL",
        parentPath: "categories",
        translationFr: "Toutes les catégories"
      },
      {
        id: 2,
        name: "LEISURE",
        parentPath: "categories.leisure",
        translationFr: "Loisirs"
      },
      {
        id: 35,
        name: "AUTO",
        parentPath: "categories.services.auto",
        translationFr: "Auto-écoles"
      },
      {
        id: 3,
        name: "MUSEUMS",
        parentPath: "categories.leisure.museums",
        translationFr: "Musées"
      },
      {
        id: 4,
        name: "MONUMENTS",
        parentPath: "categories.leisure.monuments",
        translationFr: "Monuments et patrimoine"
      },
      {
        id: 5,
        name: "OPERAS",
        parentPath: "categories.leisure.operas",
        translationFr: "Opéras"
      },
      {
        id: 6,
        name: "THEATER",
        parentPath: "categories.leisure.theater",
        translationFr: "Théâtres"
      },
      {
        id: 7,
        name: "CINEMAS",
        parentPath: "categories.leisure.cinemas",
        translationFr: "Cinémas"
      },
      {
        id: 8,
        name: "CONCERT",
        parentPath: "categories.leisure.concert",
        translationFr: "Salles de concert"
      },
      {
        id: 9,
        name: "FITNESS",
        parentPath: "categories.leisure.fitness",
        translationFr: "Salles de fitness"
      },
      {
        id: 10,
        name: "CLUBS",
        parentPath: "categories.leisure.clubs",
        translationFr: "Clubs de sport"
      },
      {
        id: 11,
        name: "SENSATIONAL",
        parentPath: "categories.leisure.sensational",
        translationFr: "Sensationnel"
      },
      {
        id: 12,
        name: "HEALTH",
        parentPath: "categories.health",
        translationFr: "Bien-être et beauté"
      },
      {
        id: 13,
        name: "HAIR",
        parentPath: "categories.health.hair",
        translationFr: "Salons de coiffure"
      },
      {
        id: 14,
        name: "CARE",
        parentPath: "categories.health.care",
        translationFr: "Soins"
      },
      {
        id: 15,
        name: "SPAS",
        parentPath: "categories.health.spas",
        translationFr: "Spas"
      },
      {
        id: 16,
        name: "MASSAGE",
        parentPath: "categories.health.massage",
        translationFr: "Massage"
      },
      {
        id: 17,
        name: "FOOD",
        parentPath: "categories.food",
        translationFr: "Restaurants et Bars"
      },
      {
        id: 18,
        name: "NIGHTCLUB",
        parentPath: "categories.food.nightclub",
        translationFr: "Boîtes de nuit"
      },
      {
        id: 19,
        name: "RESTAURANTS",
        parentPath: "categories.food.restaurants",
        translationFr: "Restaurants"
      },
      {
        id: 20,
        name: "BARS",
        parentPath: "categories.food.bars",
        translationFr: "Bars"
      },
      {
        id: 21,
        name: "FASTFOOD",
        parentPath: "categories.food.fastfood",
        translationFr: "Sur le pouce"
      }
    ];
    
    var root = categories.reduce((children, item) => {
      children(item.parentPath.replace(/(^|\.)\w+$/g, "")).push({
        ...item,
        children: children(item.parentPath)
      });
      
      return children;
    }, function(key){ 
      return this[key] || (this[key]=[]) 
    }.bind({}) )("");
    
    console.log(root);