This is an automated email from the ASF dual-hosted git repository.

ashb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2111287e087 Allow keeping PostgresHook SQLAlchemy engines on psycopg2 
(#72000)
2111287e087 is described below

commit 2111287e0875e17b962833f9c8335ff7c6435813
Author: Blahaj <[email protected]>
AuthorDate: Tue Sep 22 17:50:18 2026 +0530

    Allow keeping PostgresHook SQLAlchemy engines on psycopg2 (#72000)
    
    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.
---
 providers/postgres/docs/connections/postgres.rst   |  9 +++
 .../airflow/providers/postgres/hooks/postgres.py   | 30 +++++++-
 .../tests/unit/postgres/hooks/test_postgres.py     | 87 ++++++++++++++++++++++
 3 files changed, 124 insertions(+), 2 deletions(-)

diff --git a/providers/postgres/docs/connections/postgres.rst 
b/providers/postgres/docs/connections/postgres.rst
index 3018769afbc..478fcc63085 100644
--- a/providers/postgres/docs/connections/postgres.rst
+++ b/providers/postgres/docs/connections/postgres.rst
@@ -108,6 +108,15 @@ Extra (optional)
       If not specified than hostname from **Connection Host** is used.
     * ``azure_conn_id`` - Azure Connection ID to be used for authentication 
via Azure Entra ID. Azure Oauth token
       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`.
+    * ``sqlalchemy_scheme`` - The SQLAlchemy ``drivername`` used for the URLs 
the hook builds
+      (``get_uri``, ``get_sqlalchemy_engine``). Must be ``postgresql`` or 
``postgresql+<driver>``.
+      Since provider 7.0.0 the hook selects psycopg (v3) whenever SQLAlchemy 
2.x is installed;
+      set this to ``postgresql+psycopg2`` to keep SQLAlchemy engines created 
from this connection
+      on psycopg2. This matters if your Dags rely on psycopg2-specific 
behaviour — for example,
+      psycopg2 sends string parameters without a type so PostgreSQL implicitly 
coerces them, while
+      the psycopg (v3) SQLAlchemy dialect renders typed casts, so inserting 
string values into
+      e.g. ``uuid`` columns (as ``pandas.DataFrame.to_sql`` does) fails with
+      *"column is of type uuid but expression is of type character varying"*.
 
     Example "extras" field (Amazon RDS PostgreSQL or Amazon Aurora PostgreSQL):
 
diff --git 
a/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py 
b/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
index 89e972a3f82..7f8064c9b2b 100644
--- a/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
+++ b/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
@@ -135,6 +135,11 @@ class PostgresHook(DbApiHook):
     :param options: Optional. Specifies command-line options to send to the 
server
         at connection start. For example, setting this to ``-c 
search_path=myschema``
         sets the session's value of the ``search_path`` to ``myschema``.
+    :param sqlalchemy_scheme: Optional. The SQLAlchemy ``drivername`` used for 
the URLs the hook
+        builds (``get_uri``, ``get_sqlalchemy_engine``), e.g. 
``postgresql+psycopg2``. Must be
+        ``postgresql`` or ``postgresql+<driver>``. Defaults to 
``postgresql+psycopg`` when
+        psycopg (v3) serves SQLAlchemy 2.x and to ``postgresql`` otherwise. 
Can also be set via
+        the connection extra ``sqlalchemy_scheme``; this parameter takes 
precedence.
     :param enable_log_db_messages: Optional. If enabled logs database messages 
sent to the client
         during the session. To avoid a memory leak psycopg2 only saves the 
last 50 messages.
         For details, see: `PostgreSQL logging configuration parameters
@@ -164,18 +169,39 @@ class PostgresHook(DbApiHook):
     default_azure_oauth_scope = 
"https://ossrdbms-aad.database.windows.net/.default";
 
     def __init__(
-        self, *args, options: str | None = None, enable_log_db_messages: bool 
= False, **kwargs
+        self,
+        *args,
+        options: str | None = None,
+        enable_log_db_messages: bool = False,
+        sqlalchemy_scheme: str | None = None,
+        **kwargs,
     ) -> None:
         super().__init__(*args, **kwargs)
         self.conn: CompatConnection | None = None
         self.database: str | None = kwargs.pop("database", None)
         self.options = options
         self.enable_log_db_messages = enable_log_db_messages
+        self._sqlalchemy_scheme = sqlalchemy_scheme
 
     @staticmethod
     def __cast_nullable(value, dst_type: type) -> Any:
         return dst_type(value) if value is not None else None
 
+    @property
+    def sqlalchemy_scheme(self) -> str:
+        """SQLAlchemy ``drivername`` used for the URLs built by this hook."""
+        scheme = self._sqlalchemy_scheme or 
self.connection.extra_dejson.get("sqlalchemy_scheme")
+        if not scheme:
+            return "postgresql+psycopg" if USE_PSYCOPG3 else "postgresql"
+        if ":" in scheme or "/" in scheme:
+            raise ValueError("The parameter 'sqlalchemy_scheme' must not 
contain ':' or '/' characters!")
+        if scheme != "postgresql" and not scheme.startswith("postgresql+"):
+            raise ValueError(
+                f"The parameter 'sqlalchemy_scheme' must be 'postgresql' or 
'postgresql+<driver>', "
+                f"got: {scheme!r}"
+            )
+        return scheme
+
     @property
     def sqlalchemy_url(self) -> URL:
         try:
@@ -192,7 +218,7 @@ class PostgresHook(DbApiHook):
         if conn.extra_dejson.get("iam", False):
             conn.login, conn.password, conn.port = self.get_iam_token(conn)
         return URL.create(
-            drivername="postgresql+psycopg" if USE_PSYCOPG3 else "postgresql",
+            drivername=self.sqlalchemy_scheme,
             username=self.__cast_nullable(conn.login, str),
             password=self.__cast_nullable(conn.password, str),
             host=self.__cast_nullable(conn.host, str),
diff --git a/providers/postgres/tests/unit/postgres/hooks/test_postgres.py 
b/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
index 12fe869a212..55fcb03d106 100644
--- a/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
+++ b/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
@@ -633,6 +633,93 @@ class TestPostgresHookConnPPG3:
         )
 
 
+class TestPostgresHookSqlalchemyScheme:
+    """Tests for overriding the SQLAlchemy ``drivername`` via the 
sqlalchemy_scheme extra/parameter."""
+
+    @staticmethod
+    def get_hook(extra: dict | None = None, **hook_kwargs) -> PostgresHook:
+        conn = Connection(
+            login="login-conn", password="password-conn", host="host", 
schema="database", extra=extra
+        )
+        return PostgresHook(connection=conn, **hook_kwargs)
+
+    @pytest.mark.parametrize("scheme", ["postgresql", "postgresql+psycopg2", 
"postgresql+psycopg"])
+    def test_sqlalchemy_scheme_from_extra(self, scheme):
+        hook = self.get_hook(extra=dict(sqlalchemy_scheme=scheme))
+        expected = f"{scheme}://login-conn:password-conn@host/database"
+        assert hook.sqlalchemy_url.render_as_string(hide_password=False) == 
expected
+
+    def test_sqlalchemy_scheme_parameter_takes_precedence_over_extra(self):
+        hook = self.get_hook(
+            extra=dict(sqlalchemy_scheme="postgresql"), 
sqlalchemy_scheme="postgresql+psycopg2"
+        )
+        expected = 
"postgresql+psycopg2://login-conn:password-conn@host/database"
+        assert hook.sqlalchemy_url.render_as_string(hide_password=False) == 
expected
+
+    def test_get_uri_with_sqlalchemy_scheme(self):
+        hook = 
self.get_hook(extra=dict(sqlalchemy_scheme="postgresql+psycopg2"))
+        assert hook.get_uri() == 
"postgresql+psycopg2://login-conn:password-conn@host/database"
+
+    @pytest.mark.parametrize("scheme", ["mysql", "mysql+pymysql", 
"postgres+psycopg2"])
+    def test_sqlalchemy_scheme_with_wrong_dialect(self, scheme):
+        hook = self.get_hook(extra=dict(sqlalchemy_scheme=scheme))
+        with pytest.raises(
+            ValueError, match="'sqlalchemy_scheme' must be 'postgresql' or 
'postgresql\\+<driver>'"
+        ):
+            hook.sqlalchemy_url
+
+    @pytest.mark.parametrize("scheme", ["postgresql+psycopg2://malicious", 
"postgresql+psycopg2/malicious"])
+    def test_sqlalchemy_scheme_with_forbidden_characters(self, scheme):
+        hook = self.get_hook(extra=dict(sqlalchemy_scheme=scheme))
+        with pytest.raises(ValueError, match="must not contain ':' or '/' 
characters"):
+            hook.sqlalchemy_url
+
+
[email protected]("postgres")
+class TestPostgresHookPandasToSqlUuid:
+    """DataFrame.to_sql with string values into a uuid column: fails on 
psycopg3 because SQLAlchemy
+    renders typed bind casts, works when the connection opts back into 
psycopg2 via sqlalchemy_scheme."""
+
+    table = "test_pandas_to_sql_uuid_table"
+
+    PSYCOPG3_XFAIL_REASON = (
+        "The psycopg3 SQLAlchemy dialect renders typed bind casts, so string 
params fail with "
+        "'column is of type uuid but expression is of type character varying' 
instead of being "
+        "implicitly coerced as under psycopg2. Tracked upstream in "
+        "https://github.com/pandas-dev/pandas/issues/63511, "
+        "https://github.com/apache/arrow/pull/50325, "
+        "https://github.com/sqlalchemy/sqlalchemy/discussions/10839 and "
+        "https://github.com/sqlalchemy/sqlalchemy/issues/12060";
+    )
+
+    def teardown_method(self):
+        with PostgresHook().get_conn() as conn:
+            with conn.cursor() as cur:
+                cur.execute(f"DROP TABLE IF EXISTS {self.table}")
+
+    def insert_string_uuid_df(self, hook: PostgresHook) -> None:
+        engine = hook.get_sqlalchemy_engine()
+        with engine.begin() as conn:
+            conn.execute(sqlalchemy.text(f"CREATE TABLE {self.table} (id UUID 
PRIMARY KEY, item TEXT)"))
+        df = pd.DataFrame({"id": ["b29d6cda-04ca-4073-8ef8-4f70d35e41e2"], 
"item": ["laptop"]})
+        df.to_sql(self.table, engine, if_exists="append", index=False)
+
+    @pytest.mark.skipif(not USE_PSYCOPG3, reason="psycopg v3 or sqlalchemy v2 
not available")
+    @pytest.mark.xfail(
+        raises=(sqlalchemy.exc.ProgrammingError, pd.errors.DatabaseError),
+        strict=True,
+        reason=PSYCOPG3_XFAIL_REASON,
+    )
+    def test_to_sql_string_uuid_fails_on_psycopg3(self):
+        self.insert_string_uuid_df(PostgresHook())
+
+    def test_to_sql_string_uuid_works_with_psycopg2_scheme(self):
+        hook = PostgresHook(sqlalchemy_scheme="postgresql+psycopg2")
+        self.insert_string_uuid_df(hook)
+        with hook.get_sqlalchemy_engine().connect() as conn:
+            assert conn.execute(sqlalchemy.text(f"SELECT COUNT(*) FROM 
{self.table}")).scalar() == 1
+
+
 @pytest.mark.backend("postgres")
 class TestPostgresHook:
     """Tests that are identical between psycopg2 and psycopg3."""

Reply via email to