Repository: gtalarico/pyairtable
Pull request #274 (2023-06-16 16:48:39)

batch_save and batch_delete for ORM models
This adds `Model.batch_save()` and `Model.batch_delete()` to the ORM, so that implementers can reduce the number of network calls required when operating on many records at once. A few quirks:

* `Model.batch_save()` will call both `Table.batch_create()` and `Table.batch_update()`, depending on the record.
* `Model.batch_delete()` will raise `ValueError` if given a record that doesn't have an ID.
* Both methods will raise `TypeError` if given a record that is not an instance of the class.


Patch:
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 7a56f91..bc754ba 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -54,10 +54,18 @@ You can read and modify attributes. If record already exists,
     }
 }
 
-Finally, you can use :meth:`~pyairtable.orm.model.Model.delete` to delete the record:
+You can use :meth:`~pyairtable.orm.model.Model.delete` to delete the record:
 
 >>> contact.delete()
 True
+
+You can also use :meth:`~pyairtable.orm.model.Model.batch_save` and
+:meth:`~pyairtable.orm.model.Model.batch_delete` on several records at once:
+
+>>> contacts = Contact.all()
+>>> contacts.append(Contact(first_name="Alice", email="alice@example.com"))
+>>> Contact.batch_save(contacts)
+>>> Contact.batch_delete(contacts)
 """
 import abc
 from functools import lru_cache
@@ -68,7 +76,13 @@ from typing_extensions import Self as SelfType
 from pyairtable.api.api import Api
 from pyairtable.api.base import Base
 from pyairtable.api.table import Table
-from pyairtable.api.types import FieldName, RecordDict, RecordId
+from pyairtable.api.types import (
+    FieldName,
+    Fields,
+    RecordDict,
+    RecordId,
+    UpdateRecordDict,
+)
 from pyairtable.orm.fields import AnyField, Field
 
 
@@ -96,7 +110,7 @@ class Model(metaclass=abc.ABCMeta):
 
     id: str = ""
     created_time: str = ""
-    _table: Table
+    _deleted: bool = False
     _fields: Dict[FieldName, Any] = {}
     _linked_cache: Dict[RecordId, SelfType] = {}
 
@@ -156,8 +170,6 @@ class Model(metaclass=abc.ABCMeta):
         return {v.field_name: k for k, v in cls._attribute_descriptor_map().items()}
 
     def __init__(self, **fields: Any):
-        self._typecast = bool(self._get_meta("typecast", default=True))
-
         # To Store Fields
         self._fields = {}
 
@@ -203,15 +215,17 @@ class Model(metaclass=abc.ABCMeta):
         )
 
     @classmethod
-    @lru_cache
     def get_base(cls) -> Base:
         return cls.get_api().base(cls._get_meta("base_id"))
 
     @classmethod
-    @lru_cache
     def get_table(cls) -> Table:
         return cls.get_base().table(cls._get_meta("table_name"))
 
+    @classmethod
+    def _typecast(cls) -> bool:
+        return bool(cls._get_meta("typecast", default=True))
+
     def exists(self) -> bool:
         """Returns boolean indicating if instance exists (has 'id' attribute)"""
         return bool(self.id)
@@ -223,15 +237,16 @@ class Model(metaclass=abc.ABCMeta):
 
         Returns `True` if was created and `False` if it was updated
         """
+        if self._deleted:
+            raise RuntimeError(f"{self.id} was deleted")
         table = self.get_table()
-        record = self.to_record(only_writable=True)
-        fields = record["fields"]
+        fields = self.to_record(only_writable=True)["fields"]
 
         if not self.id:
-            record = table.create(fields, typecast=self._typecast)
+            record = table.create(fields, typecast=self._typecast())
             did_create = True
         else:
-            record = table.update(self.id, fields, typecast=self._typecast)
+            record = table.update(self.id, fields, typecast=self._typecast())
             did_create = False
 
         self.id = record["id"]
@@ -244,6 +259,7 @@ class Model(metaclass=abc.ABCMeta):
             raise ValueError("cannot be deleted because it does not have id")
         table = self.get_table()
         result = table.delete(self.id)
+        self._deleted = True
         # Is it even possible to get "deleted" False?
         return bool(result["deleted"])
 
@@ -337,3 +353,45 @@ class Model(metaclass=abc.ABCMeta):
 
     def __repr__(self) -> str:
         return "<Model={} {}>".format(self.__class__.__name__, hex(id(self)))
