Skip to content

Commit 0c5b1e7

Browse files
authored
Merge pull request #74 from mts-ai/feature/Fix-custom-sql-filtering-support
Fix custom sql filtering support: bring back backward compatibility
2 parents 0d716a4 + c918b5f commit 0c5b1e7

File tree

10 files changed

+345
-113
lines changed

10 files changed

+345
-113
lines changed

Diff for: docs/changelog.rst

+15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
Changelog
22
#########
33

4+
**2.5.1**
5+
*********
6+
7+
Fix custom sql filtering, bring back backward compatibility
8+
===========================================================
9+
10+
* Fix custom sql filtering support: bring back backward compatibility by `@mahenzon`_ in `#74 <https://github.com/mts-ai/FastAPI-JSONAPI/pull/74>`_
11+
* Read version from file by `@mahenzon`_ in `#74 <https://github.com/mts-ai/FastAPI-JSONAPI/pull/74>`_
12+
13+
Authors
14+
"""""""
15+
16+
* `@mahenzon`_
17+
18+
419
**2.5.0**
520
*********
621

Diff for: docs/conf.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@
1919
import os
2020
import sys
2121
from datetime import datetime
22+
from pathlib import Path
2223

2324
sys.path.insert(0, os.path.abspath(".."))
2425

26+
BASE_DIR = Path(__file__).resolve().parent.parent
27+
VERSION_FILEPATH = BASE_DIR / "fastapi_jsonapi" / "VERSION"
28+
RELEASE_VERSION = VERSION_FILEPATH.read_text().strip()
29+
2530
# -- General configuration ------------------------------------------------
2631

2732
# If your documentation needs a minimal Sphinx version, state it here.
@@ -64,9 +69,9 @@
6469
# built documents.
6570
#
6671
# The short X.Y version.
67-
version = "2.5"
72+
version = ".".join(RELEASE_VERSION.split(".", maxsplit=2)[:2])
6873
# The full version, including alpha/beta/rc tags.
69-
release = "2.5.2"
74+
release = RELEASE_VERSION
7075

7176
# The language for content autogenerated by Sphinx. Refer to documentation
7277
# for a list of supported languages.

Diff for: examples/custom_filter_example.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from typing import Any
1+
from typing import Any, Union
22

33
from pydantic.fields import Field, ModelField
44
from sqlalchemy.orm import InstrumentedAttribute
5+
from sqlalchemy.sql.elements import BinaryExpression, BooleanClauseList
56

67
from fastapi_jsonapi.schema_base import BaseModel
78

@@ -11,18 +12,17 @@ def jsonb_contains_sql_filter(
1112
model_column: InstrumentedAttribute,
1213
value: dict[Any, Any],
1314
operator: str,
14-
) -> tuple[Any, list[Any]]:
15+
) -> Union[BinaryExpression, BooleanClauseList]:
1516
"""
1617
Any SQLA (or Tortoise) magic here
1718
1819
:param schema_field:
1920
:param model_column:
2021
:param value: any dict
2122
:param operator: value 'jsonb_contains'
22-
:return: one sqla filter and list of joins
23+
:return: one sqla filter expression
2324
"""
24-
filter_sqla = model_column.op("@>")(value)
25-
return filter_sqla, []
25+
return model_column.op("@>")(value)
2626

2727

2828
class PictureSchema(BaseModel):

Diff for: fastapi_jsonapi/VERSION

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2.5.1

