Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions airtabledb/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

from pyairtable import Table
from shillelagh.adapters.base import Adapter
from shillelagh.fields import Boolean, Field, Float, Order, String
from shillelagh.fields import Boolean, Field, Order, String
from shillelagh.filters import Equal, Filter, IsNotNull, IsNull, NotEqual, Range
from shillelagh.typing import RequestedOrder

from .fields import MaybeListString
from .fields import AirtableFloat, MaybeList, MaybeListString
from .formulas import get_airtable_formula
from .types import BaseMetadata, TypedDict

Expand All @@ -34,21 +34,22 @@ def guess_field(values: List[Any]) -> Field:
if types0 is str:
return String(**FIELD_KWARGS)
elif types0 is float:
return Float(**FIELD_KWARGS)
return AirtableFloat(**FIELD_KWARGS)
elif types0 is int:
# This seems safest as there are cases where we get floats and ints
return Float(**FIELD_KWARGS)
return AirtableFloat(**FIELD_KWARGS)
elif types0 is bool:
return Boolean(**FIELD_KWARGS)
elif types0 is list:
# TODO(cancan101): do more work + make a Field for this
return MaybeListString(**FIELD_KWARGS)
return MaybeList(
guess_field([v for vi in values for v in vi]), **FIELD_KWARGS
)
elif types == {float, int}:
return Float(**FIELD_KWARGS)
return AirtableFloat(**FIELD_KWARGS)
elif types == {float, dict} or types == {int, dict} or types == {int, float, dict}:
# TODO(cancan101) check the dict + make a Field for this
# This seems safest as there are cases where we get floats and ints
return MaybeListString(**FIELD_KWARGS)
# TODO(cancan101) check the dict
return AirtableFloat(**FIELD_KWARGS)

return MaybeListString(**FIELD_KWARGS)

Expand Down
116 changes: 101 additions & 15 deletions airtabledb/fields.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,127 @@
import math
from typing import List, Optional, Union

from shillelagh.fields import Field
from shillelagh.fields import Field, String

from .types import TypedDict

# -----------------------------------------------------------------------------

AirtableRawInputTypes = Union[str, int, float]

class AirtableFloatType(TypedDict):
specialValue: str


AirtableRawNumericInputTypes = Union[int, float, AirtableFloatType]
AirtableRawInputTypes = Union[str, AirtableRawNumericInputTypes]
AirtableInputTypes = Union[AirtableRawInputTypes, List[AirtableRawInputTypes]]
AirtablePrimitiveTypes = Union[str, int, float]


SPECIAL_VALUE_KEY = "specialValue"

NAN_REPRESENTATION = AirtableFloatType(specialValue="NaN")
INF_REPRESENTATION = AirtableFloatType(specialValue="Infinity")
INF_NEG_REPRESENTATION = AirtableFloatType(specialValue="-Infinity")


class MaybeListString(
Field[AirtableInputTypes, AirtablePrimitiveTypes] # type: ignore
):
# These types are not really "correct" given the polymorphism
type = "TEXT"
db_api_type = "STRING"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self._scalar_handler = AirtableScalar()
self._list_handler = MaybeList(field=self._scalar_handler)

def parse(
self, value: Optional[AirtableInputTypes]
) -> Optional[AirtablePrimitiveTypes]:
if value is None:
return None

if isinstance(value, (str, int, float)):
return value
elif isinstance(value, list):
if len(value) == 0:
return None
elif len(value) == 1:
ret = value[0]
# TODO(cancan101): Do we have to handle nested arrays / special types?
if ret is not None and not isinstance(ret, (str, int, float)):
raise TypeError(f"Unknown type: {type(ret)}")
return ret
return self._list_handler.parse(value)
else:
return self._scalar_handler.parse(value)


class MaybeList(
Field[List[AirtableRawInputTypes], AirtablePrimitiveTypes] # type: ignore
):
def __init__(self, field: Field, **kwargs):
super().__init__(**kwargs)
self.field = field
self.type = field.type
self.db_api_type = field.db_api_type

def parse(
self, value: Optional[List[AirtableRawInputTypes]]
) -> Optional[AirtablePrimitiveTypes]:
if value is None:
return None
elif len(value) == 0:
return None
elif len(value) == 1:
ret = value[0]
# TODO(cancan101): Do we have to handle nested arrays?
# We handle dict here to allow for "special values"
if ret is not None and not isinstance(ret, (str, int, float, dict)):
raise TypeError(f"Unknown type: {type(ret)}")
return self.field.parse(ret)
else:
raise ValueError("Unable to handle list of length > 1")


class AirtableFloat(
Field[AirtableRawNumericInputTypes, Union[float, int]] # type: ignore
):
"""An Airtable float."""

type = "REAL"
db_api_type = "NUMBER"

def parse(
self, value: Optional[AirtableRawNumericInputTypes]
) -> Optional[Union[float, int]]:
if isinstance(value, dict):
if value == NAN_REPRESENTATION:
return math.nan
elif value == INF_REPRESENTATION:
return math.inf
elif value == INF_NEG_REPRESENTATION:
return -math.inf
else:
raise ValueError("Unable to handle list of length > 1")
elif value == {"specialValue": "NaN"}:
raise ValueError(f"Unknown float representation: {value}")
return value


