From 98f901c020931ff8bff7dce5e61dbeb0dda0519f Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Mon, 16 Feb 2026 12:30:41 +0000 Subject: [PATCH] Materialize record releases to improve performance --- lib/music_library/collection.ex | 12 +-- lib/music_library/records/record_release.ex | 11 +++ lib/music_library/wishlist.ex | 12 +-- .../20260216121934_create_record_releases.exs | 79 +++++++++++++++++++ 4 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 lib/music_library/records/record_release.ex create mode 100644 priv/repo/migrations/20260216121934_create_record_releases.exs diff --git a/lib/music_library/collection.ex b/lib/music_library/collection.ex index 8873df5e..e42f929f 100644 --- a/lib/music_library/collection.ex +++ b/lib/music_library/collection.ex @@ -3,7 +3,7 @@ defmodule MusicLibrary.Collection do import MusicLibrary.Records, only: [order_alphabetically: 0] alias MusicLibrary.Records - alias MusicLibrary.Records.{Record, SearchIndex} + alias MusicLibrary.Records.{Record, RecordRelease, SearchIndex} alias MusicLibrary.Repo def search_records(query, opts \\ []) do @@ -121,13 +121,9 @@ defmodule MusicLibrary.Collection do end def collected_releases_query do - from r in fragment("records, json_each(records.release_ids)"), - where: fragment("records.purchased_at IS NOT NULL"), - select: %{ - record_id: fragment("records.id"), - cover_hash: fragment("records.cover_hash"), - release_id: r.value - } + from rr in RecordRelease, + where: not is_nil(rr.purchased_at), + select: %{record_id: rr.record_id, cover_hash: rr.cover_hash, release_id: rr.release_id} end defp base_search do diff --git a/lib/music_library/records/record_release.ex b/lib/music_library/records/record_release.ex new file mode 100644 index 00000000..f6d7a71a --- /dev/null +++ b/lib/music_library/records/record_release.ex @@ -0,0 +1,11 @@ +defmodule MusicLibrary.Records.RecordRelease do + use Ecto.Schema + + @primary_key false + schema "record_releases" do + field :record_id, :binary_id + field :release_id, :string + field :cover_hash, :string + field :purchased_at, :utc_datetime + end +end diff --git a/lib/music_library/wishlist.ex b/lib/music_library/wishlist.ex index 8fa77df0..3df70176 100644 --- a/lib/music_library/wishlist.ex +++ b/lib/music_library/wishlist.ex @@ -2,7 +2,7 @@ defmodule MusicLibrary.Wishlist do import Ecto.Query, warn: false alias MusicLibrary.Records - alias MusicLibrary.Records.SearchIndex + alias MusicLibrary.Records.{RecordRelease, SearchIndex} alias MusicLibrary.Repo def search_records(query, opts \\ []) do @@ -22,13 +22,9 @@ defmodule MusicLibrary.Wishlist do end def wishlisted_releases_query do - from r in fragment("records, json_each(records.release_ids)"), - where: fragment("records.purchased_at IS NULL"), - select: %{ - record_id: fragment("records.id"), - cover_hash: fragment("records.cover_hash"), - release_id: r.value - } + from rr in RecordRelease, + where: is_nil(rr.purchased_at), + select: %{record_id: rr.record_id, cover_hash: rr.cover_hash, release_id: rr.release_id} end defp base_search do diff --git a/priv/repo/migrations/20260216121934_create_record_releases.exs b/priv/repo/migrations/20260216121934_create_record_releases.exs new file mode 100644 index 00000000..89143abb --- /dev/null +++ b/priv/repo/migrations/20260216121934_create_record_releases.exs @@ -0,0 +1,79 @@ +defmodule MusicLibrary.Repo.Migrations.CreateRecordReleases do + use Ecto.Migration + + def up do + execute """ + CREATE TABLE record_releases ( + record_id TEXT NOT NULL, + release_id TEXT NOT NULL, + cover_hash TEXT, + purchased_at TEXT, + PRIMARY KEY (record_id, release_id), + FOREIGN KEY (record_id) REFERENCES records(id) ON DELETE CASCADE + ); + """ + + execute """ + CREATE INDEX record_releases_release_id_index ON record_releases(release_id); + """ + + flush() + + # Populate from existing data + execute """ + INSERT INTO record_releases (record_id, release_id, cover_hash, purchased_at) + SELECT records.id, json_each.value, records.cover_hash, records.purchased_at + FROM records, json_each(records.release_ids); + """ + + flush() + + # Trigger: after insert on records, expand release_ids into record_releases + execute """ + CREATE TRIGGER record_releases_after_insert + AFTER INSERT ON records + BEGIN + INSERT INTO record_releases (record_id, release_id, cover_hash, purchased_at) + SELECT NEW.id, json_each.value, NEW.cover_hash, NEW.purchased_at + FROM json_each(NEW.release_ids); + END; + """ + + # Trigger: before update on records, remove old rows + execute """ + CREATE TRIGGER record_releases_before_update + BEFORE UPDATE ON records + BEGIN + DELETE FROM record_releases WHERE record_id = OLD.id; + END; + """ + + # Trigger: after update on records, insert new rows + execute """ + CREATE TRIGGER record_releases_after_update + AFTER UPDATE ON records + BEGIN + INSERT INTO record_releases (record_id, release_id, cover_hash, purchased_at) + SELECT NEW.id, json_each.value, NEW.cover_hash, NEW.purchased_at + FROM json_each(NEW.release_ids); + END; + """ + + # Trigger: before delete on records, remove rows + execute """ + CREATE TRIGGER record_releases_before_delete + BEFORE DELETE ON records + BEGIN + DELETE FROM record_releases WHERE record_id = OLD.id; + END; + """ + end + + def down do + execute "DROP TRIGGER IF EXISTS record_releases_after_insert" + execute "DROP TRIGGER IF EXISTS record_releases_before_update" + execute "DROP TRIGGER IF EXISTS record_releases_after_update" + execute "DROP TRIGGER IF EXISTS record_releases_before_delete" + execute "DROP TABLE IF EXISTS record_releases" + end +end