Skip to content

Commit 52d1896

Browse files
authored
Merge pull request #217 from doubledare704/feature/sort-nested-relationships
implementing sort for nested fields
2 parents 4ed7bee + 3c2aea6 commit 52d1896

File tree

5 files changed

+274
-26
lines changed

5 files changed

+274
-26
lines changed

docs/advanced/joins.md

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ FastCRUD simplifies CRUD operations while offering capabilities for handling com
1414
- **`alias`**: An optional SQLAlchemy `AliasedClass` for complex scenarios like self-referential joins or multiple joins on the same model.
1515
- **`filters`**: An optional dictionary to apply filters directly to the joined model.
1616
- **`relationship_type`**: Specifies the relationship type, such as `"one-to-one"` or `"one-to-many"`. Default is `"one-to-one"`.
17+
- **`sort_columns`**: An optional column name or list of column names to sort the nested items by. Only applies to `"one-to-many"` relationships.
18+
- **`sort_orders`**: An optional sort order (`"asc"` or `"desc"`) or list of sort orders corresponding to the columns in `sort_columns`. If not provided, defaults to `"asc"` for each column.
1719

1820
!!! TIP
1921

@@ -86,12 +88,12 @@ task_count = await task_crud.count(
8688
db=db,
8789
joins_config=[
8890
JoinConfig(
89-
model=User,
91+
model=User,
9092
join_on=Task.assigned_user_id == User.id,
9193
),
9294
JoinConfig(
93-
model=Department,
94-
join_on=User.department_id == Department.id,
95+
model=Department,
96+
join_on=User.department_id == Department.id,
9597
filters={"name": "Engineering"},
9698
),
9799
],
@@ -213,19 +215,19 @@ users = await user_crud.get_multi_joined(
213215
schema_to_select=ReadUserSchema,
214216
joins_config=[
215217
JoinConfig(
216-
model=Department,
217-
join_on=User.department_id == Department.id,
218+
model=Department,
219+
join_on=User.department_id == Department.id,
218220
join_prefix="dept_",
219221
),
220222
JoinConfig(
221-
model=Tier,
222-
join_on=User.tier_id == Tier.id,
223+
model=Tier,
224+
join_on=User.tier_id == Tier.id,
223225
join_prefix="tier_",
224226
),
225227
JoinConfig(
226-
model=User,
227-
alias=manager_alias,
228-
join_on=User.manager_id == manager_alias.id,
228+
model=User,
229+
alias=manager_alias,
230+
join_on=User.manager_id == manager_alias.id,
229231
join_prefix="manager_",
230232
),
231233
],
@@ -355,13 +357,59 @@ The result will be:
355357
}
356358
```
357359

360+
##### Sorting Nested Items in One-to-Many Relationships
361+
362+
FastCRUD allows you to sort nested items in one-to-many relationships using the `sort_columns` and `sort_orders` parameters in the `JoinConfig`. This is particularly useful when you want to display nested items in a specific order.
363+
364+
```python
365+
from fastcrud import FastCRUD, JoinConfig
366+
367+
author_crud = FastCRUD(Author)
368+
369+
# Define join configuration with sorting
370+
joins_config = [
371+
JoinConfig(
372+
model=Article,
373+
join_on=Author.id == Article.author_id,
374+
join_prefix="articles_",
375+
relationship_type="one-to-many",
376+
sort_columns="title", # Sort articles by title
377+
sort_orders="asc" # In ascending order
378+
)
379+
]
380+
381+
# Fetch authors with their articles sorted by title
382+
result = await author_crud.get_multi_joined(
383+
db=db,
384+
joins_config=joins_config,
385+
nest_joins=True
386+
)
387+
```
388+
389+
You can also sort by multiple columns with different sort orders:
390+
391+
```python
392+
joins_config = [
393+
JoinConfig(
394+
model=Article,
395+
join_on=Author.id == Article.author_id,
396+
join_prefix="articles_",
397+
relationship_type="one-to-many",
398+
sort_columns=["published_date", "title"], # Sort by date first, then title
399+
sort_orders=["desc", "asc"] # Date descending, title ascending
400+
)
401+
]
402+
```
403+
404+
This will result in nested articles being sorted first by published_date in descending order, and then by title in ascending order within each date group.
405+
358406
#### Many-to-Many Relationships with `get_multi_joined`
359407

360408
FastCRUD simplifies dealing with many-to-many relationships by allowing easy fetch operations with joined models. Here, we demonstrate using `get_multi_joined` to handle a many-to-many relationship between `Project` and `Participant` models, linked through an association table.
361409

362410
**Note on Handling Many-to-Many Relationships:**
363411

364-
When using `get_multi_joined` for many-to-many relationships, it's essential to maintain a specific order in your `joins_config`:
412+
When using `get_multi_joined` for many-to-many relationships, it's essential to maintain a specific order in your `joins_config`:
365413

366414
1. **First**, specify the main table you're querying from.
367415
2. **Next**, include the association table that links your main table to the other table involved in the many-to-many relationship.
@@ -419,7 +467,7 @@ joins_config = [
419467

420468
# Fetch projects with their participants
421469
projects_with_participants = await project_crud.get_multi_joined(
422-
db_session,
470+
db_session,
423471
joins_config=joins_config,
424472
)
425473
```

fastcrud/crud/helper.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class JoinConfig(BaseModel):
2020
alias: Optional[AliasedClass] = None
2121
filters: Optional[dict] = None
2222
relationship_type: Optional[str] = "one-to-one"
23+
sort_columns: Optional[Union[str, list[str]]] = None
24+
sort_orders: Optional[Union[str, list[str]]] = None
2325

2426
model_config = ConfigDict(arbitrary_types_allowed=True)
2527

@@ -289,6 +291,57 @@ def _handle_one_to_many(nested_data, nested_key, nested_field, value):
289291
return nested_data
290292

291293

294+
def _sort_nested_list(nested_list: list[dict], sort_columns: Union[str, list[str]], sort_orders: Optional[Union[str, list[str]]] = None) -> list[dict]:
295+
"""
296+
Sorts a list of dictionaries based on specified sort columns and orders.
297+
298+
Args:
299+
nested_list: The list of dictionaries to sort.
300+
sort_columns: A single column name or a list of column names on which to apply sorting.
301+
sort_orders: A single sort order ("asc" or "desc") or a list of sort orders corresponding
302+
to the columns in `sort_columns`. If not provided, defaults to "asc" for each column.
303+
304+
Returns:
305+
The sorted list of dictionaries.
306+
307+
Examples:
308+
Sorting a list of dictionaries by a single column in ascending order:
309+
>>> _sort_nested_list([{"id": 2, "name": "B"}, {"id": 1, "name": "A"}], "name")
310+
[{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]
311+
312+
Sorting by multiple columns with different orders:
313+
>>> _sort_nested_list([{"id": 1, "name": "A"}, {"id": 2, "name": "A"}], ["name", "id"], ["asc", "desc"])
314+
[{"id": 2, "name": "A"}, {"id": 1, "name": "A"}]
315+
"""
316+
if not nested_list or not sort_columns:
317+
return nested_list
318+
319+
if not isinstance(sort_columns, list):
320+
sort_columns = [sort_columns]
321+
322+
if sort_orders:
323+
if not isinstance(sort_orders, list):
324+
sort_orders = [sort_orders] * len(sort_columns)
325+
if len(sort_columns) != len(sort_orders):
326+
raise ValueError("The length of sort_columns and sort_orders must match.")
327+
328+
for order in sort_orders:
329+
if order not in ["asc", "desc"]:
330+
raise ValueError(f"Invalid sort order: {order}. Only 'asc' or 'desc' are allowed.")
331+
else:
332+
sort_orders = ["asc"] * len(sort_columns)
333+
334+
# Create a list of (column, order) tuples for sorting
335+
sort_specs = [(col, 1 if order == "asc" else -1) for col, order in zip(sort_columns, sort_orders)]
336+
337+
# Sort the list using the sort_specs
338+
sorted_list = nested_list.copy()
339+
for col, direction in reversed(sort_specs):
340+
sorted_list.sort(key=lambda x: (x.get(col) is None, x.get(col)), reverse=direction == -1)
341+
342+
return sorted_list
343+
344+
292345
def _nest_join_data(
293346
data: dict,
294347
join_definitions: list[JoinConfig],
@@ -432,6 +485,11 @@ def _nest_join_data(
432485
item[join_primary_key] is None for item in nested_data[nested_key]
433486
):
434487
nested_data[nested_key] = []
488+
# Apply sorting to nested list if sort_columns is specified
489+
elif join.sort_columns and nested_data[nested_key]:
490+
nested_data[nested_key] = _sort_nested_list(
491+
nested_data[nested_key], join.sort_columns, join.sort_orders
492+
)
435493

436494
if nested_key in nested_data and isinstance(nested_data[nested_key], dict):
437495
if (
@@ -576,6 +634,14 @@ def _nest_multi_join_data(
576634
join_prefix
577635
].append(item)
578636
existing_items.add(item[join_primary_key])
637+
638+
# Apply sorting to nested list if sort_columns is specified
639+
if join_config.sort_columns and pre_nested_data[primary_key_value][join_prefix]:
640+
pre_nested_data[primary_key_value][join_prefix] = _sort_nested_list(
641+
pre_nested_data[primary_key_value][join_prefix],
642+
join_config.sort_columns,
643+
join_config.sort_orders
644+
)
579645
else: # pragma: no cover
580646
if join_prefix in row_dict:
581647
value = row_dict[join_prefix]

tests/sqlalchemy/conftest.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,22 @@ class Card(Base):
138138
title = Column(String(32))
139139

140140

141+
class Author(Base):
142+
__tablename__ = "authors"
143+
id = Column(Integer, primary_key=True)
144+
name = Column(String(32))
145+
articles = relationship("Article", back_populates="author")
146+
147+
141148
class Article(Base):
142149
__tablename__ = "articles"
143150
id = Column(Integer, primary_key=True)
144151
title = Column(String(32))
145-
card_id = Column(Integer, ForeignKey("cards.id"))
152+
content = Column(String(100))
153+
published_date = Column(String(32))
154+
author_id = Column(Integer, ForeignKey("authors.id"))
155+
author = relationship("Author", back_populates="articles")
156+
card_id = Column(Integer, ForeignKey("cards.id"), nullable=True)
146157
card = relationship("Card", back_populates="articles")
147158

148159

@@ -254,7 +265,16 @@ class BookingSchema(BaseModel):
254265
class ArticleSchema(BaseModel):
255266
id: int
256267
title: str
257-
card_id: int
268+
content: Optional[str] = None
269+
published_date: Optional[str] = None
270+
author_id: Optional[int] = None
271+
card_id: Optional[int] = None
272+
273+
274+
class AuthorSchema(BaseModel):
275+
id: int
276+
name: str
277+
articles: Optional[list[ArticleSchema]] = []
258278

259279

260280
class CardSchema(BaseModel):

tests/sqlalchemy/core/test_nest_multi_join_data.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ async def test_nest_multi_join_data_new_row_none(async_session):
2020
await async_session.flush()
2121

2222
articles = [
23-
Article(title="Article 1", card_id=cards[0].id),
23+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
2424
Article(
25-
title="Article 2", card_id=None
25+
title="Article 2", content="Content 2", author_id=2, card_id=None
2626
), # This should trigger new_row[key] = []
2727
]
2828
async_session.add_all(articles)
@@ -76,10 +76,10 @@ async def test_nest_multi_join_data_existing_row_none(async_session):
7676
await async_session.flush()
7777

7878
articles = [
79-
Article(title="Article 1", card_id=cards[0].id),
80-
Article(title="Article 2", card_id=cards[0].id),
79+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
80+
Article(title="Article 2", content="Content 2", author_id=2, card_id=cards[0].id),
8181
Article(
82-
title="Article 3", card_id=None
82+
title="Article 3", content="Content 3", author_id=3, card_id=None
8383
), # This will trigger existing_row[key] = []
8484
]
8585
async_session.add_all(articles)
@@ -128,7 +128,7 @@ async def test_nest_multi_join_data_nested_schema(async_session):
128128
await async_session.flush()
129129

130130
articles = [
131-
Article(title="Article 1", card_id=cards[0].id),
131+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
132132
]
133133
async_session.add_all(articles)
134134
await async_session.commit()
@@ -178,7 +178,7 @@ async def test_nest_multi_join_data_prefix_in_item(async_session):
178178
await async_session.flush()
179179

180180
articles = [
181-
Article(title="Article 1", card_id=cards[0].id),
181+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
182182
]
183183
async_session.add_all(articles)
184184
await async_session.commit()
@@ -228,8 +228,8 @@ async def test_nest_multi_join_data_isinstance_list(async_session):
228228
await async_session.flush()
229229

230230
articles = [
231-
Article(title="Article 1", card_id=cards[0].id),
232-
Article(title="Article 2", card_id=cards[0].id),
231+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
232+
Article(title="Article 2", content="Content 2", author_id=2, card_id=cards[0].id),
233233
]
234234
async_session.add_all(articles)
235235
await async_session.commit()
@@ -276,8 +276,8 @@ async def test_nest_multi_join_data_convert_list_to_schema(async_session):
276276
await async_session.flush()
277277

278278
articles = [
279-
Article(title="Article 1", card_id=cards[0].id),
280-
Article(title="Article 2", card_id=cards[0].id),
279+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
280+
Article(title="Article 2", content="Content 2", author_id=2, card_id=cards[0].id),
281281
]
282282
async_session.add_all(articles)
283283
await async_session.commit()
@@ -324,7 +324,7 @@ async def test_nest_multi_join_data_convert_dict_to_schema(async_session):
324324
await async_session.flush()
325325

326326
articles = [
327-
Article(title="Article 1", card_id=cards[0].id),
327+
Article(title="Article 1", content="Content 1", author_id=1, card_id=cards[0].id),
328328
]
329329
async_session.add_all(articles)
330330
await async_session.commit()

0 commit comments

Comments
 (0)