The old school approach is to have the RDBMS order the result to keep the necessary information clustered together and then aggregate the result rows:
# file: music_db/priv/repo/playground.exs
#
# http://www.pragmaticprogrammer.com/titles/wmecto
# https://pragprog.com/titles/wmecto/source_code
# http://media.pragprog.com/titles/wmecto/code/wmecto-code.zip
#
# pg_ctl -D /usr/local/var/postgres start
# mix format ./priv/repo/playground.exs
# mix run ./priv/repo/playground.exs
#
defmodule AppInfo do
def string() do
Application.loaded_applications()
|> Enum.map(&to_app_keyword/1)
|> Enum.sort_by(&map_app_name/1)
|> Enum.map_join(", ", &app_keyword_to_string/1)
end
defp to_app_keyword({app, _, vsn}),
do: {app, vsn}
defp app_keyword_to_string({app, vsn}),
do: Atom.to_string(app) <> ": " <> to_string(vsn)
defp map_app_name({app, _}),
do: app
end
defmodule Playground do
import Ecto.Query
alias MusicDB.Repo
alias MusicDB.{Artist, Album, Track}
def query,
do:
from(a in Artist,
left_join: b in Album,
on: a.id == b.artist_id,
left_join: t in Track,
on: b.id == t.album_id,
order_by: [asc: a.id, asc: b.id, asc: t.index],
select: %{
artist_id: a.id,
name: a.name,
album_id: b.id,
album_title: b.title,
track_id: t.id,
title: t.title
}
)
def init_acc(),
do: {{nil, nil, []}, {nil, nil, []}, []}
def init_album_acc(%{album_id: album_id, album_title: album_title, title: title}),
do: {album_id, album_title, [title]}
def aggregate_album_acc(track, {album_id, title, tracks}),
do: {album_id, title, [track.title | tracks]}
def init_artist_acc(%{artist_id: artist_id, name: name}),
do: {artist_id, name, []}
def aggregate_artist_acc({_, title, tracks}, {artist_id, name, albums}),
do: {artist_id, name, [{title, :lists.reverse(tracks)} | albums]}
def aggregate_artists({{nil, _, _}, {nil, _, _}, []}) do
[]
end
def aggregate_artists({album_acc, artist_acc, artists}) do
{_, name, albums} = aggregate_artist_acc(album_acc, artist_acc)
[{name, :lists.reverse(albums)} | artists]
end
# same album and artist - just add a track
def aggregate(
%{artist_id: artist_id, album_id: album_id} = track,
{{album_id, _, _} = acc, {artist_id, _, _} = artist_acc, artists}
),
do: {aggregate_album_acc(track, acc), artist_acc, artists}
# same artist, new album
def aggregate(
%{artist_id: artist_id} = track,
{album_acc, {artist_id, _, _} = artist_acc, artists}
),
do: {init_album_acc(track), aggregate_artist_acc(album_acc, artist_acc), artists}
# new artist and album
def aggregate(track, acc),
do: {init_album_acc(track), init_artist_acc(track), aggregate_artists(acc)}
def play do
IO.puts(AppInfo.string())
query()
|> Repo.all()
|> List.foldl(init_acc(), &aggregate/2)
|> aggregate_artists()
|> :lists.reverse()
end
end
IO.inspect(Playground.play())
$ mix run ./priv/repo/playground.exs
asn1: 5.0.7, compiler: 7.2.7, connection: 1.0.4, crypto: 4.3.3, db_connection: 2.0.3, decimal: 1.6.0, ecto: 3.0.5, ecto_sql: 3.0.3, elixir: 1.7.4, hex: 0.18.2, inets: 7.0.2, kernel: 6.1.1, logger: 1.7.4, mix: 1.7.4, music_db: 0.1.0, poison: 3.1.0, postgrex: 0.14.1, public_key: 1.6.3, ssl: 9.0.3, stdlib: 3.6, telemetry: 0.2.0
23:46:42.849 [debug] QUERY OK source="artists" db=1.3ms decode=0.6ms queue=1.3ms
SELECT a0."id", a0."name", a1."id", a1."title", t2."id", t2."title" FROM "artists" AS a0 LEFT OUTER JOIN "albums" AS a1 ON a0."id" = a1."artist_id" LEFT OUTER JOIN "tracks" AS t2 ON a1."id" = t2."album_id" ORDER BY a0."id", a1."id", t2."index" []
[
{"Miles Davis",
[
{"Kind Of Blue",
["So What", "Freddie Freeloader", "Blue In Green", "All Blues",
"Flamenco Sketches"]},
{"Cookin' At The Plugged Nickel",
["If I Were A Bell", "Stella By Starlight", "Walkin'", "Miles",
"No Blues"]}
]},
{"Bill Evans",
[
{"You Must Believe In Spring",
["B Minor Waltz (for Ellaine)", "You Must Believe In Spring",
"Gary's Theme", "We Will Meet Again (for Harry)", "The Peacocks",
"Sometime Ago", "Theme From M*A*S*H (Suicide Is Painless)",
"Without a Song", "Freddie Freeloader", "All of You"]},
{"Portrait In Jazz",
["Come Rain Or Come Shine", "Autumn Leaves", "Witchcraft",
"When I Fall In Love", "Peri's Scope", "What Is This Thing Called Love?",
"Spring Is Here", "Someday My Prince Will Come", "Blue In Green"]}
]},
{"Bobby Hutcherson",
[
{"Live At Montreaux",
["Anton's Ball", "The Moontrane", "Farallone", "Song Of Songs"]}
]}
]
$
Using Ecto associations is the easier option.
# file: music_db/priv/repo/playground.exs
#
# http://www.pragmaticprogrammer.com/titles/wmecto
# https://pragprog.com/titles/wmecto/source_code
# http://media.pragprog.com/titles/wmecto/code/wmecto-code.zip
#
# pg_ctl -D /usr/local/var/postgres start
# mix format ./priv/repo/playground.exs
# mix run ./priv/repo/playground.exs
#
defmodule AppInfo do
def string() do
Application.loaded_applications()
|> Enum.map(&to_app_keyword/1)
|> Enum.sort_by(&map_app_name/1)
|> Enum.map_join(", ", &app_keyword_to_string/1)
end
defp to_app_keyword({app, _, vsn}),
do: {app, vsn}
defp app_keyword_to_string({app, vsn}),
do: Atom.to_string(app) <> ": " <> to_string(vsn)
defp map_app_name({app, _}),
do: app
end
defmodule Playground do
import Ecto.Query
alias MusicDB.Repo
alias MusicDB.{Artist}
def query,
do:
from(a in Artist,
left_join: m in assoc(a, :albums),
left_join: t in assoc(m, :tracks),
order_by: [asc: a.id, asc: m.id, asc: t.index],
preload: [albums: {m, tracks: t}]
)
def play do
IO.puts(AppInfo.string())
artists =
query()
|> Repo.all()
for(
%MusicDB.Artist{name: name, albums: albums} <- artists,
do:
{name,
for(
%MusicDB.Album{title: title, tracks: tracks} <- albums,
do: {title, [for(%MusicDB.Track{title: title} <- tracks, do: title)]}
)}
)
end
end
IO.inspect(Playground.play())
$ mix run ./priv/repo/playground.exs
asn1: 5.0.7, compiler: 7.2.7, connection: 1.0.4, crypto: 4.3.3, db_connection: 2.0.3, decimal: 1.6.0, ecto: 3.0.5, ecto_sql: 3.0.3, elixir: 1.7.4, hex: 0.18.2, inets: 7.0.2, kernel: 6.1.1, logger: 1.7.4, mix: 1.7.4, music_db: 0.1.0, poison: 3.1.0, postgrex: 0.14.1, public_key: 1.6.3, ssl: 9.0.3, stdlib: 3.6, telemetry: 0.2.0
00:28:32.879 [debug] QUERY OK source="artists" db=5.0ms decode=0.6ms queue=1.6ms
SELECT a0."id", a0."name", a0."birth_date", a0."death_date", a0."inserted_at", a0."updated_at", a1."id", a1."title", a1."inserted_at", a1."updated_at", a1."artist_id", t2."id", t2."title", t2."duration", t2."index", t2."number_of_plays", t2."inserted_at", t2."updated_at", t2."album_id" FROM "artists" AS a0 LEFT OUTER JOIN "albums" AS a1 ON a1."artist_id" = a0."id" LEFT OUTER JOIN "tracks" AS t2 ON t2."album_id" = a1."id" ORDER BY a0."id", a1."id", t2."index" []
[
{"Miles Davis",
[
{"Kind Of Blue",
[
["So What", "Freddie Freeloader", "Blue In Green", "All Blues",
"Flamenco Sketches"]
]},
{"Cookin' At The Plugged Nickel",
[
["If I Were A Bell", "Stella By Starlight", "Walkin'", "Miles",
"No Blues"]
]}
]},
{"Bill Evans",
[
{"You Must Believe In Spring",
[
["B Minor Waltz (for Ellaine)", "You Must Believe In Spring",
"Gary's Theme", "We Will Meet Again (for Harry)", "The Peacocks",
"Sometime Ago", "Theme From M*A*S*H (Suicide Is Painless)",
"Without a Song", "Freddie Freeloader", "All of You"]
]},
{"Portrait In Jazz",
[
["Come Rain Or Come Shine", "Autumn Leaves", "Witchcraft",
"When I Fall In Love", "Peri's Scope",
"What Is This Thing Called Love?", "Spring Is Here",
"Someday My Prince Will Come", "Blue In Green"]
]}
]},
{"Bobby Hutcherson",
[
{"Live At Montreaux",
[["Anton's Ball", "The Moontrane", "Farallone", "Song Of Songs"]]}
]}
]
$
Given the following schemas
schema "artists" do
field(:name)
field(:birth_date, :date)
field(:death_date, :date)
timestamps()
has_many(:albums, Album)
has_many(:tracks, through: [:albums, :tracks])
end
schema "albums" do
field(:title, :string)
timestamps()
belongs_to(:artist, Artist)
has_many(:tracks, Track)
many_to_many(:genres, Genre, join_through: "albums_genres")
end
schema "tracks" do
field(:title, :string)
field(:duration, :integer)
field(:duration_string, :string, virtual: true)
field(:index, :integer)
field(:number_of_plays, :integer)
timestamps()
belongs_to(:album, Album)
end