Repository: gtalarico/pyairtable
Pull request #363 (2024-04-05 15:37:14)

Add `orm.fields.RequiredTextField`, etc. for fields which cannot be null
We have several use cases where we want to
1. ensure we do not store a null value to Airtable,
2. squawk loudly if a field value in Airtable happens to be null, and
3. assume a non-null value for type checking.

Our code winds up being peppered with "if value is None, do something, else ..." conditions, and it would be easier if we could declare that as a type checked property of the field. This branch adds support for that.

For example, given this code:

```python
from pyairtable.orm import Model, fields as F

class User(Model):
    class Meta:
        ...
    user_id = F.RequiredNumberField("ID")
```

The following will all raise an exception:

```python
>>> User(user_id=None)
Traceback (most recent call last):
  ...
MissingValue: User.user_id does not accept empty values

>>> r = User.from_record(fake_record(ID=123))
>>> r.user_id
123
>>> r.user_id = None
Traceback (most recent call last):
  ...
MissingValue: User.user_id does not accept empty values

>>> r = User.from_record(fake_record(ID=None))
>>> r.user_id
Traceback (most recent call last):
  ...
MissingValue: User.user_id received an empty value
```

It would have been simpler to implement this as a flag on the field's constructor, but with that approach I couldn't find a way to get mypy to understand that the type of `r.user_id` is `int` and not `Optional[int]`.


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4729eae..950dcf7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,7 +7,7 @@ repos:
         name: cog
         language: python
         additional_dependencies: [cogapp]
-        entry: python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]"
+        entry: 'python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]"'
         files: \.py$
 -   repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v4.5.0
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 13d93b3..42e9cda 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -5,24 +5,26 @@ Changelog
 3.0 (TBD)
 ------------------------
 
-* Rewrite of :mod:`pyairtable.formulas` module.
+* Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`.
   - `PR #329 <https://github.com/gtalarico/pyairtable/pull/329>`_.
-* ORM fields :class:`~pyairtable.orm.fields.TextField` and
-  :class:`~pyairtable.orm.fields.CheckboxField` will no longer
-  return ``None`` when the field is empty.
+* :class:`~pyairtable.orm.fields.TextField` and
+  :class:`~pyairtable.orm.fields.CheckboxField` return ``""``
+  or ``False`` instead of ``None``.
   - `PR #347 <https://github.com/gtalarico/pyairtable/pull/347>`_.
 * Changed the type of :data:`~pyairtable.orm.Model.created_time`
   from ``str`` to ``datetime``, along with all other timestamp fields
   used in :ref:`API: pyairtable.models`.
   - `PR #352 <https://github.com/gtalarico/pyairtable/pull/352>`_.
 * Added ORM field type :class:`~pyairtable.orm.fields.SingleLinkField`
-  for record link fields that (generally) link to a single record.
+  for record links that should only contain one record.
   - `PR #354 <https://github.com/gtalarico/pyairtable/pull/354>`_.
-* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``.
 * Support ``use_field_ids`` in the :ref:`ORM`.
   - `PR #355 <https://github.com/gtalarico/pyairtable/pull/355>`_.
 * Removed the ``pyairtable.metadata`` module.
   - `PR #360 <https://github.com/gtalarico/pyairtable/pull/360>`_.
+* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``.
+  - `PR #362 <https://github.com/gtalarico/pyairtable/pull/362>`_.
+* Added ORM fields that :ref:`require a non-null value <Required Values>`.
 
 2.3.2 (2024-03-18)
 ------------------------
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 56b494a..c2313a7 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -109,17 +109,31 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
     from operator import attrgetter
     from pyairtable.orm import fields
 
-    cog.outl("..")
-    cog.outl(".. list-table::")
-    cog.outl("   :header-rows: 1\n")
-    cog.outl("   * - ORM field class")
-    cog.outl("     - Airtable field type(s)")
-
-    for cls in sorted(fields.ALL_FIELDS, key=attrgetter("__name__")):
-        links = re.findall(r"`.+? <.*?field-model.*?>`", cls.__doc__ or "")
-        ro = ' 🔒' if cls.readonly else ''
-        cog.outl(f"   * - :class:`~pyairtable.orm.fields.{cls.__name__}`{ro}")
-        cog.outl(f"     - {', '.join(f'{link}__' for link in links) if links else '(see docs)'}")
+    def cog_class_table(classes):
+        cog.outl(".. list-table::")
+        cog.outl("   :header-rows: 1\n")
+        cog.outl("   * - ORM field class")
+        cog.outl("     - Airtable field type(s)")
+        for cls in classes:
+            links = re.findall(r"`.+? <.*?field-model.*?>`", cls.__doc__ or "")
+            ro = ' 🔒' if cls.readonly else ''
+            cog.outl(f"   * - :class:`~pyairtable.orm.fields.{cls.__name__}`{ro}")
+            cog.outl(f"     - {', '.join(f'{link}__' for link in links) if links else '(see docs)'}")
+
+    classes = sorted(fields.ALL_FIELDS, key=attrgetter("__name__"))
+    optional = [cls for cls in classes if not cls.__name__.startswith("Required")]
+    required = [cls for cls in classes if cls.__name__.startswith("Required")]
+
+    cog.outl("..")  # terminate the comment block
+    cog_class_table(optional)
+    cog.outl("")
+    cog.outl("Airtable does not have a concept of fields that require values,")
+    cog.outl("but pyAirtable allows you to enforce that concept within code")
+    cog.outl("using one of the following field classes.")
+    cog.outl("")
+    cog.outl("See :ref:`Required Values` for more details.")
+    cog.outl("")
+    cog_class_table(required)
     ]]]
 ..
 .. list-table::
