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

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

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

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

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

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

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

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

ממשק ה-API של האימות מבחין בין ערכי משתמש גמישים לבין ערכי משתמש מחמירים. קבוצת הערכים lax היא קבוצת-על של קבוצת הערכים strict. השיטה _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, תשמח לשמוע שלא צריך לכתוב קוד מוכן מראש כדי שהן יפעלו. קל יותר להגדיר מחלקת משנה של נכס אחר, לדוגמה:

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 כדי לשלוט בהתנהגות שעוברת בירושה. ב-3 השיטות האלה, האינטראקציה עדינה וsuper לא עושה את מה שאתם רוצים).