Skip to content

Commit b5242c0

Browse files
authored
Merge pull request #210 from doubledare704/feature/Add-support-for-OR-and-AND-conditions-in-filtering
Implement OR and NOT for filtering.
2 parents 92b4778 + 2337e43 commit b5242c0

File tree

3 files changed

+296
-37
lines changed

3 files changed

+296
-37
lines changed

docs/advanced/filters.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Advanced Filtering
2+
3+
The `_parse_filters` method in FastCRUD supports complex filtering operations including OR and NOT conditions.
4+
5+
## Basic Usage
6+
7+
Filters are specified as keyword arguments in the format `field_name__operator=value`:
8+
9+
```python
10+
# Simple equality filter
11+
results = await crud.get_multi(db, name="John")
12+
13+
# Comparison operators
14+
results = await crud.get_multi(db, age__gt=18)
15+
```
16+
17+
## OR Operations
18+
19+
Use the `__or` suffix to apply multiple conditions to the same field with OR logic:
20+
21+
```python
22+
# Find users aged under 18 OR over 65
23+
results = await crud.get_multi(
24+
db,
25+
age__or={
26+
"lt": 18,
27+
"gt": 65
28+
}
29+
)
30+
# Generates: WHERE age < 18 OR age > 65
31+
```
32+
33+
## NOT Operations
34+
35+
Use the `__not` suffix to negate multiple conditions on the same field:
36+
37+
```python
38+
# Find users NOT aged 20 AND NOT between 30-40
39+
results = await crud.get_multi(
40+
db,
41+
age__not={
42+
"eq": 20,
43+
"between": (30, 40)
44+
}
45+
)
46+
# Generates: WHERE NOT age = 20 AND NOT (age BETWEEN 30 AND 40)
47+
```
48+
49+
## Supported Operators
50+
51+
- Comparison: `eq`, `gt`, `lt`, `gte`, `lte`, `ne`
52+
- Null checks: `is`, `is_not`
53+
- Text matching: `like`, `notlike`, `ilike`, `notilike`, `startswith`, `endswith`, `contains`, `match`
54+
- Collections: `in`, `not_in`, `between`
55+
- Logical: `or`, `not`
56+
57+
## Examples
58+
59+
```python
60+
# Complex age filtering
61+
results = await crud.get_multi(
62+
db,
63+
age__or={
64+
"between": (20, 30),
65+
"eq": 18
66+
},
67+
status__not={
68+
"in": ["inactive", "banned"]
69+
}
70+
)
71+
72+
# Text search with OR conditions
73+
results = await crud.get_multi(
74+
db,
75+
name__or={
76+
"startswith": "A",
77+
"endswith": "smith"
78+
}
79+
)
80+
```
81+
82+
## Error Handling
83+
84+
- Invalid column names raise `ValueError`
85+
- Invalid operators are ignored
86+
- Invalid value types for operators (e.g., non-list for `between`) raise `ValueError`

fastcrud/crud/fast_crud.py

Lines changed: 118 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Generic, Union, Optional, Callable
1+
from typing import Any, Generic, Union, Optional, Callable, cast
22
from datetime import datetime, timezone
33

44
from pydantic import ValidationError
@@ -15,6 +15,8 @@
1515
desc,
1616
or_,
1717
column,
18+
not_,
19+
Column,
1820
)
1921
from sqlalchemy.exc import ArgumentError, MultipleResultsFound, NoResultFound
2022
from sqlalchemy.sql import Join
@@ -47,6 +49,7 @@
4749

4850
from ..endpoint.helper import _get_primary_keys
4951

52+
FilterCallable = Callable[[Column[Any]], Callable[..., ColumnElement[bool]]]
5053

