Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class MySearchQuery(BaseSearchQuery):
def get_search_filters(self) -> Iterator[tuple[str, BoundSearchFilter]]:
"""
Returns an iterator that instantiates BoundSearchFilters for all search parameters in this class that are set
by the user (i.e. not None).
by the user (i.e. not None or UnsetValue).

The iterator yields tuples consisting of exactly two elements: the parameter name (i.e. the field name in the
dataclass) and the BoundSearchFilter.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from typing_extensions import dataclass_transform
from validataclass.dataclasses import validataclass, validataclass_field, Default
from validataclass.exceptions import DataclassValidatorFieldException
from validataclass.validators import Validator

from ..filters import SearchParam
Comment thread
JGaukrogers marked this conversation as resolved.
Expand Down Expand Up @@ -90,15 +91,19 @@ def _prepare_search_query_dataclass(cls) -> None:
field_args = existing_fields.get(name, {})

# Overwrite existing field arguments with validator etc. from tuple
field_args.update(_parse_validator_tuple(value))
try:
field_args.update(_parse_validator_tuple(value))
except Exception as e:
raise DataclassValidatorFieldException(f'Dataclass field "{name}": {e}')

# Ignore all fields without a SearchParam (they will be handled by @validataclass as usual validataclass fields)
if 'search_param' not in field_args.keys():
continue

# Ensure that a validator is set
if not isinstance(field_args.get('validator', None), Validator):
raise ValueError(f'Dataclass field "{name}" must specify a Validator.')
# TODO: Update exception messages to be consistent with validataclass 0.12.0
Comment thread
flauschzelle marked this conversation as resolved.
raise DataclassValidatorFieldException(f'Dataclass field "{name}" must specify a Validator.')

# For SearchParam fields, use Default(None) if no explicit default was set
if field_args.get('default', None) is None:
Expand Down Expand Up @@ -147,6 +152,7 @@ def _parse_validator_tuple(args: Any) -> dict:
# Find validator, default object and search param in tuple and return them as a dictionary
arg_dict = {}

# TODO: Update exception messages to be consistent with validataclass 0.12.0
for arg in args:
if isinstance(arg, Validator):
if 'validator' in arg_dict:
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/search_queries/_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
validataclass-search-queries
Copyright (c) 2026, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

import dataclasses
from typing import Any

from validataclass.dataclasses import Default


# Test helpers for dataclass tests

def assert_field_default(field: dataclasses.Field[Any], default_value: Any) -> None:
"""
Assert that a given validataclass field has the specified default value.
"""
# Check that the field has a regular dataclass default VALUE or default FACTORY, but not both
assert field.default is not dataclasses.MISSING or field.default_factory is not dataclasses.MISSING
assert field.default is dataclasses.MISSING or field.default_factory is dataclasses.MISSING

# Check regular dataclass default
if field.default_factory is not dataclasses.MISSING:
assert field.default_factory() == default_value
else:
assert field.default == default_value

# Check defaults in dataclass metadata
metadata_default = field.metadata.get('validator_default')
assert isinstance(metadata_default, Default)
assert metadata_default.get_value() == default_value


def assert_field_no_default(field: dataclasses.Field[Any]) -> None:
"""
Assert that a given validataclass field has no default value.
"""
# Check regular dataclass defaults
assert field.default is dataclasses.MISSING
assert field.default_factory is dataclasses.MISSING

# Check defaults in dataclass metadata
assert 'validator_default' not in field.metadata


def get_dataclass_fields(cls: type[object]) -> dict[str, dataclasses.Field[Any]]:
"""
Return a dictionary containing all fields of a given dataclass.
"""
# Make sure the class is really a dataclass
assert dataclasses.is_dataclass(cls) and isinstance(cls, type)

# Get fields and return them as a dictionary
fields_tuple = dataclasses.fields(cls)
return {field.name: field for field in fields_tuple}
159 changes: 159 additions & 0 deletions tests/unit/search_queries/base_search_query_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""
validataclass-search-queries
Copyright (c) 2026, binary butterfly GmbH and contributors
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

from typing import Iterator

import pytest
from validataclass.dataclasses import DefaultUnset
from validataclass.exceptions import DictFieldsValidationError
from validataclass.helpers import UnsetValue, UnsetValueType
from validataclass.validators import DataclassValidator, EnumValidator, IntegerValidator, StringValidator

from tests.helpers import UnitTestEnum
from validataclass_search_queries.filters import BoundSearchFilter, SearchParamEquals, SearchParamGreaterThan
from validataclass_search_queries.search_queries import BaseSearchQuery, search_query_dataclass


@search_query_dataclass
class UnitTestSearchQuery(BaseSearchQuery):
""" Example search query dataclass to use in unit tests. """

# Regular validataclass field (should be ignored by get_search_filters)
regular_required: str = StringValidator()

# Simple search parameter with default column name (auto-determined from field name) and implicit default None
filter1: int | None = SearchParamEquals(), IntegerValidator(allow_strings=True)

# Search parameter with explicit column name
filter2: int | None = SearchParamEquals('example_column'), IntegerValidator(allow_strings=True)

# Search parameter with explicit default UnsetValue
filter3: int | UnsetValueType = SearchParamGreaterThan(), IntegerValidator(allow_strings=True), DefaultUnset


def test_search_query_manual_instantiation_with_minimal_data():
""" Test manual instantiation of a search query dataclass with minimal data (i.e. only required fields). """
search_query = UnitTestSearchQuery(regular_required='test')

# Check values
assert search_query.regular_required == 'test'
assert search_query.filter1 is None
assert search_query.filter2 is None
assert search_query.filter3 is UnsetValue

# Only set fields are regular validataclass fields, so there should be no search filters
assert list(search_query.get_search_filters()) == []

# Check result of to_dict()
assert search_query.to_dict() == {
'regular_required': 'test',
}


def test_search_query_manual_instantiation_with_complete_data():
""" Test manual instantiation of a search query dataclass with complete data (all fields set). """
search_query = UnitTestSearchQuery(
regular_required='foo',
filter1=42,
filter2=1312,
filter3=10,
)

# Check values
assert search_query.regular_required == 'foo'
assert search_query.filter1 == 42
assert search_query.filter2 == 1312
assert search_query.filter3 == 10

# Check that get_search_filters returns an Iterator
assert isinstance(search_query.get_search_filters(), Iterator)

# Check bound search filters (should only contain fields with SearchParam)
bound_filters = [
(field_name, bound_filter.column_name, type(bound_filter.search_param), bound_filter.value)
for field_name, bound_filter in search_query.get_search_filters()
]
assert bound_filters == [
('filter1', 'filter1', SearchParamEquals, 42),
('filter2', 'example_column', SearchParamEquals, 1312),
('filter3', 'filter3', SearchParamGreaterThan, 10),
]

# Check result of to_dict()
assert search_query.to_dict() == {
'regular_required': 'foo',
'filter1': 42,
'filter2': 1312,
'filter3': 10,
}


def test_search_query_with_dataclass_validator_valid():
""" Test validation of a search query dataclass with the DataclassValidator. """
validator = DataclassValidator(UnitTestSearchQuery)
search_query = validator.validate(
{
'regular_required': 'meow',
'filter1': '42',
}
)

# Check values
assert search_query.regular_required == 'meow'
assert search_query.filter1 == 42
assert search_query.filter2 is None
assert search_query.filter3 is UnsetValue

# Check bound search filters (should only contain fields with SearchParam)
bound_filters: dict[str, BoundSearchFilter] = dict(search_query.get_search_filters())
assert list(bound_filters.keys()) == ['filter1']
assert bound_filters['filter1'].column_name == 'filter1'
assert bound_filters['filter1'].value == 42
assert type(bound_filters['filter1'].search_param) is SearchParamEquals

# Check result of to_dict()
assert search_query.to_dict() == {
'regular_required': 'meow',
'filter1': 42,
}


def test_search_query_with_dataclass_validator_invalid():
""" Test validation of a search query dataclass with the DataclassValidator and invalid data. """
validator = DataclassValidator(UnitTestSearchQuery)

with pytest.raises(DictFieldsValidationError) as exception_info:
validator.validate(
{
'filter1': 'banana',
'filter2': True,
}
)

assert exception_info.value.to_dict() == {
'code': 'field_errors',
'field_errors': {
'regular_required': {'code': 'required_field'},
'filter1': {'code': 'invalid_integer'},
'filter2': {
'code': 'invalid_type',
'expected_types': ['int', 'str'],
},
},
}


def test_to_dict_with_enums():
""" Test that `BaseSearchQuery.to_dict()` properly converts enums to their values. """

@search_query_dataclass
class SearchQueryWithEnum(BaseSearchQuery):
enum_filter: UnitTestEnum | None = SearchParamEquals(), EnumValidator(UnitTestEnum)

search_query = SearchQueryWithEnum(enum_filter=UnitTestEnum.FOO)
assert search_query.to_dict() == {
'enum_filter': 'foo',
}
Loading
Loading