Skip to content

Commit

Permalink
Merge pull request #74 from mts-ai/feature/Fix-custom-sql-filtering-s…
Browse files Browse the repository at this point in the history
…upport

Fix custom sql filtering support: bring back backward compatibility
  • Loading branch information
mahenzon authored Jan 19, 2024
2 parents 0d716a4 + c918b5f commit 0c5b1e7
Show file tree
Hide file tree
Showing 10 changed files with 345 additions and 113 deletions.
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

0 comments on commit 0c5b1e7

Please sign in to comment.