Comments — One-to-Many with Ownership and Moderation

Comments are the canonical one-to-many relationship in web applications: one post has many comments, each comment belongs to exactly one post. Adding threaded replies introduces a self-referential one-to-many: one comment can have many reply comments, each pointing back to a parent via parent_comment_id. This lesson builds the complete comments API — create, list (with threading), moderate, and delete — showing how to chain related ownership checks (a post author can moderate any comment on their post, a commenter can delete their own comment) and how to load threaded comment trees efficiently.

Comment Model

# app/models/comment.py
from sqlalchemy import Text, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from datetime import datetime
from app.database import Base

class Comment(Base):
    __tablename__ = "comments"

    id:               Mapped[int]            = mapped_column(primary_key=True)
    body:             Mapped[str]            = mapped_column(Text, nullable=False)
    is_approved:      Mapped[bool]           = mapped_column(Boolean, default=True)
    post_id:          Mapped[int]            = mapped_column(
                          ForeignKey("posts.id", ondelete="CASCADE"), index=True)
    author_id:        Mapped[int]            = mapped_column(
                          ForeignKey("users.id", ondelete="CASCADE"), index=True)
    parent_id:        Mapped[int | None]     = mapped_column(
                          ForeignKey("comments.id", ondelete="CASCADE"), nullable=True, index=True)
    created_at:       Mapped[datetime]       = mapped_column(server_default=func.now())
    updated_at:       Mapped[datetime]       = mapped_column(
                          server_default=func.now(), onupdate=func.now())

    # ── Relationships ─────────────────────────────────────────────────────────
    post:    Mapped["Post"]           = relationship(back_populates="comments")
    author:  Mapped["User"]           = relationship(back_populates="comments")
    # Self-referential: one comment can have many replies
    replies: Mapped[list["Comment"]]  = relationship(
        back_populates = "parent",
        cascade        = "all, delete-orphan",
    )
    parent:  Mapped["Comment | None"] = relationship(
        back_populates = "replies",
        remote_side    = [id],   # remote_side marks the "one" side of the self-join
    )
Note: The remote_side=[id] parameter on the parent relationship is required for self-referential relationships — it tells SQLAlchemy which column is the “one” side. Without it, SQLAlchemy cannot determine the join direction. For a comment where parent_id = 5, SQLAlchemy needs to know that it should join comments.id = 5 (not comments.parent_id = 5). The remote_side parameter says “the remote (parent) side uses the id column.”
Tip: For threaded comments, limit the nesting depth to prevent infinitely deep threads that are hard to render. A maximum depth of 2–3 levels (root comment → reply → reply-to-reply) covers most real-world discussion patterns. Enforce this at the API level: when creating a comment, check if parent and parent.parent_id is not None: raise HTTPException(400, "Maximum nesting depth exceeded"). Deep threads are also expensive to load — a flat list with indentation is often a better UX than true nesting.
Warning: The cascade="all, delete-orphan" on replies means deleting a comment also deletes all its replies — cascading deletion through the tree. This happens at the ORM level (Python). The ondelete="CASCADE" on the parent_id foreign key enforces the same behaviour at the PostgreSQL level. Both are needed: the ORM cascade handles deletions made through SQLAlchemy; the database-level cascade handles direct SQL deletions (during migrations or admin operations).

Comment Schemas

# app/schemas/comment.py
from pydantic import BaseModel, Field
from datetime import datetime

class AuthorSummary(BaseModel):
    model_config = {"from_attributes": True}
    id:   int
    name: str

class CommentCreate(BaseModel):
    body:      str = Field(..., min_length=1, max_length=10000)
    parent_id: int | None = None   # None for top-level, int for reply

class CommentUpdate(BaseModel):
    body: str = Field(..., min_length=1, max_length=10000)

class CommentResponse(BaseModel):
    model_config = {"from_attributes": True}
    id:          int
    body:        str
    is_approved: bool
    post_id:     int
    parent_id:   int | None
    created_at:  datetime
    author:      AuthorSummary
    replies:     list["CommentResponse"] = []   # nested replies

CommentResponse.model_rebuild()   # needed for forward references in nested schema

Comments Router