class AirtableScalar(
Field[AirtableRawInputTypes, AirtablePrimitiveTypes] # type: ignore
):
# These types are not really "correct" given the polymorphism
type = "TEXT"
db_api_type = "STRING"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self._string_handler = String()
self._float_handler = AirtableFloat()

def parse(
self, value: Optional[AirtableInputTypes]
) -> Optional[AirtablePrimitiveTypes]:
if value is None:
return None
elif isinstance(value, str):
return self._string_handler.parse(value)
elif isinstance(value, (int, float)):
return self._float_handler.parse(value)
elif isinstance(value, dict) and len(value) == 1 and SPECIAL_VALUE_KEY in value:
return self._float_handler.parse(value)
else:
raise TypeError(f"Unknown type: {type(value)}")
21 changes: 10 additions & 11 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
from shillelagh.fields import Boolean, Float, Order, String
from shillelagh.fields import Boolean, Order, String

from airtabledb import fields
from airtabledb.adapter import get_airtable_sort, guess_field


def test_guess_field():
assert type(guess_field([1])) is Float
assert type(guess_field([1.5])) is Float
assert type(guess_field([1, 1.5])) is Float
assert type(guess_field([1])) is fields.AirtableFloat
assert type(guess_field([1.5])) is fields.AirtableFloat
assert type(guess_field([1, 1.5])) is fields.AirtableFloat

assert type(guess_field([True])) is Boolean

assert type(guess_field(["a"])) is String

assert type(guess_field([1, {"specialValue": "NaN"}])) is fields.MaybeListString
assert type(guess_field([1.5, {"specialValue": "NaN"}])) is fields.MaybeListString
assert (
type(guess_field([1.5, 1, {"specialValue": "NaN"}])) is fields.MaybeListString
)
assert type(guess_field([1, {"specialValue": "NaN"}])) is fields.AirtableFloat
assert type(guess_field([1.5, {"specialValue": "NaN"}])) is fields.AirtableFloat
assert type(guess_field([1.5, 1, {"specialValue": "NaN"}])) is fields.AirtableFloat

# Not sure if this comes up in practice
assert type(guess_field([["a"], ["b"]])) is fields.MaybeListString
string_list_field = guess_field([["a"], ["b"]])
assert type(string_list_field) is fields.MaybeList
assert type(string_list_field.field) is String

# Not sure if this comes up in practice
assert type(guess_field(["a", 4])) is fields.MaybeListString
Expand Down
81 changes: 67 additions & 14 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,88 @@
import math

import pytest

from airtabledb.fields import MaybeListString
from airtabledb.fields import AirtableFloat, AirtableScalar, MaybeList, MaybeListString


def test_maybe_list_string_none():
assert MaybeListString().parse(None) is None
field = MaybeListString()

assert field.parse(None) is None


def test_maybe_list_string_primitive():
assert MaybeListString().parse("a") == "a"
assert MaybeListString().parse(1) == 1
assert MaybeListString().parse(1.5) == 1.5
field = MaybeListString()

assert field.parse("a") == "a"
assert field.parse(1) == 1
assert field.parse(1.5) == 1.5


def test_maybe_list_string_list():
assert MaybeListString().parse([]) is None
assert MaybeListString().parse(["a"]) == "a"
assert MaybeListString().parse([1]) == 1
assert MaybeListString().parse([1.5]) == 1.5
assert MaybeListString().parse([None]) is None
field = MaybeListString()

assert field.parse([]) is None
assert field.parse(["a"]) == "a"
assert field.parse([1]) == 1
assert field.parse([1.5]) == 1.5
assert field.parse([None]) is None

with pytest.raises(TypeError):
assert MaybeListString().parse([{}])
assert field.parse([{}])

# At some point we may handle this case differently (w/o error)
with pytest.raises(ValueError):
assert MaybeListString().parse([1, 2])
assert field.parse([1, 2])


def test_maybe_list_string_special():
assert MaybeListString().parse({"specialValue": "NaN"}) is None
field = MaybeListString()
assert math.isnan(field.parse({"specialValue": "NaN"}))
assert math.isnan(field.parse([{"specialValue": "NaN"}]))

with pytest.raises(ValueError):
field.parse({"specialValue": "XXX"})


def test_airtable_float_special():
field = AirtableFloat()
assert math.isnan(field.parse({"specialValue": "NaN"}))
assert math.isinf(field.parse({"specialValue": "Infinity"}))
assert math.isinf(field.parse({"specialValue": "-Infinity"}))

with pytest.raises(ValueError):
field.parse({"specialValue": "XXX"})


def test_maybe_list():
field = MaybeList(AirtableScalar())
assert field.parse(None) is None
assert field.parse([]) is None
assert field.parse(["a"]) == "a"
assert field.parse([1]) == 1
assert field.parse([1.5]) == 1.5
assert field.parse([None]) is None

with pytest.raises(TypeError):
assert field.parse([{}])

with pytest.raises(TypeError):
assert field.parse([b"asdf"])

# At some point we may handle this case differently (w/o error)
with pytest.raises(ValueError):
assert field.parse([1, 2])


def test_airtable_scalar():
field = AirtableScalar()

assert field.parse(None) is None
assert field.parse(1) == 1
assert field.parse(1.5) == 1.5
assert field.parse("a") == "a"
assert math.isnan(field.parse({"specialValue": "NaN"}))

with pytest.raises(TypeError):
MaybeListString().parse({"specialValue": "XXX"})
assert field.parse({})