2025, Oct 07 05:31

SQLAlchemy ORM में id के लिए Protocol और ClassVar से टाइपिंग

SQLAlchemy 2.0 में integer id वाली ORM क्लासों के लिए टाइप-सुरक्षित select/where/order_by कैसे लिखें: Protocol, ClassVar और orm.Mapped से mypy-अनुकूल समाधान।

स्टैटिक टाइपिंग और SQLAlchemy अक्सर एक मुश्किल मोड़ पर मिलते हैं: क्लास-लेवल के mapped एट्रिब्यूट, जो क्लास और इंस्टेंस पर अलग व्यवहार करते हैं। एक सामान्य जरूरत यह है कि ऐसी फंक्शन को टाइप किया जाए जो किसी भी SQLAlchemy ORM क्लास को स्वीकार करे, जिसमें id नाम का integer प्राइमरी की हो। लक्ष्य है कि सख्त टाइप्स बनाए रखें और id: Any पर पीछे न जाएँ, जबकि select/where/order_by की चेन में model.id > 42 और model.id.desc() जैसी अभिव्यक्तियों का समर्थन भी हो। वातावरण: Python 3.12.3, SQLAlchemy 2.0.41, और mypy से टाइप चेकिंग।

समस्या

उद्देश्य ऐसी फंक्शन लिखना है जो किसी भी ORM क्लास को स्वीकार करे जिसके पास integer प्रकार का id हो, और बदले में typed SQLAlchemy Select लौटाए। Protocol और TypeVar से बाधा को मॉडल करने की कई कोशिशें mypy त्रुटियों पर खत्म हुईं, और समझौते के तौर पर id: Any लिखना पड़ा।

from typing import Any, Protocol, TypeVar
import sqlalchemy as sa
from sqlalchemy import orm
class WithIntPk(Protocol):
    id: Any  # मनचाहे जितना सख्त नहीं, पर टाइप चेक पास हो जाता है
U = TypeVar('U', bound=WithIntPk)
def build_query(model_cls: type[U]) -> sa.Select[tuple[U]]:
    return sa.select(model_cls).where(model_cls.id > 42).order_by(model_cls.id.desc())
class OrmBase(orm.DeclarativeBase):
    pass
class Widget(OrmBase):
    __tablename__ = 'widgets'
    id: orm.Mapped[int] = orm.mapped_column(primary_key=True)
print(build_query(Widget))

पहले किए गए प्रयासों में id: int, id: orm.Mapped[int], और id: orm.attributes.InstrumentedAttribute[int] शामिल थे। mypy ने ऐसे संदेशों के साथ शिकायत की: “Argument 1 to 'where' of 'Select' has incompatible type 'bool' ... [arg-type]” और “Value of type variable 'T' of 'f' cannot be 'Foo' [type-var]”.

ऐसा क्यों होता है

मूल बात यह है कि क्लास पर मौजूद SQLAlchemy फ़ील्ड SQL expressions बनाते हैं और ऐसे ऑपरेटर/मेथड कॉल्स का समर्थन करते हैं जिन्हें साधारण integers नहीं करते। यानी model.id > 42 कोई boolean नहीं है; यह SQLAlchemy expression देता है। उसी तरह, model.id.desc() SQL expression ऑब्जेक्ट का मेथड है, int का नहीं। इसलिए साधारण int एनोटेशन where और order_by में उपयोग के अनुकूल नहीं बैठता।

Protocols में एक बारीकी और है: Protocol में घोषित एट्रिब्यूट डिफ़ॉल्ट रूप से इंस्टेंस एट्रिब्यूट माने जाते हैं, जबकि हमारी फंक्शन क्लास एट्रिब्यूट्स (model_cls.id) के साथ काम कर रही है। SQLAlchemy भी इस पर निर्भर करते हुए अलग प्रकार दिखाता है कि आप id को क्लास पर एक्सेस कर रहे हैं या इंस्टेंस पर। टाइप्स reveal करने पर यह अंतर साफ दिखता है:

reveal_type(Foo.id) — "sqlalchemy.sql.elements.ColumnElement[builtins.bool]"
reveal_type(Foo().id) — "builtins.int"

तो, Protocol को ऐसा क्लास एट्रिब्यूट व्यक्त करना होगा जो SQLAlchemy के mapped टाइप के साथ हो, न कि इंस्टेंस एट्रिब्यूट।

समाधान

