Repository: gtalarico/pyairtable
Pull request #354 (2024-03-20 04:46:53)

orm.fields.SingleLinkField
This branch introduces `orm.fields.SingleLinkField`, which wraps (but does not inherit from) `LinkField`. Implementers can use `SingleLinkField` to indicate that they only ever want the first linked record of a particular field (or `None`), to avoid having to deal with lists. It is configurable whether the field will silently ignore 2nd-Nth values in fields that happen to have multiple records linked, or if it will raise an exception in that circumstance.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gtalarico/pyairtable/pull/354?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) Report
Attention: Patch coverage is `98.00000%` with `1 lines` in your changes are missing coverage. Please review.
> Project coverage is 99.95%. Comparing base [(`eef7b6f`)](https://app.codecov.io/gh/gtalarico/pyairtable/commit/eef7b6ff4948a1be33f9083e62b146a029a54be5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) to head [(`99693bf`)](https://app.codecov.io/gh/gtalarico/pyairtable/pull/354?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico).

| [Files](https://app.codecov.io/gh/gtalarico/pyairtable/pull/354?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) | Patch % | Lines |
|---|---|---|
| [pyairtable/formulas.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/354?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9mb3JtdWxhcy5weQ==) | 75.00% | [1 Missing :warning: ](https://app.codecov.io/gh/gtalarico/pyairtable/pull/354?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #354      +/-   ##
===========================================
- Coverage   100.00%   99.95%   -0.05%     
===========================================
  Files           25       25              
  Lines         2313     2355      +42     
===========================================
+ Hits          2313     2354      +41     
- Misses           0        1       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/gtalarico/pyairtable/pull/354?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico).


Patch:
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index d0e2a8f..a8c8800 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -20,7 +20,7 @@ The :class:`~pyairtable.orm.Model` class allows you create ORM-style classes for
         last_name = F.TextField("Last Name")
         email = F.EmailField("Email")
         is_registered = F.CheckboxField("Registered")
-        company = F.LinkField("Company", Company, lazy=False)
+        company = F.SingleLinkField("Company", Company, lazy=False)
 
         class Meta:
             base_id = "appaPqizdsNHDvlEm"
@@ -187,11 +187,13 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
      - `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__
    * - :class:`~pyairtable.orm.fields.SelectField`
      - `Single select <https://airtable.com/developers/web/api/field-model#select>`__
+   * - :class:`~pyairtable.orm.fields.SingleLinkField`
+     - `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>`__
    * - :class:`~pyairtable.orm.fields.TextField`
      - `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__, `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__
    * - :class:`~pyairtable.orm.fields.UrlField`
      - `Url <https://airtable.com/developers/web/api/field-model#urltext>`__
-.. [[[end]]] (checksum: 01c5696293e7571ac8250c4e8a2453e8)
+.. [[[end]]] (checksum: afd0edeabb06937f2a3afd73a7bac32e)
 
 
 Formula, Rollup, and Lookup Fields
@@ -249,44 +251,48 @@ Linked Records
 ----------------
 
 In addition to standard data type fields, the :class:`~pyairtable.orm.fields.LinkField`
-class offers a special behaviour that can fetch linked records, so that you can
-traverse between related records.
+and :class:`~pyairtable.orm.fields.SingleLinkField` classes will fetch linked records
+upon access, so that you can traverse between related records.
 
 .. code-block:: python
 
     from pyairtable.orm import Model, fields as F
 
-    class Company(Model):
+    class Person(Model):
         class Meta: ...
 
         name = F.TextField("Name")
+        company = F.SingleLinkField("Company", "Company")
 
-    class Person(Model):
+    class Company(Model):
         class Meta: ...
 
         name = F.TextField("Name")
-        company = F.LinkField("Company", Company)
+        people = F.LinkField("People", Person)
+
 
 .. code-block:: python
 
     >>> person = Person.from_id("recZ6qSLw0OCA61ul")
     >>> person.company
-    [<Company id='recqSk20OCrB13lZ7'>]
-    >>> person.company[0].name
+    <Company id='recqSk20OCrB13lZ7'>
+    >>> person.company.name
     'Acme Corp'
+    >>> person.company.people
+    [<Person id='recZ6qSLw0OCA61ul'>, ...]
 
 pyAirtable will not retrieve field values for a model's linked records until the
-first time you access that field. So in the example above, the fields for Company
-were loaded when ``person.company`` was called for the first time. After that,
-the Company models are persisted, and won't be refreshed until you call
+first time you access a field. So in the example above, the fields for Company
+were loaded when ``person.company`` was called for the first time. Linked models
+are persisted after being created, and won't be refreshed until you call
 :meth:`~pyairtable.orm.Model.fetch`.
 
 .. note::
     :class:`~pyairtable.orm.fields.LinkField` will always return a list of values,
     even if there is only a single value shown in the Airtable UI. It will not
     respect the `prefersSingleRecordLink <https://airtable.com/developers/web/api/field-model#foreignkey-fieldtype-options-preferssinglerecordlink>`_
-    field configuration option, because the API will *always* return linked fields
-    as a list of record IDs.
+    field configuration option. If you expect a field to only ever return a single
+    linked record, use :class:`~pyairtable.orm.fields.SingleLinkField`.
 
 
 Cyclical links
@@ -317,18 +323,18 @@ address this:
         class Meta: ...
 
         name = F.TextField("Name")
-        company = F.LinkField[Company]("Company", Company)
-        manager = F.LinkField["Person"]("Manager", "Person")  # option 2
+        company = F.SingleLinkField[Company]("Company", Company)
+        manager = F.SingleLinkField["Person"]("Manager", "Person")  # option 2
         reports = F.LinkField["Person"]("Reports", F.LinkSelf)  # option 3
 
 .. code-block:: python
 
     >>> person = Person.from_id("recZ6qSLw0OCA61ul")
     >>> person.manager
-    [<Person id='recSLw0OCA61ulZ6q'>]
-    >>> person.manager[0].reports
+    <Person id='recSLw0OCA61ulZ6q'>
+    >>> person.manager.reports
     [<Person id='recZ6qSLw0OCA61ul'>, ...]
-    >>> person.company[0].employees
+    >>> person.company.employees
     [<Person id='recZ6qSLw0OCA61ul'>, <Person id='recSLw0OCA61ulZ6q'>, ...]
 
 Breaking down the :class:`~pyairtable.orm.fields.LinkField` invocation above,
@@ -413,20 +419,23 @@ For example:
 
 .. code-block:: python
 
+    from pyairtable.orm import fields as F
+
     class Person(Model):
         class Meta: ...
 
         name = F.TextField("Name")
-        manager = F.LinkField["Person"]("Manager", "Person")
+        manager = F.SingleLinkField["Person"]("Manager", F.LinkSelf)
         # This field is a formula: {Manager} != BLANK()
         has_manager = F.IntegerField("Has Manager?", readonly=True)
 
 
     bob = Person.from_id("rec2AqNuHwWcnG871")
-    assert bob.manager == []
+    assert bob.manager is None
     assert bob.has_manager == 0
 
-    bob.manager = [alice]
+    alice = Person.from_id("recAB2AqNuHwWcnG8")
+    bob.manager = alice
     bob.save()
     assert bob.has_manager == 0
 
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index 1a08c42..a6be574 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -484,6 +484,11 @@ class FunctionCall(Formula):
         self.name = name
         self.args = args
 
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, FunctionCall):
+            return False
+        return (self.name, self.args) == (other.name, other.args)
+
     def __str__(self) -> str:
         joined_args = ", ".join(to_formula_str(v) for v in self.args)
         return f"{self.name}({joined_args})"
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 9d543cf..2e0744e 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -511,6 +511,7 @@ class LinkField(_ListField[RecordId, T_Linked]):
     """
 
     _linked_model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]]
+    _max_retrieve: Optional[int] = None
 
     def __init__(
         self,
@@ -539,7 +540,7 @@ class LinkField(_ListField[RecordId, T_Linked]):
             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 oly IDs;
+            lazy: If ``True``, this field will return empty objects with only IDs;
                 call :meth:`~pyairtable.orm.Model.fetch` to retrieve values.
         """
         from pyairtable.orm import Model  # noqa, avoid circular import
@@ -586,37 +587,70 @@ class LinkField(_ListField[RecordId, T_Linked]):
             ("lazy", self._lazy),
         ]
 
