Repository: gtalarico/pyairtable (python)

Issue:
Create and update commands do not take return_fields_by_field_id into account
The record returned will always have names, not IDs. We need to fix it. I guess it is quite simple.

Pull request:
Fix return_fields_by_field_id on create/update, and add more tests
The reason this wasn't working before was that we were passing `returnFieldsByFieldId` as a query parameter, when we really needed to include it in the JSON payload in the POST body. This branch fixes that, adds some tests, and does a bit of tidying around the integration test suite.

On further digging, it doesn't look like [Create records](https://airtable.com/developers/web/api/create-records) or [Update records](https://airtable.com/developers/web/api/update-multiple-records) support _any_ of the options (besides this one) which we can pass to [List records](https://airtable.com/developers/web/api/list-records), so I removed the `**options` kwargs from those method signatures.

Fixes #194 

Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 86edb0a..229412a 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -89,8 +89,8 @@ table, whichever is shorter.
 
   >>> for records in table.iterate(page_size=100, max_records=1000):
   ...     print(records)
-  [{id:'rec123asa23', fields': {'Last Name': 'Alfred', "Age": 84}, ...}, ... ]
-  [{id:'rec123asa23', fields': {'Last Name': 'Jameson', "Age": 42}, ...}, ... ]
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Jameson', 'Age': 42}, ...}, ...]
 
 :meth:`~pyairtable.api.Table.all`
 
@@ -100,7 +100,7 @@ hood it uses :meth:`~pyairtable.api.Table.iterate` to fetch records so multiple
 .. code-block:: python
 
   >>> table.all(sort=["First Name", "-Age"]):
-  [{id:'rec123asa23', fields': {'Last Name': 'Alfred', "Age": 84}, ...}, ... ]
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
 
 
 Creating Records
@@ -113,7 +113,7 @@ Creates a single record from a dictionary representing the table's fields.
 .. code-block:: python
 
   >>> table.create({'First Name': 'John'})
-  {id:'rec123asa23', fields': {'First Name': 'John', ...}}
+  {'id': 'rec123asa23', 'fields': {'First Name': 'John', ...}}
 
 
 :meth:`~pyairtable.api.Table.batch_create`
@@ -123,7 +123,7 @@ Batch create records from a list of dictionaries representing the table's fields
 .. code-block:: python
 
   >>> table.batch_create([{'First Name': 'John'}, ...])
-  [{id:'rec123asa23', fields': {'First Name': 'John', ...}}, ...]
+  [{'id': 'rec123asa23', 'fields': {'First Name': 'John'}}, ...]
 
 
 Updating Records
@@ -137,7 +137,7 @@ dictionary representing the table's fields.
 .. code-block:: python
 
   >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
-  [{id:'recwPQIfs4wKPyc9D', fields': {"First Name": "John", "Age": 21, ...}}, ...]
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"First Name": "John", "Age": 21}}, ...]
 
 
 :meth:`~pyairtable.api.Table.batch_update`
@@ -147,7 +147,7 @@ Batch update records from a list of records.
 .. code-block:: python
 
   >>> table.batch_update([{"id": "recwPQIfs4wKPyc9D", "fields": {"First Name": "Matt"}}, ...])
-  [{id:'recwPQIfs4wKPyc9D', fields': {"First Name": "Matt", "Age": 21, ...}}, ...]
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"First Name": "Matt", ...}}, ...]
 
 
 Deleting Records
@@ -160,7 +160,7 @@ Deletes a single record using the provided ``record_id``.
 .. code-block:: python
 
   >>> table.delete('recwPQIfs4wKPyc9D')
-  { "deleted": True, ... }
+  {'deleted': True, 'id': 'recwPQIfs4wKPyc9D'}
 
 :meth:`~pyairtable.api.Table.batch_delete`
 
@@ -168,8 +168,9 @@ Batch delete records using a list of record ids.
 
 .. code-block:: python
 
-  >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwAcQdqwe21as'])
-  [  { "deleted": True, ... }, ... ]
+  >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwAcQdqwe21asdf'])
+  [{'deleted': True, 'id': 'recwPQIfs4wKPyc9D'},
+   {'deleted': True, 'id': 'recwAcQdqwe21asdf'}]
 
 
 Return Values
@@ -181,43 +182,46 @@ will most often be a list of Airtable records (dictionary) in a format as shown
 .. code-block:: python
 
   >>> table.all()
-  ... [{
-  ...     "records": [
-  ...         {
-  ...             "id": "recwPQIfs4wKPyc9D",
-  ...             "fields": {
-  ...                 "COLUMN_ID": "1",
-  ...             },
-  ...             "createdTime": "2017-03-14T22:04:31.000Z"
-  ...         },
-  ...         {
-  ...             "id": "rechOLltN9SpPHq5o",
-  ...             "fields": {
-  ...                 "COLUMN_ID": "2",
-  ...             },
-  ...             "createdTime": "2017-03-20T15:21:50.000Z"
-  ...         },
-  ...         {
-  ...             "id": "rec5eR7IzKSAOBHCz",
-  ...             "fields": {
-  ...                 "COLUMN_ID": "3",
-  ...             },
-  ...             "createdTime": "2017-08-05T21:47:52.000Z"
-  ...         }
-  ...     ],
-  ...     "offset": "rec5eR7IzKSAOBHCz"
-  ... }, ... ]
-
-
-The :class:`~pyairtable.api.Base` class is similar to :class:`~pyairtable.api.Table`, the main difference is that .
+  [
+      {
+          "records": [
+              {
+                  "id": "recwPQIfs4wKPyc9D",
+                  "fields": {
+                      "COLUMN_ID": "1",
+                  },
+                  "createdTime": "2017-03-14T22:04:31.000Z"
+              },
+              {
+                  "id": "rechOLltN9SpPHq5o",
+                  "fields": {
+                      "COLUMN_ID": "2",
+                  },
+                  "createdTime": "2017-03-20T15:21:50.000Z"
+              },
+              {
+                  "id": "rec5eR7IzKSAOBHCz",
+                  "fields": {
+                      "COLUMN_ID": "3",
+                  },
+                  "createdTime": "2017-08-05T21:47:52.000Z"
+              }
+          ],
+          "offset": "rec5eR7IzKSAOBHCz"
+      },
+      ...
+  ]
+
+
+The :class:`~pyairtable.api.Base` class is similar to :class:`~pyairtable.api.Table`, the main difference is that
 `table_name` is not provided during initialization. Instead, it can be
 specified on each request.
 
 .. code-block:: python
 
   >>> base = Base('auth_token', 'base_id')
-  >>> base.all('Contacts)
-  [{id:'rec123asa23', fields': {'Last Name': 'Alfred', "Age": 84}, ... ]
+  >>> base.all('Contacts')
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...]
 
 
 -------------------------
@@ -357,7 +361,6 @@ against a Python dictionary:
 
   >>> from pyairtable import Table
   >>> from pyairtable.formulas import match
-  >>>
   >>> table = Table("auth_token", "base_id", "Contact")
   >>> formula = match({"First Name": "John", "Age": 21})
   >>> table.first(formula=formula)
diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst
index 4c1336d..be0ecd3 100644
--- a/docs/source/getting-started.rst
+++ b/docs/source/getting-started.rst
@@ -51,12 +51,26 @@ or update your records:
     >>> api_key = os.environ['AIRTABLE_API_KEY']
     >>> table = Table(api_key, 'base_id', 'table_name')
     >>> table.all()
-    [ {"id": "rec5eR7IzKSAOBHCz", "fields": { ... }}]
+    [
+        {
+            "id": "rec5eR7IzKSAOBHCz",
+            "createdTime": "2017-03-14T22:04:31.000Z",
+            "fields": {...}
+        }
+    ]
     >>> table.create({"Foo": "Bar"})
-    {"id": "recwAcQdqwe21as", "fields": { "Foo": "Bar" }}]
-    >>> table.update("recwAcQdqwe21as", {"Foo": "Foo"})
-    {"id": "recwAcQdqwe21as", "fields": { "Foo": "Foo" }}]
-    >>> table.delete("recwAcQdqwe21as")
-    True
+    {
+        "id": "recwAcQdqwe21asdf",
+        "createdTime": "...",
+        "fields": {"Foo": "Bar"}
+    }
+    >>> table.update("recwAcQdqwe21asdf", {"Foo": "Foo"})
+    {
+        "id": "recwAcQdqwe21asdf",
+        "createdTime": "...",
+        "fields": {"Foo": "Foo"}
+    }
+    >>> table.delete("recwAcQdqwe21asdf")
+    {'id': 'recwAcQdqwe21asdf', 'deleted': True}
 
 For more details on the available classes and methods check out the :doc:`api` section.
diff --git a/pyairtable/api/abstract.py b/pyairtable/api/abstract.py
index 1035e9d..ec52d0c 100644
--- a/pyairtable/api/abstract.py
+++ b/pyairtable/api/abstract.py
@@ -200,14 +200,22 @@ class ApiAbstract(metaclass=abc.ABCMeta):
         return all_records
 
     def _create(
-        self, base_id: str, table_name: str, fields: dict, typecast=False, **options
+        self,
+        base_id: str,
+        table_name: str,
+        fields: dict,
+        typecast=False,
+        return_fields_by_field_id=False,
     ):
         table_url = self.get_table_url(base_id, table_name)
         return self._request(
             "post",
             table_url,
-            json_data={"fields": fields, "typecast": typecast},
-            options=options,
+            json_data={
+                "fields": fields,
+                "typecast": typecast,
+                "returnFieldsByFieldId": return_fields_by_field_id,
+            },
         )
 
     def _batch_create(
@@ -216,7 +224,7 @@ class ApiAbstract(metaclass=abc.ABCMeta):
         table_name: str,
         records: List[dict],
         typecast=False,
-        **options,
+        return_fields_by_field_id=False,
     ) -> List[dict]:
         table_url = self.get_table_url(base_id, table_name)
         inserted_records = []
@@ -225,8 +233,11 @@ class ApiAbstract(metaclass=abc.ABCMeta):
             response = self._request(
                 "post",
                 table_url,
-                json_data={"records": new_records, "typecast": typecast},
-                options=options,
+                json_data={
+                    "records": new_records,
+                    "typecast": typecast,
+                    "returnFieldsByFieldId": return_fields_by_field_id,
+                },
             )
             inserted_records += response["records"]
             time.sleep(self.API_LIMIT)
@@ -240,7 +251,6 @@ class ApiAbstract(metaclass=abc.ABCMeta):
         fields: dict,
         replace=False,
         typecast=False,
-        **options,
     ) -> List[dict]:
         record_url = self._get_record_url(base_id, table_name, record_id)
 
@@ -249,7 +259,6 @@ class ApiAbstract(metaclass=abc.ABCMeta):
             method,
             record_url,
             json_data={"fields": fields, "typecast": typecast},
-            options=options,
         )
 
     def _batch_update(
@@ -259,7 +268,7 @@ class ApiAbstract(metaclass=abc.ABCMeta):
         records: List[dict],
         replace=False,
         typecast=False,
-        **options,
+        return_fields_by_field_id=False,
     ):
         updated_records = []
         table_url = self.get_table_url(base_id, table_name)
@@ -269,8 +278,11 @@ class ApiAbstract(metaclass=abc.ABCMeta):
             response = self._request(
                 method,
                 table_url,
-                json_data={"records": chunk_records, "typecast": typecast},
-                options=options,
+                json_data={
+                    "records": chunk_records,
+                    "typecast": typecast,
+                    "returnFieldsByFieldId": return_fields_by_field_id,
+                },
             )
             updated_records += response["records"]
             time.sleep(self.API_LIMIT)
diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py
index 36375ef..4f8a5a0 100644
--- a/pyairtable/api/api.py
+++ b/pyairtable/api/api.py
@@ -149,6 +149,10 @@ class Api(ApiAbstract):
             fields: |kwarg_fields|
             sort: |kwarg_sort|
             formula: |kwarg_formula|
+            cell_format: |kwarg_cell_format|
+            user_locale: |kwarg_user_locale|
+            time_zone: |kwarg_time_zone|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
         return super()._first(base_id, table_name, **options)
 
@@ -185,7 +189,14 @@ class Api(ApiAbstract):
         """
         return super()._all(base_id, table_name, **options)
 
-    def create(self, base_id: str, table_name: str, fields: dict, typecast=False):
+    def create(
+        self,
+        base_id: str,
+        table_name: str,
+        fields: dict,
+        typecast=False,
+        return_fields_by_field_id=False,
+    ):
         """
         Creates a new record
 
@@ -200,14 +211,28 @@ class Api(ApiAbstract):
 
         Keyword Args:
             typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
 
         Returns:
             record (``dict``): Inserted record
 
         """
-        return super()._create(base_id, table_name, fields, typecast=typecast)
+        return super()._create(
+            base_id,
+            table_name,
+            fields,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
+        )
 
-    def batch_create(self, base_id: str, table_name: str, records, typecast=False):
+    def batch_create(
+        self,
+        base_id: str,
+        table_name: str,
+        records,
+        typecast=False,
+        return_fields_by_field_id=False,
+    ):
         """
         Breaks records into chunks of 10 and inserts them in batches.
         Follows the set API rate.
@@ -225,11 +250,18 @@ class Api(ApiAbstract):
 
         Keyword Args:
             typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
 
         Returns:
             records (``list``): list of added records
         """
-        return super()._batch_create(base_id, table_name, records, typecast=typecast)
+        return super()._batch_create(
+            base_id,
+            table_name,
+            records,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
+        )
 
     def update(
         self,
@@ -254,7 +286,7 @@ class Api(ApiAbstract):
             table_name: |arg_table_name|
             record_id: |arg_record_id|
             fields(``dict``): Fields to update.
-                Must be dictionary with Column names as Key
+                Must be a dict with column names or IDs as keys
 
         Keyword Args:
             replace (``bool``, optional): If ``True``, record is replaced in its entirety
@@ -283,6 +315,7 @@ class Api(ApiAbstract):
         records: List[dict],
         replace=False,
         typecast=False,
+        return_fields_by_field_id=False,
     ):
         """
         Updates a records by their record id's in batch.
@@ -298,12 +331,18 @@ class Api(ApiAbstract):
                 bet set to null. If False, only provided fields are updated.
                 Default is ``False``.
             typecast: |kwarg_typecast|
+            return_fields_by_field_id: |kwarg_return_fields_by_field_id|
 
         Returns:
             records(``list``): list of updated records
         """
         return super()._batch_update(
-            base_id, table_name, records, replace=replace, typecast=typecast
+            base_id,
+            table_name,
+            records,
+            replace=replace,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
         )
 
     def delete(self, base_id: str, table_name: str, record_id: str):
diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py
index 6dbabce..f81ee3a 100644
--- a/pyairtable/api/base.py
+++ b/pyairtable/api/base.py
@@ -87,20 +87,38 @@ class Base(ApiAbstract):
         """
         return super()._all(self.base_id, table_name, **options)
 
-    def create(self, table_name: str, fields: dict, typecast=False):
+    def create(
+        self,
+        table_name: str,
+        fields: dict,
+        typecast=False,
+        return_fields_by_field_id=False,
+    ):
         """
         Same as :meth:`Api.create <pyairtable.api.Api.create>`
         but without ``base_id`` arg.
         """
-        return super()._create(self.base_id, table_name, fields, typecast=typecast)
+        return super()._create(
+            self.base_id,
+            table_name,
+            fields,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
+        )
 
-    def batch_create(self, table_name: str, records, typecast=False):
+    def batch_create(
+        self, table_name: str, records, typecast=False, return_fields_by_field_id=False
+    ):
         """
         Same as :meth:`Api.batch_create <pyairtable.api.Api.batch_create>`
         but without ``base_id`` arg.
         """
         return super()._batch_create(
-            self.base_id, table_name, records, typecast=typecast
+            self.base_id,
+            table_name,
+            records,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
         )
 
     def update(
@@ -125,14 +143,24 @@ class Base(ApiAbstract):
         )
 
     def batch_update(
-        self, table_name: str, records: List[dict], replace=False, typecast=False
+        self,
+        table_name: str,
+        records: List[dict],
+        replace=False,
+        typecast=False,
+        return_fields_by_field_id=False,
     ):
         """
         Same as :meth:`Api.batch_update <pyairtable.api.Api.batch_update>`
         but without ``base_id`` arg.
         """
         return super()._batch_update(
-            self.base_id, table_name, records, replace=replace, typecast=typecast
+            self.base_id,
+            table_name,
+            records,
+            replace=replace,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
         )
 
     def delete(self, table_name: str, record_id: str):
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index b2e2750..3f426d9 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -96,27 +96,43 @@ class Table(ApiAbstract):
         """
         return super()._all(self.base_id, self.table_name, **options)
 
-    def create(self, fields: dict, typecast=False, **options):
+    def create(
+        self,
+        fields: dict,
+        typecast=False,
+        return_fields_by_field_id=False,
+    ):
         """
         Same as :meth:`Api.create <pyairtable.api.Api.create>`
         but without ``base_id`` and ``table_name`` arg.
         """
         return super()._create(
-            self.base_id, self.table_name, fields, typecast=typecast, **options
+            self.base_id,
+            self.table_name,
+            fields,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
         )
 
-    def batch_create(self, records, typecast=False, **options):
+    def batch_create(
+        self,
+        records,
+        typecast=False,
+        return_fields_by_field_id=False,
+    ):
         """
         Same as :meth:`Api.batch_create <pyairtable.api.Api.batch_create>`
         but without ``base_id`` and ``table_name`` arg.
         """
         return super()._batch_create(
-            self.base_id, self.table_name, records, typecast=typecast, **options
+            self.base_id,
+            self.table_name,
+            records,
+            typecast=typecast,
+            return_fields_by_field_id=return_fields_by_field_id,
         )
 
-    def update(
-        self, record_id: str, fields: dict, replace=False, typecast=False, **options
-    ):
+    def update(self, record_id: str, fields: dict, replace=False, typecast=False):
         """
         Same as :meth:`Api.update <pyairtable.api.Api.update>`
         but without ``base_id`` and ``table_name`` arg.
@@ -128,11 +144,14 @@ class Table(ApiAbstract):
             fields,
             replace=replace,
             typecast=typecast,
-            **options,
         )
 
     def batch_update(
-        self, records: List[dict], replace=False, typecast=False, **options
+        self,
+        records: List[dict],
+        replace=False,
+        typecast=False,
+        return_fields_by_field_id=False,
     ):
         """
         Same as :meth:`Api.batch_update <pyairtable.api.Api.batch_update>`
@@ -144,7 +163,7 @@ class Table(ApiAbstract):
             records,
             replace=replace,
             typecast=typecast,
-            **options,
+            return_fields_by_field_id=return_fields_by_field_id,
         )
 
     def delete(self, record_id: str):
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 0ae71eb..e58c8aa 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -216,7 +216,7 @@ class Model(metaclass=abc.ABCMeta):
             record = table.create(fields, typecast=self.typecast)
             did_create = True
         else:
-            record = table.update(self.id, fields, replace=True, typecast=self.typecast)
+            record = table.update(self.id, fields, typecast=self.typecast)
             did_create = False
 
         self.id = record["id"]
@@ -260,15 +260,14 @@ class Model(metaclass=abc.ABCMeta):
         """Create instance from record dictionary"""
         name_attr_map = cls._field_name_attribute_map()
         name_field_map = cls._field_name_descriptor_map()
-        try:
-            # Convert Column Names into model field names
-            # Use field's to_internal to cast into model fields
-            kwargs = {
-                name_attr_map[k]: name_field_map[k].to_internal_value(v)
-                for k, v in record["fields"].items()
-            }
-        except KeyError as exc:
-            raise ValueError("Invalid Field Name: {} for model {}".format(exc, cls))
+        # Convert Column Names into model field names
+        kwargs = {
+            # Use field's to_internal_value to cast into model fields
+            name_attr_map[k]: name_field_map[k].to_internal_value(v)
+            for k, v in record["fields"].items()
+            # Silently proceed if Airtable returns fields we don't recognize
+            if k in name_attr_map
+        }
         instance = cls(**kwargs)
         instance.created_time = record["createdTime"]
         instance.id = record["id"]


Test patch:
diff --git a/pyairtable/testing.py b/pyairtable/testing.py
new file mode 100644
index 0000000..a60c3da
--- /dev/null
+++ b/pyairtable/testing.py
@@ -0,0 +1,58 @@
+"""
+Helper functions for writing tests that use the pyairtable library.
+"""
+import datetime
+import random
+import string
+
+
+def fake_id(type="rec", value=None):
+    """
+    Generates a fake Airtable-style ID.
+
+    Keyword Args:
+        type: the object type prefix, defaults to "rec"
+        value: any value to use as the ID, defaults to random letters and digits
+
+    >>> fake_id()
+    'rec...'
+    >>> fake_id('tbl')
+    'tbl...'
+    >>> fake_id(value='12345')
+    'rec00000000012345'
+    """
+    if value is None:
+        value = "".join(random.sample(string.ascii_letters + string.digits, 14))
+    return type + f"{value:0>14}"[:14]
+
+
+def fake_meta(
+    base_id="appFakeTestingApp",
+    table_name="tblFakeTestingTbl",
+    api_key="patFakePersonalAccessToken",
+):
+    """
+    Returns a ``Meta`` class for inclusion in a ``Model`` subclass.
+    """
+    attrs = {"base_id": base_id, "table_name": table_name, "api_key": api_key}
+    return type("Meta", (), attrs)
+
+
+def fake_record(fields=None, id=None, **other_fields):
+    """
+    Returns a fake record dict with the given field values.
+
+    >>> fake_record({"Name": "Alice"})
+    {'id': '...', 'createdTime': '...', 'fields': {'Name': 'Alice'}}
+
+    >>> fake_record(name='Alice', address='123 Fake St')
+    {'id': '...', 'createdTime': '...', 'fields': {'name': 'Alice', 'address': '123 Fake St'}}
+
+    >>> fake_record(name='Alice', id='123')
+    {'id': 'rec00000000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}}
+    """
+    return {
+        "id": fake_id(value=id),
+        "createdTime": datetime.datetime.now().isoformat() + "Z",
+        "fields": {**(fields or {}), **other_fields},
+    }
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 819000b..2e84166 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -4,6 +4,8 @@ import pytest
 
 from pyairtable import Base, Table
 
+BASE_ID = "appaPqizdsNHDvlEm"
+
 
 @pytest.fixture
 def base_name():
@@ -31,7 +33,7 @@ def cols():
 
 @pytest.fixture
 def base():
-    base_id = "appaPqizdsNHDvlEm"
+    base_id = BASE_ID
     base = Base(os.environ["AIRTABLE_API_KEY"], base_id)
     yield base
     table_name = "TEST_TABLE"
@@ -41,7 +43,7 @@ def base():
 
 @pytest.fixture
 def table():
-    base_id = "appaPqizdsNHDvlEm"
+    base_id = BASE_ID
     table_name = "TEST_TABLE"
     table = Table(os.environ["AIRTABLE_API_KEY"], base_id, table_name)
     yield table
diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py
index a55d611..ea97571 100644
--- a/tests/integration/test_integration_api.py
+++ b/tests/integration/test_integration_api.py
@@ -7,8 +7,9 @@ from pyairtable import Table
 from pyairtable import formulas as fo
 from pyairtable.utils import attachment
 
+pytestmark = [pytest.mark.integration]
+
 
-@pytest.mark.integration
 def test_integration_table(table, cols):
     # Create / Get
     rec = table.create({cols.TEXT: "A", cols.NUM: 1, cols.BOOL: True})
@@ -29,15 +30,6 @@ def test_integration_table(table, cols):
     assert rec["id"] in [r["id"] for r in records]
     assert cols.NUM in records[0]["fields"]  # col name in "fields"
 
-    # All `return_by_field_ids`
-    records = table.all(return_fields_by_field_id=True)
-    assert cols.NUM not in records[0]["fields"]  # fields returns fieldId
-
-    # Get `return_by_field_ids`
-    record = table.get(records[0]["id"], return_fields_by_field_id=True)
-    assert records[0]["id"] == record["id"]
-    assert cols.NUM not in record["fields"]
-
     # Delete
     rv = table.delete(rec["id"])
     assert rv["deleted"]
@@ -58,8 +50,60 @@ def test_integration_table(table, cols):
     assert len(records) == COUNT
 
 
-@pytest.mark.integration
+def test_return_fields_by_field_id(table: Table, cols):
+    """
+    Test that we can get, create, and update records by field ID vs. name.
+
+    See https://github.com/gtalarico/pyairtable/issues/194
+    """
+    # Create one record with return_fields_by_field_id=True
+    record = table.create({cols.TEXT_ID: "Hello"}, return_fields_by_field_id=True)
+    assert record["fields"][cols.TEXT_ID] == "Hello"
+
+    # Updating a record by field ID does not require any special parameters,
+    # but the return value will have field names (not IDs).
+    updated = table.update(record["id"], {cols.TEXT_ID: "Goodbye"})
+    assert updated["fields"][cols.TEXT] == "Goodbye"
+
+    # This is not supported (422 Client Error: Unprocessable Entity for url)
+    # updated = table.update(
+    #     record["id"],
+    #     {cols.TEXT_ID: "Goodbye"},
+    #     return_fields_by_field_id=True,
+    # )
+    # assert updated["fields"][cols.TEXT_ID] == "Goodbye"
+
+    # Create multiple records with return_fields_by_field_id=True
+    records = table.batch_create(
+        [
+            {cols.TEXT_ID: "Alpha"},
+            {cols.TEXT_ID: "Bravo"},
+            {cols.TEXT_ID: "Charlie"},
+        ],
+        return_fields_by_field_id=True,
+    )
+    assert records[0]["fields"][cols.TEXT_ID] == "Alpha"
+    assert records[1]["fields"][cols.TEXT_ID] == "Bravo"
+    assert records[2]["fields"][cols.TEXT_ID] == "Charlie"
+
+    # Update multiple records with return_fields_by_field_id=True
+    updates = [
+        dict(
+            record,
+            fields={cols.TEXT_ID: "Hello, " + record["fields"][cols.TEXT_ID]},
+        )
+        for record in records
+    ]
+    updated = table.batch_update(updates, return_fields_by_field_id=True)
+    assert updated[0]["fields"][cols.TEXT_ID] == "Hello, Alpha"
+    assert updated[1]["fields"][cols.TEXT_ID] == "Hello, Bravo"
+    assert updated[2]["fields"][cols.TEXT_ID] == "Hello, Charlie"
+
+
 def test_get_records_options(table: Table, cols):
+    """
+    Test that we pass all valid options to the GET endpoint.
+    """
     rec = table.create({cols.TEXT: "abracadabra"})
 
     # Test that each supported option works via GET
@@ -102,7 +146,6 @@ def test_get_records_options(table: Table, cols):
     ]
 
 
-@pytest.mark.integration
 def test_integration_field_equals(table: Table, cols):
     TEXT_VALUE = "Test {}".format(uuid4())
     NUM_VALUE = 12345
@@ -124,7 +167,6 @@ def test_integration_field_equals(table: Table, cols):
     assert rv_first and rv_first["id"] == rv_create["id"]
 
 
-@pytest.mark.integration
 def test_integration_formula_datetime(table: Table, cols):
     VALUE = datetime.utcnow()
     str_value = fo.to_airtable_value(VALUE)
@@ -133,7 +175,6 @@ def test_integration_formula_datetime(table: Table, cols):
     assert rv_first and rv_first["id"] == rv_create["id"]
 
 
-@pytest.mark.integration
 def test_integration_formula_date_filter(table: Table, cols):
     dt = datetime.utcnow()
     date = dt.date()
@@ -150,7 +191,6 @@ def test_integration_formula_date_filter(table: Table, cols):
     assert set([r["id"] for r in rv_all]) == set([r["id"] for r in created])
 
 
-@pytest.mark.integration
 def test_integration_field_equals_with_quotes(table: Table, cols):
     VALUE = "Contact's Name {}".format(uuid4())
     rv_create = table.create({cols.TEXT: VALUE})
@@ -163,7 +203,6 @@ def test_integration_field_equals_with_quotes(table: Table, cols):
     assert rv_first and rv_first["id"] == rv_create["id"]
 
 
-@pytest.mark.integration
 def test_integration_formula_composition(table: Table, cols):
     text = "Mike's Thing {}".format(uuid4())
     num = 1
@@ -182,14 +221,12 @@ def test_integration_formula_composition(table: Table, cols):
     assert rv_first["id"] == rv_create["id"]
 
 
-@pytest.mark.integration
 def test_integration_attachment(table, cols, valid_img_url):
     rec = table.create({cols.ATTACHMENT: [attachment(valid_img_url)]})
     rv_get = table.get(rec["id"])
     assert rv_get["fields"]["attachment"][0]["filename"] == "logo.png"
 
 
-@pytest.mark.integration
 def test_integration_attachment_multiple(table, cols, valid_img_url):
     rec = table.create(
         {
diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py
index 267efa9..1d55e87 100644
--- a/tests/integration/test_integration_metadata.py
+++ b/tests/integration/test_integration_metadata.py
@@ -3,8 +3,9 @@ import pytest
 from pyairtable import Base, Table
 from pyairtable.metadata import get_api_bases, get_base_schema, get_table_schema
 
+pytestmark = [pytest.mark.integration]
+
 
-@pytest.mark.integration
 def test_get_api_bases(base: Base, base_name: str):
     rv = get_api_bases(base)
     assert base_name in [b["name"] for b in rv["bases"]]
diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py
index 7b77306..2cc918a 100644
--- a/tests/integration/test_integration_orm.py
+++ b/tests/integration/test_integration_orm.py
@@ -5,8 +5,9 @@ import pytest
 
 from pyairtable.orm import Model
 from pyairtable.orm import fields as f
+from tests.integration.conftest import BASE_ID
 
-BASE_ID = "appaPqizdsNHDvlEm"
+pytestmark = [pytest.mark.integration]
 
 
 @pytest.fixture
@@ -52,7 +53,6 @@ def Contact(Address):
     table.batch_delete([r["id"] for r in records])
 
 
-@pytest.mark.integration
 def test_integration_orm(Contact, Address):
     STREET = "123 Han"
     address = Address(street=STREET)
@@ -88,3 +88,37 @@ def test_integration_orm(Contact, Address):
     assert not rv_address_2.street
     rv_address_2.fetch()
     assert rv_address_2.street == rv_address.street == STREET
+
+
+@pytest.mark.integration
+def test_undeclared_fields():
+    """
+    Test that if our ORM is missing some fields, it does not fail on retrieval
+    and does not clobber their values on save.
+    """
+
+    class Contact(Model):
+        first_name = f.TextField("First Name")
+        last_name = f.TextField("Last Name")
+
+        class Meta:
+            base_id = BASE_ID
+            api_key = os.environ["AIRTABLE_API_KEY"]
+            table_name = "Contact"
+
+    table = Contact.get_table()
+    record = table.create(
+        {
+            "First Name": "Alice",
+            "Last Name": "Arnold",
+            "Email": "alice@example.com",
+            "Birthday": "1970-01-01",
+        }
+    )
+
+    # This should not raise an exception
+    contact = Contact.from_id(record["id"])
+
+    # This should not clobber the values in 'Email' or 'Birthday'
+    contact.save()
+    assert table.get(record["id"]) == record
diff --git a/tests/test_orm.py b/tests/test_orm.py
index c551505..a9d8685 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -7,6 +7,7 @@ from requests_mock import Mocker
 from pyairtable import Table
 from pyairtable.orm import Model
 from pyairtable.orm import fields as f
+from pyairtable.testing import fake_meta, fake_record
 
 
 def test_model_missing_meta():
@@ -26,25 +27,18 @@ def test_model_overlapping():
     with pytest.raises(ValueError):
 
         class Address(Model):
+            Meta = fake_meta()
             exists = f.TextField("Exists")  # clases with Model.exists()
 
-            class Meta:
-                base_id = "required"
-                table_name = "required"
-                api_key = "required"
-
 
 def test_model():
     class Address(Model):
+        Meta = fake_meta(table_name="Address")
         street = f.TextField("Street")
         number = f.TextField("Number")
 
-        class Meta:
-            base_id = "address_base_id"
-            table_name = "Address"
-            api_key = "fake"
-
     class Contact(Model):
+        Meta = fake_meta(table_name="Contact")
         first_name = f.TextField("First Name")
         last_name = f.TextField("Last Name")
         email = f.EmailField("Email")
@@ -52,11 +46,6 @@ def test_model():
         link = f.LinkField("Link", Address, lazy=True)
         birthday = f.DateField("Birthday")
 
-        class Meta:
-            base_id = "contact_base_id"
-            table_name = "Contact"
-            api_key = "fake"
-
     contact = Contact(
         first_name="Gui",
         last_name="Talarico",
@@ -92,14 +81,10 @@ def test_model():
 
 def test_from_record():
     class Contact(Model):
+        Meta = fake_meta()
         first_name = f.TextField("First Name")
         timestamp = f.DatetimeField("Timestamp")
 
-        class Meta:
-            base_id = "contact_base_id"
-            table_name = "Contact"
-            api_key = "fake"
-
     # Fetch = True
     with mock.patch.object(Table, "get") as m_get:
         m_get.return_value = {
@@ -124,21 +109,13 @@ def test_from_record():
 
 def test_linked_record():
     class Address(Model):
+        Meta = fake_meta(table_name="Address")
         street = f.TextField("Street")
 
-        class Meta:
-            base_id = "address_base_id"
-            table_name = "Address"
-            api_key = "fake"
-
     class Contact(Model):
+        Meta = fake_meta(table_name="Contact")
         address = f.LinkField("Link", Address, lazy=True)
 
-        class Meta:
-            base_id = "contact_base_id"
-            table_name = "Contact"
-            api_key = "fake"
-
     record = {"id": "recFake", "createdTime": "", "fields": {"Street": "A"}}
     address = Address.from_id("recFake", fetch=False)
 
@@ -153,3 +130,38 @@ def test_linked_record():
         contact.address[0].fetch()
 
     assert contact.address[0].street == "A"
+
+
+def test_undeclared_field__from_id(requests_mock):
+    """
+    Test that Model.from_id ignores any fields which are missing from the Model definition.
+    See https://github.com/gtalarico/pyairtable/issues/190
+    """
+
+    class JustName(Model):
+        Meta = fake_meta()
+        name = f.TextField("Name")
+
+    record = fake_record({"Name": "Alice", "Address": "123 Fake St"})
+    requests_mock.get(
+        JustName.get_table().get_record_url(record["id"]),
+        status_code=200,
+        json=record,
+    )
+
+    instance = JustName.from_id(record["id"])
+    assert instance.to_record()["fields"] == {"Name": "Alice"}
+
+
+def test_undeclared_field__all():
+    """
+    Test that Model.all ignores any fields which are missing from the Model definition.
+    """
+    pytest.skip("To be implemented in 2.0.0; see #249")  # TODO
+
+
+def test_undeclared_field__first():
+    """
+    Test that Model.first ignores any fields which are missing from the Model definition.
+    """
+    pytest.skip("To be implemented in 2.0.0; see #249")  # TODO