Room का @Relation: Android पर N+1 क्वेरी के बिना वन-टू-मेनी डेटा क्वेरी करना
Room के @Relation एनोटेशन की एक व्यावहारिक गाइड — categories और entries जैसे वन-टू-मेनी डेटा को N+1 क्वेरी या मैनुअल join के बिना मॉडल करना।
एक बजट ऐप में categories होती हैं, और हर category में entries होती हैं। एक पैंट्री ऐप में products होते हैं, और हर product का एक स्कैन history होता है। लगभग हर local-first ऐप में कहीं न कहीं यही आकार मिलता है: एक row जो कई और rows की मालिक होती है। इसे Room में naive तरीके से लोड करना — parents लाना, फिर लूप करके हर parent के children लाना — एक N+1 क्वेरी बग है जो इंतज़ार में बैठा है। Room के पास एक एनोटेशन है जो इसे सही तरीके से ठीक करता है, और यह उतना बड़ा नहीं जितना लोग सोचते हैं: @Relation.
वह क्वेरी जो आपको नहीं लिखनी चाहिए
मान लीजिए आप Granyn की category-वार खर्च स्क्रीन बना रहे हैं। आपके पास एक Category टेबल और एक Entry टेबल है, जहां हर entry categoryId के ज़रिए अपनी category की ओर वापस इशारा करती है। सहज प्रवृत्ति यह होती है कि categories लाई जाएं, फिर लूप में हर category की entries DAO से मांगी जाएं:
val categories = categoryDao.getAll()
val result = categories.map { category ->
category to entryDao.getByCategory(category.id) // one query per category
}
यह N+1 है: category सूची के लिए एक क्वेरी, फिर हर category के लिए एक और क्वेरी। पांच categories के साथ यह अदृश्य रहता है। एक दर्जन categories में फैले एक साल के history के साथ, यह हर स्क्रीन लोड पर SQLite तक एक दर्जन राउंड-ट्रिप बन जाता है, हर एक बिना किसी वजह के अपनी अलग क्वेरी-प्लानिंग लागत चुकाते हुए।
@Relation असल में क्या generate करता है
@Relation इसे जादुई तरीके से SQL JOIN में नहीं बदल देता। डेटा के इस आकार के लिए यह जो करता है वह ज़्यादा समझदारी भरा है: यह ऐसा कोड generate करता है जो, चाहे आपके पास कितने भी parents हों, कुल दो क्वेरी चलाता है। पहली parents लाती है। दूसरी सभी children को एक साथ लाती है, सभी parent id से एक साथ बने WHERE categoryId IN (...) से फ़िल्टर करके।
Kotlin की तरफ यह एक wrapper class है जो एक parent और उसके children को रखती है:
data class CategoryWithEntries(
@Embedded val category: Category,
@Relation(
parentColumn = "id",
entityColumn = "categoryId",
)
val entries: List<Entry>,
)
@Embedded, Category के अपने columns को result में flatten कर देता है। @Relation बताता है कि parent का कौन सा column (id) child के कौन से column (categoryId) से मेल खाता है — वही foreign-key संबंध जो Granyn का schema पहले से व्यक्त करता है, बस इसे हाथ से लिखे SQL की बजाय Room के क्वेरी बिल्डर के लिए declare किया गया है।
DAO method एक जोड़ के साथ लगभग एक सामान्य क्वेरी जैसा ही है:
@Transaction
@Query("SELECT * FROM categories")
fun getCategoriesWithEntries(): Flow<List<CategoryWithEntries>>
यहां @Transaction मायने रखता है और इसे गलती से छोड़ना आसान है। इसके बिना, parent क्वेरी और batched child क्वेरी दो स्वतंत्र reads की तरह चलती हैं — अगर बीच में entries टेबल पर कोई write आ जाए, तो आपको एक category सूची और एक entries सूची मिल सकती है जो थोड़ी देर के लिए एक-दूसरे से मेल नहीं खातीं। दोनों को एक transaction में लपेटना यह गारंटी देता है कि यह जोड़ी एक ही consistent snapshot से पढ़ी जाए।
जो चीज़ अब भी लोगों को हैरान करती है: batching की एक सीमा है
SQLite एक ही statement में अनुमत variables की संख्या पर एक सीमा लगाता है — ऐतिहासिक रूप से 999, हाल के versions में ज़्यादा लेकिन फिर भी सीमित। अगर आपके पास इस सीमा से ज़्यादा parent rows हैं, तो Room fail नहीं होता; यह चुपचाप IN (...) clause को कई क्वेरी में बांट देता है और परिणामों को वापस जोड़ देता है। एक category सूची के लिए ऐसा कभी नहीं होगा, लेकिन अगर आप इसी pattern को कहीं हज़ारों parents वाली जगह पर लागू करते हैं (मान लीजिए एक product catalog), तो क्वेरी की संख्या चुपचाप ठीक 2 नहीं रह जाती। यह जानना उपयोगी है, इससे पहले कि आप मान लें कि “दो क्वेरी” हर स्केल पर एक पक्की गारंटी है।
@Relation केवल पढ़ने के लिए है
Generate हुआ method केवल reads के लिए combined object बनाता है। कोई @Insert या @Update समकक्ष नहीं है जो CategoryWithEntries को एक इकाई के रूप में समझे — आप अब भी Category को CategoryDao के ज़रिए और Entry को EntryDao के ज़रिए insert करते हैं, बिल्कुल वैसे ही जैसे relation के बिना करते। @Relation एक query-time सुविधा है, कोई नया persistence model नहीं। wrapper class को writes के लिए दोबारा इस्तेमाल करने की कोशिश करना वह सबसे आम तरीका है जिससे लोग इसे लेकर उलझते हैं।
कब इसे पूरी तरह छोड़ देना चाहिए
हर वन-टू-मेनी read @Relation के पीछे नहीं होनी चाहिए। अगर स्क्रीन को वाकई हर entry चाहिए — मान लीजिए किसी category की transaction सूची — तो यह सही tool है: सही तरीके से batched दो क्वेरी, कोई N+1 नहीं। लेकिन अगर आपको सिर्फ एक संख्या चाहिए — मान लीजिए pie chart के लिए इस महीने का category-वार total — तो सिर्फ Kotlin में जोड़ने के लिए हर Entry को memory में load करना बेकार काम है। एक raw aggregate क्वेरी rows को कभी materialize किए बिना वही काम कर देती है:
@Query("""
SELECT categoryId, SUM(amountMinor) AS total
FROM entries
WHERE at BETWEEN :start AND :end
GROUP BY categoryId
""")
fun monthlyTotals(start: Long, end: Long): Flow<List<CategoryTotal>>
अंगूठे का नियम: @Relation तब इस्तेमाल करें जब UI को असली child rows चाहिए हों, और जिस पल आपको सिर्फ उनसे निकाला गया एक नंबर चाहिए हो, GROUP BY क्वेरी पर आ जाएं। एक sum निकालने के लिए पूरा object graph load करना local database की over-fetching वाली version है, और अगर आप सीधे पूछें तो SQLite खुशी-खुशी आपके लिए जोड़ कर देगा, बजाय इसके कि Kotlin से पूछा जाए।
@Relation अपनी जगह उसी वजह से कमाता है जिस वजह से Room का बाकी हिस्सा कमाता है: यह correctness bug की एक पूरी category — N+1 loop — को हटा देता है, बिना आपसे join हाथ से लिखवाए। सही तरीके से batched दो क्वेरी, एक transaction में लिपटी हुई। बस यही पूरी तरकीब है।
// संबंधित पठन
जर्नल से और भी
2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना
Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।
2026 में Room डेटाबेस इंडेक्स: वाकई धीमी क्वेरी को ढूँढना और ठीक करना
Android पर Room/SQLite डेटाबेस को इंडेक्स करने की व्यावहारिक गाइड — EXPLAIN QUERY PLAN पढ़ना, बिना अंदाज़े के @Index जोड़ना, और वे गलतियाँ जो चुपचाप इंडेक्स को बेअसर कर देती हैं।
Room में फुल-टेक्स्ट सर्च: 2026 में एक लोकल-फर्स्ट Android ऐप में इंस्टेंट सर्च जोड़ना
Android पर Room के FTS4 सपोर्ट की एक व्यावहारिक गाइड — एक वर्चुअल सर्च टेबल बनाना, उसे ट्रिगर्स से सिंक रखना, और FTS5 को मैनुअल माइग्रेशन की ज़रूरत क्यों पड़ती है।