Skip to content

Commit ed202ef

Browse files
amosttAygentic
andcommitted
feat(entity): add Entity models, service layer, and database migration [AYG-69]
Implements the entity resource foundation for the microservice starter template: - Pure Pydantic models: EntityBase, EntityCreate, EntityUpdate, EntityPublic, EntitiesPublic with field validation (title 1-255 chars, description <= 1000) - Service layer with CRUD operations via supabase-py table builder pattern: create_entity, get_entity, list_entities, update_entity, delete_entity - All operations enforce ownership via owner_id filtering - Pagination with offset/limit (capped at 100, clamped to valid range) - Structured error handling: APIError → 404, infrastructure errors → 500 - Generic error messages (no exception details leaked to clients) - Supabase SQL migration: entities table, owner index, updated_at trigger, RLS policies with WITH CHECK on UPDATE - 34 unit tests covering happy paths, edge cases, and error handling Fixes AYG-69 Related to AYG-64 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com>
1 parent f717ed9 commit ed202ef

File tree

8 files changed

+1025
-0
lines changed

8 files changed

+1025
-0
lines changed

backend/app/models/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Import from here to avoid deep import paths in consuming modules::
44
55
from app.models import ErrorResponse, PaginatedResponse, Principal
6+
from app.models import EntityCreate, EntityPublic, EntitiesPublic
67
"""
78

89
from app.models.auth import Principal
@@ -12,8 +13,18 @@
1213
ValidationErrorDetail,
1314
ValidationErrorResponse,
1415
)
16+
from app.models.entity import (
17+
EntitiesPublic,
18+
EntityCreate,
19+
EntityPublic,
20+
EntityUpdate,
21+
)
1522

1623
__all__ = [
24+
"EntitiesPublic",
25+
"EntityCreate",
26+
"EntityPublic",
27+
"EntityUpdate",
1728
"ErrorResponse",
1829
"PaginatedResponse",
1930
"Principal",

backend/app/models/entity.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Entity Pydantic models.
2+
3+
Defines the data shapes for the Entity resource used across API request
4+
validation, response serialisation, and service-layer contracts.
5+
6+
All models are pure Pydantic BaseModel (not SQLModel) because persistence
7+
is handled via the Supabase REST client rather than an ORM.
8+
"""
9+
10+
from datetime import datetime
11+
from uuid import UUID
12+
13+
from pydantic import BaseModel, Field
14+
15+
16+
class EntityBase(BaseModel):
17+
"""Shared fields for all entity representations."""
18+
19+
title: str = Field(min_length=1, max_length=255)
20+
"""Human-readable entity title. Required, 1–255 characters."""
21+
22+
description: str | None = Field(default=None, max_length=1000)
23+
"""Optional freeform description. Maximum 1000 characters."""
24+
25+
26+
class EntityCreate(EntityBase):
27+
"""Payload for creating a new entity.
28+
29+
Inherits ``title`` (required) and ``description`` (optional) from
30+
:class:`EntityBase`.
31+
"""
32+
33+
34+
class EntityUpdate(BaseModel):
35+
"""Payload for partially updating an existing entity.
36+
37+
Does NOT inherit :class:`EntityBase` so that every field is optional,
38+
enabling true partial-update (PATCH) semantics.
39+
"""
40+
41+
title: str | None = Field(default=None, min_length=1, max_length=255)
42+
"""Updated title. Must be 1–255 characters if provided."""
43+
44+
description: str | None = Field(default=None, max_length=1000)
45+
"""Updated description. Maximum 1000 characters if provided."""
46+
47+
48+
class EntityPublic(EntityBase):
49+
"""Full entity representation returned to API consumers."""
50+
51+
id: UUID
52+
"""Unique identifier assigned by the database."""
53+
54+
owner_id: str
55+
"""Clerk user ID of the entity owner."""
56+
57+
created_at: datetime
58+
"""UTC timestamp of entity creation."""
59+
60+
updated_at: datetime
61+
"""UTC timestamp of the most recent entity update."""
62+
63+
64+
class EntitiesPublic(BaseModel):
65+
"""Paginated collection of entities returned to API consumers."""
66+
67+
data: list[EntityPublic]
68+
"""Ordered list of entity records for the current page."""
69+
70+
count: int
71+
"""Total number of entities matching the query (for pagination)."""

