Repository navigation
fix(sqlalchemy-spanner): resolve compliance and migration failures with Alembic 1.20 and SQLAlchemy 2.1 #18570
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
Changes from all commits
97621f1
149b4a9
38e9d11
34f5c02
81a2a58
dcc07be
a155597
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,14 +15,6 @@ | |
| import re | ||
|
|
||
| import sqlalchemy | ||
| from alembic.ddl.base import ( | ||
| ColumnNullable, | ||
| ColumnType, | ||
| alter_column, | ||
| alter_table, | ||
| format_server_default, | ||
| format_type, | ||
| ) | ||
| from google.api_core.client_options import ClientOptions | ||
| from google.auth.credentials import AnonymousCredentials | ||
| from google.cloud.spanner_v1 import Client, TransactionOptions | ||
|
|
@@ -51,6 +43,26 @@ | |
| from google.cloud.sqlalchemy_spanner import version as sqlalchemy_spanner_version | ||
| from google.cloud.sqlalchemy_spanner._opentelemetry_tracing import trace_call | ||
|
|
||
| # Defensively decouple the Alembic import so the Spanner dialect does not | ||
| # hard-depend on Alembic at runtime. A database dialect does not inherently | ||
| # require a schema migration tool, and consumers using only SQLAlchemy Core | ||
| # or ORM (or managing DDL outside of Alembic) can still import and use the | ||
| # dialect even if Alembic is omitted or unavailable in the environment | ||
| # (see #18584). | ||
| try: | ||
| from alembic.ddl.base import ( | ||
| ColumnNullable, | ||
| ColumnType, | ||
| alter_column, | ||
| alter_table, | ||
| format_server_default, | ||
| format_type, | ||
| ) | ||
|
|
||
| HAS_ALEMBIC_INSTALLED = True | ||
| except ImportError: | ||
| HAS_ALEMBIC_INSTALLED = False | ||
|
|
||
| USING_SQLACLCHEMY_20 = False | ||
| if sqlalchemy.__version__.split(".")[0] == "2": | ||
| USING_SQLACLCHEMY_20 = True | ||
|
|
@@ -1882,55 +1894,60 @@ def do_execute_no_params(self, cursor, statement, context=None): | |
| cursor.execute(statement) | ||
|
|
||
|
|
||
| # Alembic ALTER operation override | ||
| @compiles(ColumnNullable, "spanner+spanner") | ||
| def visit_column_nullable( | ||
| element: "ColumnNullable", compiler: "SpannerDDLCompiler", **kw | ||
| ) -> str: | ||
| return _format_alter_column( | ||
| compiler, | ||
| element.table_name, | ||
| element.schema, | ||
| element.column_name, | ||
| element.existing_type, | ||
| element.nullable, | ||
| element.existing_server_default, | ||
| ) | ||
|
|
||
|
|
||
| # Alembic ALTER operation override | ||
| @compiles(ColumnType, "spanner+spanner") | ||
| def visit_column_type( | ||
| element: "ColumnType", compiler: "SpannerDDLCompiler", **kw | ||
| ) -> str: | ||
| return _format_alter_column( | ||
| compiler, | ||
| element.table_name, | ||
| element.schema, | ||
| element.column_name, | ||
| element.type_, | ||
| element.existing_nullable, | ||
| element.existing_server_default, | ||
| ) | ||
| # Cloud Spanner requires ALTER TABLE ... ALTER COLUMN statements to specify the | ||
| # complete column definition (type, nullability, and default expression), whereas | ||
| # Alembic's default DDL compiler emits partial clauses (e.g., only SET NOT NULL | ||
| # or TYPE). Because the @compiles decorators reference Alembic's ColumnNullable | ||
| # and ColumnType classes at module import time, we only register these overrides | ||
| # when Alembic is available in the environment. | ||
| if HAS_ALEMBIC_INSTALLED: | ||
| # Alembic ALTER operation override | ||
| @compiles(ColumnNullable, "spanner+spanner") | ||
| def visit_column_nullable( | ||
| element: "ColumnNullable", compiler: "SpannerDDLCompiler", **kw | ||
| ) -> str: | ||
| return _format_alter_column( | ||
| compiler, | ||
| element.table_name, | ||
| element.schema, | ||
| element.column_name, | ||
| element.existing_type, | ||
| element.nullable, | ||
| element.existing_server_default, | ||
| ) | ||
|
|
||
| # Alembic ALTER operation override | ||
| @compiles(ColumnType, "spanner+spanner") | ||
| def visit_column_type( | ||
| element: "ColumnType", compiler: "SpannerDDLCompiler", **kw | ||
| ) -> str: | ||
| return _format_alter_column( | ||
| compiler, | ||
| element.table_name, | ||
| element.schema, | ||
| element.column_name, | ||
| element.type_, | ||
| element.existing_nullable, | ||
| element.existing_server_default, | ||
| ) | ||
|
|
||
| def _format_alter_column( | ||
| compiler, table_name, schema, column_name, type_, nullable, server_default | ||
| ): | ||
| # Older versions of SQLAlchemy pass in a boolean to indicate whether there | ||
| # is an existing DEFAULT constraint, instead of the actual DEFAULT constraint | ||
| # expression. In those cases, we do not want to explicitly include the DEFAULT | ||
| # constraint in the expression that is generated here. | ||
| if isinstance(server_default, bool): | ||
| server_default = None | ||
| return "%s %s %s%s%s" % ( | ||
| alter_table(compiler, table_name, schema), | ||
| alter_column(compiler, column_name), | ||
| format_type(compiler, type_), | ||
| "" if nullable else " NOT NULL", | ||
| ( | ||
| "" | ||
| if server_default is None | ||
| else f" DEFAULT {format_server_default(compiler, server_default)}" | ||
| ), | ||
| ) | ||
| def _format_alter_column( | ||
| compiler, table_name, schema, column_name, type_, nullable, server_default | ||
| ): | ||
| # Older versions of SQLAlchemy pass in a boolean to indicate whether there | ||
| # is an existing DEFAULT constraint, instead of the actual DEFAULT constraint | ||
| # expression. In those cases, we do not want to explicitly include the DEFAULT | ||
| # constraint in the expression that is generated here. | ||
| if isinstance(server_default, bool): | ||
| server_default = None | ||
| return "%s %s %s%s%s" % ( | ||
| alter_table(compiler, table_name, schema), | ||
| alter_column(compiler, column_name), | ||
| format_type(compiler, type_), | ||
| "" if nullable else " NOT NULL", | ||
| ( | ||
| "" | ||
| if server_default is None | ||
| else f" DEFAULT {format_server_default(compiler, server_default)}" | ||
| ), | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think we need to add any version guards to setup.py? Right now, there are no upper-bound limits, and alembic doesn't have any version pin at all. It seems like that could lead to more versioning issues in the future, if a major update comes out and breaks things overnight
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RESOLVED |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels misleading, since alembic is a required dependency, so we should be able to trust that it was installed. It seems like the ImportError is actually thrown here when the environment has a version compatibility mismatch?
I'm a little confused about how that would happen in practice, and how we should best guard against it. Because we should be able to trust the package managers to sort this out for us
Is this really a problem with the package, or do we just have a broken test environment? Maybe we just need to solve the contradictions in our nox installations?
Or, should we change alembic to an optional dependency, and keep this fall-back code?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@daniel-sanche
You are correct that package managers prevent this mismatch in normal customer installs and our nox script had some long standing inefficiencies that broke due to updates in upstream dependencies. However, I would like to keep the guard as defensive decoupling:
--no-deps)sqlalchemy-bigquery.See: sqlalchemy-spanner Make alembic an optional extra dependency
In the short term we will add version upper bounds (
sqlalchemy<3.0.0,alembic<2.0.0) insetup.py.Note
For context, it appears that
alembicwas originally installed as a hard dependency because someone putalembicinto dependencies purely becausesqlalchemy_spanner.pyhad unconditional@compilesdecorators that imported fromalembic.ddl.baseat the top level. That code forced the packaging requirement, rather than the packaging requirement dictating the code.