Skip to content

Commit 2e89b1f

Browse files
authored
Merge pull request #289 from benavlabs/some-bug-fixes
Some bug fixes
2 parents cb13264 + 34ac30c commit 2e89b1f

File tree

6 files changed

+556
-19
lines changed

6 files changed

+556
-19
lines changed

docs/advanced/joins.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,48 @@ This works for both `get_joined` and `get_multi_joined`.
201201

202202
Note that the final `"_"` in the passed `"tier_"` is stripped.
203203

204-
!!! WARNING "join_prefix and return_as_model Compatibility"
204+
#### Returning Pydantic Models with `return_as_model`
205205

206-
When using `return_as_model=True` with `nest_joins=True`, ensure that your `join_prefix` (minus trailing "_") matches the field name in your Pydantic schema. Otherwise, FastCRUD will raise a `ValueError` with clear guidance on how to fix the mismatch.
206+
By default, `get_joined` returns dictionaries containing the joined data. However, you can use the `return_as_model` parameter to get Pydantic model instances instead:
207+
208+
```python
209+
# Using a schema that includes joined fields
210+
class UserWithTier(BaseModel):
211+
id: int
212+
name: str
213+
tier_id: int
214+
tier_name: str
215+
216+
# Returns a dictionary (default behavior)
217+
user_dict = await user_crud.get_joined(
218+
db=db,
219+
join_model=Tier,
220+
join_prefix="tier_",
221+
schema_to_select=UserWithTier,
222+
return_as_model=False, # Default
223+
id=1,
224+
)
225+
# Result: {"id": 1, "name": "Example", "tier_id": 1, "tier_name": "Free"}
226+
227+
# Returns a Pydantic model instance
228+
user_model = await user_crud.get_joined(
229+
db=db,
230+
join_model=Tier,
231+
join_prefix="tier_",
232+
schema_to_select=UserWithTier,
233+
return_as_model=True,
234+
id=1,
235+
)
236+
# Result: UserWithTier(id=1, name="Example", tier_id=1, tier_name="Free")
237+
```
238+
239+
!!! NOTE "`return_as_model` Usage Notes"
240+
241+
**Required Parameters**: When `return_as_model=True`, the `schema_to_select` parameter is required. FastCRUD will raise a `ValueError` if you try to use `return_as_model=True` without providing a schema.
242+
243+
**Schema Design**: Ensure your schema includes all the fields that will be present in the flattened result, including joined fields with their prefixes.
244+
245+
**Nested Joins Compatibility**: When using `return_as_model=True` with `nest_joins=True`, ensure that your `join_prefix` (minus trailing "_") matches the field name in your Pydantic schema. Otherwise, FastCRUD will raise a `ValueError` with clear guidance on how to fix the mismatch.
207246

208247
**❌ This will raise an error:**
209248
```python

docs/changelog.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@
55
The Changelog documents all notable changes made to FastCRUD. This includes new features, bug fixes, and improvements. It's organized by version and date, providing a clear history of the library's development.
66
___
77

8+
## [0.19.2] - Nov 15, 2025
9+
10+
#### Added
11+
- **get_joined Method Overloads** by [@igorbenav](https://github.com/igorbenav)
12+
- Added missing `@overload` signatures for `get_joined()` method to support proper type inference
13+
- Added `return_as_model` parameter for converting joined results to Pydantic models
14+
- Enhanced type safety: returns `SelectSchemaType` when `return_as_model=True`, `dict` when `False`
15+
- Consistent API with other CRUD methods like `get()`, `update()`, etc.
16+
17+
#### Improved
18+
- **create Method Performance and Consistency** by [@igorbenav](https://github.com/igorbenav)
19+
- Removed unnecessary database round-trip by eliminating redundant `get()` call after creation
20+
- Added deprecation warnings for upcoming API consistency changes in next major version
21+
- Fixed type hints to match actual implementation behavior (removed incorrect `None` return type)
22+
23+
#### Deprecated
24+
- **create Method Behavior Changes** (Warnings Added)
25+
- `create()` without `schema_to_select` will return `None` instead of SQLAlchemy model in next major version
26+
- `create()` with `schema_to_select` will properly respect `return_as_model` parameter in next major version
27+
- These changes align `create()` behavior with `update()` and other CRUD methods for consistency
28+
29+
#### Fixed
30+
- **Type Safety Issues** by [@igorbenav](https://github.com/igorbenav)
31+
- Fixed `get_joined()` method type annotations to properly reflect actual return types
32+
- Corrected `create()` method type hints to remove impossible `None` return type
33+
- Enhanced test coverage with 8 new comprehensive tests for `get_joined` return type variations
34+
35+
#### Breaking Changes
36+
⚠️ **None** - This release maintains full backward compatibility with 0.19.1. Deprecation warnings provide clear migration path for next major version.
37+
38+
___
39+
840
## [0.19.1] - Nov 10, 2025
941

1042
#### Improved

docs/usage/crud.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,15 @@ create(
128128
new_item = await item_crud.create(db, CreateItemSchema(name="New Item"))
129129
```
130130

