sqlalchemy / sqlalchemy/alembic
autogenerate with compare_server_default=True and reflecting table randomly fails to set conn_col_default
Nobody has claimed this yet.
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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