Repository: gtalarico/pyairtable (language: python)

Issue:
Dynamic solution to formulas
Not sure if this is a poor implementation or if it doesn't fit the intended update goals, but I'm working on a dynamic solution to the issues surrounding formulas mentioned by @mesozoic and others.

### I have a WIP that allows the following to be done

#### Function/Formula Subclass Generation
1. Take a CSV export from the [formula playground](https://www.airtable.com/universe/expHF9XTWWwAT299z/airtable-formula-playground?explore=true)
2. Run the WIP script generate.py passing in the export as args.
3. The script runs through each row from the export, identifies the type of function as well as the type for each parameter passed into the function, and serializes the current ~80 functions.
5. The script then generates subclasses from a parent class in `formulas.py` that contains generic attributes/functions.
6.  This generated content is then spliced back into the actual package.

**Issues**:

- Still requires some manual effort via pulling the CSV.
- Generates many lengthy lines, likely won't fit any style guidelines.
![image](https://github.com/gtalarico/pyairtable/assets/79327706/24ec0946-10cb-4af1-891f-5fb2addd1319)

### Working on composable objects
I've got the composable aspect down conceptually, going about it in a way similar to reverse Polish notation, but I could use some guidance/suggestions regarding @mesozoic's comment:

> We should be able to use operators like `&` and `|` to build complex logical conditions

I'm familiar with overriding `__add__` for example, but could use some clarity on how this is ideally implemented for this project. Ke

Please critique the generation features in the WIP if they are throwing out alarm bells or if it is a noob idea in general.







Discussion / hints:

Fix patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index e665cfe9..ecb77817 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,3 +3,4 @@ include *.rst
 include tox.ini
 include LICENSE
 include README.md
+exclude pyairtable/formulas.txt
diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst
index c95a5626..67309dde 100644
--- a/docs/source/_substitutions.rst
+++ b/docs/source/_substitutions.rst
@@ -32,8 +32,7 @@
 .. |kwarg_formula| replace:: An Airtable formula. The formula will be evaluated for each record, and if the result
     is none of ``0``, ``false``, ``""``, ``NaN``, ``[]``, or ``#Error!`` the record will be included
     in the response. If combined with view, only records in that view which satisfy the
-    formula will be returned. For example, to only include records where
-    ``COLUMN_A`` isn't empty, pass in ``formula="{COLUMN_A}"``.
+    formula will be returned. Read more at :doc:`formulas`.
 
 .. |kwarg_typecast| replace:: The Airtable API will perform best-effort
     automatic data conversion from string values.
@@ -46,17 +45,17 @@
 
 .. |kwarg_user_locale| replace:: The user locale that should be used to format
     dates when using `string` as the `cell_format`. See
-    https://support.airtable.com/hc/en-us/articles/220340268-Supported-locale-modifiers-for-SET-LOCALE
+    `Supported SET_LOCALE modifiers <https://support.airtable.com/docs/supported-locale-modifiers-for-set-locale>`__
     for valid values.
 
 .. |kwarg_time_zone| replace:: The time zone that should be used to format dates
     when using `string` as the `cell_format`. See
-    https://support.airtable.com/hc/en-us/articles/216141558-Supported-timezones-for-SET-TIMEZONE
+    `Supported SET_TIMEZONE timezones <https://support.airtable.com/docs/supported-timezones-for-set-timezone>`__
     for valid values.
 
 .. |kwarg_replace| replace:: If ``True``, record is replaced in its entirety
     by provided fields; if a field is not included its value will
-    bet set to null. If False, only provided fields are updated.
+    bet set to null. If ``False``, only provided fields are updated.
 
 .. |kwarg_return_fields_by_field_id| replace:: An optional boolean value that lets you return field objects where the
     key is the field id. This defaults to `false`, which returns field objects where the key is the field name.
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index baebf6aa..08eceb5f 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -2,6 +2,12 @@
 Changelog
 =========
 
+3.0 (TBD)
+------------------------
+
+* Rewrite of :mod:`pyairtable.formulas` module.
+  - `PR #329 <https://github.com/gtalarico/pyairtable/pull/329>`_.
+
 2.3.2 (2024-03-18)
 ------------------------
 
diff --git a/docs/source/formulas.rst b/docs/source/formulas.rst
new file mode 100644
index 00000000..cf1a7a5d
--- /dev/null
+++ b/docs/source/formulas.rst
@@ -0,0 +1,95 @@
+Building Formulas
+=================
+
+pyAirtable lets you construct formulas at runtime using Python syntax,
+and will convert those formula objects into the appropriate strings when
+sending them to the Airtable API.
+
+
+Basics
+--------------------------
+
+In cases where you want to find records with fields matching a computed value,
+this library provides the :func:`~pyairtable.formulas.match` function, which
+returns a formula that can be passed to methods like :func:`Table.all <pyairtable.Table.all>`:
+
+.. autofunction:: pyairtable.formulas.match
+    :noindex:
+
+
+Compound conditions
+--------------------------
+
+Formulas and conditions can be chained together if you need to create
+more complex criteria:
+
+    >>> from datetime import date
+    >>> from pyairtable.formulas import AND, GTE, Field, match
+    >>> formula = AND(
+    ...     match("Customer", 'Alice'),
+    ...     GTE(Field("Delivery Date"), date.today())
+    ... )
+    >>> formula
+    AND(EQ(Field('Customer'), 'Alice'),
+        GTE(Field('Delivery Date'), datetime.date(2023, 12, 10)))
+    >>> str(formula)
+    "AND({Customer}='Alice', {Delivery Date}>=DATETIME_PARSE('2023-12-10'))"
+
+pyAirtable has support for the following comparisons:
+
+    .. list-table::
+
+       * - :class:`pyairtable.formulas.EQ`
+         - ``lval = rval``
+       * - :class:`pyairtable.formulas.NE`
+         - ``lval != rval``
+       * - :class:`pyairtable.formulas.GT`
+         - ``lval > rval``
+       * - :class:`pyairtable.formulas.GTE`
+         - ``lval >= rval``
+       * - :class:`pyairtable.formulas.LT`
+         - ``lval < rval``
+       * - :class:`pyairtable.formulas.LTE`
+         - ``lval <= rval``
+
+These are also implemented as convenience methods on all instances
+of :class:`~pyairtable.formulas.Formula`, so that the following are equivalent:
+
+    >>> EQ(Field("Customer"), "Alice")
+    >>> match({"Customer": "Alice"})
+    >>> Field("Customer").eq("Alice")
+
+pyAirtable exports ``AND``, ``OR``, ``NOT``, and ``XOR`` for chaining conditions.
+You can also use Python operators to modify and combine formulas:
+
+    >>> from pyairtable.formulas import match
+    >>> match({"Customer": "Bob"}) & ~match({"Product": "TEST"})
+    AND(EQ(Field('Customer'), 'Bob'),
+        NOT(EQ(Field('Product'), 'TEST')))
+
+    .. list-table::
+       :header-rows: 1
+
+       * - Python operator
+         - `Airtable expression <https://support.airtable.com/docs/formula-field-reference#logical-operators-and-functions-in-airtable>`__
+       * - ``lval & rval``
+         - ``AND(lval, rval)``
+       * - ``lval | rval``
+         - ``OR(lval, rval)``
+       * - ``~rval``
+         - ``NOT(rval)``
+       * - ``lval ^ rval``
+         - ``XOR(lval, rval)``
+
+Calling functions
+--------------------------
+
+pyAirtable also exports functions that act as placeholders for calling
+Airtable formula functions:
+
+    >>> from pyairtable.formulas import Field, GTE, DATETIME_DIFF, TODAY
+    >>> formula = GTE(DATETIME_DIFF(TODAY(), Field("Purchase Date"), "days"), 7)
+    >>> str(formula)
+    "DATETIME_DIFF(TODAY(), {Purchase Date}, 'days')>=7"
+
+All supported functions are listed in the :mod:`pyairtable.formulas` API reference.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index ec4ca998..0cb16ec9 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -26,6 +26,7 @@ pyAirtable
 
    getting-started
    tables
+   formulas
    orm
    metadata
    webhooks
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index 69495a45..4bedda0c 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -6,6 +6,50 @@ Migration Guide
 *****************
 
 
+Migrating from 2.x to 3.0
+============================
+
+In this release we've made breaking changes to the :mod:`pyairtable.formulas` module.
+In general, most functions and methods in this module will return instances of
+:class:`~pyairtable.formulas.Formula`, which can be chained, combined, and eventually
+passed to the ``formula=`` keyword argument to methods like :meth:`~pyairtable.Table.all`.
+
+The full list of breaking changes is below:
+
+.. list-table::
+    :header-rows: 1
+
+    * - Function
+      - Changes
+    * - :func:`~pyairtable.formulas.match`
+      - This now raises ``ValueError`` on empty input,
+        instead of returning ``None``.
+    * - ``to_airtable_value()``
+      - Removed. Use :func:`~pyairtable.formulas.to_formula_str` instead.
+    * - ``EQUAL()``
+      - Removed. Use :class:`~pyairtable.formulas.EQ` instead.
+    * - ``NOT_EQUAL()``
+      - Removed. Use :class:`~pyairtable.formulas.NE` instead.
+    * - ``LESS()``
+      - Removed. Use :class:`~pyairtable.formulas.LT` instead.
+    * - ``LESS_EQUAL()``
+      - Removed. Use :class:`~pyairtable.formulas.LTE` instead.
+    * - ``GREATER()``
+      - Removed. Use :class:`~pyairtable.formulas.GT` instead.
+    * - ``GREATER_EQUAL()``
+      - Removed. Use :class:`~pyairtable.formulas.GTE` instead.
+    * - ``FIELD()``
+      - Removed. Use :class:`~pyairtable.formulas.Field` or :func:`~pyairtable.formulas.field_name`.
+    * - ``STR_VALUE()``
+      - Removed. Use :func:`~pyairtable.formulas.quoted` instead.
+    * - :func:`~pyairtable.formulas.AND`, :func:`~pyairtable.formulas.OR`
+      - These no longer return ``str``, and instead return instances of
+        :class:`~pyairtable.formulas.Comparison`.
+    * - :func:`~pyairtable.formulas.IF`, :func:`~pyairtable.formulas.FIND`, :func:`~pyairtable.formulas.LOWER`
+      - These no longer return ``str``, and instead return instances of
+        :class:`~pyairtable.formulas.FunctionCall`.
+
+
 Migrating from 2.2 to 2.3
 ============================
 
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 86a97ed8..d0e2a8f6 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -28,9 +28,13 @@ The :class:`~pyairtable.orm.Model` class allows you create ORM-style classes for
             api_key = "keyapikey"
 
 
-Once you have a model, you can create new objects to represent your
-Airtable records. Call :meth:`~pyairtable.orm.Model.save` to save the
-newly created object to the Airtable API.
+Once you have a model, you can query for existing records using the
+``first()`` and ``all()`` methods, which take the same arguments as
+:meth:`Table.first <pyairtable.Table.first>` and :meth:`Table.all <pyairtable.Table.all>`.
+
+You can also create new objects to represent Airtable records you wish
+to create and save. Call :meth:`~pyairtable.orm.Model.save` to save the
+newly created object back to Airtable.
 
     >>> contact = Contact(
     ...     first_name="Mike",
@@ -47,7 +51,6 @@ newly created object to the Airtable API.
     >>> contact.id
     'recS6qSLw0OCA6Xul'
 
-
 You can read and modify attributes, then call :meth:`~pyairtable.orm.Model.save`
 when you're ready to save your changes to the API.
 
@@ -63,7 +66,7 @@ To refresh a record from the API, use :meth:`~pyairtable.orm.Model.fetch`:
     >>> contact.is_registered
     True
 
-Finally, you can use :meth:`~pyairtable.orm.Model.delete` to delete the record:
+Use :meth:`~pyairtable.orm.Model.delete` to delete the record:
 
     >>> contact.delete()
     True
@@ -77,6 +80,21 @@ create, modify, or delete several records at once:
     >>> Contact.batch_save(contacts)
     >>> Contact.batch_delete(contacts)
 
+You can use your model's fields in :doc:`formula expressions <formulas>`.
+ORM models' fields also provide shortcut methods
+:meth:`~pyairtable.orm.fields.Field.eq`,
+:meth:`~pyairtable.orm.fields.Field.ne`,
+:meth:`~pyairtable.orm.fields.Field.gt`,
+:meth:`~pyairtable.orm.fields.Field.gte`,
+:meth:`~pyairtable.orm.fields.Field.lt`, and
+:meth:`~pyairtable.orm.fields.Field.lte`:
+
+    >>> formula = Contact.last_name.eq("Smith") & Contact.is_registered
+    >>> str(formula)
+    "AND({Last Name}='Smith', {Registered})"
+    >>> results = Contact.all(formula=formula)
+    [...]
+
 
 Supported Field Types
 -----------------------------
@@ -176,7 +194,7 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
 .. [[[end]]] (checksum: 01c5696293e7571ac8250c4e8a2453e8)
 
 
-Formulas, Rollups, and Lookups
+Formula, Rollup, and Lookup Fields
 ----------------------------------
 
 The data type of "formula", "rollup", and "lookup" fields will depend on the underlying fields
diff --git a/docs/source/tables.rst b/docs/source/tables.rst
index 8d5f4022..a76308ef 100644
--- a/docs/source/tables.rst
+++ b/docs/source/tables.rst
@@ -152,32 +152,21 @@ This library will return records as :class:`~pyairtable.api.types.RecordDict`.
 Formulas
 ********
 
-The :mod:`pyairtable.formulas` module provides functionality to help you compose
-`Airtable formulas <https://support.airtable.com/hc/en-us/articles/203255215-Formula-field-reference>`_.
+Methods like :meth:`~pyairtable.Table.all` or :meth:`~pyairtable.Table.first`
+accept a ``formula=`` keyword argument so you can filter results using an
+`Airtable formula <https://support.airtable.com/hc/en-us/articles/203255215-Formula-field-reference>`_.
 
-* :func:`~pyairtable.formulas.match` checks field values from a Python ``dict``:
+The simplest option is to pass your formula as a string; however, if your use case
+is complex and you want to avoid lots of f-strings and escaping, use
+:func:`~pyairtable.formulas.match` to check field values from a ``dict``:
 
   .. code-block:: python
 
       >>> from pyairtable.formulas import match
-      >>> formula = match({"First Name": "John", "Age": 21})
-      >>> formula
-      "AND({First Name}='John',{Age}=21)"
-      >>> table.first(formula=formula)
+      >>> table.first(formula=match({"First Name": "John", "Age": 21}))
       {"id": "recUwKa6lbNSMsetH", "fields": {"First Name": "John", "Age": 21}}
 
-* :func:`~pyairtable.formulas.to_airtable_value` converts a Python value
-  to an expression that can be included in a formula:
-
-  .. code-block:: python
-
-      >>> from pyairtable.formulas import to_airtable_value
-      >>> to_airtable_value(1)
-      1
-      >>> to_airtable_value(datetime.date.today())
-      '2023-06-13'
-
-For more on generating formulas, look over the :mod:`pyairtable.formulas` API reference.
+For more on generating formulas, read the :doc:`formulas` documentation.
 
 
 Retries
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index e4c39a5c..0cc4cdc5 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -16,6 +16,7 @@
     assert_typed_dict,
     assert_typed_dicts,
 )
+from pyairtable.formulas import Formula, to_formula_str
 from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema
 from pyairtable.utils import is_table_id
 
@@ -227,6 +228,8 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]:
             time_zone: |kwarg_time_zone|
             return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
+        if isinstance(formula := options.get("formula"), Formula):
+            options["formula"] = to_formula_str(formula)
         for page in self.api.iterate_requests(
             method="get",
             url=self.url,
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index 734a4e4b..1a08c421 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -1,270 +1,1095 @@
+"""
+This module exports building blocks for constructing Airtable formulas,
+including function call proxies for all formula functions as of Dec '23.
+
+See :doc:`formulas` for more information.
+"""
+
+import datetime
 import re
-from datetime import date, datetime
-from typing import Any
+from decimal import Decimal
+from fractions import Fraction
+from typing import Any, ClassVar, Iterable, List, Optional, Set, Union
+
+from typing_extensions import Self as SelfType
 
 from pyairtable.api.types import Fields
+from pyairtable.utils import date_to_iso_str, datetime_to_iso_str
+
+
+class Formula:
+    """
+    Represents an Airtable formula that can be combined with other formulas
+    or converted to a string. On its own, this class simply wraps a ``str``
+    so that it will be not be modified or escaped as if it were a value.
+
+    >>> Formula("{Column} = 1")
+    Formula('{Column} = 1')
+    >>> str(_)
+    '{Column} = 1'
+    """
+
+    def __init__(self, value: str) -> None:
+        self.value = value
+
+    def __str__(self) -> str:
+        return self.value
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self.value!r})"
+
+    def __and__(self, other: "Formula") -> "Formula":
+        return AND(self, other)
+
+    def __or__(self, other: "Formula") -> "Formula":
+        return OR(self, other)
+
+    def __xor__(self, other: "Formula") -> "Formula":
+        return XOR(self, other)
 