131+
!!! WARNING "Deprecated Behavior"
132+
133+
**Upcoming Changes in Next Major Version**: The `create()` method currently behaves inconsistently compared to other CRUD methods like `update()`. It will change:
134+
135+
- **Currently without `schema_to_select`**: Returns SQLAlchemy model → **Will return `None`**
136+
- **Currently with `schema_to_select`**: Bypasses `return_as_model` parameter → **Will respect `return_as_model` like other methods**
137+
138+
This makes `create()` consistent with `update()` behavior. Current usage patterns will trigger deprecation warnings. See [changelog](../changelog.md#0192---nov-15-2025) for details.
139+
131140
!!! WARNING
132141

133142
Note that naive `datetime` such as `datetime.utcnow` is not supported by `FastCRUD` as it was [deprecated](https://github.com/python/cpython/pull/103858).

fastcrud/crud/fast_crud.py

Lines changed: 132 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any, Generic, Union, Optional, Callable, overload, Literal, cast
22
from datetime import datetime, timezone
3+
import warnings
34

45
from sqlalchemy import (
56
select,
@@ -508,7 +509,7 @@ async def create(
508509
commit: bool = True,
509510
schema_to_select: None = None,
510511
return_as_model: Literal[False] = False,
511-
) -> Optional[dict[str, Any]]: ...
512+
) -> ModelType: ...
512513

513514
@overload
514515
async def create(
@@ -519,7 +520,7 @@ async def create(
519520
commit: bool = True,
520521
schema_to_select: type[SelectSchemaType],
521522
return_as_model: Literal[False] = False,
522-
) -> Optional[dict[str, Any]]: ...
523+
) -> dict[str, Any]: ...
523524

524525
@overload
525526
async def create(
@@ -530,7 +531,7 @@ async def create(
530531
commit: bool = True,
531532
schema_to_select: Optional[type[SelectSchemaType]] = None,
532533
return_as_model: bool = False,
533-
) -> Union[ModelType, SelectSchemaType, dict[str, Any], None]: ...
534+
) -> Union[ModelType, SelectSchemaType, dict[str, Any]]: ...
534535

535536
async def create(
536537
self,
@@ -539,7 +540,7 @@ async def create(
539540
commit: bool = True,
540541
schema_to_select: Optional[type[SelectSchemaType]] = None,
541542
return_as_model: bool = False,
542-
) -> Union[ModelType, SelectSchemaType, dict, None]:
543+
) -> Union[ModelType, SelectSchemaType, dict]:
543544
"""
544545
Create a new record in the database.
545546
@@ -573,18 +574,35 @@ async def create(
573574
await db.flush()
574575
await db.refresh(db_object)
575576

576-
if schema_to_select:
577-
if not self._primary_keys:
578-
raise ValueError("Cannot fetch created record without a primary key.")
579-
580-
pks = {pk.name: getattr(db_object, pk.name) for pk in self._primary_keys}
581-
return await self.get(
582-
db=db,
583-
schema_to_select=schema_to_select,
584-
return_as_model=return_as_model,
585-
**pks,
577+
# Add deprecation warnings for upcoming behavior changes
578+
if not schema_to_select:
579+
warnings.warn(
580+
"create() without schema_to_select will return None instead of the SQLAlchemy model "
581+
"in the next major version for consistency with other CRUD methods. "
582+
"Provide schema_to_select to get data back. "
583+
"Return type will change from Union[ModelType, SelectSchemaType, dict] to Optional[Union[SelectSchemaType, dict]].",
584+
DeprecationWarning,
585+
stacklevel=2,
586+
)
587+
elif schema_to_select and not return_as_model:
588+
warnings.warn(
589+
"create() with schema_to_select will default to returning dict "
590+
"in the next major version for consistency with other CRUD methods. "
591+
"Return type will change from Union[ModelType, SelectSchemaType, dict] to Optional[Union[SelectSchemaType, dict]].",
592+
DeprecationWarning,
593+
stacklevel=2,
586594
)
587595

596+
if schema_to_select:
597+
# Convert db_object to dict following same pattern as get() method
598+
data_dict = {
599+
col.key: getattr(db_object, col.key)
600+
for col in db_object.__table__.columns
601+
}
602+
if not return_as_model:
603+
return data_dict
604+
return schema_to_select(**data_dict)
605+
588606
return db_object
589607

590608
async def select(
@@ -1383,10 +1401,73 @@ async def get_multi(
13831401

13841402
return response
13851403

1404+
@overload
1405+
async def get_joined(
1406+
self,
1407+
db: AsyncSession,
1408+
*,
1409+
schema_to_select: type[SelectSchemaType],
1410+
return_as_model: Literal[True],
1411+
join_model: Optional[ModelType] = None,
1412+
join_on: Optional[Union[Join, BinaryExpression]] = None,
1413+
join_prefix: Optional[str] = None,
1414+
join_schema_to_select: Optional[type[SelectSchemaType]] = None,
1415+
join_type: str = "left",
1416+
alias: Optional[AliasedClass] = None,
1417+
join_filters: Optional[dict] = None,
1418+
joins_config: Optional[list[JoinConfig]] = None,
1419+
nest_joins: bool = False,
1420+
relationship_type: Optional[str] = None,
1421+
**kwargs: Any,
1422+
) -> Optional[SelectSchemaType]: ...
1423+
1424+
@overload
1425+
async def get_joined(
1426+
self,
1427+
db: AsyncSession,
1428+
*,
1429+
schema_to_select: None = None,
1430+
return_as_model: Literal[False] = False,
1431+
join_model: Optional[ModelType] = None,
1432+
join_on: Optional[Union[Join, BinaryExpression]] = None,
1433+
join_prefix: Optional[str] = None,
1434+
join_schema_to_select: Optional[type[SelectSchemaType]] = None,
1435+
join_type: str = "left",
1436+
alias: Optional[AliasedClass] = None,
1437+
join_filters: Optional[dict] = None,
1438+
joins_config: Optional[list[JoinConfig]] = None,
1439+
nest_joins: bool = False,
1440+
relationship_type: Optional[str] = None,
1441+
**kwargs: Any,
1442+
) -> Optional[dict[str, Any]]: ...
1443+
1444+
@overload
13861445
async def get_joined(
13871446
self,
13881447
db: AsyncSession,
1448+
*,
1449+
schema_to_select: type[SelectSchemaType],
1450+
return_as_model: Literal[False] = False,
1451+
join_model: Optional[ModelType] = None,
1452+
join_on: Optional[Union[Join, BinaryExpression]] = None,
1453+
join_prefix: Optional[str] = None,
1454+
join_schema_to_select: Optional[type[SelectSchemaType]] = None,
1455+
join_type: str = "left",
1456+
alias: Optional[AliasedClass] = None,
1457+
join_filters: Optional[dict] = None,
1458+
joins_config: Optional[list[JoinConfig]] = None,
1459+
nest_joins: bool = False,
1460+
relationship_type: Optional[str] = None,
1461+
**kwargs: Any,
1462+
) -> Optional[dict[str, Any]]: ...
1463+
1464+
@overload
1465+
async def get_joined(
1466+
self,
1467+
db: AsyncSession,
1468+
*,
13891469
schema_to_select: Optional[type[SelectSchemaType]] = None,
1470+
return_as_model: bool = False,
13901471
join_model: Optional[ModelType] = None,
13911472
join_on: Optional[Union[Join, BinaryExpression]] = None,
13921473
join_prefix: Optional[str] = None,
@@ -1398,7 +1479,25 @@ async def get_joined(
13981479
nest_joins: bool = False,
13991480
relationship_type: Optional[str] = None,
14001481
**kwargs: Any,
1401-
) -> Optional[dict[str, Any]]:
1482+
) -> Optional[Union[dict[str, Any], SelectSchemaType]]: ...
1483+
1484+
async def get_joined(
1485+
self,
1486+
db: AsyncSession,
1487+
schema_to_select: Optional[type[SelectSchemaType]] = None,
1488+
return_as_model: bool = False,
1489+
join_model: Optional[ModelType] = None,
1490+
join_on: Optional[Union[Join, BinaryExpression]] = None,
1491+
join_prefix: Optional[str] = None,
1492+
join_schema_to_select: Optional[type[SelectSchemaType]] = None,
1493+
join_type: str = "left",
1494+
alias: Optional[AliasedClass] = None,
1495+
join_filters: Optional[dict] = None,
1496+
joins_config: Optional[list[JoinConfig]] = None,
1497+
nest_joins: bool = False,
1498+
relationship_type: Optional[str] = None,
1499+
**kwargs: Any,
1500+
) -> Optional[Union[dict[str, Any], SelectSchemaType]]:
14021501
"""
14031502
Fetches a single record with one or multiple joins on other models. If `join_on` is not provided, the method attempts
14041503
to automatically detect the join condition using foreign key relationships. For multiple joins, use `joins_config` to
@@ -1409,6 +1508,7 @@ async def get_joined(
14091508
Args:
14101509
db: The SQLAlchemy async session.
14111510
schema_to_select: Pydantic schema for selecting specific columns from the primary model. Required if `return_as_model` is True.
1511+
return_as_model: If `True`, returns data as a Pydantic model instance based on `schema_to_select`. Defaults to `False`.
14121512
join_model: The model to join with.
14131513
join_on: SQLAlchemy Join object for specifying the `ON` clause of the join. If `None`, the join condition is auto-detected based on foreign keys.
14141514
join_prefix: Optional prefix to be added to all columns of the joined model. If `None`, no prefix is added.
@@ -1422,7 +1522,10 @@ async def get_joined(
14221522
**kwargs: Filters to apply to the primary model query, supporting advanced comparison operators for refined searching.
14231523
14241524
Returns:
1425-
A dictionary representing the joined record, or `None` if no record matches the criteria.
1525+
A dictionary or Pydantic model instance representing the joined record, or `None` if no record matches the criteria:
1526+
1527+
- When `return_as_model=True` and `schema_to_select` is provided: `Optional[SelectSchemaType]`
1528+
- When `return_as_model=False`: `Optional[Dict[str, Any]]`
14261529
14271530
Raises:
14281531
ValueError: If both single join parameters and `joins_config` are used simultaneously.
@@ -1690,7 +1793,19 @@ async def get_joined(
16901793
else:
16911794
data_list = []
16921795

1693-
return process_joined_data(data_list, join_definitions, nest_joins, self.model)
1796+
processed_data = process_joined_data(
1797+
data_list, join_definitions, nest_joins, self.model
1798+
)
1799+
1800+
if processed_data is None or not return_as_model:
1801+
return processed_data
1802+
1803+
if not schema_to_select:
1804+
raise ValueError(
1805+
"schema_to_select must be provided when return_as_model is True."
1806+
)
1807+
1808+
return schema_to_select(**processed_data)
16941809

16951810
@overload
16961811
async def get_multi_joined(

0 commit comments

Comments
 (0)