-    def _get_list_value(self, instance: "Model") -> List[T_Linked]:
+    def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None:
         """
-        Unlike most other field classes, LinkField does not store its internal
-        representation (T_ORM) in instance._fields after Model.from_record().
-        Instead, we defer creating objects until they're requested for the first
-        time, so we can avoid infinite recursion during to_internal_value().
+        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.
+
+        Usage:
+
+            .. code-block:: python
+
+                from pyairtable.orm import Model, fields as F
+
+                class Book(Model):
+                    class Meta: ...
+
+                class Author(Model):
+                    class Meta: ...
+                    books = F.LinkField("Books", Book)
+
+                author = Author.from_id("reculZ6qSLw0OCA61")
+                Author.books.populate(author, lazy=True)
         """
+        if self._model and not isinstance(instance, self._model):
+            raise RuntimeError(
+                f"populate() got {type(instance)}; expected {self._model}"
+            )
+        lazy = lazy if lazy is not None else self._lazy
         if not (records := super()._get_list_value(instance)):
-            return records
+            return
         # If there are any values which are IDs rather than instances,
         # retrieve their values in bulk, and store them keyed by ID
         # so we can maintain the order we received from the API.
         new_records = {}
-        if new_record_ids := [v for v in records if isinstance(v, RecordId)]:
+        if new_record_ids := [
+            v for v in records[: self._max_retrieve] if isinstance(v, RecordId)
+        ]:
             new_records = {
                 record.id: record
                 for record in self.linked_model.from_ids(
                     cast(List[RecordId], new_record_ids),
-                    fetch=(not self._lazy),
+                    fetch=(not lazy),
                 )
             }
         # If the list contains record IDs, replace the contents with instances.
         # Other code may already have references to this specific list, so
         # we replace the existing list's values.
-        records[:] = [
+        records[: self._max_retrieve] = [
             new_records[cast(RecordId, value)] if isinstance(value, RecordId) else value
-            for value in records
+            for value in records[: self._max_retrieve]
         ]
-        return records
 
-    def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]:
+    def _get_list_value(self, instance: "Model") -> List[T_Linked]:
+        """
+        Unlike most other field classes, LinkField does not store its internal
+        representation (T_ORM) in instance._fields after Model.from_record().
+        They will first be stored as a list of IDs.
+
+        We defer creating Model objects until they're requested for the first
+        time, so we can avoid infinite recursion during to_internal_value().
+        """
+        self.populate(instance)
+        return super()._get_list_value(instance)
+
+    def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]:
         """
         Build the list of record IDs which should be persisted to the API.
         """
@@ -625,15 +659,17 @@ class LinkField(_ListField[RecordId, T_Linked]):
         # When persisting this model back to the API, we can just write those IDs.
         if all(isinstance(v, str) for v in value):
             return cast(List[str], value)
-        # From here on, we assume we're dealing with models, not record IDs.
-        records = cast(List[T_Linked], value)
+
+        # Validate any items in our list which are not record IDs
+        records = [v for v in value if not isinstance(v, str)]
         self.valid_or_raise(records)
-        # We could *try* to recursively save models that don't have an ID yet,
-        # but that requires us to second-guess the implementers' intentions.
-        # Better to just raise an exception.
         if not all(record.exists() for record in records):
+            # We could *try* to recursively save models that don't have an ID yet,
+            # but that requires us to second-guess the implementers' intentions.
+            # Better to just raise an exception.
             raise ValueError(f"{self._description} contains an unsaved record")
-        return [record.id for record in records]
+
+        return [v if isinstance(v, str) else v.id for v in value]
 
     def valid_or_raise(self, value: Any) -> None:
         super().valid_or_raise(value)
@@ -642,6 +678,154 @@ class LinkField(_ListField[RecordId, T_Linked]):
                 raise TypeError(f"expected {self.linked_model}; got {type(obj)}")
 
 
+class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]):
+    """
+    Represents a MultipleRecordLinks field which we assume will only ever contain one link.
+    Returns and accepts a single instance of the linked model, which will be converted to/from
+    a list of IDs when communicating with the Airtable API.
+
+    See `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>`__.
+
+    .. warning::
+
+        If Airtable returns multiple IDs for a SingleLinkField and you modify the field value,
+        only the first ID will be saved to the API once you call ``.save()``. The other IDs will be lost.
+
+    By default, a SingleLinkField will ignore the 2nd...Nth IDs if it receives multiple IDs from the API.
+    This behavior can be overridden by passing ``raise_if_many=True`` to the constructor.
+
+    .. code-block:: python
+
+        from pyairtable.orm import Model, fields as F
+
+        class Book(Model):
+            class Meta: ...
+
+            author = F.SingleLinkField("Author", Person)
+            editor = F.SingleLinkField("Editor", Person, raise_if_many=True)
+
+    Given the model configuration above and the data below,
+    one field will silently return a single value,
+    while the other field will throw an exception.
+
+    .. code-block:: python
+
+        >>> book = Book.from_record({
+        ...     "id": "recZ6qSLw0OCA61ul",
+        ...     "createdTime": ...,
+        ...     "fields": {
+        ...         "Author": ["reculZ6qSLw0OCA61", "rec61ulZ6qSLw0OCA"],
+        ...         "Editor": ["recLw0OCA61ulZ6qS", "recOCA61ulZ6qSLw0"],
+        ...     }
+        ... })
+        >>> book.author
+        <Person id='reculZ6qSLw0OCA61'>
+        >>> book.editor
+        Traceback (most recent call last):
+          ...
+        MultipleValues: Book.editor got more than one linked record
+
+    """
+
+    def __init__(
+        self,
+        field_name: str,
+        model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]],
+        validate_type: bool = True,
+        readonly: Optional[bool] = 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 ¯\_(ツ)_/¯
+        self._link_field = LinkField[T_Linked](
+            field_name,
+            model,
+            validate_type=validate_type,
+            readonly=readonly,
+            lazy=lazy,
+        )
+        self._link_field._max_retrieve = 1
+
+    def _repr_fields(self) -> List[Tuple[str, Any]]:
+        return [
+            ("model", self._link_field._linked_model),
+            ("validate_type", self.validate_type),
+            ("readonly", self.readonly),
+            ("lazy", self._link_field._lazy),
+            ("raise_if_many", self._raise_if_many),
+        ]
+
+    @overload
+    def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ...
+
+    @overload
+    def __get__(self, instance: "Model", owner: Type[Any]) -> Optional[T_Linked]: ...
+
+    def __get__(
+        self, instance: Optional["Model"], owner: Type[Any]
+    ) -> Union[SelfType, Optional[T_Linked]]:
+        if not instance:
+            return self
+        if self._raise_if_many and len(instance._fields.get(self.field_name) or []) > 1:
+            raise MultipleValues(f"{self._description} got more than one linked record")
+        links = self._link_field.__get__(instance, owner)
+        try:
+            return links[0]
+        except IndexError:
+            return self._missing_value()
+
+    def __set__(self, instance: "Model", value: Optional[T_Linked]) -> None:
+        values = None if value is None else [value]
+        self._link_field.__set__(instance, values)
+
+    def __set_name__(self, owner: Any, name: str) -> None:
+        super().__set_name__(owner, name)
+        self._link_field.__set_name__(owner, name)
+
+    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)
+
+    @property
+    def linked_model(self) -> Type[T_Linked]:
+        return self._link_field.linked_model
+
+
+class MultipleValues(ValueError):
+    """
+    SingleLinkField received more than one value from either Airtable or calling code.
+    """
+
+
 # Many of these are "passthrough" subclasses for now. E.g. there is no real
 # difference between `field = TextField()` and `field = PhoneNumberField()`.
 #
