Repository: gtalarico/pyairtable (python)

Issue:
Model.save() specific fields
Is there a way to save specific fields of a Model and not the entire record while using the `Model.save` method?

Indeed, I have a record that has a pretty heavy payload (number of fields). I'm fetching the record, doing some operations/updates on it on **specific fields** and then save again.

I would like to only save/update those specific fields instead of posting the whole record (see [pyairtable.orm.model.Model.save()](https://github.com/gtalarico/pyairtable/blob/7029671178f2964848104fde3e13e09d37a20edc/pyairtable/orm/model.py#L237C9-L237C62)):

I couldn't find a way with the current ORM. Is there a solution?

If not, we could update the `pyairtable.orm.model.Model.save` method to (adding the `fields_to_save: List = []` argument):

```python
    def save(self, fields_to_save: List = []) -> bool:
        if self._deleted:
            raise RuntimeError(f"{self.id} was deleted")
        table = self.meta.table
        fields = self.to_record(only_writable=True)["fields"]

        if fields_to_save:
            fields = {key: value for key, value in fields.items() if key in fields_to_save}

        if not self.id:
            record = table.create(fields, typecast=self.meta.typecast)
            did_create = True
        else:
            record = table.update(self.id, fields, typecast=self.meta.typecast)
            did_create = False

        self.id = record["id"]
        self.created_time = datetime_from_iso_str(record["createdTime"])
        return did_create
```

Pull request description:
Track changes to ORM fields and only save modified fields (unless forced)
This branch adds change tracking to the ORM Model and Field classes, so that we can avoid sending field values to the API if they haven't been modified. This is the default behavior now; implementers can override this behavior by calling `obj.save(force=True)` on a model instance.

Fixes #378 

FYI @gildastone @bapcon

Patch:
diff --git a/pyairtable/orm/changes.py b/pyairtable/orm/changes.py
new file mode 100644
index 0000000..021b8d2
--- /dev/null
+++ b/pyairtable/orm/changes.py
@@ -0,0 +1,48 @@
+from typing import Callable, Iterable, List, SupportsIndex, Union
+
+from typing_extensions import TypeVar
+
+T = TypeVar("T")
+
+
+class ChangeNotifyingList(List[T]):
+    """
+    A list that calls a callback any time it is changed. This allows us to know
+    if any mutations happened to the lists returned from linked record fields.
+    """
+
+    def __init__(self, *args: Iterable[T], on_change: Callable[[], None]) -> None:
+        super().__init__(*args)
+        self._on_change = on_change
+
+    def __setitem__(self, index: SupportsIndex, value: T) -> None:  # type: ignore[override]
+        self._on_change()
+        return super().__setitem__(index, value)
+
+    def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
+        self._on_change()
+        return super().__delitem__(key)
+
+    def append(self, object: T) -> None:
+        self._on_change()
+        return super().append(object)
+
+    def insert(self, index: SupportsIndex, object: T) -> None:
+        self._on_change()
+        return super().insert(index, object)
+
+    def remove(self, value: T) -> None:
+        self._on_change()
+        return super().remove(value)
+
+    def clear(self) -> None:
+        self._on_change()
+        return super().clear()
+
+    def extend(self, iterable: Iterable[T]) -> None:
+        self._on_change()
+        return super().extend(iterable)
+
+    def pop(self, index: SupportsIndex = -1) -> T:
+        self._on_change()
+        return super().pop(index)
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index a18cfa2..32c0f53 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -29,6 +29,7 @@ import abc
 import importlib
 from datetime import date, datetime, timedelta
 from enum import Enum
+from functools import partial
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -58,6 +59,7 @@ from pyairtable.api.types import (
     RecordId,
 )
 from pyairtable.exceptions import MissingValueError, MultipleValuesError
+from pyairtable.orm.changes import ChangeNotifyingList
 
 if TYPE_CHECKING:
     from pyairtable.orm import Model  # noqa
@@ -176,11 +178,13 @@ class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta):
 
     def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None:
         self._raise_if_readonly()
-        if not hasattr(instance, "_fields"):
-            instance._fields = {}
         if self.validate_type and value is not None:
             self.valid_or_raise(value)
+        if not hasattr(instance, "_fields"):
+            instance._fields = {}
         instance._fields[self.field_name] = value
+        if hasattr(instance, "_changed"):
+            instance._changed[self.field_name] = True
 
     def __delete__(self, instance: "Model") -> None:
         raise AttributeError(f"cannot delete {self._description}")
@@ -486,15 +490,23 @@ class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM], List[T_O
         return self._get_list_value(instance)
 
     def _get_list_value(self, instance: "Model") -> List[T_ORM]:
-        value = cast(List[T_ORM], instance._fields.get(self.field_name))
+        value = instance._fields.get(self.field_name)
         # If Airtable returns no value, substitute an empty list.
         if value is None:
             value = []
-            # For implementers to be able to modify this list in place
-            # and persist it later when they call .save(), we need to
-            # set this empty list as the field's value.
-            if not self.readonly:
-                instance._fields[self.field_name] = value
+        if self.readonly:
+            return value
+
+        # We need to keep track of any mutations to this list, so we know
+        # whether to write the field back to the API when the model is saved.
+        if not isinstance(value, ChangeNotifyingList):
+            on_change = partial(instance._changed.__setitem__, self.field_name, True)
+            value = ChangeNotifyingList[T_ORM](value, on_change=on_change)
+
+        # For implementers to be able to modify this list in place
+        # and persist it later when they call .save(), we need to
+        # set the list as the field's value.
+        instance._fields[self.field_name] = value
         return value
 
 
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 65430ad..88313c1 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -125,6 +125,7 @@ class Model:
     _deleted: bool = False
     _fetched: bool = False
     _fields: Dict[FieldName, Any]
+    _changed: Dict[FieldName, bool]
     _memoized: ClassVar[Dict[RecordId, SelfType]]
 
     def __init_subclass__(cls, **kwargs: Any):
@@ -133,10 +134,21 @@ class Model:
         cls._validate_class()
         super().__init_subclass__(**kwargs)
 
-    def __repr__(self) -> str:
-        if not self.id:
-            return f"<unsaved {self.__class__.__name__}>"
-        return f"<{self.__class__.__name__} id={self.id!r}>"
+    @classmethod
+    def _validate_class(cls) -> None:
+        # Verify required Meta attributes were set (but don't call any callables)
+        assert cls.meta.get("api_key", required=True, call=False)
+        assert cls.meta.get("base_id", required=True, call=False)
+        assert cls.meta.get("table_name", required=True, call=False)
+
+        model_attributes = [a for a in cls.__dict__.keys() if not a.startswith("__")]
+        overridden = set(model_attributes).intersection(Model.__dict__.keys())
+        if overridden:
+            raise ValueError(
+                "Class {cls} fields clash with existing method: {name}".format(
+                    cls=cls.__name__, name=overridden
+                )
+            )
 
     @classmethod
     def _attribute_descriptor_map(cls) -> Dict[str, AnyField]:
@@ -199,21 +211,13 @@ class Model:
                 raise AttributeError(key)
             setattr(self, key, value)
 
-    @classmethod
-    def _validate_class(cls) -> None:
-        # Verify required Meta attributes were set (but don't call any callables)
-        assert cls.meta.get("api_key", required=True, call=False)
-        assert cls.meta.get("base_id", required=True, call=False)
-        assert cls.meta.get("table_name", required=True, call=False)
+        # Only start tracking changes after the object is created
+        self._changed = {}
 
-        model_attributes = [a for a in cls.__dict__.keys() if not a.startswith("__")]
-        overridden = set(model_attributes).intersection(Model.__dict__.keys())
-        if overridden:
-            raise ValueError(
-                "Class {cls} fields clash with existing method: {name}".format(
-                    cls=cls.__name__, name=overridden
-                )
-            )
+    def __repr__(self) -> str:
+        if not self.id:
+            return f"<unsaved {self.__class__.__name__}>"
+        return f"<{self.__class__.__name__} id={self.id!r}>"
 
     def exists(self) -> bool:
         """
@@ -221,31 +225,45 @@ class Model:
         """
         return bool(self.id)
 
-    def save(self) -> bool:
+    def save(self, *, force: bool = False) -> bool:
         """
         Save the model to the API.
 
         If the instance does not exist already, it will be created;
-        otherwise, the existing record will be updated.
+        otherwise, the existing record will be updated, using only the
+        fields which have been modified since it was retrieved.
+
+        Args:
+            force: If ``True``, all fields will be saved, even if they have not changed.
 
         Returns:
-            ``True`` if a record was created, ``False`` if it was updated.
+            ``True`` if a record was created;
+            ``False`` if it was updated, or if the model had no changes.
         """
         if self._deleted:
             raise RuntimeError(f"{self.id} was deleted")
-        table = self.meta.table
-        fields = self.to_record(only_writable=True)["fields"]
+
+        field_values = self.to_record(only_writable=True)["fields"]
 
         if not self.id:
-            record = table.create(fields, typecast=self.meta.typecast)
-            did_create = True
-        else:
-            record = table.update(self.id, fields, typecast=self.meta.typecast)
-            did_create = False
+            record = self.meta.table.create(field_values, typecast=self.meta.typecast)
+            self.id = record["id"]
+            self.created_time = datetime_from_iso_str(record["createdTime"])
+            self._changed.clear()
+            return True
+
+        if not force:
+            if not self._changed:
+                return False
+            field_values = {
+                field_name: value
+                for field_name, value in field_values.items()
+                if self._changed.get(field_name)
+            }
 
-        self.id = record["id"]
-        self.created_time = datetime_from_iso_str(record["createdTime"])
-        return did_create
+        self.meta.table.update(self.id, field_values, typecast=self.meta.typecast)
+        self._changed.clear()
+        return False
 
     def delete(self) -> bool:
         """
@@ -391,6 +409,7 @@ class Model:
         record = self.meta.table.get(self.id)
         unused = self.from_record(record, memoize=False)
         self._fields = unused._fields
+        self._changed.clear()
         self._fetched = True
         self.created_time = unused.created_time
 


Test patch:
diff --git a/tests/test_orm.py b/tests/test_orm.py
index 784abd9..70ff53e 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -1,5 +1,6 @@
 import re
 from datetime import datetime, timezone
+from functools import partial
 from operator import itemgetter
 from unittest import mock
 
@@ -26,12 +27,25 @@ class Contact(Model):
     first_name = f.TextField("First Name")
     last_name = f.TextField("Last Name")
     email = f.EmailField("Email")
-    is_registered = f.CheckboxField("Registered")
+    is_registered = f.CheckboxField("Registered?")
     address = f.LinkField("Link", Address, lazy=False)
     birthday = f.DateField("Birthday")
     created_at = f.CreatedTimeField("Created At")
 
 
+@pytest.fixture
+def contact_record():
+    return fake_record(
+        {
+            "First Name": "John",
+            "Last Name": "Doe",
+            "Email": "john@example.com",
+            "Registered?": True,
+            "Birthday": "1970-01-01",
+        }
+    )
+
+
 def test_model_basics():
     contact = Contact(
         first_name="Gui",
@@ -138,31 +152,49 @@ def test_from_record():
         assert not contact.first_name == "X"
 
 
-def test_readonly_field_not_saved():
+def test_unmodified_field_not_saved(contact_record):
     """
-    Test that we do not attempt to save readonly fields to the API,
-    but we can retrieve readonly fields and set them on instantiation.
+    Test that we do not attempt to save fields to the API if they are unchanged.
     """
+    contact = Contact.from_record(contact_record)
+    mock_update_contact = partial(
+        mock.patch.object, Table, "update", return_value=contact_record
+    )
 
-    record = {
-        "id": "recwnBLPIeQJoYVt4",
-        "createdTime": datetime.now(timezone.utc).isoformat(),
-        "fields": {
-            "Birthday": "1970-01-01",
-            "Age": 57,
-        },
-    }
+    # Do not call update() if the record is unchanged
+    with mock_update_contact() as m_update:
+        contact.save()
+        m_update.assert_not_called()
 
-    contact = Contact.from_record(record)
-    with mock.patch.object(Table, "update") as m_update:
-        m_update.return_value = record
-        contact.birthday = datetime(2000, 1, 1)
+    # By default, only pass fields which were changed to the API
+    with mock_update_contact() as m_update:
+        contact.email = "john.doe@example.com"
         contact.save()
+        m_update.assert_called_once_with(
+            contact.id,
+            {"Email": "john.doe@example.com"},
+            typecast=True,
+        )
+
+    # Once saved, the field is no longer marked as changed
+    with mock_update_contact() as m_update:
+        contact.save()
+        m_update.assert_not_called()
 
-    # We should not pass 'Age' to the API
-    m_update.assert_called_once_with(
-        contact.id, {"Birthday": "2000-01-01"}, typecast=True
-    )
+    # We can explicitly pass all fields to the API
+    with mock_update_contact() as m_update:
+        contact.save(force=True)
+        m_update.assert_called_once_with(
+            contact.id,
+            {
+                "First Name": "John",
+                "Last Name": "Doe",
+                "Email": "john.doe@example.com",
+                "Registered?": True,
+                "Birthday": "1970-01-01",
+            },
+            typecast=True,
+        )
 
 
 def test_linked_record():
@@ -209,7 +241,7 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records):
     if access_linked_records:
         assert contact.address[0].id == address_id
 
-    contact.save()
+    contact.save(force=True)
     assert mock_save.last_request.json() == {
         "fields": {
             "Email": "alice@example.com",
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 7ce54e3..63c4269 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -756,6 +756,56 @@ def test_link_field__load_many(requests_mock):
     assert mock_list.call_count == 2
 
 
+@pytest.mark.parametrize(
+    "mutation",
+    (
+        "author.books = [book]",
+        "author.books.append(book)",
+        "author.books[0] = book",
+        "author.books.insert(0, book)",
+        "author.books[0:1] = []",
+        "author.books.pop(0)",
+        "del author.books[0]",
+        "author.books.remove(author.books[0])",
+        "author.books.clear()",
+        "author.books.extend([book])",
+    ),
+)
+def test_link_field__save(requests_mock, mutation):
+    """
+    Test that we correctly detect changes to linked fields and save them.
+    """
+
+    class Book(Model):
+        Meta = fake_meta()
+
+    class Author(Model):
+        Meta = fake_meta()
+        books = f.LinkField("Books", model=Book)
+
+    b1 = Book.from_record(fake_record())
+    b2 = Book.from_record(fake_record())
+    author = Author.from_record(fake_record({"Books": [b1.id]}))
+
+    def _cb(request, context):
+        return {
+            "id": author.id,
+            "createdTime": datetime_to_iso_str(author.created_time),
+            "fields": request.json()["fields"],
+        }
+
+    requests_mock.get(
+        Book.meta.table.url,
+        json={"records": [b1.to_record(), b2.to_record()]},
+    )
+    m = requests_mock.patch(Author.meta.table.record_url(author.id), json=_cb)
+    exec(mutation, {}, {"author": author, "book": b2})
+    assert author._changed["Books"]
+    author.save()
+    assert m.call_count == 1
+    assert "Books" in m.last_request.json()["fields"]
+
+
 def test_single_link_field():
     class Author(Model):
         Meta = fake_meta()
@@ -833,7 +883,7 @@ def test_single_link_field__multiple_values():
 
     # if book.author.__set__ not called, the entire list will be sent back to the API
     with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m:
-        book.save()
+        book.save(force=True)
         m.assert_called_once_with(book.id, {"Author": [a1, a2, a3]}, typecast=True)
 
     # if we modify the field value, it will drop items 2-N
@@ -974,7 +1024,7 @@ def test_datetime_timezones(requests_mock):
     # Test that we parse the "Z" into UTC correctly
     assert obj.dt.date() == datetime.date(2024, 2, 29)
     assert obj.dt.tzinfo is datetime.timezone.utc
-    obj.save()
+    obj.save(force=True)
     assert m.last_request.json()["fields"]["dt"] == "2024-02-29T12:34:56.000Z"
 
     # Test that we can set a UTC timezone and it will be saved as-is.
@@ -1018,5 +1068,5 @@ def test_select_field(fields, expected):
     assert obj.the_field == expected
 
     with mock.patch("pyairtable.Table.update", return_value=obj.to_record()) as m:
-        obj.save()
+        obj.save(force=True)
         m.assert_called_once_with(obj.id, fields, typecast=True)