Skip to content

Commit

Permalink
Merge pull request gtalarico#369 from mesozoic/orm_memoize
Browse files Browse the repository at this point in the history
Reintroduce support for memoizing linked models
  • Loading branch information
mesozoic committed May 10, 2024
2 parents 2631bee + 87a767f commit d87ffa8
Show file tree
Hide file tree
Showing 12 changed files with 516 additions and 83 deletions.
14 changes: 14 additions & 0 deletions docs/source/_substitutions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@
If ``True``, will fetch information from the metadata API and validate the ID/name exists,
raising ``KeyError`` if it does not.

.. |kwarg_orm_fetch| replace::
If ``True``, records will be fetched and field values will be
updated. If ``False``, new instances are created with the provided IDs,
but field values are unset.

.. |kwarg_orm_memoize| replace::
If ``True``, any objects created will be memoized for future reuse.
If ``False``, objects created will *not* be memoized.
The default behavior is defined on the :class:`~pyairtable.orm.Model` subclass.

.. |kwarg_orm_lazy| replace::
If ``True``, this field will return empty objects with only IDs;
call :meth:`~pyairtable.orm.Model.fetch` to retrieve values.

.. |kwarg_permission_level| replace::
See `application permission levels <https://airtable.com/developers/web/api/model/application-permission-levels>`__.

Expand Down
1 change: 1 addition & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Changelog
* Added ORM fields that :ref:`require a non-null value <Required Values>`.
- `PR #363 <https://github.com/gtalarico/pyairtable/pull/363>`_.
* Refactored methods for accessing ORM model configuration.
* Added support for :ref:`memoization of ORM models <memoizing linked records>`.

2.3.3 (2024-03-22)
------------------------
Expand Down
76 changes: 76 additions & 0 deletions docs/source/orm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,82 @@ there are four components:
4. The model class, the path to the model class, or :data:`~pyairtable.orm.fields.LinkSelf`


Memoizing linked records
"""""""""""""""""""""""""""""

There are cases where your application may need to retrieve hundreds of nested
models through the ORM, and you don't want to make hundreds of Airtable API calls.
pyAirtable provides a way to pre-fetch and memoize instances for each record,
which will then be reused later by record link fields.

The usual way to do this is passing ``memoize=True`` to a retrieval method
at the beginning of your code to pre-fetch any records you might need.
For example, you might have the following:

.. code-block:: python
from pyairtable.orm import Model, fields as F
from operator import attrgetter
class Book(Model):
class Meta: ...
title = F.TextField("Title")
published = F.DateField("Publication Date")
class Author(Model):
class Meta: ...
name = F.TextField("Name")
books = F.LinkField("Books", Book)
def main():
books = Book.all(memoize=True)
authors = Author.all(memoize=True)
for author in authors:
print(f"* {author.name}")
for book in sorted(author.books, key=attrgetter("published")):
print(f" - {book.title} ({book.published.isoformat()})")
This code will perform a series of API calls at the beginning to fetch
all records from the Books and Authors tables, so that ``author.books``
does not need to request linked records one at a time during the loop.

.. note::
Memoization does not affect whether pyAirtable will make an API call.
It only affects whether pyAirtable will reuse a model instance that
was already created, or create a new one. For example, calling
``model.all(memoize=True)`` N times will still result in N calls to the API.

You can also set ``memoize = True`` in the ``Meta`` configuration for your model,
which indicates that you always want to memoize models retrieved from the API:

.. code-block:: python
class Book(Model):
Meta = {..., "memoize": True}
title = F.TextField("Title")
class Author(Model):
Meta = {...}
name = F.TextField("Name")
books = F.LinkField("Books", Book)
Book.first() # this will memoize the book it creates
Author.first().books # this will memoize all books created
Book.all(memoize=False) # this will skip memoization
The following methods support the ``memoize=`` keyword argument.
You can pass ``memoize=False`` to override memoization that is
enabled on the model configuration.

* :meth:`Model.all <pyairtable.orm.Model.all>`
* :meth:`Model.first <pyairtable.orm.Model.first>`
* :meth:`Model.from_record <pyairtable.orm.Model.from_record>`
* :meth:`Model.from_id <pyairtable.orm.Model.from_id>`
* :meth:`Model.from_ids <pyairtable.orm.Model.from_ids>`
* :meth:`LinkField.populate <pyairtable.orm.fields.LinkField.populate>`
* :meth:`SingleLinkField.populate <pyairtable.orm.fields.SingleLinkField.populate>`


Comments
----------

Expand Down
65 changes: 34 additions & 31 deletions pyairtable/orm/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,11 +608,22 @@ def _repr_fields(self) -> List[Tuple[str, Any]]:
("lazy", self._lazy),
]

def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None:
def populate(
self,
instance: "Model",
*,
lazy: Optional[bool] = None,
memoize: Optional[bool] = None,
) -> None:
"""
Populates the field's value for the given instance. This allows you to
selectively load models in either lazy or non-lazy fashion, depending on
your need, without having to decide at the time of field construction.
control how linked models are loaded, depending on your need, without
having to decide at the time of field or model construction.
Args:
instance: An instance of this field's :class:`~pyairtable.orm.Model` class.
lazy: |kwarg_orm_lazy|
memoize: |kwarg_orm_memoize|
Usage:
Expand All @@ -628,7 +639,7 @@ class Meta: ...
books = F.LinkField("Books", Book)
author = Author.from_id("reculZ6qSLw0OCA61")
Author.books.populate(author, lazy=True)
Author.books.populate(author, lazy=True, memoize=False)
"""
if self._model and not isinstance(instance, self._model):
raise RuntimeError(
Expand All @@ -648,6 +659,7 @@ class Meta: ...
record.id: record
for record in self.linked_model.from_ids(
cast(List[RecordId], new_record_ids),
memoize=memoize,
fetch=(not lazy),
)
}
Expand Down Expand Up @@ -748,6 +760,14 @@ class Meta: ...
"""