-from .utils import date_to_iso_str, datetime_to_iso_str
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, Formula):
+            return False
+        return other.value == self.value
 
+    def __invert__(self) -> "Formula":
+        return NOT(self)
 
-def match(dict_values: Fields, *, match_any: bool = False) -> str:
+    def flatten(self) -> "Formula":
+        return self
+
+    def eq(self, value: Any) -> "Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.EQ` comparison using this field.
+        """
+        return EQ(self, value)
+
+    def ne(self, value: Any) -> "Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.NE` comparison using this field.
+        """
+        return NE(self, value)
+
+    def gt(self, value: Any) -> "Comparison":
+        """
+        Build a :class:`~pyairtable.formulas.GT` comparison using this field.
+        """
+        return GT(self, value)
+
+    def lt(self, value: Any) -> "Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.LT` comparison using this field.
+        """
+        return LT(self, value)
+
+    def gte(self, value: Any) -> "Comparison":
+        """
+        Build a :class:`~pyairtable.formulas.GTE` comparison using this field.
+        """
+        return GTE(self, value)
+
+    def lte(self, value: Any) -> "Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.LTE` comparison using this field.
+        """
+        return LTE(self, value)
+
+
+class Field(Formula):
+    """
+    Represents a field name.
     """
-    Create one or more ``EQUAL()`` expressions for each provided dict value.
-    If more than one assetions is included, the expressions are
-    groupped together into using ``AND()`` (all values must match).
 
-    If ``match_any=True``, expressions are grouped with ``OR()``, record is return
-    if any of the values match.
+    def __str__(self) -> str:
+        return "{%s}" % escape_quotes(self.value)
 
-    This function also handles escaping field names and casting python values
-    to the appropriate airtable types using :func:`to_airtable_value` on all
-    provided values to help generate the expected formula syntax.
 
-    If you need more advanced matching you can build similar expressions using lower
-    level forumula primitives.
+class Comparison(Formula):
+    """
+    Represents a logical condition that compares two expressions.
+    """
 
+    operator: ClassVar[str] = ""
 
-    Args:
-        dict_values: dictionary containing column names and values
+    def __init__(self, lval: Any, rval: Any):
+        if not self.operator:
+            raise NotImplementedError(
+                f"{self.__class__.__name__}.operator is not defined"
+            )
+        self.lval = lval
+        self.rval = rval
 
