Skip to content

Commit 1b0e5c5

Browse files
Allow keeping PostgresHook SQLAlchemy engines on psycopg2
Provider 7.0.0 switched hook-built SQLAlchemy engines to psycopg (v3) whenever SQLAlchemy 2.x is installed, with no opt-out. The psycopg dialect renders typed bind casts, so string parameters PostgreSQL previously coerced implicitly now fail server-side — most visibly pandas.DataFrame.to_sql into uuid columns (#71977). Honor the existing DbApiHook sqlalchemy_scheme connection extra (and hook parameter) so connections that rely on psycopg2 behaviour can keep it.
1 parent 2811044 commit 1b0e5c5

4 files changed

Lines changed: 82 additions & 2 deletions

File tree

providers/postgres/docs/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ Breaking changes
108108
There is no connection or configuration option to keep hooks on psycopg2; the
109109
``sql_alchemy_conn`` workarounds above cover the metadata database only. If your Dags rely on
110110
psycopg2-specific behaviour, test before upgrading or pin the provider below 7.0.0.
111+
Starting with provider 7.1.0, the SQLAlchemy engines built by the hook can be kept on
112+
psycopg2 by setting the ``sqlalchemy_scheme`` connection extra (or hook parameter) to
113+
``postgresql+psycopg2``.
111114

112115
* ``Make psycopg (v3) the default synchronous Postgres driver (#69526)``
113116
* ``Switch the default async Postgres driver from asyncpg to psycopg3 (#69089)``

providers/postgres/docs/connections/postgres.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ Extra (optional)
108108
If not specified than hostname from **Connection Host** is used.
109109
* ``azure_conn_id`` - Azure Connection ID to be used for authentication via Azure Entra ID. Azure Oauth token
110110
is retrieved from the azure connection which is used as password for PostgreSQL connection. Scope for the Azure OAuth token can be set in the config option ``azure_oauth_scope`` under the section ``[postgres]``. Requires `apache-airflow-providers-microsoft-azure>=12.8.0`.
111+
* ``sqlalchemy_scheme`` - The SQLAlchemy drivername used for the URLs the hook builds
112+
(``get_uri``, ``get_sqlalchemy_engine``). Must be ``postgresql`` or ``postgresql+<driver>``.
113+
Since provider 7.0.0 the hook selects psycopg (v3) whenever SQLAlchemy 2.x is installed;
114+
set this to ``postgresql+psycopg2`` to keep SQLAlchemy engines created from this connection
115+
on psycopg2. This matters if your Dags rely on psycopg2-specific behaviour — for example,
116+
psycopg2 sends string parameters untyped so PostgreSQL implicitly coerces them, while
117+
the psycopg (v3) SQLAlchemy dialect renders typed casts, so inserting string values into
118+
e.g. ``uuid`` columns (as ``pandas.DataFrame.to_sql`` does) fails with
119+
*"column is of type uuid but expression is of type character varying"*.
111120

112121
Example "extras" field (Amazon RDS PostgreSQL or Amazon Aurora PostgreSQL):
113122

providers/postgres/src/airflow/providers/postgres/hooks/postgres.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ class PostgresHook(DbApiHook):
135135
:param options: Optional. Specifies command-line options to send to the server
136136
at connection start. For example, setting this to ``-c search_path=myschema``
137137
sets the session's value of the ``search_path`` to ``myschema``.
138+
:param sqlalchemy_scheme: Optional. The SQLAlchemy drivername used for the URLs the hook
139+
builds (``get_uri``, ``get_sqlalchemy_engine``), e.g. ``postgresql+psycopg2``. Must be
140+
``postgresql`` or ``postgresql+<driver>``. Defaults to ``postgresql+psycopg`` when
141+
psycopg (v3) serves SQLAlchemy 2.x and to ``postgresql`` otherwise. Can also be set via
142+
the connection extra ``sqlalchemy_scheme``; this parameter takes precedence.
138143
:param enable_log_db_messages: Optional. If enabled logs database messages sent to the client
139144
during the session. To avoid a memory leak psycopg2 only saves the last 50 messages.
140145
For details, see: `PostgreSQL logging configuration parameters
@@ -164,18 +169,39 @@ class PostgresHook(DbApiHook):
164169
default_azure_oauth_scope = "https://ossrdbms-aad.database.windows.net/.default"
165170

166171
def __init__(
167-
self, *args, options: str | None = None, enable_log_db_messages: bool = False, **kwargs
172+
self,
173+
*args,
174+
options: str | None = None,
175+
enable_log_db_messages: bool = False,
176+
sqlalchemy_scheme: str | None = None,
177+
**kwargs,
168178
) -> None:
169179
super().__init__(*args, **kwargs)
170180
self.conn: CompatConnection | None = None
171181
self.database: str | None = kwargs.pop("database", None)
172182
self.options = options
173183
self.enable_log_db_messages = enable_log_db_messages
184+
self._sqlalchemy_scheme = sqlalchemy_scheme
174185

175186
@staticmethod
176187
def __cast_nullable(value, dst_type: type) -> Any:
177188
return dst_type(value) if value is not None else None
178189

190+
@property
191+
def sqlalchemy_scheme(self) -> str:
192+
"""SQLAlchemy drivername used for the URLs built by this hook."""
193+
scheme = self._sqlalchemy_scheme or self.connection.extra_dejson.get("sqlalchemy_scheme")
194+
if not scheme:
195+
return "postgresql+psycopg" if USE_PSYCOPG3 else "postgresql"
196+
if ":" in scheme or "/" in scheme:
197+
raise ValueError("The parameter 'sqlalchemy_scheme' must not contain ':' or '/' characters!")
198+
if scheme != "postgresql" and not scheme.startswith("postgresql+"):
199+
raise ValueError(
200+
f"The parameter 'sqlalchemy_scheme' must be 'postgresql' or 'postgresql+<driver>', "
201+
f"got: {scheme!r}"
202+
)
203+
return scheme
204+
179205
@property
180206
def sqlalchemy_url(self) -> URL:
181207
try:
@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
192218
if conn.extra_dejson.get("iam", False):
193219
conn.login, conn.password, conn.port = self.get_iam_token(conn)
194220
return URL.create(
195-
drivername="postgresql+psycopg" if USE_PSYCOPG3 else "postgresql",
221+
drivername=self.sqlalchemy_scheme,
196222
username=self.__cast_nullable(conn.login, str),
197223
password=self.__cast_nullable(conn.password, str),
198224
host=self.__cast_nullable(conn.host, str),

providers/postgres/tests/unit/postgres/hooks/test_postgres.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,48 @@ def test_get_conn_cursor(self, mocker):
633633
)
634634

635635

636+
class TestPostgresHookSqlalchemyScheme:
637+
"""Tests for overriding the SQLAlchemy drivername via the sqlalchemy_scheme extra/parameter."""
638+
639+
@staticmethod
640+
def get_hook(extra: dict | None = None, **hook_kwargs) -> PostgresHook:
641+
conn = Connection(
642+
login="login-conn", password="password-conn", host="host", schema="database", extra=extra
643+
)
644+
return PostgresHook(connection=conn, **hook_kwargs)
645+
646+
@pytest.mark.parametrize("scheme", ["postgresql", "postgresql+psycopg2", "postgresql+psycopg"])
647+
def test_sqlalchemy_scheme_from_extra(self, scheme):
648+
hook = self.get_hook(extra=dict(sqlalchemy_scheme=scheme))
649+
expected = f"{scheme}://login-conn:password-conn@host/database"
650+
assert hook.sqlalchemy_url.render_as_string(hide_password=False) == expected
651+
652+
def test_sqlalchemy_scheme_parameter_takes_precedence_over_extra(self):
653+
hook = self.get_hook(
654+
extra=dict(sqlalchemy_scheme="postgresql"), sqlalchemy_scheme="postgresql+psycopg2"
655+
)
656+
expected = "postgresql+psycopg2://login-conn:password-conn@host/database"
657+
assert hook.sqlalchemy_url.render_as_string(hide_password=False) == expected
658+
659+
def test_get_uri_with_sqlalchemy_scheme(self):
660+
hook = self.get_hook(extra=dict(sqlalchemy_scheme="postgresql+psycopg2"))
661+
assert hook.get_uri() == "postgresql+psycopg2://login-conn:password-conn@host/database"
662+
663+
@pytest.mark.parametrize("scheme", ["mysql", "mysql+pymysql", "postgres+psycopg2"])
664+
def test_sqlalchemy_scheme_with_wrong_dialect(self, scheme):
665+
hook = self.get_hook(extra=dict(sqlalchemy_scheme=scheme))
666+
with pytest.raises(
667+
ValueError, match="'sqlalchemy_scheme' must be 'postgresql' or 'postgresql\\+<driver>'"
668+
):
669+
hook.sqlalchemy_url
670+
671+
@pytest.mark.parametrize("scheme", ["postgresql+psycopg2://malicious", "postgresql+psycopg2/malicious"])
672+
def test_sqlalchemy_scheme_with_forbidden_characters(self, scheme):
673+
hook = self.get_hook(extra=dict(sqlalchemy_scheme=scheme))
674+
with pytest.raises(ValueError, match="must not contain ':' or '/' characters"):
675+
hook.sqlalchemy_url
676+
677+
636678
@pytest.mark.backend("postgres")
637679
class TestPostgresHook:
638680
"""Tests that are identical between psycopg2 and psycopg3."""

0 commit comments

Comments
 (0)