@@ -193,7 +207,57 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
      - `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: afd0edeabb06937f2a3afd73a7bac32e)
+
+Airtable does not have a concept of fields that require values,
+but pyAirtable allows you to enforce that concept within code
+using one of the following field classes.
+
+See :ref:`Required Values` for more details.
+
+.. list-table::
+   :header-rows: 1
+
+   * - ORM field class
+     - Airtable field type(s)
+   * - :class:`~pyairtable.orm.fields.RequiredAITextField` 🔒
+     - `AI Text <https://airtable.com/developers/web/api/field-model#aitext>`__
+   * - :class:`~pyairtable.orm.fields.RequiredBarcodeField`
+     - `Barcode <https://airtable.com/developers/web/api/field-model#barcode>`__
+   * - :class:`~pyairtable.orm.fields.RequiredCollaboratorField`
+     - `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`__
+   * - :class:`~pyairtable.orm.fields.RequiredCountField` 🔒
+     - `Count <https://airtable.com/developers/web/api/field-model#count>`__
+   * - :class:`~pyairtable.orm.fields.RequiredCurrencyField`
+     - `Currency <https://airtable.com/developers/web/api/field-model#currencynumber>`__
+   * - :class:`~pyairtable.orm.fields.RequiredDateField`
+     - `Date <https://airtable.com/developers/web/api/field-model#dateonly>`__
+   * - :class:`~pyairtable.orm.fields.RequiredDatetimeField`
+     - `Date and time <https://airtable.com/developers/web/api/field-model#dateandtime>`__
+   * - :class:`~pyairtable.orm.fields.RequiredDurationField`
+     - `Duration <https://airtable.com/developers/web/api/field-model#durationnumber>`__
+   * - :class:`~pyairtable.orm.fields.RequiredEmailField`
+     - `Email <https://airtable.com/developers/web/api/field-model#email>`__
+   * - :class:`~pyairtable.orm.fields.RequiredFloatField`
+     - `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__
+   * - :class:`~pyairtable.orm.fields.RequiredIntegerField`
+     - `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__
+   * - :class:`~pyairtable.orm.fields.RequiredNumberField`
+     - `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__
+   * - :class:`~pyairtable.orm.fields.RequiredPercentField`
+     - `Percent <https://airtable.com/developers/web/api/field-model#percentnumber>`__
+   * - :class:`~pyairtable.orm.fields.RequiredPhoneNumberField`
+     - `Phone <https://airtable.com/developers/web/api/field-model#phone>`__
+   * - :class:`~pyairtable.orm.fields.RequiredRatingField`
+     - `Rating <https://airtable.com/developers/web/api/field-model#rating>`__
+   * - :class:`~pyairtable.orm.fields.RequiredRichTextField`
+     - `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__
+   * - :class:`~pyairtable.orm.fields.RequiredSelectField`
+     - `Single select <https://airtable.com/developers/web/api/field-model#select>`__
+   * - :class:`~pyairtable.orm.fields.RequiredTextField`
+     - `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.RequiredUrlField`
+     - `Url <https://airtable.com/developers/web/api/field-model#urltext>`__
+.. [[[end]]] (checksum: 131138e1071ba71d4f46f05da4d57570)
 
 
 Formula, Rollup, and Lookup Fields