-    Keyword Args:
-        match_any (``bool``, default: ``False``):
-            If ``True``, matches if **any** of the provided values match.
-            Otherwise, all values must match.
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, Comparison):
+            return False
+        return (self.lval, self.operator, self.rval) == (
+            other.lval,
+            other.operator,
+            other.rval,
+        )
+
+    def __str__(self) -> str:
+        lval, rval = (to_formula_str(v) for v in (self.lval, self.rval))
+        lval = f"({lval})" if isinstance(self.lval, Comparison) else lval
+        rval = f"({rval})" if isinstance(self.rval, Comparison) else rval
+        return f"{lval}{self.operator}{rval}"
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self.lval!r}, {self.rval!r})"
+
+
+class EQ(Comparison):
+    """
+    Produces an ``lval = rval`` formula.
+    """
+
+    operator = "="
+
+
+class NE(Comparison):
+    """
+    Produces an ``lval != rval`` formula.
+    """
+
+    operator = "!="
+
+
+class GT(Comparison):
+    """
+    Produces an ``lval > rval`` formula.
+    """
+
+    operator = ">"
+
+
+class GTE(Comparison):
+    """
+    Produces an ``lval >= rval`` formula.
+    """
+
+    operator = ">="
+
+
+class LT(Comparison):
+    """
+    Produces an ``lval < rval`` formula.
+    """
+
+    operator = "<"
+
+
+class LTE(Comparison):
+    """
+    Produces an ``lval <= rval`` formula.
+    """
+
+    operator = "<="
+
+
+COMPARISONS_BY_OPERATOR = {cls.operator: cls for cls in (EQ, NE, GT, GTE, LT, LTE)}
+
+
+class Compound(Formula):
+    """
+    Represents a boolean logical operator (AND, OR, etc.) wrapping around
+    one or more component formulas.
+    """
+
+    operator: str
+    components: List[Formula]
+
+    def __init__(
+        self,
+        operator: str,
+        components: Iterable[Formula],
+    ) -> None:
+        if not isinstance(components, list):
+            components = list(components)
+        if len(components) == 0:
+            raise ValueError("Compound() requires at least one component")
+
+        self.operator = operator
+        self.components = components
+
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, Compound):
+            return False
+        return (self.operator, self.components) == (other.operator, other.components)
+
+    def __str__(self) -> str:
+        joined_components = ", ".join(str(c) for c in self.components)
+        return f"{self.operator}({joined_components})"
+
+    def __repr__(self) -> str:
+        return f"{self.operator}({repr(self.components)[1:-1]})"
+
+    def flatten(self, /, memo: Optional[Set[int]] = None) -> "Compound":
+        """
+        Reduces the depth of nested AND, OR, and NOT statements.
+        """
+        memo = memo if memo else set()
+        memo.add(id(self))
+        flattened: List[Formula] = []
+        for item in self.components:
+            if id(item) in memo:
+                raise CircularDependency(item)
+            if isinstance(item, Compound) and item.operator == self.operator:
+                flattened.extend(item.flatten(memo=memo).components)
+            else:
+                flattened.append(item.flatten())
+
+        return Compound(self.operator, flattened)
+
+    @classmethod
+    def build(cls, operator: str, *components: Any, **fields: Any) -> SelfType:
+        items = list(components)
+        if len(items) == 1 and hasattr(first := items[0], "__iter__"):
+            items = [first] if isinstance(first, str) else list(first)
+        if fields:
+            items.extend(EQ(Field(k), v) for (k, v) in fields.items())
+        return cls(operator, items)
+
+
+def AND(*components: Union[Formula, Iterable[Formula]], **fields: Any) -> Compound:
+    """
+    Join one or more logical conditions into an AND compound condition.
+    Keyword arguments will be treated as field names.
+
+    >>> AND(EQ("foo", 1), EQ(Field("bar"), 2), baz=3)
+    AND(EQ('foo', 1), EQ(Field('bar'), 2), EQ(Field('baz'), 3))
+    """
+    return Compound.build("AND", *components, **fields)
+
+
+def OR(*components: Union[Formula, Iterable[Formula]], **fields: Any) -> Compound:
+    """
+    Join one or more logical conditions into an OR compound condition.
+    Keyword arguments will be treated as field names.
+
+    >>> OR(EQ("foo", 1), EQ(Field("bar"), 2), baz=3)
+    OR(EQ('foo', 1), EQ(Field('bar'), 2), EQ(Field('baz'), 3))
+    """
+    return Compound.build("OR", *components, **fields)
+
+
+def NOT(component: Optional[Formula] = None, /, **fields: Any) -> Compound:
+    """
+    Wrap one logical condition in a negation compound.
+    Keyword arguments will be treated as field names.
+
+    Can be called with either a formula or with a single
+    kewyord argument, but not both.
+
+    >>> NOT(EQ("foo", 1))
+    NOT(EQ('foo', 1))
+
+    >>> NOT(foo=1)
+    NOT(EQ(Field('foo'), 1))
+
+    If not called with exactly one condition, will throw an exception:
+
+    >>> NOT(EQ("foo", 1), EQ("bar", 2))
+    Traceback (most recent call last):
+    TypeError: NOT() takes from 0 to 1 positional arguments but 2 were given
+
+    >>> NOT(EQ("foo", 1), bar=2)
+    Traceback (most recent call last):
+    ValueError: NOT() requires exactly one condition; got 2
+
+    >>> NOT(foo=1, bar=2)
+    Traceback (most recent call last):
+    ValueError: NOT() requires exactly one condition; got 2
+
+    >>> NOT()
+    Traceback (most recent call last):
+    ValueError: NOT() requires exactly one condition; got 0
+    """
+    items: List[Formula] = [EQ(Field(k), v) for (k, v) in fields.items()]
+    if component:
+        items.append(component)
+    if (count := len(items)) != 1:
+        raise ValueError(f"NOT() requires exactly one condition; got {count}")
+    return Compound.build("NOT", items)
+
+
+class CircularDependency(RecursionError):
+    """
+    A circular dependency was encountered when flattening nested conditions.
+    """
+
+
+def match(field_values: Fields, *, match_any: bool = False) -> Formula:
+    r"""
+    Create one or more equality expressions for each provided value,
+    treating keys as field names and values as values (not formula expressions).
+
+    If more than one assertion is included, the expressions are
+    grouped together into using ``AND()`` (all values must match).
+    If ``match_any=True``, expressions are grouped with ``OR()``.
 
-    Usage:
         >>> match({"First Name": "John", "Age": 21})
-        "AND({First Name}='John',{Age}=21)"
+        AND(EQ(Field('First Name'), 'John'),
+            EQ(Field('Age'), 21))
+
         >>> match({"First Name": "John", "Age": 21}, match_any=True)
-        "OR({First Name}='John',{Age}=21)"
-        >>> match({"First Name": "John"})
-        "{First Name}='John'"
-        >>> match({"Registered": True})
-        "{Registered}=1"
-        >>> match({"Owner's Name": "Mike"})
-        "{Owner\\'s Name}='Mike'"
+        OR(EQ(Field('First Name'), 'John'),
+           EQ(Field('Age'), 21))
+
+    To use comparisons other than equality, use a 2-tuple of ``(operator, value)``
+    as the value for a particular field. For example:
+
+        >>> match({"First Name": "John", "Age": (">=", 21)})
+        AND(EQ(Field('First Name'), 'John'),
+            GTE(Field('Age'), 21))
+
+    If you need more advanced matching you can build formula expressions using lower
+    level primitives.
 
+    Args:
+        field_values: mapping of column names to values
+            (or to 2-tuples of the format ``(operator, value)``).
+        match_any:
+            If ``True``, matches if *any* of the provided values match.
+            Otherwise, all values must match.
     """
-    expressions = []
-    for key, value in dict_values.items():
-        expression = EQUAL(FIELD(key), to_airtable_value(value))
-        expressions.append(expression)
+    expressions: List[Formula] = []
+
+    for key, val in field_values.items():
+        if isinstance(val, tuple) and len(val) == 2:
+            cmp, val = COMPARISONS_BY_OPERATOR[val[0]], val[1]
+        else:
+            cmp = EQ
+        expressions.append(cmp(Field(key), val))
 
     if len(expressions) == 0:
-        return ""
-    elif len(expressions) == 1:
+        raise ValueError(
+            "match() requires at least one field-value pair or keyword argument"
+        )
+    if len(expressions) == 1:
         return expressions[0]
-    else:
-        if not match_any:
-            return AND(*expressions)
-        else:
-            return OR(*expressions)
+    if match_any:
+        return OR(*expressions)
+    return AND(*expressions)
+
+
+def to_formula_str(value: Any) -> str:
+    """
+    Converts the given value into a string representation that can be used
+    in an Airtable formula expression.
+
+    >>> to_formula_str(EQ(F.Formula("a"), "b"))
+    "a='b'"
+    >>> to_formula_str(True)
+    'TRUE()'
+    >>> to_formula_str(False)
+    'FALSE()'
+    >>> to_formula_str(3)
+    '3'
+    >>> to_formula_str(3.5)
+    '3.5'
+    >>> to_formula_str(Decimal("3.14159265"))
+    '3.14159265'
+    >>> to_formula_str(Fraction("4/19"))
+    '4/19'
+    >>> to_formula_str("asdf")
+    "'asdf'"
+    >>> to_formula_str("Jane's")
+    "'Jane\\'s'"
+    >>> to_formula_str(datetime.date(2023, 12, 1))
+    "DATETIME_PARSE('2023-12-01')"
+    >>> to_formula_str(datetime.datetime(2023, 12, 1, 12, 34, 56))
+    "DATETIME_PARSE('2023-12-01T12:34:56.000Z')"
+    """
+    # Runtime import to avoid circular dependency
+    from pyairtable import orm
+
+    if isinstance(value, Formula):
+        return str(value)
+    if isinstance(value, bool):
+        return "TRUE()" if value else "FALSE()"
+    if isinstance(value, (int, float, Decimal, Fraction)):
+        return str(value)
+    if isinstance(value, str):
+        return "'{}'".format(escape_quotes(value))
+    if isinstance(value, datetime.datetime):
+        return str(DATETIME_PARSE(datetime_to_iso_str(value)))
+    if isinstance(value, datetime.date):
+        return str(DATETIME_PARSE(date_to_iso_str(value)))
+    if isinstance(value, orm.fields.Field):
+        return field_name(value.field_name)
+    raise TypeError(value, type(value))
+
+
+def quoted(value: str) -> str:
+    r"""
+    Wrap string in quotes. This is needed when referencing a string inside a formula.
+    Quotes are escaped.
+
+    >>> quoted("John")
+    "'John'"
+    >>> quoted("Guest's Name")
+    "'Guest\\'s Name'"
+    """
+    return "'{}'".format(escape_quotes(str(value)))
 
 
 def escape_quotes(value: str) -> str:
     r"""
-    Ensures any quotes are escaped. Already escaped quotes are ignored.
+    Ensure any quotes are escaped. Already escaped quotes are ignored.
 
     Args:
         value: text to be escaped
 
     Usage:
-        >>> escape_quotes("Player's Name")
-        Player\'s Name
-        >>> escape_quotes("Player\'s Name")
-        Player\'s Name
+        >>> escape_quotes(r"Player's Name")
+        "Player\\'s Name"
+        >>> escape_quotes(r"Player\'s Name")
+        "Player\\'s Name"
     """
     escaped_value = re.sub("(?<!\\\\)'", "\\'", value)
     return escaped_value
 
 
-def to_airtable_value(value: Any) -> Any:
+def field_name(name: str) -> str:
+    r"""
+    Create a reference to a field. Quotes are escaped.
+
+    Args:
+        name: field name
+
+    Usage:
+        >>> field_name("First Name")
+        '{First Name}'
+        >>> field_name("Guest's Name")
+        "{Guest\\'s Name}"
     """
-    Cast value to appropriate airtable types and format.
-    For example, to check ``bool`` values in formulas, you actually to compare
-    to 0 and 1.
+    return "{%s}" % escape_quotes(name)
 
-    .. list-table::
-        :widths: 25 75
-        :header-rows: 1
 
-        * - Input
-          - Output
-        * - ``bool``
-          - ``int``
-        * - ``str``
-          - ``str``; text is wrapped in `'single quotes'`; existing quotes are escaped.
-        * - all others
-          - unchanged
+class FunctionCall(Formula):
+    """
+    Represents a function call in an Airtable formula, and converts
+    all arguments to that function into Airtable formula expressions.
 
-    Args:
-        value: value to be cast.
+    >>> FunctionCall("WEEKDAY", datetime.date(2024, 1, 1))
+    WEEKDAY(datetime.date(2024, 1, 1))
+    >>> str(_)
+    "WEEKDAY(DATETIME_PARSE('2024-01-01'))"
 
+    pyAirtable exports shortcuts like :meth:`~pyairtable.formulas.WEEKDAY`
+    for all formula functions known at time of publishing.
     """
-    if isinstance(value, bool):
-        return int(value)
-    elif isinstance(value, (int, float)):
-        return value
-    elif isinstance(value, str):
-        return STR_VALUE(value)
-    elif isinstance(value, datetime):
-        return datetime_to_iso_str(value)
-    elif isinstance(value, date):
-        return date_to_iso_str(value)
+
+    def __init__(self, name: str, *args: List[Any]):
+        self.name = name
+        self.args = args
+
+    def __str__(self) -> str:
+        joined_args = ", ".join(to_formula_str(v) for v in self.args)
+        return f"{self.name}({joined_args})"
+
+    def __repr__(self) -> str:
+        joined_args_repr = ", ".join(repr(v) for v in self.args)
+        return f"{self.name}({joined_args_repr})"
+
+
+# fmt: off
+r"""[[[cog]]]
+
+import re
+from pathlib import Path
+
+definitions = [
+    line.strip()
+    for line in Path(cog.inFile).with_suffix(".txt").read_text().splitlines()
+    if line.strip()
+    and not line.startswith("#")
+]
+
+cog.outl("\n")
+
+for definition in definitions:
+    comment = ""
+    if "#" in definition:
+        definition, comment = (x.strip() for x in definition.split("#", 1))
+
+    name, argspec = definition.rstrip(")").split("(", 1)
+    if name in ("AND", "OR", "NOT"):
+        continue
+
+    args = [
+        re.sub(
+            "([a-z])([A-Z])",
+            lambda m: m[1] + "_" + m[2].lower(),
+            name.strip()
+        )
+        for name in argspec.split(",")
+    ]
+
+    required = [arg for arg in args if arg and not arg.startswith("[")]
+    optional = [arg.strip("[]") for arg in args if arg.startswith("[") and arg.endswith("]")]
+    signature = [f"{arg}: Any" for arg in required]
+    params = [*required]
+    splat = optional.pop().rstrip(".") if optional and optional[-1].endswith("...") else None
+
+    if optional:
+        signature += [f"{arg}: Optional[Any] = None" for arg in optional]
+        params += ["*(v for v in [" + ", ".join(optional) + "] if v is not None)"]
+
+    if required or optional:
+        signature += ["/"]
+
+    if splat:
+        signature += [f"*{splat}: Any"]
+        params += [f"*{splat}"]
+
+    joined_signature = ", ".join(signature)
+    joined_params = (", " + ", ".join(params)) if params else ""
+
+    cog.outl(f"def {name}({joined_signature}) -> FunctionCall:")
+    cog.outl(f"    \"\"\"")
+    if comment:
+        cog.outl(f"    {comment}")
     else:
-        return value
+        cog.outl(f"    Produce a formula that calls ``{name}()``")
+    cog.outl(f"    \"\"\"")
+    cog.outl(f"    return FunctionCall({name!r}{joined_params})")
+    cog.outl("\n")
 
+[[[out]]]"""
 