@@ -993,6 +1177,7 @@ __all__ = [
     "RatingField",
     "RichTextField",
     "SelectField",
+    "SingleLinkField",
     "TextField",
     "UrlField",
     "ALL_FIELDS",
@@ -1001,7 +1186,7 @@ __all__ = [
     "FIELD_CLASSES_TO_TYPES",
     "LinkSelf",
 ]
-# [[[end]]] (checksum: 2aa36f4e76db73f3d0b741b6be6c9e9e)
+# [[[end]]] (checksum: 314db7bbb782c156d620305a1c42dfef)
 
 
 # Delayed import to avoid circular dependency
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 63dbcd3..461f983 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -31,7 +31,6 @@ 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``.
 
     .. code-block:: python
 
@@ -183,15 +182,6 @@ class Model:
                 )
             )
 
-    @classmethod
-    def _get_meta_request_kwargs(cls):
-        return {
-            "user_locale": None,
-            "cell_format": "json",
-            "time_zone": None,
-            "return_fields_by_field_id": cls._get_meta("use_field_ids", default=False),
-        }
-
     @classmethod
     @lru_cache
     def get_api(cls) -> Api:
@@ -265,7 +255,6 @@ class Model:
         Retrieve all records for this model. For all supported
         keyword arguments, see :meth:`Table.all <pyairtable.Table.all>`.
         """
-        kwargs.update(cls._get_meta_request_kwargs())
         table = cls.get_table()
         return [cls.from_record(record) for record in table.all(**kwargs)]
 
@@ -275,7 +264,6 @@ class Model:
         Retrieve the first record for this model. For all supported
         keyword arguments, see :meth:`Table.first <pyairtable.Table.first>`.
         """
-        kwargs.update(cls._get_meta_request_kwargs())
         table = cls.get_table()
         if record := table.first(**kwargs):
             return cls.from_record(record)


Test patch:
diff --git a/pyairtable/testing.py b/pyairtable/testing.py
index a594bf2..69158f0 100644
--- a/pyairtable/testing.py
+++ b/pyairtable/testing.py
@@ -34,17 +34,11 @@ def fake_meta(
     base_id: str = "appFakeTestingApp",
     table_name: str = "tblFakeTestingTbl",
     api_key: str = "patFakePersonalAccessToken",
-    use_field_ids: bool = False,
 ) -> type:
     """
     Generate a ``Meta`` class for inclusion in a ``Model`` subclass.
     """
-    attrs = {
-        "base_id": base_id,
-        "table_name": table_name,
-        "api_key": api_key,
-        "use_field_ids": use_field_ids,
-    }
+    attrs = {"base_id": base_id, "table_name": table_name, "api_key": api_key}
     return type("Meta", (), attrs)
 
 
diff --git a/tests/test_formulas.py b/tests/test_formulas.py
index 2b85984..b7b44c1 100644
--- a/tests/test_formulas.py
+++ b/tests/test_formulas.py
@@ -278,6 +278,14 @@ def test_function_call():
     assert str(fc) == "IF(1, TRUE(), FALSE())"
 
 
