כתיבת מחלקות משנה של מאפיינים

המחלקות Property מיועדות להיות מחלקות משנה. עם זאת, בדרך כלל קל יותר ליצור מחלקת משנה של מחלקת משנה קיימת.Property

כל המאפיינים המיוחדים Property, כולל אלה שנחשבים 'ציבוריים', מתחילים בקו תחתון. הסיבה לכך היא ש-StructuredProperty משתמש במרחב שמות של מאפיינים ללא קו תחתון כדי להתייחס לשמות של Property בתוך מאפיינים; זה חיוני לציון שאילתות על מאפייני משנה.

המחלקות Property והמחלקות המשניות המוגדרות מראש שלה מאפשרות יצירת מחלקות משניות באמצעות ממשקי API של אימות והמרה שניתנים להרכבה (או להערמה). כדי להבין את המושגים האלה, צריך להגדיר כמה מונחים:

  • ערך משתמש הוא ערך שמוגדר ומתבצעת אליו גישה על ידי קוד האפליקציה באמצעות מאפיינים רגילים בישות.
  • ערך בסיס הוא ערך שניתן לבצע לו סריאליזציה אל מאגר הנתונים ולבטל את הסריאליזציה ממנו.

מחלקת משנה Property שמטמיעה טרנספורמציה ספציפית בין ערכי משתמשים לבין ערכים שניתנים לסריאליזציה צריכה להטמיע שתי שיטות, _to_base_type() ו-_from_base_type(). הן לא צריכות להפעיל את ה-method‏ super() שלהן. זו המשמעות של ממשקי API שניתנים להרכבה (או להערמה).

ה-API תומך ביצירת מקבצים של מחלקות עם המרות מתוחכמות יותר ויותר של בסיס המשתמשים: ההמרה ממשתמש לבסיס הופכת ממתוחכמת יותר לפחות מתוחכמת, בעוד שההמרה מבסיס למשתמש הופכת מפחות מתוחכמת למתוחכמת יותר. לדוגמה, אפשר לראות את הקשר בין BlobProperty, TextProperty ו-StringProperty. לדוגמה, TextProperty יורש מ-BlobProperty; הקוד שלו די פשוט כי הוא יורש את רוב ההתנהגות שהוא צריך.

בנוסף ל-_to_base_type() ול-_from_base_type(), גם השיטה _validate() היא API שאפשר להרכיב ממנו פתרונות.

ממשק ה-API של האימות מבחין בין ערכי משתמש גמישים לבין ערכי משתמש מחמירים. קבוצת הערכים המותרים היא קבוצת-על של קבוצת הערכים המחמירים. השיטה _validate() מקבלת ערך לא מדויק, ואם צריך היא ממירה אותו לערך מדויק. כלומר, כשמגדירים את ערך המאפיין, מתקבלים ערכים לא מחמירים, אבל כשמקבלים את ערך המאפיין, מוחזרים רק ערכים מחמירים. אם לא נדרשת המרה, הפונקציה _validate() עשויה להחזיר None. אם הארגומנט לא נמצא בקבוצת הערכים המותרים של lax, הפונקציה _validate() צריכה להחזיר חריגה, רצוי TypeError או datastore_errors.BadValueError.

הדגלים _validate(), ‫_to_base_type() ו-_from_base_type() לא צריכים לטפל ב:

  • None: הפונקציות האלה לא יופעלו עם None (ואם הן מחזירות None, זה אומר שלא צריך להמיר את הערך).
  • ערכים חוזרים: התשתית דואגת להפעיל את הפונקציה _from_base_type() או את הפונקציה _to_base_type() לכל פריט ברשימה של ערך חוזר.
  • הבחנה בין ערכי משתמשים לבין ערכי בסיס: התשתית מטפלת בזה באמצעות קריאה לממשקי ה-API הניתנים להרכבה.
  • השוואות: פעולות ההשוואה קוראות ל-_to_base_type() על האופרנד שלהן.
  • הבחנה בין ערכי משתמש לערכי בסיס: התשתית מבטיחה שהפונקציה _from_base_type() תיקרא עם ערך בסיס (לא עטוף), ושהפונקציה _to_base_type() תיקרא עם ערך משתמש.

