Repository: gtalarico/pyairtable
Pull request #369 (2024-04-22 22:14:02)

Reintroduce support for memoizing linked models
In 6c1135dff251a39c078c9c1faa160a17b874a77a we removed the behavior of memoizing references to existing linked model instances because it was hard to test and led to unpredictable behavior. However, it is actually _very useful_ to prefetch all linked models rather than grabbing them one by one via API calls, because it can be much faster.

This branch reintroduces memoization as an optional feature, with more granular control over how and when it happens.


Patch:
diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst
index 1aea254..b5e230c 100644
--- a/docs/source/_substitutions.rst
+++ b/docs/source/_substitutions.rst
@@ -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>`__.
 
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 14e39ab..e91e858 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -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)
 ------------------------
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index c2313a7..3a37cc4 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -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
 ----------
 
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index ef70405..1b92d6a 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -608,11 +608,22 @@ class LinkField(_ListField[RecordId, T_Linked]):
             ("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:
 
@@ -628,7 +639,7 @@ class LinkField(_ListField[RecordId, T_Linked]):
                     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(
@@ -648,6 +659,7 @@ class LinkField(_ListField[RecordId, T_Linked]):
                 record.id: record
                 for record in self.linked_model.from_ids(
                     cast(List[RecordId], new_record_ids),
+                    memoize=memoize,
                     fetch=(not lazy),
                 )
             }
@@ -748,6 +760,14 @@ class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]):
 
     """
 
+    @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,
@@ -757,31 +777,6 @@ class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]):
         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 ¯\_(ツ)_/¯
@@ -833,10 +828,18 @@ class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, 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
 
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 65bbb90..d661d7f 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -50,7 +50,13 @@ class Model:
         * ``table_name`` (required) - Table ID or name.
         * ``timeout`` - A tuple indicating a connect and read timeout. Defaults to no timeout.
         * ``typecast`` - |kwarg_typecast| Defaults to ``True``.
-        * ``use_field_ids`` - |kwarg_use_field_ids| Defaults to ``False``.
+        * ``retry`` - An instance of `urllib3.util.Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`_.
+          If ``None`` or ``False``, requests will not be retried.
+          If ``True``, the default strategy will be applied
+          (see :func:`~pyairtable.retry_strategy` for details).
+        * ``use_field_ids`` - Whether fields will be defined by ID, rather than name. Defaults to ``False``.
+        * ``memoize`` - Whether the model should reuse models it creates between requests.
+          See :ref:`Memoizing linked records` for more information.
 
     For example, the following two are equivalent:
 
@@ -117,10 +123,13 @@ class Model:
     meta: ClassVar["_Meta"]
 
     _deleted: bool = False
+    _fetched: bool = False
     _fields: Dict[FieldName, Any]
+    _memoized: ClassVar[Dict[RecordId, SelfType]]
 
     def __init_subclass__(cls, **kwargs: Any):
         cls.meta = _Meta(cls)
+        cls._memoized = {}
         cls._validate_class()
         super().__init_subclass__(**kwargs)
 
@@ -176,8 +185,10 @@ class Model:
         <Contact id='recWPqD9izdsNvlE'>
         """
 
-        if "id" in fields:
+        try:
             self.id = fields.pop("id")
+        except KeyError:
+            pass
 
         # Field values in internal (not API) representation
         self._fields = {}
@@ -252,25 +263,45 @@ class Model:
         return bool(result["deleted"])
 
     @classmethod
-    def all(cls, **kwargs: Any) -> List[SelfType]:
+    def all(cls, *, memoize: Optional[bool] = None, **kwargs: Any) -> List[SelfType]:
         """
         Retrieve all records for this model. For all supported
         keyword arguments, see :meth:`Table.all <pyairtable.Table.all>`.
+
+        Args:
+            memoize: |kwarg_orm_memoize|
         """
         kwargs.update(cls.meta.request_kwargs)
-        return [cls.from_record(record) for record in cls.meta.table.all(**kwargs)]
+        return [
+            cls.from_record(record, memoize=memoize)
+            for record in cls.meta.table.all(**kwargs)
+        ]
 
     @classmethod
-    def first(cls, **kwargs: Any) -> Optional[SelfType]:
+    def first(
+        cls, *, memoize: Optional[bool] = None, **kwargs: Any
+    ) -> Optional[SelfType]:
         """
         Retrieve the first record for this model. For all supported
         keyword arguments, see :meth:`Table.first <pyairtable.Table.first>`.
+
+        Args:
+            memoize: |kwarg_orm_memoize|
         """
         kwargs.update(cls.meta.request_kwargs)
         if record := cls.meta.table.first(**kwargs):
-            return cls.from_record(record)
+            return cls.from_record(record, memoize=memoize)
         return None
 
+    @classmethod
+    def _maybe_memoize(cls, instance: SelfType, memoize: Optional[bool]) -> None:
+        """
+        If memoization is enabled, save the instance to the memoization cache.
+        """
+        memoize = cls.meta.memoize if memoize is None else memoize
+        if memoize:
+            cls._memoized[instance.id] = instance
+
     def to_record(self, only_writable: bool = False) -> RecordDict:
         """
         Build a :class:`~pyairtable.api.types.RecordDict` to represent this instance.
@@ -293,9 +324,15 @@ class Model:
         return {"id": self.id, "createdTime": ct, "fields": fields}
 
     @classmethod
-    def from_record(cls, record: RecordDict) -> SelfType:
+    def from_record(
+        cls, record: RecordDict, *, memoize: Optional[bool] = None
+    ) -> SelfType:
         """
         Create an instance from a record dict.
+
+        Args:
+            record: The record data from the Airtable API.
+            memoize: |kwarg_orm_memoize|
         """
         name_field_map = cls._field_name_descriptor_map()
         # Convert Column Names into model field names
@@ -314,27 +351,34 @@ class Model:
         # any readonly fields, instead we directly set instance._fields.
         instance = cls(id=record["id"])
         instance._fields = field_values
+        instance._fetched = True
         instance.created_time = datetime_from_iso_str(record["createdTime"])
+        cls._maybe_memoize(instance, memoize)
         return instance
 
     @classmethod
     def from_id(
         cls,
         record_id: RecordId,
+        *,
         fetch: bool = True,
+        memoize: Optional[bool] = None,
     ) -> SelfType:
         """
         Create an instance from a record ID.
 
         Args:
             record_id: |arg_record_id|
-            fetch: If ``True``, record will be fetched and field values will be
-                updated. If ``False``, a new instance is created with the provided ID,
-                but field values are unset.
+            fetch: |kwarg_orm_fetch|
+            memoize: |kwarg_orm_memoize|
         """
-        instance = cls(id=record_id)
-        if fetch:
+        try:
+            instance = cast(SelfType, cls._memoized[record_id])
+        except KeyError:
+            instance = cls(id=record_id)
+        if fetch and not instance._fetched:
             instance.fetch()
+        cls._maybe_memoize(instance, memoize)
         return instance
 
     def fetch(self) -> None:
@@ -345,15 +389,18 @@ class Model:
             raise ValueError("cannot be fetched because instance does not have an id")
 
         record = self.meta.table.get(self.id)
-        unused = self.from_record(record)
+        unused = self.from_record(record, memoize=False)
         self._fields = unused._fields
+        self._fetched = True
         self.created_time = unused.created_time
 
     @classmethod
     def from_ids(
         cls,
         record_ids: Iterable[RecordId],
+        *,
         fetch: bool = True,
+        memoize: Optional[bool] = None,
     ) -> List[SelfType]:
         """
         Create a list of instances from record IDs. If any record IDs returned
@@ -362,20 +409,34 @@ class Model:
 
         Args:
             record_ids: |arg_record_id|
-            fetch: 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.
+            fetch: |kwarg_orm_fetch|
+            memoize: |kwarg_orm_memoize|
         """
-        record_ids = list(record_ids)
         if not fetch:
             return [cls.from_id(record_id, fetch=False) for record_id in record_ids]
-        # There's no endpoint to query multiple IDs at once, but we can use a formula.
-        formula = OR(EQ(RECORD_ID(), record_id) for record_id in record_ids)
-        record_data = cls.meta.table.all(formula=formula)
-        records = [cls.from_record(record) for record in record_data]
+
+        record_ids = list(record_ids)
+        by_id: Dict[RecordId, SelfType] = {}
+
+        if cls._memoized:
+            for record_id in record_ids:
+                try:
+                    by_id[record_id] = cast(SelfType, cls._memoized[record_id])
+                except KeyError:
+                    pass
+
+        if remaining := sorted(set(record_ids) - set(by_id)):
+            # Only retrieve records that aren't already memoized
+            formula = OR(EQ(RECORD_ID(), record_id) for record_id in sorted(remaining))
+            by_id.update(
+                {
+                    record["id"]: cls.from_record(record, memoize=memoize)
+                    for record in cls.meta.table.all(formula=formula)
+                }
+            )
+
         # Ensure we return records in the same order, and raise KeyError if any are missing
-        records_by_id = {record.id: record for record in records}
-        return [records_by_id[record_id] for record_id in record_ids]
+        return [by_id[record_id] for record_id in record_ids]
 
     @classmethod
     def batch_save(cls, models: List[SelfType]) -> None:
@@ -540,6 +601,10 @@ class _Meta:
     def use_field_ids(self) -> bool:
         return bool(self.get("use_field_ids", default=False))
 
+    @property
+    def memoize(self) -> bool:
+        return bool(self.get("memoize", default=False))
+
     @property
     def request_kwargs(self) -> Dict[str, Any]:
         return {
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index b89297e..c4d4420 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -25,6 +25,8 @@ from pyairtable.api.types import CreateAttachmentDict
 P = ParamSpec("P")
 R = TypeVar("R", covariant=True)
 T = TypeVar("T")
+C = TypeVar("C", contravariant=True)
+F = TypeVar("F", bound=Callable[..., Any])
 
 
 def datetime_to_iso_str(value: datetime) -> str:
@@ -141,9 +143,6 @@ is_field_id = partial(is_airtable_id, prefix="fld")
 is_user_id = partial(is_airtable_id, prefix="usr")
 
 
-F = TypeVar("F", bound=Callable[..., Any])
-
-
 def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F:
     """
     Wrap a function or method so that if Airtable returns a 404,
@@ -193,13 +192,21 @@ def _append_docstring_text(obj: Any, text: str) -> None:
     obj.__doc__ = f"{doc}\n\n{text}"
 
 
-class FetchMethod(Protocol, Generic[R]):
-    def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: ...
+def docstring_from(obj: Any, append: str = "") -> Callable[[F], F]:
+    def _wrapper(func: F) -> F:
+        func.__doc__ = obj.__doc__ + append
+        return func
+
+    return _wrapper
+
+
+class FetchMethod(Protocol, Generic[C, R]):
+    def __get__(self, instance: C, owner: Any) -> Callable[..., R]: ...
 
-    def __call__(self_, self: Any, *, force: bool = False) -> R: ...
+    def __call__(self_, self: C, *, force: bool = False) -> R: ...
 
 
-def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
+def cache_unless_forced(func: Callable[[C], R]) -> FetchMethod[C, R]:
     """
     Wrap a method (e.g. ``Base.shares()``) in a decorator that will save
     a memoized version of the return value for future reuse, but will also
@@ -211,7 +218,7 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
         attr = "_cached_" + attr.lstrip("_")
 
     @wraps(func)
-    def _inner(self: Any, *, force: bool = False) -> R:
+    def _inner(self: C, *, force: bool = False) -> R:
         if force or getattr(self, attr, None) is None:
             setattr(self, attr, func(self))
         return cast(R, getattr(self, attr))
@@ -219,7 +226,7 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
     _inner.__annotations__["force"] = bool
     _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|")
 
-    return _inner
+    return cast(FetchMethod[C, R], _inner)
 
 
 def coerce_iso_str(value: Any) -> Optional[str]:
diff --git a/tox.ini b/tox.ini
index b3c023c..43e0ff9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,24 +1,30 @@
 [tox]
 envlist =
     pre-commit
-    mypy
+    mypy-py3{8,9,10,11,12}
     py3{8,9,10,11,12}{,-pydantic1,-requestsmin}
     integration
     coverage
 
 [gh-actions]
 python =
-    3.8: py38, mypy
-    3.9: py39, mypy
-    3.10: py310, mypy
-    3.11: py311, mypy
-    3.12: coverage, mypy
+    3.8: py38, mypy-py38
+    3.9: py39, mypy-py39
+    3.10: py310, mypy-py310
+    3.11: py311, mypy-py311
+    3.12: coverage, mypy-py312
 
 [testenv:pre-commit]
 deps = pre-commit
 commands = pre-commit run --all-files
 
-[testenv:mypy]
+[testenv:mypy-py3{8,9,10,11,12}]
+basepython =
+    py38: python3.8
+    py39: python3.9
+    py310: python3.10
+    py311: python3.11
+    py312: python3.12
 deps = -r requirements-dev.txt
 commands = mypy --strict pyairtable tests/test_typing.py
 
@@ -53,6 +59,7 @@ commands =
     python -m sphinx -T -E -b html {toxinidir}/docs/source {toxinidir}/docs/build
 
 [pytest]
+requests_mock_case_sensitive = true
 markers =
     integration: integration tests, hit airtable api
 
@@ -60,7 +67,7 @@ markers =
 filename = *.py
 count = True
 # See https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html
-ignore = E203, E266, E501, E704, W503
+ignore = E203, E226, E266, E501, E704, W503
 select = B,C,E,F,W,T4,B9
 max-line-length = 88
 max-complexity = 15


Test patch:
diff --git a/pyairtable/testing.py b/pyairtable/testing.py
index 359ad33..ccbdd7a 100644
--- a/pyairtable/testing.py
+++ b/pyairtable/testing.py
@@ -10,6 +10,7 @@ from typing import Any, Optional, Union
 from pyairtable.api import retrying
 from pyairtable.api.api import TimeoutTuple
 from pyairtable.api.types import AttachmentDict, CollaboratorDict, Fields, RecordDict
+from pyairtable.utils import is_airtable_id
 
 
 def fake_id(type: str = "rec", value: Any = None) -> str:
@@ -33,23 +34,29 @@ def fake_id(type: str = "rec", value: Any = None) -> str:
 
 
 def fake_meta(
-    base_id: str = "appFakeTestingApp",
-    table_name: str = "tblFakeTestingTbl",
+    base_id: str = "",
+    table_name: str = "",
     api_key: str = "patFakePersonalAccessToken",
     timeout: Optional[TimeoutTuple] = None,
     retry: Optional[Union[bool, retrying.Retry]] = None,
+    typecast: bool = True,
     use_field_ids: bool = False,
+    memoize: bool = False,
 ) -> type:
     """
     Generate a ``Meta`` class for inclusion in a ``Model`` subclass.
     """
     attrs = {
-        "base_id": base_id,
-        "table_name": table_name,
+        "base_id": base_id or fake_id("app"),
+        "table_name": table_name or fake_id("tbl"),
         "api_key": api_key,
         "timeout": timeout,
         "retry": retry,
+        "typecast": typecast,
+        "timeout": timeout,
+        "retry": retry,
         "use_field_ids": use_field_ids,
+        "memoize": memoize,
     }
     return type("Meta", (), attrs)
 
@@ -65,14 +72,17 @@ def fake_record(
     >>> fake_record({"Name": "Alice"})
     {'id': '...', 'createdTime': '...', 'fields': {'Name': 'Alice'}}
 
-    >>> fake_record(name='Alice', address='123 Fake St')
+    >>> fake_record(name="Alice", address="123 Fake St")
     {'id': '...', 'createdTime': '...', 'fields': {'name': 'Alice', 'address': '123 Fake St'}}
 
-    >>> fake_record(name='Alice', id='123')
+    >>> fake_record(name="Alice", id="123")
     {'id': 'rec00000000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}}
+
+    >>> fake_record(name="Alice", id="recABC00000000123")
+    {'id': 'recABC00000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}}
     """
     return {
-        "id": fake_id(value=id),
+        "id": str(id) if is_airtable_id(id, "rec") else fake_id(value=id),
         "createdTime": datetime.datetime.now().isoformat() + "Z",
         "fields": {**(fields or {}), **other_fields},
     }
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index c435bb2..e52c676 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -223,7 +223,7 @@ def test_audit_log__sortorder(
         list(enterprise.audit_log(*fncall.args, **fncall.kwargs))
 
     request = enterprise_mocks.get_audit_log.last_request
-    assert request.qs["sortorder"] == [sortorder]
+    assert request.qs["sortOrder"] == [sortorder]
     assert m.mock_calls[-1].kwargs["offset_field"] == offset_field
 
 
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index df8a7e8..6b25bd5 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -222,7 +222,9 @@ def test_from_ids(mock_api):
         url=FakeModel.meta.table.url,
         fallback=("post", FakeModel.meta.table.url + "/listRecords"),
         options={
-            "formula": "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in fake_ids)
+            "formula": (
+                "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(fake_ids))
+            )
         },
     )
     assert len(contacts) == len(fake_records)
@@ -306,6 +308,48 @@ def test_get_fields_by_id(fake_records_by_id):
         _ = getattr(fake_models[1], fake_records_by_id[0]["Age"])
 
 
+def test_meta_wrapper():
+    """
+    Test that Model subclasses have access to the _Meta wrapper.
+    """
+
+    class Dummy(Model):
+        Meta = fake_meta(api_key="asdf")
+
+    assert Dummy.meta.model is Dummy
+    assert Dummy.meta.api.api_key == "asdf"
+
+
+def test_meta_dict():
+    """
+    Test that Meta can be a dict instead of a class.
+    """
+
+    class Dummy(Model):
+        Meta = {
+            "api_key": "asdf",
+            "base_id": "qwer",
+            "table_name": "zxcv",
+            "timeout": (1, 1),
+        }
+
+    assert Dummy.meta.model is Dummy
+    assert Dummy.meta.api.api_key == "asdf"
+
+
+@pytest.mark.parametrize("meta_kwargs", [{"timeout": 1}, {"retry": "asdf"}])
+def test_meta_type_check(meta_kwargs):
+    """
+    Test that we check types on certain Meta attributes.
+    """
+
+    class Dummy(Model):
+        Meta = fake_meta(**meta_kwargs)
+
+    with pytest.raises(TypeError):
+        Dummy.meta.api
+
+
 def test_dynamic_model_meta():
     """
     Test that we can provide callables in our Meta class to provide
@@ -327,7 +371,7 @@ def test_dynamic_model_meta():
     f = Fake()
     Fake.Meta.table_name.assert_not_called()
 
-    assert f.meta.get("api_key") == data["api_key"]
-    assert f.meta.get("base_id") == data["base_id"]
-    assert f.meta.get("table_name") == data["table_name"]
+    assert f.meta.api_key == data["api_key"]
+    assert f.meta.base_id == data["base_id"]
+    assert f.meta.table_name == data["table_name"]
     Fake.Meta.table_name.assert_called_once()
diff --git a/tests/test_orm_model__memoization.py b/tests/test_orm_model__memoization.py
new file mode 100644
index 0000000..053d02d
--- /dev/null
+++ b/tests/test_orm_model__memoization.py
@@ -0,0 +1,201 @@
+from unittest.mock import Mock
+
+import pytest
+
+from pyairtable.orm import Model
+from pyairtable.orm import fields as f
+from pyairtable.testing import fake_meta, fake_record
+
+
+class Author(Model):
+    Meta = fake_meta(memoize=True)
+    name = f.TextField("Name")
+    books = f.LinkField["Book"]("Books", "Book")
+
+
+class Book(Model):
+    Meta = fake_meta()
+    name = f.TextField("Title")
+    author = f.SingleLinkField("Author", Author)
+
+
+@pytest.fixture(autouse=True)
+def clear_memoization_and_forbid_api_calls(requests_mock):
+    Author._memoized.clear()
+    Book._memoized.clear()
+
+
+@pytest.fixture
+def record_mocks(requests_mock):
+    mocks = Mock()
+    mocks.authors = {
+        record["id"]: record
+        for record in [
+            fake_record(Name="Abigail Adams"),
+            fake_record(Name="Babette Brown"),
+            fake_record(Name="Cristina Cubas"),
+        ]
+    }
+    mocks.books = {
+        record["id"]: record
+        for author_id, n in zip(mocks.authors, range(3))
+        if (record := fake_record(Title=f"Book {n}", Author=[author_id]))
+    }
+    for book_id, book_record in mocks.books.items():
+        author_record = mocks.authors[book_record["fields"]["Author"][0]]
+        author_record["fields"]["Books"] = [book_id]
+
+    # for Model.all
+    mocks.get_authors = requests_mock.get(
+        Author.meta.table.url,
+        json={"records": list(mocks.authors.values())},
+    )
+    mocks.get_books = requests_mock.get(
+        Book.meta.table.url,
+        json={"records": list(mocks.books.values())},
+    )
+
+    # for Model.from_id
+    mocks.get_author = {
+        record_id: requests_mock.get(
+            Author.meta.table.record_url(record_id), json=record_data
+        )
+        for record_id, record_data in mocks.authors.items()
+    }
+    mocks.get_book = {
+        record_id: requests_mock.get(
+            Book.meta.table.record_url(record_id), json=record_data
+        )
+        for record_id, record_data in mocks.books.items()
+    }
+    mocks.get = {**mocks.get_author, **mocks.get_book}
+
+    return mocks
+
+
+parametrized_memoization_test = pytest.mark.parametrize(
+    "cls,kwargs,expect_memoized",
+    [
+        # Meta.memoize is True, memoize= is not provided
+        (Author, {}, True),
+        # Meta.memoize is True, memoize=False
+        (Author, {"memoize": False}, False),
+        # Meta.memoize is False, memoize= is not provided
+        (Book, {}, False),
+        # Meta.memoize is False, memoize=True
+        (Book, {"memoize": True}, True),
+    ],
+)
+
+
+@parametrized_memoization_test
+def test_memoize__from_record(cls, kwargs, expect_memoized):
+    """
+    Test whether Model.from_record saves objects to Model._memoized
+    """
+    obj = cls.from_record(fake_record(), **kwargs)
+    assert_memoized(obj, expect_memoized)
+
+
+@parametrized_memoization_test
+def test_memoize__from_id(record_mocks, cls, kwargs, expect_memoized):
+    """
+    Test whether Model.from_id saves objects to Model._memoized
+    """
+    record_id = list(getattr(record_mocks, cls.__name__.lower() + "s"))[0]
+    obj = cls.from_id(record_id, **kwargs)
+    assert record_mocks.get[record_id].call_count == 1
+    assert_memoized(obj, expect_memoized)
+
+
+@parametrized_memoization_test
+def test_memoize__all(record_mocks, cls, kwargs, expect_memoized):
+    """
+    Test whether Model.all saves objects to Model._memoized
+    """
+    for obj in cls.all(**kwargs):
+        assert_memoized(obj, expect_memoized)
+
+
+@parametrized_memoization_test
+def test_memoize__first(record_mocks, cls, kwargs, expect_memoized):
+    """
+    Test whether Model.all saves objects to Model._memoized
+    """
+    assert_memoized(cls.first(**kwargs), expect_memoized)
+
+
+def assert_memoized(obj: Model, expect_memoized: bool = True):
+    if expect_memoized:
+        assert obj.__class__._memoized[obj.id] is obj
+    else:
+        assert obj.id not in obj.__class__._memoized
+
+
+def test_from_id():
+    """
+    Test that Model.from_id pulls from Model._memoized, regardless
+    of whether Model.Meta.memoize is True or False.
+    """
+    book = Book.from_record(fake_record())
+    Book._memoized[book.id] = book
+    assert Book.from_id(book.id) is book
+
+
+def test_from_ids(record_mocks):
+    """
+    Test that Model.from_ids pulls from Model._memoized, regardless
+    of whether Model.Meta.memoize is True or False.
+    """
+    book = Book.from_record(fake_record())
+    Book._memoized = {book.id: book}
+    books = Book.from_ids([book.id, *list(record_mocks.books)])
+    # We got all four, but only requested the non-memoized three from the API
+    assert len(books) == 4
+    assert record_mocks.get_books.call_count == 1
+    assert record_mocks.get_books.last_request.qs["filterByFormula"] == [
+        "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(record_mocks.books))
+    ]
+
+
+def test_memoize__link_field(record_mocks):
+    """
+    Test that Model.link_field writes to Model._memoized if Model.Meta.memoize is True
+    """
+    book_id = list(record_mocks.books)[0]
+    book = Book.from_id(book_id)
+    assert record_mocks.get[book_id].call_count == 1
+
+    # no memoization yet
+    assert not Book._memoized
+    assert not Author._memoized
+
+    book.author  # this makes the call
+    assert book.author.id == record_mocks.books[book_id]["fields"]["Author"][0]
+    assert Author._memoized[book.author.id] is book.author
+
+    # test that we only ever made one network call per object
+    assert record_mocks.get[book.id].call_count == 1
+    assert record_mocks.get[book.author.id].call_count == 0
+    assert record_mocks.get_authors.call_count == 1
+    assert record_mocks.get_authors.last_request.qs["filterByFormula"] == [
+        f"OR(RECORD_ID()='{book.author.id}')"
+    ]
+
+
+def test_memoize__link_field__populate(record_mocks):
+    """
+    Test that Model.link_field.populate writes to Model._memoized if memoize=True
+    """
+    author_id = list(record_mocks.authors)[0]
+    author = Author.from_id(author_id)
+    Author.books.populate(author, memoize=True)
+    assert len(author.books) == 1
+    for book in author.books:
+        assert Book._memoized[book.id] is book
+        assert record_mocks.get[book.id].call_count == 0
+    # test that we only ever made one network call
+    assert record_mocks.get_books.call_count == 1
+    assert record_mocks.get_books.last_request.qs["filterByFormula"] == [
+        "OR(%s)" % ", ".join(f"RECORD_ID()='{book.id}'" for book in author.books)
+    ]
diff --git a/tests/test_testing.py b/tests/test_testing.py
index 93ca3d9..98b3d30 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -15,6 +15,11 @@ from pyairtable import testing as T
             call(id=123),
             {"id": "rec00000000000123", "createdTime": ANY, "fields": {}},
         ),
+        (
+            "fake_record",
+            call(id="recABC00000000123"),
+            {"id": "recABC00000000123", "createdTime": ANY, "fields": {}},
+        ),
         (
             "fake_record",
             call({"A": 1}, 123),