Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

literal_column() in ExcludeConstraint are autogenerated as column(), failing in the migration #1184

Closed
jankatins opened this issue Feb 21, 2023 · 24 comments
Labels

Comments

@jankatins
Copy link
Contributor

jankatins commented Feb 21, 2023

Describe the bug

If you add a literal_column in a ExcludeConstraint (e.g. a literal_column("int8range(col_from, col_to)")), this is rendered as column("int8range(col_from, col_to)") in the autogenerated migration and the migration will fail as such a column does not exist in the table.

Expected behavior

literal_columns are passed through in the rendered migration

To Reproduce

from sqlalchemy.dialects.postgresql import JSONB, ExcludeConstraint
from sqlalchemy.sql.expression import column, literal_column
from sqlalchemy import Column, Table
from sqlalchemy.orm import declarative_base
from sqlalchemy.sql.schema import MetaData
from sqlalchemy.sql.sqltypes import *

Base2 = declarative_base()
whatever2 = Table(
    "whatever2",
    Base2.metadata,
    Column("id", BigInteger(), nullable=False, primary_key=True),
    ExcludeConstraint(
        ("id", "="),
        (literal_column("id + 2"), "="),
        name=f"whatever_id_int8range_excl",
    ),
)

Error

The migration code after alembic revision --autogenerate -m "test2"looks like this:

def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('whatever2',
    sa.Column('id', sa.BigInteger(), nullable=False),
    postgresql.ExcludeConstraint((sa.column('id'), '='), (sa.column('id + 2'), '='), using='gist', name='whatever_id_int8range_excl'),
    sa.PrimaryKeyConstraint('id')
    )

running this migration via alembic upgrade head results in:

psycopg2.errors.UndefinedColumn: column "id + 2" named in key does not exist