+def test_function_call_equivalence():
+    assert F.TODAY() == F.TODAY()
+    assert F.TODAY() != F.NOW()
+    assert F.CEILING(1) == F.CEILING(1)
+    assert F.CEILING(1) != F.CEILING(2)
+    assert F.TODAY() != F.Formula("TODAY()")
+
+
 def test_field_name():
     assert F.field_name("First Name") == "{First Name}"
     assert F.field_name("Guest's Name") == "{Guest\\'s Name}"
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index c93c284..fcd781d 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -4,7 +4,9 @@ import re
 from unittest import mock
 
 import pytest
+from requests_mock import NoMockAddress
 
+from pyairtable.formulas import OR, RECORD_ID
 from pyairtable.orm import fields as f
 from pyairtable.orm.model import Model
 from pyairtable.testing import (
@@ -21,6 +23,10 @@ DATETIME_S = "2023-04-12T09:30:00.000Z"
 DATETIME_V = datetime.datetime(2023, 4, 12, 9, 30, 0, tzinfo=datetime.timezone.utc)
 
 
+class Dummy(Model):
+    Meta = fake_meta()
+
+
 def test_field():
     class T:
         name = f.Field("Name")
@@ -71,6 +77,12 @@ def test_description():
             f.LinkField("Records", type("TestModel", (Model,), {"Meta": fake_meta()})),
             "LinkField('Records', model=<class 'test_orm_fields.TestModel'>, validate_type=True, readonly=False, lazy=False)",
         ),
+        (
+            f.SingleLinkField(
+                "Records", type("TestModel", (Model,), {"Meta": fake_meta()})
+            ),
+            "SingleLinkField('Records', model=<class 'test_orm_fields.TestModel'>, validate_type=True, readonly=False, lazy=False, raise_if_many=False)",
+        ),
     ],
 )
 def test_repr(instance, expected):
@@ -321,11 +333,13 @@ def test_completeness():
     assert_all_fields_tested_by(test_writable_fields, test_readonly_fields)
     assert_all_fields_tested_by(
         test_type_validation,
-        exclude=f.READONLY_FIELDS | {f.LinkField},
+        exclude=f.READONLY_FIELDS | {f.LinkField, f.SingleLinkField},
     )
 
 
-def assert_all_fields_tested_by(*test_fns, exclude=(f.Field, f.LinkField)):
+def assert_all_fields_tested_by(
+    *test_fns, exclude=(f.Field, f.LinkField, f.SingleLinkField)
+):
     """
     Allows meta-tests that fail if any new Field classes appear in pyairtable.orm.fields
     which are not covered by one of a few basic tests. This is intended to help remind
@@ -436,12 +450,13 @@ def test_list_field_with_string():
         t.items = "hello!"
 
 
-def test_link_field_must_link_to_model():
+@pytest.mark.parametrize("cls", (f.LinkField, f.SingleLinkField))
+def test_link_field_must_link_to_model(cls):
     """
     Tests that a LinkField cannot link to an arbitrary type.
     """
     with pytest.raises(TypeError):
-        f.LinkField("Field Name", model=dict)
+        cls("Field Name", model=dict)
 
 
 def test_link_field():
@@ -471,10 +486,6 @@ def test_link_field():
         author.books = -1
 
 
-class Dummy(Model):
-    Meta = fake_meta()
-
-
 def test_link_field__linked_model():
     """
     Test the various ways of specifying a linked model for the LinkField.
@@ -590,6 +601,154 @@ def test_link_field__load_many(requests_mock):
     assert mock_list.call_count == 2
 
 