backend/app/services/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Service layer modules."""
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""Entity business logic and Supabase CRUD operations.
2+
3+
Provides service-layer functions for entity lifecycle management.
4+
All functions accept a supabase Client as the first argument;
5+
dependency injection happens at the route handler level.
6+
"""
7+
8+
from postgrest.exceptions import APIError
9+
from supabase import Client
10+
11+
from app.core.errors import ServiceError
12+
from app.core.logging import get_logger
13+
from app.models import EntitiesPublic, EntityCreate, EntityPublic, EntityUpdate
14+
15+
logger = get_logger(module=__name__)
16+
17+
_TABLE = "entities"
18+
19+
# Maximum number of entities that can be fetched in a single list call.
20+
_MAX_LIMIT = 100
21+
22+
23+
def create_entity(supabase: Client, data: EntityCreate, owner_id: str) -> EntityPublic:
24+
"""Insert a new entity owned by owner_id. Returns EntityPublic.
25+
26+
Args:
27+
supabase: Authenticated Supabase client.
28+
data: Validated creation payload.
29+
owner_id: Clerk user ID that will own the new entity.
30+
31+
Returns:
32+
The newly created entity as :class:`~app.models.entity.EntityPublic`.
33+
34+
Raises:
35+
ServiceError: 500 if the Supabase operation fails.
36+
"""
37+
payload = {
38+
"title": data.title,
39+
"description": data.description,
40+
"owner_id": owner_id,
41+
}
42+
try:
43+
response = supabase.table(_TABLE).insert(payload).execute()
44+
except Exception as exc:
45+
logger.error("Failed to create entity", error=str(exc))
46+
raise ServiceError(
47+
status_code=500,
48+
message="Failed to create entity",
49+
code="ENTITY_CREATE_FAILED",
50+
) from exc
51+
52+
if not response.data:
53+
raise ServiceError(
54+
status_code=500,
55+
message="Entity insert returned no data",
56+
code="ENTITY_CREATE_FAILED",
57+
)
58+
59+
return EntityPublic(**response.data[0]) # type: ignore[arg-type]
60+
61+
62+
def get_entity(supabase: Client, entity_id: str, owner_id: str) -> EntityPublic:
63+
"""Fetch a single entity by ID and owner.
64+
65+
Args:
66+
supabase: Authenticated Supabase client.
67+
entity_id: UUID string of the entity to retrieve.
68+
owner_id: Clerk user ID used to enforce ownership.
69+
70+
Returns:
71+
The matching entity as :class:`~app.models.entity.EntityPublic`.
72+
73+
Raises:
74+
ServiceError: 404 if the entity does not exist or is not owned by owner_id.
75+
ServiceError: 500 if a database or network error occurs.
76+
"""
77+
try:
78+
response = (
79+
supabase.table(_TABLE)
80+
.select("*")
81+
.eq("id", entity_id)
82+
.eq("owner_id", owner_id)
83+
.single()
84+
.execute()
85+
)
86+
except APIError as exc:
87+
raise ServiceError(
88+
status_code=404,
89+
message="Entity not found",
90+
code="ENTITY_NOT_FOUND",
91+
) from exc
92+
except Exception as exc:
93+
logger.error("Failed to get entity", entity_id=entity_id, error=str(exc))
94+
raise ServiceError(
95+
status_code=500,
96+
message="Failed to retrieve entity",
97+
code="ENTITY_GET_FAILED",
98+
) from exc
99+
100+
return EntityPublic(**response.data) # type: ignore[arg-type]
101+
102+
103+
def list_entities(
104+
supabase: Client,
105+
owner_id: str,
106+
*,
107+
offset: int = 0,
108+
limit: int = 20,
109+
) -> EntitiesPublic:
110+
"""List entities for owner with pagination. Caps limit at 100.
111+
112+
Args:
113+
supabase: Authenticated Supabase client.
114+
owner_id: Clerk user ID used to filter entities by ownership.
115+
offset: Zero-based index of the first record to return (default 0).
116+
limit: Maximum number of records to return (default 20, capped at 100).
117+
118+
Returns:
119+
:class:`~app.models.entity.EntitiesPublic` with ``data`` list and total ``count``.
120+
121+
Raises:
122+
ServiceError: 500 if the Supabase operation fails.
123+
"""
124+
offset = max(0, offset)
125+
limit = max(1, min(limit, _MAX_LIMIT))
126+
end = offset + limit - 1
127+
128+
try:
129+
response = (
130+
supabase.table(_TABLE)
131+
.select("*", count="exact") # type: ignore[arg-type]
132+
.eq("owner_id", owner_id)
133+
.range(offset, end)
134+
.execute()
135+
)
136+
except Exception as exc:
137+
logger.error("Failed to list entities", error=str(exc))
138+
raise ServiceError(
139+
status_code=500,
140+
message="Failed to list entities",
141+
code="ENTITY_LIST_FAILED",
142+
) from exc
143+
144+
items = [EntityPublic(**row) for row in response.data] # type: ignore[arg-type]
145+
return EntitiesPublic(data=items, count=response.count or 0)
146+
147+
148+
def update_entity(
149+
supabase: Client,
150+
entity_id: str,
151+
owner_id: str,
152+
data: EntityUpdate,
153+
) -> EntityPublic:
154+
"""Partially update an entity. Raises ServiceError(404) if not found or not owned.
155+
156+
When ``data`` contains no fields (all values are unset), the function skips the
157+
UPDATE call and returns the current entity unchanged.
158+
159+
Args:
160+
supabase: Authenticated Supabase client.
161+
entity_id: UUID string of the entity to update.
162+
owner_id: Clerk user ID used to enforce ownership.
163+
data: Partial update payload. Only provided fields are written.
164+
165+
Returns:
166+
The updated (or unchanged) entity as :class:`~app.models.entity.EntityPublic`.
167+
168+
Raises:
169+
ServiceError: 404 if the entity does not exist or is not owned by owner_id.
170+
ServiceError: 500 if the Supabase operation fails.
171+
"""
172+
fields = data.model_dump(exclude_unset=True)
173+
174+
# No-op: no fields provided — fetch and return the current entity.
175+
if not fields:
176+
return get_entity(supabase, entity_id, owner_id)
177+
178+
try:
179+
response = (
180+
supabase.table(_TABLE)
181+
.update(fields)
182+
.eq("id", entity_id)
183+
.eq("owner_id", owner_id)
184+
.execute()
185+
)
186+
except Exception as exc:
187+
logger.error("Failed to update entity", entity_id=entity_id, error=str(exc))
188+
raise ServiceError(
189+
status_code=500,
190+
message="Failed to update entity",
191+
code="ENTITY_UPDATE_FAILED",
192+
) from exc
193+
194+
if not response.data:
195+
raise ServiceError(
196+
status_code=404,
197+
message="Entity not found",
198+
code="ENTITY_NOT_FOUND",
199+
)
200+
201+
return EntityPublic(**response.data[0]) # type: ignore[arg-type]
202+
203+
204+
def delete_entity(supabase: Client, entity_id: str, owner_id: str) -> None:
205+
"""Delete an entity. Raises ServiceError(404) if not found or not owned.
206+
207+
Args:
208+
supabase: Authenticated Supabase client.
209+
entity_id: UUID string of the entity to delete.
210+
owner_id: Clerk user ID used to enforce ownership.
211+
212+
Returns:
213+
None on success.
214+
215+
Raises:
216+
ServiceError: 404 if the entity does not exist or is not owned by owner_id.
217+
ServiceError: 500 if the Supabase operation fails.
218+
"""
219+
try:
220+
response = (
221+
supabase.table(_TABLE)
222+
.delete()
223+
.eq("id", entity_id)
224+
.eq("owner_id", owner_id)
225+
.execute()
226+
)
227+
except Exception as exc:
228+
logger.error("Failed to delete entity", entity_id=entity_id, error=str(exc))
229+
raise ServiceError(
230+
status_code=500,
231+
message="Failed to delete entity",
232+
code="ENTITY_DELETE_FAILED",
233+
) from exc
234+
235+
if not response.data:
236+
raise ServiceError(
237+
status_code=404,
238+
message="Entity not found",
239+
code="ENTITY_NOT_FOUND",
240+
)

0 commit comments

Comments
 (0)