The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/bin/alembic", line 8, in <module>
    sys.exit(main())
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/config.py", line 591, in main
    CommandLine(prog=prog).main(argv=argv)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/config.py", line 585, in main
    self.run_cmd(cfg, options)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/config.py", line 562, in run_cmd
    fn(
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/command.py", line 378, in upgrade
    script.run_env()
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/script/base.py", line 569, in run_env
    util.load_python_file(self.dir, "env.py")
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/util/pyfiles.py", line 94, in load_python_file
    module = load_module_py(module_id, path)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/util/pyfiles.py", line 110, in load_module_py
    spec.loader.exec_module(module)  # type: ignore
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/Users/jankatins/projects/50hz/mds-elt-service/alembic/env.py", line 115, in <module>
    run_migrations_online()
  File "/Users/jankatins/projects/50hz/mds-elt-service/alembic/env.py", line 109, in run_migrations_online
    context.run_migrations()
  File "<string>", line 8, in run_migrations
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/runtime/environment.py", line 867, in run_migrations
    self.get_context().run_migrations(**kw)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/runtime/migration.py", line 624, in run_migrations
    step.migration_fn(**kw)
  File "/Users/jankatins/projects/50hz/mds-elt-service/alembic/versions/f7aae215babc_test2.py", line 30, in upgrade
    op.create_table('whatever2',
  File "<string>", line 8, in create_table
  File "<string>", line 3, in create_table
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/operations/ops.py", line 1260, in create_table
    return operations.invoke(op)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/operations/base.py", line 401, in invoke
    return fn(self, operation)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/operations/toimpl.py", line 114, in create_table
    operations.impl.create_table(table)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/ddl/impl.py", line 354, in create_table
    self._exec(schema.CreateTable(table))
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/alembic/ddl/impl.py", line 193, in _exec
    return conn.execute(  # type: ignore[call-overload]
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1380, in execute
    return meth(self, multiparams, params, _EMPTY_EXECUTION_OPTS)
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/sql/ddl.py", line 80, in _execute_on_connection
    return connection._execute_ddl(
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1472, in _execute_ddl
    ret = self._execute_context(
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1943, in _execute_context
    self._handle_dbapi_exception(
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 2124, in _handle_dbapi_exception
    util.raise_(
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/util/compat.py", line 211, in raise_
    raise exception
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
    self.dialect.do_execute(
  File "/Users/jankatins/Library/Caches/pypoetry/virtualenvs/mds-elt-service-JeB-l5pZ-py3.10/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 736, in do_execute
    cursor.execute(statement, parameters)
sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedColumn) column "id + 2" named in key does not exist

[SQL:
CREATE TABLE whatever2 (
        id BIGSERIAL NOT NULL,
        PRIMARY KEY (id),
        CONSTRAINT whatever_id_int8range_excl EXCLUDE USING gist (id WITH =, "id + 2" WITH =)
)

]
(Background on this error at: https://sqlalche.me/e/14/f405)

Versions.

  • OS: Macosx latest
  • Python: 3.10
  • Alembic: alembic==1.9.4
  • SQLAlchemy: SQLAlchemy==1.4.46
  • Database: postgresql 14
  • DBAPI: psycopg2-binary==2.9.5
@jankatins jankatins added the requires triage New issue that requires categorization label Feb 21, 2023
@jankatins
Copy link
Contributor Author

Suggested fix is in #1185

@jankatins jankatins changed the title literal_column in exclusion constraint are autogenerated as columns, failing in the migration literal_column() in ExcludeConstraint are autogenerated as column(), failing in the migration Feb 21, 2023
@CaselIT
Copy link
Member

CaselIT commented Feb 21, 2023

Hi,

thanks for reporting. That looks indeed like a bug.
does passing text() works in this case?
A string is interpreted as a column by alembic and that may actually be wrong, at least according to the docs at sqlalchemy https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#sqlalchemy.dialects.postgresql.ExcludeConstraint.params.*elements "or a raw SQL string". Looking at the implementation in sqlalchemy I think the assumption there is also that strings are columns, so the docs may be improved. (trying to use ExcludeConstraint(("id + 2", "=")) does indeed error)

@CaselIT CaselIT added bug Something isn't working postgresql and removed requires triage New issue that requires categorization labels Feb 21, 2023
@jankatins
Copy link
Contributor Author

Adding text() around it makes it even worse:

- postgresql.ExcludeConstraint((sa.column('branch_id'), '='), (sa.column('int8range(commit_id_from, commit_id_to)'), '&&'), (sa.column('mrid'), '='), using='gist', name='whatever_branch_id_int8range_mrid_excl'),
+ postgresql.ExcludeConstraint((sa.column('branch_id'), '='), (sa.column('mrid'), '='), using='gist', name='whatever_branch_id_int8range_mrid_excl'),

-> The whole element is removed :-(

@jankatins
Copy link
Contributor Author

jankatins commented Feb 21, 2023

If I remember correctly, that was due to coercions.expect_col_expression_collection() not returning something in the add_element position here:

https://github.com/sqlalchemy/sqlalchemy/blob/5147bf8e3cb321b8728b3af5bb0b2644995b3793/lib/sqlalchemy/dialects/postgresql/ext.py#L218-L225

        for (expr, column, strname, add_element), operator in zip(
            coercions.expect_col_expression_collection(
                roles.DDLConstraintColumnRole, expressions
            ),
            operators,
        ):
            if add_element is not None:
                columns.append(add_element)

@CaselIT
Copy link
Member

CaselIT commented Feb 21, 2023

I'll take a look at how sqlalchemy behaves with text.

@zzzeek do you think text should be supported?

@jankatins
Copy link
Contributor Author

jankatins commented Feb 21, 2023

One thing which should IMO be changed is that ExcludeConstraint.__init__() errors when an expression which got passed in is not actually applied.

E.g.

if add_element is not None:
    columns.append(add_element)
else:
    RuntimeError(...)

debugging that part was a real pain: I tried using functions func.int8range("col", "col") instead of literal_column first and that simply vanished like text(). And adding columns within the func func.int8range(column("col"), column("col")) was ending up as a single column without the int8range around it (reason: in that generator there is a visitor going into the func, pulling out the two columns and only using the first)...

@CaselIT
Copy link
Member

CaselIT commented Feb 22, 2023

That's something that indeed should be improved.
That issue with only a single column being considered is somewhat similar to this sqlalchemy/sqlalchemy#9233 (I haven't checked to see if it's the same function that gets called, but it may very well be)

let's wait for mike's opinion on these other things.

currently on sqlalchemy we can improve the documentation indicating that ExcludeConstrain does not accpect an SQL string but the name of a column as string

@zzzeek
Copy link
Member

zzzeek commented Feb 22, 2023

I'll take a look at how sqlalchemy behaves with text.

@zzzeek do you think text should be supported?

I would look to see what rendering for Index and CheckConstraint do. Whatever is implemented for ExcludeConstraint should work for those as well otherwise we are being inconsistent.

@jankatins
Copy link
Contributor Author

Re test cases:

for case in (
    literal_column("int8range(a,b)"),
    func.int8range("a","b"),
    func.int8range(column("a"),column("b")),
    text("int8range(a,b)"), # ?
):
    ExcludeConstraint(
        (case, "&&"),
        name=f"whatever_int8range_excl",
    ),

the end resulting object in sqlalchemy and the migration SQL via alembic should look the same, shouldn't it?

@CaselIT
Copy link
Member

CaselIT commented Feb 22, 2023

based on how are currently implemented constraints supporting just literal_column("int8range(a,b)") may be easier.

In any case what we can do is better document it and error in case of an unsupported case instead behaving like now that results in a more or less broken state

@CaselIT
Copy link
Member

CaselIT commented Feb 22, 2023

I'll take a look at how sqlalchemy behaves with text.
@zzzeek do you think text should be supported?

I would look to see what rendering for Index and CheckConstraint do. Whatever is implemented for ExcludeConstraint should work for those as well otherwise we are being inconsistent.

The rendering seems fine for the most part, but the exclusion constraint does not use literal binding, as evidenced by the wrong function rendereing

In both index and exclude constraint using 'a+1' does not work as string, you need text / column / literal column.

import sqlalchemy as sa
from sqlalchemy.schema import CreateTable, CreateIndex
from sqlalchemy.dialects.postgresql import dialect, ExcludeConstraint

m = sa.MetaData()
t = sa.Table(
    "x",
    m,
    sa.Column("id", sa.Integer),
    sa.Column("a", sa.Integer),
    sa.Column("b", sa.Integer),
    idx := sa.Index(
        "my-idx",
        "a",
        sa.text("a+1"),
        sa.column("b"),
        sa.literal_column("a+b"),
        sa.func.power("a", 2),
    ),
    ex := ExcludeConstraint(
        ("a", "="),
        (sa.text("a+1"), "="),
        (sa.column("b"), "&&"),
        (sa.literal_column("a+b"), "^^"),
        (sa.func.power(sa.column("a"), 2), '!'),
        name="my-ex",
    ),
)

print(CreateTable(t).compile(dialect=dialect()).string)
print(CreateIndex(idx).compile(dialect=dialect()).string)
print("index cols", idx.columns)
print("exclude cols", ex.columns)

The following prints

CREATE TABLE x (
        id INTEGER,
        a INTEGER,
        b INTEGER,
        CONSTRAINT "my-ex" EXCLUDE USING gist (a WITH =, "a+1" WITH =, b WITH &&, a+b WITH ^^, power(a, %(power_1)s) WITH !)
)


CREATE INDEX "my-idx" ON x (a, a+1, b, a+b, power('a', 2))
index cols ReadOnlyColumnCollection(x.a, b, a+b)
exclude cols ReadOnlyColumnCollection(a, "a+1", b, a+b)

So there is currently for sure a bug in alembic regarding exclude constraint since it does not consider text. Not sure about indexes

@CaselIT
Copy link
Member

CaselIT commented Feb 22, 2023

Forgot to check func. Updated the example above

@CaselIT
Copy link
Member

CaselIT commented Feb 22, 2023

The issue with literal binds was fixed in https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/4462.
I've also tried to improve the documentation in the docs of ExcludeConstraint

@jankatins
Copy link
Contributor Author

jankatins commented Feb 23, 2023

@CaselIT Thanks, looks good. As a sqlalachemy beginner, I would appreciate an example what a SQL expression element is.

E.g.

A sequence of two tuples of the form ``(column, operator)`` where 
"column" is either a :class:`_schema.Column` object, or a 
SQL expression element (e.g. ``func.int8range(table.from, table.to)``) or 
the name of a column as string. "operator" is a string containing the 
operator to use (e.g. `"&&"` or `"="`).  

In order to specify a column name when a :class:`_schema.Column`
object is not available, while ensuring that any necessary quoting rules
take effect, an ad-hoc :class:`_schema.Column` or 
:func:`_expression.column` object should be used. 
``column`` may also be a string SQL expression when 
passed as :func:`_expression.literal_column`

( I assume text() is out? Or should the last part include a or :func:_expression.text`)

(from https://gerrit.sqlalchemy.org/c/sqlalchemy/sqlalchemy/+/4462/1/lib/sqlalchemy/dialects/postgresql/ext.py#167)

@jankatins
Copy link
Contributor Author

BTW: is #1185 of use or should I close it? It's currently in limbo because the gerrit check is not started.

@CaselIT
Copy link
Member

CaselIT commented Feb 23, 2023

BTW: is #1185 of use or should I close it? It's currently in limbo because the gerrit check is not started.

I've yet to look at it sorry.

@CaselIT
Copy link
Member

CaselIT commented Feb 23, 2023

thanks for the suggestion, I'll integrate it in the docs

( I assume text() is out? Or should the last part include a or :func:_expression.text`)

text seems to work too. We may need to support in in alembic though

@jankatins
Copy link
Contributor Author

We may need to support in in alembic though

I can add that to my PR. It's should be treated the same as literal_column() isn't it?

@CaselIT
Copy link
Member

CaselIT commented Feb 23, 2023

We may need to support in in alembic though

I can add that to my PR. It's should be treated the same as literal_column() isn't it?

yes it should, it seems to behave the same, with the only difference being that litaral_column appears in constraint.columns while text does not. But otherwise the rendering is the same

Not sure if it makes sense make alembic render them as the same or keep the difference

@zzzeek
Copy link
Member

zzzeek commented Feb 24, 2023

so the bug is not here, it's sqlalchemy/sqlalchemy#9349 and this can be closed, is that accurate?

@zzzeek zzzeek added external SQLAlchemy issues the issue is in SQLAlchemy, not here in alembic awaiting info waiting for the submitter to give more information labels Feb 24, 2023
@CaselIT
Copy link
Member

CaselIT commented Feb 24, 2023

Well if sqlalchemy accepts literal_columns and text in exclude contraints, alembic should too, so the issue here is valid

@zzzeek
Copy link
Member

zzzeek commented Feb 24, 2023

to be clear, you mean in the autogenerate Python rendering as I've labeled, is that correct?

@zzzeek zzzeek removed external SQLAlchemy issues the issue is in SQLAlchemy, not here in alembic awaiting info waiting for the submitter to give more information labels Feb 24, 2023
@CaselIT
Copy link
Member

CaselIT commented Feb 24, 2023

Yes, other that that I don't think there are other issues here

@sqla-tester
Copy link
Collaborator

Jan Katins has proposed a fix for this issue in the main branch:

improve autogen rendering for PG ExcludeConstraint https://gerrit.sqlalchemy.org/c/sqlalchemy/alembic/+/4493

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants