Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9bd09b0
WIP: Update to dev version of validataclass 0.12.0
binaryDiv Mar 18, 2026
1f9e357
Remove obsolete "_name" argument from validataclass_field() calls
binaryDiv Mar 18, 2026
551eeaa
Compatibility changes for validataclass 0.12.0
binaryDiv Mar 26, 2026
764f9eb
Add support for Python 3.13 and 3.14
binaryDiv Mar 12, 2026
d611ffd
Use absolute imports (unless same package)
binaryDiv Mar 18, 2026
1adb79c
Don't export type vars to discourage reusing them
binaryDiv Mar 18, 2026
3cee79d
Add mypy to dev toolchain
binaryDiv Mar 26, 2026
87b3d35
Refactor search_query_dataclass decorator for better typing
binaryDiv Mar 26, 2026
eb13e4d
Fix typing of multi-select validators
binaryDiv Mar 26, 2026
bb1bb5d
Fix typing of PaginationLimitValidator
binaryDiv Mar 26, 2026
5425978
Fix several typing issues
binaryDiv Mar 26, 2026
78915b7
Enable and configure validataclass mypy plugin
binaryDiv Mar 26, 2026
bdf423e
Explicitly re-export imports using __all__
binaryDiv Mar 26, 2026
41a5bc7
Improve typing of search filters
binaryDiv Mar 26, 2026
514dc81
Fix incorrect short-circuiting of substring search filters
binaryDiv Mar 26, 2026
2452ded
Enable mypy strict mode; fix various typing issues
binaryDiv Mar 26, 2026
66f3211
Enable more strict mypy rules; fix typing issues
binaryDiv Mar 26, 2026
5668517
Reformatting; reduce max-line-length to 120
binaryDiv Mar 26, 2026
1945f50
Add py.typed file to make the package PEP 561 compatible
binaryDiv Mar 26, 2026
0581805
CI: Run mypy in test workflow
binaryDiv Mar 26, 2026
6539698
Pin testing dependencies to minor versions instead of major versions
binaryDiv Apr 14, 2026
9de66d7
Update to final release of validataclass 0.12.0
binaryDiv Apr 14, 2026
2c785c0
Fix weird mypy thing that only happens on older Python versions
binaryDiv Apr 14, 2026
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
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14'
sqlalchemy-version:
- '1.4'
- '2.0'
Expand All @@ -42,7 +44,7 @@ jobs:

- name: Run test suite with tox
# Run tox using the version of Python in `PATH`
run: tox run -e clean,py-sqlalchemy${{ matrix.sqlalchemy-version }},report,flake8 -- --junit-xml=reports/pytest_${{ matrix.python-version }}_sqlalchemy${{ matrix.sqlalchemy-version }}.xml
run: tox run -e clean,py-sqlalchemy${{ matrix.sqlalchemy-version }},report,flake8,mypy -- --junit-xml=reports/pytest_${{ matrix.python-version }}_sqlalchemy${{ matrix.sqlalchemy-version }}.xml

- name: Upload test result artifacts
uses: actions/upload-artifact@v4
Expand Down
21 changes: 20 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ test:
flake8:
tox run -e flake8

# Only run mypy (via tox; you can also just run "mypy" directly)
.PHONY: mypy
mypy:
tox run -e mypy

# Open HTML coverage report in browser
.PHONY: open-coverage
open-coverage:
Expand All @@ -65,9 +70,19 @@ _docker-tox:
docker-tox: _docker-tox

# Run partial tox test suites in Docker
.PHONY: docker-test-py312-sqlalchemy1.4 docker-test-py312-sqlalchemy2.0 \
.PHONY: docker-test-py314-sqlalchemy1.4 docker-test-py314-sqlalchemy2.0 \
docker-test-py313-sqlalchemy1.4 docker-test-py313-sqlalchemy2.0 \
docker-test-py312-sqlalchemy1.4 docker-test-py312-sqlalchemy2.0 \
docker-test-py311-sqlalchemy1.4 docker-test-py311-sqlalchemy2.0 \
docker-test-py310-sqlalchemy1.4 docker-test-py310-sqlalchemy2.0
docker-test-py314-sqlalchemy1.4: TOX_ARGS="-e clean,py314-sqlalchemy1.4,py312-report"
docker-test-py314-sqlalchemy1.4: _docker-tox
docker-test-py314-sqlalchemy2.0: TOX_ARGS="-e clean,py314-sqlalchemy2.0,py312-report"
docker-test-py314-sqlalchemy2.0: _docker-tox
docker-test-py313-sqlalchemy1.4: TOX_ARGS="-e clean,py313-sqlalchemy1.4,py312-report"
docker-test-py313-sqlalchemy1.4: _docker-tox
docker-test-py313-sqlalchemy2.0: TOX_ARGS="-e clean,py313-sqlalchemy2.0,py312-report"
docker-test-py313-sqlalchemy2.0: _docker-tox
docker-test-py312-sqlalchemy1.4: TOX_ARGS="-e clean,py312-sqlalchemy1.4,py312-report"
docker-test-py312-sqlalchemy1.4: _docker-tox
docker-test-py312-sqlalchemy2.0: TOX_ARGS="-e clean,py312-sqlalchemy2.0,py312-report"
Expand All @@ -90,6 +105,10 @@ docker-test-all:
make docker-test-py311-sqlalchemy2.0
make docker-test-py312-sqlalchemy1.4
make docker-test-py312-sqlalchemy2.0
make docker-test-py313-sqlalchemy1.4
make docker-test-py313-sqlalchemy2.0
make docker-test-py314-sqlalchemy1.4
make docker-test-py314-sqlalchemy2.0

