sqlalchemy / sqlalchemy/alembic

autogenerate with compare_server_default=True and reflecting table randomly fails to set conn_col_default

Open
#1,454 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

autogenerate - detection postgresql
Dominant language
Python
Stars
4.4k
Forks
375
PR merge metrics
No merged PRs in 30d

Description

Describe the bug

I have compare_server_default=True and am running alembic revision --autogenerate. I am changing primary key column from int to uuid and have compare_server_default enabled to help with server_default=func.gen_random_uuid(). Sometimes autogenerate recognizes the existing pk sequences, sometimes it does not, leading to a new migration file that's empty, or sometimes contains a change applying server_default=None. Using postgres 15.x

Expected behavior
I'm still new to postgres/alembic/sqlalchemy, so I don't know what the desired behavior is. However, I would expect a deterministic result.

To Reproduce

When I am running autogenerate this is basically my models.py:

@as_declarative()
class BaseModel:
    metadata = MetaData(
        naming_convention={
            "ix": "ix_%(column_0_label)s",
            "uq": "uq_%(table_name)s_%(column_0_name)s",
            "ck": "ck_%(table_name)s_`%(constraint_name)s`",
            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
            "pk": "pk_%(table_name)s",
        }
    )

    id = Column(UUID(as_uuid=True), primary_key=True, index=True, unique=True, server_default=func.gen_random_uuid())

    @declared_attr
    def pk(self):
        return deferred(Column(Integer().evaluates_none(), primary_key=False, index=True, nullable=True))


class AssociationModel(BaseModel):
    __tablename__ = "association_table"
    __table_args__ = (UniqueConstraint("parent_id", "child_id"),)

    parent_id = Column(ForeignKey("parent.id", ondelete="CASCADE"))
    child_id = Column(ForeignKey("child.id", ondelete="CASCADE"))


class ParentModel(BaseModel):
    __tablename__ = "parent"

    child_set = relationship(
        "ChildModel",
        secondary="association_table",
        back_populates="parent_set",
        cascade="all, delete",
    )


class ChildModel(BaseModel):
    __tablename__ = "child"

    parent_set = relationship(
        "ParentModel",
        secondary="association_table",
        back_populates="child_set",
    )

And the database has these defaults for the tables:

                                              Table "public.parent"
          Column           |           Type           | Collation | Nullable |               Default                
---------------------------+--------------------------+-----------+----------+--------------------------------------
 pk                        | integer                  |           |          | nextval('parent_pk_seq'::regclass)
 id                        | uuid                     |           | not null | gen_random_uuid()

                                              Table "public.child"
          Column           |           Type           | Collation | Nullable |               Default                
---------------------------+--------------------------+-----------+----------+--------------------------------------
 pk                        | integer                  |           |          | nextval('child_pk_seq'::regclass)
 id                        | uuid                     |           | not null | gen_random_uuid()

Error

autogenerate creates a file with upgrade/downgrade function that contain either 0, 1, or 2 alter_columns:

def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.alter_column('parent', 'pk',
               existing_type=sa.INTEGER(),
               server_default=None,
               existing_nullable=True)
    op.alter_column('child', 'pk',
               existing_type=sa.INTEGER(),
               server_default=None,
               existing_nullable=True)
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.alter_column('child', 'pk',
               existing_type=sa.INTEGER(),
               server_default=sa.text("nextval('child_pk_seq'::regclass)"),
               existing_nullable=True)
    op.alter_column('parent', 'pk',
               existing_type=sa.INTEGER(),
               server_default=sa.text("nextval('parent_pk_seq'::regclass)"),
               existing_nullable=True)
    # ### end Alembic commands ###

For no operations, the log looks like this:

INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.ddl.postgresql] Detected sequence named 'parent_pk_seq' as owned by integer column 'parent(pk)', assuming SERIAL and omitting
INFO  [alembic.ddl.postgresql] Detected sequence named 'child_pk_seq' as owned by integer column 'child(pk)', assuming SERIAL and omitting
INFO  [alembic.ddl.postgresql] Detected sequence named 'association_table_pk_seq' as owned by integer column 'association_table(pk)', assuming SERIAL and omitting

For 1 operation it can look like this:

INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.ddl.postgresql] Detected sequence named 'parent_pk_seq' as owned by integer column 'parent(pk)', assuming SERIAL and omitting
INFO  [alembic.ddl.postgresql] Detected sequence named 'association_table_pk_seq' as owned by integer column 'association_table(pk)', assuming SERIAL and omitting
INFO  [alembic.autogenerate.compare] Detected server default on column 'child.pk'

Versions.

  • OS: debian
  • Python: 3.11
  • Alembic: 1.13.1
  • SQLAlchemy: 2.0.29
  • Database: postgresql 15.4
  • DBAPI: asyncpg 0.29.0

Additional context

The order in which the tables are reflected seems to matter and is random. The alter_column operations are added when association_table is reflected before the other(s). I played around with the resolve_fks: bool = True-argument in the functionreflect_table: with this boolean set to False, the output is consistent no matter the other the tables are reflected in. The log output can end up being:

INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.ddl.postgresql] Detected sequence named 'child_pk_seq' as owned by integer column 'child(pk)', assuming SERIAL and omitting
INFO  [alembic.ddl.postgresql] Detected sequence named 'association_table_pk_seq' as owned by integer column 'association_table(pk)', assuming SERIAL and omitting
INFO  [alembic.ddl.postgresql] Detected sequence named 'parent_pk_seq' as owned by integer column 'parent(pk)', assuming SERIAL and omitting

Although association_table is reflected before parent here, it does not result in an alter_column operation in the migration file.

When I looked at locals() inside autogenerate/compare.py::_compare_server_default, when child and/or parent was reflected thanks to resolve_fks=True, the variable conn_col_default had a value of DefaultClause(<sqlalchemy.sql.elements.TextClause object at 0x..........>, for_update=False) which holds the string nextval('child_pk_seq'::regclass) (or parent_pk_seq of course). When association_table is reflected last, or with resolve_fks=False, the variable conn_col_default is always None.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in autogenerate/compare.py::_compare_server_default and trace how conn_col_default is populated during reflect_table with resolve_fks=True or False. Reproduce the PostgreSQL 15 case using the supplied models and reflection order, then verify that repeated autogenerate runs produce the same result without spurious server_default changes.

Written by the indexing model from the issue text.

Assessment

Tech stack
postgresql, python, sqlalchemy
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.