לדוגמה, נניח שאתם צריכים לאחסן מספרים שלמים ארוכים מאוד. התקן IntegerProperty תומך רק במספרים שלמים (עם סימן) של 64 ביט. יכול להיות שבנכס שלכם מאוחסן מספר שלם ארוך כמחרוזת. מומלץ שהמחלקה של הנכס תטפל בהמרה. אפליקציה שמשתמשת במחלקת הנכסים שלכם יכולה להיראות כך:

from datetime import date

import my_models
...
class MyModel(ndb.Model):
    name = ndb.StringProperty()
    abc = LongIntegerProperty(default=0)
    xyz = LongIntegerProperty(repeated=True)
...
# Create an entity and write it to the Datastore.
entity = my_models.MyModel(name='booh', xyz=[10**100, 6**666])
assert entity.abc == 0
key = entity.put()
...
# Read an entity back from the Datastore and update it.
entity = key.get()
entity.abc += 1
entity.xyz.append(entity.abc//3)
entity.put()
...
# Query for a MyModel entity whose xyz contains 6**666.
# (NOTE: using ordering operations don't work, but == does.)
results = my_models.MyModel.query(
    my_models.MyModel.xyz == 6**666).fetch(10)

זה נראה פשוט וברור. בנוסף, מוצגות דוגמאות לשימוש באפשרויות סטנדרטיות של מאפיינים (ברירת מחדל, חוזר); בתור המחבר של LongIntegerProperty, תשמח לשמוע שלא צריך לכתוב קוד boilerplate כדי שהן יפעלו. קל יותר להגדיר מחלקת משנה של מאפיין אחר, לדוגמה:

class LongIntegerProperty(ndb.StringProperty):
    def _validate(self, value):
        if not isinstance(value, (int, long)):
            raise TypeError('expected an integer, got %s' % repr(value))

    def _to_base_type(self, value):
        return str(value)  # Doesn't matter if it's an int or a long

    def _from_base_type(self, value):
        return long(value)  # Always return a long

כשמגדירים ערך של מאפיין בישות, למשל: ent.abc = 42, השיטה _validate() מופעלת, ואם לא נוצר חריג, הערך מאוחסן בישות. כשכותבים את הישות ל-Datastore, השיטה _to_base_type() נקראת, והערך מומר למחרוזת. לאחר מכן הערך הזה עובר סריאליזציה על ידי מחלקת הבסיס, StringProperty. השרשרת ההפוכה של האירועים מתרחשת כשקוראים את הישות בחזרה מ-Datastore. המחלקות StringProperty ו-Property מטפלות יחד בפרטים האחרים, כמו סדרת הנתונים של המחרוזת, הגדרת ברירת המחדל וטיפול בערכי מאפיינים חוזרים.

בדוגמה הזו, כדי לתמוך באי-שוויונים (כלומר, בשאילתות שמשתמשות בסימנים <, <=, >, >=) נדרשת עבודה נוספת. בדוגמה הבאה להטמעה מוגדר גודל מקסימלי למספר שלם, והערכים מאוחסנים כמחרוזות באורך קבוע:

class BoundedLongIntegerProperty(ndb.StringProperty):
    def __init__(self, bits, **kwds):
        assert isinstance(bits, int)
        assert bits > 0 and bits % 4 == 0  # Make it simple to use hex
        super(BoundedLongIntegerProperty, self).__init__(**kwds)
        self._bits = bits

    def _validate(self, value):
        assert -(2 ** (self._bits - 1)) <= value < 2 ** (self._bits - 1)

    def _to_base_type(self, value):
        # convert from signed -> unsigned
        if value < 0:
            value += 2 ** self._bits
        assert 0 <= value < 2 ** self._bits
        # Return number as a zero-padded hex string with correct number of
        # digits:
        return '%0*x' % (self._bits // 4, value)

    def _from_base_type(self, value):
        value = int(value, 16)
        if value >= 2 ** (self._bits - 1):
            value -= 2 ** self._bits
        return value

אפשר להשתמש בו באותו אופן כמו ב-LongIntegerProperty אלא שצריך להעביר את מספר הביטים לבונה המאפיינים, למשל BoundedLongIntegerProperty(1024).

אפשר ליצור מחלקת משנה של סוגי נכסים אחרים בדרכים דומות.

הגישה הזו מתאימה גם לאחסון נתונים מובנים. נניח שיש לכם מחלקת Python‏ FuzzyDate שמייצגת טווח תאריכים. המחלקה משתמשת בשדות first ו-last כדי לאחסן את תאריך ההתחלה ותאריך הסיום של טווח התאריכים:

from datetime import date

...
class FuzzyDate(object):
    def __init__(self, first, last=None):
        assert isinstance(first, date)
        assert last is None or isinstance(last, date)
        self.first = first
        self.last = last or first

אפשר ליצור FuzzyDateProperty שמבוסס על StructuredProperty. לצערנו, האפשרות השנייה לא פועלת עם מחלקות Python רגילות, אלא עם מחלקת משנה של Model. לכן מגדירים מחלקה משנית של Model כייצוג ביניים.

class FuzzyDateModel(ndb.Model):
    first = ndb.DateProperty()
    last = ndb.DateProperty()

בשלב הבא, יוצרים מחלקת משנה של StructuredProperty שמקודדת את הארגומנט modelclass כ-FuzzyDateModel, ומגדירים את השיטות _to_base_type() ו-_from_base_type() כדי להמיר בין FuzzyDate ל-FuzzyDateModel:

class FuzzyDateProperty(ndb.StructuredProperty):
    def __init__(self, **kwds):
        super(FuzzyDateProperty, self).__init__(FuzzyDateModel, **kwds)

    def _validate(self, value):
        assert isinstance(value, FuzzyDate)

    def _to_base_type(self, value):
        return FuzzyDateModel(first=value.first, last=value.last)

    def _from_base_type(self, value):
        return FuzzyDate(value.first, value.last)

אפליקציה יכולה להשתמש במחלקה הזו באופן הבא:

class HistoricPerson(ndb.Model):
    name = ndb.StringProperty()
    birth = FuzzyDateProperty()
    death = FuzzyDateProperty()
    # Parallel lists:
    event_dates = FuzzyDateProperty(repeated=True)
    event_names = ndb.StringProperty(repeated=True)
...
columbus = my_models.HistoricPerson(
    name='Christopher Columbus',
    birth=my_models.FuzzyDate(date(1451, 8, 22), date(1451, 10, 31)),
    death=my_models.FuzzyDate(date(1506, 5, 20)),
    event_dates=[my_models.FuzzyDate(
        date(1492, 1, 1), date(1492, 12, 31))],
    event_names=['Discovery of America'])
columbus.put()

# Query for historic people born no later than 1451.
results = my_models.HistoricPerson.query(
    my_models.HistoricPerson.birth.last <= date(1451, 12, 31)).fetch()

נניח שאתם רוצים לקבל אובייקטים פשוטים של date בנוסף לאובייקטים של FuzzyDate כערכים של FuzzyDateProperty. כדי לעשות זאת, משנים את שיטת _validate() ההגדרה באופן הבא:

def _validate(self, value):
    if isinstance(value, date):
        return FuzzyDate(value)  # Must return the converted value!
    # Otherwise, return None and leave validation to the base class

במקום זאת, אפשר ליצור מחלקת משנה של FuzzyDateProperty באופן הבא (בהנחה ש-FuzzyDateProperty._validate() הוא כמו שמוצג למעלה).

class MaybeFuzzyDateProperty(FuzzyDateProperty):
    def _validate(self, value):
        if isinstance(value, date):
            return FuzzyDate(value)  # Must return the converted value!
        # Otherwise, return None and leave validation to the base class

כשמקצים ערך לשדה MaybeFuzzyDateProperty, מופעלים גם MaybeFuzzyDateProperty._validate() וגם FuzzyDateProperty._validate(), בסדר הזה. אותו עיקרון חל על _to_base_type() ו-_from_base_type(): ה-methods במחלקת האב ובמחלקה המשנית משולבים באופן מרומז. (אל תשתמשו ב-super כדי לשלוט בהתנהגות שעוברת בירושה. בשלוש השיטות האלה, האינטראקציה עדינה וsuper לא עושה את מה שאתם רוצים).