+def test_single_link_field():
+    class Author(Model):
+        Meta = fake_meta()
+        name = f.TextField("Name")
+
+    class Book(Model):
+        Meta = fake_meta()
+        author = f.SingleLinkField("Author", Author, lazy=True)
+
+    assert Book.author.linked_model is Author
+
+    book = Book()
+    assert book.author is None
+
+    with pytest.raises(TypeError):
+        book.author = [Author()]
+
+    with pytest.raises(TypeError):
+        book.author = []
+
+    alice = Author.from_record(fake_record(Name="Alice"))
+    book.author = alice
+
+    with mock.patch("pyairtable.Table.get", return_value=alice.to_record()) as m:
+        book.author.fetch()
+        m.assert_called_once_with(alice.id)
+
+    assert book.author.id == alice.id
+    assert book.author.name == "Alice"
+
+    book.author = (bob := Author(name="Bob"))
+    assert not book.author.exists()
+    assert book.author.name == "Bob"
+
+    with mock.patch("pyairtable.Table.create", return_value=fake_record()) as m:
+        book.author.save()
+        m.assert_called_once_with({"Name": "Bob"}, typecast=True)
+
+    with mock.patch("pyairtable.Table.create", return_value=fake_record()) as m:
+        book.save()
+        m.assert_called_once_with({"Author": [bob.id]}, typecast=True)
+
+    with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m:
+        book.author = None
+        book.save()
+        m.assert_called_once_with(book.id, {"Author": None}, typecast=True)
+
+
+def test_single_link_field__multiple_values():
+    """
+    Test the behavior of SingleLinkField when the Airtable API
+    returns multiple values.
+    """
+
+    class Author(Model):
+        Meta = fake_meta()
+        name = f.TextField("Name")
+
+    class Book(Model):
+        Meta = fake_meta()
+        author = f.SingleLinkField("Author", Author)
+
+    records = [fake_record(Name=f"Author {n+1}") for n in range(3)]
+    a1, a2, a3 = [r["id"] for r in records]
+
+    # if Airtable sends back multiple IDs, we'll only retrieve the first one.
+    book = Book.from_record(fake_record(Author=[a1, a2, a3]))
+    with mock.patch("pyairtable.Table.all", return_value=records) as m:
+        book.author
+        m.assert_called_once_with(formula=OR(RECORD_ID().eq(records[0]["id"])))
+
+    assert book.author.id == a1
+    assert book.author.name == "Author 1"
+    assert book._fields["Author"][1:] == [a2, a3]  # not converted to models
+
+    # if no modifications made, the entire list will be sent back to the API
+    with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m:
+        book.save()
+        m.assert_called_once_with(book.id, {"Author": [a1, a2, a3]}, typecast=True)
+
+    # if we modify the field value, it will drop items 2-N
+    book.author = Author.from_record(fake_record())
+    with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m:
+        book.save()
+        m.assert_called_once_with(book.id, {"Author": [book.author.id]}, typecast=True)
+
+
+def test_single_link_field__raise_if_many():
+    """
+    Test that passing raise_if_many=True to SingleLinkField will cause an exception
+    to be raised if (1) the field receives multiple values and (2) is accessed.
+    """
+
+    class Author(Model):
+        Meta = fake_meta()
+        name = f.TextField("Name")
+
+    class Book(Model):
+        Meta = fake_meta()
+        author = f.SingleLinkField("Author", Author, raise_if_many=True)
+
+    book = Book.from_record(fake_record(Author=[fake_id(), fake_id()]))
+    with pytest.raises(f.MultipleValues):
+        book.author
+
+
+@pytest.mark.parametrize("field_type", (f.LinkField, f.SingleLinkField))
+def test_link_field__populate(field_type, requests_mock):
+    """
+    Test that implementers can use Model.link_field.populate(instance) to control
+    whether loading happens lazy or non-lazy at runtime.
+    """
+
+    class Linked(Model):
+        Meta = fake_meta()
+        name = f.TextField("Name")
+
+    class T(Model):
+        Meta = fake_meta()
+        link = field_type("Link", Linked)
+
+    links = [fake_record(id=n, Name=f"link{n}") for n in range(1, 4)]
+    link_ids = [link["id"] for link in links]
+    obj = T.from_record(fake_record(Link=link_ids[:]))
+    assert obj._fields.get("Link") == link_ids
+    assert obj._fields.get("Link") is not link_ids
+
+    # calling the record directly will attempt network traffic
+    with pytest.raises(NoMockAddress):
+        obj.link
+
+    # on a non-lazy field, we can still call .populate() to load it lazily
+    T.link.populate(obj, lazy=True)
+
+    if field_type is f.SingleLinkField:
+        assert isinstance(obj.link, Linked)
+        assert obj.link.id == links[0]["id"]
+        assert obj.link.name == ""
+    else:
+        assert isinstance(obj.link[0], Linked)
+        assert link_ids == [link.id for link in obj.link]
+        assert all(link.name == "" for link in obj.link)
+
+    # calling .populate() on the wrong model raises an exception
+    with pytest.raises(RuntimeError):
+        T.link.populate(Linked())
+
+
 def test_lookup_field():
     class T:
         items = f.LookupField("Items")
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index e6e669b..133e6e2 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -2,7 +2,6 @@ from functools import partial
 from unittest import mock
 
 import pytest