# app/routers/comments.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
from app.dependencies import get_db, get_current_user
from app.models.comment import Comment
from app.models.post    import Post
from app.schemas.comment import CommentCreate, CommentUpdate, CommentResponse

router = APIRouter()

@router.get("/posts/{post_id}/comments", response_model=list[CommentResponse])
def list_comments(post_id: int, db: Session = Depends(get_db)):
    """Return top-level approved comments with their replies."""
    comments = db.scalars(
        select(Comment)
        .options(
            joinedload(Comment.author),
            selectinload(Comment.replies).joinedload(Comment.author),
        )
        .where(
            Comment.post_id   == post_id,
            Comment.parent_id.is_(None),   # top-level only
            Comment.is_approved == True,
        )
        .order_by(Comment.created_at.asc())
    ).all()
    return comments

@router.post("/posts/{post_id}/comments",
             response_model=CommentResponse, status_code=201)
def create_comment(
    post_id:      int,
    comment_in:   CommentCreate,
    db:           Session = Depends(get_db),
    current_user = Depends(get_current_user),
):
    post = db.get(Post, post_id)
    if not post or post.deleted_at:
        raise HTTPException(404, "Post not found")

    # Validate parent comment belongs to same post
    if comment_in.parent_id:
        parent = db.get(Comment, comment_in.parent_id)
        if not parent or parent.post_id != post_id:
            raise HTTPException(400, "Invalid parent comment")
        # Limit nesting depth to 2 levels
        if parent.parent_id is not None:
            raise HTTPException(400, "Maximum reply depth exceeded")

    comment = Comment(
        body      = comment_in.body,
        post_id   = post_id,
        author_id = current_user.id,
        parent_id = comment_in.parent_id,
    )
    db.add(comment)
    db.flush()
    db.refresh(comment)
    return comment

@router.delete("/comments/{comment_id}", status_code=204)
def delete_comment(
    comment_id:   int,
    db:           Session = Depends(get_db),
    current_user = Depends(get_current_user),
):
    comment = db.get(Comment, comment_id)
    if not comment:
        raise HTTPException(404, "Comment not found")
    # Comment author OR post author can delete
    post = db.get(Post, comment.post_id)
    if comment.author_id != current_user.id and \
       (post and post.author_id != current_user.id) and \
       current_user.role != "admin":
        raise HTTPException(403, "Not authorised")
    db.delete(comment)
    db.flush()

Common Mistakes

Mistake 1 — Missing remote_side on self-referential relationship

❌ Wrong — SQLAlchemy cannot determine join direction:

parent: Mapped["Comment | None"] = relationship(back_populates="replies")
# AmbiguousForeignKeysError or wrong join!

✅ Correct — always specify remote_side for self-referential:

parent: Mapped["Comment | None"] = relationship(
    back_populates="replies", remote_side=[id]   # ✓
)

Mistake 2 — Not validating parent comment belongs to the same post

❌ Wrong — client can attach a reply to a comment from a different post:

comment = Comment(body=body, post_id=post_id, parent_id=parent_id)
# parent_id might reference a comment on a completely different post!

✅ Correct — verify parent.post_id == post_id before creating the reply.

Mistake 3 — Loading all comments without the top-level filter

❌ Wrong — returns both root comments and replies at the top level (duplicates replies):

select(Comment).where(Comment.post_id == post_id)
# Returns root comments AND replies — replies appear twice (as their own rows AND nested)

✅ Correct — filter for top-level only, load replies via selectinload:

select(Comment).where(Comment.post_id == post_id,
                      Comment.parent_id.is_(None))   # ✓

Quick Reference

Endpoint Description Auth
GET /posts/{id}/comments List approved top-level comments + replies No
POST /posts/{id}/comments Create comment or reply Yes
PATCH /comments/{id} Edit own comment body Yes (owner)
DELETE /comments/{id} Delete — comment owner or post author Yes
POST /comments/{id}/approve Approve a comment (post author or admin) Yes

🧠 Test Yourself

A user tries to reply to a reply (depth 2, so the parent has a non-null parent_id). Your API should allow maximum depth 2 (root → reply → reply-to-reply). How do you check this?