5154
class FastCRUD(
5255
Generic[
@@ -453,6 +456,7 @@ class Comment(Base):
453456
"""
454457

455458
_SUPPORTED_FILTERS = {
459+
"eq": lambda column: column.__eq__,
456460
"gt": lambda column: column.__gt__,
457461
"lt": lambda column: column.__lt__,
458462
"gte": lambda column: column.__ge__,
@@ -471,6 +475,8 @@ class Comment(Base):
471475
"between": lambda column: column.between,
472476
"in": lambda column: column.in_,
473477
"not_in": lambda column: column.not_in,
478+
"or": lambda column: column.or_,
479+
"not": lambda column: column.not_,
474480
}
475481

476482
def __init__(
@@ -491,51 +497,131 @@ def _get_sqlalchemy_filter(
491497
self,
492498
operator: str,
493499
value: Any,
494-
) -> Optional[Callable[[str], Callable]]:
500+
) -> Optional[FilterCallable]:
495501
if operator in {"in", "not_in", "between"}:
496502
if not isinstance(value, (tuple, list, set)):
497503
raise ValueError(f"<{operator}> filter must be tuple, list or set")
498-
return self._SUPPORTED_FILTERS.get(operator)
504+
return cast(Optional[FilterCallable], self._SUPPORTED_FILTERS.get(operator))
499505

500506
def _parse_filters(
501-
self, model: Optional[Union[type[ModelType], AliasedClass]] = None, **kwargs
507+
self,
508+
model: Optional[Union[type[ModelType], AliasedClass]] = None,
509+
**kwargs
502510
) -> list[ColumnElement]:
511+
"""Parse and convert filter arguments into SQLAlchemy filter conditions.
512+
513+
Args:
514+
model: The model to apply filters to. Defaults to self.model
515+
**kwargs: Filter arguments in the format field_name__operator=value
516+
517+
Returns:
518+
List of SQLAlchemy filter conditions
519+
"""
503520
model = model or self.model
504521
filters = []
505522

506523
for key, value in kwargs.items():
507-
if "__" in key:
508-
field_name, op = key.rsplit("__", 1)
509-
column = getattr(model, field_name, None)
510-
if column is None:
511-
raise ValueError(f"Invalid filter column: {field_name}")
512-
if op == "or":
513-
or_filters = [
514-
sqlalchemy_filter(column)(or_value)
515-
for or_key, or_value in value.items()
516-
if (
517-
sqlalchemy_filter := self._get_sqlalchemy_filter(
518-
or_key, or_value
519-
)
520-
)
521-
is not None
522-
]
523-
filters.append(or_(*or_filters))
524-
else:
525-
sqlalchemy_filter = self._get_sqlalchemy_filter(op, value)
526-
if sqlalchemy_filter:
527-
filters.append(
528-
sqlalchemy_filter(column)(value)
529-
if op != "between"
530-
else sqlalchemy_filter(column)(*value)
531-
)
524+
if "__" not in key:
525+
filters.extend(self._handle_simple_filter(model, key, value))
526+
continue
527+
528+
field_name, operator = key.rsplit("__", 1)
529+
model_column = self._get_column(model, field_name)
530+
531+
if operator == "or":
532+
filters.extend(self._handle_or_filter(model_column, value))
533+
elif operator == "not":
534+
filters.extend(self._handle_not_filter(model_column, value))
532535
else:
533-
column = getattr(model, key, None)
534-
if column is not None:
535-
filters.append(column == value)
536+
filters.extend(self._handle_standard_filter(model_column, operator, value))
536537

537538
return filters
538539

540+
def _handle_simple_filter(
541+
self,
542+
model: Union[type[ModelType], AliasedClass],
543+
key: str,
544+
value: Any
545+
) -> list[ColumnElement]:
546+
"""Handle simple equality filters (e.g., name='John')."""
547+
col = getattr(model, key, None)
548+
return [col == value] if col is not None else []
549+
550+
def _handle_or_filter(
551+
self,
552+
col: Column,
553+
value: dict
554+
) -> list[ColumnElement]:
555+
"""Handle OR conditions (e.g., age__or={'gt': 18, 'lt': 65})."""
556+
if not isinstance(value, dict):
557+
raise ValueError("OR filter value must be a dictionary")
558+
559+
or_conditions = []
560+
for or_op, or_value in value.items():
561+
sqlalchemy_filter = self._get_sqlalchemy_filter(or_op, or_value)
562+
if sqlalchemy_filter:
563+
condition = (
564+
sqlalchemy_filter(col)(*or_value)
565+
if or_op == "between"
566+
else sqlalchemy_filter(col)(or_value)
567+
)
568+
or_conditions.append(condition)
569+
570+
return [or_(*or_conditions)] if or_conditions else []
571+
572+
def _handle_not_filter(
573+
self,
574+
col: Column,
575+
value: dict
576+
) -> list[ColumnElement[bool]]:
577+
"""Handle NOT conditions (e.g., age__not={'eq': 20, 'between': (30, 40)})."""
578+
if not isinstance(value, dict):
579+
raise ValueError("NOT filter value must be a dictionary")
580+
581+
not_conditions = []
582+
for not_op, not_value in value.items():
583+
sqlalchemy_filter = self._get_sqlalchemy_filter(not_op, not_value)
584+
if sqlalchemy_filter is None:
585+
continue
586+
587+
condition = (
588+
sqlalchemy_filter(col)(*not_value)
589+
if not_op == "between"
590+
else sqlalchemy_filter(col)(not_value)
591+
)
592+
not_conditions.append(condition)
593+
594+
return [and_(*(not_(cond) for cond in not_conditions))] if not_conditions else []
595+
596+
def _handle_standard_filter(
597+
self,
598+
col: Column[Any],
599+
operator: str,
600+
value: Any
601+
) -> list[ColumnElement[bool]]:
602+
"""Handle standard comparison operators (e.g., age__gt=18)."""
603+
sqlalchemy_filter = self._get_sqlalchemy_filter(operator, value)
604+
if sqlalchemy_filter is None:
605+
return []
606+
607+
condition = (
608+
sqlalchemy_filter(col)(*value)
609+
if operator == "between"
610+
else sqlalchemy_filter(col)(value)
611+
)
612+
return [condition]
613+
614+
def _get_column(
615+
self,
616+
model: Union[type[ModelType], AliasedClass],
617+
field_name: str
618+
) -> Column[Any]:
619+
"""Get column from model, raising ValueError if not found."""
620+
model_column = getattr(model, field_name, None)
621+
if model_column is None:
622+
raise ValueError(f"Invalid filter column: {field_name}")
623+
return cast(Column[Any], model_column)
624+
539625
def _apply_sorting(
540626
self,
541627
stmt: Select,
@@ -2080,11 +2166,9 @@ async def get_multi_by_cursor(
20802166
db,
20812167
limit=10,
20822168
sort_column='registration_date',
2083-
sort_order='desc',
20842169
)
20852170
20862171
# Fetch the next set of records using the cursor from the first page
2087-
next_cursor = first_page['next_cursor']
20882172
second_page = await user_crud.get_multi_by_cursor(
20892173
db,
20902174
cursor=next_cursor,
@@ -2102,14 +2186,12 @@ async def get_multi_by_cursor(
21022186
limit=10,
21032187
sort_column='age',
21042188
sort_order='asc',
2105-
age__gt=30,
21062189
)
21072190
```
21082191
21092192
Fetch records excluding a specific username using cursor-based pagination:
21102193
21112194
```python
2112-
first_page = await user_crud.get_multi_by_cursor(
21132195
db,
21142196
limit=10,
21152197
sort_column='username',
@@ -2121,7 +2203,6 @@ async def get_multi_by_cursor(
21212203
Note:
21222204
This method is designed for efficient pagination in large datasets and is ideal for infinite scrolling features.
21232205
Make sure the column used for cursor pagination is indexed for performance.
2124-
This method assumes that your records can be ordered by a unique, sequential field (like `id` or `created_at`).
21252206
"""
21262207
if limit == 0:
21272208
return {"data": [], "next_cursor": None}

0 commit comments

Comments
 (0)