Skip to content

Commit ecbdf36

Browse files
committed
Lookup for edition & book in hadith search & get id
1 parent 81ed539 commit ecbdf36

5 files changed

Lines changed: 183 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ uv run ruff format .
155155
- Create a **better rate limit policy**
156156
- Integrate **Redis** for faster data retrieval
157157
- Add **unit tests** for core functionality
158+
- Refacto...
158159

159160
### Development & Deployment
160161

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
from src.modules.book.model import Book
2+
from src.modules.edition.model import Edition
13
from src.modules.hadith.model import Hadith
24

35

46
class HadithWithVariants(Hadith):
57
variants: list[Hadith] | None = None
68

79

8-
class HadithSearchItem(Hadith):
10+
class HadithJoinedEditionAndBook(Hadith):
11+
edition: Edition | None = None
12+
book: Book | None = None
13+
14+
15+
class HadithSearchItem(HadithJoinedEditionAndBook):
916
score: float

src/modules/hadith/repository.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ def find_one_by_id(
5858
) -> dict[str, Any] | None:
5959
return self.collection.find_one({"_id": document_id}, projection)
6060

61+
def find_one_with_lookup(self, pipeline: list[dict]) -> dict[str, Any] | None:
62+
result = list(self.collection.aggregate(pipeline))
63+
return result[0] if result else None
64+
6165
def search(self, pipeline: list[dict[str, Any]]) -> dict[str, Any]:
6266
result = self._aggregate_one(pipeline)
6367
return result or {"items": [], "total": 0}

src/modules/hadith/router.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
HadithIndexMinor,
2424
HadithServiceDepends,
2525
)
26-
from src.modules.hadith.dto.hadith_response import HadithSearchItem, HadithWithVariants
26+
from src.modules.hadith.dto.hadith_response import (
27+
HadithJoinedEditionAndBook,
28+
HadithSearchItem,
29+
HadithWithVariants,
30+
)
2731
from src.modules.hadith.model import Hadith
2832

