Repository: scikit-hep/awkward

Issue:
`to_numpy` for option-type of record type fails because `_to_backend_array` is missing a case for structured arrays
### Version of Awkward Array

HEAD

### Description and code to reproduce

This if-elif needs one more elif:

https://github.com/scikit-hep/awkward/blob/dfc9690537386ab690c7e7de1baa8e6b041632be/src/awkward/contents/indexedoptionarray.py#L1593-L1624

For NumPy structured arrays, the `dtype` is a list of fields, and we should make dummy data for every field in that list. That probably means that the whole if-elif needs to be factorized out into another function, so that the list case can recursively (1 level deep!) call into it for each of its fields.


Discussion:
```python
>>> import awkward as ak
>>> ak.Array([{"x": 1, "y": 1.1}, None])
<Array [{x: 1, y: 1.1}, None] type='2 * ?{x: int64, y: float64}'>
>>> ak.Array([{"x": 1, "y": 1.1}, None]).to_numpy()
```

```
Traceback (most recent call last):
  File "/home/jpivarski/irishep/awkward/src/awkward/_dispatch.py", line 62, in dispatch
    next(gen_or_result)
  File "/home/jpivarski/irishep/awkward/src/awkward/operations/ak_to_numpy.py", line 48, in to_numpy
    return _impl(array, allow_missing)
  File "/home/jpivarski/irishep/awkward/src/awkward/operations/ak_to_numpy.py", line 60, in _impl
    return numpy_layout.to_backend_array(allow_missing=allow_missing)
  File "/home/jpivarski/irishep/awkward/src/awkward/contents/content.py", line 1024, in to_backend_array
    return self._to_backend_array(allow_missing, backend)
  File "/home/jpivarski/irishep/awkward/src/awkward/contents/indexedoptionarray.py", line 1624, in _to_backend_array
    raise AssertionError(f"unrecognized dtype: {content.dtype}")
AssertionError: unrecognized dtype: [('x', '<i8'), ('y', '<f8')]

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/jpivarski/irishep/awkward/src/awkward/highlevel.py", line 502, in to_numpy
    return ak.operations.to_numpy(self, allow_missing=allow_missing)
  File "/home/jpivarski/irishep/awkward/src/awkward/_dispatch.py", line 38, in dispatch
    with OperationErrorContext(name, args, kwargs):
  File "/home/jpivarski/irishep/awkward/src/awkward/_errors.py", line 85, in __exit__
    self.handle_exception(exception_type, exception_value)
  File "/home/jpivarski/irishep/awkward/src/awkward/_errors.py", line 95, in handle_exception
    raise self.decorate_exception(cls, exception)
AssertionError: unrecognized dtype: [('x', '<i8'), ('y', '<f8')]

See if this has been reported at https://github.com/scikit-hep/awkward/issues
```

Resolving patch:
diff --git a/src/awkward/contents/indexedoptionarray.py b/src/awkward/contents/indexedoptionarray.py
--- a/src/awkward/contents/indexedoptionarray.py
+++ b/src/awkward/contents/indexedoptionarray.py
@@ -1594,26 +1594,15 @@ def _to_backend_array(self, allow_missing, backend):
 
                 data[index_nplike.logical_not(mask0)] = content
 
-                if np.issubdtype(content.dtype, np.bool_):
-                    data[mask0] = False
-                elif np.issubdtype(content.dtype, np.floating):
-                    data[mask0] = np.nan
-                elif np.issubdtype(content.dtype, np.complexfloating):
-                    data[mask0] = np.nan + np.nan * 1j
-                elif np.issubdtype(content.dtype, np.integer):
-                    data[mask0] = np.iinfo(content.dtype).max
-                elif np.issubdtype(content.dtype.type, np.datetime64) or np.issubdtype(
-                    content.dtype.type, np.timedelta64
-                ):
-                    data[mask0] = nplike.asarray(
-                        [np.iinfo(np.int64).max], dtype=content.dtype
-                    )[0]
-                elif np.issubdtype(content.dtype, np.str_):
-                    data[mask0] = ""
-                elif np.issubdtype(content.dtype, np.bytes_):
-                    data[mask0] = b""
+                if content.dtype.names is not None:
+                    missing_data = tuple(
+                        create_missing_data(each_dtype, backend)
+                        for each_dtype, _ in content.dtype.fields.values()
+                    )
                 else:
-                    raise AssertionError(f"unrecognized dtype: {content.dtype}")
+                    missing_data = create_missing_data(content.dtype, backend)
+
+                data[mask0] = missing_data
 
                 return nplike.ma.MaskedArray(data, mask)
             else:
@@ -1775,3 +1764,30 @@ def _is_equal_to(
                 other.content, index_dtype, numpyarray, all_parameters
             )
         )
+
+
+def create_missing_data(dtype, backend):
+    """Create missing data based on the input dtype
+
+    Missing data are represented differently based on the Numpy array
+    dtype, this function returns the proper missing data representation
+    given the input dtype
+    """
+    if np.issubdtype(dtype, np.bool_):
+        return False
+    elif np.issubdtype(dtype, np.floating):
+        return np.nan
+    elif np.issubdtype(dtype, np.complexfloating):
+        return np.nan + np.nan * 1j
+    elif np.issubdtype(dtype, np.integer):
+        return np.iinfo(dtype).max
+    elif np.issubdtype(dtype.type, np.datetime64) or np.issubdtype(
+        dtype.type, np.timedelta64
+    ):
+        return backend.nplike.asarray([np.iinfo(np.int64).max], dtype=dtype)[0]
+    elif np.issubdtype(dtype, np.str_):
+        return ""
+    elif np.issubdtype(dtype, np.bytes_):
+        return b""
+    else:
+        raise AssertionError(f"unrecognized dtype: {dtype}")


Test patch:
diff --git a/tests/test_3060_indexoption_to_numpy_record_array.py b/tests/test_3060_indexoption_to_numpy_record_array.py
new file mode 100644
--- /dev/null
+++ b/tests/test_3060_indexoption_to_numpy_record_array.py
@@ -0,0 +1,23 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward/blob/main/LICENSE
+
+from __future__ import annotations
+
+import numpy as np
+
+import awkward as ak
+
+
+def test_to_numpy_record_array():
+    test_array = ak.Array([{"x": 1, "y": 1.1}, None]).to_numpy()
+    expected_numpy = np.ma.masked_array(
+        data=[(1, 1.1), (np.iinfo(np.int64).max, np.nan)],
+        mask=[(False, False), (True, True)],
+        dtype=[("x", "<i8"), ("y", "<f8")],
+    )
+    assert np.array_equal(test_array["x"], expected_numpy["x"])
+    # equal_nan not supported on integer arrays
+    try:
+        assert np.array_equal(test_array["y"], expected_numpy["y"], equal_nan=True)
+    except TypeError:
+        # older numpy versions do not support `equal_nan`
+        assert np.array_equal(test_array["y"].filled(), expected_numpy["y"].filled())