"""create paper authoring schema Adds the three tables behind the 论文 feature — writing a paper against a template: ``paper`` the document: title, author, status, keywords, target journal, and the template it is written against. ``paper_sentence`` one row per sentence, addressed by the *position* (``paper_template_filed_sort``) of the paragraph it belongs to rather than by a foreign key to a ``template_field`` row, plus its own ``sort`` inside that paragraph. ``paper_sentence_reference`` citations of one sentence, many per sentence. Why the paragraph is addressed by position ------------------------------------------ Two templates have no rows in common; what they can share is a position. A sentence that remembers "I sit at position 7" therefore survives a template swap: it moves to whatever the new template puts at position 7, and a position the new template does not have is still rendered, in order, under an unset heading. Nothing is deleted by a swap and nothing is lost — see ``app.crud.paper.build_document``. Notes on the foreign keys, as elsewhere in this schema: TiDB parses ``FOREIGN KEY`` for compatibility and then ignores it. They are declared to document the relationships and the integrity is enforced in the application layer. ``paper_sentence_reference.reference_id`` deliberately has **no** foreign key: the reference library table does not exist yet. Revision ID: c41d7b09e5af Revises: 5030e3939a26 Create Date: 2026-09-18 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "c41d7b09e5af" down_revision: str | None = "5030e3939a26" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None #: ``CURRENT_TIMESTAMP`` rather than ``now()``: the spelling MySQL and TiDB #: both accept as a DATETIME column default without expression parentheses. _NOW = sa.text("CURRENT_TIMESTAMP") def upgrade() -> None: op.create_table( "paper", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("title", sa.String(length=255), nullable=False), # Nullable: a paper may exist before it has chosen a template. sa.Column("template_id", sa.Integer(), nullable=True), sa.Column("abstract", sa.Text(), nullable=True), sa.Column("author", sa.String(length=255), nullable=True), # 草稿 / 撰写中 / 已完成, stored as the short token. sa.Column( "status", sa.String(length=16), server_default=sa.text("'draft'"), nullable=False, ), # 关键词 as one comma-separated string. sa.Column("keywords", sa.String(length=255), nullable=True), sa.Column("target_journal", sa.String(length=255), 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.ForeignKeyConstraint( ["template_id"], ["paper_template.id"], ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("id"), ) op.create_index("ix_paper_template_id", "paper", ["template_id"], unique=False) op.create_table( "paper_sentence", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("paper_id", sa.Integer(), nullable=False), # Provenance: the template this sentence was written against. Cleared, # not cascaded, when that template is deleted. sa.Column("template_id", sa.Integer(), nullable=True), # The paragraph, as the template placement's `sort` — not a field id. sa.Column("paper_template_filed_sort", sa.Integer(), nullable=False), # This sentence's position inside that paragraph. sa.Column("sort", sa.Integer(), nullable=False), # TEXT: a "sentence" is whatever the writer treats as one line, and a # line can be long. No server default — MySQL and TiDB cannot default a # TEXT column, and the ORM always supplies a value. sa.Column("content", sa.Text(), 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.ForeignKeyConstraint(["paper_id"], ["paper.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint( ["template_id"], ["paper_template.id"], ondelete="SET NULL" ), sa.PrimaryKeyConstraint("id"), ) # One index for the document read: every sentence of a paper, in paragraph # then sentence order. op.create_index( "ix_paper_sentence_paper_paragraph_sort", "paper_sentence", ["paper_id", "paper_template_filed_sort", "sort"], unique=False, ) op.create_index( "ix_paper_sentence_template_id", "paper_sentence", ["template_id"], unique=False ) op.create_table( "paper_sentence_reference", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("sentence_id", sa.Integer(), nullable=False), # No foreign key: the reference library does not exist yet. Plain # integer, nullable, so a citation can be written before it is linked. sa.Column("reference_id", sa.Integer(), nullable=True), # 引用内容 — required. A citation that does not say what it quotes is # rejected by the API. sa.Column("quote", sa.Text(), nullable=False), sa.Column("sort", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["sentence_id"], ["paper_sentence.id"], ondelete="CASCADE" ), sa.PrimaryKeyConstraint("id"), ) op.create_index( "ix_paper_sentence_reference_sentence_sort", "paper_sentence_reference", ["sentence_id", "sort"], unique=False, ) op.create_index( "ix_paper_sentence_reference_reference_id", "paper_sentence_reference", ["reference_id"], unique=False, ) def downgrade() -> None: # Reverse dependency order throughout. op.drop_index( "ix_paper_sentence_reference_reference_id", table_name="paper_sentence_reference", ) op.drop_index( "ix_paper_sentence_reference_sentence_sort", table_name="paper_sentence_reference", ) op.drop_table("paper_sentence_reference") op.drop_index("ix_paper_sentence_template_id", table_name="paper_sentence") op.drop_index( "ix_paper_sentence_paper_paragraph_sort", table_name="paper_sentence" ) op.drop_table("paper_sentence") op.drop_index("ix_paper_template_id", table_name="paper") op.drop_table("paper")