+
+    @classmethod
+    def batch_save(cls, models: List[SelfType]) -> None:
+        """
+        Saves a list of model instances to the Airtable API with as few
+        network requests as possible. Can accept a mixture of new records
+        (which have not been saved yet) and existing records that have IDs.
+        """
+        if not all(isinstance(model, cls) for model in models):
+            raise TypeError(set(type(model) for model in models))
+
+        create_models = [model for model in models if not model.id]
+        update_models = [model for model in models if model.id]
+        create_records: List[Fields] = [
+            record["fields"]
+            for model in create_models
+            if (record := model.to_record(only_writable=True))
+        ]
+        update_records: List[UpdateRecordDict] = [
+            {"id": record["id"], "fields": record["fields"]}
+            for model in update_models
+            if (record := model.to_record(only_writable=True))
+        ]
+
+        table = cls.get_table()
+        table.batch_update(update_records, typecast=cls._typecast())
+        created_records = table.batch_create(create_records, typecast=cls._typecast())
+        for model, created_record in zip(create_models, created_records):
+            model.id = created_record["id"]
+            model.created_time = created_record["createdTime"]
+
+    @classmethod
+    def batch_delete(cls, models: List[SelfType]) -> None:
+        """
+        Deletes a list of model instances from Airtable. Raises ``ValueError`` if
+        given a model which has never been saved to Airtable.
+        """
+        if not all(model.id for model in models):
+            raise ValueError("cannot delete an unsaved model")
+        if not all(isinstance(model, cls) for model in models):
+            raise TypeError(set(type(model) for model in models))
+        cls.get_table().batch_delete([model.id for model in models])


Test patch:
diff --git a/tests/test_orm.py b/tests/test_orm.py
index 8493a39..befcc2a 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -86,7 +86,7 @@ def test_model():
     assert contact.first_name == "Gui"
     assert not contact.id
 
-    # delete
+    # save
     with mock.patch.object(Table, "create") as m_save:
         m_save.return_value = {"id": "id", "createdTime": "time"}
         contact.save()
@@ -101,6 +101,10 @@ def test_model():
 
     assert m_delete.called
 
+    # cannot save a deleted record
+    with pytest.raises(RuntimeError):
+        contact.save()
+
     record = contact.to_record()
     assert record["id"] == contact.id
     assert record["createdTime"] == contact.created_time
@@ -234,3 +238,104 @@ def test_undeclared_field(requests_mock, test_case):
     _, get_model_instance = test_case
     instance = get_model_instance(Address, record["id"])
     assert instance.to_record()["fields"] == {"Number": "123", "Street": "Fake St"}
+
+
+@mock.patch("pyairtable.Table.batch_create")
+@mock.patch("pyairtable.Table.batch_update")
+def test_batch_save(mock_update, mock_create):
+    """
+    Test that we can pass multiple unsaved Model instances (or dicts) to batch_save
+    and it will create or update them all in as few requests as possible.
+    """
+    addr1 = Address(number="123", street="Fake St")
+    addr2 = Address(number="456", street="Fake St")
+    addr3 = Address.from_record(
+        {
+            "id": "recExistingRecord",
+            "createdTime": datetime.utcnow().isoformat(),
+            "fields": {"Number": "789", "Street": "Fake St"},
+        }
+    )
+
+    mock_create.return_value = [
+        fake_record(id="abc", Number="123", Street="Fake St"),
+        fake_record(id="def", Number="456", Street="Fake St"),
+    ]
+
+    # Just like model.save(), Model.batch_save() will set IDs on new records.
+    Address.batch_save([addr1, addr2, addr3])
+    assert addr1.id == "rec00000000000abc"
+    assert addr2.id == "rec00000000000def"
+    assert addr3.id == "recExistingRecord"
+
+    mock_create.assert_called_once_with(
+        [
+            {"Number": "123", "Street": "Fake St"},
+            {"Number": "456", "Street": "Fake St"},
+        ],
+        typecast=True,
+    )
+    mock_update.assert_called_once_with(
+        [
+            {
+                "id": "recExistingRecord",
+                "fields": {"Number": "789", "Street": "Fake St"},
+            },
+        ],
+        typecast=True,
+    )
+
+
+@mock.patch("pyairtable.Table.batch_create")
+@mock.patch("pyairtable.Table.batch_update")
+def test_batch_save__invalid_class(mock_update, mock_create):
+    """
+    Test that batch_save() raises TypeError if a model is given which is not an
+    instance of the model being called.
+    """
+    with pytest.raises(TypeError):
+        Address.batch_save([Contact()])
+
+    assert mock_update.call_count == 0
+    assert mock_create.call_count == 0
+
+
+@mock.patch("pyairtable.Table.batch_delete")
+def test_batch_delete(mock_delete):
+    """
+    Test that we can pass a list of models to Model.batch_delete.
+    """
+    addresses = [
+        Address.from_record(fake_record(id=n, Number=str(n), Street="Fake St"))
+        for n in range(20)
+    ]
+    Address.batch_delete(addresses)
+    mock_delete.assert_called_once_with([record.id for record in addresses])
+
+
+@mock.patch("pyairtable.Table.batch_delete")
+def test_batch_delete__unsaved_record(mock_delete):
+    """
+    Test that we get a ValueError (and make no deletions) if Model.batch_delete
+    receives any models which have not been created yet.
+    """
+    addresses = [
+        Address.from_record(fake_record(Number="1", Street="Fake St")),
+        Address(number="2", street="Fake St"),
+    ]
+    with pytest.raises(ValueError):
+        Address.batch_delete(addresses)
+
+    assert mock_delete.call_count == 0
+
+
+@mock.patch("pyairtable.Table.batch_delete")
+def test_batch_delete__invalid_class(mock_delete):
+    """
+    Test that batch_delete() raises TypeError if a model is given which is not an
+    instance of the model being called.
+    """
+    with pytest.raises(TypeError):
+        Address.batch_delete([Contact.from_record(fake_record())])
+
+    assert mock_delete.call_count == 0