-def EQUAL(left: Any, right: Any) -> str:
+
+def ABS(value: Any, /) -> FunctionCall:
+    """
+    Returns the absolute value.
     """
-    Create an equality assertion
+    return FunctionCall('ABS', value)
+
 
-    >>> EQUAL(2,2)
-    '2=2'
+def AVERAGE(number: Any, /, *numbers: Any) -> FunctionCall:
     """
-    return "{}={}".format(left, right)
+    Returns the average of the numbers.
+    """
+    return FunctionCall('AVERAGE', number, *numbers)
 
 
-def FIELD(name: str) -> str:
+def BLANK() -> FunctionCall:
     """
-    Create a reference to a field. Quotes are escaped.
+    Returns a blank value.
+    """
+    return FunctionCall('BLANK')
 
-    Args:
-        name: field name
 
-    Usage:
-        >>> FIELD("First Name")
-        '{First Name}'
-        >>> FIELD("Guest's Name")
-        "{Guest\\'s Name}"
+def CEILING(value: Any, significance: Optional[Any] = None, /) -> FunctionCall:
     """
-    return "{%s}" % escape_quotes(name)
+    Returns the nearest integer multiple of significance that is greater than or equal to the value. If no significance is provided, a significance of 1 is assumed.
+    """
+    return FunctionCall('CEILING', value, *(v for v in [significance] if v is not None))
 
 
-def STR_VALUE(value: str) -> str:
+def CONCATENATE(text: Any, /, *texts: Any) -> FunctionCall:
     """
-    Wrap string in quotes. This is needed when referencing a string inside a formula.
-    Quotes are escaped.
+    Joins together the text arguments into a single text value.
+    """
+    return FunctionCall('CONCATENATE', text, *texts)
 
-    >>> STR_VALUE("John")
-    "'John'"
-    >>> STR_VALUE("Guest's Name")
-    "'Guest\\'s Name'"
-    >>> EQUAL(STR_VALUE("John"), FIELD("First Name"))
-    "'John'={First Name}"
+
+def COUNT(number: Any, /, *numbers: Any) -> FunctionCall:
     """
-    return "'{}'".format(escape_quotes(str(value)))
+    Count the number of numeric items.
+    """
+    return FunctionCall('COUNT', number, *numbers)
 
 
-def IF(logical: str, value1: str, value2: str) -> str:
+def COUNTA(value: Any, /, *values: Any) -> FunctionCall:
     """
-    Create an IF statement
+    Count the number of non-empty values. This function counts both numeric and text values.
+    """
+    return FunctionCall('COUNTA', value, *values)
+
 
-    >>> IF(1=1, 0, 1)
-    'IF(1=1, 0, 1)'
+def COUNTALL(value: Any, /, *values: Any) -> FunctionCall:
+    """
+    Count the number of all elements including text and blanks.
     """
-    return "IF({}, {}, {})".format(logical, value1, value2)
+    return FunctionCall('COUNTALL', value, *values)
 
 
-def FIND(what: str, where: str, start_position: int = 0) -> str:
+def CREATED_TIME() -> FunctionCall:
     """
-    Create a FIND statement
+    Returns the date and time a given record was created.
+    """
+    return FunctionCall('CREATED_TIME')
 
-    >>> FIND(STR_VALUE(2021), FIELD('DatetimeCol'))
-    "FIND('2021', {DatetimeCol})"
 
-    Args:
-        what: String to search for
-        where: Where to search. Could be a string, or a field reference.
-        start_position: Index of where to start search. Default is 0.
+def DATEADD(date: Any, number: Any, units: Any, /) -> FunctionCall:
+    """
+    Adds specified "count" units to a datetime. (See `list of shared unit specifiers <https://support.airtable.com/hc/en-us/articles/226061308>`__. For this function we recommend using the full unit specifier for your desired unit.)
+    """
+    return FunctionCall('DATEADD', date, number, units)
+
 