# Pull the latest image of the multi-python Docker image
.PHONY: docker-pull
Expand Down
6 changes: 5 additions & 1 deletion docs/02-using-search-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,16 @@ handle your special filter, and then continue using the methods like you would n
Here is an example how you could implement this:

```python
from typing import TypeVar, override
from sqlalchemy.orm import Session, Query
from validataclass.validators import StringValidator
from validataclass_search_queries.filters import SearchParamContains, BoundSearchFilter
from validataclass_search_queries.pagination import PaginatedResult
from validataclass_search_queries.repositories import SearchQueryRepositoryMixin
from validataclass_search_queries.search_queries import search_query_dataclass, BaseSearchQuery

T_Query = TypeVar('T_Query')

# Stubs for SQLAlchemy models (Customer needs a 1:n relationship "addresses" to Address)
class Customer: ...
class Address: ...
Expand Down Expand Up @@ -231,7 +234,8 @@ class CustomerRepository(SearchQueryRepositoryMixin[Customer]):
query = self.session.query(Customer).join(Customer.addresses)
return self._search_and_paginate(query, search_query)

def _apply_bound_search_filter(self, query: Query, bound_filter: BoundSearchFilter) -> Query:
@override
def _apply_bound_search_filter(self, query: Query[T_Query], bound_filter: BoundSearchFilter) -> Query[T_Query]:
# Implement special handling for the "city" filter
if bound_filter.column_name == 'city':
return query.filter(bound_filter.get_sqlalchemy_filter(Address.city))
Expand Down
51 changes: 51 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,54 @@ build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "src/validataclass_search_queries/_version.py"
version_scheme = "post-release"

[tool.mypy]
files = ["src/", "tests/"]
mypy_path = "src/"
explicit_package_bases = true

# Enable validataclass mypy plugin
plugins = [
"validataclass.mypy.plugin",
]

# Enable strict type checking
strict = true

# Enable further checks that are not included in strict mode
disallow_any_unimported = true
strict_equality_for_none = true
warn_unreachable = true
enable_error_code = [
"deprecated",
"explicit-override",
"ignore-without-code",
"mutable-override",
"possibly-undefined",
"redundant-expr",
"redundant-self",
"truthy-bool",
"truthy-iterable",
"unused-awaitable",
]

[[tool.mypy.overrides]]
module = 'tests.*'

# Don't enforce typed definitions in tests, this is a lot of unnecessary work (most parameters would be Any anyway).
allow_untyped_defs = true

[tool.validataclass_mypy]
# Allow incompatible overrides for fields in validataclass sub classes (this is the default, but the default might be
# changed in the future).
allow_incompatible_field_overrides = true

# Declare @search_query_dataclass as a decorator that creates validataclasses
custom_validataclass_decorators = [
"validataclass_search_queries.search_queries.search_query_dataclass.search_query_dataclass",
]

# Ignore SearchParam objects in validataclass field definitions
ignore_custom_types_in_fields = [
"validataclass_search_queries.filters.search_params.base_search_param.SearchParam",
]
15 changes: 9 additions & 6 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,19 @@ packages = find:
python_requires = ~=3.10
install_requires =
typing-extensions ~= 4.15
# Allow validataclass 0.10.* and 0.11.*
validataclass >= 0.10.0, < 0.12.0
# Allow validataclass 0.12.*
validataclass >= 0.12.0, < 0.13.0
sqlalchemy >= 1.4, < 2.1

[options.packages.find]
where = src

[options.extras_require]
# Set minimum versions but allow patch level updates with "~= x.y.z" to avoid breaking tests or similar, e.g. when mypy
# changes how error messages look in a minor version update.
testing =
pytest ~= 9.0
pytest-cov ~= 7.0
coverage ~= 7.13
flake8 ~= 7.3
pytest ~= 9.0.3
pytest-cov ~= 7.0.0
coverage ~= 7.13.5
flake8 ~= 7.3.0
mypy ~= 1.19.1
21 changes: 21 additions & 0 deletions src/validataclass_search_queries/filters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,24 @@
SearchParamStartsWith,
SearchParamEndsWith,
)

__all__ = [
'BoundSearchFilter',
'SearchParam',
'SearchParamBoolean',
'SearchParamIsNone',
'SearchParamIsNotNone',
'SearchParamTernary',
'SearchParamCustom',
'SearchParamEquals',
'SearchParamGreaterThan',
'SearchParamGreaterOrEqual',
'SearchParamLessThan',
'SearchParamLessOrEqual',
'SearchParamSince',
'SearchParamUntil',
'SearchParamMultiSelect',
'SearchParamContains',
'SearchParamStartsWith',
'SearchParamEndsWith',
]
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def column_name(self) -> str:
"""
return self.search_param.column_name or self.param_name

def get_sqlalchemy_filter(self, column: ColumnElement) -> ColumnElement:
def get_sqlalchemy_filter(self, column: ColumnElement[Any]) -> ColumnElement[bool]:
"""
Returns an SQLAlchemy filter for the given column (can be any ColumnElement, i.e. any SQLAlchemy expression that
can be used in a WHERE clause) based on the filter function defined by the SearchParam.
Expand Down
20 changes: 20 additions & 0 deletions src/validataclass_search_queries/filters/search_params/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,23 @@
SearchParamStartsWith,
SearchParamEndsWith,
)

__all__ = [
'SearchParam',
'SearchParamBoolean',
'SearchParamIsNone',
'SearchParamIsNotNone',
'SearchParamTernary',
'SearchParamCustom',
'SearchParamEquals',
'SearchParamGreaterThan',
'SearchParamGreaterOrEqual',
'SearchParamLessThan',
'SearchParamLessOrEqual',
'SearchParamSince',
'SearchParamUntil',
'SearchParamMultiSelect',
'SearchParamContains',
'SearchParamStartsWith',
'SearchParamEndsWith',
]
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ class MySearchQuery(BaseSearchQuery):
def __init__(self, column_name: str | None = None):
self.column_name = column_name

@staticmethod # pragma: nocover
@abstractmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
@abstractmethod # pragma: nocover
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
"""
This abstract method defines the SQLAlchemy filter expression. See existing implementations for examples.
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

from sqlalchemy.sql import ColumnElement
from typing_extensions import override

from .base_search_param import SearchParam

Expand All @@ -23,8 +24,8 @@ class SearchParamBoolean(SearchParam):
Boolean search parameter to filter a boolean column for true or false.
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.is_(bool(value))


Expand All @@ -36,21 +37,22 @@ class SearchParamIsNone(SearchParam):
If the search parameter is False, only results where the specified column is NOT None will be included.
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.is_(None) if value is True else column.is_not(None)


class SearchParamIsNotNone(SearchParam):
"""
Boolean search parameter to filter a column for values that are None or not None. Inverted version of SearchParamIsNone.
Boolean search parameter to filter a column for values that are None or not None.
Inverted version of SearchParamIsNone.

If the search parameter is True, only results where the specified column is NOT None will be included.
If the search parameter is False, only results where the specified column is None will be included.
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.is_not(None) if value is True else column.is_(None)


Expand All @@ -74,5 +76,6 @@ def __init__(self, true: Any, false: Any, *, column_name: str | None = None):
self.value_true = true
self.value_false = false

def sqlalchemy_filter(self, column: ColumnElement, value: Any) -> ColumnElement:
return column == (self.value_true if value else self.value_false)
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.__eq__(self.value_true if value else self.value_false)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

from sqlalchemy.sql import ColumnElement
from typing_extensions import override

from .base_search_param import SearchParam

Expand All @@ -24,6 +25,6 @@ class SearchParamCustom(SearchParam):
overriding either `_apply_bound_search_filter` or `_filter_by_search_query`.
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
raise NotImplementedError('Custom search parameter needs to be handled in the repository!')
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

from sqlalchemy.sql import ColumnElement
from typing_extensions import override

from .base_search_param import SearchParam

Expand All @@ -22,6 +23,6 @@ class SearchParamEquals(SearchParam):
Note: For strings, this might or might not be case sensitive, depending on your database collations.
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
return column == value
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.__eq__(value)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

from sqlalchemy.sql import ColumnElement
from typing_extensions import override

from .base_search_param import SearchParam

Expand All @@ -25,39 +26,39 @@ class SearchParamGreaterThan(SearchParam):
Search parameter to filter for values greater than the filter value (`column > value`).
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
return column > value
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.__gt__(value)


class SearchParamGreaterOrEqual(SearchParam):
"""
Search parameter to filter for values greater than or equal to the filter value (`column >= value`).
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
return column >= value
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.__ge__(value)


class SearchParamLessThan(SearchParam):
"""
Search parameter to filter for values less than the filter value (`column < value`).
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
return column < value
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.__lt__(value)


class SearchParamLessOrEqual(SearchParam):
"""
Search parameter to filter for values less than or equal to the filter value (`column <= value`).
"""

@staticmethod
def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement:
return column <= value
@override
def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]:
return column.__le__(value)


class SearchParamSince(SearchParamGreaterOrEqual):
Expand Down
Loading
Loading