सामग्री पर जाएं
सभी पोस्ट

2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना

Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।

MFKAPPS 6 मिनट पढ़ना

SQLite सिर्फ़ पाँच स्टोरेज क्लासेज़ जानता है: NULL, INTEGER, REAL, TEXT, BLOB। किसी असली ऐप के डेटा मॉडल में शायद ही कुछ ऐसा दिखे। किसी रिकरिंग सब्सक्रिप्शन का एक बिलिंग Frequency एनम होता है। किसी पैंट्री आइटम की एक एक्सपायरी डेट होती है। किसी बजट एंट्री में टैग्स की एक लिस्ट हो सकती है। Room का @TypeConverter इन दोनों दुनियाओं के बीच का पुल है, और यह इतना छोटा-सा कोड है कि लोग इसे एक बार लिखकर उसके बारे में सोचना बंद कर देते हैं, और आख़िर में एक ऐसा कनवर्टर बच जाता है जो दो साल बाद चुपचाप किसी कॉलम का मतलब ही बदल देता है।

मैं तीन लोकल-फर्स्ट ऐप्स में कनवर्टर मेंटेन करता हूँ — Subly की रिकरिंग बिलिंग डेट्स, Stocky की एक्सपायरी ट्रैकिंग, और Granyn में कैटेगरी एनम्स — और जो बग मैंने वाक़ई शिप किए हैं, वे सब एक ही मुट्ठी भर ग़लतियों से आए हैं। यहाँ बताया गया है कि इनसे कैसे बचें।

बुनियादी आकार

एक कनवर्टर बस दो प्योर फ़ंक्शन्स की एक जोड़ी है, जो डेटाबेस पर रजिस्टर की जाती है:

class Converters {
    @TypeConverter
    fun fromFrequency(value: Frequency): String = value.name

    @TypeConverter
    fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
}

@Database(entities = [Subscription::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

बस इतना ही पूरा कॉन्ट्रैक्ट है: हर दिशा के लिए, हर टाइप के लिए एक फ़ंक्शन। Room इन्हें अपने-आप कॉल करता है जब भी किसी एंटिटी फ़ील्ड का टाइप ऐसा हो जिसे वह मूल रूप से नहीं समझता। जाल इसमें है कि हर टाइप के लिए क्या एक उचित इम्प्लीमेंटेशन जैसा दिखता है।

एनम: नाम स्टोर करें, कभी ऑर्डिनल नहीं

Enum.ordinal लुभावना लगता है क्योंकि यह एक Int है और Room इसे बिना किसी कन्वर्ज़न कोड के मूल रूप से स्टोर कर लेता है। यह एक बारूदी सुरंग भी है: ऑर्डिनल बस सोर्स फ़ाइल में एनम की पोज़िशन है। Frequency के केसेज़ को दोबारा क्रम में लगाएँ, या MONTHLY और YEARLY के बीच QUARTERLY डालें, और हर मौजूदा रो चुपचाप ग़लत वैल्यू पर पॉइंट करने लगती है। कुछ भी थ्रो नहीं होता। बग यह है कि जो सब्सक्रिप्शन साप्ताहिक बिल होता है वह सालाना दिखने लगता है — इसे कोई यूज़र खोजता है, कोई टेस्ट नहीं।

enum class Frequency { WEEKLY, MONTHLY, QUARTERLY, YEARLY }

@TypeConverter
fun fromFrequency(value: Frequency): String = value.name

@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)

value.name को TEXT के रूप में स्टोर करने पर हर रो में कुछ अतिरिक्त बाइट्स ख़र्च होते हैं, लेकिन यह दोबारा क्रम बदलने से अप्रभावित रहता है। जो असली ख़तरा बचता है वह है किसी केस का नाम बदलना — इसे एक मैनुअल माइग्रेशन से करें जो स्टोर की गई स्ट्रिंग्स को दोबारा लिखता है, ठीक उसी तरह जैसे आप किसी भी दूसरे कॉलम-लेवल डेटा बदलाव को हैंडल करते।

डेट: एक ही रिप्रेज़ेंटेशन चुनें, और कभी लोकल टाइम स्टोर न करें

क्लासिक Room डेट बग कनवर्टर में नहीं होता, वह असंगति में होता है: कोडबेस का एक हिस्सा LocalDateTime को डिवाइस-लोकल टाइम मानकर कन्वर्ट करता है, दूसरा हिस्सा UTC मानता है, और जो डेट इस्तांबुल के किसी यूज़र के लिए सही है वही उसी यूज़र के लिए टाइमज़ोन पार करने वाली फ़्लाइट के बाद घंटों ग़लत हो जाती है। जिस भी चीज़ को डिवाइसेज़ और टाइमज़ोन्स के पार सही ढंग से तुलना या सॉर्ट करना हो, उसके लिए एक इंस्टेंट स्टोर करें, लोकल डेट-टाइम नहीं:

@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()

@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)

INTEGER के रूप में Long epoch-मिलीज़ रॉ SQL में सही ढंग से सॉर्ट होता है (यह ORDER BY और रेंज क्वेरीज़ के लिए उपयोगी है, बिना पहले रो को Kotlin में लोड किए), और स्टोर की गई वैल्यू में कोई टाइमज़ोन अस्पष्टता नहीं होती — टाइमज़ोन सिर्फ़ डिस्प्ले के वक़्त मायने रखता है, UI लेयर में, जहाँ उसे रहना ही चाहिए। अगर फ़ील्ड वाक़ई एक कैलेंडर डेट है जिसमें कोई टाइम कॉम्पोनेंट नहीं — जैसे किसी पैंट्री आइटम की एक्सपायरी डेट — तो इसके बजाय इसे एक ISO-8601 TEXT स्ट्रिंग (2026-09-14) के रूप में स्टोर करें। यह स्ट्रिंग के रूप में भी सॉर्ट होने लायक़ रहती है, और यह किसी ऐसी चीज़ के लिए टाइमस्टैम्प की झूठी सटीकता से बचाती है जो कभी समय का कोई क्षण थी ही नहीं।

लिस्ट और कलेक्शन: जानें कि आप क्या छोड़ रहे हैं

List<String> स्टोर करने का मतलब आमतौर पर कनवर्टर में JSON में सीरियलाइज़ करना होता है:

@TypeConverter
fun fromTags(value: List<String>): String = Json.encodeToString(value)

@TypeConverter
fun toTags(value: String): List<String> = Json.decodeFromString(value)

यह काम करता है, और छोटी, कम-क्वेरी होने वाली लिस्ट्स के लिए — जैसे किसी बजट एंट्री पर मुट्ठी भर फ़्रीफ़ॉर्म टैग्स — यह व्यावहारिक चुनाव है। लेकिन यह एक ऐसा ट्रेड-ऑफ़ है जो आप जान-बूझकर कर रहे हैं, कोई मुफ़्त सुविधा नहीं: एक कॉलम में रखी JSON वैल्यू SQL के लिए अपारदर्शी होती है। आप किसी टैग पर WHERE नहीं लगा सकते, आप इसे इंडेक्स नहीं कर सकते, आप इसके ख़िलाफ़ जॉइन नहीं कर सकते। जिस पल किसी “चीज़ों की लिस्ट” को क्वेरी करने, फ़िल्टर करने, या दूसरे डेटा से जोड़ने की ज़रूरत पड़े, वह अब कनवर्टर की समस्या नहीं रहती — वह एक ग़ायब टेबल की समस्या बन जाती है। जॉइन टेबल के साथ एक ठीक-ठाक @Relation शुरुआत में ज़्यादा सेटअप माँगता है, लेकिन पहली बार जब किसी क्वेरी को “work टैग वाला हर सब्सक्रिप्शन” चाहिए होता है, न कि “हर सब्सक्रिप्शन, फिर Kotlin में टैग्स फ़िल्टर करना” — तब यह अपनी क़ीमत ख़ुद वसूल लेता है।

