Skip to content

Commit 2914e19

Browse files
authored
docs: verify services (#619)
Adjusted service docs
1 parent 3153b49 commit 2914e19

1 file changed

Lines changed: 125 additions & 151 deletions

File tree

docs/usage/services.rst

Lines changed: 125 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -19,47 +19,59 @@ Services provide:
1919
- Automatic schema validation and transformation
2020
- Support for SQLAlchemy query results (Row types) and RowMapping objects
2121

22+
.. note::
23+
24+
The following example assumes the existence of the
25+
``Post`` model defined in :ref:`many_to_many_relationships` and the
26+
``Tag`` model defined in :ref:`using_unique_mixin`.
27+
2228
Basic Service Usage
2329
-------------------
2430

25-
Let's build upon our blog example by creating services for posts and tags:
31+
Let's build upon our blog example by creating services for posts:
2632

2733
.. code-block:: python
2834
2935
import datetime
30-
from typing import Optional, List
31-
from uuid import UUID
36+
from typing import Optional
3237
38+
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
3339
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
3440
from pydantic import BaseModel
3541
3642
# Pydantic schemas for validation
3743
class PostCreate(BaseModel):
3844
title: str
3945
content: str
40-
tag_names: List[str]
46+
tag_names: list[str]
47+
4148
4249
class PostUpdate(BaseModel):
4350
title: Optional[str] = None
4451
content: Optional[str] = None
4552
published: Optional[bool] = None
4653
54+
4755
class PostResponse(BaseModel):
4856
id: int
4957
title: str
5058
content: str
5159
published: bool
52-
published_at: Optional[datetime.datetime]
5360
created_at: datetime.datetime
5461
updated_at: datetime.datetime
55-
tags: List["TagResponse"]
5662
5763
model_config = {"from_attributes": True}
5864
65+
5966
class PostService(SQLAlchemyAsyncRepositoryService[Post]):
60-
"""Service for managing blog posts with automatic schema validation."""
67+
"""Post Service."""
68+
69+
class Repo(SQLAlchemyAsyncRepository[Post]):
70+
"""Post repository."""
6171
62-
repository_type = PostRepository
72+
model_type = Post
73+
74+
repository_type = Repo
6375
6476
Service Operations
6577
------------------
@@ -68,28 +80,17 @@ Services provide high-level methods for common operations:
6880

6981
.. code-block:: python
7082
71-
async def create_post(
72-
post_service: PostService,
73-
data: PostCreate,
74-
) -> PostResponse:
75-
"""Create a post with associated tags."""
76-
post = await post_service.create(
77-
data,
78-
auto_commit=True,
79-
)
83+
async def create_post(post_service: PostService, data: PostCreate) -> PostResponse:
84+
post = await post_service.create(data=data, auto_commit=True)
8085
return post_service.to_schema(post, schema_type=PostResponse)
8186
87+
8288
async def update_post(
8389
post_service: PostService,
8490
post_id: int,
8591
data: PostUpdate,
8692
) -> PostResponse:
87-
"""Update a post."""
88-
post = await post_service.update(
89-
item_id=post_id,
90-
data=data,
91-
auto_commit=True,
92-
)
93+
post = await post_service.update(item_id=post_id, data=data, auto_commit=True)
9394
return post_service.to_schema(post, schema_type=PostResponse)
9495
9596
Composite Primary Keys
@@ -120,118 +121,109 @@ Complex Operations
120121
Services can handle complex business logic involving multiple models.
121122
The code below shows a service coordinating posts and tags.
122123

123-
.. note::
124-
125-
The following example assumes the existence of the
126-
``Post`` model defined in :ref:`many_to_many_relationships` and the
127-
``Tag`` model defined in :ref:`using_unique_mixin`.
128-
129124
.. code-block:: python
130125
131-
from typing import List
132-
133-
from advanced_alchemy.exceptions import ErrorMessages
134-
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
126+
import datetime
127+
from typing import Any
128+
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
129+
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, schema_dump
135130
from advanced_alchemy.service.typing import ModelDictT
131+
from advanced_alchemy.filters import LimitOffset
132+
from advanced_alchemy.service.pagination import OffsetPagination
133+
from advanced_alchemy.utils.text import slugify
134+
135+
class PostService(SQLAlchemyAsyncRepositoryService[Post]):
136+
"""Post service for handling post operations with tag management."""
136137
137-
from .models import Post, Tag
138+
class Repo(SQLAlchemyAsyncRepository[Post]):
139+
"""Post repository."""
138140
139-
class PostService(SQLAlchemyAsyncRepositoryService[Post, PostRepository]):
141+
model_type = Post
140142
141143
loader_options = [Post.tags]
142-
repository_type = PostRepository
143-
match_fields = ["name"]
144+
repository_type = Repo
145+
match_fields = ["title"]
146+
147+
async def to_model_on_create(self, data: ModelDictT[Post]) -> ModelDictT[Post]:
148+
"""Convert and enrich data for post creation, handling tags."""
149+
data = schema_dump(data)
150+
tags_added = data.pop("tags", [])
151+
data = await super().to_model(data)
144152
145-
# Override creation behavior to handle tags
146-
async def create(self, data: ModelDictT[Post], **kwargs) -> Post:
147-
"""Create a new post with tags, if provided."""
148-
tags_added: list[str] = []
149-
if isinstance(data, dict):
150-
data["id"] = data.get("id", uuid4())
151-
tags_added = data.pop("tags", [])
152-
data = await self.to_model(data, "create")
153153
if tags_added:
154154
data.tags.extend(
155155
[
156-
await Tag.as_unique_async(self.repository.session, name=tag_text, slug=slugify(tag_text))
157-
for tag_text in tags_added
156+
await Tag.as_unique_async(self.repository.session, name=tag, slug=slugify(tag))
157+
for tag in tags_added
158158
],
159159
)
160-
return await super().create(data=data, **kwargs)
161-
162-
# Override update behavior to handle tags
163-
async def update(
164-
self,
165-
data: ModelDictT[Post],
166-
item_id: Any | None = None,
167-
**kwargs,
168-
) -> Post:
169-
"""Update a post with tags, if provided."""
170-
tags_updated: list[str] = []
171-
if isinstance(data, dict):
172-
tags_updated.extend(data.pop("tags", None) or [])
173-
data["id"] = item_id
174-
data = await self.to_model(data, "update")
175-
existing_tags = [tag.name for tag in data.tags]
176-
tags_to_remove = [tag for tag in data.tags if tag.name not in tags_updated]
160+
return data
161+
162+
async def to_model_on_update(self, data: ModelDictT[Post]) -> ModelDictT[Post]:
163+
"""Convert and enrich data for post update, handling tags."""
164+
data = schema_dump(data)
165+
tags_updated = data.pop("tags", [])
166+
post = await super().to_model(data)
167+
168+
if tags_updated:
169+
existing_tags = [tag.name for tag in post.tags]
170+
tags_to_remove = [tag for tag in post.tags if tag.name not in tags_updated]
177171
tags_to_add = [tag for tag in tags_updated if tag not in existing_tags]
172+
178173
for tag_rm in tags_to_remove:
179-
data.tags.remove(tag_rm)
180-
data.tags.extend(
174+
post.tags.remove(tag_rm)
175+
176+
post.tags.extend(
181177
[
182-
await Tag.as_unique_async(self.repository.session, name=tag_text, slug=slugify(tag_text))
183-
for tag_text in tags_to_add
178+
await Tag.as_unique_async(self.repository.session, name=tag, slug=slugify(tag))
179+
for tag in tags_to_add
184180
],
185181
)
186-
return await super().update(
187-
data=data,
188-
item_id=item_id,
189-
**kwargs,
190-
)
191-
192-
# A custom write operation
193-
async def publish_post(
194-
self,
195-
post_id: int,
196-
publish: bool = True,
197-
) -> PostResponse:
198-
"""Publish or unpublish a post with timestamp."""
199-
data = PostUpdate(
200-
published=publish,
201-
published_at=datetime.datetime.utcnow() if publish else None,
202-
)
203-
post = await self.repository.update(
204-
item_id=post_id,
205-
data=data,
206-
auto_commit=True,
207-
)
208-
return self.to_schema(post, schema_type=PostResponse)
209-
210-
# A custom read operation
211-
async def get_trending_posts(
212-
self,
213-
days: int = 7,
214-
min_views: int = 100,
215-
) -> List[PostResponse]:
216-
"""Get trending posts based on view count and recency."""
217-
posts = await self.post_service.list(
218-
Post.published == True,
219-
Post.created_at > (datetime.datetime.utcnow() - timedelta(days=days)),
220-
Post.view_count >= min_views,
221-
order_by=[Post.view_count.desc()],
222-
)
223-
return self.post_service.to_schema(posts, schema_type=PostResponse)
224-
225-
# Override the default `to_model` to handle slugs
226-
async def to_model(self, data: ModelDictT[Post], operation: str | None = None) -> Post:
227-
"""Convert a dictionary, msgspec Struct, or Pydantic model to a Post model. """
228-
if (is_msgspec_struct(data) or is_pydantic_model(data)) and operation in {"create", "update"} and data.slug is None:
229-
data.slug = await self.repository.get_available_slug(data.name)
230-
if is_dict(data) and "slug" not in data and operation == "create":
182+
return post
183+
184+
Working with Slugs
185+
------------------
186+
187+
Services can automatically generate URL-friendly slugs using the ``SQLAlchemyAsyncSlugRepository``.
188+
Here's an example service for managing tags with automatic slug generation:
189+
190+
.. code-block:: python
191+
192+
from advanced_alchemy.repository import SQLAlchemyAsyncSlugRepository
193+
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, schema_dump, is_dict_without_field, is_dict_with_field
194+
from advanced_alchemy.service.typing import ModelDictT
195+
196+
class TagService(SQLAlchemyAsyncRepositoryService[Tag]):
197+
"""Tag service with automatic slug generation."""
198+
199+
class Repo(SQLAlchemyAsyncSlugRepository[Tag]):
200+
"""Tag repository."""
201+
202+
model_type = Tag
203+
204+
repository_type = Repo
205+
match_fields = ["name"]
206+
207+
async def to_model_on_create(self, data: ModelDictT[Tag]) -> ModelDictT[Tag]:
208+
"""Generate slug on tag creation if not provided."""
209+
data = schema_dump(data)
210+
if is_dict_without_field(data, "slug") and is_dict_with_field(data, "name"):
231211
data["slug"] = await self.repository.get_available_slug(data["name"])
232-
if is_dict(data) and "slug" not in data and "name" in data and operation == "update":
212+
return data
213+
214+
async def to_model_on_update(self, data: ModelDictT[Tag]) -> ModelDictT[Tag]:
215+
"""Update slug if name changes."""
216+
data = schema_dump(data)
217+
if is_dict_without_field(data, "slug") and is_dict_with_field(data, "name"):
233218
data["slug"] = await self.repository.get_available_slug(data["name"])
234-
return await super().to_model(data, operation)
219+
return data
220+
221+
async def to_model_on_upsert(self, data: ModelDictT[Tag]) -> ModelDictT[Tag]:
222+
"""Generate slug on upsert if needed."""
223+
data = schema_dump(data)
224+
if is_dict_without_field(data, "slug") and (tag_name := data.get("name")) is not None:
225+
data["slug"] = await self.repository.get_available_slug(tag_name)
226+
return data
235227
236228
Schema Integration
237229
------------------
@@ -246,15 +238,16 @@ Pydantic Models
246238
from pydantic import BaseModel
247239
from typing import Optional
248240
249-
class UserSchema(BaseModel):
250-
name: str
251-
email: str
252-
age: Optional[int] = None
241+
class PostSchema(BaseModel):
242+
id: int
243+
title: str
244+
content: str
245+
published: bool
253246
254247
model_config = {"from_attributes": True}
255248
256249
# Convert database model to Pydantic schema
257-
user_data = service.to_schema(user_model, schema_type=UserSchema)
250+
post_data = post_service.to_schema(post_model, schema_type=PostSchema)
258251
259252
Msgspec Structs
260253
***************
@@ -264,13 +257,14 @@ Msgspec Structs
264257
from msgspec import Struct
265258
from typing import Optional
266259
267-
class UserStruct(Struct):
268-
name: str
269-
email: str
270-
age: Optional[int] = None
260+
class PostStruct(Struct):
261+
id: int
262+
title: str
263+
content: str
264+
published: bool
271265
272266
# Convert database model to Msgspec struct
273-
user_data = service.to_schema(user_model, schema_type=UserStruct)
267+
post_data = post_service.to_schema(post_model, schema_type=PostStruct)
274268
275269
Attrs Classes
276270
*************
@@ -281,13 +275,14 @@ Attrs Classes
281275
from typing import Optional
282276
283277
@define
284-
class UserAttrs:
285-
name: str
286-
email: str
287-
age: Optional[int] = None
278+
class PostAttrs:
279+
id: int
280+
title: str
281+
content: str
282+
published: bool
288283
289284
# Convert database model to attrs class
290-
user_data = service.to_schema(user_model, schema_type=UserAttrs)
285+
post_data = post_service.to_schema(post_model, schema_type=PostAttrs)
291286
292287
.. note::
293288

@@ -296,27 +291,6 @@ Attrs Classes
296291
for improved performance and type-aware serialization. This provides better handling of
297292
complex types, nested structures, and custom converters.
298293

299-
SQLAlchemy Query Result Support
300-
*******************************
301-
302-
Services now provide comprehensive support for SQLAlchemy query results:
303-
304-
.. code-block:: python
305-
306-
from sqlalchemy import select
307-
308-
# Direct support for SQLAlchemy Row objects
309-
query_results = await session.execute(select(User))
310-
rows = query_results.fetchall() # Returns list[Row[Any]]
311-
312-
# Convert Row objects to schema types
313-
user_data = service.to_schema(rows[0], schema_type=UserSchema)
314-
users_paginated = service.to_schema(rows, schema_type=UserSchema)
315-
316-
# Also supports RowMapping objects
317-
row_mapping_results = await session.execute(select(User)).mappings()
318-
mapping_data = service.to_schema(row_mapping_results.first(), schema_type=UserSchema)
319-
320294

321295
Framework Integration
322296
---------------------

0 commit comments

Comments
 (0)