@utils.docstring_from(
LinkField.__init__,
append="""
raise_if_many: If ``True``, this field will raise a
:class:`~pyairtable.orm.fields.MultipleValues` exception upon
being accessed if the underlying field contains multiple values.
""",
)
def __init__(
self,
field_name: str,
Expand All @@ -757,31 +777,6 @@ def __init__(
lazy: bool = False,
raise_if_many: bool = False,
):
"""
Args:
field_name: Name of the Airtable field.
model:
Model class representing the linked table. There are a few options:
1. You can provide a ``str`` that is the fully qualified module and class name.
For example, ``"your.module.Model"`` will import ``Model`` from ``your.module``.
2. You can provide a ``str`` that is *just* the class name, and it will be imported
from the same module as the model class.
3. You can provide the sentinel value :data:`~LinkSelf`, and the link field
will point to the same model where the link field is created.
validate_type: Whether to raise a TypeError if attempting to write
an object of an unsupported type as a field value. If ``False``, you
may encounter unpredictable behavior from the Airtable API.
readonly: If ``True``, any attempt to write a value to this field will
raise an ``AttributeError``. This will not, however, prevent any
modification of the list object returned by this field.
lazy: If ``True``, this field will return empty objects with only IDs;
call :meth:`~pyairtable.orm.Model.fetch` to retrieve values.
raise_if_many: If ``True``, this field will raise a
:class:`~pyairtable.orm.fields.MultipleValues` exception upon
being accessed if the underlying field contains multiple values.
"""
super().__init__(field_name, validate_type=validate_type, readonly=readonly)
self._raise_if_many = raise_if_many
# composition is easier than inheritance in this case ¯\_(ツ)_/¯
Expand Down Expand Up @@ -833,10 +828,18 @@ def __set_name__(self, owner: Any, name: str) -> None:
def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]:
return self._link_field.to_record_value(value)

def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None:
self._link_field.populate(instance, lazy=lazy)
@utils.docstring_from(LinkField.populate)
def populate(
self,
instance: "Model",
*,
lazy: Optional[bool] = None,
memoize: Optional[bool] = None,
) -> None:
self._link_field.populate(instance, lazy=lazy, memoize=memoize)

@property
@utils.docstring_from(LinkField.linked_model)
def linked_model(self) -> Type[T_Linked]:
return self._link_field.linked_model

Expand Down
Loading

0 comments on commit d87ffa8

Please sign in to comment.