दो नियम जो ज़्यादातर कनवर्टर बग्स को शिप होने से पहले पकड़ लेते हैं

कनवर्टर प्योर और टोटल होने चाहिए। कोई I/O नहीं, कोई Clock.System.now() नहीं, और जहाँ तक संभव हो, अनपेक्षित इनपुट पर कोई थ्रो नहीं — जो कनवर्टर किसी पुराने ऐप वर्शन द्वारा स्टोर की गई वैल्यू पर थ्रो करता है, वह एक अकेली करप्ट रो को उस टेबल को छूने वाले हर लॉन्च पर क्रैश में बदल देता है। मौजूदा डेटा पढ़ने वाली किसी भी चीज़ के लिए, थ्रोन एक्सेप्शन के बजाय एक सुरक्षित फ़ॉलबैक (एक डिफ़ॉल्ट एनम केस, एक null डेट) को प्राथमिकता दें।

किसी कनवर्टर का स्टोरेज फ़ॉर्मैट आपके स्कीमा का हिस्सा है, भले ही Room का स्कीमा एक्सपोर्ट उसे न देखे। AutoMigration कॉलम टाइप्स और नामों का diff निकालता है — उसे इस बात का कोई अंदाज़ा नहीं होता कि आपने किसी कनवर्टर को Instant को मिलीज़ के रूप में स्टोर करने से बदलकर उसे ISO स्ट्रिंग के रूप में स्टोर करने लगा दिया है। इस बदलाव को स्टोर किए गए डेटा के किसी भी दूसरे रीशेपिंग जितना ही मैनुअल माइग्रेशन और MigrationTestHelper ट्रीटमेंट चाहिए, क्योंकि SQLite के नज़रिए से, एक TEXT कॉलम को यह पता नहीं होता कि पहले उसका मतलब कुछ और हुआ करता था।

इन दोनों को सही रखें, एनम्स को नाम के रूप में स्टोर रखें, टाइमस्टैम्प्स को UTC epoch-मिलीज़ के रूप में रखें, और JSON तक पहुँचने से पहले एक असली टेबल तक पहुँचें — और कनवर्टर लेयर वह जगह नहीं रह जाती जहाँ बग छुपते हैं।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 5 मिनट पढ़ना

2026 में Room डेटाबेस इंडेक्स: वाकई धीमी क्वेरी को ढूँढना और ठीक करना

Android पर Room/SQLite डेटाबेस को इंडेक्स करने की व्यावहारिक गाइड — EXPLAIN QUERY PLAN पढ़ना, बिना अंदाज़े के @Index जोड़ना, और वे गलतियाँ जो चुपचाप इंडेक्स को बेअसर कर देती हैं।

#android #engineering #room
MFKAPPS 5 मिनट पढ़ना

Room का @Relation: Android पर N+1 क्वेरी के बिना वन-टू-मेनी डेटा क्वेरी करना

Room के @Relation एनोटेशन की एक व्यावहारिक गाइड — categories और entries जैसे वन-टू-मेनी डेटा को N+1 क्वेरी या मैनुअल join के बिना मॉडल करना।

#android #engineering #room
MFKAPPS 6 मिनट पढ़ना

Room में फुल-टेक्स्ट सर्च: 2026 में एक लोकल-फर्स्ट Android ऐप में इंस्टेंट सर्च जोड़ना

Android पर Room के FTS4 सपोर्ट की एक व्यावहारिक गाइड — एक वर्चुअल सर्च टेबल बनाना, उसे ट्रिगर्स से सिंक रखना, और FTS5 को मैनुअल माइग्रेशन की ज़रूरत क्यों पड़ती है।

#android #engineering #room