Skip to content

Commit 65f5f3b

Browse files
authored
Hotfix/many to many updates (#22)
* WIP: Fix many-to-many relationships updates * WIP: Clean up the SQL CRUD methods * WIP: Enable nested updates in SQL * WIP: Clean up the SQL update method * Fix 'AttributeError: 'int' object has no attribute 'id'' with SQL update * Fix failing tests * Fix dependency errors in python 3.13 * Fix failing CI tests for examples * Fix 'TypeError: field 'TodoList.description' was not initialized with a Field() or Relationship()' * Add tests for blog example in github CI
1 parent ce1aa00 commit 65f5f3b

15 files changed

Lines changed: 749 additions & 350 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ jobs:
6969
strategy:
7070
matrix:
7171
python-version: [ "3.10", "3.11", "3.12", "3.13" ]
72-
example_app: ["todos"]
72+
example_app: ["todos", "blog"]
7373

7474
services:
7575
mongodb:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ venv/
135135
ENV/
136136
env.bak/
137137
venv.bak/
138+
.venv3_13
138139

139140
# Spyder project settings
140141
.spyderproject

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ By contributing, you agree that your contributions will be licensed under its MI
7777
- Install the dependencies
7878

7979
```bash
80-
pip install -r requirements.txt
80+
pip install -r ."[all,test]"
8181
```
8282

8383
- Run the pre-commit installation

LIMITATIONS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Limitations
2+
3+
## Filtering
4+
5+
### Redis
6+
7+
- Mongo-style regular expression filtering is not supported.
8+
This is because native redis regular expression filtering is limited to the most basic text based search.
9+
10+
## Update Operation
11+
12+
### SQL
13+
14+
- Even though one can update a model to theoretically infinite number of levels deep,
15+
the returned results can only contain 1-level-deep nested models and no more.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,11 @@ libraries = await redis_store.delete(
336336

337337
- [ ] Add documentation site
338338

339+
## Limitations
340+
341+
This library is limited in some specific cases.
342+
Read through the [`LIMITATIONS.md`](./LIMITATIONS.md) file for more.
343+
339344
## Contributions
340345

341346
Contributions are welcome. The docs have to maintained, the code has to be made cleaner, more idiomatic and faster,

examples/blog/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest_asyncio
88
import pytest_mock
99
from fastapi.testclient import TestClient
10-
from models import ( # SqlAuthor,
10+
from models import (
1111
MongoAuthor,
1212
MongoComment,
1313
MongoInternalAuthor,

examples/blog/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from fastapi.security import OAuth2PasswordRequestForm
1414
from models import MongoPost, RedisPost, SqlInternalAuthor, SqlPost
1515
from pydantic import BaseModel
16-
from schemas import InternalAuthor, Post, TokenResponse
16+
from schemas import InternalAuthor, PartialPost, Post, TokenResponse
1717
from stores import MongoStoreDep, RedisStoreDep, SqlStoreDep, clear_stores
1818

1919
_ACCESS_TOKEN_EXPIRE_MINUTES = 30
@@ -112,8 +112,13 @@ async def search(
112112

113113
if redis:
114114
# redis's regex search is not mature so we use its full text search
115+
# Unfortunately, redis search does not permit us to search fields that are arrays.
115116
redis_query = [
116-
(_get_redis_field(RedisPost, k) % f"*{v}*")
117+
(
118+
(_get_redis_field(RedisPost, k) == f"{v}")
119+
if k == "tags.title"
120+
else (_get_redis_field(RedisPost, k) % f"*{v}*")
121+
)
117122
for k, v in query_dict.items()
118123
]
119124
results += await redis.find(RedisPost, *redis_query)
@@ -194,7 +199,7 @@ async def update_one(
194199
mongo: MongoStoreDep,
195200
current_user: CurrentUserDep,
196201
id_: int | str,
197-
payload: Post,
202+
payload: PartialPost,
198203
):
199204
"""Update a post"""
200205
results = []

examples/blog/models.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"MongoPost",
2222
Post,
2323
embedded_models={
24-
"author": MongoAuthor,
24+
"author": MongoAuthor | None,
2525
"comments": list[MongoComment],
2626
"tags": list[MongoTag],
2727
},
@@ -39,15 +39,14 @@
3939
"RedisPost",
4040
Post,
4141
embedded_models={
42-
"author": RedisAuthor,
42+
"author": RedisAuthor | None,
4343
"comments": list[RedisComment],
4444
"tags": list[RedisTag],
4545
},
4646
)
4747

4848
# sqlite models
4949
SqlInternalAuthor = SQLModel("SqlInternalAuthor", InternalAuthor)
50-
# SqlAuthor = SQLModel("SqlAuthor", Author, table=False)
5150
SqlComment = SQLModel(
5251
"SqlComment", Comment, relationships={"author": SqlInternalAuthor | None}
5352
)

examples/blog/schemas.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime
44

55
from pydantic import BaseModel
6-
from utils import current_timestamp
6+
from utils import Partial, current_timestamp
77

88
from nqlstore import Field, Relationship
99

@@ -33,15 +33,8 @@ class Post(BaseModel):
3333
disable_on_redis=True,
3434
)
3535
author: Author | None = Relationship(default=None)
36-
comments: list["Comment"] = Relationship(
37-
default=[],
38-
disable_on_redis=True,
39-
)
40-
tags: list["Tag"] = Relationship(
41-
default=[],
42-
link_model="TagLink",
43-
disable_on_redis=True,
44-
)
36+
comments: list["Comment"] = Relationship(default=[])
37+
tags: list["Tag"] = Relationship(default=[], link_model="TagLink")
4538
created_at: str = Field(index=True, default_factory=current_timestamp)
4639
updated_at: str = Field(index=True, default_factory=current_timestamp)
4740

@@ -89,11 +82,15 @@ class TagLink(BaseModel):
8982
class Tag(BaseModel):
9083
"""The tags to help searching for posts"""
9184

92-
title: str = Field(index=True, unique=True, full_text_search=True)
85+
title: str = Field(index=True, unique=True)
9386

9487

9588
class TokenResponse(BaseModel):
9689
"""HTTP-only response"""
9790

9891
access_token: str
9992
token_type: str
93+
94+
95+
# Partial models
96+
PartialPost = Partial("PartialPost", Post)

examples/blog/test_main.py

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
_TITLE_SEARCH_TERMS = ["ho", "oo", "work"]
1313
_TAG_SEARCH_TERMS = ["art", "om"]
14+
_HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
1415

1516

1617
@pytest.mark.asyncio
@@ -21,9 +22,7 @@ async def test_create_sql_post(
2122
"""POST to /posts creates a post in sql and returns it"""
2223
timestamp = datetime.now().isoformat()
2324
with client_with_sql as client:
24-
response = client.post(
25-
"/posts", json=post, headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}
26-
)
25+
response = client.post("/posts", json=post, headers=_HEADERS)
2726

2827
got = response.json()
2928
post_id = got["id"]
@@ -61,12 +60,12 @@ async def test_create_redis_post(
6160
client_with_redis: TestClient,
6261
redis_store: RedisStore,
6362
post: dict,
63+
freezer,
6464
):
6565
"""POST to /posts creates a post in redis and returns it"""
66+
timestamp = datetime.now().isoformat()
6667
with client_with_redis as client:
67-
response = client.post(
68-
"/posts", json=post, headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}
69-
)
68+
response = client.post("/posts", json=post, headers=_HEADERS)
7069

7170
got = response.json()
7271
post_id = got["id"]
@@ -75,7 +74,8 @@ async def test_create_redis_post(
7574
expected = {
7675
"id": post_id,
7776
"title": post["title"],
78-
"content": post.get("content"),
77+
"content": post.get("content", ""),
78+
"author": {**got["author"], **AUTHOR},
7979
"pk": post_id,
8080
"tags": [
8181
{
@@ -86,6 +86,8 @@ async def test_create_redis_post(
8686
for raw, resp in zip(raw_tags, resp_tags)
8787
],
8888
"comments": [],
89+
"created_at": timestamp,
90+
"updated_at": timestamp,
8991
}
9092

9193
db_query = {"id": {"$eq": post_id}}
@@ -102,25 +104,25 @@ async def test_create_mongo_post(
102104
client_with_mongo: TestClient,
103105
mongo_store: MongoStore,
104106
post: dict,
107+
freezer,
105108
):
106109
"""POST to /posts creates a post in redis and returns it"""
110+
timestamp = datetime.now().isoformat()
107111
with client_with_mongo as client:
108-
response = client.post("/posts", json=post)
112+
response = client.post("/posts", json=post, headers=_HEADERS)
109113

110114
got = response.json()
111115
post_id = got["id"]
112116
raw_tags = post.get("tags", [])
113117
expected = {
114118
"id": post_id,
115119
"title": post["title"],
116-
"content": post.get("content"),
117-
"tags": [
118-
{
119-
**raw,
120-
}
121-
for raw in raw_tags
122-
],
120+
"content": post.get("content", ""),
121+
"author": {"name": AUTHOR["name"]},
122+
"tags": raw_tags,
123123
"comments": [],
124+
"created_at": timestamp,
125+
"updated_at": timestamp,
124126
}
125127

126128
db_query = {"_id": {"$eq": ObjectId(post_id)}}
@@ -138,22 +140,26 @@ async def test_update_sql_post(
138140
sql_store: SQLStore,
139141
sql_posts: list[SqlPost],
140142
index: int,
143+
freezer,
141144
):
142145
"""PUT to /posts/{id} updates the sql post of given id and returns updated version"""
146+
timestamp = datetime.now().isoformat()
143147
with client_with_sql as client:
144148
post = sql_posts[index]
149+
post_dict = post.model_dump(mode="json", exclude_none=True, exclude_unset=True)
145150
id_ = post.id
146151
update = {
147-
"name": "some other name",
148-
"todos": [
149-
*post.tags,
152+
**post_dict,
153+
"title": "some other title",
154+
"tags": [
155+
*post_dict["tags"],
150156
{"title": "another one"},
151157
{"title": "another one again"},
152158
],
153-
"comments": [*post.comments, *COMMENT_LIST[index:]],
159+
"comments": [*post_dict["comments"], *COMMENT_LIST[index:]],
154160
}
155161

156-
response = client.put(f"/posts/{id_}", json=update)
162+
response = client.put(f"/posts/{id_}", json=update, headers=_HEADERS)
157163

158164
got = response.json()
159165
expected = {
@@ -164,8 +170,9 @@ async def test_update_sql_post(
164170
**raw,
165171
"id": final["id"],
166172
"post_id": final["post_id"],
167-
"author": final["author"],
168-
"author_id": final["author_id"],
173+
"author_id": 1,
174+
"created_at": timestamp,
175+
"updated_at": timestamp,
169176
}
170177
for raw, final in zip(update["comments"], got["comments"])
171178
],
@@ -192,22 +199,25 @@ async def test_update_redis_post(
192199
redis_store: RedisStore,
193200
redis_posts: list[RedisPost],
194201
index: int,
202+
freezer,
195203
):
196204
"""PUT to /posts/{id} updates the redis post of given id and returns updated version"""
205+
timestamp = datetime.now().isoformat()
197206
with client_with_redis as client:
198207
post = redis_posts[index]
208+
post_dict = post.model_dump(mode="json", exclude_none=True, exclude_unset=True)
199209
id_ = post.id
200210
update = {
201-
"name": "some other name",
202-
"todos": [
203-
*post.tags,
211+
"title": "some other title",
212+
"tags": [
213+
*post_dict.get("tags", []),
204214
{"title": "another one"},
205215
{"title": "another one again"},
206216
],
207-
"comments": [*post.comments, *COMMENT_LIST[index:]],
217+
"comments": [*post_dict.get("comments", []), *COMMENT_LIST[index:]],
208218
}
209219

210-
response = client.put(f"/posts/{id_}", json=update)
220+
response = client.put(f"/posts/{id_}", json=update, headers=_HEADERS)
211221

212222
got = response.json()
213223
expected = {
@@ -219,6 +229,8 @@ async def test_update_redis_post(
219229
"id": final["id"],
220230
"author": final["author"],
221231
"pk": final["pk"],
232+
"created_at": timestamp,
233+
"updated_at": timestamp,
222234
}
223235
for raw, final in zip(update["comments"], got["comments"])
224236
],
@@ -265,22 +277,25 @@ async def test_update_mongo_post(
265277
mongo_store: MongoStore,
266278
mongo_posts: list[MongoPost],
267279
index: int,
280+
freezer,
268281
):
269282
"""PUT to /posts/{id} updates the mongo post of given id and returns updated version"""
283+
timestamp = datetime.now().isoformat()
270284
with client_with_mongo as client:
271285
post = mongo_posts[index]
286+
post_dict = post.model_dump(mode="json", exclude_none=True, exclude_unset=True)
272287
id_ = post.id
273288
update = {
274-
"name": "some other name",
275-
"todos": [
276-
*post.tags,
289+
"title": "some other title",
290+
"tags": [
291+
*post_dict.get("tags", []),
277292
{"title": "another one"},
278293
{"title": "another one again"},
279294
],
280-
"comments": [*post.comments, *COMMENT_LIST[index:]],
295+
"comments": [*post_dict.get("comments", []), *COMMENT_LIST[index:]],
281296
}
282297

283-
response = client.put(f"/posts/{id_}", json=update)
298+
response = client.put(f"/posts/{id_}", json=update, headers=_HEADERS)
284299

285300
got = response.json()
286301
expected = {
@@ -290,6 +305,8 @@ async def test_update_mongo_post(
290305
{
291306
**raw,
292307
"author": final["author"],
308+
"created_at": timestamp,
309+
"updated_at": timestamp,
293310
}
294311
for raw, final in zip(update["comments"], got["comments"])
295312
],
@@ -538,14 +555,14 @@ async def test_search_sql_by_tag(
538555

539556

540557
@pytest.mark.asyncio
541-
@pytest.mark.parametrize("q", _TAG_SEARCH_TERMS)
558+
@pytest.mark.parametrize("q", ["random", "another one", "another one again"])
542559
async def test_search_redis_by_tag(
543560
client_with_redis: TestClient,
544561
redis_store: RedisStore,
545562
redis_posts: list[RedisPost],
546563
q: str,
547564
):
548-
"""GET /posts?tag={} gets all redis posts with tag containing search item"""
565+
"""GET /posts?tag={} gets all redis posts with tag containing search item. Partial searches nit supported."""
549566
with client_with_redis as client:
550567
response = client.get(f"/posts?tag={q}")
551568

0 commit comments

Comments
 (0)