उपाय यह है कि एट्रिब्यूट का टाइप orm.Mapped[int] रखा जाए और उसे ClassVar के साथ लपेटा जाए, ताकि यह क्लास-लेवल अनुबंध बने। यह वही रूप दर्शाता है जैसा SQLAlchemy की टाइपिंग मशीनरी एट्रिब्यूट को दिखाती है: क्लास पर यह mapped, expression-कैपेबल एट्रिब्यूट की तरह व्यवहार करता है, जबकि इंस्टेंस पर यह मूल Python टाइप में सुलझ जाता है।

from typing import ClassVar, Protocol, TypeVar
import sqlalchemy as sa
from sqlalchemy import orm
class IntPkContract(Protocol):
    id: ClassVar[orm.Mapped[int]]
R = TypeVar('R', bound=IntPkContract)
def make_select(entity: type[R]) -> sa.Select[tuple[R]]:
    return sa.select(entity).where(entity.id > 42).order_by(entity.id.desc())
class BaseModel(orm.DeclarativeBase):
    pass
class Thing(BaseModel):
    __tablename__ = 'things'
    id: orm.Mapped[int] = orm.mapped_column(primary_key=True)
print(make_select(Thing))

इससे टाइप्स सटीक बने रहते हैं और साथ ही where तथा order_by में परिचित SQLAlchemy expressions का उपयोग संभव रहता है।

क्या ध्यान रखें

Protocol में id एट्रिब्यूट के लिए सही लक्ष्य टाइप orm.Mapped[int] है, क्योंकि यह क्वेरीज़ में प्रयुक्त SQLAlchemy के ऑपरेटर ओवरलोड्स और मेथड्स का समर्थन करता है। ClassVar यह सुनिश्चित करता है कि आप इंस्टेंस नहीं, बल्कि क्लास एट्रिब्यूट का अनुबंध निर्दिष्ट कर रहे हैं। इंस्टेंस पर SQLAlchemy फिर भी एट्रिब्यूट को int में सुलझा देता है, जैसा अपेक्षित है।

एक रिपोर्टेड अवलोकन यह भी है कि Protocol में सीधे id: orm.Mapped[int] लिखने पर pyright टाइप चेक पास कर देता है, जबकि mypy में पास कराने के लिए ClassVar आवश्यक है। एक राय यह है कि यह mypy की बग हो सकती है; इस विषय पर संबंधित mypy issue और pyright चर्चा के लिंक दिए गए हैं। भविष्य में व्यवहार बदलने पर इस परिदृश्य में ClassVar की आवश्यकता भी बदल सकती है। संदर्भ: https://github.com/python/mypy/issues/19702 और https://github.com/microsoft/pyright/discussions/7942.

यह क्यों मायने रखता है

SQLAlchemy मॉडलों के लिए सटीक टाइपिंग के ठोस फायदे हैं: यह बिना शोर के होने वाली रिग्रेशन से बचाती है, सुरक्षित रिफैक्टरिंग संभव बनाती है, और क्वेरी बनाने वाले कोड को mapped एट्रिब्यूट्स के स्वरूप व व्यवहार के प्रति ईमानदार रखती है। id: Any से बचने पर पूरी expression चेन untyped क्षेत्र में नहीं फिसलती, जो सूक्ष्म बग्स को छिपा सकती है।

मुख्य बातें

यदि आपको ऐसी फंक्शन चाहिए जो integer id वाली किसी भी SQLAlchemy ORM क्लास को स्वीकार करे और आप mypy का उपयोग कर रहे हैं, तो id: ClassVar[orm.Mapped[int]] के साथ एक Protocol परिभाषित करें और अपने TypeVar को उसी से बाँधें। इससे select, where और order_by के लिए आवश्यक expression semantics के साथ क्लास-लेवल एक्सेस पैटर्न, दोनों संतुष्ट होते हैं। mypy और pyright में टाइप चेकर्स के व्यवहार पर नज़र रखें, और जहाँ भी संभव हो Any के बजाय सटीक एनोटेशन अपनाएँ। क्लास-लेवल टाइपिंग की पृष्ठभूमि के लिए Python दस्तावेज़ देखें: https://docs.python.org/3/library/typing.html#typing.ClassVar.

यह लेख StackOverflow पर प्रश्न, जिसे jacquev6 ने पूछा, और daveruinseverything के उत्तर पर आधारित है।