@@ -247,6 +311,57 @@ You can check for errors using the :func:`~pyairtable.api.types.is_airtable_erro
   True
 
 
+Required Values
+---------------
+
+Airtable does not generally have a concept of fields that require values, but
+pyAirtable allows you to enforce that a field must have a value before saving it.
+To do this, use one of the "Required" field types, which will raise an exception
+if either of the following occur:
+
+  1. If you try to set its value to ``None`` (or, sometimes, to the empty string).
+  2. If the API returns a ``None`` (or empty string) as the field's value.
+
+For example, given this code:
+
+.. code-block:: python
+
+    from pyairtable.orm import Model, fields as F
+
+    class MyTable(Model):
+        class Meta:
+            ...
+
+        name = F.RequiredTextField("Name")
+
+The following will all raise an exception:
+
+.. code-block:: python
+
+    >>> MyTable(name=None)
+    Traceback (most recent call last):
+      ...
+    MissingValue: MyTable.name does not accept empty values
+
+    >>> r = MyTable.from_record(fake_record(Name="Alice"))
+    >>> r.name
+    'Alice'
+    >>> r.name = None
+    Traceback (most recent call last):
+      ...
+    MissingValue: MyTable.name does not accept empty values
+
+    >>> r = MyTable.from_record(fake_record(Name=None))
+    >>> r.name
+    Traceback (most recent call last):
+      ...
+    MissingValue: MyTable.name received an empty value
+
+One reason to use these fields (sparingly!) might be to avoid adding defensive
+null-handling checks all over your code, if you are confident that the workflows
+around your Airtable base will not produce an empty value (or that an empty value
+is enough of a problem that your code should raise an exception).
+
 Linked Records
 ----------------
 
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 2e0744e..ef70405 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -89,6 +89,9 @@ class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta):
     #: Types that are allowed to be passed to this field.
     valid_types: ClassVar[_ClassInfo] = ()
 
+    #: The value to return when the field is missing
+    missing_value: ClassVar[Any] = None
+
     #: Whether to allow modification of the value in this field.
     readonly: bool = False
 
@@ -163,9 +166,12 @@ class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta):
         if not instance:
             return self
         try:
-            return cast(T_ORM, instance._fields[self.field_name])
+            value = instance._fields[self.field_name]
         except (KeyError, AttributeError):
-            return self._missing_value()
+            return cast(T_Missing, self.missing_value)
+        if value is None:
+            return cast(T_Missing, self.missing_value)
+        return cast(T_ORM, value)
 
     def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None:
         self._raise_if_readonly()
@@ -178,13 +184,6 @@ class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta):
     def __delete__(self, instance: "Model") -> None:
         raise AttributeError(f"cannot delete {self._description}")
 
-    @classmethod
-    def _missing_value(cls) -> T_Missing:
-        # This assumes Field[T_API, T_ORM, None]. If a subclass defines T_Missing as
-        # something different, it needs to override _missing_value.
-        # This can be tidied in 3.13 with T_Missing(default=None). See PEP-696.
-        return cast(T_Missing, None)
-
     def to_record_value(self, value: Any) -> Any:
         """
         Calculate the value which should be persisted to the API.
@@ -258,33 +257,53 @@ class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta):
         return formulas.LTE(self, value)
 
 
-class _FieldWithTypedDefaultValue(Generic[T], Field[T, T, T]):
+class _FieldWithRequiredValue(Generic[T_API, T_ORM], Field[T_API, T_ORM, T_ORM]):
     """
-    A generic Field with default value of the same type as internal and API representations.
+    A mix-in for a Field class which indicates two things:
+
+    1. It should never receive a null value from the Airtable API.
+    2. It should never allow other code to set it as None (or the empty string).
 
-    For now this is used for TextField and CheckboxField, because Airtable stores the empty
-    values for those types ("" and False) internally as None.
+    If either of those conditions occur, the field will raise an exception.
     """
 
-    @classmethod
-    def _missing_value(cls) -> T:
-        first_type = cls.valid_types
-        while isinstance(first_type, tuple):
-            if not first_type:
-                raise RuntimeError(f"{cls.__qualname__}.valid_types is malformed")
-            first_type = first_type[0]
-        return cast(T, first_type())
+    @overload
+    def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ...
+
+    @overload
+    def __get__(self, instance: "Model", owner: Type[Any]) -> T_ORM: ...
+
+    def __get__(
+        self, instance: Optional["Model"], owner: Type[Any]
+    ) -> Union[SelfType, T_ORM]:
+        value = super().__get__(instance, owner)
+        if value is None or value == "":
+            raise MissingValue(f"{self._description} received an empty value")
+        return value
+
+    def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None:
+        if value in (None, ""):
+            raise MissingValue(f"{self._description} does not accept empty values")
+        super().__set__(instance, value)
+
+
+class MissingValue(ValueError):
+    """
+    A required field received an empty value, either from Airtable or other code.
+    """
 
 
 #: A generic Field with internal and API representations that are the same type.
 _BasicField: TypeAlias = Field[T, T, None]
+_BasicFieldWithMissingValue: TypeAlias = Field[T, T, T]
+_BasicFieldWithRequiredValue: TypeAlias = _FieldWithRequiredValue[T, T]
 
 
 #: An alias for any type of Field.
 AnyField: TypeAlias = Field[Any, Any, Any]
 
 
-class TextField(_FieldWithTypedDefaultValue[str]):
+class TextField(_BasicFieldWithMissingValue[str]):
     """
     Accepts ``str``.
     Returns ``""`` instead of ``None`` if the field is empty on the Airtable base.
@@ -293,6 +312,7 @@ class TextField(_FieldWithTypedDefaultValue[str]):
     and `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__.
     """
 
+    missing_value = ""
     valid_types = str
 
 
@@ -357,7 +377,7 @@ class RatingField(IntegerField):
             raise ValueError("rating cannot be below 1")
 
 
-class CheckboxField(_FieldWithTypedDefaultValue[bool]):
+class CheckboxField(_BasicFieldWithMissingValue[bool]):
     """
     Accepts ``bool``.
     Returns ``False`` instead of ``None`` if the field is empty on the Airtable base.
@@ -365,6 +385,7 @@ class CheckboxField(_FieldWithTypedDefaultValue[bool]):
     See `Checkbox <https://airtable.com/developers/web/api/field-model#checkbox>`__.
     """
 
+    missing_value = False
     valid_types = bool
 
 
@@ -799,7 +820,7 @@ class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]):
         try:
             return links[0]
         except IndexError:
-            return self._missing_value()
+            return None
 
     def __set__(self, instance: "Model", value: Optional[T_Linked]) -> None:
         values = None if value is None else [value]
@@ -853,16 +874,6 @@ class AttachmentsField(_ValidatingListField[AttachmentDict]):
     contains_type = cast(Type[AttachmentDict], dict)
 
 
-class AutoNumberField(IntegerField):
-    """
-    Equivalent to :class:`IntegerField(readonly=True) <IntegerField>`.
-
-    See `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__.
-    """
-
-    readonly = True
-
-
 class BarcodeField(_DictField[BarcodeDict]):
     """
     Accepts a `dict` that should conform to the format detailed in the
@@ -871,16 +882,6 @@ class BarcodeField(_DictField[BarcodeDict]):
     """
 
 
-class ButtonField(_DictField[ButtonDict]):
-    """
-    Read-only field that returns a `dict`. For more information, read the
-    `Button <https://airtable.com/developers/web/api/field-model#button>`_
-    documentation.
-    """
-
-    readonly = True
-
-
 class CollaboratorField(_DictField[CollaboratorDict]):
     """
     Accepts a `dict` that should conform to the format detailed in the
