diff --git a/src/validataclass_search_queries/search_queries/base_search_query.py b/src/validataclass_search_queries/search_queries/base_search_query.py index 6fd9372..67cbe0e 100644 --- a/src/validataclass_search_queries/search_queries/base_search_query.py +++ b/src/validataclass_search_queries/search_queries/base_search_query.py @@ -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. diff --git a/src/validataclass_search_queries/search_queries/search_query_dataclass.py b/src/validataclass_search_queries/search_queries/search_query_dataclass.py index 5e0222f..00b5010 100644 --- a/src/validataclass_search_queries/search_queries/search_query_dataclass.py +++ b/src/validataclass_search_queries/search_queries/search_query_dataclass.py @@ -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 @@ -90,7 +91,10 @@ 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(): @@ -98,7 +102,8 @@ def _prepare_search_query_dataclass(cls) -> None: # 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 + 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: @@ -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: diff --git a/tests/unit/search_queries/_helpers.py b/tests/unit/search_queries/_helpers.py new file mode 100644 index 0000000..d29c452 --- /dev/null +++ b/tests/unit/search_queries/_helpers.py @@ -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} diff --git a/tests/unit/search_queries/base_search_query_test.py b/tests/unit/search_queries/base_search_query_test.py new file mode 100644 index 0000000..5edd3b6 --- /dev/null +++ b/tests/unit/search_queries/base_search_query_test.py @@ -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', + } diff --git a/tests/unit/search_queries/search_query_dataclass_test.py b/tests/unit/search_queries/search_query_dataclass_test.py new file mode 100644 index 0000000..128998a --- /dev/null +++ b/tests/unit/search_queries/search_query_dataclass_test.py @@ -0,0 +1,383 @@ +""" +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 dataclasses import FrozenInstanceError + +import pytest +from validataclass.dataclasses import Default, DefaultUnset, NoDefault, validataclass_field +from validataclass.exceptions import DataclassValidatorFieldException +from validataclass.helpers import UnsetValue, UnsetValueType +from validataclass.validators import BooleanValidator, IntegerValidator, RejectValidator, StringValidator + +from tests.unit.search_queries._helpers import assert_field_default, assert_field_no_default, get_dataclass_fields +from validataclass_search_queries.filters import ( + SearchParamEquals, + SearchParamGreaterThan, + SearchParamLessThan, + SearchParamTernary, + SearchParamMultiSelect, +) +from validataclass_search_queries.search_queries import search_query_dataclass +from validataclass_search_queries.validators import MultiSelectIntegerValidator + + +def test_search_query_dataclass_with_empty_class(): + """ Create an empty search query dataclass. """ + + @search_query_dataclass + class EmptySearchQuery: + pass + + assert len(get_dataclass_fields(EmptySearchQuery)) == 0 + + +def test_search_query_dataclass_without_kwargs(): + """ Create a search query dataclass without additional arguments and check that fields are created correctly. """ + + @search_query_dataclass + class UnitTestSearchQuery: + # Regular validataclass field (no default expected) + regular_required: str = StringValidator() + + # Regular validataclass field with default + regular_optional: str | None = StringValidator(), Default(None) + + # Field created explicitly via validataclass_field() + explicit_field: str = validataclass_field(StringValidator(), default=Default('meow')) + + # Search parameter without explicit default (default None expected) + filter1: int | None = SearchParamEquals('field'), IntegerValidator(allow_strings=True) + + # Search parameter with explicit default + filter2: int = SearchParamGreaterThan(), IntegerValidator(allow_strings=True), Default(42) + + # Search parameter that explicitly has no default + filter3: int = SearchParamLessThan(), IntegerValidator(allow_strings=True), NoDefault + + # Special search parameters + filter4: list[int] | None = SearchParamMultiSelect(), MultiSelectIntegerValidator(min_value=0, max_value=10) + filter5: bool | None = SearchParamTernary('visible', 'hidden'), BooleanValidator(allow_strings=True) + + # Check that @search_query_dataclass actually created a dataclass (i.e. @dataclass was used on the class) + assert dataclasses.is_dataclass(UnitTestSearchQuery) + + # Get fields from dataclass + fields = get_dataclass_fields(UnitTestSearchQuery) + + # Check names and types of all fields + assert list(fields.keys()) == [ + 'regular_required', + 'regular_optional', + 'explicit_field', + 'filter1', + 'filter2', + 'filter3', + 'filter4', + 'filter5', + ] + assert fields['regular_required'].type == str + assert fields['regular_optional'].type == str | None + assert fields['explicit_field'].type == str + assert fields['filter1'].type == int | None + assert fields['filter2'].type == int + assert fields['filter3'].type == int + assert fields['filter4'].type == list[int] | None + assert fields['filter5'].type == bool | None + + # Check field defaults + assert_field_no_default(fields['regular_required']) + assert_field_default(fields['regular_optional'], default_value=None) + assert_field_default(fields['explicit_field'], default_value='meow') + assert_field_default(fields['filter1'], default_value=None) + assert_field_default(fields['filter2'], default_value=42) + assert_field_no_default(fields['filter3']) + assert_field_default(fields['filter4'], default_value=None) + assert_field_default(fields['filter5'], default_value=None) + + # Check that fields have correct validators + assert type(fields['regular_required'].metadata.get('validator')) is StringValidator + assert type(fields['regular_optional'].metadata.get('validator')) is StringValidator + assert type(fields['explicit_field'].metadata.get('validator')) is StringValidator + assert type(fields['filter1'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter2'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter3'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter4'].metadata.get('validator')) is MultiSelectIntegerValidator + assert type(fields['filter5'].metadata.get('validator')) is BooleanValidator + + # Check that fields have correct SearchParams + assert fields['regular_required'].metadata.get('search_param') is None + assert fields['regular_optional'].metadata.get('search_param') is None + assert fields['explicit_field'].metadata.get('search_param') is None + assert type(fields['filter1'].metadata.get('search_param')) is SearchParamEquals + assert type(fields['filter2'].metadata.get('search_param')) is SearchParamGreaterThan + assert type(fields['filter3'].metadata.get('search_param')) is SearchParamLessThan + assert type(fields['filter4'].metadata.get('search_param')) is SearchParamMultiSelect + filter5_search_param = fields['filter5'].metadata.get('search_param') + assert type(filter5_search_param) is SearchParamTernary + assert filter5_search_param.value_true == 'visible' + assert filter5_search_param.value_false == 'hidden' + + +def test_search_query_dataclass_with_kwargs(): + """ Create a search query dataclass with additional arguments and check that they are passed to @dataclass. """ + + # As an example, create two dataclasses, one without any arguments and one with frozen=True. + # Writing to an object of the frozen dataclass should raise an exception. + + @search_query_dataclass() + class NormalSearchQuery: + foo: int | None = SearchParamEquals(), IntegerValidator() + + @search_query_dataclass(frozen=True) + class FrozenSearchQuery: + foo: int | None = SearchParamEquals(), IntegerValidator() + + # NormalSearchQuery is not frozen, writing is allowed + normal = NormalSearchQuery(foo=42) + assert normal.foo == 42 + normal.foo = 13 + assert normal.foo == 13 + + # FrozenSearchQuery is frozen, writing should raise an exception! + frozen = FrozenSearchQuery(foo=42) + assert frozen.foo == 42 + with pytest.raises(FrozenInstanceError, match="cannot assign to field 'foo'"): + frozen.foo = 13 # type: ignore[misc] + + +def test_search_query_dataclass_create_objects_valid(): + """ Create a search query dataclass and instantiate objects from it. """ + + @search_query_dataclass + class UnitTestSearchQuery: + # Regular required/optional validataclass fields + regular_required: str = StringValidator() + regular_optional: str | None = StringValidator(), Default(None) + + # Search filters with/without explicit default + filter1: int | None = SearchParamEquals('field'), IntegerValidator(allow_strings=True) + filter2: int = SearchParamGreaterThan(), IntegerValidator(allow_strings=True), Default(42) + + # Create an instance where all fields are specified explicitly + instance1 = UnitTestSearchQuery( + regular_required='banana', + regular_optional='apple', + filter1=42, + filter2=1312, + ) + assert instance1.regular_required == 'banana' + assert instance1.regular_optional == 'apple' + assert instance1.filter1 == 42 + assert instance1.filter2 == 1312 + + # Create an instance with default values + instance2 = UnitTestSearchQuery( + regular_required='banana', + ) + assert instance2.regular_required == 'banana' + assert instance2.regular_optional is None + assert instance2.filter1 is None + assert instance2.filter2 == 42 + + +def test_search_query_dataclass_create_objects_invalid(): + """ Create a search query dataclass and try to instantiate objects from it, but missing a required values. """ + + @search_query_dataclass + class UnitTestSearchQuery: + # Regular required/optional validataclass fields + regular_required: str = StringValidator() + regular_optional: str | None = StringValidator(), Default(None) + + # Search filters with/without explicit default + filter1: int | None = SearchParamEquals('field'), IntegerValidator(allow_strings=True) + filter2: int = SearchParamGreaterThan(), IntegerValidator(allow_strings=True), Default(42) + + # Try to instantiate without the required field + with pytest.raises(TypeError, match="missing 1 required keyword-only argument: 'regular_required'"): + UnitTestSearchQuery() + + # Try to instantiate with some fields, but missing the required field + with pytest.raises(TypeError, match="missing 1 required keyword-only argument: 'regular_required'"): + UnitTestSearchQuery( + regular_optional='apple', + filter1=42, + filter2=1312, + ) + + +# Subclassing / inheritance + +def test_search_query_dataclass_subclassing(): + """ Create a subclass of a search query dataclass and check that fields are inherited and overridden correctly. """ + + @search_query_dataclass + class BaseClass: + # Regular fields + regular_required1: str = StringValidator() + regular_required2: str = StringValidator() + regular_optional1: str = StringValidator(), Default('') + regular_optional2: str = StringValidator(), Default('') + regular_to_filter: str = StringValidator() + + # Search parameters + filter1: int | None = SearchParamEquals(), IntegerValidator(allow_strings=True) + filter2: int = SearchParamEquals(), IntegerValidator(allow_strings=True), Default(42) + filter3: int | None = SearchParamEquals(), IntegerValidator(allow_strings=True) + filter4: int | None = SearchParamEquals(), IntegerValidator(allow_strings=True), Default(None) + filter5: int = SearchParamEquals(), IntegerValidator(allow_strings=True), NoDefault + + @search_query_dataclass + class SubClass(BaseClass): + # Skipped fields must be still present and unchanged in subclass + # regular_required1: Unchanged + # regular_optional1: Unchanged + # filter1: Unchanged + # filter2: Unchanged + + # Changing defaults or validators in regular fields + regular_required2: UnsetValueType = RejectValidator(), DefaultUnset + regular_optional2: str = NoDefault + + # Changing a regular field to a search parameter + regular_to_filter: str | None = SearchParamEquals() + + # Changing defaults or validators in search parameter fields + filter3: int = NoDefault + filter4: int = Default(42) + filter5: None = RejectValidator(), Default(None) + + # Check that @search_query_dataclass actually created a dataclass (i.e. @dataclass was used on the class) + assert dataclasses.is_dataclass(SubClass) + + # Get fields from dataclass + fields = get_dataclass_fields(SubClass) + + # Check names and types of all fields + assert list(fields.keys()) == [ + 'regular_required1', + 'regular_required2', + 'regular_optional1', + 'regular_optional2', + 'regular_to_filter', + 'filter1', + 'filter2', + 'filter3', + 'filter4', + 'filter5', + ] + assert fields['regular_required1'].type == str + assert fields['regular_required2'].type == UnsetValueType + assert fields['regular_optional1'].type == str + assert fields['regular_optional2'].type == str + assert fields['regular_to_filter'].type == str | None + assert fields['filter1'].type == int | None + assert fields['filter2'].type == int + assert fields['filter3'].type == int + assert fields['filter4'].type == int + assert fields['filter5'].type is None + + # Check field defaults + assert_field_no_default(fields['regular_required1']) + assert_field_default(fields['regular_required2'], default_value=UnsetValue) + assert_field_default(fields['regular_optional1'], default_value='') + assert_field_no_default(fields['regular_optional2']) + assert_field_default(fields['regular_to_filter'], default_value=None) + assert_field_default(fields['filter1'], default_value=None) + assert_field_default(fields['filter2'], default_value=42) + assert_field_no_default(fields['filter3']) + assert_field_default(fields['filter4'], default_value=42) + assert_field_default(fields['filter5'], default_value=None) + + # Check that fields have correct validators + assert type(fields['regular_required1'].metadata.get('validator')) is StringValidator + assert type(fields['regular_required2'].metadata.get('validator')) is RejectValidator + assert type(fields['regular_optional1'].metadata.get('validator')) is StringValidator + assert type(fields['regular_optional2'].metadata.get('validator')) is StringValidator + assert type(fields['regular_to_filter'].metadata.get('validator')) is StringValidator + assert type(fields['filter1'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter2'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter3'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter4'].metadata.get('validator')) is IntegerValidator + assert type(fields['filter5'].metadata.get('validator')) is RejectValidator + + # Check that fields have correct SearchParams + assert fields['regular_required1'].metadata.get('search_param') is None + assert fields['regular_required2'].metadata.get('search_param') is None + assert fields['regular_optional1'].metadata.get('search_param') is None + assert fields['regular_optional2'].metadata.get('search_param') is None + assert type(fields['regular_to_filter'].metadata.get('search_param')) is SearchParamEquals + assert type(fields['filter1'].metadata.get('search_param')) is SearchParamEquals + assert type(fields['filter2'].metadata.get('search_param')) is SearchParamEquals + assert type(fields['filter3'].metadata.get('search_param')) is SearchParamEquals + assert type(fields['filter4'].metadata.get('search_param')) is SearchParamEquals + assert type(fields['filter5'].metadata.get('search_param')) is SearchParamEquals + + +# Error cases + +def test_search_query_dataclass_with_invalid_values(): + """ + Test that @search_query_dataclass raises exceptions if a field is not already defined (e.g. with field()) and has + no Validator. + """ + + with pytest.raises(DataclassValidatorFieldException) as exception_info: + @search_query_dataclass + class InvalidSearchQueryDataclass: + foo: int + + assert str(exception_info.value) == 'Dataclass field "foo" must specify a Validator.' + + +@pytest.mark.parametrize( + 'field_tuple, expected_exception_msg', + [ + # Missing validator + (None, 'Dataclass field "foo" must specify a Validator.'), + ((Default(3)), 'Dataclass field "foo" must specify a Validator.'), + ((SearchParamEquals(), Default(0)), 'Dataclass field "foo" must specify a Validator.'), + + # Too many validators + ( + (IntegerValidator(), StringValidator()), + 'Dataclass field "foo": Only one Validator can be specified.', + ), + ( + (SearchParamEquals(), IntegerValidator(), IntegerValidator()), + 'Dataclass field "foo": Only one Validator can be specified.', + ), + + # Too many defaults + ( + (Default(1), IntegerValidator(), Default(2)), + 'Dataclass field "foo": Only one Default can be specified.', + ), + ( + (Default(1), SearchParamEquals(), IntegerValidator(), Default(2)), + 'Dataclass field "foo": Only one Default can be specified.', + ), + + # Too many SearchParams + ( + (SearchParamEquals(), IntegerValidator(), SearchParamLessThan()), + 'Dataclass field "foo": Only one SearchParam can be specified.', + ), + + # Unexpected type in tuple + ((IntegerValidator(), 42), 'Dataclass field "foo": Unexpected type of argument: int'), + ((SearchParamEquals(), IntegerValidator(), 42), 'Dataclass field "foo": Unexpected type of argument: int'), + ], +) +def test_search_query_dataclass_with_invalid_field_tuples(field_tuple, expected_exception_msg): + """ Test that @search_query_dataclass raises exceptions for various invalid tuples. """ + with pytest.raises(DataclassValidatorFieldException) as exception_info: + @search_query_dataclass + class InvalidSearchQueryDataclass: + foo: int = field_tuple + + assert str(exception_info.value) == expected_exception_msg