2933
hadith_router = APIRouter(
@@ -79,7 +83,7 @@ def search_hadiths( # noqa: PLR0913
7983

8084
@hadith_router.get(
8185
"/{hadith_id}",
82-
response_model=Hadith,
86+
response_model=HadithJoinedEditionAndBook,
8387
responses={
8488
404: not_found_response_annotation(Resource.hadith),
8589
400: invalid_request_annotation(),

src/modules/hadith/service.py

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,91 @@ class HadithService:
1313
def __init__(self, repository: HadithRepository):
1414
self.repository = repository
1515

16+
def _build_get_by_id_pipeline(
17+
self, hadith_id: ObjectId, languages: list[str]
18+
) -> list[dict[str, Any]]:
19+
return [
20+
{"$match": {"_id": hadith_id}},
21+
# 🔹 Edition lookup
22+
{
23+
"$lookup": {
24+
"from": "edition",
25+
"let": {"id": "$editionId"},
26+
"pipeline": [
27+
{"$match": {"$expr": {"$eq": ["$_id", "$$id"]}}},
28+
{
29+
"$project": {
30+
"_id": 1,
31+
"slug": 1,
32+
"availableLanguages": 1,
33+
"hadithCount": 1,
34+
"bookCount": 1,
35+
"name": self._filter_languages_expr(languages, "$name"),
36+
}
37+
},
38+
],
39+
"as": "edition",
40+
}
41+
},
42+
{"$unwind": {"path": "$edition", "preserveNullAndEmptyArrays": True}},
43+
# 🔹 Book lookup
44+
{
45+
"$lookup": {
46+
"from": "book",
47+
"let": {
48+
"editionId": "$editionId",
49+
"bookIndex": "$bookIndex",
50+
},
51+
"pipeline": [
52+
{
53+
"$match": {
54+
"$expr": {
55+
"$and": [
56+
{"$eq": ["$editionId", "$$editionId"]},
57+
{"$eq": ["$bookIndex", "$$bookIndex"]},
58+
]
59+
}
60+
}
61+
},
62+
{
63+
"$project": {
64+
"_id": 1,
65+
"slug": 1,
66+
"editionId": 1,
67+
"bookIndex": 1,
68+
"hadithCount": 1,
69+
"hadithIndexStart": 1,
70+
"name": self._filter_languages_expr(languages, "$name"),
71+
}
72+
},
73+
],
74+
"as": "book",
75+
}
76+
},
77+
{"$unwind": {"path": "$book", "preserveNullAndEmptyArrays": True}},
78+
# 🔹 Final projection (reuse your logic)
79+
{
80+
"$project": {
81+
**self._projection_for_languages(languages),
82+
# remove nulls cleanly
83+
"edition": {
84+
"$cond": [
85+
{"$ne": ["$edition", None]},
86+
"$edition",
87+
"$$REMOVE",
88+
]
89+
},
90+
"book": {
91+
"$cond": [
92+
{"$ne": ["$book", None]},
93+
"$book",
94+
"$$REMOVE",
95+
]
96+
},
97+
}
98+
},
99+
]
100+
16101
def _projection_for_languages(self, languages: list[str]) -> dict[str, int]:
17102
projection = {
18103
"_id": 1,
@@ -29,12 +114,26 @@ def _projection_for_languages(self, languages: list[str]) -> dict[str, int]:
29114
projection.update(build_text_projection(languages))
30115
return projection
31116

117+
def _filter_languages_expr(self, languages: list[str], field: str) -> Any:
118+
if "*" in languages:
119+
return field
120+
121+
return {
122+
"$arrayToObject": {
123+
"$filter": {
124+
"input": {"$objectToArray": field},
125+
"as": "item",
126+
"cond": {"$in": ["$$item.k", languages]},
127+
}
128+
}
129+
}
130+
32131
def _search_text_projection(
33-
self, languages: list[str]
132+
self, languages: list[str], key: str = "$text"
34133
) -> dict[str, Any] | Literal[1]:
35134
if "*" in languages:
36135
return 1
37-
return {language: f"$text.{language}" for language in languages}
136+
return {language: f"{key}.{language}" for language in languages}
38137

39138
def _build_search_pipeline(
40139
self,
@@ -77,6 +176,8 @@ def _build_search_pipeline(
77176
"text": self._search_text_projection(languages),
78177
"grades": 1,
79178
"score": {"$meta": "searchScore"},
179+
"edition": 1,
180+
"book": 1,
80181
}
81182
}
82183

@@ -88,6 +189,62 @@ def _build_search_pipeline(
88189
}
89190
},
90191
{"$limit": 100},
192+
# 👇 ADD LOOKUPS HERE
193+
{
194+
"$lookup": {
195+
"from": "edition",
196+
"let": {"id": "$editionId"},
197+
"pipeline": [
198+
{"$match": {"$expr": {"$eq": ["$_id", "$$id"]}}},
199+
{
200+
"$project": {
201+
"_id": 1,
202+
"availableLanguages": 1,
203+
"slug": 1,
204+
"hadithCount": 1,
205+
"bookCount": 1,
206+
"name": self._filter_languages_expr(languages, "$name"),
207+
}
208+
},
209+
],
210+
"as": "edition",
211+
}
212+
},
213+
{"$unwind": {"path": "$edition", "preserveNullAndEmptyArrays": True}},
214+
{
215+
"$lookup": {
216+
"from": "book",
217+
"let": {
218+
"editionId": "$editionId",
219+
"bookIndex": "$bookIndex",
220+
},
221+
"pipeline": [
222+
{
223+
"$match": {
224+
"$expr": {
225+
"$and": [
226+
{"$eq": ["$editionId", "$$editionId"]},
227+
{"$eq": ["$bookIndex", "$$bookIndex"]},
228+
]
229+
}
230+
}
231+
},
232+
{
233+
"$project": {
234+
"_id": 1,
235+
"slug": 1,
236+
"editionId": 1,
237+
"hadithCount": 1,
238+
"bookIndex": 1,
239+
"hadithIndexStart": 1,
240+
"name": self._filter_languages_expr(languages, "$name"),
241+
}
242+
},
243+
],
244+
"as": "book",
245+
}
246+
},
247+
{"$unwind": {"path": "$book", "preserveNullAndEmptyArrays": True}},
91248
{
92249
"$facet": {
93250
"items": [
@@ -112,10 +269,13 @@ def _build_search_pipeline(
112269
def get_hadith_by_id(
113270
self, hadith_id: PyObjectId, languages: list[str]
114271
) -> dict[str, Any]:
115-
projection = self._projection_for_languages(languages)
116-
hadith = self.repository.find_one_by_id(ObjectId(hadith_id), projection)
272+
pipeline = self._build_get_by_id_pipeline(ObjectId(hadith_id), languages)
273+
274+
hadith = self.repository.find_one_with_lookup(pipeline)
275+
117276
if hadith is None:
118277
raise HadithNotFoundError(hadith_id)
278+
119279
return hadith
120280

121281
def get_hadiths_paginated(

0 commit comments

Comments
 (0)