11"""Neo4j Cypher queries for Explore service.
22
33All database queries are defined here to keep the API layer thin.
4+
5+ Graph model reference:
6+ (Release)-[:BY]->(Artist) Release is by Artist
7+ (Release)-[:ON]->(Label) Release is on Label
8+ (Release)-[:IS]->(Genre) Release is genre
9+ (Release)-[:IS]->(Style) Release is style
10+ (Release)-[:DERIVED_FROM]->(Master) Release derived from Master (Master has year)
11+ (Artist)-[:ALIAS_OF]->(Artist) Artist alias
12+ (Artist)-[:MEMBER_OF]->(Artist) Artist is member of group
413"""
514
615from functools import lru_cache
@@ -61,16 +70,18 @@ async def explore_artist(driver: AsyncResilientNeo4jDriver, name: str) -> dict[s
6170 """Get artist center node with category counts."""
6271 cypher = """
6372 MATCH (a:Artist {name: $name})
64- OPTIONAL MATCH (a )-[:BY]->(r:Release )
73+ OPTIONAL MATCH (r:Release )-[:BY]->(a )
6574 WITH a, count(DISTINCT r) AS release_count
66- OPTIONAL MATCH (a )-[:BY]->(r2:Release )-[:ON]->(l:Label)
75+ OPTIONAL MATCH (r2:Release )-[:BY]->(a), (r2 )-[:ON]->(l:Label)
6776 WITH a, release_count, count(DISTINCT l) AS label_count
68- OPTIONAL MATCH (a)-[:MEMBER_OF]->(g:Artist)
69- WITH a, release_count, label_count, count(DISTINCT g) AS alias_count
70- OPTIONAL MATCH (a2:Artist)-[:MEMBER_OF]->(a)
71- WITH a, release_count, label_count, alias_count + count(DISTINCT a2) AS alias_count
77+ OPTIONAL MATCH (a)-[:ALIAS_OF]->(alias:Artist)
78+ WITH a, release_count, label_count, count(DISTINCT alias) AS alias_count
79+ OPTIONAL MATCH (a)-[:MEMBER_OF]->(grp:Artist)
80+ WITH a, release_count, label_count, alias_count, count(DISTINCT grp) AS group_count
81+ OPTIONAL MATCH (m:Artist)-[:MEMBER_OF]->(a)
82+ WITH a, release_count, label_count, alias_count, group_count, count(DISTINCT m) AS member_count
7283 RETURN a.id AS id, a.name AS name,
73- release_count, label_count, alias_count
84+ release_count, label_count, alias_count + group_count + member_count AS alias_count
7485 """
7586 async with await driver .session () as session :
7687 result = await session .run (cypher , name = name )
@@ -84,11 +95,11 @@ async def explore_genre(driver: AsyncResilientNeo4jDriver, name: str) -> dict[st
8495 """Get genre center node with category counts."""
8596 cypher = """
8697 MATCH (g:Genre {name: $name})
87- OPTIONAL MATCH (a:Artist )-[:IS]->(g)
98+ OPTIONAL MATCH (r:Release )-[:IS]->(g), (r)-[:BY]->(a:Artist )
8899 WITH g, count(DISTINCT a) AS artist_count
89- OPTIONAL MATCH (a2:Artist )-[:IS]->(g), (a2)-[:BY]->(r:Release )-[:ON]->(l:Label)
100+ OPTIONAL MATCH (r2:Release )-[:IS]->(g), (r2 )-[:ON]->(l:Label)
90101 WITH g, artist_count, count(DISTINCT l) AS label_count
91- OPTIONAL MATCH (a3:Artist )-[:IS]->(g), (a3 )-[:IS]->(s:Style)
102+ OPTIONAL MATCH (r3:Release )-[:IS]->(g), (r3 )-[:IS]->(s:Style)
92103 WITH g, artist_count, label_count, count(DISTINCT s) AS style_count
93104 RETURN g.name AS id, g.name AS name,
94105 artist_count, label_count, style_count
@@ -107,7 +118,7 @@ async def explore_label(driver: AsyncResilientNeo4jDriver, name: str) -> dict[st
107118 MATCH (l:Label {name: $name})
108119 OPTIONAL MATCH (r:Release)-[:ON]->(l)
109120 WITH l, count(DISTINCT r) AS release_count
110- OPTIONAL MATCH (a:Artist )-[:BY ]->(r2:Release )-[:ON ]->(l )
121+ OPTIONAL MATCH (r2:Release )-[:ON ]->(l), (r2 )-[:BY ]->(a:Artist )
111122 WITH l, release_count, count(DISTINCT a) AS artist_count
112123 RETURN l.id AS id, l.name AS name,
113124 release_count, artist_count
@@ -126,9 +137,12 @@ async def explore_label(driver: AsyncResilientNeo4jDriver, name: str) -> dict[st
126137async def expand_artist_releases (driver : AsyncResilientNeo4jDriver , artist_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
127138 """Get releases by an artist."""
128139 cypher = """
129- MATCH (a:Artist {name: $name})-[:BY]->(r:Release)
130- RETURN r.id AS id, r.title AS name, 'release' AS type, r.year AS year
131- ORDER BY r.year DESC
140+ MATCH (r:Release)-[:BY]->(a:Artist {name: $name})
141+ OPTIONAL MATCH (r)-[:DERIVED_FROM]->(m:Master)
142+ WITH r, m.year AS year
143+ RETURN r.id AS id, r.title AS name, 'release' AS type,
144+ CASE WHEN toInteger(year) > 0 THEN toInteger(year) ELSE null END AS year
145+ ORDER BY year DESC
132146 LIMIT $limit
133147 """
134148 async with await driver .session () as session :
@@ -139,8 +153,8 @@ async def expand_artist_releases(driver: AsyncResilientNeo4jDriver, artist_name:
139153async def expand_artist_labels (driver : AsyncResilientNeo4jDriver , artist_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
140154 """Get labels associated with an artist via their releases."""
141155 cypher = """
142- MATCH (a:Artist {name: $name})-[:BY]->(r:Release )-[:ON]->(l:Label)
143- RETURN DISTINCT l.id AS id, l.name AS name, 'label' AS type, count(r) AS release_count
156+ MATCH (r:Release)-[:BY]->( a:Artist {name: $name}), (r )-[:ON]->(l:Label)
157+ RETURN l.id AS id, l.name AS name, 'label' AS type, count(DISTINCT r) AS release_count
144158 ORDER BY release_count DESC
145159 LIMIT $limit
146160 """
@@ -150,15 +164,18 @@ async def expand_artist_labels(driver: AsyncResilientNeo4jDriver, artist_name: s
150164
151165
152166async def expand_artist_aliases (driver : AsyncResilientNeo4jDriver , artist_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
153- """Get aliases and group memberships for an artist."""
167+ """Get aliases, group memberships, and members for an artist."""
154168 cypher = """
155169 MATCH (a:Artist {name: $name})
156- OPTIONAL MATCH (a)-[:MEMBER_OF]->(g:Artist)
157- WITH a, collect(DISTINCT {id: g.id, name: g.name, type: 'artist'}) AS groups
170+ OPTIONAL MATCH (a)-[:ALIAS_OF]->(alias:Artist)
171+ WITH a, collect(DISTINCT {id: alias.id, name: alias.name, type: 'artist'}) AS aliases
172+ OPTIONAL MATCH (a)-[:MEMBER_OF]->(grp:Artist)
173+ WITH a, aliases, collect(DISTINCT {id: grp.id, name: grp.name, type: 'artist'}) AS groups
158174 OPTIONAL MATCH (m:Artist)-[:MEMBER_OF]->(a)
159- WITH groups, collect(DISTINCT {id: m.id, name: m.name, type: 'artist'}) AS members
160- UNWIND (groups + members) AS alias
161- RETURN DISTINCT alias.id AS id, alias.name AS name, alias.type AS type
175+ WITH aliases, groups, collect(DISTINCT {id: m.id, name: m.name, type: 'artist'}) AS members
176+ UNWIND (aliases + groups + members) AS item
177+ WITH item WHERE item.id IS NOT NULL
178+ RETURN DISTINCT item.id AS id, item.name AS name, item.type AS type
162179 LIMIT $limit
163180 """
164181 async with await driver .session () as session :
@@ -167,9 +184,9 @@ async def expand_artist_aliases(driver: AsyncResilientNeo4jDriver, artist_name:
167184
168185
169186async def expand_genre_artists (driver : AsyncResilientNeo4jDriver , genre_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
170- """Get artists in a genre."""
187+ """Get artists in a genre (via releases) ."""
171188 cypher = """
172- MATCH (a:Artist )-[:IS]->(g:Genre {name: $name})
189+ MATCH (r:Release )-[:IS]->(g:Genre {name: $name}), (r)-[:BY]->(a:Artist )
173190 RETURN DISTINCT a.id AS id, a.name AS name, 'artist' AS type
174191 ORDER BY a.name
175192 LIMIT $limit
@@ -180,11 +197,10 @@ async def expand_genre_artists(driver: AsyncResilientNeo4jDriver, genre_name: st
180197
181198
182199async def expand_genre_labels (driver : AsyncResilientNeo4jDriver , genre_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
183- """Get labels associated with a genre via artists and releases."""
200+ """Get labels associated with a genre via releases."""
184201 cypher = """
185- MATCH (a:Artist)-[:IS]->(g:Genre {name: $name}),
186- (a)-[:BY]->(r:Release)-[:ON]->(l:Label)
187- RETURN DISTINCT l.id AS id, l.name AS name, 'label' AS type, count(DISTINCT r) AS release_count
202+ MATCH (r:Release)-[:IS]->(g:Genre {name: $name}), (r)-[:ON]->(l:Label)
203+ RETURN l.id AS id, l.name AS name, 'label' AS type, count(DISTINCT r) AS release_count
188204 ORDER BY release_count DESC
189205 LIMIT $limit
190206 """
@@ -194,12 +210,11 @@ async def expand_genre_labels(driver: AsyncResilientNeo4jDriver, genre_name: str
194210
195211
196212async def expand_genre_styles (driver : AsyncResilientNeo4jDriver , genre_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
197- """Get styles (subgenres) associated with a genre."""
213+ """Get styles (subgenres) associated with a genre via releases ."""
198214 cypher = """
199- MATCH (a:Artist)-[:IS]->(g:Genre {name: $name}),
200- (a)-[:IS]->(s:Style)
201- RETURN DISTINCT s.name AS id, s.name AS name, 'style' AS type, count(DISTINCT a) AS artist_count
202- ORDER BY artist_count DESC
215+ MATCH (r:Release)-[:IS]->(g:Genre {name: $name}), (r)-[:IS]->(s:Style)
216+ RETURN s.name AS id, s.name AS name, 'style' AS type, count(DISTINCT r) AS release_count
217+ ORDER BY release_count DESC
203218 LIMIT $limit
204219 """
205220 async with await driver .session () as session :
@@ -211,8 +226,11 @@ async def expand_label_releases(driver: AsyncResilientNeo4jDriver, label_name: s
211226 """Get releases on a label."""
212227 cypher = """
213228 MATCH (r:Release)-[:ON]->(l:Label {name: $name})
214- RETURN r.id AS id, r.title AS name, 'release' AS type, r.year AS year
215- ORDER BY r.year DESC
229+ OPTIONAL MATCH (r)-[:DERIVED_FROM]->(m:Master)
230+ WITH r, m.year AS year
231+ RETURN r.id AS id, r.title AS name, 'release' AS type,
232+ CASE WHEN toInteger(year) > 0 THEN toInteger(year) ELSE null END AS year
233+ ORDER BY year DESC
216234 LIMIT $limit
217235 """
218236 async with await driver .session () as session :
@@ -223,8 +241,8 @@ async def expand_label_releases(driver: AsyncResilientNeo4jDriver, label_name: s
223241async def expand_label_artists (driver : AsyncResilientNeo4jDriver , label_name : str , limit : int = 50 ) -> list [dict [str , Any ]]:
224242 """Get artists on a label."""
225243 cypher = """
226- MATCH (a:Artist)-[:BY]->( r:Release)-[:ON]->(l:Label {name: $name})
227- RETURN DISTINCT a.id AS id, a.name AS name, 'artist' AS type, count(DISTINCT r) AS release_count
244+ MATCH (r:Release)-[:ON]->(l:Label {name: $name}), (r)-[:BY]->(a:Artist )
245+ RETURN a.id AS id, a.name AS name, 'artist' AS type, count(DISTINCT r) AS release_count
228246 ORDER BY release_count DESC
229247 LIMIT $limit
230248 """
@@ -240,12 +258,12 @@ async def get_artist_details(driver: AsyncResilientNeo4jDriver, node_id: str) ->
240258 """Get full details for an artist node."""
241259 cypher = """
242260 MATCH (a:Artist) WHERE a.id = $id OR a.name = $id
243- OPTIONAL MATCH (a )-[:IS]->(g:Genre)
261+ OPTIONAL MATCH (r:Release)-[:BY]->(a), (r )-[:IS]->(g:Genre)
244262 WITH a, collect(DISTINCT g.name) AS genres
245- OPTIONAL MATCH (a )-[:IS]->(s:Style)
263+ OPTIONAL MATCH (r2:Release)-[:BY]->(a), (r2 )-[:IS]->(s:Style)
246264 WITH a, genres, collect(DISTINCT s.name) AS styles
247- OPTIONAL MATCH (a )-[:BY]->(r:Release )
248- WITH a, genres, styles, count(DISTINCT r ) AS release_count
265+ OPTIONAL MATCH (r3:Release )-[:BY]->(a )
266+ WITH a, genres, styles, count(DISTINCT r3 ) AS release_count
249267 OPTIONAL MATCH (a)-[:MEMBER_OF]->(grp:Artist)
250268 WITH a, genres, styles, release_count, collect(DISTINCT grp.name) AS groups
251269 RETURN a.id AS id, a.name AS name, genres, styles, release_count, groups
@@ -262,15 +280,18 @@ async def get_release_details(driver: AsyncResilientNeo4jDriver, node_id: str) -
262280 """Get full details for a release node."""
263281 cypher = """
264282 MATCH (r:Release) WHERE r.id = $id OR r.title = $id
265- OPTIONAL MATCH (a:Artist )-[:BY]->(r )
283+ OPTIONAL MATCH (r )-[:BY]->(a:Artist )
266284 WITH r, collect(DISTINCT a.name) AS artists
267285 OPTIONAL MATCH (r)-[:ON]->(l:Label)
268286 WITH r, artists, collect(DISTINCT l.name) AS labels
269- OPTIONAL MATCH (r)-[:HAS_GENRE ]->(g:Genre)
287+ OPTIONAL MATCH (r)-[:IS ]->(g:Genre)
270288 WITH r, artists, labels, collect(DISTINCT g.name) AS genres
271- OPTIONAL MATCH (r)-[:HAS_STYLE ]->(s:Style)
289+ OPTIONAL MATCH (r)-[:IS ]->(s:Style)
272290 WITH r, artists, labels, genres, collect(DISTINCT s.name) AS styles
273- RETURN r.id AS id, r.title AS name, r.year AS year, r.country AS country,
291+ OPTIONAL MATCH (r)-[:DERIVED_FROM]->(m:Master)
292+ WITH r, artists, labels, genres, styles,
293+ CASE WHEN toInteger(m.year) > 0 THEN toInteger(m.year) ELSE null END AS year
294+ RETURN r.id AS id, r.title AS name, year,
274295 artists, labels, genres, styles
275296 """
276297 async with await driver .session () as session :
@@ -301,7 +322,7 @@ async def get_genre_details(driver: AsyncResilientNeo4jDriver, node_id: str) ->
301322 """Get full details for a genre node."""
302323 cypher = """
303324 MATCH (g:Genre {name: $id})
304- OPTIONAL MATCH (a:Artist )-[:IS]->(g)
325+ OPTIONAL MATCH (r:Release )-[:IS]->(g), (r)-[:BY]->(a:Artist )
305326 WITH g, count(DISTINCT a) AS artist_count
306327 RETURN g.name AS id, g.name AS name, artist_count
307328 """
@@ -317,38 +338,44 @@ async def get_genre_details(driver: AsyncResilientNeo4jDriver, node_id: str) ->
317338
318339
319340async def trends_artist (driver : AsyncResilientNeo4jDriver , name : str ) -> list [dict [str , Any ]]:
320- """Get release count by year for an artist."""
341+ """Get release count by year for an artist (year from Master) ."""
321342 cypher = """
322- MATCH (a:Artist {name: $name})-[:BY]->(r:Release)
323- WHERE r.year IS NOT NULL AND r.year > 0
324- RETURN r.year AS year, count(r) AS count
325- ORDER BY r.year
343+ MATCH (r:Release)-[:BY]->(a:Artist {name: $name}),
344+ (r)-[:DERIVED_FROM]->(m:Master)
345+ WHERE toInteger(m.year) > 0
346+ WITH toInteger(m.year) AS year, count(DISTINCT r) AS count
347+ RETURN year, count
348+ ORDER BY year
326349 """
327350 async with await driver .session () as session :
328351 result = await session .run (cypher , name = name )
329352 return [dict (record ) async for record in result ]
330353
331354
332355async def trends_genre (driver : AsyncResilientNeo4jDriver , name : str ) -> list [dict [str , Any ]]:
333- """Get release count by year for a genre."""
356+ """Get release count by year for a genre (year from Master) ."""
334357 cypher = """
335- MATCH (r:Release)-[:HAS_GENRE]->(g:Genre {name: $name})
336- WHERE r.year IS NOT NULL AND r.year > 0
337- RETURN r.year AS year, count(r) AS count
338- ORDER BY r.year
358+ MATCH (r:Release)-[:IS]->(g:Genre {name: $name}),
359+ (r)-[:DERIVED_FROM]->(m:Master)
360+ WHERE toInteger(m.year) > 0
361+ WITH toInteger(m.year) AS year, count(DISTINCT r) AS count
362+ RETURN year, count
363+ ORDER BY year
339364 """
340365 async with await driver .session () as session :
341366 result = await session .run (cypher , name = name )
342367 return [dict (record ) async for record in result ]
343368
344369
345370async def trends_label (driver : AsyncResilientNeo4jDriver , name : str ) -> list [dict [str , Any ]]:
346- """Get release count by year for a label."""
371+ """Get release count by year for a label (year from Master) ."""
347372 cypher = """
348- MATCH (r:Release)-[:ON]->(l:Label {name: $name})
349- WHERE r.year IS NOT NULL AND r.year > 0
350- RETURN r.year AS year, count(r) AS count
351- ORDER BY r.year
373+ MATCH (r:Release)-[:ON]->(l:Label {name: $name}),
374+ (r)-[:DERIVED_FROM]->(m:Master)
375+ WHERE toInteger(m.year) > 0
376+ WITH toInteger(m.year) AS year, count(DISTINCT r) AS count
377+ RETURN year, count
378+ ORDER BY year
352379 """
353380 async with await driver .session () as session :
354381 result = await session .run (cypher , name = name )
0 commit comments