notepad-new-database-table
Reference doc: New Database Table Checklist
SKILL.md.claude/skills/notepad-new-database-table/SKILL.md
GitHubNew Database Table Checklist
Use this notepad when adding a new database table/model to simpler-grants-gov.
Model Definition
import uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.base import ApiSchemaTable, TimestampMixin
class MyEntity(ApiSchemaTable, TimestampMixin):
__tablename__ = "my_entity" # ALWAYS singular
my_entity_id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(sa.Text, nullable=False)
description: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
is_active: Mapped[bool] = mapped_column(default=True) # Boolean prefix: is_*
# Relationships — ALWAYS use back_populates (never backref)
parent_id: Mapped[uuid.UUID] = mapped_column(
UUID, ForeignKey("api.parent.parent_id"), nullable=False, index=True
)
parent: Mapped["Parent"] = relationship(back_populates="my_entities")
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Table name | Singular | "user" not "users" |
| Primary key | <singular_table>_id |
user_id |
| Lookup table | lk_ prefix |
"lk_status" |
| Link/junction table | link_ prefix |
"link_role_privilege" |
| Boolean column | is_*, has_*, can_* |
is_deleted, has_attachment |
Column Syntax
ALWAYS use SQLAlchemy 2.0 syntax:
# Correct — Mapped[T] with mapped_column()
name: Mapped[str] = mapped_column(sa.Text, nullable=False)
optional_field: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
# WRONG — legacy Column() syntax
name = Column(String, nullable=False) # NEVER do this
Lookup Table Pattern (four layers)
# 1. StrEnum
class EntityStatus(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
# 2. LookupConfig
ENTITY_STATUS_CONFIG = LookupConfig([
LkEntityStatus(description="Active"),
LkEntityStatus(description="Inactive"),
])
# 3. LookupTable model
class LkEntityStatus(LookupTable):
__tablename__ = "lk_entity_status"
# 4. LookupColumn on parent model
status: Mapped[EntityStatus] = mapped_column(
"status", LookupColumn(LkEntityStatus), nullable=False
)
New enum values do NOT need a migration — they sync automatically.
Migration
"""Add my_entity table"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade():
op.create_table(
"my_entity",
sa.Column("my_entity_id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("is_active", sa.Boolean, server_default=sa.true()),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()),
schema="api", # ALWAYS specify schema
)
def downgrade():
op.drop_table("my_entity", schema="api")
Factory
class MyEntityFactory(BaseFactory):
class Meta:
model = MyEntity
my_entity_id = factory.LazyFunction(uuid.uuid4)
name = factory.Faker("company")
is_active = True
Soft Deletes
For user-facing deletion, use soft delete pattern:
is_deleted: Mapped[bool] = mapped_column(default=False)
Filter in queries: .where(MyEntity.is_deleted == False)
Don't Forget
- Inherits
ApiSchemaTable+TimestampMixin - UUID primary key with
default=uuid.uuid4 - Singular table name
-
Mapped[T]syntax (not legacyColumn()) - Relationships use
back_populates= - Migration specifies
schema="api" - Migration has both
upgrade()anddowngrade() - Factory created in test factories
- Booleans use
is_*/has_*prefixes