Replace inter-service _handlers dicts with declarative sx defquery/defaction
The inter-service data layer (fetch_data/call_action) was the least structured part of the codebase — Python _handlers dicts with ad-hoc param extraction scattered across 16 route files. This replaces them with declarative .sx query/action definitions that make the entire inter-service protocol self-describing and greppable. Infrastructure: - defquery/defaction special forms in the sx evaluator - Query/action registry with load, lookup, and schema introspection - Query executor using async_eval with I/O primitives - Blueprint factories (create_data_blueprint/create_action_blueprint) with sx-first dispatch and Python fallback - /internal/schema endpoint on every service - parse-datetime and split-ids primitives for type coercion Service extractions: - LikesService (toggle, is_liked, liked_slugs, liked_ids) - PageConfigService (ensure, get_by_container, get_by_id, get_batch, update) - RelationsService (wraps module-level functions) - AccountDataService (user_by_email, newsletters) - CartItemsService, MarketDataService (raw SQLAlchemy lookups) 50 of 54 handlers converted to sx, 4 Python fallbacks remain (ghost-sync/push-member, clear-cart-for-order, create-order). Net: -1,383 lines Python, +251 lines modified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
shared/services/account_impl.py
Normal file
42
shared/services/account_impl.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Service methods for account data queries.
|
||||
|
||||
Extracted from account/bp/data/routes.py to enable sx defquery conversion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models import User
|
||||
|
||||
|
||||
class SqlAccountDataService:
|
||||
|
||||
async def user_by_email(
|
||||
self, session: AsyncSession, *, email: str,
|
||||
) -> dict[str, Any] | None:
|
||||
email = (email or "").strip().lower()
|
||||
if not email:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(User.id).where(User.email.ilike(email))
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
return {"user_id": row[0]}
|
||||
|
||||
async def newsletters(self, session: AsyncSession) -> list[dict[str, Any]]:
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
result = await session.execute(
|
||||
select(
|
||||
GhostNewsletter.id, GhostNewsletter.ghost_id,
|
||||
GhostNewsletter.name, GhostNewsletter.slug,
|
||||
).order_by(GhostNewsletter.name)
|
||||
)
|
||||
return [
|
||||
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
|
||||
for row in result.all()
|
||||
]
|
||||
37
shared/services/cart_items_impl.py
Normal file
37
shared/services/cart_items_impl.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Extra cart query methods not in the CartService protocol.
|
||||
|
||||
cart-items returns raw CartItem data without going through CartSummaryDTO.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.market import CartItem
|
||||
|
||||
|
||||
class SqlCartItemsService:
|
||||
|
||||
async def cart_items(
|
||||
self, session: AsyncSession, *,
|
||||
user_id: int | None = None, session_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
filters = [CartItem.deleted_at.is_(None)]
|
||||
if user_id is not None:
|
||||
filters.append(CartItem.user_id == user_id)
|
||||
elif session_id is not None:
|
||||
filters.append(CartItem.session_id == session_id)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await session.execute(select(CartItem).where(*filters))
|
||||
return [
|
||||
{
|
||||
"product_id": item.product_id,
|
||||
"product_slug": item.product_slug,
|
||||
"quantity": item.quantity,
|
||||
}
|
||||
for item in result.scalars().all()
|
||||
]
|
||||
103
shared/services/likes_impl.py
Normal file
103
shared/services/likes_impl.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""SQL implementation of the LikesService protocol.
|
||||
|
||||
Extracted from likes/bp/data/routes.py and likes/bp/actions/routes.py
|
||||
to enable sx defquery/defaction conversion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from likes.models.like import Like
|
||||
|
||||
|
||||
class SqlLikesService:
|
||||
|
||||
async def is_liked(
|
||||
self, session: AsyncSession, *,
|
||||
user_id: int, target_type: str,
|
||||
target_slug: str | None = None, target_id: int | None = None,
|
||||
) -> bool:
|
||||
if not user_id or not target_type:
|
||||
return False
|
||||
filters = [
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.deleted_at.is_(None),
|
||||
]
|
||||
if target_slug is not None:
|
||||
filters.append(Like.target_slug == target_slug)
|
||||
elif target_id is not None:
|
||||
filters.append(Like.target_id == target_id)
|
||||
else:
|
||||
return False
|
||||
row = await session.scalar(select(Like.id).where(*filters))
|
||||
return row is not None
|
||||
|
||||
async def liked_slugs(
|
||||
self, session: AsyncSession, *,
|
||||
user_id: int, target_type: str,
|
||||
) -> list[str]:
|
||||
if not user_id or not target_type:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(Like.target_slug).where(
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.target_slug.isnot(None),
|
||||
Like.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def liked_ids(
|
||||
self, session: AsyncSession, *,
|
||||
user_id: int, target_type: str,
|
||||
) -> list[int]:
|
||||
if not user_id or not target_type:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(Like.target_id).where(
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.target_id.isnot(None),
|
||||
Like.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def toggle(
|
||||
self, session: AsyncSession, *,
|
||||
user_id: int, target_type: str,
|
||||
target_slug: str | None = None, target_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Toggle a like. Returns True if now liked, False if unliked."""
|
||||
filters = [
|
||||
Like.user_id == user_id,
|
||||
Like.target_type == target_type,
|
||||
Like.deleted_at.is_(None),
|
||||
]
|
||||
if target_slug is not None:
|
||||
filters.append(Like.target_slug == target_slug)
|
||||
elif target_id is not None:
|
||||
filters.append(Like.target_id == target_id)
|
||||
else:
|
||||
raise ValueError("target_slug or target_id required")
|
||||
|
||||
existing = await session.scalar(select(Like).where(*filters))
|
||||
|
||||
if existing:
|
||||
await session.execute(
|
||||
update(Like).where(Like.id == existing.id).values(deleted_at=func.now())
|
||||
)
|
||||
return False
|
||||
else:
|
||||
new_like = Like(
|
||||
user_id=user_id,
|
||||
target_type=target_type,
|
||||
target_slug=target_slug,
|
||||
target_id=target_id,
|
||||
)
|
||||
session.add(new_like)
|
||||
await session.flush()
|
||||
return True
|
||||
55
shared/services/market_data_impl.py
Normal file
55
shared/services/market_data_impl.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Extra market query methods for raw-SQLAlchemy data lookups.
|
||||
|
||||
products-by-ids and marketplaces-by-ids use direct selects rather
|
||||
than the MarketService protocol methods.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class SqlMarketDataService:
|
||||
|
||||
async def products_by_ids(
|
||||
self, session: AsyncSession, *, ids: list[int],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not ids:
|
||||
return []
|
||||
from shared.models.market import Product
|
||||
rows = (await session.execute(
|
||||
select(Product).where(Product.id.in_(ids))
|
||||
)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"title": p.title,
|
||||
"slug": p.slug,
|
||||
"image": p.image,
|
||||
"regular_price": str(p.regular_price) if p.regular_price is not None else None,
|
||||
"special_price": str(p.special_price) if p.special_price is not None else None,
|
||||
}
|
||||
for p in rows
|
||||
]
|
||||
|
||||
async def marketplaces_by_ids(
|
||||
self, session: AsyncSession, *, ids: list[int],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not ids:
|
||||
return []
|
||||
from shared.models.market_place import MarketPlace
|
||||
rows = (await session.execute(
|
||||
select(MarketPlace).where(MarketPlace.id.in_(ids))
|
||||
)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"slug": m.slug,
|
||||
"container_type": m.container_type,
|
||||
"container_id": m.container_id,
|
||||
}
|
||||
for m in rows
|
||||
]
|
||||
137
shared/services/page_config_impl.py
Normal file
137
shared/services/page_config_impl.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""SQL implementation of PageConfig service methods.
|
||||
|
||||
Extracted from blog/bp/data/routes.py and blog/bp/actions/routes.py
|
||||
to enable sx defquery/defaction conversion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
|
||||
def _to_dict(pc: PageConfig) -> dict[str, Any]:
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
|
||||
class SqlPageConfigService:
|
||||
|
||||
async def ensure(
|
||||
self, session: AsyncSession, *,
|
||||
container_type: str = "page", container_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Get or create a PageConfig. Returns minimal dict with id."""
|
||||
row = (await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
async def get_by_container(
|
||||
self, session: AsyncSession, *,
|
||||
container_type: str = "page", container_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
pc = (await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
return _to_dict(pc) if pc else None
|
||||
|
||||
async def get_by_id(
|
||||
self, session: AsyncSession, *, id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
pc = await session.get(PageConfig, id)
|
||||
return _to_dict(pc) if pc else None
|
||||
|
||||
async def get_batch(
|
||||
self, session: AsyncSession, *,
|
||||
container_type: str = "page", ids: list[int],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not ids:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_to_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
async def update(
|
||||
self, session: AsyncSession, *,
|
||||
container_type: str = "page", container_id: int,
|
||||
features: dict | None = None,
|
||||
sumup_merchant_code: str | None = None,
|
||||
sumup_checkout_prefix: str | None = None,
|
||||
sumup_api_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
pc = (await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=features or {},
|
||||
)
|
||||
session.add(pc)
|
||||
await session.flush()
|
||||
|
||||
if features is not None:
|
||||
merged = dict(pc.features or {})
|
||||
for key, val in features.items():
|
||||
if isinstance(val, bool):
|
||||
merged[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
merged[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
merged[key] = False
|
||||
pc.features = merged
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if sumup_merchant_code is not None:
|
||||
pc.sumup_merchant_code = sumup_merchant_code or None
|
||||
if sumup_checkout_prefix is not None:
|
||||
pc.sumup_checkout_prefix = sumup_checkout_prefix or None
|
||||
if sumup_api_key is not None:
|
||||
pc.sumup_api_key = sumup_api_key or None
|
||||
|
||||
await session.flush()
|
||||
|
||||
result = _to_dict(pc)
|
||||
result["sumup_configured"] = bool(pc.sumup_api_key)
|
||||
return result
|
||||
@@ -23,6 +23,7 @@ from shared.contracts.protocols import (
|
||||
CartService,
|
||||
FederationService,
|
||||
)
|
||||
from shared.contracts.likes import LikesService
|
||||
|
||||
|
||||
class _ServiceRegistry:
|
||||
@@ -38,6 +39,7 @@ class _ServiceRegistry:
|
||||
self._market: MarketService | None = None
|
||||
self._cart: CartService | None = None
|
||||
self._federation: FederationService | None = None
|
||||
self._likes: LikesService | None = None
|
||||
self._extra: dict[str, Any] = {}
|
||||
|
||||
# -- calendar -------------------------------------------------------------
|
||||
@@ -73,6 +75,17 @@ class _ServiceRegistry:
|
||||
def cart(self, impl: CartService) -> None:
|
||||
self._cart = impl
|
||||
|
||||
# -- likes ----------------------------------------------------------------
|
||||
@property
|
||||
def likes(self) -> LikesService:
|
||||
if self._likes is None:
|
||||
raise RuntimeError("LikesService not registered")
|
||||
return self._likes
|
||||
|
||||
@likes.setter
|
||||
def likes(self, impl: LikesService) -> None:
|
||||
self._likes = impl
|
||||
|
||||
# -- federation -----------------------------------------------------------
|
||||
@property
|
||||
def federation(self) -> FederationService:
|
||||
|
||||
164
shared/services/relations_impl.py
Normal file
164
shared/services/relations_impl.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Service wrapper for relations module functions.
|
||||
|
||||
Wraps the module-level functions in shared.services.relationships into
|
||||
a class so they can be called via the ``(service "relations" ...)`` primitive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
def _serialize_rel(r) -> dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"parent_type": r.parent_type,
|
||||
"parent_id": r.parent_id,
|
||||
"child_type": r.child_type,
|
||||
"child_id": r.child_id,
|
||||
"sort_order": r.sort_order,
|
||||
"label": r.label,
|
||||
"relation_type": r.relation_type,
|
||||
"metadata": r.metadata_,
|
||||
}
|
||||
|
||||
|
||||
class SqlRelationsService:
|
||||
|
||||
async def get_children(
|
||||
self, session: AsyncSession, *,
|
||||
parent_type: str, parent_id: int,
|
||||
child_type: str | None = None,
|
||||
relation_type: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
from shared.services.relationships import get_children
|
||||
rels = await get_children(
|
||||
session, parent_type, parent_id, child_type,
|
||||
relation_type=relation_type,
|
||||
)
|
||||
return [_serialize_rel(r) for r in rels]
|
||||
|
||||
async def get_parents(
|
||||
self, session: AsyncSession, *,
|
||||
child_type: str, child_id: int,
|
||||
parent_type: str | None = None,
|
||||
relation_type: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
from shared.services.relationships import get_parents
|
||||
rels = await get_parents(
|
||||
session, child_type, child_id, parent_type,
|
||||
relation_type=relation_type,
|
||||
)
|
||||
return [_serialize_rel(r) for r in rels]
|
||||
|
||||
async def attach_child(
|
||||
self, session: AsyncSession, *,
|
||||
parent_type: str, parent_id: int,
|
||||
child_type: str, child_id: int,
|
||||
label: str | None = None,
|
||||
sort_order: int | None = None,
|
||||
relation_type: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from shared.services.relationships import attach_child
|
||||
rel = await attach_child(
|
||||
session,
|
||||
parent_type=parent_type, parent_id=parent_id,
|
||||
child_type=child_type, child_id=child_id,
|
||||
label=label, sort_order=sort_order,
|
||||
relation_type=relation_type, metadata=metadata,
|
||||
)
|
||||
return _serialize_rel(rel)
|
||||
|
||||
async def detach_child(
|
||||
self, session: AsyncSession, *,
|
||||
parent_type: str, parent_id: int,
|
||||
child_type: str, child_id: int,
|
||||
relation_type: str | None = None,
|
||||
) -> bool:
|
||||
from shared.services.relationships import detach_child
|
||||
return await detach_child(
|
||||
session,
|
||||
parent_type=parent_type, parent_id=parent_id,
|
||||
child_type=child_type, child_id=child_id,
|
||||
relation_type=relation_type,
|
||||
)
|
||||
|
||||
async def relate(
|
||||
self, session: AsyncSession, *,
|
||||
relation_type: str,
|
||||
from_id: int, to_id: int,
|
||||
label: str | None = None,
|
||||
sort_order: int | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Registry-aware relation creation with cardinality enforcement."""
|
||||
from shared.services.relationships import attach_child, get_children
|
||||
from shared.sx.relations import get_relation
|
||||
|
||||
defn = get_relation(relation_type)
|
||||
if defn is None:
|
||||
raise ValueError(f"unknown relation_type: {relation_type}")
|
||||
|
||||
if defn.cardinality == "one-to-one":
|
||||
existing = await get_children(
|
||||
session,
|
||||
parent_type=defn.from_type,
|
||||
parent_id=from_id,
|
||||
child_type=defn.to_type,
|
||||
relation_type=relation_type,
|
||||
)
|
||||
if existing:
|
||||
raise ValueError("one-to-one relation already exists")
|
||||
|
||||
rel = await attach_child(
|
||||
session,
|
||||
parent_type=defn.from_type, parent_id=from_id,
|
||||
child_type=defn.to_type, child_id=to_id,
|
||||
label=label, sort_order=sort_order,
|
||||
relation_type=relation_type, metadata=metadata,
|
||||
)
|
||||
return _serialize_rel(rel)
|
||||
|
||||
async def unrelate(
|
||||
self, session: AsyncSession, *,
|
||||
relation_type: str, from_id: int, to_id: int,
|
||||
) -> bool:
|
||||
from shared.services.relationships import detach_child
|
||||
from shared.sx.relations import get_relation
|
||||
|
||||
defn = get_relation(relation_type)
|
||||
if defn is None:
|
||||
raise ValueError(f"unknown relation_type: {relation_type}")
|
||||
|
||||
return await detach_child(
|
||||
session,
|
||||
parent_type=defn.from_type, parent_id=from_id,
|
||||
child_type=defn.to_type, child_id=to_id,
|
||||
relation_type=relation_type,
|
||||
)
|
||||
|
||||
async def can_relate(
|
||||
self, session: AsyncSession, *,
|
||||
relation_type: str, from_id: int,
|
||||
) -> dict[str, Any]:
|
||||
from shared.services.relationships import get_children
|
||||
from shared.sx.relations import get_relation
|
||||
|
||||
defn = get_relation(relation_type)
|
||||
if defn is None:
|
||||
return {"allowed": False, "reason": f"unknown relation_type: {relation_type}"}
|
||||
|
||||
if defn.cardinality == "one-to-one":
|
||||
existing = await get_children(
|
||||
session,
|
||||
parent_type=defn.from_type,
|
||||
parent_id=from_id,
|
||||
child_type=defn.to_type,
|
||||
relation_type=relation_type,
|
||||
)
|
||||
if existing:
|
||||
return {"allowed": False, "reason": "one-to-one relation already exists"}
|
||||
|
||||
return {"allowed": True}
|
||||
Reference in New Issue
Block a user