+def DATESTR(date: Any, /) -> FunctionCall:
     """
-    if start_position:
-        return "FIND({}, {}, {})".format(what, where, start_position)
-    else:
-        return "FIND({}, {})".format(what, where)
+    Formats a datetime into a string (YYYY-MM-DD).
+    """
+    return FunctionCall('DATESTR', date)
+
+
+def DATETIME_DIFF(date1: Any, date2: Any, units: Any, /) -> FunctionCall:
+    """
+    Returns the difference between datetimes in specified units. The difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative.
+    """
+    return FunctionCall('DATETIME_DIFF', date1, date2, units)
 
 
-def AND(*args: str) -> str:
+def DATETIME_FORMAT(date: Any, output_format: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Formats a datetime into a specified string. See an `explanation of how to use this function with date fields <https://support.airtable.com/hc/en-us/articles/215646218>`__ or a list of `supported format specifiers <https://support.airtable.com/hc/en-us/articles/216141218>`__.
     """
-    Create an AND Statement
+    return FunctionCall('DATETIME_FORMAT', date, *(v for v in [output_format] if v is not None))
 
-    >>> AND(1, 2, 3)
-    'AND(1, 2, 3)'
+
+def DATETIME_PARSE(date: Any, input_format: Optional[Any] = None, locale: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Interprets a text string as a structured date, with optional input format and locale parameters. The output format will always be formatted 'M/D/YYYY h:mm a'.
     """
-    return "AND({})".format(",".join(args))
+    return FunctionCall('DATETIME_PARSE', date, *(v for v in [input_format, locale] if v is not None))
 
 
-def OR(*args: str) -> str:
+def DAY(date: Any, /) -> FunctionCall:
+    """
+    Returns the day of the month of a datetime in the form of a number between 1-31.
     """
-    .. versionadded:: 1.2.0
+    return FunctionCall('DAY', date)
 
-    Creates an OR Statement
 
-    >>> OR(1, 2, 3)
-    'OR(1, 2, 3)'
+def ENCODE_URL_COMPONENT(component_string: Any, /) -> FunctionCall:
     """
-    return "OR({})".format(",".join(args))
+    Replaces certain characters with encoded equivalents for use in constructing URLs or URIs. Does not encode the following characters: ``-_.~``
+    """
+    return FunctionCall('ENCODE_URL_COMPONENT', component_string)
 
 
-def LOWER(value: str) -> str:
+def ERROR() -> FunctionCall:
+    """
+    Returns a generic Error value (``#ERROR!``).
     """
-    .. versionadded:: 1.3.0
+    return FunctionCall('ERROR')
 
-    Creates the LOWER function, making a string lowercase.
-    Can be used on a string or a field name and will lower all the strings in the field.
 
-    >>> LOWER("TestValue")
-    "LOWER(TestValue)"
+def EVEN(value: Any, /) -> FunctionCall:
+    """
+    Returns the smallest even integer that is greater than or equal to the specified value.
     """
-    return "LOWER({})".format(value)
+    return FunctionCall('EVEN', value)
 
 
-def NOT_EQUAL(left: Any, right: Any) -> str:
+def EXP(power: Any, /) -> FunctionCall:
     """
-    Create an inequality assertion
+    Computes **Euler's number** (e) to the specified power.
+    """
+    return FunctionCall('EXP', power)
+
 
-    >>> NOT_EQUAL(2,2)
-    '2!=2'
+def FALSE() -> FunctionCall:
+    """
+    Logical value false. False is represented numerically by a 0.
     """
-    return "{}!={}".format(left, right)
+    return FunctionCall('FALSE')
 
 
-def LESS_EQUAL(left: Any, right: Any) -> str:
+def FIND(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall:
     """
-    Create a less than assertion
+    Finds an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition.(startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be 0.
+    """
+    return FunctionCall('FIND', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None))
+
 
-    >>> LESS_EQUAL(2,2)
-    '2<=2'
+def FLOOR(value: Any, significance: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Returns the nearest integer multiple of significance that is less than or equal to the value. If no significance is provided, a significance of 1 is assumed.
     """
-    return "{}<={}".format(left, right)
+    return FunctionCall('FLOOR', value, *(v for v in [significance] if v is not None))
 
 
-def GREATER_EQUAL(left: Any, right: Any) -> str:
+def FROMNOW(date: Any, /) -> FunctionCall:
     """
-    Create a greater than assertion
+    Calculates the number of days between the current date and another date.
+    """
+    return FunctionCall('FROMNOW', date)
+
 
-    >>> GREATER_EQUAL(2,2)
-    '2>=2'
+def HOUR(datetime: Any, /) -> FunctionCall:
+    """
+    Returns the hour of a datetime as a number between 0 (12:00am) and 23 (11:00pm).
     """
-    return "{}>={}".format(left, right)
+    return FunctionCall('HOUR', datetime)
 
 
-def LESS(left: Any, right: Any) -> str:
+def IF(expression: Any, if_true: Any, if_false: Any, /) -> FunctionCall:
     """
-    Create a less assertion
+    Returns value1 if the logical argument is true, otherwise it returns value2. Can also be used to make `nested IF statements <https://support.airtable.com/hc/en-us/articles/221564887-Nested-IF-formulas>`__.
+    """
+    return FunctionCall('IF', expression, if_true, if_false)
+
 
-    >>> LESS(2,2)
-    '2<2'
+def INT(value: Any, /) -> FunctionCall:
+    """
+    Returns the greatest integer that is less than or equal to the specified value.
     """
-    return "{}<{}".format(left, right)
+    return FunctionCall('INT', value)
 
 
-def GREATER(left: Any, right: Any) -> str:
+def ISERROR(expr: Any, /) -> FunctionCall:
     """
-    Create a greater assertion
+    Returns true if the expression causes an error.
+    """
+    return FunctionCall('ISERROR', expr)
+
 
-    >>> GREATER(2,2)
-    '2>2'
+def IS_AFTER(date1: Any, date2: Any, /) -> FunctionCall:
+    """
+    Determines if [date1] is later than [date2]. Returns 1 if yes, 0 if no.
     """
-    return "{}>{}".format(left, right)
+    return FunctionCall('IS_AFTER', date1, date2)
+
+
+def IS_BEFORE(date1: Any, date2: Any, /) -> FunctionCall:
+    """
+    Determines if [date1] is earlier than [date2]. Returns 1 if yes, 0 if no.
+    """
+    return FunctionCall('IS_BEFORE', date1, date2)
+
+
+def IS_SAME(date1: Any, date2: Any, unit: Any, /) -> FunctionCall:
+    """
+    Compares two dates up to a unit and determines whether they are identical. Returns 1 if yes, 0 if no.
+    """
+    return FunctionCall('IS_SAME', date1, date2, unit)
+
+
+def LAST_MODIFIED_TIME(*fields: Any) -> FunctionCall:
+    """
+    Returns the date and time of the most recent modification made by a user in a non-computed field in the table.
+    """
+    return FunctionCall('LAST_MODIFIED_TIME', *fields)
+
+
+def LEFT(string: Any, how_many: Any, /) -> FunctionCall:
+    """
+    Extract how many characters from the beginning of the string.
+    """
+    return FunctionCall('LEFT', string, how_many)
+
+
+def LEN(string: Any, /) -> FunctionCall:
+    """
+    Returns the length of a string.
+    """
+    return FunctionCall('LEN', string)
+
+
+def LOG(number: Any, base: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Computes the logarithm of the value in provided base. The base defaults to 10 if not specified.
+    """
+    return FunctionCall('LOG', number, *(v for v in [base] if v is not None))
+
+
+def LOWER(string: Any, /) -> FunctionCall:
+    """
+    Makes a string lowercase.
+    """
+    return FunctionCall('LOWER', string)
+
+
+def MAX(number: Any, /, *numbers: Any) -> FunctionCall:
+    """
+    Returns the largest of the given numbers.
+    """
+    return FunctionCall('MAX', number, *numbers)
+
+
+def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall:
+    """
+    Extract a substring of count characters starting at whereToStart.
+    """
+    return FunctionCall('MID', string, where_to_start, count)
+
+
+def MIN(number: Any, /, *numbers: Any) -> FunctionCall:
+    """
+    Returns the smallest of the given numbers.
+    """
+    return FunctionCall('MIN', number, *numbers)
+
+
+def MINUTE(datetime: Any, /) -> FunctionCall:
+    """
+    Returns the minute of a datetime as an integer between 0 and 59.
+    """
+    return FunctionCall('MINUTE', datetime)
+
+
+def MOD(value: Any, divisor: Any, /) -> FunctionCall:
+    """
+    Returns the remainder after dividing the first argument by the second.
+    """
+    return FunctionCall('MOD', value, divisor)
+
+
+def MONTH(date: Any, /) -> FunctionCall:
+    """
+    Returns the month of a datetime as a number between 1 (January) and 12 (December).
+    """
+    return FunctionCall('MONTH', date)
+
+
+def NOW() -> FunctionCall:
+    """
+    While similar to the TODAY() function, NOW() returns the current date AND time.
+    """
+    return FunctionCall('NOW')
+
+
+def ODD(value: Any, /) -> FunctionCall:
+    """
+    Rounds positive value up the the nearest odd number and negative value down to the nearest odd number.
+    """
+    return FunctionCall('ODD', value)
+
+
+def POWER(base: Any, power: Any, /) -> FunctionCall:
+    """
+    Computes the specified base to the specified power.
+    """
+    return FunctionCall('POWER', base, power)
+
+
+def RECORD_ID() -> FunctionCall:
+    """
+    Returns the ID of the current record.
+    """
+    return FunctionCall('RECORD_ID')
+
+
+def REGEX_EXTRACT(string: Any, regex: Any, /) -> FunctionCall:
+    """
+    Returns the first substring that matches a regular expression.
+    """
+    return FunctionCall('REGEX_EXTRACT', string, regex)
+
+
+def REGEX_MATCH(string: Any, regex: Any, /) -> FunctionCall:
+    """
+    Returns whether the input text matches a regular expression.
+    """
+    return FunctionCall('REGEX_MATCH', string, regex)
+
+
+def REGEX_REPLACE(string: Any, regex: Any, replacement: Any, /) -> FunctionCall:
+    """
+    Substitutes all matching substrings with a replacement string value.
+    """
+    return FunctionCall('REGEX_REPLACE', string, regex, replacement)
+
+
+def REPLACE(string: Any, start_character: Any, number_of_characters: Any, replacement: Any, /) -> FunctionCall:
+    """
+    Replaces the number of characters beginning with the start character with the replacement text.
+    """
+    return FunctionCall('REPLACE', string, start_character, number_of_characters, replacement)
+
+
+def REPT(string: Any, number: Any, /) -> FunctionCall:
+    """
+    Repeats string by the specified number of times.
+    """
+    return FunctionCall('REPT', string, number)
+
+
+def RIGHT(string: Any, how_many: Any, /) -> FunctionCall:
+    """
+    Extract howMany characters from the end of the string.
+    """
+    return FunctionCall('RIGHT', string, how_many)
+
+
+def ROUND(value: Any, precision: Any, /) -> FunctionCall:
+    """
+    Rounds the value to the number of decimal places given by "precision." (Specifically, ROUND will round to the nearest integer at the specified precision, with ties broken by `rounding half up toward positive infinity <https://en.wikipedia.org/wiki/Rounding#Round_half_up>`__.)
+    """
+    return FunctionCall('ROUND', value, precision)
+
+
+def ROUNDDOWN(value: Any, precision: Any, /) -> FunctionCall:
+    """
+    Rounds the value to the number of decimal places given by "precision," always `rounding down <https://en.wikipedia.org/wiki/Rounding#Rounding_to_integer>`__.
+    """
+    return FunctionCall('ROUNDDOWN', value, precision)
+
+
+def ROUNDUP(value: Any, precision: Any, /) -> FunctionCall:
+    """
+    Rounds the value to the number of decimal places given by "precision," always `rounding up <https://en.wikipedia.org/wiki/Rounding#Rounding_to_integer>`__.
+    """
+    return FunctionCall('ROUNDUP', value, precision)
+
+
+def SEARCH(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Searches for an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition. (startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be empty.
+    """
+    return FunctionCall('SEARCH', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None))
+
+
+def SECOND(datetime: Any, /) -> FunctionCall:
+    """
+    Returns the second of a datetime as an integer between 0 and 59.
+    """
+    return FunctionCall('SECOND', datetime)
+
+
+def SET_LOCALE(date: Any, locale_modifier: Any, /) -> FunctionCall:
+    """
+    Sets a specific locale for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported locale modifiers can be found `here <https://support.airtable.com/hc/en-us/articles/220340268>`__.
+    """
+    return FunctionCall('SET_LOCALE', date, locale_modifier)
+
+
+def SET_TIMEZONE(date: Any, tz_identifier: Any, /) -> FunctionCall:
+    """
+    Sets a specific timezone for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported timezone identifiers can be found `here <https://support.airtable.com/hc/en-us/articles/216141558-Supported-timezones-for-SET-TIMEZONE>`__.
+    """
+    return FunctionCall('SET_TIMEZONE', date, tz_identifier)
+
+
+def SQRT(value: Any, /) -> FunctionCall:
+    """
+    Returns the square root of a nonnegative number.
+    """
+    return FunctionCall('SQRT', value)
+
+
+def SUBSTITUTE(string: Any, old_text: Any, new_text: Any, index: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Replaces occurrences of old_text in string with new_text.
+    """
+    return FunctionCall('SUBSTITUTE', string, old_text, new_text, *(v for v in [index] if v is not None))
+
+
+def SUM(number: Any, /, *numbers: Any) -> FunctionCall:
+    """
+    Sum together the numbers. Equivalent to number1 + number2 + ...
+    """
+    return FunctionCall('SUM', number, *numbers)
+
+
+def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) -> FunctionCall:
+    """
+    Takes an expression, a list of possible values for that expression, and for each one, a value that the expression should take in that case. It can also take a default value if the expression input doesn't match any of the defined patterns. In many cases, SWITCH() can be used instead `of a nested IF() formula <https://support.airtable.com/hc/en-us/articles/360041812413>`__.
+    """
+    return FunctionCall('SWITCH', expression, pattern, result, *pattern_results)
+
+
+def T(value: Any, /) -> FunctionCall:
+    """
+    Returns the argument if it is text and blank otherwise.
+    """
+    return FunctionCall('T', value)
+
+
+def TIMESTR(timestamp: Any, /) -> FunctionCall:
+    """
+    Formats a datetime into a time-only string (HH:mm:ss).
+    """
+    return FunctionCall('TIMESTR', timestamp)
+
+
+def TODAY() -> FunctionCall:
+    """
+    While similar to the NOW() function: TODAY() returns the current date (not the current time, if formatted, time will return 12:00am).
+    """
+    return FunctionCall('TODAY')
+
+
+def TONOW(date: Any, /) -> FunctionCall:
+    """
+    Calculates the number of days between the current date and another date.
+    """
+    return FunctionCall('TONOW', date)
+
+
+def TRIM(string: Any, /) -> FunctionCall:
+    """
+    Removes whitespace at the beginning and end of string.
+    """
+    return FunctionCall('TRIM', string)
+
+
+def TRUE() -> FunctionCall:
+    """
+    Logical value true. The value of true is represented numerically by a 1.
+    """
+    return FunctionCall('TRUE')
+
+
+def UPPER(string: Any, /) -> FunctionCall:
+    """
+    Makes string uppercase.
+    """
+    return FunctionCall('UPPER', string)
+
+
+def VALUE(text: Any, /) -> FunctionCall:
+    """
+    Converts the text string to a number. Some exceptions apply—if the string contains certain mathematical operators(-,%) the result may not return as expected. In these scenarios we recommend using a combination of VALUE and REGEX_REPLACE to remove non-digit values from the string:
+    """
+    return FunctionCall('VALUE', text)
+
+
+def WEEKDAY(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Returns the day of the week as an integer between 0 (Sunday) and 6 (Saturday). You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default.
+    """
+    return FunctionCall('WEEKDAY', date, *(v for v in [start_day_of_week] if v is not None))
+
+
+def WEEKNUM(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Returns the week number in a year. You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default.
+    """
+    return FunctionCall('WEEKNUM', date, *(v for v in [start_day_of_week] if v is not None))
+
+
+def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Returns a date that is numDays working days after startDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates.
+    """
+    return FunctionCall('WORKDAY', start_date, num_days, *(v for v in [holidays] if v is not None))
+
+
+def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall:
+    """
+    Counts the number of working days between startDate and endDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates.
+    """
+    return FunctionCall('WORKDAY_DIFF', start_date, end_date, *(v for v in [holidays] if v is not None))
+
+
+def XOR(expression: Any, /, *expressions: Any) -> FunctionCall:
+    """
+    Returns true if an **odd** number of arguments are true.
+    """
+    return FunctionCall('XOR', expression, *expressions)
+
+
+def YEAR(date: Any, /) -> FunctionCall:
+    """
+    Returns the four-digit year of a datetime.
+    """
+    return FunctionCall('YEAR', date)
+
+
+# [[[end]]] (checksum: 6d21fb2dafa8810cefa1caad266e1453)
+# fmt: on
diff --git a/pyairtable/formulas.txt b/pyairtable/formulas.txt
new file mode 100644
index 00000000..5308c048
--- /dev/null
+++ b/pyairtable/formulas.txt
@@ -0,0 +1,82 @@
+# Retrieved from https://www.airtable.com/universe/expHF9XTWWwAT299z
+# and then edited by hand for consistency and correctness.
+
+ABS(value)  # Returns the absolute value.
+AND(expression, [expressions...])  # Returns true if all the arguments are true, returns false otherwise.
+AVERAGE(number, [numbers...])  # Returns the average of the numbers.
+BLANK()  # Returns a blank value.
+CEILING(value, [significance])  # Returns the nearest integer multiple of significance that is greater than or equal to the value. If no significance is provided, a significance of 1 is assumed.
+CONCATENATE(text, [texts...])  # Joins together the text arguments into a single text value.
+COUNT(number, [numbers...])  # Count the number of numeric items.
+COUNTA(value, [values...])  # Count the number of non-empty values. This function counts both numeric and text values.
+COUNTALL(value, [values...])  # Count the number of all elements including text and blanks.
+CREATED_TIME()  # Returns the date and time a given record was created.
+DATEADD(date, number, units)  # Adds specified "count" units to a datetime. (See `list of shared unit specifiers <https://support.airtable.com/hc/en-us/articles/226061308>`__. For this function we recommend using the full unit specifier for your desired unit.)
+DATESTR(date)  # Formats a datetime into a string (YYYY-MM-DD).
+DATETIME_DIFF(date1, date2, units)  # Returns the difference between datetimes in specified units. The difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative.
+DATETIME_FORMAT(date, [output_format])  # Formats a datetime into a specified string. See an `explanation of how to use this function with date fields <https://support.airtable.com/hc/en-us/articles/215646218>`__ or a list of `supported format specifiers <https://support.airtable.com/hc/en-us/articles/216141218>`__.
+DATETIME_PARSE(date, [input_format], [locale])  # Interprets a text string as a structured date, with optional input format and locale parameters. The output format will always be formatted 'M/D/YYYY h:mm a'.
+DAY(date)  # Returns the day of the month of a datetime in the form of a number between 1-31.
+ENCODE_URL_COMPONENT(component_string)  # Replaces certain characters with encoded equivalents for use in constructing URLs or URIs. Does not encode the following characters: ``-_.~``
+ERROR()  # Returns a generic Error value (``#ERROR!``).
+EVEN(value)  # Returns the smallest even integer that is greater than or equal to the specified value.
+EXP(power)  # Computes **Euler's number** (e) to the specified power.
+FALSE()  # Logical value false. False is represented numerically by a 0.
+FIND(stringToFind, whereToSearch, [startFromPosition])  # Finds an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition.(startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be 0.
+FLOOR(value, [significance])  # Returns the nearest integer multiple of significance that is less than or equal to the value. If no significance is provided, a significance of 1 is assumed.
+FROMNOW(date)  # Calculates the number of days between the current date and another date.
+HOUR(datetime)  # Returns the hour of a datetime as a number between 0 (12:00am) and 23 (11:00pm).
+IF(expression, if_true, if_false)  # Returns value1 if the logical argument is true, otherwise it returns value2. Can also be used to make `nested IF statements <https://support.airtable.com/hc/en-us/articles/221564887-Nested-IF-formulas>`__.
+INT(value)  # Returns the greatest integer that is less than or equal to the specified value.
+ISERROR(expr)  # Returns true if the expression causes an error.
+IS_AFTER(date1, date2)  # Determines if [date1] is later than [date2]. Returns 1 if yes, 0 if no.
+IS_BEFORE(date1, date2)  # Determines if [date1] is earlier than [date2]. Returns 1 if yes, 0 if no.
+IS_SAME(date1, date2, unit)  # Compares two dates up to a unit and determines whether they are identical. Returns 1 if yes, 0 if no.
+LAST_MODIFIED_TIME([fields...])  # Returns the date and time of the most recent modification made by a user in a non-computed field in the table.
+LEFT(string, howMany)  # Extract how many characters from the beginning of the string.
+LEN(string)  # Returns the length of a string.
+LOG(number, [base])  # Computes the logarithm of the value in provided base. The base defaults to 10 if not specified.
+LOWER(string)  # Makes a string lowercase.
+MAX(number, [numbers...])  # Returns the largest of the given numbers.
+MID(string, whereToStart, count)  # Extract a substring of count characters starting at whereToStart.
+MIN(number, [numbers...])  # Returns the smallest of the given numbers.
+MINUTE(datetime)  # Returns the minute of a datetime as an integer between 0 and 59.
+MOD(value, divisor)  # Returns the remainder after dividing the first argument by the second.
+MONTH(date)  # Returns the month of a datetime as a number between 1 (January) and 12 (December).
+NOT(expression)  # Reverses the logical value of its argument.
+NOW()  # While similar to the TODAY() function, NOW() returns the current date AND time.
+ODD(value)  # Rounds positive value up the the nearest odd number and negative value down to the nearest odd number.
+OR(expression, [expressions...])  # Returns true if any one of the arguments is true.
+POWER(base, power)  # Computes the specified base to the specified power.
+RECORD_ID()  # Returns the ID of the current record.
+REGEX_EXTRACT(string, regex)  # Returns the first substring that matches a regular expression.
+REGEX_MATCH(string, regex)  # Returns whether the input text matches a regular expression.
+REGEX_REPLACE(string, regex, replacement)  # Substitutes all matching substrings with a replacement string value.
+REPLACE(string, start_character, number_of_characters, replacement)  # Replaces the number of characters beginning with the start character with the replacement text.
+REPT(string, number)  # Repeats string by the specified number of times.
+RIGHT(string, howMany)  # Extract howMany characters from the end of the string.
+ROUND(value, precision)  # Rounds the value to the number of decimal places given by "precision." (Specifically, ROUND will round to the nearest integer at the specified precision, with ties broken by `rounding half up toward positive infinity <https://en.wikipedia.org/wiki/Rounding#Round_half_up>`__.)
+ROUNDDOWN(value, precision)  # Rounds the value to the number of decimal places given by "precision," always `rounding down <https://en.wikipedia.org/wiki/Rounding#Rounding_to_integer>`__.
+ROUNDUP(value, precision)  # Rounds the value to the number of decimal places given by "precision," always `rounding up <https://en.wikipedia.org/wiki/Rounding#Rounding_to_integer>`__.
+SEARCH(stringToFind, whereToSearch, [startFromPosition])  # Searches for an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition. (startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be empty.
+SECOND(datetime)  # Returns the second of a datetime as an integer between 0 and 59.
+SET_LOCALE(date, locale_modifier)  # Sets a specific locale for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported locale modifiers can be found `here <https://support.airtable.com/hc/en-us/articles/220340268>`__.
+SET_TIMEZONE(date, tz_identifier)  # Sets a specific timezone for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported timezone identifiers can be found `here <https://support.airtable.com/hc/en-us/articles/216141558-Supported-timezones-for-SET-TIMEZONE>`__.
+SQRT(value)  # Returns the square root of a nonnegative number.
+SUBSTITUTE(string, old_text, new_text, [index])  # Replaces occurrences of old_text in string with new_text.
+SUM(number, [numbers...])  # Sum together the numbers. Equivalent to number1 + number2 + ...
+SWITCH(expression, pattern, result, [pattern_results...])  # Takes an expression, a list of possible values for that expression, and for each one, a value that the expression should take in that case. It can also take a default value if the expression input doesn't match any of the defined patterns. In many cases, SWITCH() can be used instead `of a nested IF() formula <https://support.airtable.com/hc/en-us/articles/360041812413>`__.
+T(value)  # Returns the argument if it is text and blank otherwise.
+TIMESTR(timestamp)  # Formats a datetime into a time-only string (HH:mm:ss).
+TODAY()  # While similar to the NOW() function: TODAY() returns the current date (not the current time, if formatted, time will return 12:00am).
+TONOW(date)  # Calculates the number of days between the current date and another date.
+TRIM(string)  # Removes whitespace at the beginning and end of string.
+TRUE()  # Logical value true. The value of true is represented numerically by a 1.
+UPPER(string)  # Makes string uppercase.
+VALUE(text)  # Converts the text string to a number. Some exceptions apply—if the string contains certain mathematical operators(-,%) the result may not return as expected. In these scenarios we recommend using a combination of VALUE and REGEX_REPLACE to remove non-digit values from the string:
+WEEKDAY(date, [startDayOfWeek])  # Returns the day of the week as an integer between 0 (Sunday) and 6 (Saturday). You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default.
+WEEKNUM(date, [startDayOfWeek])  # Returns the week number in a year. You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default.
+WORKDAY(startDate, numDays, [holidays])  # Returns a date that is numDays working days after startDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates.
+WORKDAY_DIFF(startDate, endDate, [holidays])  # Counts the number of working days between startDate and endDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates.
+XOR(expression, [expressions...])  # Returns true if an **odd** number of arguments are true.
+YEAR(date)  # Returns the four-digit year of a datetime.
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index e3b9f499..3bf18054 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -1,5 +1,5 @@
 """
-Field are used to define the Airtable column type for your pyAirtable models.
+Fields define how you'll interact with your data when using the :doc:`orm`.
 
 Internally these are implemented as `descriptors <https://docs.python.org/3/howto/descriptor.html>`_,
 which allows us to define methods and type annotations for getting and setting attribute values.
@@ -212,6 +212,42 @@ def _repr_fields(self) -> List[Tuple[str, Any]]:
             ("validate_type", self.validate_type),
         ]
 
+    def eq(self, value: Any) -> "formulas.Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.EQ` comparison using this field.
+        """
+        return formulas.EQ(self, value)
+
+    def ne(self, value: Any) -> "formulas.Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.NE` comparison using this field.
+        """
+        return formulas.NE(self, value)
+
+    def gt(self, value: Any) -> "formulas.Comparison":
+        """
+        Build a :class:`~pyairtable.formulas.GT` comparison using this field.
+        """
+        return formulas.GT(self, value)
+
+    def lt(self, value: Any) -> "formulas.Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.LT` comparison using this field.
+        """
+        return formulas.LT(self, value)
+
+    def gte(self, value: Any) -> "formulas.Comparison":
+        """
+        Build a :class:`~pyairtable.formulas.GTE` comparison using this field.
+        """
+        return formulas.GTE(self, value)
+
+    def lte(self, value: Any) -> "formulas.Comparison":
+        """
+        Build an :class:`~pyairtable.formulas.LTE` comparison using this field.
+        """
+        return formulas.LTE(self, value)
+
 
 #: A generic Field whose internal and API representations are the same type.
 _BasicField: TypeAlias = Field[T, T]
@@ -893,13 +929,14 @@ class UrlField(TextField):
 names = sorted(classes + constants + extras)
 
 cog.outl("\n\n__all__ = [")
-for name in names:
+for name in ["Field", *names]:
     cog.outl(f'    "{name}",')
 cog.outl("]")
 [[[out]]]"""
 
 
 __all__ = [
+    "Field",
     "AITextField",
     "AttachmentsField",
     "AutoNumberField",
@@ -934,4 +971,8 @@ class UrlField(TextField):
     "TextField",
     "UrlField",
 ]
-# [[[end]]] (checksum: 4722c0951e598ac999d3c16ebd3d8c1c)
+# [[[end]]] (checksum: 3fa8c12315457baf170f9766fd8c9f8e)
+
+
+# Delayed import to avoid circular dependency
+from pyairtable import formulas  # noqa
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index f3e752c4..21038cd1 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -13,7 +13,7 @@
     UpdateRecordDict,
     WritableFields,
 )
-from pyairtable.formulas import OR, STR_VALUE
+from pyairtable.formulas import EQ, OR, RECORD_ID
 from pyairtable.models import Comment
 from pyairtable.orm.fields import AnyField, Field
 
@@ -350,14 +350,12 @@ def from_ids(
         record_ids = list(record_ids)
         if not fetch:
             return [cls.from_id(record_id, fetch=False) for record_id in record_ids]
-        formula = OR(
-            *[f"RECORD_ID()={STR_VALUE(record_id)}" for record_id in record_ids]
-        )
-        records = [
-            cls.from_record(record) for record in cls.get_table().all(formula=formula)
-        ]
-        records_by_id = {record.id: record for record in records}
+        # 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.get_table().all(formula=formula)
+        records = [cls.from_record(record) for record in record_data]
         # 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]
 
     @classmethod
diff --git a/tox.ini b/tox.ini
index 5f14a013..6d218d9e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -27,7 +27,8 @@ passenv =
     AIRTABLE_ENTERPRISE_ID
 addopts = -v
 testpaths = tests
-commands = python -m pytest {posargs}
+commands =
+    python -m pytest {posargs}
 deps =
     -r requirements-test.txt
     requestsmin: requests==2.22.0  # Keep in sync with setup.cfg


Test patch:
diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py
index e601cf71..084c8b71 100644
--- a/tests/integration/test_integration_api.py
+++ b/tests/integration/test_integration_api.py
@@ -5,7 +5,7 @@
 
 from pyairtable import Table
 from pyairtable import formulas as fo
-from pyairtable.utils import attachment
+from pyairtable.utils import attachment, date_to_iso_str, datetime_to_iso_str
 
 pytestmark = [pytest.mark.integration]
 
@@ -223,26 +223,27 @@ def test_batch_upsert(table: Table, cols):
 
 
 def test_integration_formula_datetime(table: Table, cols):
-    value = datetime.utcnow().replace(tzinfo=timezone.utc)
-    str_value = fo.to_airtable_value(value)
-    formula = fo.match({cols.DATETIME: str_value})
-    rv_create = table.create({cols.DATETIME: str_value})
+    now = datetime.now(timezone.utc)
+    formula = fo.match({cols.DATETIME: now})
+    rv_create = table.create({cols.DATETIME: datetime_to_iso_str(now)})
     rv_first = table.first(formula=formula)
     assert rv_first and rv_first["id"] == rv_create["id"]
 
 
 def test_integration_formula_date_filter(table: Table, cols):
-    dt = datetime.utcnow()
+    dt = datetime.now(timezone.utc)
+    dt_str = datetime_to_iso_str(dt)
     date = dt.date()
-    date_str = fo.to_airtable_value(date)
+    date_str = date_to_iso_str(date)
 
     created = []
     for _ in range(2):
-        rec = table.create({cols.DATETIME: fo.to_airtable_value(dt)})
+        rec = table.create({cols.DATETIME: dt_str})
         created.append(rec)
 
-    formula = fo.FIND(fo.STR_VALUE(date_str), fo.FIELD(cols.DATETIME))
+    formula = fo.FIND(date_str, fo.Field(cols.DATETIME))
     rv_all = table.all(formula=formula)
+    print("repr", repr(formula), "\nstr", str(formula))
     assert rv_all
     assert set([r["id"] for r in rv_all]) == set([r["id"] for r in created])
 
@@ -266,11 +267,9 @@ def test_integration_formula_composition(table: Table, cols):
     rv_create = table.create({cols.TEXT: text, cols.NUM: num, cols.BOOL: bool_})
 
     formula = fo.AND(
-        fo.EQUAL(fo.FIELD(cols.TEXT), fo.to_airtable_value(text)),
-        fo.EQUAL(fo.FIELD(cols.NUM), fo.to_airtable_value(num)),
-        fo.EQUAL(
-            fo.FIELD(cols.BOOL), fo.to_airtable_value(bool_)
-        ),  # not needs to be int()
+        fo.EQ(fo.Field(cols.TEXT), text),
+        fo.EQ(fo.Field(cols.NUM), num),
+        fo.EQ(fo.Field(cols.BOOL), bool_),  # not needs to be int()
     )
     rv_first = table.first(formula=formula)
 
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index f3cad438..7f2f6f01 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -1,10 +1,12 @@
 from posixpath import join as urljoin
+from unittest import mock
 
 import pytest
 from requests import Request
 from requests_mock import Mocker
 
 from pyairtable import Api, Base, Table
+from pyairtable.formulas import AND, EQ, Field
 from pyairtable.metadata import get_table_schema
 from pyairtable.models.schema import TableSchema
 from pyairtable.testing import fake_id, fake_record
@@ -259,6 +261,23 @@ def test_iterate(table: Table, mock_response_list, mock_records):
         assert seq_equals(pages[n], response["records"])
 
 
+def test_iterate__formula_conversion(table):
+    """
+    Test that .iterate() will convert a Formula to a str.
+    """
+    with mock.patch("pyairtable.Api.iterate_requests") as m:
+        table.all(formula=AND(EQ(Field("Name"), "Alice")))
+
+    m.assert_called_once_with(
+        method="get",
+        url=table.url,
+        fallback=mock.ANY,
+        options={
+            "formula": "AND({Name}='Alice')",
+        },
+    )
+
+
 def test_create(table: Table, mock_response_single):
     with Mocker() as mock:
         post_data = mock_response_single["fields"]
diff --git a/tests/test_formulas.py b/tests/test_formulas.py
index 0736da5a..2b85984c 100644
--- a/tests/test_formulas.py
+++ b/tests/test_formulas.py
@@ -1,113 +1,407 @@
+from datetime import date, datetime, timezone
+from decimal import Decimal
+from fractions import Fraction
+
 import pytest
+from mock import call
 
-from pyairtable.formulas import (
-    AND,
-    EQUAL,
-    FIELD,
-    FIND,
-    GREATER,
-    GREATER_EQUAL,
-    IF,
-    LESS,
-    LESS_EQUAL,
-    LOWER,
-    NOT_EQUAL,
-    OR,
-    STR_VALUE,
-    escape_quotes,
-    match,
-)
+from pyairtable import formulas as F
+from pyairtable import orm
+from pyairtable.formulas import AND, EQ, GT, GTE, LT, LTE, NE, NOT, OR
+from pyairtable.testing import fake_meta
+
+
+def test_equivalence():
+    assert F.Formula("a") == F.Formula("a")
+    assert F.Formula("a") != F.Formula("b")
+    assert F.Formula("a") != "b"
+
+
+def test_operators():
+    lft = F.Formula("a")
+    rgt = F.Formula("b")
+    assert str(lft) == "a"
+    assert str(lft & rgt) == "AND(a, b)"
+    assert str(lft | rgt) == "OR(a, b)"
+    assert str(~(lft & rgt)) == "NOT(AND(a, b))"
+    assert repr(lft & rgt) == "AND(Formula('a'), Formula('b'))"
+    assert repr(lft | rgt) == "OR(Formula('a'), Formula('b'))"
+    assert repr(~F.Formula("a")) == "NOT(Formula('a'))"
+    assert lft.flatten() is lft
+    assert repr(lft ^ rgt) == "XOR(Formula('a'), Formula('b'))"
+    assert str(lft ^ rgt) == "XOR(a, b)"
 
 
-def test_equal():
-    assert EQUAL("A", "B") == "A=B"
+@pytest.mark.parametrize(
+    "cmp,op",
+    [
+        (EQ, "="),
+        (NE, "!="),
+        (GT, ">"),
+        (GTE, ">="),
+        (LT, "<"),
+        (LTE, "<="),
+    ],
+)
+def test_comparisons(cmp, op):
+    assert repr(cmp(1, 1)) == f"{cmp.__name__}(1, 1)"
+    assert str(cmp(1, 1)) == f"1{op}1"
+    assert str(cmp(F.Formula("Foo"), "Foo")) == f"Foo{op}'Foo'"
 
 
-def test_field():
-    assert FIELD("Name") == "{Name}"
-    assert FIELD("Guest's Name") == r"{Guest\'s Name}"
+@pytest.mark.parametrize(
+    "target",
+    [
+        F.Formula("X"),  # Formula
+        F.Field("X"),  # Field
+        F.EQ(1, 1),  # Comparison
+        F.TODAY(),  # FunctionCall
+    ],
+)
+@pytest.mark.parametrize(
+    "shortcut,cmp",
+    [
+        ("eq", EQ),
+        ("ne", NE),
+        ("gt", GT),
+        ("gte", GTE),
+        ("lt", LT),
+        ("lte", LTE),
+    ],
+)
+def test_comparison_shortcuts(target, shortcut, cmp):
+    """
+    Test that methods like .eq() are exposed on all subclasses of Formula.
+    """
+    formula = getattr(target, shortcut)("Y")  # Field("X").eq("Y")
+    assert formula == cmp(target, "Y")  # EQ(Field("X"), "Y")
 
 
-def test_and():
-    assert AND("A", "B", "C") == "AND(A,B,C)"
+def test_comparison_equivalence():
+    assert EQ(1, 1) == EQ(1, 1)
+    assert EQ(1, 2) != EQ(2, 1)
+    assert EQ(1, 1) != NE(1, 1)
+    assert EQ(1, 1) != F.Formula("1=1")
 
 
-def test_or():
-    assert OR("A", "B", "C") == "OR(A,B,C)"
+def test_comparison_is_abstract():
+    with pytest.raises(NotImplementedError):
+        F.Comparison("lft", "rgt")
 
 
-def test_if():
-    assert IF(1, 0, 1) == "IF(1, 0, 1)"
+@pytest.mark.parametrize("op", ("AND", "OR"))
+def test_compound(op):
+    cmp = F.Compound(op, [EQ("foo", 1), EQ("bar", 2)])
+    assert repr(cmp) == f"{op}(EQ('foo', 1), EQ('bar', 2))"
 
 
-def test_find():
-    rv = FIND(STR_VALUE(2021), FIELD("DatetimeCol"))
-    assert rv == "FIND('2021', {DatetimeCol})"
-    rv = FIND(STR_VALUE(2021), FIELD("DatetimeCol"), 2)
-    assert rv == "FIND('2021', {DatetimeCol}, 2)"
+@pytest.mark.parametrize("op", ("AND", "OR"))
+def test_compound_with_iterable(op):
+    cmp = F.Compound(op, (EQ(f"f{n}", n) for n in range(3)))
+    assert repr(cmp) == f"{op}(EQ('f0', 0), EQ('f1', 1), EQ('f2', 2))"
 
 
-def test_string_value():
-    assert STR_VALUE("A") == "'A'"
+def test_compound_equivalence():
+    assert F.Compound("AND", [1]) == F.Compound("AND", [1])
+    assert F.Compound("AND", [1]) != F.Compound("AND", [2])
+    assert F.Compound("AND", [1]) != F.Compound("OR", [1])
+    assert F.Compound("AND", [1]) != [1]
 
 
-def test_combination():
-    formula = AND(
-        EQUAL(FIELD("First Name"), STR_VALUE("A")),
-        EQUAL(FIELD("Last Name"), STR_VALUE("B")),
-        EQUAL(FIELD("Age"), STR_VALUE(15)),
+@pytest.mark.parametrize("cmp", [AND, OR])
+@pytest.mark.parametrize(
+    "call_args",
+    [
+        # mix *components and and **fields
+        call(EQ("foo", 1), bar=2),
+        # multiple *components
+        call(EQ("foo", 1), EQ(F.Field("bar"), 2)),
+        # one item in *components that is also an iterable
+        call([EQ("foo", 1), EQ(F.Field("bar"), 2)]),
+        call((EQ("foo", 1), EQ(F.Field("bar"), 2))),
+        lambda: call(iter([EQ("foo", 1), EQ(F.Field("bar"), 2)])),
+        # test that we accept `str` and convert to formulas
+        call(["'foo'=1", "{bar}=2"]),
+    ],
+)
+def test_compound_constructors(cmp, call_args):
+    if type(call_args) is not type(call):
+        call_args = call_args()
+    compound = cmp(*call_args.args, **call_args.kwargs)
+    expected = cmp(EQ("foo", 1), EQ(F.Field("bar"), 2))
+    # compare final output expression, since the actual values will not be equal
+    assert str(compound) == str(expected)
+
+
+@pytest.mark.parametrize("cmp", ["AND", "OR", "NOT"])
+def test_compound_without_parameters(cmp):
+    with pytest.raises(
+        ValueError,
+        match=r"Compound\(\) requires at least one component",
+    ):
+        F.Compound(cmp, [])
+
+
+def test_compound_flatten():
+    a = EQ("a", "a")
+    b = EQ("b", "b")
+    c = EQ("c", "c")
+    d = EQ("d", "d")
+    e = EQ("e", "e")
+    c = (a & b) & (c & (d | e))
+    assert repr(c) == repr(
+        AND(
+            AND(EQ("a", "a"), EQ("b", "b")),
+            AND(EQ("c", "c"), OR(EQ("d", "d"), EQ("e", "e"))),
+        )
+    )
+    assert repr(c.flatten()) == repr(
+        AND(
+            EQ("a", "a"),
+            EQ("b", "b"),
+            EQ("c", "c"),
+            OR(EQ("d", "d"), EQ("e", "e")),
+        )
+    )
+    assert repr((~c).flatten()) == repr(
+        NOT(
+            AND(
+                EQ("a", "a"),
+                EQ("b", "b"),
+                EQ("c", "c"),
+                OR(EQ("d", "d"), EQ("e", "e")),
+            )
+        )
     )
-    assert formula == ("AND({First Name}='A',{Last Name}='B',{Age}='15')")
+    assert str((~c).flatten()) == (
+        "NOT(AND('a'='a', 'b'='b', 'c'='c', OR('d'='d', 'e'='e')))"
+    )
+
+
+def test_compound_flatten_circular_dependency():
+    circular = NOT(F.Formula("x"))
+    circular.components = [circular]
+    with pytest.raises(F.CircularDependency):
+        circular.flatten()
+
+
+@pytest.mark.parametrize(
+    "compound,expected",
+    [
+        (EQ(1, 1).eq(True), "(1=1)=TRUE()"),
+        (EQ(False, EQ(1, 2)), "FALSE()=(1=2)"),
+    ],
+)
+def test_compound_with_compound(compound, expected):
+    assert str(compound) == expected
+
+
+def test_not():
+    assert str(NOT(EQ("foo", 1))) == "NOT('foo'=1)"
+    assert str(NOT(foo=1)) == "NOT({foo}=1)"
+
+    with pytest.raises(TypeError):
+        NOT(EQ("foo", 1), EQ("bar", 2))
+
+    with pytest.raises(ValueError, match="requires exactly one condition; got 2"):
+        NOT(EQ("foo", 1), bar=2)
+
+    with pytest.raises(ValueError, match="requires exactly one condition; got 2"):
+        NOT(foo=1, bar=2)
+
+    with pytest.raises(ValueError, match="requires exactly one condition; got 0"):
+        NOT()
 
 
 @pytest.mark.parametrize(
-    "dict,kwargs,expected_formula",
+    "input,expected",
     [
-        ({"First Name": "John"}, {"match_any": False}, "{First Name}='John'"),
-        ({"First Name": "John"}, {"match_any": True}, "{First Name}='John'"),
-        ({"A": "1", "B": "2"}, {"match_any": False}, "AND({A}='1',{B}='2')"),
-        ({"A": "1", "B": "2"}, {"match_any": True}, "OR({A}='1',{B}='2')"),
-        ({}, {"match_any": False}, ""),
-        ({}, {"match_any": True}, ""),
+        (EQ(F.Formula("a"), "b"), "a='b'"),
+        (True, "TRUE()"),
+        (False, "FALSE()"),
+        (3, "3"),
+        (3.5, "3.5"),
+        (Decimal("3.14159265"), "3.14159265"),
+        (Fraction("4/19"), "4/19"),
+        ("asdf", "'asdf'"),
+        ("Jane's", "'Jane\\'s'"),
+        ([1, 2, 3], TypeError),
+        ((1, 2, 3), TypeError),
+        ({1, 2, 3}, TypeError),
+        ({1: 2, 3: 4}, TypeError),
+        (
+            date(2023, 12, 1),
+            "DATETIME_PARSE('2023-12-01')",
+        ),
+        (
+            datetime(2023, 12, 1, 12, 34, 56),
+            "DATETIME_PARSE('2023-12-01T12:34:56.000')",
+        ),
+        (
+            datetime(2023, 12, 1, 12, 34, 56, tzinfo=timezone.utc),
+            "DATETIME_PARSE('2023-12-01T12:34:56.000Z')",
+        ),
     ],
 )
-def test_match(dict, kwargs, expected_formula):
-    rv = match(dict, **kwargs)
-    assert rv == expected_formula
+def test_to_formula(input, expected):
+    if isinstance(expected, type) and issubclass(expected, Exception):
+        with pytest.raises(expected):
+            F.to_formula_str(input)
+    else:
+        assert F.to_formula_str(input) == expected
 
 
 @pytest.mark.parametrize(
-    "text,escaped",
+    "sig,expected",
     [
-        ("hello", "hello"),
-        ("player's name", r"player\'s name"),
-        (r"player\'s name", r"player\'s name"),
+        (call({"Field": "value"}), "{Field}='value'"),
+        (call({"A": ("=", 123), "B": ("!=", 123)}), "AND({A}=123, {B}!=123)"),
+        (call({"A": 123, "B": 123}, match_any=True), "OR({A}=123, {B}=123)"),
+        (call({"Field": ("<", 123)}), "{Field}<123"),
+        (call({"Field": ("<=", 123)}), "{Field}<=123"),
+        (call({"Field": (">", 123)}), "{Field}>123"),
+        (call({"Field": (">=", 123)}), "{Field}>=123"),
     ],
 )
-def test_escape_quotes(text, escaped):
-    rv = escape_quotes(text)
-    assert rv == escaped
+def test_match(sig, expected):
+    assert str(F.match(*sig.args, **sig.kwargs)) == expected
 
 
-def test_lower():
-    assert LOWER("TestValue") == "LOWER(TestValue)"
+def test_match__exception():
+    with pytest.raises(ValueError):
+        F.match({})
 
 
-def test_greater_equal():
-    assert GREATER_EQUAL("A", "B") == "A>=B"
+def test_function_call():
+    fc = F.FunctionCall("IF", 1, True, False)
+    assert repr(fc) == "IF(1, True, False)"
+    assert str(fc) == "IF(1, TRUE(), FALSE())"
 
 
-def test_less_equal():
-    assert LESS_EQUAL("A", "B") == "A<=B"
+def test_field_name():
+    assert F.field_name("First Name") == "{First Name}"
+    assert F.field_name("Guest's Name") == "{Guest\\'s Name}"
 
 
-def test_greater():
-    assert GREATER("A", "B") == "A>B"
+def test_quoted():
+    assert F.quoted("John") == "'John'"
+    assert F.quoted("Guest's Name") == "'Guest\\'s Name'"
 
 
-def test_less():
-    assert LESS("A", "B") == "A<B"
+@pytest.mark.parametrize(
+    "methodname,op",
+    [
+        ("eq", "="),
+        ("ne", "!="),
+        ("gt", ">"),
+        ("gte", ">="),
+        ("lt", "<"),
+        ("lte", "<="),
+    ],
+)
+def test_orm_field(methodname, op):
+    class FakeModel(orm.Model):
+        Meta = fake_meta()
+        name = orm.fields.TextField("Name")
+        age = orm.fields.IntegerField("Age")
+
+    formula = getattr(FakeModel.name, methodname)("Value")
+    formula &= GTE(FakeModel.age, 21)
+    assert F.to_formula_str(formula) == f"AND({{Name}}{op}'Value', {{Age}}>=21)"
 
 
-def test_not_equal():
-    assert NOT_EQUAL("A", "B") == "A!=B"
+@pytest.mark.parametrize(
+    "fn,argcount",
+    [
+        ("ABS", 1),
+        ("AVERAGE", 2),
+        ("BLANK", 0),
+        ("CEILING", 2),
+        ("CONCATENATE", 2),
+        ("COUNT", 2),
+        ("COUNTA", 2),
+        ("COUNTALL", 2),
+        ("CREATED_TIME", 0),
+        ("DATEADD", 3),
+        ("DATESTR", 1),
+        ("DATETIME_DIFF", 3),
+        ("DATETIME_FORMAT", 2),
+        ("DATETIME_PARSE", 3),
+        ("DAY", 1),
+        ("ENCODE_URL_COMPONENT", 1),
+        ("ERROR", 0),
+        ("EVEN", 1),
+        ("EXP", 1),
+        ("FALSE", 0),
+        ("FIND", 3),
+        ("FLOOR", 2),
+        ("FROMNOW", 1),
+        ("HOUR", 1),
+        ("IF", 3),
+        ("INT", 1),
+        ("ISERROR", 1),
+        ("IS_AFTER", 2),
+        ("IS_BEFORE", 2),
+        ("IS_SAME", 3),
+        ("LAST_MODIFIED_TIME", 1),
+        ("LEFT", 2),
+        ("LEN", 1),
+        ("LOG", 2),
+        ("LOWER", 1),
+        ("MAX", 2),
+        ("MID", 3),
+        ("MIN", 2),
+        ("MINUTE", 1),
+        ("MOD", 2),
+        ("MONTH", 1),
+        ("NOW", 0),
+        ("ODD", 1),
+        ("POWER", 2),
+        ("RECORD_ID", 0),
+        ("REGEX_EXTRACT", 2),
+        ("REGEX_MATCH", 2),
+        ("REGEX_REPLACE", 3),
+        ("REPLACE", 4),
+        ("REPT", 2),
+        ("RIGHT", 2),
+        ("ROUND", 2),
+        ("ROUNDDOWN", 2),
+        ("ROUNDUP", 2),
+        ("SEARCH", 3),
+        ("SECOND", 1),
+        ("SET_LOCALE", 2),
+        ("SET_TIMEZONE", 2),
+        ("SQRT", 1),
+        ("SUBSTITUTE", 4),
+        ("SUM", 2),
+        ("SWITCH", 4),
+        ("T", 1),
+        ("TIMESTR", 1),
+        ("TODAY", 0),
+        ("TONOW", 1),
+        ("TRIM", 1),
+        ("TRUE", 0),
+        ("UPPER", 1),
+        ("VALUE", 1),
+        ("WEEKDAY", 2),
+        ("WEEKNUM", 2),
+        ("WORKDAY", 3),
+        ("WORKDAY_DIFF", 3),
+        ("XOR", 2),
+        ("YEAR", 1),
+    ],
+)
+def test_function_calls(fn, argcount):
+    """
+    Test that the function call shortcuts in the formulas module
+    all behave as expected with the given number of arguments.
+    """
+    args = tuple(f"arg{n}" for n in range(1, argcount + 1))
+    args_repr = ", ".join(repr(arg) for arg in args)
+    args_formula = ", ".join(F.to_formula_str(arg) for arg in args)
+    result = getattr(F, fn)(*args)
+    assert isinstance(result, F.FunctionCall)
+    assert result.name == fn
+    assert result.args == args
+    assert repr(result) == f"{fn}({args_repr})"
+    assert str(result) == f"{fn}({args_formula})"
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index 3f069305..a95d5228 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -146,21 +146,30 @@ class Contact(Model):
     assert contact.name == "Alice"
 
 
-@mock.patch("pyairtable.Table.all")
-def test_from_ids(mock_all):
+@mock.patch("pyairtable.Api.iterate_requests")
+def test_from_ids(mock_api):
     fake_records = [fake_record() for _ in range(10)]
-    mock_all.return_value = fake_records
+    mock_api.return_value = [{"records": fake_records}]
 
     fake_ids = [record["id"] for record in fake_records]
     contacts = FakeModel.from_ids(fake_ids)
-    mock_all.assert_called_once()
+    mock_api.assert_called_once_with(
+        method="get",
+        url=FakeModel.get_table().url,
+        fallback=("post", FakeModel.get_table().url + "/listRecords"),
+        options={
+            "formula": "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in fake_ids)
+        },
+    )
     assert len(contacts) == len(fake_records)
     assert {c.id for c in contacts} == {r["id"] for r in fake_records}
 
+
+@mock.patch("pyairtable.Table.all")
+def test_from_ids__invalid_id(mock_all):
     # Should raise KeyError because of the invalid ID
-    mock_all.reset_mock()
     with pytest.raises(KeyError):
-        FakeModel.from_ids(fake_ids + ["recDefinitelyNotValid"])
+        FakeModel.from_ids(["recDefinitelyNotValid"])
     mock_all.assert_called_once()
 
 
@@ -173,6 +182,18 @@ def test_from_ids__no_fetch(mock_all):
     assert set(contact.id for contact in contacts) == set(fake_ids)
 
 
+@pytest.mark.parametrize("methodname", ("all", "first"))
+def test_passthrough(methodname):
+    """
+    Test that .all() and .first() pass through whatever they get.
+    """
+    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)
+
+
 def test_dynamic_model_meta():
     """
     Test that we can provide callables in our Meta class to provide