From 5236544148aa2c044da6ffe6e0d533e490d337e3 Mon Sep 17 00:00:00 2001 From: manavgup Date: Mon, 30 Mar 2026 17:08:05 -0400 Subject: [PATCH] Phase 5: Simple mode entity templates (9 templates) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 Jinja2 templates that generate per-entity code: - model.py.j2 — SQLAlchemy model with enums, FKs, relationships, TYPE_CHECKING imports, all 13 field types - schema.py.j2 — Pydantic v2 Create/Update/Response/DetailResponse with nested relationships and audit fields - repository.py.j2 — extends SqlAlchemyRepository, optional search() - service.py.j2 — extends CrudService with lifecycle hook stubs - router.py.j2 — FastAPI CRUD endpoints with query param filtering - factory.py.j2 — polyfactory ModelFactory - fake_repository.py.j2 — in-memory dict-based Protocol implementation - test_unit_service.py.j2 — service tests with fake repository - test_integration.py.j2 — API endpoint test stubs 56 new tests (337 total) verifying: - All 9 templates render valid Python for User/Post/Category/Tag entities - Cross-template consistency (names, imports, classes) - Model: tablename, base class, enums, FKs, relationships - Schema: four classes, required fields, response ID - Repository: inherits SqlAlchemyRepository, conditional search - All templates handle minimal entities (single field) cleanly Part of Phase 5 in #1 Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/simple/factory.py.j2 | 11 + templates/simple/fake_repository.py.j2 | 46 ++++ templates/simple/model.py.j2 | 241 ++++++++++++++++++ templates/simple/repository.py.j2 | 50 ++++ templates/simple/router.py.j2 | 57 +++++ templates/simple/schema.py.j2 | 148 +++++++++++ templates/simple/service.py.j2 | 29 +++ templates/simple/test_integration.py.j2 | 33 +++ templates/simple/test_unit_service.py.j2 | 69 ++++++ tests/test_templates/test_simple_mode.py | 301 +++++++++++++++++++++++ 10 files changed, 985 insertions(+) create mode 100644 templates/simple/factory.py.j2 create mode 100644 templates/simple/fake_repository.py.j2 create mode 100644 templates/simple/model.py.j2 create mode 100644 templates/simple/repository.py.j2 create mode 100644 templates/simple/router.py.j2 create mode 100644 templates/simple/schema.py.j2 create mode 100644 templates/simple/service.py.j2 create mode 100644 templates/simple/test_integration.py.j2 create mode 100644 templates/simple/test_unit_service.py.j2 create mode 100644 tests/test_templates/test_simple_mode.py diff --git a/templates/simple/factory.py.j2 b/templates/simple/factory.py.j2 new file mode 100644 index 0000000..1d9e356 --- /dev/null +++ b/templates/simple/factory.py.j2 @@ -0,0 +1,11 @@ +"""{{ entity.name }} test factories.""" + +from polyfactory.factories.pydantic_factory import ModelFactory + +from app.schemas.{{ entity.name | lower }} import {{ entity.name }}Create + + +class {{ entity.name }}CreateFactory(ModelFactory): + """Factory for generating {{ entity.name }}Create test data.""" + + __model__ = {{ entity.name }}Create diff --git a/templates/simple/fake_repository.py.j2 b/templates/simple/fake_repository.py.j2 new file mode 100644 index 0000000..0b9ddf6 --- /dev/null +++ b/templates/simple/fake_repository.py.j2 @@ -0,0 +1,46 @@ +"""Fake {{ entity.name }} repository for unit testing.""" + +import uuid +from uuid import UUID + +from faststack_core.exceptions.domain import NotFoundError + +from app.models.{{ entity.name | lower }} import {{ entity.name }} + + +class Fake{{ entity.name }}Repository: + """In-memory {{ entity.name }} repository satisfying the Repository Protocol. + + Uses a dict as backing store. No database required. + """ + + def __init__(self) -> None: + self._store: dict[UUID, {{ entity.name }}] = {} + + async def get_by_id(self, id: UUID) -> {{ entity.name }} | None: + return self._store.get(id) + + async def list(self, skip: int = 0, limit: int = 100) -> list[{{ entity.name }}]: + items = list(self._store.values()) + return items[skip : skip + limit] + + async def create(self, data: dict) -> {{ entity.name }}: + entity = {{ entity.name }}(id=uuid.uuid4(), **data) + self._store[entity.id] = entity + return entity + + async def update(self, id: UUID, data: dict) -> {{ entity.name }}: + entity = self._store.get(id) + if not entity: + raise NotFoundError("{{ entity.name }} not found") + for key, value in data.items(): + setattr(entity, key, value) + return entity + + async def delete(self, id: UUID) -> None: + if id not in self._store: + raise NotFoundError("{{ entity.name }} not found") + del self._store[id] + + async def count(self) -> int: + return len(self._store) diff --git a/templates/simple/model.py.j2 b/templates/simple/model.py.j2 new file mode 100644 index 0000000..e1f8fed --- /dev/null +++ b/templates/simple/model.py.j2 @@ -0,0 +1,241 @@ +{#- -------------------------------------------------------------------------- + model.py.j2 — Generate a SQLAlchemy model from an EntityDefinition. + + Template variable: entity (EntityDefinition) + -------------------------------------------------------------------------- -#} +{%- set ns = namespace( + need_enum=false, + need_uuid=false, + need_datetime=false, + need_date=false, + need_relationship=false +) -%} +{#- Scan fields for import requirements -#} +{%- for field in entity.fields -%} +{%- if field.type == "enum" %}{% set ns.need_enum = true %}{% endif -%} +{%- if field.type == "uuid" or field.references %}{% set ns.need_uuid = true %}{% endif -%} +{%- if field.type == "datetime" %}{% set ns.need_datetime = true %}{% endif -%} +{%- if field.type == "date" %}{% set ns.need_date = true %}{% endif -%} +{%- if field.references %}{% set ns.need_relationship = true %}{% endif -%} +{%- endfor -%} +{%- if entity.relationships %}{% set ns.need_relationship = true %}{% endif -%} +{#- Collect SA type names -#} +{%- set _sa_map = { + "string": "String", "text": "Text", "integer": "Integer", + "float": "Float", "boolean": "Boolean", "datetime": "DateTime", + "date": "Date", "decimal": "Numeric", "json": "JSON", + "jsonb": "JSONB", "array": "ARRAY", "enum": "Enum" +} -%} +{%- set _seen = namespace(types=[]) -%} +{%- for field in entity.fields -%} +{%- if field.references -%} +{%- if "ForeignKey" not in _seen.types %}{% set _seen.types = _seen.types + ["ForeignKey"] %}{% endif -%} +{%- elif field.type == "uuid" -%} +{%- if "UUID" not in _seen.types %}{% set _seen.types = _seen.types + ["UUID"] %}{% endif -%} +{%- elif field.type in _sa_map -%} +{%- set _sa_name = _sa_map[field.type] -%} +{%- if _sa_name not in _seen.types %}{% set _seen.types = _seen.types + [_sa_name] %}{% endif -%} +{%- endif -%} +{%- endfor -%} +{%- set sa_imports = _seen.types | sort -%} +{#- Collect TYPE_CHECKING targets -#} +{%- set _tc = namespace(targets=[]) -%} +{%- for rel in entity.relationships -%} +{%- if rel.type != "self_referential" and rel.target_entity != entity.name -%} +{%- if rel.target_entity not in _tc.targets %}{% set _tc.targets = _tc.targets + [rel.target_entity] %}{% endif -%} +{%- endif -%} +{%- endfor -%} +{#- ========== Begin output ========== -#} +"""{{ entity.name }} model.""" +{%- if _tc.targets %} + +from __future__ import annotations + +from typing import TYPE_CHECKING +{%- endif %} +{%- if ns.need_enum %} + +import enum +{%- endif %} +{%- if ns.need_uuid %} + +import uuid +{%- endif %} +{%- if ns.need_datetime and ns.need_date %} + +from datetime import date, datetime +{%- elif ns.need_datetime %} + +from datetime import datetime +{%- elif ns.need_date %} + +from datetime import date +{%- endif %} +{%- if sa_imports %} + +from sqlalchemy import {{ sa_imports | join(", ") }} +{%- endif %} + +from sqlalchemy.orm import Mapped, mapped_column{{ ", relationship" if ns.need_relationship else "" }} + +from faststack_core.base.entity import {{ entity.base }} +{%- if _tc.targets %} + +if TYPE_CHECKING: +{%- for target in _tc.targets | sort %} + from app.models.{{ target | lower }} import {{ target }} +{%- endfor %} +{%- endif %} +{#- ---------- Enum classes ---------- -#} +{%- for field in entity.fields %} +{%- if field.type == "enum" and field.enum_values %} + + +class {{ entity.name }}{{ field.name | capitalize }}(str, enum.Enum): +{%- for value in field.enum_values %} + {{ value | upper }} = "{{ value }}" +{%- endfor %} +{%- endif %} +{%- endfor %} + + +class {{ entity.name }}({{ entity.base }}): + """{{ entity.name }} domain model.""" + + __tablename__ = "{{ entity.table_name }}" +{%- for field in entity.fields %} +{#- ---- Foreign key columns ---- -#} +{%- if field.references %} +{%- if field.references == "self" %} +{%- if field.required %} + {{ field.name }}: Mapped[uuid.UUID] = mapped_column(ForeignKey("{{ entity.table_name }}.id", ondelete="{{ field.on_delete }}"){{ ", unique=True" if field.unique else "" }}) +{%- else %} + {{ field.name }}: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("{{ entity.table_name }}.id", ondelete="{{ field.on_delete }}"){{ ", unique=True" if field.unique else "" }}, default=None) +{%- endif %} +{%- else %} +{%- if field.required %} + {{ field.name }}: Mapped[uuid.UUID] = mapped_column(ForeignKey("{{ field.references | lower }}s.id", ondelete="{{ field.on_delete }}"){{ ", unique=True" if field.unique else "" }}) +{%- else %} + {{ field.name }}: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("{{ field.references | lower }}s.id", ondelete="{{ field.on_delete }}"){{ ", unique=True" if field.unique else "" }}, default=None) +{%- endif %} +{%- endif %} +{#- ---- String ---- -#} +{%- elif field.type == "string" %} +{%- if field.required %} + {{ field.name }}: Mapped[str] = mapped_column(String(255){{ ", unique=True" if field.unique else "" }}{{ ', default="' + field.default + '"' if field.default else "" }}) +{%- else %} + {{ field.name }}: Mapped[str | None] = mapped_column(String(255){{ ", unique=True" if field.unique else "" }}{{ ', default="' + field.default + '"' if field.default else ", default=None" }}) +{%- endif %} +{#- ---- Text ---- -#} +{%- elif field.type == "text" %} +{%- if field.required %} + {{ field.name }}: Mapped[str] = mapped_column(Text{{ ", unique=True" if field.unique else "" }}) +{%- else %} + {{ field.name }}: Mapped[str | None] = mapped_column(Text{{ ", unique=True" if field.unique else "" }}, default=None) +{%- endif %} +{#- ---- Integer ---- -#} +{%- elif field.type == "integer" %} +{%- if field.required %} + {{ field.name }}: Mapped[int] = mapped_column(Integer{{ ", unique=True" if field.unique else "" }}{{ ", default=" + field.default if field.default else "" }}) +{%- else %} + {{ field.name }}: Mapped[int | None] = mapped_column(Integer{{ ", unique=True" if field.unique else "" }}{{ ", default=" + field.default if field.default else ", default=None" }}) +{%- endif %} +{#- ---- Float ---- -#} +{%- elif field.type == "float" %} +{%- if field.required %} + {{ field.name }}: Mapped[float] = mapped_column(Float{{ ", unique=True" if field.unique else "" }}{{ ", default=" + field.default if field.default else "" }}) +{%- else %} + {{ field.name }}: Mapped[float | None] = mapped_column(Float{{ ", unique=True" if field.unique else "" }}{{ ", default=" + field.default if field.default else ", default=None" }}) +{%- endif %} +{#- ---- Boolean ---- -#} +{%- elif field.type == "boolean" %} +{%- if field.required %} + {{ field.name }}: Mapped[bool] = mapped_column(Boolean{{ ", default=" + (field.default | string | capitalize) if field.default is not none else "" }}) +{%- else %} + {{ field.name }}: Mapped[bool] = mapped_column(Boolean, default={{ field.default | string | capitalize if field.default is not none else "False" }}) +{%- endif %} +{#- ---- DateTime ---- -#} +{%- elif field.type == "datetime" %} +{%- if field.required %} + {{ field.name }}: Mapped[datetime] = mapped_column(DateTime) +{%- else %} + {{ field.name }}: Mapped[datetime | None] = mapped_column(DateTime, default=None) +{%- endif %} +{#- ---- Date ---- -#} +{%- elif field.type == "date" %} +{%- if field.required %} + {{ field.name }}: Mapped[date] = mapped_column(Date) +{%- else %} + {{ field.name }}: Mapped[date | None] = mapped_column(Date, default=None) +{%- endif %} +{#- ---- UUID (non-FK) ---- -#} +{%- elif field.type == "uuid" %} +{%- if field.required %} + {{ field.name }}: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True){{ ", unique=True" if field.unique else "" }}) +{%- else %} + {{ field.name }}: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True){{ ", unique=True" if field.unique else "" }}, default=None) +{%- endif %} +{#- ---- Decimal ---- -#} +{%- elif field.type == "decimal" %} +{%- if field.required %} + {{ field.name }}: Mapped[float] = mapped_column(Numeric(10, 2){{ ", unique=True" if field.unique else "" }}{{ ", default=" + field.default if field.default else "" }}) +{%- else %} + {{ field.name }}: Mapped[float | None] = mapped_column(Numeric(10, 2){{ ", unique=True" if field.unique else "" }}{{ ", default=" + field.default if field.default else ", default=None" }}) +{%- endif %} +{#- ---- JSON ---- -#} +{%- elif field.type == "json" %} +{%- if field.required %} + {{ field.name }}: Mapped[dict] = mapped_column(JSON) +{%- else %} + {{ field.name }}: Mapped[dict | None] = mapped_column(JSON, default=None) +{%- endif %} +{#- ---- JSONB ---- -#} +{%- elif field.type == "jsonb" %} +{%- if field.required %} + {{ field.name }}: Mapped[dict] = mapped_column(JSONB) +{%- else %} + {{ field.name }}: Mapped[dict | None] = mapped_column(JSONB, default=None) +{%- endif %} +{#- ---- Array ---- -#} +{%- elif field.type == "array" %} +{%- set _inner_sa = {"string": "String", "integer": "Integer", "float": "Float", "text": "Text", "boolean": "Boolean"} %} +{%- set _inner = _inner_sa.get(field.items, "String") if field.items else "String" %} +{%- if field.required %} + {{ field.name }}: Mapped[list] = mapped_column(ARRAY({{ _inner }})) +{%- else %} + {{ field.name }}: Mapped[list | None] = mapped_column(ARRAY({{ _inner }}), default=None) +{%- endif %} +{#- ---- Enum ---- -#} +{%- elif field.type == "enum" %} +{%- set enum_class = entity.name + field.name | capitalize %} +{%- set _clean_default = field.default | replace('"', '') | replace("'", '') if field.default else "" %} +{%- if field.required %} + {{ field.name }}: Mapped[{{ enum_class }}] = mapped_column({{ "default=" + enum_class + "." + _clean_default | upper if _clean_default else "" }}) +{%- else %} + {{ field.name }}: Mapped[{{ enum_class }} | None] = mapped_column({{ "default=" + enum_class + "." + _clean_default | upper if _clean_default else "default=None" }}) +{%- endif %} +{%- endif %} +{%- endfor %} +{#- ---------- Relationships ---------- -#} +{%- for rel in entity.relationships %} +{%- if rel.type == "many_to_one" %} + + # Relationship to {{ rel.target_entity }} + {{ rel.target_entity | lower }}: Mapped["{{ rel.target_entity }}"] = relationship(back_populates="{{ rel.back_populates }}") +{%- elif rel.type == "self_referential" %} + + # Self-referential relationship + parent: Mapped["{{ entity.name }} | None"] = relationship( + back_populates="{{ rel.back_populates }}", + remote_side="[{{ entity.name }}.id]", + ) + {{ rel.back_populates }}: Mapped[list["{{ entity.name }}"]] = relationship(back_populates="parent") +{%- elif rel.type == "many_to_many" %} + + # Many-to-many relationship to {{ rel.target_entity }} + {{ rel.field_name }}: Mapped[list["{{ rel.target_entity }}"]] = relationship( + back_populates="{{ rel.back_populates }}", + secondary="{{ entity.name | lower }}_{{ rel.target_entity | lower }}", + ) +{%- endif %} +{%- endfor %} diff --git a/templates/simple/repository.py.j2 b/templates/simple/repository.py.j2 new file mode 100644 index 0000000..eb13cfe --- /dev/null +++ b/templates/simple/repository.py.j2 @@ -0,0 +1,50 @@ +{#- -------------------------------------------------------------------------- + repository.py.j2 — Generate a repository from an EntityDefinition. + + Template variable: entity (EntityDefinition) + -------------------------------------------------------------------------- -#} +"""{{ entity.name }} repository.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.{{ entity.name | lower }} import {{ entity.name }} +from faststack_core.base.repository import SqlAlchemyRepository + + +class {{ entity.name }}Repository(SqlAlchemyRepository[{{ entity.name }}]): + """{{ entity.name }} repository with custom query methods. + + Base CRUD operations are inherited from SqlAlchemyRepository: + - get_by_id(id) -> {{ entity.name }} | None + - list(skip, limit) -> list[{{ entity.name }}] + - create(data) -> {{ entity.name }} + - update(id, data) -> {{ entity.name }} + - delete(id) -> None (soft-delete when available) + - count() -> int + + Add custom query methods below. + """ + + def __init__(self, db: AsyncSession) -> None: + super().__init__(db, {{ entity.name }}) +{%- if entity.searchable %} + + async def search(self, query: str, skip: int = 0, limit: int = 100) -> list[{{ entity.name }}]: + """Search {{ entity.name | lower }} records by {{ entity.searchable | join(", ") }}.""" + from sqlalchemy import or_, select + + stmt = ( + select({{ entity.name }}) + .where( + or_( +{%- for field in entity.searchable %} + {{ entity.name }}.{{ field }}.ilike(f"%{query}%"), +{%- endfor %} + ) + ) + .offset(skip) + .limit(limit) + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) +{%- endif %} diff --git a/templates/simple/router.py.j2 b/templates/simple/router.py.j2 new file mode 100644 index 0000000..f5951a8 --- /dev/null +++ b/templates/simple/router.py.j2 @@ -0,0 +1,57 @@ +"""{{ entity.name }} API routes.""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from app.schemas.{{ entity.name | lower }} import ( + {{ entity.name }}Create, + {{ entity.name }}DetailResponse, + {{ entity.name }}Response, + {{ entity.name }}Update, +) + +router = APIRouter( + prefix="/{{ entity.table_name }}", + tags=["{{ entity.name }}"], +) + + +@router.get("/", response_model=list[{{ entity.name }}Response]) +async def list_{{ entity.table_name }}( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), +{%- for field in entity.fields if field.references %} + {{ field.name }}: UUID | None = Query(None), +{%- endfor %} +{%- if entity.searchable %} + q: str | None = Query(None, description="Search {{ entity.searchable | join(', ') }}"), +{%- endif %} + # TODO: Inject service via Depends() +): + """List {{ entity.name }} records.""" + ... + + +@router.get("/{id}", response_model={{ entity.name }}DetailResponse) +async def get_{{ entity.name | lower }}(id: UUID): + """Get a single {{ entity.name }} by ID.""" + ... + + +@router.post("/", response_model={{ entity.name }}Response, status_code=201) +async def create_{{ entity.name | lower }}(data: {{ entity.name }}Create): + """Create a new {{ entity.name }}.""" + ... + + +@router.put("/{id}", response_model={{ entity.name }}Response) +async def update_{{ entity.name | lower }}(id: UUID, data: {{ entity.name }}Update): + """Update an existing {{ entity.name }}.""" + ... + + +@router.delete("/{id}", status_code=204) +async def delete_{{ entity.name | lower }}(id: UUID): + """Delete a {{ entity.name }}.""" + ... diff --git a/templates/simple/schema.py.j2 b/templates/simple/schema.py.j2 new file mode 100644 index 0000000..53f74c8 --- /dev/null +++ b/templates/simple/schema.py.j2 @@ -0,0 +1,148 @@ +{#- -------------------------------------------------------------------------- + schema.py.j2 — Generate Pydantic v2 schemas from an EntityDefinition. + + Template variable: entity (EntityDefinition) + + Generates: Create, Update, Response, and DetailResponse schemas. + -------------------------------------------------------------------------- -#} +{%- set ns = namespace( + need_uuid=false, + need_datetime=false, + need_date=false, + need_decimal=false, + has_enum_fields=false +) -%} +{%- for field in entity.fields -%} +{%- if field.type == "uuid" or field.references %}{% set ns.need_uuid = true %}{% endif -%} +{%- if field.type == "datetime" %}{% set ns.need_datetime = true %}{% endif -%} +{%- if field.type == "date" %}{% set ns.need_date = true %}{% endif -%} +{%- if field.type == "decimal" %}{% set ns.need_decimal = true %}{% endif -%} +{%- if field.type == "enum" and field.enum_values %}{% set ns.has_enum_fields = true %}{% endif -%} +{%- endfor -%} +{#- Response schema always needs UUID; audit bases need datetime -#} +{%- set needs_datetime_import = ns.need_datetime or entity.base in ("AuditedEntity", "FullAuditedEntity", "SoftDeleteEntity") -%} +{#- Map YAML types to Pydantic annotation strings -#} +{%- set _pydantic_map = { + "string": "str", "text": "str", "integer": "int", "float": "float", + "boolean": "bool", "datetime": "datetime", "date": "date", + "uuid": "UUID", "decimal": "Decimal", "json": "dict", "jsonb": "dict" +} -%} +{%- set _array_inner_map = { + "string": "str", "text": "str", "integer": "int", "float": "float", + "boolean": "bool", "uuid": "UUID", "decimal": "Decimal" +} -%} +{#- Macro: Pydantic type for a field -#} +{%- macro pydantic_type(field) -%} +{%- if field.references -%} +UUID +{%- elif field.type == "enum" and field.enum_values -%} +{{ entity.name }}{{ field.name | capitalize }} +{%- elif field.type == "array" -%} +list[{{ _array_inner_map.get(field.items, "str") if field.items else "str" }}] +{%- elif field.type in _pydantic_map -%} +{{ _pydantic_map[field.type] }} +{%- else -%} +str +{%- endif -%} +{%- endmacro -%} +{#- ========== Begin output ========== -#} +"""{{ entity.name }} schemas.""" + +from __future__ import annotations +{%- if needs_datetime_import and ns.need_date %} + +from datetime import date, datetime +{%- elif needs_datetime_import %} + +from datetime import datetime +{%- elif ns.need_date %} + +from datetime import date +{%- endif %} +{%- if ns.need_decimal %} + +from decimal import Decimal +{%- endif %} + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +{%- for field in entity.fields %} +{%- if field.type == "enum" and field.enum_values %} + +from app.models.{{ entity.name | lower }} import {{ entity.name }}{{ field.name | capitalize }} +{%- endif %} +{%- endfor %} + + +class {{ entity.name }}Create(BaseModel): + """Schema for creating a new {{ entity.name }}.""" +{% for field in entity.fields %} +{%- if field.required %} + {{ field.name }}: {{ pydantic_type(field) }} +{%- elif field.default is not none %} +{%- if field.type == "boolean" %} + {{ field.name }}: {{ pydantic_type(field) }} = {{ field.default | string | capitalize }} +{%- elif field.type == "enum" and field.default %} +{%- set _clean_default = field.default | replace('"', '') | replace("'", '') %} + {{ field.name }}: {{ pydantic_type(field) }} = {{ entity.name }}{{ field.name | capitalize }}.{{ _clean_default | upper }} +{%- elif field.type in ("integer", "float", "decimal") %} + {{ field.name }}: {{ pydantic_type(field) }} = {{ field.default }} +{%- else %} + {{ field.name }}: {{ pydantic_type(field) }} = "{{ field.default }}" +{%- endif %} +{%- else %} + {{ field.name }}: {{ pydantic_type(field) }} | None = None +{%- endif %} +{%- endfor %} + + +class {{ entity.name }}Update(BaseModel): + """Schema for updating a {{ entity.name }} (PATCH semantics).""" +{% for field in entity.fields %} + {{ field.name }}: {{ pydantic_type(field) }} | None = None +{%- endfor %} + + +class {{ entity.name }}Response(BaseModel): + """Schema for {{ entity.name }} API responses.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID +{%- for field in entity.fields %} +{%- if field.required %} + {{ field.name }}: {{ pydantic_type(field) }} +{%- else %} + {{ field.name }}: {{ pydantic_type(field) }} | None = None +{%- endif %} +{%- endfor %} +{%- if entity.base in ("AuditedEntity", "FullAuditedEntity") %} + created_at: datetime + updated_at: datetime + created_by: str | None = None + updated_by: str | None = None +{%- endif %} +{%- if entity.base in ("SoftDeleteEntity", "FullAuditedEntity") %} + is_deleted: bool = False + deleted_at: datetime | None = None + deleted_by: str | None = None +{%- endif %} + + +class {{ entity.name }}DetailResponse({{ entity.name }}Response): + """Schema for {{ entity.name }} API responses with nested relationships.""" +{%- if entity.relationships %} +{%- for rel in entity.relationships %} +{%- if rel.type == "many_to_one" %} + {{ rel.target_entity | lower }}: {{ rel.target_entity }}Response | None = None +{%- elif rel.type == "many_to_many" %} + {{ rel.field_name }}: list[{{ rel.target_entity }}Response] = [] +{%- elif rel.type == "self_referential" %} + {{ rel.back_populates }}: list[{{ entity.name }}Response] = [] +{%- endif %} +{%- endfor %} +{%- else %} + + pass +{%- endif %} diff --git a/templates/simple/service.py.j2 b/templates/simple/service.py.j2 new file mode 100644 index 0000000..387570e --- /dev/null +++ b/templates/simple/service.py.j2 @@ -0,0 +1,29 @@ +"""{{ entity.name }} service.""" + +from faststack_core.base.repository import Repository +from faststack_core.base.service import CrudService + +from app.models.{{ entity.name | lower }} import {{ entity.name }} + + +class {{ entity.name }}Service(CrudService[{{ entity.name }}]): + """{{ entity.name }} service with business logic. + + Override lifecycle hooks below to add custom behavior: + - before_create / after_create + - before_update / after_update + - before_delete / after_delete + """ + + def __init__(self, repository: Repository[{{ entity.name }}]) -> None: + super().__init__(repository) +{%- set unique_fields = entity.fields | selectattr("unique") | list %} +{%- if unique_fields %} + + async def before_create(self, data: dict) -> dict: + """Check for duplicate values before creating.""" +{%- for field in unique_fields %} + # TODO: Implement uniqueness check for {{ field.name }} +{%- endfor %} + return data +{%- endif %} diff --git a/templates/simple/test_integration.py.j2 b/templates/simple/test_integration.py.j2 new file mode 100644 index 0000000..c44bd89 --- /dev/null +++ b/templates/simple/test_integration.py.j2 @@ -0,0 +1,33 @@ +"""Integration tests for {{ entity.name }} API endpoints.""" + +# TODO: Wire up the `client` fixture with FastAPI TestClient + DI overrides (Phase 6). + +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient + + +async def test_create_{{ entity.name | lower }}(client): + response = await client.post( + "/api/{{ entity.table_name }}", + json={ +{%- for field in entity.fields if field.required and not field.references %} + "{{ field.name }}": {% if field.type == "string" or field.type == "text" %}"test_{{ field.name }}"{% elif field.type == "integer" %}1{% elif field.type == "float" %}1.0{% elif field.type == "boolean" %}True{% elif field.type == "enum" %}"{{ field.enum_values[0] }}"{% elif field.type == "decimal" %}"10.00"{% else %}"test_{{ field.name }}"{% endif %}, +{%- endfor %} + }, + ) + assert response.status_code == 201 + data = response.json() + assert "id" in data + + +async def test_list_{{ entity.table_name }}(client): + response = await client.get("/api/{{ entity.table_name }}") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +async def test_get_{{ entity.name | lower }}_not_found(client): + response = await client.get(f"/api/{{ entity.table_name }}/{uuid.uuid4()}") + assert response.status_code == 404 diff --git a/templates/simple/test_unit_service.py.j2 b/templates/simple/test_unit_service.py.j2 new file mode 100644 index 0000000..b6c6415 --- /dev/null +++ b/templates/simple/test_unit_service.py.j2 @@ -0,0 +1,69 @@ +"""Unit tests for {{ entity.name }}Service.""" + +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from app.services.{{ entity.name | lower }} import {{ entity.name }}Service +from faststack_core.exceptions.domain import NotFoundError +from tests.unit.fakes.{{ entity.name | lower }}_repository import Fake{{ entity.name }}Repository + + +@pytest.fixture +def repo(): + return Fake{{ entity.name }}Repository() + + +@pytest.fixture +def service(repo): + return {{ entity.name }}Service(repo) + + +async def test_create(service): + entity = await service.create({ +{%- for field in entity.fields if field.required and not field.references %} + "{{ field.name }}": {% if field.type == "string" or field.type == "text" %}"test_{{ field.name }}"{% elif field.type == "integer" %}1{% elif field.type == "float" %}1.0{% elif field.type == "boolean" %}True{% elif field.type == "uuid" %}uuid.uuid4(){% elif field.type == "datetime" %}datetime.now(UTC){% elif field.type == "enum" %}"{{ field.enum_values[0] }}"{% elif field.type == "decimal" %}Decimal("10.00"){% else %}"test_{{ field.name }}"{% endif %}, +{%- endfor %} + }) + assert entity.id is not None +{%- for field in entity.fields if field.required and not field.references %} + assert entity.{{ field.name }} is not None +{%- endfor %} + + +async def test_get(service): + entity = await service.create({ +{%- for field in entity.fields if field.required and not field.references %} + "{{ field.name }}": {% if field.type == "string" or field.type == "text" %}"test_{{ field.name }}"{% elif field.type == "integer" %}1{% elif field.type == "float" %}1.0{% elif field.type == "boolean" %}True{% elif field.type == "uuid" %}uuid.uuid4(){% elif field.type == "datetime" %}datetime.now(UTC){% elif field.type == "enum" %}"{{ field.enum_values[0] }}"{% elif field.type == "decimal" %}Decimal("10.00"){% else %}"test_{{ field.name }}"{% endif %}, +{%- endfor %} + }) + found = await service.get(entity.id) + assert found.id == entity.id + + +async def test_get_not_found(service): + with pytest.raises(NotFoundError): + await service.get(uuid.uuid4()) + + +async def test_list(service): + await service.create({ +{%- for field in entity.fields if field.required and not field.references %} + "{{ field.name }}": {% if field.type == "string" or field.type == "text" %}"test_{{ field.name }}"{% elif field.type == "integer" %}1{% elif field.type == "float" %}1.0{% elif field.type == "boolean" %}True{% elif field.type == "uuid" %}uuid.uuid4(){% elif field.type == "datetime" %}datetime.now(UTC){% elif field.type == "enum" %}"{{ field.enum_values[0] }}"{% elif field.type == "decimal" %}Decimal("10.00"){% else %}"test_{{ field.name }}"{% endif %}, +{%- endfor %} + }) + result = await service.list() + assert len(result) == 1 + + +async def test_delete(service): + entity = await service.create({ +{%- for field in entity.fields if field.required and not field.references %} + "{{ field.name }}": {% if field.type == "string" or field.type == "text" %}"test_{{ field.name }}"{% elif field.type == "integer" %}1{% elif field.type == "float" %}1.0{% elif field.type == "boolean" %}True{% elif field.type == "uuid" %}uuid.uuid4(){% elif field.type == "datetime" %}datetime.now(UTC){% elif field.type == "enum" %}"{{ field.enum_values[0] }}"{% elif field.type == "decimal" %}Decimal("10.00"){% else %}"test_{{ field.name }}"{% endif %}, +{%- endfor %} + }) + await service.delete(entity.id) + with pytest.raises(NotFoundError): + await service.get(entity.id) diff --git a/tests/test_templates/test_simple_mode.py b/tests/test_templates/test_simple_mode.py new file mode 100644 index 0000000..ab9bfad --- /dev/null +++ b/tests/test_templates/test_simple_mode.py @@ -0,0 +1,301 @@ +"""Tests for simple mode entity templates. + +Renders all 9 templates for User/Post/Category entities from the design +plan YAML example. Verifies: +- Output is syntactically valid Python (ast.parse) +- Cross-template consistency (names, imports match) +""" + +import ast +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader + +from cli.yaml_parser import EntityDefinition, FieldDefinition, RelationshipDefinition + +TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates" / "simple" + + +# --------------------------------------------------------------------------- +# Test entities (from design plan YAML example) +# --------------------------------------------------------------------------- + +USER_ENTITY = EntityDefinition( + name="User", + base="FullAuditedEntity", + table_name="users", + fields=[ + FieldDefinition(name="email", type="string", required=True, unique=True), + FieldDefinition(name="name", type="string", required=True), + FieldDefinition( + name="role", + type="enum", + enum_values=["admin", "editor", "viewer"], + default='"viewer"', + ), + FieldDefinition(name="bio", type="text"), + ], + relationships=[], + searchable=["email", "name"], +) + +POST_ENTITY = EntityDefinition( + name="Post", + base="AuditedEntity", + table_name="posts", + fields=[ + FieldDefinition(name="title", type="string", required=True), + FieldDefinition(name="content", type="text"), + FieldDefinition( + name="status", + type="enum", + enum_values=["draft", "published", "archived"], + default='"draft"', + ), + FieldDefinition(name="tags", type="array", items="string"), + FieldDefinition(name="metadata", type="jsonb"), + FieldDefinition(name="user_id", type="uuid", references="User"), + ], + relationships=[ + RelationshipDefinition( + field_name="user_id", + type="many_to_one", + target_entity="User", + back_populates="posts", + ), + ], + searchable=["title"], +) + +CATEGORY_ENTITY = EntityDefinition( + name="Category", + base="AuditedEntity", + table_name="categories", + fields=[ + FieldDefinition(name="name", type="string", required=True), + FieldDefinition(name="parent_id", type="uuid", references="self"), + ], + relationships=[ + RelationshipDefinition( + field_name="parent_id", + type="self_referential", + target_entity="Category", + back_populates="children", + ), + ], + searchable=[], +) + +MINIMAL_ENTITY = EntityDefinition( + name="Tag", + base="Entity", + table_name="tags", + fields=[ + FieldDefinition(name="label", type="string", required=True), + ], + relationships=[], + searchable=[], +) + +ALL_ENTITIES = [USER_ENTITY, POST_ENTITY, CATEGORY_ENTITY, MINIMAL_ENTITY] + +TEMPLATES = [ + "model.py.j2", + "schema.py.j2", + "repository.py.j2", + "service.py.j2", + "router.py.j2", + "factory.py.j2", + "fake_repository.py.j2", + "test_unit_service.py.j2", + "test_integration.py.j2", +] + + +@pytest.fixture +def jinja_env(): + return Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + keep_trailing_newline=True, + ) + + +def _render(jinja_env, template_name: str, entity: EntityDefinition) -> str: + template = jinja_env.get_template(template_name) + return template.render(entity=entity) + + +# --------------------------------------------------------------------------- +# Every template renders valid Python for every entity +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("template_name", TEMPLATES) +@pytest.mark.parametrize( + "entity", + ALL_ENTITIES, + ids=["User", "Post", "Category", "Tag"], +) +def test_template_renders_valid_python(jinja_env, template_name, entity): + """Rendered output must be parseable as Python.""" + output = _render(jinja_env, template_name, entity) + try: + ast.parse(output) + except SyntaxError as e: + pytest.fail( + f"{template_name} for {entity.name} produced invalid Python:\n" + f" {e}\n\nGenerated code:\n{output}" + ) + + +# --------------------------------------------------------------------------- +# Model template specifics +# --------------------------------------------------------------------------- + + +def test_model_has_tablename(jinja_env): + output = _render(jinja_env, "model.py.j2", USER_ENTITY) + assert '__tablename__ = "users"' in output + + +def test_model_inherits_base(jinja_env): + output = _render(jinja_env, "model.py.j2", USER_ENTITY) + assert "class User(FullAuditedEntity)" in output + + +def test_model_has_enum_class(jinja_env): + output = _render(jinja_env, "model.py.j2", USER_ENTITY) + assert "class UserRole" in output + assert "ADMIN" in output or "admin" in output.lower() + + +def test_model_has_fk(jinja_env): + output = _render(jinja_env, "model.py.j2", POST_ENTITY) + assert "ForeignKey" in output + assert "user_id" in output + + +def test_model_has_relationship(jinja_env): + output = _render(jinja_env, "model.py.j2", POST_ENTITY) + assert "relationship" in output + + +def test_model_self_referential(jinja_env): + output = _render(jinja_env, "model.py.j2", CATEGORY_ENTITY) + assert "parent_id" in output + assert "ForeignKey" in output + + +# --------------------------------------------------------------------------- +# Schema template specifics +# --------------------------------------------------------------------------- + + +def test_schema_has_four_classes(jinja_env): + output = _render(jinja_env, "schema.py.j2", USER_ENTITY) + assert "class UserCreate" in output + assert "class UserUpdate" in output + assert "class UserResponse" in output + assert "class UserDetailResponse" in output + + +def test_schema_create_has_required_fields(jinja_env): + output = _render(jinja_env, "schema.py.j2", USER_ENTITY) + assert "email:" in output + assert "name:" in output + + +def test_schema_response_has_id(jinja_env): + output = _render(jinja_env, "schema.py.j2", USER_ENTITY) + assert "id:" in output + + +# --------------------------------------------------------------------------- +# Repository template specifics +# --------------------------------------------------------------------------- + + +def test_repository_inherits_sqlalchemy_repo(jinja_env): + output = _render(jinja_env, "repository.py.j2", USER_ENTITY) + assert "SqlAlchemyRepository" in output + assert "class UserRepository" in output + + +def test_repository_has_search_for_searchable(jinja_env): + output = _render(jinja_env, "repository.py.j2", USER_ENTITY) + assert "search" in output + + +def test_repository_no_search_for_non_searchable(jinja_env): + output = _render(jinja_env, "repository.py.j2", MINIMAL_ENTITY) + assert "search" not in output + + +# --------------------------------------------------------------------------- +# Service template specifics +# --------------------------------------------------------------------------- + + +def test_service_inherits_crud_service(jinja_env): + output = _render(jinja_env, "service.py.j2", USER_ENTITY) + assert "CrudService" in output + assert "class UserService" in output + + +def test_service_accepts_repository_protocol(jinja_env): + output = _render(jinja_env, "service.py.j2", USER_ENTITY) + assert "Repository" in output + + +# --------------------------------------------------------------------------- +# Router template specifics +# --------------------------------------------------------------------------- + + +def test_router_has_crud_endpoints(jinja_env): + output = _render(jinja_env, "router.py.j2", USER_ENTITY) + assert "@router.get" in output + assert "@router.post" in output + assert "@router.put" in output + assert "@router.delete" in output + + +def test_router_has_correct_prefix(jinja_env): + output = _render(jinja_env, "router.py.j2", USER_ENTITY) + assert "/users" in output + + +# --------------------------------------------------------------------------- +# Fake repository template specifics +# --------------------------------------------------------------------------- + + +def test_fake_has_all_protocol_methods(jinja_env): + output = _render(jinja_env, "fake_repository.py.j2", USER_ENTITY) + for method in ("get_by_id", "list", "create", "update", "delete", "count"): + assert f"async def {method}" in output + + +def test_fake_uses_dict_store(jinja_env): + output = _render(jinja_env, "fake_repository.py.j2", USER_ENTITY) + assert "_store" in output + + +# --------------------------------------------------------------------------- +# Test templates specifics +# --------------------------------------------------------------------------- + + +def test_unit_test_has_fixtures(jinja_env): + output = _render(jinja_env, "test_unit_service.py.j2", USER_ENTITY) + assert "def repo" in output + assert "def service" in output + assert "test_create" in output + assert "test_get_not_found" in output + + +def test_integration_test_has_endpoints(jinja_env): + output = _render(jinja_env, "test_integration.py.j2", USER_ENTITY) + assert "test_create" in output + assert "test_list" in output