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
15 changes: 15 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
Changelog
#########

**2.5.1**
*********

Fix custom sql filtering, bring back backward compatibility
===========================================================

* Fix custom sql filtering support: bring back backward compatibility by `@mahenzon`_ in `#74 <https://github.com/mts-ai/FastAPI-JSONAPI/pull/74>`_
* Read version from file by `@mahenzon`_ in `#74 <https://github.com/mts-ai/FastAPI-JSONAPI/pull/74>`_

Authors
"""""""

* `@mahenzon`_


**2.5.0**
*********

Expand Down
9 changes: 7 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@
import os
import sys
from datetime import datetime
from pathlib import Path

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

BASE_DIR = Path(__file__).resolve().parent.parent
VERSION_FILEPATH = BASE_DIR / "fastapi_jsonapi" / "VERSION"
RELEASE_VERSION = VERSION_FILEPATH.read_text().strip()

# -- General configuration ------------------------------------------------

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

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
10 changes: 5 additions & 5 deletions examples/custom_filter_example.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from typing import Any
from typing import Any, Union

from pydantic.fields import Field, ModelField
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.sql.elements import BinaryExpression, BooleanClauseList

from fastapi_jsonapi.schema_base import BaseModel

Expand All @@ -11,18 +12,17 @@ def jsonb_contains_sql_filter(
model_column: InstrumentedAttribute,
value: dict[Any, Any],
operator: str,
) -> tuple[Any, list[Any]]:
) -> Union[BinaryExpression, BooleanClauseList]:
"""
Any SQLA (or Tortoise) magic here

:param schema_field:
:param model_column:
:param value: any dict
:param operator: value 'jsonb_contains'
:return: one sqla filter and list of joins
:return: one sqla filter expression
"""
filter_sqla = model_column.op("@>")(value)
return filter_sqla, []
return model_column.op("@>")(value)


