Repository: voxel51/fiftyone

Issue:
[BUG] Document `has_field` does not work for non-default field names

### System information

-   **OS Platform and Distribution** (e.g., Linux Ubuntu 16.04): Ubuntu 20.04.3 LTS
-   **Python version** (`python --version`): Python 3.10.2
-   **FiftyOne version** (`fiftyone --version`): 0.21.4
-   **FiftyOne installed from** (pip or source): pip

### Commands to reproduce

Running the following in an `ipython` shell should demonstrate the problem with `has_field`. 

```
import fiftyone as fo
# Create an empty detection
det = fo.Detection()
# Add a custom field to the detection
det['asdf'] = 1.0
# Check with has_field
print(f"Has asdf: {det.has_field('asdf')}")
# Check with `in`
print(f"Has asdf (in): {'asdf' in det}")
```

### Describe the problem

In previous versions, custom fields on a `Detection` would work with `has_field`.  Since updating to version 0.21.4, this no longer works for detections regardless of saved status.

### Code to reproduce issue

See above.  Can be demonstrated with the quickstart dataset as well after saving samples

### What areas of FiftyOne does this bug affect?

-   [ ] `App`: FiftyOne application issue
-   [x] `Core`: Core Python library issue
-   [ ] `Server`: FiftyOne server issue

### Willingness to contribute

The FiftyOne Community encourages bug fix contributions. Would you or another member of your organization be willing to contribute a fix for this bug to the FiftyOne codebase?

-   [ ] Yes. I can contribute a fix for this bug independently
-   [x ] Yes. I would be willing to contribute a fix for this bug with guidance from the FiftyOne community
-   [ ] No. I cannot contribute a bug fix at this time



Discussion:

Resolving patch:
diff --git a/fiftyone/core/fields.py b/fiftyone/core/fields.py
--- a/fiftyone/core/fields.py
+++ b/fiftyone/core/fields.py
@@ -1314,6 +1314,9 @@ def get_field(self, path):
         chunks = path.split(".", 1)
         if len(chunks) > 1:
             field = self._fields.get(chunks[0], None)
+            while isinstance(field, ListField):
+                field = field.field
+
             if not isinstance(field, EmbeddedDocumentField):
                 return None
 
diff --git a/fiftyone/core/odm/document.py b/fiftyone/core/odm/document.py
--- a/fiftyone/core/odm/document.py
+++ b/fiftyone/core/odm/document.py
@@ -297,16 +297,11 @@ def field_names(self):
         return self._get_field_names(include_private=False)
 
     def has_field(self, field_name):
-        # pylint: disable=no-member
-        chunks = field_name.split(".", 1)
-        if len(chunks) > 1:
-            field = self._fields.get(chunks[0], None)
-            try:
-                return field.has_field(chunks[1])
-            except AttributeError:
-                return False
-
-        return field_name in self._fields
+        try:
+            _ = self._get_field(field_name)
+            return True
+        except (KeyError, AttributeError):
+            return False
 
     def get_field(self, field_name):
         chunks = field_name.split(".", 1)
diff --git a/fiftyone/core/odm/embedded_document.py b/fiftyone/core/odm/embedded_document.py
--- a/fiftyone/core/odm/embedded_document.py
+++ b/fiftyone/core/odm/embedded_document.py
@@ -45,6 +45,27 @@ class DynamicEmbeddedDocument(
     meta = {"abstract": True, "allow_inheritance": True}
 
     def __init__(self, *args, **kwargs):
-        self._custom_fields = {}
         super().__init__(*args, **kwargs)
         self.validate()
+
+    def _get_field(self, field_name):
+        # pylint: disable=no-member
+        chunks = field_name.split(".", 1)
+        if len(chunks) > 1:
+            field = self._fields.get(chunks[0], None)
+            if field is None:
+                field = self._dynamic_fields.get(chunks[0], None)
+
+            if field is not None:
+                field = field.get_field(chunks[1])
+        else:
+            field = self._fields.get(field_name, None)
+            if field is None:
+                field = self._dynamic_fields.get(field_name, None)
+
+        if field is None:
+            raise AttributeError(
+                "%s has no field '%s'" % (self.__class__.__name__, field_name)
+            )
+
+        return field


Test patch:
diff --git a/tests/unittests/dataset_tests.py b/tests/unittests/dataset_tests.py
--- a/tests/unittests/dataset_tests.py
+++ b/tests/unittests/dataset_tests.py
@@ -4057,6 +4057,38 @@ def test_dynamic_fields_sample(self):
             sample.frames[1]["ground_truth"].detections[0].mood = False
             sample.save()
 
+    @drop_datasets
+    def test_dynamic_has_field(self):
+        sample = fo.Sample(
+            filepath="image.jpg",
+            ground_truth=fo.Detections(
+                detections=[
+                    fo.Detection(
+                        label="cat",
+                        bounding_box=[0.1, 0.1, 0.8, 0.8],
+                        foo="bar",
+                    ),
+                ]
+            ),
+        )
+
+        dataset = fo.Dataset()
+        dataset.add_sample(sample, dynamic=True)
+
+        detection = sample.ground_truth.detections[0]
+
+        self.assertTrue(dataset.has_field("ground_truth.detections.label"))
+        self.assertTrue(sample.has_field("ground_truth.detections.label"))
+        self.assertTrue(detection.has_field("label"))
+
+        self.assertTrue(dataset.has_field("ground_truth.detections.foo"))
+        self.assertTrue(sample.has_field("ground_truth.detections.foo"))
+        self.assertTrue(detection.has_field("foo"))
+
+        self.assertFalse(dataset.has_field("ground_truth.detections.spam"))
+        self.assertFalse(sample.has_field("ground_truth.detections.spam"))
+        self.assertFalse(detection.has_field("spam"))
+
     @drop_datasets
     def test_dynamic_list_fields(self):
         sample1 = fo.Sample(
diff --git a/tests/unittests/label_tests.py b/tests/unittests/label_tests.py
--- a/tests/unittests/label_tests.py
+++ b/tests/unittests/label_tests.py
@@ -62,6 +62,26 @@ def test_dynamic_fields(self):
             custom_id=ObjectId(),
         )
 
+        self.assertTrue(detection.has_field("label"))
+        self.assertTrue(detection.has_field("foo"))
+        self.assertFalse(detection.has_field("spam"))
+
+        self.assertIsNone(detection.get_field("label"))
+        self.assertEqual(detection.get_field("foo"), "bar")
+
+        detection.set_field("label", "cat")
+        detection.set_field("foo", "baz")
+        detection.set_field("spam", "eggs")
+
+        self.assertEqual(detection.get_field("label"), "cat")
+        self.assertEqual(detection.get_field("foo"), "baz")
+        self.assertEqual(detection.get_field("spam"), "eggs")
+
+        # pylint: disable=no-member
+        self.assertEqual(detection.label, "cat")
+        self.assertEqual(detection.foo, "baz")
+        self.assertEqual(detection.spam, "eggs")
+
         d = detection.to_dict()
 
         self.assertIsInstance(d["_id"], ObjectId)