Diff for: fastapi_jsonapi/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""JSON API utils package."""
2+
from pathlib import Path
23

34
from fastapi import FastAPI
45

@@ -8,7 +9,7 @@
89
from fastapi_jsonapi.exceptions.json_api import HTTPException
910
from fastapi_jsonapi.querystring import QueryStringManager
1011

11-
__version__ = "2.5.0"
12+
__version__ = Path(__file__).parent.joinpath("VERSION").read_text().strip()
1213

1314
__all__ = [
1415
"init",

Diff for: fastapi_jsonapi/data_layers/filtering/sqlalchemy.py

+122-83
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Helper to create sqlalchemy filters according to filter querystring parameter"""
22
import inspect
33
import logging
4+
from collections.abc import Sequence
45
from typing import (
56
Any,
67
Callable,
@@ -16,7 +17,7 @@
1617
from pydantic import BaseConfig, BaseModel
1718
from pydantic.fields import ModelField
1819
from pydantic.validators import _VALIDATORS, find_validators
19-
from sqlalchemy import and_, not_, or_
20+
from sqlalchemy import and_, false, not_, or_
2021
from sqlalchemy.orm import aliased
2122
from sqlalchemy.orm.attributes import InstrumentedAttribute
2223
from sqlalchemy.orm.util import AliasedClass
@@ -396,11 +397,83 @@ def prepare_relationships_info(
396397
)
397398

398399

400+
def build_terminal_node_filter_expressions(
401+
filter_item: Dict,
402+
target_schema: Type[TypeSchema],
403+
target_model: Type[TypeModel],
404+
relationships_info: Dict[RelationshipPath, RelationshipFilteringInfo],
405+
):
406+
name: str = filter_item["name"]
407+
if is_relationship_filter(name):
408+
*relationship_path, field_name = name.split(RELATIONSHIP_SPLITTER)
409+
relationship_info: RelationshipFilteringInfo = relationships_info[
410+
RELATIONSHIP_SPLITTER.join(relationship_path)
411+
]
412+
model_column = get_model_column(
413+
model=relationship_info.aliased_model,
414+
schema=relationship_info.target_schema,
415+
field_name=field_name,
416+
)
417+
target_schema = relationship_info.target_schema
418+
else:
419+
field_name = name
420+
model_column = get_model_column(
421+
model=target_model,
422+
schema=target_schema,
423+
field_name=field_name,
424+
)
425+
426+
schema_field = target_schema.__fields__[field_name]
427+
428+
filter_operator = filter_item["op"]
429+
custom_filter_expression: Callable = get_custom_filter_expression_callable(
430+
schema_field=schema_field,
431+
operator=filter_operator,
432+
)
433+
if custom_filter_expression is None:
434+
return build_filter_expression(
435+
schema_field=schema_field,
436+
model_column=model_column,
437+
operator=get_operator(
438+
model_column=model_column,
439+
operator_name=filter_operator,
440+
),
441+
value=filter_item["val"],
442+
)
443+
444+
custom_call_result = custom_filter_expression(
445+
schema_field=schema_field,
446+
model_column=model_column,
447+
value=filter_item["val"],
448+
operator=filter_operator,
449+
)
450+
if isinstance(custom_call_result, Sequence):
451+
expected_len = 2
452+
if len(custom_call_result) != expected_len:
453+
log.error(
454+
"Invalid filter, returned sequence length is not %s: %s, len=%s",
455+
expected_len,
456+
custom_call_result,
457+
len(custom_call_result),
458+
)
459+
raise InvalidFilters(detail="Custom sql filter backend error.")
460+
log.warning(
461+
"Custom filter result of `[expr, [joins]]` is deprecated."
462+
" Please return only filter expression from now on. "
463+
"(triggered on schema field %s for filter operator %s on column %s)",
464+
schema_field,
465+
filter_operator,
466+
model_column,
467+
)
468+
custom_call_result = custom_call_result[0]
469+
return custom_call_result
470+
471+
399472
def build_filter_expressions(
400-
filter_item: Union[dict, list],
473+
filter_item: Dict,
401474
target_schema: Type[TypeSchema],
402475
target_model: Type[TypeModel],
403-
relationships_info: dict[RelationshipPath, RelationshipFilteringInfo],
476+
relationships_info: Dict[RelationshipPath, RelationshipFilteringInfo],
404477
) -> Union[BinaryExpression, BooleanClauseList]:
405478
"""
406479
Return sqla expressions.
@@ -409,93 +482,59 @@ def build_filter_expressions(
409482
in where condition: query(Model).where(build_filter_expressions(...))
410483
"""
411484
if is_terminal_node(filter_item):
412-
name = filter_item["name"]
485+
return build_terminal_node_filter_expressions(
486+
filter_item=filter_item,
487+
target_schema=target_schema,
488+
target_model=target_model,
489+
relationships_info=relationships_info,
490+
)
413491

414-
if is_relationship_filter(name):
415-
*relationship_path, field_name = name.split(RELATIONSHIP_SPLITTER)
416-
relationship_info: RelationshipFilteringInfo = relationships_info[
417-
RELATIONSHIP_SPLITTER.join(relationship_path)
418-
]
419-
model_column = get_model_column(
420-
model=relationship_info.aliased_model,
421-
schema=relationship_info.target_schema,
422-
field_name=field_name,
423-
)
424-
target_schema = relationship_info.target_schema
425-
else:
426-
field_name = name
427-
model_column = get_model_column(
428-
model=target_model,
429-
schema=target_schema,
430-
field_name=field_name,
431-
)
492+
if not isinstance(filter_item, dict):
493+
log.warning("Could not build filtering expressions %s", locals())
494+
# dirty. refactor.
495+
return not_(false())
432496

433-
schema_field = target_schema.__fields__[field_name]
497+
sqla_logic_operators = {
498+
"or": or_,
499+
"and": and_,
500+
"not": not_,
501+
}
434502

435-
custom_filter_expression = get_custom_filter_expression_callable(
436-
schema_field=schema_field,
437-
operator=filter_item["op"],
503+
if len(logic_operators := set(filter_item.keys())) > 1:
504+
msg = (
505+
f"In each logic node expected one of operators: {set(sqla_logic_operators.keys())} "
506+
f"but got {len(logic_operators)}: {logic_operators}"
438507
)
439-
if custom_filter_expression:
440-
return custom_filter_expression(
441-
schema_field=schema_field,
442-
model_column=model_column,
443-
value=filter_item["val"],
444-
operator=filter_item["op"],
445-
)
446-
else:
447-
return build_filter_expression(
448-
schema_field=schema_field,
449-
model_column=model_column,
450-
operator=get_operator(
451-
model_column=model_column,
452-
operator_name=filter_item["op"],
453-
),
454-
value=filter_item["val"],
455-
)
508+
raise InvalidFilters(msg)
456509

457-
if isinstance(filter_item, dict):
458-
sqla_logic_operators = {
459-
"or": or_,
460-
"and": and_,
461-
"not": not_,
462-
}
463-
464-
if len(logic_operators := set(filter_item.keys())) > 1:
465-
msg = (
466-
f"In each logic node expected one of operators: {set(sqla_logic_operators.keys())} "
467-
f"but got {len(logic_operators)}: {logic_operators}"
468-
)
469-
raise InvalidFilters(msg)
470-
471-
if (logic_operator := logic_operators.pop()) not in set(sqla_logic_operators.keys()):
472-
msg = f"Not found logic operator {logic_operator} expected one of {set(sqla_logic_operators.keys())}"
473-
raise InvalidFilters(msg)
474-
475-
op = sqla_logic_operators[logic_operator]
476-
477-
if logic_operator == "not":
478-
return op(
479-
build_filter_expressions(
480-
filter_item=filter_item[logic_operator],
481-
target_schema=target_schema,
482-
target_model=target_model,
483-
relationships_info=relationships_info,
484-
),
485-
)
510+
if (logic_operator := logic_operators.pop()) not in set(sqla_logic_operators.keys()):
511+
msg = f"Not found logic operator {logic_operator} expected one of {set(sqla_logic_operators.keys())}"
512+
raise InvalidFilters(msg)
486513

487-
expressions = []
488-
for filter_sub_item in filter_item[logic_operator]:
489-
expressions.append(
490-
build_filter_expressions(
491-
filter_item=filter_sub_item,
492-
target_schema=target_schema,
493-
target_model=target_model,
494-
relationships_info=relationships_info,
495-
),
496-
)
514+
op = sqla_logic_operators[logic_operator]
515+
516+
if logic_operator == "not":
517+
return op(
518+
build_filter_expressions(
519+
filter_item=filter_item[logic_operator],
520+
target_schema=target_schema,
521+
target_model=target_model,
522+
relationships_info=relationships_info,
523+
),
524+
)
525+
526+
expressions = []
527+
for filter_sub_item in filter_item[logic_operator]:
528+
expressions.append(
529+
build_filter_expressions(
530+
filter_item=filter_sub_item,
531+
target_schema=target_schema,
532+
target_model=target_model,
533+
relationships_info=relationships_info,
534+
),
535+
)
497536

498-
return op(*expressions)
537+
return op(*expressions)
499538

500539

501540
def create_filters_and_joins(

Diff for: pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ packages = [
7272

7373
[tool.poetry]
7474
name = "fastapi-jsonapi"
75-
version = "2.5.0"
75+
version = "2.5.1"
7676
description = "FastAPI extension to create REST web api according to JSON:API specification"
7777
authors = [
7878
"Aleksei Nekrasov <[email protected]>",

Diff for: tests/fixtures/app.py

+1
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,5 @@ def build_app_custom(
243243

244244
atomic = AtomicOperations()
245245
app.include_router(atomic.router, prefix="")
246+
init(app)
246247
return app

0 commit comments

Comments
 (0)