Repository: JakobGM/patito

Issue:
Problem while validating `list` type object
It seems that the `_find_errors` method used to validate the content of a `polars.DataFrame` based on a `patito.Model` is not taking list of objects properly when they are wrapped as `Optional`.

Here is a small reproducible code:

```python
    from typing import Optional

    import patito
    import polars


    class Inner(patito.Model):

        name: str
        reliability: bool
        level: int


    class Outer(patito.Model):

        id: str
        code: str
        label: str
        inner_types: Optional[list[Inner]]


    df = polars.DataFrame(
        {
            "id": [1, 2, 3],
            "code": ["A", "B", "C"],
            "label": ["a", "b", "c"],
            "inner_types": [
                [{"name": "a", "reliability": True, "level": 1}],
                [{"name": "b", "reliability": False, "level": 2}],
                None,
            ],
        }
    )
    df = Outer.DataFrame(df).cast().derive()
    df.validate()
```

Here is the traceback I get:

```bash
Traceback (most recent call last):
  File "/home/user/__init__.py", line 1093, in <module>
    df.validate()
  File "/home/user/.venv/lib/python3.9/site-packages/patito/polars.py", line 584, in validate
    self.model.validate(dataframe=self, columns=columns, **kwargs)
  File "/home/user/.venv/lib/python3.9/site-packages/patito/pydantic.py", line 476, in validate
    validate(
  File "/home/user/.venv/lib/python3.9/site-packages/patito/validators.py", line 445, in validate
    errors = _find_errors(
  File "/home/user/.venv/lib/python3.9/site-packages/patito/validators.py", line 303, in _find_errors
    list_struct_errors = _find_errors(
  File "/home/user/.venv/lib/python3.9/site-packages/patito/validators.py", line 143, in _find_errors
    schema_subset = columns or schema.columns
AttributeError: type object 'list' has no attribute 'columns'
```

It might comes from the fact list of object which aren't primitive types aren't well understood by the method? Or list wrapped into `Optional` aren't well understood?

Discussion:

Solution patch:
diff --git a/src/patito/validators.py b/src/patito/validators.py
index 5bf6fb0..fa09eae 100644
--- a/src/patito/validators.py
+++ b/src/patito/validators.py
@@ -277,29 +277,20 @@ def _find_errors(  # noqa: C901
         elif schema.dtypes[column_name] == pl.List(pl.Struct):
             list_annotation = schema.model_fields[column_name].annotation
             assert list_annotation is not None
-            nested_schema = list_annotation.__args__[0]
 
             # Additional unpack required if structs column is optional
-            if is_optional(nested_schema):
-                nested_schema = unwrap_optional(nested_schema)
-                # An optional struct means that we allow the struct entry to be
-                # null. It is the inner model that is responsible for determining
-                # whether its fields are optional or not. Since the struct is optional,
-                # we need to filter out any null rows as the inner model may disallow
-                # nulls on a particular field
+            if is_optional(list_annotation):
+                list_annotation = unwrap_optional(list_annotation)
+                # An optional list means that we allow the list entry to be
+                # null. Since the list is optional, we need to filter out any
+                # null rows.
 
-                # NB As of Polars 1.1, struct_col.is_null()  cannot return True
-                # The following code has been added to accomodate this
-
-                struct_fields = dataframe_tmp[column_name].struct.fields
-                col_struct = pl.col(column_name).struct
-                only_non_null_expr = ~pl.all_horizontal(
-                    [col_struct.field(name).is_null() for name in struct_fields]
-                )
-                dataframe_tmp = dataframe_tmp.filter(only_non_null_expr)
+                dataframe_tmp = dataframe_tmp.filter(pl.col(column_name).is_not_null())
                 if dataframe_tmp.is_empty():
                     continue
 
+            nested_schema = list_annotation.__args__[0]
+
             list_struct_errors = _find_errors(
                 dataframe=dataframe_tmp.select(column_name)
                 .explode(column_name)


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 186da34..0148b3c 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -862,6 +862,52 @@ class ListModel(pt.Model):
             ListModel.validate(valid_df.with_columns(pl.col(old).alias(new)))
 
 
+def test_optional_nested_list() -> None:
+    """It should be able to validate optional structs organized in lists."""
+
+    class Inner(pt.Model):
+        name: str
+        reliability: bool
+        level: int
+
+    class Outer(pt.Model):
+        id: str
+        code: str
+        label: str
+        inner_types: Optional[list[Inner]]  # noqa: UP007
+
+    good_df = pl.DataFrame(
+        {
+            "id": [1, 2, 3],
+            "code": ["A", "B", "C"],
+            "label": ["a", "b", "c"],
+            "inner_types": [
+                [{"name": "a", "reliability": True, "level": 1}],
+                [{"name": "b", "reliability": False, "level": 2}],
+                None,
+            ],
+        }
+    )
+    df = Outer.DataFrame(good_df).cast().derive()
+    df.validate()
+
+    bad_df = pl.DataFrame(
+        {
+            "id": [1, 2, 3],
+            "code": ["A", "B", "C"],
+            "label": ["a", "b", "c"],
+            "inner_types": [
+                [{"name": "a", "level": 1}],  # missing reliability
+                [{"name": "b", "reliability": False, "level": 2}],
+                None,
+            ],
+        }
+    )
+    df = Outer.DataFrame(bad_df).cast().derive()
+    with pytest.raises(DataFrameValidationError):
+        df.validate()
+
+
 def test_nested_field_attrs() -> None:
     """Ensure that constraints are respected even when embedded inside 'anyOf'."""