Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Allow keeping PostgresHook SQLAlchemy engines on psycopg2 by sehajsandhu123 · Pull Request #72000 · apache/airflow · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions providers/postgres/docs/connections/postgres.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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:
Expand All@@ -192,7 +218,7 @@ def sqlalchemy_url(self) -> URL:
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this should be an operator/hook parameter or something defined inside the Connection itself.

What was your thinking for putting it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ashb It's primarily a connection property, the hook param is just an override on top. The intended usage is setting it once on the connection (or not setting it at all is also fine, for backwards compatibility) and the hook param only kicks in if explicitly passed.

The reason I added it is if most dags on a connection are fine on psycopg3 and only one or two break, it pins just those tasks instead of rolling the whole connection back to psycopg2. Keeps the opt-out small and lets the connection default move forward.

Happy to drop the hook param if you just want the connection extra.

username=self.__cast_nullable(conn.login, str),
password=self.__cast_nullable(conn.password, str),
host=self.__cast_nullable(conn.host, str),
Expand Down
87 changes: 87 additions & 0 deletions providers/postgres/tests/unit/postgres/hooks/test_postgres.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -633,6 +633,93 @@ def test_get_conn_cursor(self, mocker):
)


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


@pytest.mark.backend("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."""
Expand Down