"""create paper template schema Creates the three tables behind the template configuration feature: ``section_field`` the reusable heading library (name / level / font size / colour). Deliberately flat — there is no ``parent_id``, so a level-2 heading can be reused under any number of parents and inside any number of templates. ``paper_template`` a named outline: name + abstract. ``template_field`` the join table, and the only place a display position is recorded (``sort``). It has **no** unique constraint on ``(template_id, field_id)``: placing the same field twice in one template is a legitimate layout, and the UI warns about it rather than the schema forbidding it. Note on the foreign keys below: TiDB parses ``FOREIGN KEY`` for compatibility but does not enforce it. They are declared to document the relationships, and the integrity they would provide is enforced in the application layer instead. Revision ID: 5030e3939a26 Revises: Create Date: 2026-09-18 15:46:10.533951 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "5030e3939a26" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None #: ``CURRENT_TIMESTAMP`` rather than ``now()``: it is the spelling MySQL and #: TiDB both accept as a DATETIME column default without the expression-default #: parentheses that only newer MySQL versions allow. _NOW = sa.text("CURRENT_TIMESTAMP") def upgrade() -> None: op.create_table( "paper_template", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("name", sa.String(length=255), nullable=False), sa.Column("abstract", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False), sa.PrimaryKeyConstraint("id"), ) op.create_table( "section_field", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), # Name carries the user's own numbering ("1. Introduction"); the API # stores and returns it verbatim. sa.Column("name", sa.String(length=255), nullable=False), # Heading depth as a rendering hint: 1 = "1.", 2 = "1.1". sa.Column("level", sa.Integer(), server_default=sa.text("1"), nullable=False), # DECIMAL(4,1) because conventional Chinese sizes are fractional # (五号 = 10.5pt). sa.Column( "font_size", sa.Numeric(precision=4, scale=1), server_default=sa.text("12.0"), nullable=False, ), # Canonical RGB as #RRGGBB. sa.Column( "font_color", sa.String(length=7), server_default=sa.text("'#000000'"), nullable=False, ), sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False), sa.PrimaryKeyConstraint("id"), ) op.create_table( "template_field", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("template_id", sa.Integer(), nullable=False), sa.Column("field_id", sa.Integer(), nullable=False), # Ascending integer; the sole determinant of render order. sa.Column("sort", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["field_id"], ["section_field.id"], ondelete="RESTRICT" ), sa.ForeignKeyConstraint( ["template_id"], ["paper_template.id"], ondelete="CASCADE" ), sa.PrimaryKeyConstraint("id"), ) # Every read is "the fields of template X, in order", so one composite # index serves both the filter and the sort. op.create_index( "ix_template_field_template_sort", "template_field", ["template_id", "sort"], unique=False, ) op.create_index( "ix_template_field_field_id", "template_field", ["field_id"], unique=False ) def downgrade() -> None: # Reverse dependency order: the join table goes before the tables it points # at. op.drop_index("ix_template_field_field_id", table_name="template_field") op.drop_index("ix_template_field_template_sort", table_name="template_field") op.drop_table("template_field") op.drop_table("section_field") op.drop_table("paper_template")