scandingo

scandingo

Creating a nested list from a flat list

I’m new to functional programming and I’d like to know the idiomatic way to convert a flat list of maps into a nested list.

For some background, I’m experimenting with Ecto. I have a table with a recursive relationship to itself. That is to say that there is a parent_id field which has a foreign key pointing to the id field of the same table. I’m trying to get a list of just the rows where the parent_id is null at the top level with all of its children in a nested list.

flat = [
    %{id: 1, name: "region 1", parent_region_id: nil},
    %{id: 2, name: "subregion 1", parent_region_id: 1},
    %{id: 3, name: "subregion 2", parent_region_id: 1},
    %{id: 4, name: "region 2", parent_region_id: nil},
    %{id: 5, name: "subregion 3", parent_region_id: 4},
    %{id: 6, name: "subregion 4", parent_region_id: 4}
  ]

nested = [
    %{
      id: 1,
      name: "region 1",
      parent_region_id: nil,
      subregions: [%{id: 2, name: "subregion 1", parent_id: 1}, %{id: 3, name: "subregion 2", parent_region_id: 1}]
    },
    %{
      id: 4,
      name: "region 2",
      parent_region_id: nil,
      subregions: [%{id: 5, subregion: "subregion 3", parent_region_id: 4}, %{id: 6, name: "subregion 4", parent_region_id: 4}]
    }
  ]

I can achieve this by using Repo.preload(:subregions), but that would add an unnecessary join. Any tips on an elegant way to convert flat into nested?

Here is the migration script.

defmodule Bazaar.Repo.Migrations.CreateRegions do
  use Ecto.Migration

  def change do
    create table(:regions) do
      add :name, :string
      add :parent_region_id, references(:regions), null: true

      timestamps()
    end
  end
end

And this is the schema.

defmodule Bazaar.Geoscheme.Region do
  use Ecto.Schema
  import Ecto.Changeset

  schema "regions" do
    field(:name, :string)

    belongs_to(:parent_region, Bazaar.Geoscheme.Region)
    has_many(:subregions, Bazaar.Geoscheme.Region, foreign_key: :parent_region_id)

    timestamps()
  end

  @doc false
  def changeset(region, attrs) do
    region
    |> cast(attrs, [:name, :parent_region_id])
    |> validate_required([:name])
  end
end

Marked As Solved

al2o3cr

al2o3cr

flat = [
    %{id: 1, name: "region 1", parent_region_id: nil, subregions: []},
    %{id: 2, name: "subregion 1", parent_region_id: 1, subregions: []},
    %{id: 3, name: "subregion 2", parent_region_id: 1, subregions: []},
    %{id: 4, name: "region 2", parent_region_id: nil, subregions: []},
    %{id: 5, name: "subregion 3", parent_region_id: 4, subregions: []},
    %{id: 6, name: "subregion 4", parent_region_id: 5, subregions: []}
  ]

defmodule Nester do
  def as_nested(flat) do
    lookup = Enum.group_by(flat, & &1.parent_region_id)

    with_subregions(nil, lookup)
  end

  defp with_subregions(parent_id, lookup) do
    lookup
    |> Map.get(parent_id, [])
    |> Enum.map(&nest_one(&1, lookup))
  end

  defp nest_one(row, lookup) do
    %{row | subregions: with_subregions(row.id, lookup)}
  end
end

Nester.as_nested(flat)
# result
[
  %{
    id: 1,
    name: "region 1",
    parent_region_id: nil,
    subregions: [
      %{id: 2, name: "subregion 1", parent_region_id: 1, subregions: []},
      %{id: 3, name: "subregion 2", parent_region_id: 1, subregions: []}
    ]
  },
  %{
    id: 4,
    name: "region 2",
    parent_region_id: nil,
    subregions: [
      %{id: 5, name: "subregion 3", parent_region_id: 4, subregions: []},
      %{id: 6, name: "subregion 4", parent_region_id: 4, subregions: []}
    ]
  }
]

The key step here is splitting the calculation into two parts:

  • the Enum.group_by that pulls together the subregions lists
  • the recursive process of taking a node and filling out its subregions

Also Liked

Sebb

Sebb

defmodule Tools.TreeFromList do
  def build_tree(nodes, config) do
    by_parent = Enum.group_by(nodes, & &1[config.parent_id_key])
    Enum.map(by_parent[config.root_parent], &build_tree_(&1, by_parent, config))
  end

  defp build_tree_(node, nodes_by_parent, config) do
    children =
      Enum.map(
        Map.get(nodes_by_parent, node[config.node_id_key], []),
        &build_tree_(&1, nodes_by_parent, config)
      )

    config.build_tree_node.(node, children)
  end
end
defmodule TreeFromListTest do
  use ExUnit.Case

  @tag :build_tree
  test "tree from list" do
    data = [
      %{id: 1, name: "F1", parent_id: nil},
      %{id: 2, name: "F2", parent_id: nil},
      %{id: 6, name: "F6", parent_id: 3},
      %{id: 4, name: "F4", parent_id: 2},
      %{id: 5, name: "F5", parent_id: 3},
      %{id: 3, name: "F3", parent_id: 1}
    ]

    config = %{
      build_tree_node: &Map.put(&1, :children, &2),
      parent_id_key: :parent_id,
      node_id_key: :id,
      root_parent: nil
    }

    assert [
             %{
               children: [
                 %{
                   children: [
                     %{children: [], name: "F6", parent_id: 3, id: 6},
                     %{children: [], name: "F5", parent_id: 3, id: 5}
                   ],
                   name: "F3",
                   parent_id: 1,
                   id: 3
                 }
               ],
               name: "F1",
               parent_id: nil,
               id: 1
             },
             %{
               children: [%{children: [], name: "F4", parent_id: 2, id: 4}],
               name: "F2",
               parent_id: nil,
               id: 2
             }
           ] == Tools.TreeFromList.build_tree(data, config)
  end
end
scandingo

scandingo

Awesome! This worked as is when piping my Repo.all into it. Now let’s see if I actually understand it.

Is the following correct?

This bit & &1.parent_region_id creates an anonymous function and returns the parent_region_id of the first argument which is then used to group the map elements.

Everything else seems comprehensible.

Where Next?

Popular in Questions Top

dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
yawaramin
In the Dialyzer docs ( http://erlang.org/doc/man/dialyzer.html#requesting-or-suppressing-warnings-in-source-files ), there is a way to tu...
New
fireproofsocks
I’m working on defining a simple Ecto schema for a table (in PostGres), but I don’t see where I can define a column as NOT NULL. Conside...
New
vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
makeitrein
Hey all, just started picking up Elixir last week and am writing a scraper as a learning project. Baby step #1 is extracting the number ...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
Fl4m3Ph03n1x
About me? ( if you have nothing better to do than reading about some random guy in the internet :stuck_out_tongue: ) Hello all, this is ...
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New

Other popular topics Top

Qqwy
Update: How to use the Blogs & Podcasts section You can post links to your blog posts or podcasts either in one of the Official Blog...
3268 119930 1237
New
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
itssasanka
Hi all, Trying to get some more clarity over utc_datetime and naive_datetime for Ecto: https://hexdocs.pm/ecto/Ecto.Schema.html#module-...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
sergio
I couldn’t find any guides that worked well with Phoenix 1.6.0 and esbuild. I hope this helps people test the waters and eases you into t...
New
malloryerik
Hi, this is for people who, like me, have had some friction using .html.heex templates in VSCode. The solution seems to be, in a hyphena...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New

We're in Beta

About us Mission Statement