@@ -899,26 +900,6 @@ class CountField(IntegerField):
     readonly = True
 
 
-class CreatedByField(CollaboratorField):
-    """
-    Equivalent to :class:`CollaboratorField(readonly=True) <CollaboratorField>`.
-
-    See `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__.
-    """
-
-    readonly = True
-
-
-class CreatedTimeField(DatetimeField):
-    """
-    Equivalent to :class:`DatetimeField(readonly=True) <DatetimeField>`.
-
-    See `Created time <https://airtable.com/developers/web/api/field-model#createdtime>`__.
-    """
-
-    readonly = True
-
-
 class CurrencyField(NumberField):
     """
     Equivalent to :class:`~NumberField`.
@@ -1049,6 +1030,348 @@ class UrlField(TextField):
     """
 
 
+# Auto-generate Required*Field classes for anything above this line
+# fmt: off
+r"""[[[cog]]]
+import re
+from collections import namedtuple
+
+with open(cog.inFile) as fp:
+    src = "".join(fp.readlines()[:cog.firstLineNum])
+
+Match = namedtuple('Match', 'cls generic bases annotation cls_kwargs doc readonly')
+expr = (
+    r'(?m)'
+    r'^class ([A-Z]\w+Field)'
+        r'\('
+            # This particular regex will not pick up Field subclasses that have
+            # multiple inheritance, which excludes anything using _NotNullField.
+            r'(?:(Generic\[.+?\]), )?'
+            r'([_A-Z][_A-Za-z]+)(?:\[(.+?)\])?'
+            r'((?:, [a-z_]+=.+)+)?'
+        r'\):\n'
+    r'    \"\"\"\n    ((?:.|\n)+?)    \"\"\"(?:\n|    (?!readonly =).*)*'
+    r'(    readonly = True)?'
+)
+classes = {
+    match.cls: match
+    for group in re.findall(expr, src)
+    if (match := Match(*group))
+}
+
+for cls, match in sorted(classes.items()):
+    if cls in {
+        # checkbox values are either `True` or missing
+        "CheckboxField",
+
+        # null value will be converted to an empty list
+        "AttachmentsField",
+        "LinkField",
+        "LookupField",
+        "MultipleCollaboratorsField",
+        "MultipleSelectField",
+
+        # unsupported
+        "SingleLinkField",
+
+        # illogical
+        "LastModifiedByField",
+        "LastModifiedTimeField",
+        "ExternalSyncSourceField",
+    }:
+        continue
+
+    # skip if we've already included Required
+    if cls.startswith("Required") or "Required" in match.bases:
+        continue
+
+    base, typn = match.cls, match.annotation
+    typn_bases = match.bases
+    while not typn:
+        typn = classes[typn_bases].annotation
+        typn_bases = classes[typn_bases].bases
+
+    if typn.endswith(", None"):
+        typn = typn[:-len(", None")]
+
+    mixin = ("_" if re.match(r"^[A-Za-z_.]+(\[\w+(, \w+)*\])?,", typn) else "_Basic") + "FieldWithRequiredValue"
+    base = base if not match.generic else f"{base}, {match.generic}"
+    cog.outl(f"\n\nclass Required{cls}({base}, {mixin}[{typn}]):")
+    cog.outl('    \"\"\"')
+    cog.outl('    ' + match.doc)
+    cog.out( '    If the Airtable API returns ``null``, ')
+    if not match.readonly:
+        cog.out('or if a caller sets this field to ``None``,\n    ')
+    cog.outl('this field raises :class:`~pyairtable.orm.fields.MissingValue`.')
+    cog.outl('    \"\"\"')
+
+cog.outl('\n')
+[[[out]]]"""
+
+
+class RequiredAITextField(AITextField, _BasicFieldWithRequiredValue[AITextDict]):
+    """
+    Read-only field that returns a `dict`. For more information, read the
+    `AI Text <https://airtable.com/developers/web/api/field-model#aitext>`_
+    documentation.
+
+    If the Airtable API returns ``null``, this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredBarcodeField(BarcodeField, _BasicFieldWithRequiredValue[BarcodeDict]):
+    """
+    Accepts a `dict` that should conform to the format detailed in the
+    `Barcode <https://airtable.com/developers/web/api/field-model#barcode>`_
+    documentation.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredCollaboratorField(CollaboratorField, _BasicFieldWithRequiredValue[CollaboratorDict]):
+    """
+    Accepts a `dict` that should conform to the format detailed in the
+    `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`_
+    documentation.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredCountField(CountField, _BasicFieldWithRequiredValue[int]):
+    """
+    Equivalent to :class:`IntegerField(readonly=True) <IntegerField>`.
+
+    See `Count <https://airtable.com/developers/web/api/field-model#count>`__.
+
+    If the Airtable API returns ``null``, this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredCurrencyField(CurrencyField, _BasicFieldWithRequiredValue[Union[int, float]]):
+    """
+    Equivalent to :class:`~NumberField`.
+
+    See `Currency <https://airtable.com/developers/web/api/field-model#currencynumber>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredDateField(DateField, _FieldWithRequiredValue[str, date]):
+    """
+    Date field. Accepts only `date <https://docs.python.org/3/library/datetime.html#date-objects>`_ values.
+
+    See `Date <https://airtable.com/developers/web/api/field-model#dateonly>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredDatetimeField(DatetimeField, _FieldWithRequiredValue[str, datetime]):
+    """
+    DateTime field. Accepts only `datetime <https://docs.python.org/3/library/datetime.html#datetime-objects>`_ values.
+
+    See `Date and time <https://airtable.com/developers/web/api/field-model#dateandtime>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredDurationField(DurationField, _FieldWithRequiredValue[int, timedelta]):
+    """
+    Duration field. Accepts only `timedelta <https://docs.python.org/3/library/datetime.html#timedelta-objects>`_ values.
+
+    See `Duration <https://airtable.com/developers/web/api/field-model#durationnumber>`__.
+    Airtable's API returns this as a number of seconds.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredEmailField(EmailField, _BasicFieldWithRequiredValue[str]):
+    """
+    Equivalent to :class:`~TextField`.
+
+    See `Email <https://airtable.com/developers/web/api/field-model#email>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredFloatField(FloatField, _BasicFieldWithRequiredValue[float]):
+    """
+    Number field with decimal precision. Accepts only ``float`` values.
+
+    See `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredIntegerField(IntegerField, _BasicFieldWithRequiredValue[int]):
+    """
+    Number field with integer precision. Accepts only ``int`` values.
+
+    See `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredNumberField(NumberField, _BasicFieldWithRequiredValue[Union[int, float]]):
+    """
+    Number field with unspecified precision. Accepts either ``int`` or ``float``.
+
+    See `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredPercentField(PercentField, _BasicFieldWithRequiredValue[Union[int, float]]):
+    """
+    Equivalent to :class:`~NumberField`.
+
+    See `Percent <https://airtable.com/developers/web/api/field-model#percentnumber>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredPhoneNumberField(PhoneNumberField, _BasicFieldWithRequiredValue[str]):
+    """
+    Equivalent to :class:`~TextField`.
+
+    See `Phone <https://airtable.com/developers/web/api/field-model#phone>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredRatingField(RatingField, _BasicFieldWithRequiredValue[int]):
+    """
+    Accepts ``int`` values that are greater than zero.
+
+    See `Rating <https://airtable.com/developers/web/api/field-model#rating>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredRichTextField(RichTextField, _BasicFieldWithRequiredValue[str]):
+    """
+    Equivalent to :class:`~TextField`.
+
+    See `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredSelectField(SelectField, _FieldWithRequiredValue[str, str]):
+    """
+    Represents a single select dropdown field. This will return ``None`` if no value is set,
+    and will only return ``""`` if an empty dropdown option is available and selected.
+
+    See `Single select <https://airtable.com/developers/web/api/field-model#select>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredTextField(TextField, _BasicFieldWithRequiredValue[str]):
+    """
+    Accepts ``str``.
+    Returns ``""`` instead of ``None`` if the field is empty on the Airtable base.
+
+    See `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__
+    and `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+class RequiredUrlField(UrlField, _BasicFieldWithRequiredValue[str]):
+    """
+    Equivalent to :class:`~TextField`.
+
+    See `Url <https://airtable.com/developers/web/api/field-model#urltext>`__.
+
+    If the Airtable API returns ``null``, or if a caller sets this field to ``None``,
+    this field raises :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+
+# [[[end]]] (checksum: 84b5c48286d992737e12318a72e4e123)
+# fmt: on
+
+
+class AutoNumberField(RequiredIntegerField):
+    """
+    Equivalent to :class:`IntegerField(readonly=True) <IntegerField>`.
+
+    See `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__.
+
+    If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+    readonly = True
+
+
+class ButtonField(_DictField[ButtonDict], _BasicFieldWithRequiredValue[ButtonDict]):
+    """
+    Read-only field that returns a `dict`. For more information, read the
+    `Button <https://airtable.com/developers/web/api/field-model#button>`_
+    documentation.
+
+    If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+    readonly = True
+
+
+class CreatedByField(RequiredCollaboratorField):
+    """
+    Equivalent to :class:`CollaboratorField(readonly=True) <CollaboratorField>`.
+
+    See `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__.
+
+    If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+    readonly = True
+
+
+class CreatedTimeField(RequiredDatetimeField):
+    """
+    Equivalent to :class:`DatetimeField(readonly=True) <DatetimeField>`.
+
+    See `Created time <https://airtable.com/developers/web/api/field-model#createdtime>`__.
+
+    If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`.
+    """
+
+    readonly = True
+
+
 #: Set of all Field subclasses exposed by the library.
 #:
 #: :meta hide-value:
@@ -1175,6 +1498,25 @@ __all__ = [
     "PercentField",
     "PhoneNumberField",
     "RatingField",
+    "RequiredAITextField",
+    "RequiredBarcodeField",
+    "RequiredCollaboratorField",
+    "RequiredCountField",
+    "RequiredCurrencyField",
+    "RequiredDateField",
+    "RequiredDatetimeField",
+    "RequiredDurationField",
+    "RequiredEmailField",
+    "RequiredFloatField",
+    "RequiredIntegerField",
+    "RequiredNumberField",
+    "RequiredPercentField",
+    "RequiredPhoneNumberField",
+    "RequiredRatingField",
+    "RequiredRichTextField",
+    "RequiredSelectField",
+    "RequiredTextField",
+    "RequiredUrlField",
     "RichTextField",
     "SelectField",
     "SingleLinkField",
@@ -1186,7 +1528,7 @@ __all__ = [
     "FIELD_CLASSES_TO_TYPES",
     "LinkSelf",
 ]
-# [[[end]]] (checksum: 314db7bbb782c156d620305a1c42dfef)
+# [[[end]]] (checksum: 21316c688401f32f59d597c496d48bf3)
 
 
 # Delayed import to avoid circular dependency


Test patch:
diff --git a/tests/test_orm.py b/tests/test_orm.py
index 60178fc..1a4e2ca 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -86,7 +86,7 @@ def test_null_fields():
     """
     a = Address(number=None, street=None)
     assert a.number is None
-    assert a.street is None
+    assert a.street == ""
 
 
 def test_first():
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 1451664..76aeeea 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -95,6 +95,7 @@ def test_repr(instance, expected):
     argvalues=[
         (f.Field, None),
         (f.CheckboxField, False),
+        (f.TextField, ""),
         (f.LookupField, []),
         (f.AttachmentsField, []),
         (f.MultipleCollaboratorsField, []),
@@ -114,10 +115,16 @@ def test_orm_missing_values(field_type, default_value):
     t = T()
     assert t.the_field == default_value
 
+    t = T.from_record(fake_record({"Field Name": None}))
+    assert t.the_field == default_value
+
 
 # Mapping from types to a test value for that type.
 TYPE_VALIDATION_TEST_VALUES = {
-    **{t: t() for t in (str, bool, list, dict)},
+    str: "some value",
+    bool: False,
+    list: [],
+    dict: {},
     int: 1,  # cannot use int() because RatingField requires value >= 1
     float: 1.0,  # cannot use float() because RatingField requires value >= 1
     datetime.date: datetime.date.today(),
@@ -130,28 +137,45 @@ TYPE_VALIDATION_TEST_VALUES = {
     "test_case",
     [
         (f.Field, tuple(TYPE_VALIDATION_TEST_VALUES)),
-        (f.TextField, str),
-        (f.IntegerField, int),
-        (f.RichTextField, str),
-        (f.DatetimeField, datetime.datetime),
-        (f.TextField, str),
-        (f.CheckboxField, bool),
+        (f.AttachmentsField, list),
         (f.BarcodeField, dict),
-        (f.NumberField, (int, float)),
-        (f.PhoneNumberField, str),
+        (f.CheckboxField, bool),
+        (f.CollaboratorField, dict),
+        (f.CurrencyField, (int, float)),
+        (f.DateField, (datetime.date, datetime.datetime)),
+        (f.DatetimeField, datetime.datetime),
         (f.DurationField, datetime.timedelta),
-        (f.RatingField, int),
-        (f.UrlField, str),
+        (f.EmailField, str),
+        (f.FloatField, float),
+        (f.IntegerField, int),
+        (f.MultipleCollaboratorsField, list),
         (f.MultipleSelectField, list),
+        (f.NumberField, (int, float)),
         (f.PercentField, (int, float)),
-        (f.DateField, (datetime.date, datetime.datetime)),
-        (f.FloatField, float),
-        (f.CollaboratorField, dict),
+        (f.PhoneNumberField, str),
+        (f.RatingField, int),
+        (f.RichTextField, str),
         (f.SelectField, str),
-        (f.EmailField, str),
-        (f.AttachmentsField, list),
-        (f.MultipleCollaboratorsField, list),
-        (f.CurrencyField, (int, float)),
+        (f.TextField, str),
+        (f.TextField, str),
+        (f.UrlField, str),
+        (f.RequiredBarcodeField, dict),
+        (f.RequiredCollaboratorField, dict),
+        (f.RequiredCurrencyField, (int, float)),
+        (f.RequiredDateField, (datetime.date, datetime.datetime)),
+        (f.RequiredDatetimeField, datetime.datetime),
+        (f.RequiredDurationField, datetime.timedelta),
+        (f.RequiredFloatField, float),
+        (f.RequiredIntegerField, int),
+        (f.RequiredNumberField, (int, float)),
+        (f.RequiredPercentField, (int, float)),
+        (f.RequiredRatingField, int),
+        (f.RequiredSelectField, str),
+        (f.RequiredEmailField, str),
+        (f.RequiredPhoneNumberField, str),
+        (f.RequiredRichTextField, str),
+        (f.RequiredTextField, str),
+        (f.RequiredUrlField, str),
     ],
     ids=operator.itemgetter(0),
 )
@@ -242,6 +266,9 @@ def test_type_validation_LinkField():
         # If a 3-tuple, we should be able to convert API -> ORM values.
         (f.CreatedTimeField, DATETIME_S, DATETIME_V),
         (f.LastModifiedTimeField, DATETIME_S, DATETIME_V),
+        # We also want to test the not-null versions of these fields
+        (f.RequiredAITextField, {"state": "empty", "isStale": True, "value": None}),
+        (f.RequiredCountField, 1),
     ],
     ids=operator.itemgetter(0),
 )
@@ -290,12 +317,31 @@ def test_readonly_fields(test_case):
         (f.PercentField, 0.5),
         (f.PhoneNumberField, "+49 40-349180"),
         (f.RichTextField, "Check out [Airtable](www.airtable.com)"),
+        (f.SelectField, ""),
         (f.SelectField, "any value"),
         (f.UrlField, "www.airtable.com"),
+        (f.RequiredNumberField, 1),
+        (f.RequiredNumberField, 1.5),
+        (f.RequiredIntegerField, 1),
+        (f.RequiredFloatField, 1.5),
+        (f.RequiredRatingField, 1),
+        (f.RequiredCurrencyField, 1.05),
+        (f.RequiredCollaboratorField, {"id": "usrFakeUserId", "email": "x@y.com"}),
+        (f.RequiredBarcodeField, {"type": "upce", "text": "084114125538"}),
+        (f.RequiredPercentField, 0.5),
+        (f.RequiredSelectField, "any value"),
+        (f.RequiredEmailField, "any value"),
+        (f.RequiredPhoneNumberField, "any value"),
+        (f.RequiredRichTextField, "any value"),
+        (f.RequiredTextField, "any value"),
+        (f.RequiredUrlField, "any value"),
         # If a 3-tuple, we should be able to convert API -> ORM values.
         (f.DateField, DATE_S, DATE_V),
-        (f.DurationField, 100.5, datetime.timedelta(seconds=100, microseconds=500000)),
         (f.DatetimeField, DATETIME_S, DATETIME_V),
+        (f.DurationField, 100.5, datetime.timedelta(seconds=100, microseconds=500000)),
+        (f.RequiredDateField, DATE_S, DATE_V),
+        (f.RequiredDatetimeField, DATETIME_S, DATETIME_V),
+        (f.RequiredDurationField, 100, datetime.timedelta(seconds=100)),
     ],
     ids=operator.itemgetter(0),
 )
@@ -327,20 +373,120 @@ def test_writable_fields(test_case):
     assert existing_obj.the_field == orm_value
 
 
+@pytest.mark.parametrize(
+    "field_type",
+    [
+        f.Field,
+        f.AITextField,
+        f.AttachmentsField,
+        f.BarcodeField,
+        f.CheckboxField,
+        f.CollaboratorField,
+        f.CountField,
+        f.CurrencyField,
+        f.DateField,
+        f.DatetimeField,
+        f.DurationField,
+        f.EmailField,
+        f.ExternalSyncSourceField,
+        f.FloatField,
+        f.IntegerField,
+        f.LastModifiedByField,
+        f.LastModifiedTimeField,
+        f.LookupField,
+        f.MultipleCollaboratorsField,
+        f.MultipleSelectField,
+        f.NumberField,
+        f.NumberField,
+        f.PercentField,
+        f.PhoneNumberField,
+        f.RatingField,
+        f.RichTextField,
+        f.SelectField,
+        f.TextField,
+        f.UrlField,
+    ],
+)
+def test_accepts_null(field_type):
+    """
+    Test field types that allow null values from Airtable.
+    """
+
+    class T(Model):
+        Meta = fake_meta()
+        the_field = field_type("Field Name")
+
+    obj = T()
+    assert not obj.the_field
+
+
+@pytest.mark.parametrize(
+    "field_type",
+    [
+        f.AutoNumberField,
+        f.ButtonField,
+        f.CreatedByField,
+        f.CreatedTimeField,
+        f.RequiredAITextField,
+        f.RequiredBarcodeField,
+        f.RequiredCollaboratorField,
+        f.RequiredCountField,
+        f.RequiredCurrencyField,
+        f.RequiredDateField,
+        f.RequiredDatetimeField,
+        f.RequiredDurationField,
+        f.RequiredEmailField,
+        f.RequiredFloatField,
+        f.RequiredIntegerField,
+        f.RequiredNumberField,
+        f.RequiredPercentField,
+        f.RequiredPhoneNumberField,
+        f.RequiredRatingField,
+        f.RequiredRichTextField,
+        f.RequiredSelectField,
+        f.RequiredTextField,
+        f.RequiredUrlField,
+    ],
+)
+def test_rejects_null(field_type):
+    """
+    Test field types that do not allow null values from Airtable.
+    """
+
+    class T(Model):
+        Meta = fake_meta()
+        the_field = field_type("Field Name")
+
+    obj = T()
+    with pytest.raises(f.MissingValue):
+        obj.the_field
+    with pytest.raises(f.MissingValue):
+        obj.the_field = None
+    with pytest.raises(f.MissingValue):
+        T(the_field=None)
+
+
 def test_completeness():
     """
     Ensure that we test conversion of all readonly and writable fields.
     """
-    assert_all_fields_tested_by(test_writable_fields, test_readonly_fields)
+    assert_all_fields_tested_by(
+        test_writable_fields,
+        test_readonly_fields,
+        exclude=(f.LinkField, f.SingleLinkField),
+    )
     assert_all_fields_tested_by(
         test_type_validation,
         exclude=f.READONLY_FIELDS | {f.LinkField, f.SingleLinkField},
     )
+    assert_all_fields_tested_by(
+        test_accepts_null,
+        test_rejects_null,
+        exclude={f.LinkField, f.SingleLinkField},
+    )
 
 
-def assert_all_fields_tested_by(
-    *test_fns, exclude=(f.Field, f.LinkField, f.SingleLinkField)
-):
+def assert_all_fields_tested_by(*test_fns, exclude=()):
     """
     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
@@ -842,54 +988,6 @@ def test_datetime_timezones(requests_mock):
     assert m.last_request.json()["fields"]["dt"] == "2024-03-01T11:22:33.000"
 
 
-@pytest.mark.parametrize(
-    "classinfo,expected",
-    [
-        (str, ""),
-        ((str, bool), ""),
-        ((((str,),),), ""),
-        (bool, False),
-    ],
-)
-def test_missing_value(classinfo, expected):
-    """
-    Test that _FieldWithTypedDefaultValue._missing_value finds the first
-    valid type and calls it to create the "missing from Airtable" value.
-    """
-
-    class F(f._FieldWithTypedDefaultValue):
-        valid_types = classinfo
-
-    class T:
-        the_field = F("Field Name")
-
-    assert T().the_field == expected
-
-
-@pytest.mark.parametrize(
-    "classinfo,exc_class",
-    [
-        ((), RuntimeError),
-        ((((), str), bool), RuntimeError),
-    ],
-)
-def test_missing_value__invalid_classinfo(classinfo, exc_class):
-    """
-    Test that _FieldWithTypedDefaultValue._missing_value raises an exception
-    if the class's valid_types is set to an invalid value.
-    """
-
-    class F(f._FieldWithTypedDefaultValue):
-        valid_types = classinfo
-
-    class T:
-        the_field = F("Field Name")
-
-    obj = T()
-    with pytest.raises(exc_class):
-        obj.the_field
-
-
 @pytest.mark.parametrize(
     "fields,expected",
     [
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 7c49db0..2078d5f 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -118,18 +118,36 @@ if TYPE_CHECKING:
         rich_text = orm.fields.RichTextField("Notes")
         select = orm.fields.SelectField("Status")
         url = orm.fields.UrlField("URL")
+        required_aitext = orm.fields.RequiredAITextField("AI Generated Text")
+        required_barcode = orm.fields.RequiredBarcodeField("Barcode")
+        required_collaborator = orm.fields.RequiredCollaboratorField("Assignee")
+        required_count = orm.fields.RequiredCountField("Count")
+        required_currency = orm.fields.RequiredCurrencyField("Dollars")
+        required_date = orm.fields.RequiredDateField("Date")
+        required_datetime = orm.fields.RequiredDatetimeField("DateTime")
+        required_duration = orm.fields.RequiredDurationField("Duration (h:mm)")
+        required_email = orm.fields.RequiredEmailField("Email")
+        required_float = orm.fields.RequiredFloatField("Decimal 1")
+        required_integer = orm.fields.RequiredIntegerField("Integer")
+        required_number = orm.fields.RequiredNumberField("Number")
+        required_percent = orm.fields.RequiredPercentField("Percent")
+        required_phone = orm.fields.RequiredPhoneNumberField("Phone")
+        required_rating = orm.fields.RequiredRatingField("Stars")
+        required_rich_text = orm.fields.RequiredRichTextField("Notes")
+        required_select = orm.fields.RequiredSelectField("Status")
+        required_url = orm.fields.RequiredUrlField("URL")
 
     record = EveryField()
     assert_type(record.aitext, Optional[T.AITextDict])
     assert_type(record.attachments, List[T.AttachmentDict])
-    assert_type(record.autonumber, Optional[int])
+    assert_type(record.autonumber, int)
     assert_type(record.barcode, Optional[T.BarcodeDict])
-    assert_type(record.button, Optional[T.ButtonDict])
+    assert_type(record.button, T.ButtonDict)
     assert_type(record.checkbox, bool)
     assert_type(record.collaborator, Optional[T.CollaboratorDict])
     assert_type(record.count, Optional[int])
-    assert_type(record.created_by, Optional[T.CollaboratorDict])
-    assert_type(record.created, Optional[datetime.datetime])
+    assert_type(record.created_by, T.CollaboratorDict)
+    assert_type(record.created, datetime.datetime)
     assert_type(record.currency, Optional[Union[int, float]])
     assert_type(record.date, Optional[datetime.date])
     assert_type(record.datetime, Optional[datetime.datetime])
@@ -148,3 +166,21 @@ if TYPE_CHECKING:
     assert_type(record.rich_text, str)
     assert_type(record.select, Optional[str])
     assert_type(record.url, str)
+    assert_type(record.required_aitext, T.AITextDict)
+    assert_type(record.required_barcode, T.BarcodeDict)
+    assert_type(record.required_collaborator, T.CollaboratorDict)
+    assert_type(record.required_count, int)
+    assert_type(record.required_currency, Union[int, float])
+    assert_type(record.required_date, datetime.date)
+    assert_type(record.required_datetime, datetime.datetime)
+    assert_type(record.required_duration, datetime.timedelta)
+    assert_type(record.required_email, str)
+    assert_type(record.required_float, float)
+    assert_type(record.required_integer, int)
+    assert_type(record.required_number, Union[int, float])
+    assert_type(record.required_percent, Union[int, float])
+    assert_type(record.required_phone, str)
+    assert_type(record.required_rating, int)
+    assert_type(record.required_rich_text, str)
+    assert_type(record.required_select, str)
+    assert_type(record.required_url, str)