Skip to content

Commit

Permalink
Merge pull request #371 from mesozoic/fields_as_formulas
Browse files Browse the repository at this point in the history
Address edge cases in `formulas` module
  • Loading branch information
mesozoic authored May 16, 2024
2 parents be5284e + 1d3347a commit 9eba0f3
Show file tree
Hide file tree
Showing 2 changed files with 148 additions and 45 deletions.
105 changes: 72 additions & 33 deletions pyairtable/formulas.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,59 +38,62 @@ 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

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)

Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
88 changes: 76 additions & 12 deletions tests/test_formulas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -286,16 +329,30 @@ 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():
assert F.quoted("John") == "'John'"
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",
[
Expand All @@ -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(
Expand Down

0 comments on commit 9eba0f3

Please sign in to comment.