Skip to content

Commit c056ab2

Browse files
authoredJul 29, 2024··
Merge pull request #139 from igorbenav/preparations_for_0_14
[WIP] Preparations for 0.14.0
2 parents 3cbbb3a + 856cb12 commit c056ab2

File tree

8 files changed

+269
-34
lines changed

8 files changed

+269
-34
lines changed
 

‎docs/changelog.md

+201
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,207 @@
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.14.0] - Jul 29, 2024
9+
10+
#### Added
11+
- Type-checking support for SQLModel types by @kdcokenny 🚀
12+
- Returning clause to update operations by @feluelle
13+
- Upsert_multi functionality by @feluelle
14+
- Simplified endpoint configurations by @JakNowy, streamlining path generation and merging pagination functionalities into a unified `_read_items` endpoint, promoting more efficient API structure and usage. Details in https://github.com/igorbenav/fastcrud/pull/105
15+
16+
#### Improved
17+
- Comprehensive tests for paginated retrieval of items, maintaining 100% coverage
18+
- Docker client check before running tests that require Docker by @feluelle
19+
20+
#### Fixed
21+
- Vulnerability associated with an outdated cryptography package
22+
- Return type inconsistency in async session fixtures by @slaarti
23+
24+
#### Documentation Updates
25+
- Cleanup of documentation formatting by @slaarti
26+
- Replacement of the Contributing section in docs with an include to file in repo root by @slaarti
27+
- Correction of links to advanced filters in docstrings by @slaarti
28+
- Backfill of docstring fixes across various modules by @slaarti
29+
- Enhanced filter documentation with new AND and OR clause examples, making complex queries more accessible and understandable.
30+
31+
#### Models and Schemas Enhancements
32+
- Introduction of simple and one-off models (Batch 1) by @slaarti
33+
- Expansion to include models and schemas for Customers, Products, and Orders (Batch 2) by @slaarti
34+
35+
#### Code Refinements
36+
- Resolution of missing type specifications in kwargs by @slaarti
37+
- Collapsed space adjustments for models/schemas in `fast_crud.py` by @slaarti
38+
39+
#### Warnings
40+
- **Deprecation Notice**: `_read_paginated` endpoint is set to be deprecated and merged into `_read_items`. Users are encouraged to transition to the latter, utilizing optional pagination parameters. Full details and usage instructions provided to ensure a smooth transition.
41+
- **Future Changes Alert**: Default endpoint names in `EndpointCreator` are anticipated to be set to empty strings in a forthcoming major release, aligning with simplification efforts. Refer to https://github.com/igorbenav/fastcrud/issues/67 for more information.
42+
43+
#### Detailed Changes
44+
___
45+
##### Simplified Endpoint Configurations
46+
47+
In an effort to streamline FastCRUD’s API, we have reconfigured endpoint paths to avoid redundancy (great work by @JakNowy). This change allows developers to specify empty strings for endpoint names in the `crud_router` setup, which prevents the generation of unnecessary `//` in the paths. The following configurations illustrate how endpoints can now be defined more succinctly:
48+
49+
```python
50+
endpoint_names = {
51+
"create": "",
52+
"read": "",
53+
"update": "",
54+
"delete": "",
55+
"db_delete": "",
56+
"read_multi": "",
57+
"read_paginated": "get_paginated",
58+
}
59+
```
60+
61+
Moreover, the `_read_paginated` logic has been integrated into the `_read_items` endpoint. This integration means that pagination can now be controlled via `page` and `items_per_page` query parameters, offering a unified method for both paginated and non-paginated reads:
62+
63+
- **Paginated read example**:
64+
65+
```bash
66+
curl -X 'GET' \
67+
'http://localhost:8000/users/get_multi?page=2&itemsPerPage=10' \
68+
-H 'accept: application/json'
69+
```
70+
71+
- **Non-paginated read example**:
72+
73+
```bash
74+
curl -X 'GET' \
75+
'http://localhost:8000/users/get_multi?offset=0&limit=100' \
76+
-H 'accept: application/json'
77+
```
78+
79+
###### Warnings
80+
81+
- **Deprecation Warning**: The `_read_paginated` endpoint is slated for deprecation. Developers should transition to using `_read_items` with the relevant pagination parameters.
82+
- **Configuration Change Alert**: In a future major release, default endpoint names in `EndpointCreator` will be empty strings by default, as discussed in [Issue #67](https://github.com/igorbenav/fastcrud/issues/67).
83+
84+
###### Advanced Filters Documentation Update
85+
86+
Documentation for advanced filters has been expanded to include comprehensive examples of AND and OR clauses, enhancing the utility and accessibility of complex query constructions.
87+
88+
- **OR clause example**:
89+
90+
```python
91+
# Fetch items priced under $5 or above $20
92+
items = await item_crud.get_multi(
93+
db=db,
94+
price__or={'lt': 5, 'gt': 20},
95+
)
96+
```
97+
98+
- **AND clause example**:
99+
100+
```python
101+
# Fetch items priced under $20 and over 2 years of warranty
102+
items = await item_crud.get_multi(
103+
db=db,
104+
price__lt=20,
105+
warranty_years__gt=2,
106+
)
107+
```
108+
109+
___
110+
##### Returning Clauses in Update Operations
111+
112+
###### Description
113+
Users can now retrieve updated records immediately following an update operation. This feature streamlines the process, reducing the need for subsequent retrieval calls and increasing efficiency.
114+
115+
###### Changes
116+
- **Return Columns**: Specify the columns to be returned after the update via the `return_columns` argument.
117+
- **Schema Selection**: Optionally select a Pydantic schema to format the returned data using the `schema_to_select` argument.
118+
- **Return as Model**: Decide if the returned data should be converted into a model using the `return_as_model` argument.
119+
- **Single or None**: Utilize the `one_or_none` argument to ensure that either a single record is returned or none, in case the conditions do not match any records.
120+
121+
These additions are aligned with existing CRUD API functions, enhancing consistency across the library and making the new features intuitive for users.
122+
123+
###### Usage Example
124+
125+
###### Returning Updated Fields
126+
127+
```python
128+
from fastcrud import FastCRUD
129+
from .models.item import Item
130+
from .database import session as db
131+
132+
crud_items = FastCRUD(Item)
133+
updated_item = await crud_items.update(
134+
db=db,
135+
object={"price": 9.99},
136+
price__lt=10,
137+
return_columns=["price"]
138+
)
139+
# This returns the updated price of the item directly.
140+
```
141+
142+
###### Returning Data as a Model
143+
144+
```python
145+
from fastcrud import FastCRUD
146+
from .models.item import Item
147+
from .schemas.item import ItemSchema
148+
from .database import session as db
149+
150+
crud_items = FastCRUD(Item)
151+
updated_item_schema = await crud_items.update(
152+
db=db,
153+
object={"price": 9.99},
154+
price__lt=10,
155+
schema_to_select=ItemSchema,
156+
return_as_model=True
157+
)
158+
# This returns the updated item data formatted as an ItemSchema model.
159+
```
160+
161+
___
162+
##### Bulk Upsert Operations with `upsert_multi`
163+
164+
The `upsert_multi` method provides the ability to perform bulk upsert operations, which are optimized for different SQL dialects.
165+
166+
###### Changes
167+
- **Dialect-Optimized SQL**: Uses the most efficient SQL commands based on the database's SQL dialect.
168+
- **Support for Multiple Dialects**: Includes custom implementations for PostgreSQL, SQLite, and MySQL, with appropriate handling for each's capabilities and limitations.
169+
170+
###### Usage Example
171+
172+
###### Upserting Multiple Records
173+
174+
```python
175+
from fastcrud import FastCRUD
176+
from .models.item import Item
177+
from .schemas.item import ItemCreateSchema, ItemSchema
178+
from .database import session as db
179+
180+
crud_items = FastCRUD(Item)
181+
items = await crud_items.upsert_multi(
182+
db=db,
183+
instances=[
184+
ItemCreateSchema(price=9.99),
185+
],
186+
schema_to_select=ItemSchema,
187+
return_as_model=True,
188+
)
189+
# This will return the upserted data in the form of ItemSchema.
190+
```
191+
192+
###### Implementation Details
193+
194+
`upsert_multi` handles different database dialects:
195+
- **PostgreSQL**: Uses `ON CONFLICT DO UPDATE`.
196+
- **SQLite**: Utilizes `ON CONFLICT DO UPDATE`.
197+
- **MySQL**: Implements `ON DUPLICATE KEY UPDATE`.
198+
199+
###### Notes
200+
- MySQL and MariaDB do not support certain advanced features used in other dialects, such as returning values directly after an insert or update operation. This limitation is clearly documented to prevent misuse.
201+
202+
#### New Contributors
203+
- @kdcokenny made their first contribution 🌟
204+
- @feluelle made their first contribution 🌟
205+
206+
**Full Changelog**: [View the full changelog](https://github.com/igorbenav/fastcrud/compare/v0.13.1...v0.14.0)
207+
208+
8209
## [0.13.1] - Jun 22, 2024
9210

10211
#### Added

‎docs/usage/endpoint.md

-4
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@ This section of the documentation explains how to use the `crud_router` utility
66

77
Before proceeding, ensure you have FastAPI and FastCRUD installed in your environment. FastCRUD streamlines interactions with the database using SQLAlchemy models and Pydantic schemas.
88

9-
!!! WARNING
10-
11-
For now, your primary column in the database model must be named `id`.
12-
139
---
1410

1511
## Using `crud_router`

‎fastcrud/crud/fast_crud.py

+5-7
Original file line numberDiff line numberDiff line change
@@ -687,13 +687,12 @@ async def upsert_multi(
687687
"MySQL does not support the returning clause for insert operations."
688688
)
689689
statement, params = await self._upsert_multi_mysql(instances)
690-
else:
690+
else: # pragma: no cover
691691
raise NotImplementedError(
692692
f"Upsert multi is not implemented for {db.bind.dialect.name}"
693693
)
694694

695695
if return_as_model:
696-
# All columns are returned to ensure the model can be constructed
697696
return_columns = self.model_col_names
698697

699698
if return_columns:
@@ -1975,7 +1974,6 @@ async def update(
19751974
stmt = update(self.model).filter(*filters).values(update_data)
19761975

19771976
if return_as_model:
1978-
# All columns are returned to ensure the model can be constructed
19791977
return_columns = self.model_col_names
19801978

19811979
if return_columns:
@@ -2007,12 +2005,12 @@ def _as_single_response(
20072005
one_or_none: bool = False,
20082006
) -> Optional[Union[dict, BaseModel]]:
20092007
result: Optional[Row] = db_row.one_or_none() if one_or_none else db_row.first()
2010-
if result is None:
2008+
if result is None: # pragma: no cover
20112009
return None
20122010
out: dict = dict(result._mapping)
20132011
if not return_as_model:
20142012
return out
2015-
if not schema_to_select:
2013+
if not schema_to_select: # pragma: no cover
20162014
raise ValueError(
20172015
"schema_to_select must be provided when return_as_model is True."
20182016
)
@@ -2029,14 +2027,14 @@ def _as_multi_response(
20292027
response: dict[str, Any] = {"data": data}
20302028

20312029
if return_as_model:
2032-
if not schema_to_select:
2030+
if not schema_to_select: # pragma: no cover
20332031
raise ValueError(
20342032
"schema_to_select must be provided when return_as_model is True."
20352033
)
20362034
try:
20372035
model_data = [schema_to_select(**row) for row in data]
20382036
response["data"] = model_data
2039-
except ValidationError as e:
2037+
except ValidationError as e: # pragma: no cover
20402038
raise ValueError(
20412039
f"Data validation error for schema {schema_to_select.__name__}: {e}"
20422040
)

‎fastcrud/crud/helper.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def _extract_matching_columns_from_schema(
6464
in the schema or all columns from the model if no schema is specified. These columns are correctly referenced
6565
through the provided alias if one is given.
6666
"""
67-
if not hasattr(model, "__table__"):
67+
if not hasattr(model, "__table__"): # pragma: no cover
6868
raise AttributeError(f"{model.__name__} does not have a '__table__' attribute.")
6969

7070
model_or_alias = alias if alias else model
@@ -117,11 +117,11 @@ def _auto_detect_join_condition(
117117
ValueError: If the join condition cannot be automatically determined.
118118
AttributeError: If either base_model or join_model does not have a `__table__` attribute.
119119
"""
120-
if not hasattr(base_model, "__table__"):
120+
if not hasattr(base_model, "__table__"): # pragma: no cover
121121
raise AttributeError(
122122
f"{base_model.__name__} does not have a '__table__' attribute."
123123
)
124-
if not hasattr(join_model, "__table__"):
124+
if not hasattr(join_model, "__table__"): # pragma: no cover
125125
raise AttributeError(
126126
f"{join_model.__name__} does not have a '__table__' attribute."
127127
)

‎fastcrud/endpoint/helper.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def _get_primary_keys(
8383
) -> Sequence[Column]:
8484
"""Get the primary key of a SQLAlchemy model."""
8585
inspector_result = sa_inspect(model)
86-
if inspector_result is None:
86+
if inspector_result is None: # pragma: no cover
8787
raise ValueError("Model inspection failed, resulting in None.")
8888
primary_key_columns: Sequence[Column] = inspector_result.mapper.primary_key
8989

@@ -109,7 +109,7 @@ def _get_column_types(
109109
) -> dict[str, Union[type, None]]:
110110
"""Get a dictionary of column names and their corresponding Python types from a SQLAlchemy model."""
111111
inspector_result = sa_inspect(model)
112-
if inspector_result is None or inspector_result.mapper is None:
112+
if inspector_result is None or inspector_result.mapper is None: # pragma: no cover
113113
raise ValueError("Model inspection failed, resulting in None.")
114114
column_types = {}
115115
for column in inspector_result.mapper.columns:
@@ -121,7 +121,7 @@ def _extract_unique_columns(
121121
model: ModelType,
122122
) -> Sequence[KeyedColumnElement]:
123123
"""Extracts columns from a SQLAlchemy model that are marked as unique."""
124-
if not hasattr(model, "__table__"):
124+
if not hasattr(model, "__table__"): # pragma: no cover
125125
raise AttributeError(f"{model.__name__} does not have a '__table__' attribute.")
126126
unique_columns = [column for column in model.__table__.columns if column.unique]
127127
return unique_columns

‎pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "fastcrud"
3-
version = "0.13.1"
3+
version = "0.14.0"
44
description = "FastCRUD is a Python package for FastAPI, offering robust async CRUD operations and flexible endpoint creation utilities."
55
authors = ["Igor Benav <igor.magalhaes.r@gmail.com>"]
66
license = "MIT"

‎tests/sqlalchemy/conftest.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ class TaskRead(TaskReadSub):
285285
client: Optional[ClientRead]
286286

287287

288-
def is_docker_running() -> bool:
288+
def is_docker_running() -> bool: # pragma: no cover
289289
try:
290290
DockerClient()
291291
return True
@@ -316,7 +316,7 @@ async def async_session(request: pytest.FixtureRequest) -> AsyncGenerator[AsyncS
316316
dialect_marker = request.node.get_closest_marker("dialect")
317317
dialect = dialect_marker.args[0] if dialect_marker else "sqlite"
318318
if dialect == "postgresql":
319-
if not is_docker_running():
319+
if not is_docker_running(): # pragma: no cover
320320
pytest.skip("Docker is required, but not running")
321321
with PostgresContainer(driver="psycopg") as pg:
322322
async with _async_session(
@@ -327,7 +327,7 @@ async def async_session(request: pytest.FixtureRequest) -> AsyncGenerator[AsyncS
327327
async with _async_session(url="sqlite+aiosqlite:///:memory:") as session:
328328
yield session
329329
elif dialect == "mysql":
330-
if not is_docker_running():
330+
if not is_docker_running(): # pragma: no cover
331331
pytest.skip("Docker is required, but not running")
332332
with MySqlContainer() as mysql:
333333
async with _async_session(
@@ -336,8 +336,8 @@ async def async_session(request: pytest.FixtureRequest) -> AsyncGenerator[AsyncS
336336
)
337337
) as session:
338338
yield session
339-
else:
340-
raise ValueError(f"Unsupported dialect: {dialect}")
339+
else: # pragma: no cover
340+
raise NotImplementedError(f"Unsupported dialect: {dialect}")
341341

342342

343343
@pytest.fixture(scope="function")

‎tests/sqlmodel/conftest.py

+51-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
from collections.abc import AsyncGenerator
22
from typing import Optional
3+
from contextlib import asynccontextmanager
34

45
import pytest
56
import pytest_asyncio
67
from datetime import datetime
78

89
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
910
from sqlalchemy.orm import sessionmaker
11+
from sqlalchemy import make_url
1012
from pydantic import ConfigDict
1113
from sqlmodel import SQLModel, Field, Relationship
1214
from fastapi import FastAPI
1315
from fastapi.testclient import TestClient
1416
from sqlalchemy.sql import func
17+
from testcontainers.postgres import PostgresContainer
18+
from testcontainers.mysql import MySqlContainer
19+
from testcontainers.core.docker_client import DockerClient
1520

1621
from fastcrud.crud.fast_crud import FastCRUD
1722
from fastcrud.endpoint.crud_router import crud_router
@@ -267,25 +272,60 @@ class TaskRead(TaskReadSub):
267272
client: Optional[ClientRead]
268273

269274

275+
def is_docker_running() -> bool: # pragma: no cover
276+
try:
277+
DockerClient()
278+
return True
279+
except Exception:
280+
return False
281+
282+
270283
async_engine = create_async_engine(
271284
"sqlite+aiosqlite:///:memory:", echo=True, future=True
272285
)
273286

274287

275-
@pytest_asyncio.fixture(scope="function")
276-
async def async_session() -> AsyncGenerator[AsyncSession]:
277-
session = sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
278-
279-
async with session() as s:
280-
async with async_engine.begin() as conn:
288+
@asynccontextmanager
289+
async def _setup_database(url: str) -> AsyncGenerator[AsyncSession]:
290+
engine = create_async_engine(url, echo=True, future=True)
291+
session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
292+
async with session_maker() as session:
293+
async with engine.begin() as conn:
281294
await conn.run_sync(SQLModel.metadata.create_all)
295+
try:
296+
yield session
297+
finally:
298+
async with engine.begin() as conn:
299+
await conn.run_sync(SQLModel.metadata.drop_all)
300+
await engine.dispose()
282301

283-
yield s
284-
285-
async with async_engine.begin() as conn:
286-
await conn.run_sync(SQLModel.metadata.drop_all)
287302

288-
await async_engine.dispose()
303+
@pytest_asyncio.fixture(scope="function")
304+
async def async_session(
305+
request: pytest.FixtureRequest,
306+
) -> AsyncGenerator[AsyncSession]: # pragma: no cover
307+
dialect_marker = request.node.get_closest_marker("dialect")
308+
dialect = dialect_marker.args[0] if dialect_marker else "sqlite"
309+
310+
if dialect == "postgresql" or dialect == "mysql":
311+
if not is_docker_running():
312+
pytest.skip("Docker is required, but not running")
313+
314+
if dialect == "postgresql":
315+
with PostgresContainer() as postgres:
316+
url = postgres.get_connection_url()
317+
async with _setup_database(url) as session:
318+
yield session
319+
elif dialect == "mysql":
320+
with MySqlContainer() as mysql:
321+
url = make_url(mysql.get_connection_url())._replace(
322+
drivername="mysql+aiomysql"
323+
)
324+
async with _setup_database(url) as session:
325+
yield session
326+
else:
327+
async with _setup_database("sqlite+aiosqlite:///:memory:") as session:
328+
yield session
289329

290330

291331
@pytest.fixture(scope="function")

0 commit comments

Comments
 (0)
Please sign in to comment.