Repository: scikit-hep/awkward

Issue:
ak.with_field is has an overzealous check for excluding RecordArrays
### Version of Awkward Array

HEAD

### Description and code to reproduce

One should be able to add new fields to a `RecordArray` with no fields:

```python
>>> no_fields = ak.Array([{}, {}, {}, {}, {}])
>>> no_fields["new_field"] = ak.Array([1, 2, 3, 4, 5])
```

but there's an error:

```
ValueError: no tuples or records in array; cannot add a new field

This error occurred while calling

    ak.Array.__setitem__(
        self = <Array [{}, {}, {}, {}, {}] type='5 * {}'>
        field_name = 'new_field'
        field_value = <Array [1, 2, 3, 4, 5] type='5 * int64'>
    )
```

This happens because

https://github.com/scikit-hep/awkward/blob/be60b5330f7f872fd01b34c44d2686eeafa9bbfc/src/awkward/operations/ak_with_field.py#L86-L87

is protecting the subsequent code from having to check for the case that `base` doesn't even have a `RecordArray`, but `base.fields == []` isn't the way to do it: you can also get that from a `RecordArray` with no fields.

Instead, that guard needs to check for the existence of a `RecordArray` some other way. Perhaps the following?

```python
try:
    ak._do.recursively_apply(no_fields.layout, lambda x, **kwargs: None, allow_records=False)
except ValueError:
    pass
else:
    raise ValueError("no tuples or records in array; cannot add a new field") from None
```

I'm not entirely happy about repurposing an anti-guard for one function as a guard for another. Also, it should complain if any of the branches of a `UnionArray` are not `RecordArrays`.


Discussion:
@jpivarski I'll take this, unless you want to!
Please, go ahead! I don't have a good idea for what to replace `len(base.fields) == 0` with, other than building a new Form method like `purelist_isregular` (for `purelist_isrecord`).
I think we can just test this in the existing visitor; setting a flag if we encounter the layout?
That said, a `purelist_isrecord` would be useful for testing the branches of the union, so perhaps I'll add that.

Resolving patch:
diff --git a/src/awkward/operations/ak_with_field.py b/src/awkward/operations/ak_with_field.py
--- a/src/awkward/operations/ak_with_field.py
+++ b/src/awkward/operations/ak_with_field.py
@@ -82,16 +82,33 @@ def _impl(base, what, where, highlevel, behavior):
 
         behavior = behavior_of(base, what, behavior=behavior)
         base = ak.operations.to_layout(base, allow_record=True, allow_other=False)
-
-        if len(base.fields) == 0:
-            raise ValueError("no tuples or records in array; cannot add a new field")
-
         what = ak.operations.to_layout(what, allow_record=True, allow_other=True)
 
         keys = copy.copy(base.fields)
         if where in base.fields:
             keys.remove(where)
 
+        def purelist_is_record(layout):
+            result = False
+
+            def action_is_record(input, **kwargs):
+                nonlocal result
+
+                if input.is_record:
+                    result = True
+                    return input
+                elif input.is_union:
+                    result = all(purelist_is_record(x) for x in input.contents)
+                    return input
+                else:
+                    return None
+
+            ak._do.recursively_apply(layout, action_is_record, return_array=False)
+            return result
+
+        if not purelist_is_record(base):
+            raise ValueError("no tuples or records in array; cannot add a new field")
+
         def action(inputs, **kwargs):
             base, what = inputs
             backend = base.backend


Test patch:
diff --git a/tests/test_2385_with_field_empty_record.py b/tests/test_2385_with_field_empty_record.py
new file mode 100644
--- /dev/null
+++ b/tests/test_2385_with_field_empty_record.py
@@ -0,0 +1,35 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import pytest
+
+import awkward as ak
+
+
+def test_no_fields():
+    no_fields = ak.Array([{}, {}, {}, {}, {}])
+    no_fields["new_field"] = ak.Array([1, 2, 3, 4, 5])
+    assert no_fields.to_list() == [
+        {"new_field": 1},
+        {"new_field": 2},
+        {"new_field": 3},
+        {"new_field": 4},
+        {"new_field": 5},
+    ]
+
+
+def test_union_partial_record():
+    no_fields = ak.Array([{}, []])
+    with pytest.raises(ValueError, match="cannot add a new field"):
+        no_fields["new_field"] = ak.Array([1, 2, 3, 4, 5])
+
+
+def test_union_record():
+    no_fields = ak.Array([{"x": 1}, {"y": 2}, {}, {}, {}])
+    no_fields["new_field"] = ak.Array([1, 2, 3, 4, 5])
+    assert no_fields.to_list() == [
+        {"new_field": 1, "x": 1, "y": None},
+        {"new_field": 2, "x": None, "y": 2},
+        {"new_field": 3, "x": None, "y": None},
+        {"new_field": 4, "x": None, "y": None},
+        {"new_field": 5, "x": None, "y": None},
+    ]