Skip to content

Commit 27afe3e

Browse files
NatalyaGrigorevaNatalia Grigoreva
authored andcommitted
json filtering
1 parent 0af1f2b commit 27afe3e

File tree

9 files changed

+574
-86
lines changed

9 files changed

+574
-86
lines changed

fastapi_jsonapi/data_layers/sqla/orm.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,7 @@ async def apply_relationships(
175175
:param action_trigger: indicates which one operation triggered relationships applying
176176
:return:
177177
"""
178-
to_one: dict = {}
179-
to_many: dict = {}
178+
to_one, to_many = {}, {}
180179
relationships: BaseModel = data_create.relationships
181180
if relationships is None:
182181
return to_one, to_many

fastapi_jsonapi/types_metadata/custom_filter_sql.py

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
import contextlib
2+
import logging
13
from dataclasses import dataclass
2-
from typing import Generic, TypeVar, Union, cast
4+
from typing import Any, Generic, TypeVar, Union, cast
5+
6+
import orjson as json
37

48
# noinspection PyProtectedMember
59
from pydantic.fields import FieldInfo
610
from sqlalchemy import func
11+
from sqlalchemy.dialects.postgresql import JSONB as JSONB_SQLA
712
from sqlalchemy.orm import InstrumentedAttribute
813
from sqlalchemy.sql.expression import BinaryExpression, BooleanClauseList
914

15+
from fastapi_jsonapi.exceptions import InvalidFilters
16+
17+
log = logging.getLogger(__name__)
18+
1019
ColumnType = TypeVar("ColumnType")
1120
ExpressionType = TypeVar("ExpressionType")
1221

@@ -29,6 +38,68 @@ class CustomFilterSQLA(CustomFilterSQL[InstrumentedAttribute, Union[BinaryExpres
2938
"""Base class for custom SQLAlchemy filters"""
3039

3140

41+
def _get_pg_jsonb_contains_expression(
42+
model_column: InstrumentedAttribute,
43+
value: Any,
44+
) -> BinaryExpression:
45+
with contextlib.suppress(ValueError):
46+
value = json.loads(value)
47+
48+
return model_column.cast(JSONB_SQLA).op("@>")(value)
49+
50+
51+
def _get_sqlite_json_contains_expression(
52+
model_column: InstrumentedAttribute,
53+
value: Any,
54+
) -> BinaryExpression:
55+
with contextlib.suppress(ValueError):
56+
value = json.loads(value)
57+
58+
return model_column.ilike(value)
59+
60+
61+
def _get_pg_jsonb_ilike_expression(
62+
model_column: InstrumentedAttribute,
63+
value: list,
64+
operator: str,
65+
) -> BinaryExpression:
66+
try:
67+
target_field, regex = value
68+
except ValueError:
69+
msg = f'The "value" field has to be list of two values for op `{operator}`'
70+
raise InvalidFilters(msg)
71+
72+
if isinstance(regex, (list, dict)):
73+
return model_column[target_field].cast(JSONB_SQLA).op("@>")(regex)
74+
elif isinstance(regex, bool):
75+
regex = f"{regex}".lower()
76+
else:
77+
regex = f"{regex}"
78+
79+
return model_column.op("->>")(target_field).ilike(regex)
80+
81+
82+
def _get_sqlite_json_ilike_expression(
83+
model_column: InstrumentedAttribute,
84+
value: list,
85+
operator: str,
86+
) -> BinaryExpression:
87+
try:
88+
target_field, regex = value
89+
except ValueError:
90+
msg = f'The "value" field has to be list of two values for op `{operator}`'
91+
raise InvalidFilters(msg)
92+
93+
if isinstance(regex, (list, dict)):
94+
regex = json.dumps(regex).decode()
95+
elif isinstance(regex, bool):
96+
return model_column.op("->>")(target_field).is_(regex)
97+
else:
98+
regex = f"{regex}"
99+
100+
return model_column.op("->>")(target_field).ilike(regex)
101+
102+
32103
class LowerEqualsFilterSQL(CustomFilterSQLA):
33104
def get_expression(
34105
self,
@@ -43,17 +114,76 @@ def get_expression(
43114
)
44115

45116

46-
# TODO: tests coverage
47-
class JSONBContainsFilterSQL(CustomFilterSQLA):
117+
class PGJSONContainsFilterSQL(CustomFilterSQLA):
48118
def get_expression(
49119
self,
50120
schema_field: FieldInfo,
51121
model_column: InstrumentedAttribute,
52-
value: str,
122+
value: Any,
123+
operator: str,
124+
) -> BinaryExpression:
125+
return _get_pg_jsonb_contains_expression(model_column, value)
126+
127+
128+
class PGJSONBContainsFilterSQL(CustomFilterSQLA):
129+
def get_expression(
130+
self,
131+
schema_field: FieldInfo,
132+
model_column: InstrumentedAttribute,
133+
value: Any,
134+
operator: str,
135+
) -> BinaryExpression:
136+
return _get_pg_jsonb_contains_expression(model_column, value)
137+
138+
139+
class PGJSONIlikeFilterSQL(CustomFilterSQLA):
140+
def get_expression(
141+
self,
142+
schema_field: FieldInfo,
143+
model_column: InstrumentedAttribute,
144+
value: list[str],
145+
operator: str,
146+
) -> BinaryExpression:
147+
return _get_pg_jsonb_ilike_expression(model_column, value, operator)
148+
149+
150+
class PGJSONBIlikeFilterSQL(CustomFilterSQLA):
151+
def get_expression(
152+
self,
153+
schema_field: FieldInfo,
154+
model_column: InstrumentedAttribute,
155+
value: list[str],
156+
operator: str,
157+
) -> BinaryExpression:
158+
return _get_pg_jsonb_ilike_expression(model_column, value, operator)
159+
160+
161+
class SQLiteJSONContainsFilterSQL(CustomFilterSQLA):
162+
def get_expression(
163+
self,
164+
schema_field: FieldInfo,
165+
model_column: InstrumentedAttribute,
166+
value: Any,
167+
operator: str,
168+
) -> BinaryExpression:
169+
return _get_sqlite_json_contains_expression(model_column, value)
170+
171+
172+
class SQLiteJSONIlikeFilterSQL(CustomFilterSQLA):
173+
def get_expression(
174+
self,
175+
schema_field: FieldInfo,
176+
model_column: InstrumentedAttribute,
177+
value: list[str],
53178
operator: str,
54179
) -> BinaryExpression:
55-
return model_column.op("@>")(value)
180+
return _get_sqlite_json_ilike_expression(model_column, value, operator)
56181

57182

58183
sql_filter_lower_equals = LowerEqualsFilterSQL(op="lower_equals")
59-
sql_filter_jsonb_contains = JSONBContainsFilterSQL(op="jsonb_contains")
184+
sql_filter_pg_json_contains = PGJSONContainsFilterSQL(op="pg_json_contains")
185+
sql_filter_pg_jsonb_contains = PGJSONBContainsFilterSQL(op="pg_jsonb_contains")
186+
sql_filter_pg_json_ilike = PGJSONIlikeFilterSQL(op="pg_json_ilike")
187+
sql_filter_pg_jsonb_ilike = PGJSONBIlikeFilterSQL(op="pg_jsonb_ilike")
188+
sql_filter_sqlite_json_contains = SQLiteJSONContainsFilterSQL(op="sqlite_json_contains")
189+
sql_filter_sqlite_json_ilike = SQLiteJSONIlikeFilterSQL(op="sqlite_json_ilike")

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
parent_1,
3636
parent_2,
3737
parent_3,
38+
task_1,
39+
task_2,
3840
user_1,
3941
user_1_bio,
4042
user_1_comments_for_u2_posts,

tests/fixtures/entities.py

Lines changed: 75 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
UserBio,
1616
Workplace,
1717
)
18+
from tests.common import is_postgres_tests
19+
from tests.fixtures.models import Task
1820
from tests.misc.utils import fake
1921

2022

@@ -47,11 +49,7 @@ async def create_user(async_session: AsyncSession, **fields) -> User:
4749

4850

4951
async def create_user_bio(async_session: AsyncSession, user: User, **fields) -> UserBio:
50-
fields = {
51-
"user": user,
52-
**fields,
53-
}
54-
user_bio = build_user_bio(**fields)
52+
user_bio = build_user_bio(user=user, **fields)
5553
async_session.add(user_bio)
5654
await async_session.commit()
5755
return user_bio
@@ -120,16 +118,17 @@ async def user_2_bio(async_session: AsyncSession, user_2: User) -> UserBio:
120118
)
121119

122120

123-
async def build_post(async_session: AsyncSession, user: User, **fields) -> Post:
121+
def build_post(user: User, **fields) -> Post:
124122
fields = {
125123
"title": fake.name(),
126124
"body": fake.sentence(),
127125
**fields,
128126
}
129-
post = Post(
130-
user=user,
131-
**fields,
132-
)
127+
return Post(user=user, **fields)
128+
129+
130+
async def create_post(async_session: AsyncSession, user: User, **fields) -> Post:
131+
post = build_post(user, **fields)
133132
async_session.add(post)
134133
await async_session.commit()
135134
return post
@@ -244,21 +243,25 @@ async def factory(name: Optional[str] = None) -> Computer:
244243
return factory
245244

246245

247-
async def build_post_comment(
248-
async_session: AsyncSession,
249-
user: User,
250-
post: Post,
251-
**fields,
252-
) -> PostComment:
246+
def build_post_comment(user: User, post: Post, **fields) -> PostComment:
253247
fields = {
254248
"text": fake.sentence(),
255249
**fields,
256250
}
257-
post_comment = PostComment(
251+
return PostComment(
258252
user=user,
259253
post=post,
260254
**fields,
261255
)
256+
257+
258+
async def create_post_comment(
259+
async_session: AsyncSession,
260+
user: User,
261+
post: Post,
262+
**fields,
263+
) -> PostComment:
264+
post_comment = build_post_comment(user=user, post=post, **fields)
262265
async_session.add(post_comment)
263266
await async_session.commit()
264267
return post_comment
@@ -478,8 +481,59 @@ async def p2_c3_association(
478481
await async_session.commit()
479482

480483

481-
async def build_workplace(async_session: AsyncSession, **fields):
482-
workplace = Workplace(**fields)
484+
def build_task(**fields):
485+
return Task(**fields)
486+
487+
488+
async def create_task(async_session: AsyncSession, **fields):
489+
task = build_task(**fields)
490+
async_session.add(task)
491+
await async_session.commit()
492+
return task
493+
494+
495+
@async_fixture()
496+
async def task_1(
497+
async_session: AsyncSession,
498+
):
499+
fields = {
500+
"task_ids_list_json": [1, 2, 3],
501+
"task_ids_dict_json": {"completed": [1, 2, 3], "count": 1, "is_complete": True},
502+
}
503+
if is_postgres_tests():
504+
fields.update(
505+
{
506+
"task_ids_list_jsonb": ["a", "b", "c"],
507+
"task_ids_dict_jsonb": {"completed": ["a", "b", "c"], "count": 2, "is_complete": True},
508+
},
509+
)
510+
yield await create_task(async_session, **fields)
511+
512+
513+
@async_fixture()
514+
async def task_2(
515+
async_session: AsyncSession,
516+
):
517+
fields = {
518+
"task_ids_list_json": [4, 5, 6],
519+
"task_ids_dict_json": {"completed": [4, 5, 6], "count": 3, "is_complete": False},
520+
}
521+
if is_postgres_tests():
522+
fields.update(
523+
{
524+
"task_ids_list_jsonb": ["d", "e", "f"],
525+
"task_ids_dict_jsonb": {"completed": ["d", "e", "f"], "count": 4, "is_complete": False},
526+
},
527+
)
528+
yield await create_task(async_session, **fields)
529+
530+
531+
def build_workplace(**fields):
532+
return Workplace(**fields)
533+
534+
535+
async def create_workplace(async_session: AsyncSession, **fields):
536+
workplace = build_workplace(**fields)
483537
async_session.add(workplace)
484538
await async_session.commit()
485539
return workplace
@@ -489,11 +543,11 @@ async def build_workplace(async_session: AsyncSession, **fields):
489543
async def workplace_1(
490544
async_session: AsyncSession,
491545
):
492-
yield await build_workplace(async_session, name="workplace_1")
546+
yield await create_workplace(async_session, name="workplace_1")
493547

494548

495549
@async_fixture()
496550
async def workplace_2(
497551
async_session: AsyncSession,
498552
):
499-
yield await build_workplace(async_session, name="workplace_2")
553+
yield await create_workplace(async_session, name="workplace_2")

tests/fixtures/models/task.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
from typing import Optional
22

33
from sqlalchemy import JSON
4+
from sqlalchemy.dialects.postgresql import JSONB
45
from sqlalchemy.orm import Mapped, mapped_column
56

67
from examples.api_for_sqlalchemy.models.base import Base
8+
from tests.common import is_postgres_tests
79

810

911
class Task(Base):
1012
__tablename__ = "tasks"
1113

12-
task_ids_dict: Mapped[Optional[dict]] = mapped_column(JSON, unique=False)
13-
task_ids_list: Mapped[Optional[list]] = mapped_column(JSON, unique=False)
14+
task_ids_dict_json: Mapped[Optional[dict]] = mapped_column(JSON, unique=False)
15+
task_ids_list_json: Mapped[Optional[list]] = mapped_column(JSON, unique=False)
16+
17+
if is_postgres_tests():
18+
task_ids_dict_jsonb: Mapped[Optional[dict]] = mapped_column(JSONB, unique=False)
19+
task_ids_list_jsonb: Mapped[Optional[list]] = mapped_column(JSONB, unique=False)

0 commit comments

Comments
 (0)