From 1d3347a30157305b8df4025a6fde6012807b43fb Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 13 May 2024 10:00:17 -0700 Subject: [PATCH] Address edge cases in `formulas` module --- pyairtable/formulas.py | 105 ++++++++++++++++++++++++++++------------- tests/test_formulas.py | 88 +++++++++++++++++++++++++++++----- 2 files changed, 148 insertions(+), 45 deletions(-) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index a6be5747..da3bbeea 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -38,17 +38,17 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"{self.__class__.__name__}({self.value!r})" - def __and__(self, other: "Formula") -> "Formula": - return AND(self, other) + def __and__(self, other: Any) -> "Formula": + return AND(self, to_formula(other)) - def __or__(self, other: "Formula") -> "Formula": - return OR(self, other) + def __or__(self, other: Any) -> "Formula": + return OR(self, to_formula(other)) - def __xor__(self, other: "Formula") -> "Formula": - return XOR(self, other) + def __xor__(self, other: Any) -> "Formula": + return XOR(self, to_formula(other)) def __eq__(self, other: Any) -> bool: - if not isinstance(other, Formula): + if not isinstance(other, type(self)): return False return other.value == self.value @@ -56,41 +56,44 @@ def __invert__(self) -> "Formula": return NOT(self) def flatten(self) -> "Formula": + """ + Return a new formula with nested boolean statements flattened. + """ return self def eq(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.EQ` comparison using this field. + Build an :class:`~pyairtable.formulas.EQ` comparison using this formula. """ return EQ(self, value) def ne(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.NE` comparison using this field. + Build an :class:`~pyairtable.formulas.NE` comparison using this formula. """ return NE(self, value) def gt(self, value: Any) -> "Comparison": """ - Build a :class:`~pyairtable.formulas.GT` comparison using this field. + Build a :class:`~pyairtable.formulas.GT` comparison using this formula. """ return GT(self, value) def lt(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.LT` comparison using this field. + Build an :class:`~pyairtable.formulas.LT` comparison using this formula. """ return LT(self, value) def gte(self, value: Any) -> "Comparison": """ - Build a :class:`~pyairtable.formulas.GTE` comparison using this field. + Build a :class:`~pyairtable.formulas.GTE` comparison using this formula. """ return GTE(self, value) def lte(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.LTE` comparison using this field. + Build an :class:`~pyairtable.formulas.LTE` comparison using this formula. """ return LTE(self, value) @@ -101,7 +104,7 @@ class Field(Formula): """ def __str__(self) -> str: - return "{%s}" % escape_quotes(self.value) + return field_name(self.value) class Comparison(Formula): @@ -372,6 +375,56 @@ def match(field_values: Fields, *, match_any: bool = False) -> Formula: return AND(*expressions) +def to_formula(value: Any) -> Formula: + """ + Converts the given value into a Formula object. + + When given a Formula object, it returns the object as-is: + + >>> to_formula(EQ(F.Formula("a"), "b")) + EQ(Formula('a'), 'b') + + When given a scalar value, it simply wraps that value's string representation + in a Formula object: + + >>> to_formula(1) + Formula('1') + >>> to_formula('foo') + Formula("'foo'") + + Boolean and date values receive custom function calls: + + >>> to_formula(True) + TRUE() + >>> to_formula(False) + FALSE() + >>> to_formula(datetime.date(2023, 12, 1)) + DATETIME_PARSE('2023-12-01') + >>> to_formula(datetime.datetime(2023, 12, 1, 12, 34, 56)) + DATETIME_PARSE('2023-12-01T12:34:56.000Z') + """ + if isinstance(value, Formula): + return value + if isinstance(value, bool): + return TRUE() if value else FALSE() + if isinstance(value, (int, float, Decimal, Fraction)): + return Formula(str(value)) + if isinstance(value, str): + return Formula(quoted(value)) + if isinstance(value, datetime.datetime): + return DATETIME_PARSE(datetime_to_iso_str(value)) + if isinstance(value, datetime.date): + return DATETIME_PARSE(date_to_iso_str(value)) + + # Runtime import to avoid circular dependency + import pyairtable.orm + + if isinstance(value, pyairtable.orm.fields.Field): + return Field(value.field_name) + + raise TypeError(value, type(value)) + + def to_formula_str(value: Any) -> str: """ Converts the given value into a string representation that can be used @@ -400,24 +453,7 @@ def to_formula_str(value: Any) -> str: >>> 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)) + return str(to_formula(value)) def quoted(value: str) -> str: @@ -463,7 +499,10 @@ def field_name(name: str) -> str: >>> field_name("Guest's Name") "{Guest\\'s Name}" """ - return "{%s}" % escape_quotes(name) + # This will not actually work with field names that contain more + # than one closing curly brace; that's a limitation of Airtable. + # Our library will escape all closing braces, but the API will fail. + return "{%s}" % escape_quotes(name.replace("}", r"\}")) class FunctionCall(Formula): diff --git a/tests/test_formulas.py b/tests/test_formulas.py index b7b44c17..0eb8a3a3 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -213,6 +213,48 @@ def test_not(): NOT() +@pytest.mark.parametrize( + "input,expected", + [ + (EQ(F.Formula("a"), "b"), EQ(F.Formula("a"), "b")), + (True, F.TRUE()), + (False, F.FALSE()), + (3, F.Formula("3")), + (3.5, F.Formula("3.5")), + (Decimal("3.14159265"), F.Formula("3.14159265")), + (Fraction("4/19"), F.Formula("4/19")), + ("asdf", F.Formula("'asdf'")), + ("Jane's", F.Formula("'Jane\\'s'")), + ([1, 2, 3], TypeError), + ((1, 2, 3), TypeError), + ({1, 2, 3}, TypeError), + ({1: 2, 3: 4}, TypeError), + ( + date(2023, 12, 1), + F.DATETIME_PARSE("2023-12-01"), + ), + ( + datetime(2023, 12, 1, 12, 34, 56), + F.DATETIME_PARSE("2023-12-01T12:34:56.000"), + ), + ( + datetime(2023, 12, 1, 12, 34, 56, tzinfo=timezone.utc), + F.DATETIME_PARSE("2023-12-01T12:34:56.000Z"), + ), + (orm.fields.Field("Foo"), F.Field("Foo")), + ], +) +def test_to_formula(input, expected): + """ + Test that certain values are not changed at all by to_formula() + """ + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + F.to_formula(input) + else: + assert F.to_formula(input) == expected + + @pytest.mark.parametrize( "input,expected", [ @@ -241,9 +283,10 @@ def test_not(): datetime(2023, 12, 1, 12, 34, 56, tzinfo=timezone.utc), "DATETIME_PARSE('2023-12-01T12:34:56.000Z')", ), + (orm.fields.Field("Foo"), "{Foo}"), ], ) -def test_to_formula(input, expected): +def test_to_formula_str(input, expected): if isinstance(expected, type) and issubclass(expected, Exception): with pytest.raises(expected): F.to_formula_str(input) @@ -286,9 +329,16 @@ def test_function_call_equivalence(): assert F.TODAY() != F.Formula("TODAY()") -def test_field_name(): - assert F.field_name("First Name") == "{First Name}" - assert F.field_name("Guest's Name") == "{Guest\\'s Name}" +@pytest.mark.parametrize( + "input,expected", + [ + ("First Name", "{First Name}"), + ("Guest's Name", r"{Guest\'s Name}"), + ("With {Curly Braces}", r"{With {Curly Braces\}}"), + ], +) +def test_field_name(input, expected): + assert F.field_name(input) == expected def test_quoted(): @@ -296,6 +346,13 @@ def test_quoted(): assert F.quoted("Guest's Name") == "'Guest\\'s Name'" +class FakeModel(orm.Model): + Meta = fake_meta() + name = orm.fields.TextField("Name") + email = orm.fields.EmailField("Email") + phone = orm.fields.PhoneNumberField("Phone") + + @pytest.mark.parametrize( "methodname,op", [ @@ -307,15 +364,22 @@ def test_quoted(): ("lte", "<="), ], ) -def test_orm_field(methodname, op): - class FakeModel(orm.Model): - Meta = fake_meta() - name = orm.fields.TextField("Name") - age = orm.fields.IntegerField("Age") - +def test_orm_field_comparison_shortcuts(methodname, op): + """ + Test each shortcut method on an ORM field. + """ formula = getattr(FakeModel.name, methodname)("Value") - formula &= GTE(FakeModel.age, 21) - assert F.to_formula_str(formula) == f"AND({{Name}}{op}'Value', {{Age}}>=21)" + assert F.to_formula_str(formula) == f"{{Name}}{op}'Value'" + + +def test_orm_field_as_formula(): + """ + Test different ways of using an ORM field in a formula. + """ + formula = FakeModel.email.ne(F.BLANK()) | NE(FakeModel.phone, F.BLANK()) + formula &= FakeModel.name + result = F.to_formula_str(formula.flatten()) + assert result == "AND(OR({Email}!=BLANK(), {Phone}!=BLANK()), {Name})" @pytest.mark.parametrize(