-from requests_mock import Mocker
 
 from pyairtable.orm import Model
 from pyairtable.orm import fields as f
@@ -91,12 +90,6 @@ class FakeModel(Model):
     two = f.TextField("two")
 
 
-class FakeModelByIds(Model):
-    Meta = fake_meta(use_field_ids=True, table_name="Apartments")
-    Name = f.TextField("fld1VnoyuotSTyxW1")
-    Age = f.NumberField("fld2VnoyuotSTy4g6")
-
-
 def test_repr():
     record = fake_record()
     assert repr(FakeModel.from_record(record)) == f"<FakeModel id='{record['id']}'>"
@@ -213,48 +206,8 @@ def test_passthrough(methodname):
     with mock.patch(f"pyairtable.Table.{methodname}") as mock_endpoint:
         method = getattr(FakeModel, methodname)
         method(a=1, b=2, c=3)
-    mock_endpoint.assert_called_once_with(
-        a=1,
-        b=2,
-        c=3,
-        return_fields_by_field_id=getattr(FakeModel.Meta, "use_field_ids", False),
-        user_locale=None,
-        time_zone=None,
-        cell_format="json",
-    )
-
-
-@pytest.fixture
-def fake_records_by_id():
-    return {
-        "records": [
-            fake_record(fld1VnoyuotSTyxW1="Alice", fld2VnoyuotSTy4g6=25),
-            fake_record(Name="Jack", Age=30),
-        ]
-    }
-
 
-def test_get_fields_by_id(fake_records_by_id):
-    """
-    Test that we can get fields by their field ID.
-    """
-    with Mocker() as mock:
-        mock.get(
-            f"{FakeModelByIds.get_table().url}?&returnFieldsByFieldId=1&cellFormat=json",
-            json=fake_records_by_id,
-            complete_qs=True,
-            status_code=200,
-        )
-        fake_models = FakeModelByIds.all()
-
-    assert fake_models[0].Name == "Alice"
-    assert fake_models[0].Age == 25
-
-    assert fake_models[1].Name != "Jack"
-    assert fake_models[1].Age != 30
-
-    with pytest.raises(KeyError):
-        _ = getattr(fake_models[1], fake_records_by_id[0]["Age"])
+    mock_endpoint.assert_called_once_with(a=1, b=2, c=3)
 
 
 def test_dynamic_model_meta():
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 6001944..7c49db0 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -79,12 +79,14 @@ if TYPE_CHECKING:
         rating = orm.fields.RatingField("Star Rating")
         prequels = orm.fields.LinkField["Movie"]("Prequels", "path.to.Movie")
         actors = orm.fields.LinkField("Actors", Actor)
+        prequel = orm.fields.SingleLinkField["Movie"]("Prequels", orm.fields.LinkSelf)
 
     movie = Movie()
     assert_type(movie.name, str)
     assert_type(movie.rating, Optional[int])
     assert_type(movie.actors, List[Actor])
     assert_type(movie.prequels, List[Movie])
+    assert_type(movie.prequel, Optional[Movie])
     assert_type(movie.actors[0].name, str)
 
     class EveryField(orm.Model):