class PictureSchema(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions fastapi_jsonapi/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.5.1
3 changes: 2 additions & 1 deletion fastapi_jsonapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""JSON API utils package."""
from pathlib import Path

from fastapi import FastAPI

Expand All @@ -8,7 +9,7 @@
from fastapi_jsonapi.exceptions.json_api import HTTPException
from fastapi_jsonapi.querystring import QueryStringManager

__version__ = "2.5.0"
__version__ = Path(__file__).parent.joinpath("VERSION").read_text().strip()

__all__ = [
"init",
Expand Down
205 changes: 122 additions & 83 deletions fastapi_jsonapi/data_layers/filtering/sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Helper to create sqlalchemy filters according to filter querystring parameter"""
import inspect
import logging
from collections.abc import Sequence
from typing import (
Any,
Callable,
Expand All @@ -16,7 +17,7 @@
from pydantic import BaseConfig, BaseModel
from pydantic.fields import ModelField
from pydantic.validators import _VALIDATORS, find_validators
from sqlalchemy import and_, not_, or_
from sqlalchemy import and_, false, not_, or_
from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.util import AliasedClass
Expand Down Expand Up @@ -396,11 +397,83 @@ def prepare_relationships_info(
)


def build_terminal_node_filter_expressions(
filter_item: Dict,
target_schema: Type[TypeSchema],
target_model: Type[TypeModel],
relationships_info: Dict[RelationshipPath, RelationshipFilteringInfo],
):
name: str = filter_item["name"]
if is_relationship_filter(name):
*relationship_path, field_name = name.split(RELATIONSHIP_SPLITTER)
relationship_info: RelationshipFilteringInfo = relationships_info[
RELATIONSHIP_SPLITTER.join(relationship_path)
]
model_column = get_model_column(
model=relationship_info.aliased_model,
schema=relationship_info.target_schema,
field_name=field_name,
)
target_schema = relationship_info.target_schema
else:
field_name = name
model_column = get_model_column(
model=target_model,
schema=target_schema,
field_name=field_name,
)

schema_field = target_schema.__fields__[field_name]

filter_operator = filter_item["op"]
custom_filter_expression: Callable = get_custom_filter_expression_callable(
schema_field=schema_field,
operator=filter_operator,
)
if custom_filter_expression is None:
return build_filter_expression(
schema_field=schema_field,
model_column=model_column,
operator=get_operator(
model_column=model_column,
operator_name=filter_operator,
),
value=filter_item["val"],
)

custom_call_result = custom_filter_expression(
schema_field=schema_field,
model_column=model_column,
value=filter_item["val"],
operator=filter_operator,
)
if isinstance(custom_call_result, Sequence):
expected_len = 2
if len(custom_call_result) != expected_len:
log.error(
"Invalid filter, returned sequence length is not %s: %s, len=%s",
expected_len,
custom_call_result,
len(custom_call_result),
)
raise InvalidFilters(detail="Custom sql filter backend error.")
log.warning(
"Custom filter result of `[expr, [joins]]` is deprecated."
" Please return only filter expression from now on. "
"(triggered on schema field %s for filter operator %s on column %s)",
schema_field,
filter_operator,
model_column,
)
custom_call_result = custom_call_result[0]
return custom_call_result


def build_filter_expressions(
filter_item: Union[dict, list],
filter_item: Dict,
target_schema: Type[TypeSchema],
target_model: Type[TypeModel],
relationships_info: dict[RelationshipPath, RelationshipFilteringInfo],
relationships_info: Dict[RelationshipPath, RelationshipFilteringInfo],
) -> Union[BinaryExpression, BooleanClauseList]:
"""
Return sqla expressions.
Expand All @@ -409,93 +482,59 @@ def build_filter_expressions(
in where condition: query(Model).where(build_filter_expressions(...))
"""
if is_terminal_node(filter_item):
name = filter_item["name"]
return build_terminal_node_filter_expressions(
filter_item=filter_item,
target_schema=target_schema,
target_model=target_model,
relationships_info=relationships_info,
)

if is_relationship_filter(name):
*relationship_path, field_name = name.split(RELATIONSHIP_SPLITTER)
relationship_info: RelationshipFilteringInfo = relationships_info[
RELATIONSHIP_SPLITTER.join(relationship_path)
]
model_column = get_model_column(
model=relationship_info.aliased_model,
schema=relationship_info.target_schema,
field_name=field_name,
)
target_schema = relationship_info.target_schema
else:
field_name = name
model_column = get_model_column(
model=target_model,
schema=target_schema,
field_name=field_name,
)
if not isinstance(filter_item, dict):
log.warning("Could not build filtering expressions %s", locals())
# dirty. refactor.
return not_(false())

schema_field = target_schema.__fields__[field_name]
sqla_logic_operators = {
"or": or_,
"and": and_,
"not": not_,
}

custom_filter_expression = get_custom_filter_expression_callable(
schema_field=schema_field,
operator=filter_item["op"],
if len(logic_operators := set(filter_item.keys())) > 1:
msg = (
f"In each logic node expected one of operators: {set(sqla_logic_operators.keys())} "
f"but got {len(logic_operators)}: {logic_operators}"
)
if custom_filter_expression:
return custom_filter_expression(
schema_field=schema_field,
model_column=model_column,
value=filter_item["val"],
operator=filter_item["op"],
)
else:
return build_filter_expression(
schema_field=schema_field,
model_column=model_column,
operator=get_operator(
model_column=model_column,
operator_name=filter_item["op"],
),
value=filter_item["val"],
)
raise InvalidFilters(msg)

if isinstance(filter_item, dict):
sqla_logic_operators = {
"or": or_,
"and": and_,
"not": not_,
}

if len(logic_operators := set(filter_item.keys())) > 1:
msg = (
f"In each logic node expected one of operators: {set(sqla_logic_operators.keys())} "
f"but got {len(logic_operators)}: {logic_operators}"
)
raise InvalidFilters(msg)

if (logic_operator := logic_operators.pop()) not in set(sqla_logic_operators.keys()):
msg = f"Not found logic operator {logic_operator} expected one of {set(sqla_logic_operators.keys())}"
raise InvalidFilters(msg)

op = sqla_logic_operators[logic_operator]

if logic_operator == "not":
return op(
build_filter_expressions(
filter_item=filter_item[logic_operator],
target_schema=target_schema,
target_model=target_model,
relationships_info=relationships_info,
),
)
if (logic_operator := logic_operators.pop()) not in set(sqla_logic_operators.keys()):
msg = f"Not found logic operator {logic_operator} expected one of {set(sqla_logic_operators.keys())}"
raise InvalidFilters(msg)

expressions = []
for filter_sub_item in filter_item[logic_operator]:
expressions.append(
build_filter_expressions(
filter_item=filter_sub_item,
target_schema=target_schema,
target_model=target_model,
relationships_info=relationships_info,
),
)
op = sqla_logic_operators[logic_operator]

if logic_operator == "not":
return op(
build_filter_expressions(
filter_item=filter_item[logic_operator],
target_schema=target_schema,
target_model=target_model,
relationships_info=relationships_info,
),
)

expressions = []
for filter_sub_item in filter_item[logic_operator]:
expressions.append(
build_filter_expressions(
filter_item=filter_sub_item,
target_schema=target_schema,
target_model=target_model,
relationships_info=relationships_info,
),
)

return op(*expressions)
return op(*expressions)


def create_filters_and_joins(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ packages = [

[tool.poetry]
name = "fastapi-jsonapi"
version = "2.5.0"
version = "2.5.1"
description = "FastAPI extension to create REST web api according to JSON:API specification"
authors = [
"Aleksei Nekrasov <[email protected]>",
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,5 @@ def build_app_custom(

atomic = AtomicOperations()
app.include_router(atomic.router, prefix="")
init(app)
return app
Loading