This is an automated email from the ASF dual-hosted git repository.
potiuk 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 7a3c9a994f5 Add rudimentary support for psycopg3 (#52976)
7a3c9a994f5 is described below
commit 7a3c9a994f5e91fe44cdee7662f1f73e1dfa0d0e
Author: Dev-iL <[email protected]>
AuthorDate: Thu Aug 21 01:14:26 2025 +0300
Add rudimentary support for psycopg3 (#52976)
Note: the new code will become active only if sqla2 and psycopg3 are both
detected.
Also:
- Refactor the postgres tests to: 1) use the `mocker` fixture; and 2) stop
using the `caplog` fixture.
---
airflow-core/src/airflow/settings.py | 27 +-
airflow-core/src/airflow/utils/db.py | 34 +-
airflow-core/tests/unit/always/test_connection.py | 23 +-
.../tests/unit/cli/commands/test_db_command.py | 52 +
devel-common/src/docs/utils/conf_constants.py | 1 +
.../src/airflow/providers/common/sql/hooks/sql.py | 3 +
.../sql/tests/unit/common/sql/sensors/test_sql.py | 4 +-
.../providers/google/cloud/hooks/cloud_sql.py | 6 +-
.../google/cloud/transfers/postgres_to_gcs.py | 51 +-
providers/jdbc/tests/unit/jdbc/hooks/test_jdbc.py | 11 +
providers/postgres/docs/connections/postgres.rst | 1 +
providers/postgres/pyproject.toml | 4 +
.../airflow/providers/postgres/hooks/postgres.py | 222 ++++-
.../tests/unit/postgres/hooks/test_postgres.py | 1001 ++++++++++++++------
14 files changed, 1094 insertions(+), 346 deletions(-)
diff --git a/airflow-core/src/airflow/settings.py
b/airflow-core/src/airflow/settings.py
index db5ce959b1f..394b3332313 100644
--- a/airflow-core/src/airflow/settings.py
+++ b/airflow-core/src/airflow/settings.py
@@ -43,6 +43,16 @@ from airflow.logging_config import configure_logging
from airflow.utils.orm_event_handlers import setup_event_handlers
from airflow.utils.sqlalchemy import is_sqlalchemy_v1
+USE_PSYCOPG3: bool
+try:
+ from importlib.util import find_spec
+
+ is_psycopg3 = find_spec("psycopg") is not None
+
+ USE_PSYCOPG3 = is_psycopg3 and not is_sqlalchemy_v1()
+except (ImportError, ModuleNotFoundError):
+ USE_PSYCOPG3 = False
+
if TYPE_CHECKING:
from sqlalchemy.engine import Engine
@@ -426,12 +436,17 @@ def configure_orm(disable_connection_pool=False,
pool_class=None):
register_at_fork(after_in_child=clean_in_fork)
-DEFAULT_ENGINE_ARGS = {
- "postgresql": {
- "executemany_mode": "values_plus_batch",
- "executemany_values_page_size" if is_sqlalchemy_v1() else
"insertmanyvalues_page_size": 10000,
- "executemany_batch_page_size": 2000,
- },
+DEFAULT_ENGINE_ARGS: dict[str, dict[str, Any]] = {
+ "postgresql": (
+ {
+ "executemany_values_page_size" if is_sqlalchemy_v1() else
"insertmanyvalues_page_size": 10000,
+ }
+ | (
+ {}
+ if USE_PSYCOPG3
+ else {"executemany_mode": "values_plus_batch",
"executemany_batch_page_size": 2000}
+ )
+ )
}
diff --git a/airflow-core/src/airflow/utils/db.py
b/airflow-core/src/airflow/utils/db.py
index 6dd01d9ba8e..e8409c86618 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -61,6 +61,21 @@ from airflow.utils.db_manager import RunDBManager
from airflow.utils.session import NEW_SESSION, provide_session
from airflow.utils.task_instance_session import
get_current_task_instance_session
+USE_PSYCOPG3: bool
+try:
+ from importlib.util import find_spec
+
+ import sqlalchemy
+ from packaging.version import Version
+
+ is_psycopg3 = find_spec("psycopg") is not None
+ sqlalchemy_version = Version(sqlalchemy.__version__)
+ is_sqla2 = (sqlalchemy_version.major, sqlalchemy_version.minor,
sqlalchemy_version.micro) >= (2, 0, 0)
+
+ USE_PSYCOPG3 = is_psycopg3 and is_sqla2
+except (ImportError, ModuleNotFoundError):
+ USE_PSYCOPG3 = False
+
if TYPE_CHECKING:
from alembic.runtime.environment import EnvironmentContext
from alembic.script import ScriptDirectory
@@ -1284,15 +1299,28 @@ def create_global_lock(
dialect = conn.dialect
try:
if dialect.name == "postgresql":
- conn.execute(text("SET LOCK_TIMEOUT to :timeout"), {"timeout":
lock_timeout})
- conn.execute(text("SELECT pg_advisory_lock(:id)"), {"id":
lock.value})
+ if USE_PSYCOPG3:
+ # psycopg3 doesn't support parameters for `SET`. Use
`set_config` instead.
+ # The timeout value must be passed as a string of milliseconds.
+ conn.execute(
+ text("SELECT set_config('lock_timeout', :timeout, false)"),
+ {"timeout": str(lock_timeout)},
+ )
+ conn.execute(text("SELECT pg_advisory_lock(:id)"), {"id":
lock.value})
+ else:
+ conn.execute(text("SET LOCK_TIMEOUT to :timeout"), {"timeout":
lock_timeout})
+ conn.execute(text("SELECT pg_advisory_lock(:id)"), {"id":
lock.value})
elif dialect.name == "mysql" and dialect.server_version_info >= (5, 6):
conn.execute(text("SELECT GET_LOCK(:id, :timeout)"), {"id":
str(lock), "timeout": lock_timeout})
yield
finally:
if dialect.name == "postgresql":
- conn.execute(text("SET LOCK_TIMEOUT TO DEFAULT"))
+ if USE_PSYCOPG3:
+ # Use set_config() to reset the timeout to its default (0 =
off/wait forever).
+ conn.execute(text("SELECT set_config('lock_timeout', '0',
false)"))
+ else:
+ conn.execute(text("SET LOCK_TIMEOUT TO DEFAULT"))
(unlocked,) = conn.execute(text("SELECT pg_advisory_unlock(:id)"),
{"id": lock.value}).fetchone()
if not unlocked:
raise RuntimeError("Error releasing DB lock!")
diff --git a/airflow-core/tests/unit/always/test_connection.py
b/airflow-core/tests/unit/always/test_connection.py
index ef6fa2213c2..2ace72179e3 100644
--- a/airflow-core/tests/unit/always/test_connection.py
+++ b/airflow-core/tests/unit/always/test_connection.py
@@ -32,7 +32,7 @@ from airflow.exceptions import AirflowException
from airflow.models import Connection, crypto
from airflow.sdk import BaseHook
-from tests_common.test_utils.version_compat import SQLALCHEMY_V_1_4
+from tests_common.test_utils.version_compat import SQLALCHEMY_V_1_4,
SQLALCHEMY_V_2_0
sqlite = pytest.importorskip("airflow.providers.sqlite.hooks.sqlite")
@@ -679,10 +679,20 @@ class TestConnection:
def test_dbapi_get_uri(self):
conn = BaseHook.get_connection(conn_id="test_uri")
hook = conn.get_hook()
- assert hook.get_uri() ==
"postgresql://username:[email protected]:5432/the_database"
+
+ ppg3_mode: bool = SQLALCHEMY_V_2_0 and "psycopg" in hook.get_uri()
+ if ppg3_mode:
+ assert (
+ hook.get_uri() ==
"postgresql+psycopg://username:[email protected]:5432/the_database"
+ )
+ else:
+ assert hook.get_uri() ==
"postgresql://username:[email protected]:5432/the_database"
conn2 = BaseHook.get_connection(conn_id="test_uri_no_creds")
hook2 = conn2.get_hook()
- assert hook2.get_uri() == "postgresql://ec2.compute.com/the_database"
+ if ppg3_mode:
+ assert hook2.get_uri() ==
"postgresql+psycopg://ec2.compute.com/the_database"
+ else:
+ assert hook2.get_uri() ==
"postgresql://ec2.compute.com/the_database"
@mock.patch.dict(
"os.environ",
@@ -695,7 +705,12 @@ class TestConnection:
conn = BaseHook.get_connection(conn_id="test_uri")
hook = conn.get_hook()
engine = hook.get_sqlalchemy_engine()
- expected =
"postgresql://username:[email protected]:5432/the_database"
+
+ if SQLALCHEMY_V_2_0 and "psycopg" in hook.get_uri():
+ expected =
"postgresql+psycopg://username:[email protected]:5432/the_database"
+ else:
+ expected =
"postgresql://username:[email protected]:5432/the_database"
+
assert isinstance(engine, sqlalchemy.engine.Engine)
if SQLALCHEMY_V_1_4:
assert str(engine.url) == expected
diff --git a/airflow-core/tests/unit/cli/commands/test_db_command.py
b/airflow-core/tests/unit/cli/commands/test_db_command.py
index eeac6aa1688..e4cac8089ae 100644
--- a/airflow-core/tests/unit/cli/commands/test_db_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_db_command.py
@@ -238,6 +238,27 @@ class TestCliDb:
"PGUSER": "postgres",
}
+ @mock.patch("airflow.cli.commands.db_command.execute_interactive")
+ @mock.patch(
+ "airflow.cli.commands.db_command.settings.engine.url",
+
make_url("postgresql+psycopg://postgres:airflow@postgres:5432/airflow"),
+ )
+ def test_cli_shell_postgres_ppg3(self, mock_execute_interactive):
+ pytest.importorskip("psycopg", reason="Test only runs when psycopg v3
is installed.")
+
+ db_command.shell(self.parser.parse_args(["db", "shell"]))
+ mock_execute_interactive.assert_called_once_with(["psql"],
env=mock.ANY)
+ _, kwargs = mock_execute_interactive.call_args
+ env = kwargs["env"]
+ postgres_env = {k: v for k, v in env.items() if k.startswith("PG")}
+ assert postgres_env == {
+ "PGDATABASE": "airflow",
+ "PGHOST": "postgres",
+ "PGPASSWORD": "airflow",
+ "PGPORT": "5432",
+ "PGUSER": "postgres",
+ }
+
@mock.patch("airflow.cli.commands.db_command.execute_interactive")
@mock.patch(
"airflow.cli.commands.db_command.settings.engine.url",
@@ -257,6 +278,27 @@ class TestCliDb:
"PGUSER": "postgres",
}
+ @mock.patch("airflow.cli.commands.db_command.execute_interactive")
+ @mock.patch(
+ "airflow.cli.commands.db_command.settings.engine.url",
+ make_url("postgresql+psycopg://postgres:airflow@postgres/airflow"),
+ )
+ def test_cli_shell_postgres_without_port_ppg3(self,
mock_execute_interactive):
+ pytest.importorskip("psycopg", reason="Test only runs when psycopg v3
is installed.")
+
+ db_command.shell(self.parser.parse_args(["db", "shell"]))
+ mock_execute_interactive.assert_called_once_with(["psql"],
env=mock.ANY)
+ _, kwargs = mock_execute_interactive.call_args
+ env = kwargs["env"]
+ postgres_env = {k: v for k, v in env.items() if k.startswith("PG")}
+ assert postgres_env == {
+ "PGDATABASE": "airflow",
+ "PGHOST": "postgres",
+ "PGPASSWORD": "airflow",
+ "PGPORT": "5432",
+ "PGUSER": "postgres",
+ }
+
@mock.patch(
"airflow.cli.commands.db_command.settings.engine.url",
make_url("invalid+psycopg2://postgres:airflow@postgres/airflow"),
@@ -265,6 +307,16 @@ class TestCliDb:
with pytest.raises(AirflowException, match=r"Unknown driver:
invalid\+psycopg2"):
db_command.shell(self.parser.parse_args(["db", "shell"]))
+ @mock.patch(
+ "airflow.cli.commands.db_command.settings.engine.url",
+ make_url("invalid+psycopg://postgres:airflow@postgres/airflow"),
+ )
+ def test_cli_shell_invalid_ppg3(self):
+ pytest.importorskip("psycopg", reason="Test only runs when psycopg v3
is installed.")
+
+ with pytest.raises(AirflowException, match=r"Unknown driver:
invalid\+psycopg"):
+ db_command.shell(self.parser.parse_args(["db", "shell"]))
+
@pytest.mark.parametrize(
"args, match",
[
diff --git a/devel-common/src/docs/utils/conf_constants.py
b/devel-common/src/docs/utils/conf_constants.py
index 89bd07b61e1..5a5b0e7f672 100644
--- a/devel-common/src/docs/utils/conf_constants.py
+++ b/devel-common/src/docs/utils/conf_constants.py
@@ -248,6 +248,7 @@ def get_autodoc_mock_imports() -> list[str]:
"pandas_gbq",
"paramiko",
"pinotdb",
+ "psycopg",
"psycopg2",
"pydruid",
"pyhive",
diff --git a/providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py
b/providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py
index 21c6a165ca7..8abda6bc014 100644
--- a/providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py
+++ b/providers/common/sql/src/airflow/providers/common/sql/hooks/sql.py
@@ -810,6 +810,9 @@ class DbApiHook(BaseHook):
self.log.info("Running statement: %s, parameters: %s",
sql_statement, parameters)
if parameters:
+ # If we're using psycopg3, we might need to handle parameters
differently
+ if hasattr(cur, "__module__") and "psycopg" in cur.__module__ and
isinstance(parameters, list):
+ parameters = tuple(parameters)
cur.execute(sql_statement, parameters)
else:
cur.execute(sql_statement)
diff --git a/providers/common/sql/tests/unit/common/sql/sensors/test_sql.py
b/providers/common/sql/tests/unit/common/sql/sensors/test_sql.py
index b1d16ed1bef..93c1483ffdf 100644
--- a/providers/common/sql/tests/unit/common/sql/sensors/test_sql.py
+++ b/providers/common/sql/tests/unit/common/sql/sensors/test_sql.py
@@ -80,8 +80,8 @@ class TestSqlSensor:
op2 = SqlSensor(
task_id="sql_sensor_check_2",
conn_id="postgres_default",
- sql="SELECT count(%s) FROM INFORMATION_SCHEMA.TABLES",
- parameters=["table_name"],
+ sql="SELECT count(*) FROM INFORMATION_SCHEMA.TABLES WHERE
table_schema = %s",
+ parameters=["information_schema"],
dag=self.dag,
)
op2.execute({})
diff --git
a/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
b/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
index 72a526e8c31..32b86cd9c02 100644
--- a/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
+++ b/providers/google/src/airflow/providers/google/cloud/hooks/cloud_sql.py
@@ -1175,9 +1175,9 @@ class CloudSQLDatabaseHook(BaseHook):
raise ValueError("The db_hook should be set")
if not isinstance(self.db_hook, PostgresHook):
raise ValueError(f"The db_hook should be PostgresHook and is
{type(self.db_hook)}")
- conn = getattr(self.db_hook, "conn")
- if conn and conn.notices:
- for output in self.db_hook.conn.notices:
+ conn = getattr(self.db_hook, "conn", None)
+ if conn and hasattr(conn, "notices") and conn.notices:
+ for output in conn.notices:
self.log.info(output)
def reserve_free_tcp_port(self) -> None:
diff --git
a/providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py
b/providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py
index 9839552cd0d..d45d7937ff0 100644
---
a/providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py
+++
b/providers/google/src/airflow/providers/google/cloud/transfers/postgres_to_gcs.py
@@ -31,7 +31,7 @@ import pendulum
from slugify import slugify
from airflow.providers.google.cloud.transfers.sql_to_gcs import
BaseSQLToGCSOperator
-from airflow.providers.postgres.hooks.postgres import PostgresHook
+from airflow.providers.postgres.hooks.postgres import USE_PSYCOPG3,
PostgresHook
if TYPE_CHECKING:
from airflow.providers.openlineage.extractors import OperatorLineage
@@ -52,9 +52,20 @@ class _PostgresServerSideCursorDecorator:
self.initialized = False
def __iter__(self):
+ """Make the cursor iterable."""
return self
def __next__(self):
+ """Fetch next row from the cursor."""
+ if USE_PSYCOPG3:
+ if self.rows:
+ return self.rows.pop()
+ self.initialized = True
+ row = self.cursor.fetchone()
+ if row is None:
+ raise StopIteration
+ return row
+ # psycopg2
if self.rows:
return self.rows.pop()
self.initialized = True
@@ -141,13 +152,29 @@ class PostgresToGCSOperator(BaseSQLToGCSOperator):
return PostgresHook(postgres_conn_id=self.postgres_conn_id)
def query(self):
- """Query Postgres and returns a cursor to the results."""
+ """Execute the query and return a cursor."""
conn = self.db_hook.get_conn()
- cursor = conn.cursor(name=self._unique_name())
- cursor.execute(self.sql, self.parameters)
- if self.use_server_side_cursor:
- cursor.itersize = self.cursor_itersize
- return _PostgresServerSideCursorDecorator(cursor)
+
+ if USE_PSYCOPG3:
+ from psycopg.types.json import register_default_adapters
+
+ # Register JSON handlers for this connection if not already done
+ register_default_adapters(conn)
+
+ if self.use_server_side_cursor:
+ cursor_name = f"airflow_{self.task_id.replace('-',
'_')}_{uuid.uuid4().hex}"[:63]
+ cursor = conn.cursor(name=cursor_name)
+ cursor.itersize = self.cursor_itersize
+ cursor.execute(self.sql, self.parameters)
+ return _PostgresServerSideCursorDecorator(cursor)
+ cursor = conn.cursor()
+ cursor.execute(self.sql, self.parameters)
+ else:
+ cursor = conn.cursor(name=self._unique_name())
+ cursor.execute(self.sql, self.parameters)
+ if self.use_server_side_cursor:
+ cursor.itersize = self.cursor_itersize
+ return _PostgresServerSideCursorDecorator(cursor)
return cursor
def field_to_bigquery(self, field) -> dict[str, str]:
@@ -182,8 +209,14 @@ class PostgresToGCSOperator(BaseSQLToGCSOperator):
hours=formatted_time.tm_hour, minutes=formatted_time.tm_min,
seconds=formatted_time.tm_sec
)
return str(time_delta)
- if stringify_dict and isinstance(value, dict):
- return json.dumps(value)
+ if stringify_dict:
+ if USE_PSYCOPG3:
+ from psycopg.types.json import Json
+
+ if isinstance(value, (dict, Json)):
+ return json.dumps(value)
+ elif isinstance(value, dict):
+ return json.dumps(value)
if isinstance(value, Decimal):
return float(value)
return value
diff --git a/providers/jdbc/tests/unit/jdbc/hooks/test_jdbc.py
b/providers/jdbc/tests/unit/jdbc/hooks/test_jdbc.py
index b7cd12d6ff9..cfd8255922c 100644
--- a/providers/jdbc/tests/unit/jdbc/hooks/test_jdbc.py
+++ b/providers/jdbc/tests/unit/jdbc/hooks/test_jdbc.py
@@ -383,6 +383,17 @@ class TestJdbcHook:
"postgresql+psycopg2://login:password@host:1234/schema",
id="sqlalchemy-scheme-with-driver",
),
+ pytest.param(
+ {
+ "conn_params": {
+ "extra": json.dumps(
+ {"sqlalchemy_scheme": "postgresql",
"sqlalchemy_driver": "psycopg"}
+ )
+ }
+ },
+ "postgresql+psycopg://login:password@host:1234/schema",
+ id="sqlalchemy-scheme-with-driver-ppg3",
+ ),
pytest.param(
{
"login": "user@domain",
diff --git a/providers/postgres/docs/connections/postgres.rst
b/providers/postgres/docs/connections/postgres.rst
index 4daec306249..539620ad08c 100644
--- a/providers/postgres/docs/connections/postgres.rst
+++ b/providers/postgres/docs/connections/postgres.rst
@@ -75,6 +75,7 @@ Extra (optional)
- ``namedtuplecursor``: Returns query results as named tuples using
``psycopg2.extras.NamedTupleCursor``.
For more information, refer to the psycopg2 documentation on `connection
and cursor subclasses
<https://www.psycopg.org/docs/extras.html#connection-and-cursor-subclasses>`_.
+ If using psycopg (v3), refer to the documentation on `connection classes
<https://www.psycopg.org/psycopg3/docs/api/connections.html>`_.
More details on all Postgres parameters supported can be found in
`Postgres documentation
<https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING>`_.
diff --git a/providers/postgres/pyproject.toml
b/providers/postgres/pyproject.toml
index 56bc5c20024..33ad216595b 100644
--- a/providers/postgres/pyproject.toml
+++ b/providers/postgres/pyproject.toml
@@ -80,6 +80,9 @@ dependencies = [
"polars" = [
"polars>=1.26.0"
]
+"psycopg" = [
+ "psycopg[binary]>=3.2.9",
+]
[dependency-groups]
dev = [
@@ -92,6 +95,7 @@ dev = [
# Additional devel dependencies (do not remove this line and add extra
development dependencies)
"apache-airflow-providers-common-sql[pandas]",
"apache-airflow-providers-common-sql[polars]",
+ "psycopg[binary]>=3.2.9",
]
# To build docs:
diff --git
a/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
b/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
index 2c3d0c61ce9..9c159deba74 100644
--- a/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
+++ b/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
@@ -21,7 +21,7 @@ import os
from collections.abc import Mapping
from contextlib import closing
from copy import deepcopy
-from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload
+from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias, cast,
overload
import psycopg2
import psycopg2.extensions
@@ -36,20 +36,66 @@ from airflow.exceptions import (
from airflow.providers.common.sql.hooks.sql import DbApiHook
from airflow.providers.postgres.dialects.postgres import PostgresDialect
+USE_PSYCOPG3: bool
+try:
+ import psycopg as psycopg # needed for patching in unit tests
+ import sqlalchemy
+ from packaging.version import Version
+
+ sqlalchemy_version = Version(sqlalchemy.__version__)
+ is_sqla2 = (sqlalchemy_version.major, sqlalchemy_version.minor,
sqlalchemy_version.micro) >= (2, 0, 0)
+
+ USE_PSYCOPG3 = is_sqla2 # implicitly includes `and bool(psycopg)` since
the import above succeeded
+except (ImportError, ModuleNotFoundError):
+ USE_PSYCOPG3 = False
+
+if USE_PSYCOPG3:
+ from psycopg.rows import dict_row, namedtuple_row
+ from psycopg.types.json import register_default_adapters
+
if TYPE_CHECKING:
from pandas import DataFrame as PandasDataFrame
from polars import DataFrame as PolarsDataFrame
- from psycopg2.extensions import connection
from airflow.providers.common.sql.dialects.dialect import Dialect
from airflow.providers.openlineage.sqlparser import DatabaseInfo
+ if USE_PSYCOPG3:
+ from psycopg.errors import Diagnostic
+
try:
from airflow.sdk import Connection
except ImportError:
from airflow.models.connection import Connection # type:
ignore[assignment]
CursorType: TypeAlias = DictCursor | RealDictCursor | NamedTupleCursor
+CursorRow: TypeAlias = dict[str, Any] | tuple[Any, ...]
+
+
+class CompatConnection(Protocol):
+ """Protocol for type hinting psycopg2 and psycopg3 connection objects."""
+
+ def cursor(self, *args, **kwargs) -> Any: ...
+ def commit(self) -> None: ...
+ def close(self) -> None: ...
+
+ # Context manager support
+ def __enter__(self) -> CompatConnection: ...
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ...
+
+ # Common properties
+ @property
+ def notices(self) -> list[Any]: ...
+
+ # psycopg3 specific (optional)
+ @property
+ def adapters(self) -> Any: ...
+
+ @property
+ def row_factory(self) -> Any: ...
+
+ # Optional method for psycopg3
+ def add_notice_handler(self, handler: Any) -> None: ...
class PostgresHook(DbApiHook):
@@ -58,8 +104,8 @@ class PostgresHook(DbApiHook):
You can specify ssl parameters in the extra field of your connection
as ``{"sslmode": "require", "sslcert": "/path/to/cert.pem", etc}``.
- Also you can choose cursor as ``{"cursor": "dictcursor"}``. Refer to the
- psycopg2.extras for more details.
+ Also, you can choose cursor as ``{"cursor": "dictcursor"}``. Refer to the
+ psycopg2.extras or psycopg.rows for more details.
Note: For Redshift, use keepalives_idle in the extra connection parameters
and set it to less than 300 seconds.
@@ -93,6 +139,8 @@ class PostgresHook(DbApiHook):
conn_name_attr = "postgres_conn_id"
default_conn_name = "postgres_default"
+ default_client_log_level = "warning"
+ default_connector_version: int = 2
conn_type = "postgres"
hook_name = "Postgres"
supports_autocommit = True
@@ -113,11 +161,15 @@ class PostgresHook(DbApiHook):
self, *args, options: str | None = None, enable_log_db_messages: bool
= False, **kwargs
) -> None:
super().__init__(*args, **kwargs)
- self.conn: connection = None
+ 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
+ @staticmethod
+ def __cast_nullable(value, dst_type: type) -> Any:
+ return dst_type(value) if value is not None else None
+
@property
def sqlalchemy_url(self) -> URL:
conn = self.connection
@@ -125,12 +177,12 @@ class PostgresHook(DbApiHook):
if not isinstance(query, dict):
raise AirflowException("The parameter 'sqlalchemy_query' must be
of type dict!")
return URL.create(
- drivername="postgresql",
- username=conn.login,
- password=conn.password,
- host=conn.host,
- port=conn.port,
- database=self.database or conn.schema,
+ drivername="postgresql+psycopg" if USE_PSYCOPG3 else "postgresql",
+ username=self.__cast_nullable(conn.login, str),
+ password=self.__cast_nullable(conn.password, str),
+ host=self.__cast_nullable(conn.host, str),
+ port=self.__cast_nullable(conn.port, int),
+ database=self.__cast_nullable(self.database, str) or
self.__cast_nullable(conn.schema, str),
query=query,
)
@@ -142,8 +194,24 @@ class PostgresHook(DbApiHook):
def dialect(self) -> Dialect:
return PostgresDialect(self)
+ def _notice_handler(self, notice: Diagnostic):
+ """Handle notices from the database and log them."""
+ self.log.info(str(notice.message_primary).strip())
+
def _get_cursor(self, raw_cursor: str) -> CursorType:
_cursor = raw_cursor.lower()
+ if USE_PSYCOPG3:
+ if _cursor == "dictcursor":
+ return dict_row
+ if _cursor == "namedtuplecursor":
+ return namedtuple_row
+ if _cursor == "realdictcursor":
+ raise AirflowException(
+ "realdictcursor is not supported with psycopg3. Use
dictcursor instead."
+ )
+ valid_cursors = "dictcursor, namedtuplecursor"
+ raise ValueError(f"Invalid cursor passed {_cursor}. Valid options
are: {valid_cursors}")
+
cursor_types = {
"dictcursor": psycopg2.extras.DictCursor,
"realdictcursor": psycopg2.extras.RealDictCursor,
@@ -154,33 +222,63 @@ class PostgresHook(DbApiHook):
valid_cursors = ", ".join(cursor_types.keys())
raise ValueError(f"Invalid cursor passed {_cursor}. Valid options are:
{valid_cursors}")
- def get_conn(self) -> connection:
+ def _generate_cursor_name(self):
+ """Generate a unique name for server-side cursor."""
+ import uuid
+
+ return f"airflow_cursor_{uuid.uuid4().hex}"
+
+ def get_conn(self) -> CompatConnection:
"""Establish a connection to a postgres database."""
conn = deepcopy(self.connection)
- # check for authentication via AWS IAM
if conn.extra_dejson.get("iam", False):
- conn.login, conn.password, conn.port = self.get_iam_token(conn)
+ login, password, port = self.get_iam_token(conn)
+ conn.login = cast("Any", login)
+ conn.password = cast("Any", password)
+ conn.port = cast("Any", port)
- conn_args = {
+ conn_args: dict[str, Any] = {
"host": conn.host,
"user": conn.login,
"password": conn.password,
"dbname": self.database or conn.schema,
"port": conn.port,
}
- raw_cursor = conn.extra_dejson.get("cursor", False)
- if raw_cursor:
- conn_args["cursor_factory"] = self._get_cursor(raw_cursor)
if self.options:
conn_args["options"] = self.options
+ # Add extra connection arguments
for arg_name, arg_val in conn.extra_dejson.items():
if arg_name not in self.ignored_extra_options:
conn_args[arg_name] = arg_val
- self.conn = psycopg2.connect(**conn_args)
+ if USE_PSYCOPG3:
+ from psycopg.connection import Connection as pgConnection
+
+ raw_cursor = conn.extra_dejson.get("cursor")
+ if raw_cursor:
+ conn_args["row_factory"] = self._get_cursor(raw_cursor)
+
+ # Use Any type for the connection args to avoid type conflicts
+ connection = pgConnection.connect(**cast("Any", conn_args))
+ self.conn = cast("CompatConnection", connection)
+
+ # Register JSON handlers for both json and jsonb types
+ # This ensures JSON data is properly decoded from bytes to Python
objects
+ register_default_adapters(connection)
+
+ # Add the notice handler AFTER the connection is established
+ if self.enable_log_db_messages and hasattr(self.conn,
"add_notice_handler"):
+ self.conn.add_notice_handler(self._notice_handler)
+ else: # psycopg2
+ raw_cursor = conn.extra_dejson.get("cursor", False)
+ if raw_cursor:
+ conn_args["cursor_factory"] = self._get_cursor(raw_cursor)
+
+ self.conn = cast("CompatConnection", psycopg2.connect(**conn_args))
+
return self.conn
@overload
@@ -231,7 +329,9 @@ class PostgresHook(DbApiHook):
engine = self.get_sqlalchemy_engine()
with engine.connect() as conn:
- return psql.read_sql(sql, con=conn, params=parameters,
**kwargs)
+ if isinstance(sql, list):
+ sql = "; ".join(sql) # Or handle multiple queries
differently
+ return cast("PandasDataFrame", psql.read_sql(sql, con=conn,
params=parameters, **kwargs))
elif df_type == "polars":
return self._get_polars_df(sql, parameters, **kwargs)
@@ -241,7 +341,7 @@ class PostgresHook(DbApiHook):
def copy_expert(self, sql: str, filename: str) -> None:
"""
- Execute SQL using psycopg2's ``copy_expert`` method.
+ Execute SQL using psycopg's ``copy_expert`` method.
Necessary to execute COPY command without access to a superuser.
@@ -252,14 +352,38 @@ class PostgresHook(DbApiHook):
they have to check its existence by themselves.
"""
self.log.info("Running copy expert: %s, filename: %s", sql, filename)
- if not os.path.isfile(filename):
- with open(filename, "w"):
- pass
-
- with open(filename, "r+") as file, closing(self.get_conn()) as conn,
closing(conn.cursor()) as cur:
- cur.copy_expert(sql, file)
- file.truncate(file.tell())
- conn.commit()
+ if USE_PSYCOPG3:
+ if " from stdin" in sql.lower():
+ # Handle COPY FROM STDIN: read from the file and write to the
database.
+ if not os.path.isfile(filename):
+ with open(filename, "w"):
+ pass # Create an empty file to prevent errors.
+
+ with open(filename, "rb") as file, self.get_conn() as conn,
conn.cursor() as cur:
+ with cur.copy(sql) as copy:
+ while data := file.read(8192):
+ copy.write(data)
+ conn.commit()
+ else:
+ # Handle COPY TO STDOUT: read from the database and write to
the file.
+ with open(filename, "wb") as file, self.get_conn() as conn,
conn.cursor() as cur:
+ with cur.copy(sql) as copy:
+ for data in copy:
+ file.write(data)
+ conn.commit()
+ else:
+ if not os.path.isfile(filename):
+ with open(filename, "w"):
+ pass
+
+ with (
+ open(filename, "r+") as file,
+ closing(self.get_conn()) as conn,
+ closing(conn.cursor()) as cur,
+ ):
+ cur.copy_expert(sql, file)
+ file.truncate(file.tell())
+ conn.commit()
def get_uri(self) -> str:
"""
@@ -278,9 +402,9 @@ class PostgresHook(DbApiHook):
self.copy_expert(f"COPY {table} TO STDOUT", tmp_file)
@staticmethod
- def _serialize_cell(cell: object, conn: connection | None = None) -> Any:
+ def _serialize_cell_ppg2(cell: object, conn: CompatConnection | None =
None) -> Any:
"""
- Serialize a cell.
+ Serialize a cell using psycopg2.
Psycopg2 adapts all arguments to the ``execute()`` method internally,
hence we return the cell without any conversion.
@@ -297,6 +421,24 @@ class PostgresHook(DbApiHook):
"""
return cell
+ @staticmethod
+ def _serialize_cell_ppg3(cell: object, conn: CompatConnection | None =
None) -> Any:
+ """Serialize a cell using psycopg3."""
+ if isinstance(cell, (dict, list)):
+ try:
+ from psycopg.types.json import Json
+
+ return Json(cell)
+ except ImportError:
+ return cell
+ return cell
+
+ @staticmethod
+ def _serialize_cell(cell: object, conn: Any | None = None) -> Any:
+ if USE_PSYCOPG3:
+ return PostgresHook._serialize_cell_ppg3(cell, conn)
+ return PostgresHook._serialize_cell_ppg2(cell, conn)
+
def get_iam_token(self, conn: Connection) -> tuple[str, str, int]:
"""
Get the IAM token.
@@ -420,11 +562,15 @@ class PostgresHook(DbApiHook):
}
def get_db_log_messages(self, conn) -> None:
- """
- Log all database messages sent to the client during the session.
+ """Log database messages."""
+ if not self.enable_log_db_messages:
+ return
- :param conn: Connection object
- """
- if self.enable_log_db_messages:
- for output in conn.notices:
- self.log.info(output)
+ if USE_PSYCOPG3:
+ self.log.debug(
+ "With psycopg3, database notices are logged upon creation (via
self._notice_handler)."
+ )
+ return
+
+ for output in conn.notices:
+ self.log.info(output)
diff --git a/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
b/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
index be332a7bda5..5a842666a5a 100644
--- a/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
+++ b/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
@@ -18,13 +18,12 @@
from __future__ import annotations
import json
-import logging
import os
+from types import SimpleNamespace
from unittest import mock
import pandas as pd
import polars as pl
-import psycopg2.extras
import pytest
import sqlalchemy
@@ -40,7 +39,68 @@ from tests_common.test_utils.version_compat import
SQLALCHEMY_V_1_4
INSERT_SQL_STATEMENT = "INSERT INTO connection (id, conn_id, conn_type,
description, host, {}, login, password, port, is_encrypted, is_extra_encrypted,
extra) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"
+USE_PSYCOPG3: bool
+try:
+ from importlib.util import find_spec
+
+ import sqlalchemy
+ from packaging.version import Version
+
+ is_psycopg3 = find_spec("psycopg") is not None
+ sqlalchemy_version = Version(sqlalchemy.__version__)
+ is_sqla2 = (sqlalchemy_version.major, sqlalchemy_version.minor,
sqlalchemy_version.micro) >= (2, 0, 0)
+
+ USE_PSYCOPG3 = is_psycopg3 and is_sqla2
+except (ImportError, ModuleNotFoundError):
+ USE_PSYCOPG3 = False
+
+if USE_PSYCOPG3:
+ import psycopg.rows
+else:
+ import psycopg2.extras
+
+
[email protected]
+def postgres_hook_setup():
+ """Set up mock PostgresHook for testing."""
+ table = "test_postgres_hook_table"
+ cur = mock.MagicMock(rowcount=0)
+ conn = mock.MagicMock()
+ conn.cursor.return_value = cur
+
+ class UnitTestPostgresHook(PostgresHook):
+ conn_name_attr = "test_conn_id"
+
+ def get_conn(self):
+ return conn
+
+ db_hook = UnitTestPostgresHook()
+
+ # Return a namespace with all the objects
+ setup = SimpleNamespace(table=table, cur=cur, conn=conn, db_hook=db_hook)
+
+ yield setup
+
+ # Teardown - only for real database tests
+ try:
+ with PostgresHook().get_conn() as real_conn:
+ with real_conn.cursor() as real_cur:
+ real_cur.execute(f"DROP TABLE IF EXISTS {table}")
+ except Exception:
+ pass # Ignore cleanup errors for unit tests
+
+
[email protected]
+def mock_connect(mocker):
+ """Mock the connection object according to the correct psycopg version."""
+ if USE_PSYCOPG3:
+ return
mocker.patch("airflow.providers.postgres.hooks.postgres.psycopg.connection.Connection.connect")
+ return
mocker.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
+
+
class TestPostgresHookConn:
+ """PostgresHookConn tests that are common to psycopg2 and psycopg3."""
+
def setup_method(self):
self.connection = Connection(login="login", password="password",
host="host", schema="database")
@@ -51,55 +111,6 @@ class TestPostgresHookConn:
self.db_hook.get_connection = mock.Mock()
self.db_hook.get_connection.return_value = self.connection
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- def test_get_conn_non_default_id(self, mock_connect):
- self.db_hook.test_conn_id = "non_default"
- self.db_hook.get_conn()
- mock_connect.assert_called_once_with(
- user="login", password="password", host="host", dbname="database",
port=None
- )
- self.db_hook.get_connection.assert_called_once_with("non_default")
-
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- def test_get_conn(self, mock_connect):
- self.db_hook.get_conn()
- mock_connect.assert_called_once_with(
- user="login", password="password", host="host", dbname="database",
port=None
- )
-
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- def test_get_uri(self, mock_connect):
- self.connection.conn_type = "postgres"
- self.connection.port = 5432
- self.db_hook.get_conn()
- assert mock_connect.call_count == 1
- assert self.db_hook.get_uri() ==
"postgresql://login:password@host:5432/database"
-
- def test_sqlalchemy_url(self):
- conn = Connection(login="login-conn", password="password-conn",
host="host", schema="database")
- hook = PostgresHook(connection=conn)
- expected = "postgresql://login-conn:password-conn@host/database"
- if SQLALCHEMY_V_1_4:
- assert str(hook.sqlalchemy_url) == expected
- else:
- assert hook.sqlalchemy_url.render_as_string(hide_password=False)
== expected
-
- def test_sqlalchemy_url_with_sqlalchemy_query(self):
- conn = Connection(
- login="login-conn",
- password="password-conn",
- host="host",
- schema="database",
- extra=dict(sqlalchemy_query={"gssencmode": "disable"}),
- )
- hook = PostgresHook(connection=conn)
-
- expected =
"postgresql://login-conn:password-conn@host/database?gssencmode=disable"
- if SQLALCHEMY_V_1_4:
- assert str(hook.sqlalchemy_url) == expected
- else:
- assert hook.sqlalchemy_url.render_as_string(hide_password=False)
== expected
-
def test_sqlalchemy_url_with_wrong_sqlalchemy_query_value(self):
conn = Connection(
login="login-conn",
@@ -113,12 +124,71 @@ class TestPostgresHookConn:
with pytest.raises(AirflowException):
hook.sqlalchemy_url
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- def test_get_conn_cursor(self, mock_connect):
- self.connection.extra = '{"cursor": "dictcursor", "sqlalchemy_query":
{"gssencmode": "disable"}}'
+ @pytest.mark.parametrize("aws_conn_id", [NOTSET, None, "mock_aws_conn"])
+ @pytest.mark.parametrize("port", [5432, 5439, None])
+ @pytest.mark.parametrize(
+ "host,conn_cluster_identifier,expected_host",
+ [
+ (
+
"cluster-identifier.ccdfre4hpd39h.us-east-1.redshift.amazonaws.com",
+ NOTSET,
+ "cluster-identifier.us-east-1",
+ ),
+ (
+
"cluster-identifier.ccdfre4hpd39h.us-east-1.redshift.amazonaws.com",
+ "different-identifier",
+ "different-identifier.us-east-1",
+ ),
+ ],
+ )
+ def test_openlineage_methods_with_redshift(
+ self,
+ mocker,
+ aws_conn_id,
+ port,
+ host,
+ conn_cluster_identifier,
+ expected_host,
+ ):
+ mock_aws_hook_class =
mocker.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
+
+ mock_conn_extra = {
+ "iam": True,
+ "redshift": True,
+ }
+ if aws_conn_id is not NOTSET:
+ mock_conn_extra["aws_conn_id"] = aws_conn_id
+ if conn_cluster_identifier is not NOTSET:
+ mock_conn_extra["cluster-identifier"] = conn_cluster_identifier
+
+ self.connection.extra = json.dumps(mock_conn_extra)
+ self.connection.host = host
+ self.connection.port = port
+
+ # Mock AWS Connection
+ mock_aws_hook_instance = mock_aws_hook_class.return_value
+ mock_aws_hook_instance.region_name = "us-east-1"
+
+ assert (
+
self.db_hook._get_openlineage_redshift_authority_part(self.connection)
+ == f"{expected_host}:{port or 5439}"
+ )
+
+ def test_get_conn_non_default_id(self, mock_connect):
+ self.db_hook.test_conn_id = "non_default"
+ self.db_hook.get_conn()
+ mock_connect.assert_called_once_with(
+ user="login",
+ password="password",
+ host="host",
+ dbname="database",
+ port=None,
+ )
+ self.db_hook.get_connection.assert_called_once_with("non_default")
+
+ def test_get_conn(self, mock_connect):
self.db_hook.get_conn()
mock_connect.assert_called_once_with(
- cursor_factory=psycopg2.extras.DictCursor,
user="login",
password="password",
host="host",
@@ -126,31 +196,46 @@ class TestPostgresHookConn:
port=None,
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- def test_get_conn_with_invalid_cursor(self, mock_connect):
+ def test_get_uri(self, mock_connect):
+ self.connection.conn_type = "postgres"
+ self.connection.port = 5432
+ self.db_hook.get_conn()
+ assert mock_connect.call_count == 1
+ assert (
+ self.db_hook.get_uri()
+ == f"postgresql{'+psycopg' if USE_PSYCOPG3 else
''}://login:password@host:5432/database"
+ )
+
+ @pytest.mark.usefixtures("mock_connect")
+ def test_get_conn_with_invalid_cursor(self):
self.connection.extra = '{"cursor": "mycursor"}'
with pytest.raises(ValueError):
self.db_hook.get_conn()
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
def test_get_conn_from_connection(self, mock_connect):
conn = Connection(login="login-conn", password="password-conn",
host="host", schema="database")
hook = PostgresHook(connection=conn)
hook.get_conn()
mock_connect.assert_called_once_with(
- user="login-conn", password="password-conn", host="host",
dbname="database", port=None
+ user="login-conn",
+ password="password-conn",
+ host="host",
+ dbname="database",
+ port=None,
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
def test_get_conn_from_connection_with_database(self, mock_connect):
conn = Connection(login="login-conn", password="password-conn",
host="host", schema="database")
hook = PostgresHook(connection=conn, database="database-override")
hook.get_conn()
mock_connect.assert_called_once_with(
- user="login-conn", password="password-conn", host="host",
dbname="database-override", port=None
+ user="login-conn",
+ password="password-conn",
+ host="host",
+ dbname="database-override",
+ port=None,
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
def test_get_conn_from_connection_with_options(self, mock_connect):
conn = Connection(login="login-conn", password="password-conn",
host="host", schema="database")
hook = PostgresHook(connection=conn, options="-c
statement_timeout=3000ms")
@@ -164,11 +249,11 @@ class TestPostgresHookConn:
options="-c statement_timeout=3000ms",
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- @mock.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
@pytest.mark.parametrize("aws_conn_id", [NOTSET, None, "mock_aws_conn"])
@pytest.mark.parametrize("port", [65432, 5432, None])
- def test_get_conn_rds_iam_postgres(self, mock_aws_hook_class,
mock_connect, aws_conn_id, port):
+ def test_get_conn_rds_iam_postgres(self, mocker, mock_connect,
aws_conn_id, port):
+ mock_aws_hook_class =
mocker.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
+
mock_conn_extra = {"iam": True}
if aws_conn_id is not NOTSET:
mock_conn_extra["aws_conn_id"] = aws_conn_id
@@ -178,9 +263,9 @@ class TestPostgresHookConn:
# Mock AWS Connection
mock_aws_hook_instance = mock_aws_hook_class.return_value
- mock_client = mock.MagicMock()
+ mock_client = mocker.MagicMock()
mock_client.generate_db_auth_token.return_value = mock_db_token
- type(mock_aws_hook_instance).conn =
mock.PropertyMock(return_value=mock_client)
+ type(mock_aws_hook_instance).conn =
mocker.PropertyMock(return_value=mock_client)
self.db_hook.get_conn()
# Check AwsHook initialization
@@ -202,7 +287,6 @@ class TestPostgresHookConn:
port=(port or 5432),
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
def test_get_conn_extra(self, mock_connect):
self.connection.extra = '{"connect_timeout": 3}'
self.db_hook.get_conn()
@@ -210,8 +294,6 @@ class TestPostgresHookConn:
user="login", password="password", host="host", dbname="database",
port=None, connect_timeout=3
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- @mock.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
@pytest.mark.parametrize("aws_conn_id", [NOTSET, None, "mock_aws_conn"])
@pytest.mark.parametrize("port", [5432, 5439, None])
@pytest.mark.parametrize(
@@ -231,7 +313,7 @@ class TestPostgresHookConn:
)
def test_get_conn_rds_iam_redshift(
self,
- mock_aws_hook_class,
+ mocker,
mock_connect,
aws_conn_id,
port,
@@ -239,6 +321,8 @@ class TestPostgresHookConn:
conn_cluster_identifier,
expected_cluster_identifier,
):
+ mock_aws_hook_class =
mocker.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
+
mock_conn_extra = {
"iam": True,
"redshift": True,
@@ -256,12 +340,12 @@ class TestPostgresHookConn:
# Mock AWS Connection
mock_aws_hook_instance = mock_aws_hook_class.return_value
- mock_client = mock.MagicMock()
+ mock_client = mocker.MagicMock()
mock_client.get_cluster_credentials.return_value = {
"DbPassword": mock_db_pass,
"DbUser": mock_db_user,
}
- type(mock_aws_hook_instance).conn =
mock.PropertyMock(return_value=mock_client)
+ type(mock_aws_hook_instance).conn =
mocker.PropertyMock(return_value=mock_client)
self.db_hook.get_conn()
# Check AwsHook initialization
@@ -286,8 +370,6 @@ class TestPostgresHookConn:
port=(port or 5439),
)
- @mock.patch("airflow.providers.postgres.hooks.postgres.psycopg2.connect")
- @mock.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
@pytest.mark.parametrize("aws_conn_id", [NOTSET, None, "mock_aws_conn"])
@pytest.mark.parametrize("port", [5432, 5439, None])
@pytest.mark.parametrize(
@@ -307,7 +389,7 @@ class TestPostgresHookConn:
)
def test_get_conn_rds_iam_redshift_serverless(
self,
- mock_aws_hook_class,
+ mocker,
mock_connect,
aws_conn_id,
port,
@@ -315,6 +397,8 @@ class TestPostgresHookConn:
conn_workgroup_name,
expected_workgroup_name,
):
+ mock_aws_hook_class =
mocker.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
+
mock_conn_extra = {
"iam": True,
"redshift-serverless": True,
@@ -332,12 +416,12 @@ class TestPostgresHookConn:
# Mock AWS Connection
mock_aws_hook_instance = mock_aws_hook_class.return_value
- mock_client = mock.MagicMock()
+ mock_client = mocker.MagicMock()
mock_client.get_credentials.return_value = {
"dbPassword": mock_db_pass,
"dbUser": mock_db_user,
}
- type(mock_aws_hook_instance).conn =
mock.PropertyMock(return_value=mock_client)
+ type(mock_aws_hook_instance).conn =
mocker.PropertyMock(return_value=mock_client)
self.db_hook.get_conn()
# Check AwsHook initialization
@@ -360,8 +444,9 @@ class TestPostgresHookConn:
port=(port or 5439),
)
- def test_get_uri_from_connection_without_database_override(self):
- self.db_hook.get_connection = mock.MagicMock(
+ def test_get_uri_from_connection_without_database_override(self, mocker):
+ expected: str = f"postgresql{'+psycopg' if USE_PSYCOPG3 else
''}://login:password@host:1/database"
+ self.db_hook.get_connection = mocker.MagicMock(
return_value=Connection(
conn_type="postgres",
host="host",
@@ -371,11 +456,14 @@ class TestPostgresHookConn:
port=1,
)
)
- assert self.db_hook.get_uri() ==
"postgresql://login:password@host:1/database"
+ assert self.db_hook.get_uri() == expected
- def test_get_uri_from_connection_with_database_override(self):
+ def test_get_uri_from_connection_with_database_override(self, mocker):
+ expected: str = (
+ f"postgresql{'+psycopg' if USE_PSYCOPG3 else
''}://login:password@host:1/database-override"
+ )
hook = PostgresHook(database="database-override")
- hook.get_connection = mock.MagicMock(
+ hook.get_connection = mocker.MagicMock(
return_value=Connection(
conn_type="postgres",
host="host",
@@ -385,109 +473,151 @@ class TestPostgresHookConn:
port=1,
)
)
- assert hook.get_uri() ==
"postgresql://login:password@host:1/database-override"
-
- @mock.patch("airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook")
- @pytest.mark.parametrize("aws_conn_id", [NOTSET, None, "mock_aws_conn"])
- @pytest.mark.parametrize("port", [5432, 5439, None])
- @pytest.mark.parametrize(
- "host,conn_cluster_identifier,expected_host",
- [
- (
-
"cluster-identifier.ccdfre4hpd39h.us-east-1.redshift.amazonaws.com",
- NOTSET,
- "cluster-identifier.us-east-1",
- ),
- (
-
"cluster-identifier.ccdfre4hpd39h.us-east-1.redshift.amazonaws.com",
- "different-identifier",
- "different-identifier.us-east-1",
- ),
- ],
- )
- def test_openlineage_methods_with_redshift(
- self,
- mock_aws_hook_class,
- aws_conn_id,
- port,
- host,
- conn_cluster_identifier,
- expected_host,
- ):
- mock_conn_extra = {
- "iam": True,
- "redshift": True,
- }
- if aws_conn_id is not NOTSET:
- mock_conn_extra["aws_conn_id"] = aws_conn_id
- if conn_cluster_identifier is not NOTSET:
- mock_conn_extra["cluster-identifier"] = conn_cluster_identifier
-
- self.connection.extra = json.dumps(mock_conn_extra)
- self.connection.host = host
- self.connection.port = port
-
- # Mock AWS Connection
- mock_aws_hook_instance = mock_aws_hook_class.return_value
- mock_aws_hook_instance.region_name = "us-east-1"
-
- assert (
-
self.db_hook._get_openlineage_redshift_authority_part(self.connection)
- == f"{expected_host}:{port or 5439}"
- )
+ assert hook.get_uri() == expected
[email protected]("postgres")
-class TestPostgresHook:
- table = "test_postgres_hook_table"
[email protected](USE_PSYCOPG3, reason="psycopg v3 is available")
+class TestPostgresHookConnPPG2:
+ """PostgresHookConn tests that are specific to psycopg2."""
def setup_method(self):
- self.cur = mock.MagicMock(rowcount=0)
- self.conn = conn = mock.MagicMock()
- self.conn.cursor.return_value = self.cur
+ self.connection = Connection(login="login", password="password",
host="host", schema="database")
class UnitTestPostgresHook(PostgresHook):
conn_name_attr = "test_conn_id"
- def get_conn(self):
- return conn
-
self.db_hook = UnitTestPostgresHook()
+ self.db_hook.get_connection = mock.Mock()
+ self.db_hook.get_connection.return_value = self.connection
- 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 test_sqlalchemy_url(self):
+ conn = Connection(login="login-conn", password="password-conn",
host="host", schema="database")
+ hook = PostgresHook(connection=conn)
+ expected = "postgresql://login-conn:password-conn@host/database"
+ if SQLALCHEMY_V_1_4:
+ assert str(hook.sqlalchemy_url) == expected
+ else:
+ assert hook.sqlalchemy_url.render_as_string(hide_password=False)
== expected
- def test_copy_expert(self):
- open_mock = mock.mock_open(read_data='{"some": "json"}')
- with mock.patch("airflow.providers.postgres.hooks.postgres.open",
open_mock):
- statement = "SQL"
- filename = "filename"
+ def test_sqlalchemy_url_with_sqlalchemy_query(self):
+ conn = Connection(
+ login="login-conn",
+ password="password-conn",
+ host="host",
+ schema="database",
+ extra=dict(sqlalchemy_query={"gssencmode": "disable"}),
+ )
+ hook = PostgresHook(connection=conn)
- self.cur.fetchall.return_value = None
+ expected =
"postgresql://login-conn:password-conn@host/database?gssencmode=disable"
+ if SQLALCHEMY_V_1_4:
+ assert str(hook.sqlalchemy_url) == expected
+ else:
+ assert hook.sqlalchemy_url.render_as_string(hide_password=False)
== expected
- assert self.db_hook.copy_expert(statement, filename) is None
+ def test_get_conn_cursor(self, mock_connect):
+ self.connection.extra = '{"cursor": "dictcursor", "sqlalchemy_query":
{"gssencmode": "disable"}}'
+ self.db_hook.get_conn()
+ mock_connect.assert_called_once_with(
+ cursor_factory=psycopg2.extras.DictCursor,
+ user="login",
+ password="password",
+ host="host",
+ dbname="database",
+ port=None,
+ )
- assert self.conn.close.call_count == 1
- assert self.cur.close.call_count == 1
- assert self.conn.commit.call_count == 1
- self.cur.copy_expert.assert_called_once_with(statement,
open_mock.return_value)
- assert open_mock.call_args.args == (filename, "r+")
- def test_bulk_load(self, tmp_path):
- hook = PostgresHook()
- input_data = ["foo", "bar", "baz"]
[email protected](not USE_PSYCOPG3, reason="psycopg v3 or sqlalchemy v2 not
available")
+class TestPostgresHookConnPPG3:
+ """PostgresHookConn tests that are specific to psycopg3."""
- with hook.get_conn() as conn, conn.cursor() as cur:
- cur.execute(f"CREATE TABLE {self.table} (c VARCHAR)")
- conn.commit()
+ def setup_method(self):
+ self.connection = Connection(login="login", password="password",
host="host", schema="database")
- path = tmp_path / "testfile"
- path.write_text("\n".join(input_data))
- hook.bulk_load(self.table, os.fspath(path))
+ class UnitTestPostgresHook(PostgresHook):
+ conn_name_attr = "test_conn_id"
- cur.execute(f"SELECT * FROM {self.table}")
+ self.db_hook = UnitTestPostgresHook()
+ self.db_hook.get_connection = mock.Mock()
+ self.db_hook.get_connection.return_value = self.connection
+
+ def test_sqlalchemy_url(self):
+ conn = Connection(login="login-conn", password="password-conn",
host="host", schema="database")
+ hook = PostgresHook(connection=conn)
+ expected =
"postgresql+psycopg://login-conn:password-conn@host/database"
+ if SQLALCHEMY_V_1_4:
+ assert str(hook.sqlalchemy_url) == expected
+ else:
+ assert hook.sqlalchemy_url.render_as_string(hide_password=False)
== expected
+
+ def test_sqlalchemy_url_with_sqlalchemy_query(self):
+ conn = Connection(
+ login="login-conn",
+ password="password-conn",
+ host="host",
+ schema="database",
+ extra=dict(sqlalchemy_query={"gssencmode": "disable"}),
+ )
+ hook = PostgresHook(connection=conn)
+
+ expected =
"postgresql+psycopg://login-conn:password-conn@host/database?gssencmode=disable"
+ if SQLALCHEMY_V_1_4:
+ assert str(hook.sqlalchemy_url) == expected
+ else:
+ assert hook.sqlalchemy_url.render_as_string(hide_password=False)
== expected
+
+ def test_get_conn_cursor(self, mocker):
+ mock_connect = mocker.patch("psycopg.connection.Connection.connect")
+ self.connection.extra = '{"cursor": "dictcursor", "sqlalchemy_query":
{"gssencmode": "disable"}}'
+ self.db_hook.get_conn()
+ mock_connect.assert_called_once_with(
+ row_factory=psycopg.rows.dict_row,
+ user="login",
+ password="password",
+ host="host",
+ dbname="database",
+ port=None,
+ )
+
+
[email protected]("postgres")
+class TestPostgresHook:
+ """Tests that are identical between psycopg2 and psycopg3."""
+
+ table = "test_postgres_hook_table"
+
+ def setup_method(self):
+ self.cur = mock.MagicMock(rowcount=0)
+ self.conn = conn = mock.MagicMock()
+ self.conn.cursor.return_value = self.cur
+
+ class UnitTestPostgresHook(PostgresHook):
+ conn_name_attr = "test_conn_id"
+
+ def get_conn(self):
+ return conn
+
+ self.db_hook = UnitTestPostgresHook()
+
+ 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 test_bulk_load(self, tmp_path):
+ hook = PostgresHook()
+ input_data = ["foo", "bar", "baz"]
+
+ with hook.get_conn() as conn, conn.cursor() as cur:
+ cur.execute(f"CREATE TABLE {self.table} (c VARCHAR)")
+ conn.commit()
+
+ path = tmp_path / "testfile"
+ path.write_text("\n".join(input_data))
+ hook.bulk_load(self.table, os.fspath(path))
+
+ cur.execute(f"SELECT * FROM {self.table}")
results = [row[0] for row in cur.fetchall()]
assert sorted(input_data) == sorted(results)
@@ -515,19 +645,20 @@ class TestPostgresHook:
("polars", pl.DataFrame),
],
)
-
@mock.patch("airflow.providers.postgres.hooks.postgres.PostgresHook._get_polars_df")
- @mock.patch("pandas.io.sql.read_sql")
-
@mock.patch("airflow.providers.postgres.hooks.postgres.PostgresHook.get_sqlalchemy_engine")
- def test_get_df_with_df_type(
- self, mock_get_engine, mock_read_sql, mock_polars_df, df_type,
expected_type
- ):
+ def test_get_df_with_df_type(self, mocker, df_type, expected_type):
+ mock_polars_df =
mocker.patch("airflow.providers.postgres.hooks.postgres.PostgresHook._get_polars_df")
+ mock_read_sql = mocker.patch("pandas.io.sql.read_sql")
+ mock_get_engine = mocker.patch(
+
"airflow.providers.postgres.hooks.postgres.PostgresHook.get_sqlalchemy_engine"
+ )
+
hook = mock_db_hook(PostgresHook)
mock_read_sql.return_value = pd.DataFrame()
mock_polars_df.return_value = pl.DataFrame()
sql = "SELECT * FROM table"
if df_type == "pandas":
- mock_conn = mock.MagicMock()
- mock_engine = mock.MagicMock()
+ mock_conn = mocker.MagicMock()
+ mock_engine = mocker.MagicMock()
mock_engine.connect.return_value.__enter__.return_value = mock_conn
mock_get_engine.return_value = mock_engine
df = hook.get_df(sql, df_type="pandas")
@@ -538,6 +669,402 @@ class TestPostgresHook:
mock_polars_df.assert_called_once_with(sql, None)
assert isinstance(df, expected_type)
+ def test_rowcount(self):
+ hook = PostgresHook()
+ input_data = ["foo", "bar", "baz"]
+
+ with hook.get_conn() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"CREATE TABLE {self.table} (c VARCHAR)")
+ values = ",".join(f"('{data}')" for data in input_data)
+ cur.execute(f"INSERT INTO {self.table} VALUES {values}")
+ conn.commit()
+ assert cur.rowcount == len(input_data)
+
+ def test_reserved_words(self):
+ hook = PostgresHook()
+ assert hook.reserved_words ==
sqlalchemy.dialects.postgresql.base.RESERVED_WORDS
+
+ def test_generate_insert_sql_without_already_escaped_column_name(self):
+ values = [
+ "1",
+ "mssql_conn",
+ "mssql",
+ "MSSQL connection",
+ "localhost",
+ "airflow",
+ "admin",
+ "admin",
+ 1433,
+ False,
+ False,
+ {},
+ ]
+ target_fields = [
+ "id",
+ "conn_id",
+ "conn_type",
+ "description",
+ "host",
+ "schema",
+ "login",
+ "password",
+ "port",
+ "is_encrypted",
+ "is_extra_encrypted",
+ "extra",
+ ]
+ hook = PostgresHook()
+ assert hook._generate_insert_sql(
+ table="connection", values=values, target_fields=target_fields
+ ) == INSERT_SQL_STATEMENT.format("schema")
+
+ def test_generate_insert_sql_with_already_escaped_column_name(self):
+ values = [
+ "1",
+ "mssql_conn",
+ "mssql",
+ "MSSQL connection",
+ "localhost",
+ "airflow",
+ "admin",
+ "admin",
+ 1433,
+ False,
+ False,
+ {},
+ ]
+ target_fields = [
+ "id",
+ "conn_id",
+ "conn_type",
+ "description",
+ "host",
+ '"schema"',
+ "login",
+ "password",
+ "port",
+ "is_encrypted",
+ "is_extra_encrypted",
+ "extra",
+ ]
+ hook = PostgresHook()
+ assert hook._generate_insert_sql(
+ table="connection", values=values, target_fields=target_fields
+ ) == INSERT_SQL_STATEMENT.format('"schema"')
+
+
[email protected]("postgres")
[email protected](USE_PSYCOPG3, reason="psycopg v3 is available")
+class TestPostgresHookPPG2:
+ """PostgresHook tests that are specific to psycopg2."""
+
+ table = "test_postgres_hook_table"
+
+ def setup_method(self):
+ self.cur = mock.MagicMock(rowcount=0)
+ self.conn = conn = mock.MagicMock()
+ self.conn.cursor.return_value = self.cur
+
+ class UnitTestPostgresHook(PostgresHook):
+ conn_name_attr = "test_conn_id"
+
+ def get_conn(self):
+ return conn
+
+ self.db_hook = UnitTestPostgresHook()
+
+ 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 test_copy_expert(self, mocker):
+ open_mock = mocker.mock_open(read_data='{"some": "json"}')
+ mocker.patch("airflow.providers.postgres.hooks.postgres.open",
open_mock)
+
+ statement = "SQL"
+ filename = "filename"
+
+ self.cur.fetchall.return_value = None
+
+ assert self.db_hook.copy_expert(statement, filename) is None
+
+ assert self.conn.close.call_count == 1
+ assert self.cur.close.call_count == 1
+ assert self.conn.commit.call_count == 1
+ self.cur.copy_expert.assert_called_once_with(statement,
open_mock.return_value)
+ assert open_mock.call_args.args == (filename, "r+")
+
+ def test_insert_rows(self, postgres_hook_setup):
+ setup = postgres_hook_setup
+ table = "table"
+ rows = [("hello",), ("world",)]
+
+ setup.db_hook.insert_rows(table, rows)
+
+ assert setup.conn.close.call_count == 1
+ assert setup.cur.close.call_count == 1
+
+ commit_count = 2 # The first and last commit
+ assert commit_count == setup.conn.commit.call_count
+
+ sql = f"INSERT INTO {table} VALUES (%s)"
+ setup.cur.executemany.assert_any_call(sql, rows)
+
+ def test_insert_rows_replace(self, postgres_hook_setup):
+ setup = postgres_hook_setup
+ table = "table"
+ rows = [
+ (
+ 1,
+ "hello",
+ ),
+ (
+ 2,
+ "world",
+ ),
+ ]
+ fields = ("id", "value")
+
+ setup.db_hook.insert_rows(table, rows, fields, replace=True,
replace_index=fields[0])
+
+ assert setup.conn.close.call_count == 1
+ assert setup.cur.close.call_count == 1
+
+ commit_count = 2 # The first and last commit
+ assert commit_count == setup.conn.commit.call_count
+
+ sql = (
+ f"INSERT INTO {table} ({fields[0]}, {fields[1]}) VALUES (%s,%s) "
+ f"ON CONFLICT ({fields[0]}) DO UPDATE SET {fields[1]} =
excluded.{fields[1]}"
+ )
+ setup.cur.executemany.assert_any_call(sql, rows)
+
+ def test_insert_rows_replace_missing_target_field_arg(self,
postgres_hook_setup):
+ setup = postgres_hook_setup
+ table = "table"
+ rows = [
+ (
+ 1,
+ "hello",
+ ),
+ (
+ 2,
+ "world",
+ ),
+ ]
+ fields = ("id", "value")
+ with pytest.raises(ValueError) as ctx:
+ setup.db_hook.insert_rows(table, rows, replace=True,
replace_index=fields[0])
+
+ assert str(ctx.value) == "PostgreSQL ON CONFLICT upsert syntax
requires column names"
+
+ def test_insert_rows_replace_missing_replace_index_arg(self,
postgres_hook_setup):
+ setup = postgres_hook_setup
+ table = "table"
+ rows = [
+ (
+ 1,
+ "hello",
+ ),
+ (
+ 2,
+ "world",
+ ),
+ ]
+ fields = ("id", "value")
+ with pytest.raises(ValueError) as ctx:
+ setup.db_hook.insert_rows(table, rows, fields, replace=True)
+
+ assert str(ctx.value) == "PostgreSQL ON CONFLICT upsert syntax
requires an unique index"
+
+ def test_insert_rows_replace_all_index(self, postgres_hook_setup):
+ setup = postgres_hook_setup
+ table = "table"
+ rows = [
+ (
+ 1,
+ "hello",
+ ),
+ (
+ 2,
+ "world",
+ ),
+ ]
+ fields = ("id", "value")
+
+ setup.db_hook.insert_rows(table, rows, fields, replace=True,
replace_index=fields)
+
+ assert setup.conn.close.call_count == 1
+ assert setup.cur.close.call_count == 1
+
+ commit_count = 2 # The first and last commit
+ assert commit_count == setup.conn.commit.call_count
+
+ sql = (
+ f"INSERT INTO {table} ({', '.join(fields)}) VALUES (%s,%s) "
+ f"ON CONFLICT ({', '.join(fields)}) DO NOTHING"
+ )
+ setup.cur.executemany.assert_any_call(sql, rows)
+
+ @pytest.mark.usefixtures("reset_logging_config")
+ def test_get_all_db_log_messages(self, mocker):
+ messages = ["a", "b", "c"]
+
+ class FakeLogger:
+ notices = messages
+
+ # Mock the logger
+ mock_logger =
mocker.patch("airflow.providers.postgres.hooks.postgres.PostgresHook.log")
+
+ hook = PostgresHook(enable_log_db_messages=True)
+ hook.get_db_log_messages(FakeLogger)
+
+ # Verify that info was called for each message
+ for msg in messages:
+ assert mocker.call(msg) in mock_logger.info.mock_calls
+
+ @pytest.mark.usefixtures("reset_logging_config")
+ def test_log_db_messages_by_db_proc(self, mocker):
+ proc_name = "raise_notice"
+ notice_proc = f"""
+ CREATE PROCEDURE {proc_name} (s text) LANGUAGE PLPGSQL AS
+ $$
+ BEGIN
+ raise notice 'Message from db: %', s;
+ END;
+ $$;
+ """
+
+ # Mock the logger
+ mock_logger =
mocker.patch("airflow.providers.postgres.hooks.postgres.PostgresHook.log")
+
+ hook = PostgresHook(enable_log_db_messages=True)
+ try:
+ hook.run(sql=notice_proc)
+ hook.run(sql=f"call {proc_name}('42')")
+
+ # Check if the notice message was logged
+ assert mocker.call("NOTICE: Message from db: 42\n") in
mock_logger.info.mock_calls
+ finally:
+ hook.run(sql=f"DROP PROCEDURE {proc_name} (s text)")
+
+ def test_dialect_name(self, postgres_hook_setup):
+ setup = postgres_hook_setup
+ assert setup.db_hook.dialect_name == "postgresql"
+
+ def test_dialect(self, postgres_hook_setup):
+ setup = postgres_hook_setup
+ assert isinstance(setup.db_hook.dialect, PostgresDialect)
+
+
[email protected]("postgres")
[email protected](not USE_PSYCOPG3, reason="psycopg v3 or sqlalchemy v2 are
not available")
+class TestPostgresHookPPG3:
+ """PostgresHook tests that are specific to psycopg3."""
+
+ table = "test_postgres_hook_table"
+
+ def setup_method(self):
+ self.cur = mock.MagicMock(rowcount=0)
+ self.conn = conn = mock.MagicMock()
+ self.conn.cursor.return_value = self.cur
+
+ class UnitTestPostgresHook(PostgresHook):
+ conn_name_attr = "test_conn_id"
+
+ def get_conn(self):
+ return conn
+
+ self.db_hook = UnitTestPostgresHook()
+
+ 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 test_copy_expert_from(self, mocker):
+ """Tests copy_expert with a 'COPY FROM STDIN' operation."""
+ statement = "COPY test_table FROM STDIN"
+ filename = "filename"
+
+ m_open = mocker.mock_open()
+ mocker.patch("airflow.providers.postgres.hooks.postgres.open", m_open)
+ mocker.patch("os.path.isfile", return_value=True) # Mock file exists
+
+ # Configure the file handle for reading
+ m_open.return_value.read.side_effect = [b'{"some": "json"}', b""]
+
+ # Set up the context manager chain properly
+ # self.conn needs to support context manager
+ self.conn.__enter__ = mocker.Mock(return_value=self.conn)
+ self.conn.__exit__ = mocker.Mock(return_value=None)
+
+ # cursor() returns something that also supports context manager
+ mock_cursor = mocker.MagicMock()
+ mock_cursor.__enter__ = mocker.Mock(return_value=mock_cursor)
+ mock_cursor.__exit__ = mocker.Mock(return_value=None)
+ self.conn.cursor.return_value = mock_cursor
+
+ # cursor.copy() returns a context manager
+ mock_copy = mocker.MagicMock()
+ mock_copy.__enter__ = mocker.Mock(return_value=mock_copy)
+ mock_copy.__exit__ = mocker.Mock(return_value=None)
+ mock_cursor.copy.return_value = mock_copy
+
+ # Call the method under test
+ self.db_hook.copy_expert(statement, filename)
+
+ # Assert that the file was opened for reading in binary mode
+ m_open.assert_any_call(filename, "rb")
+
+ # Assert write was called with the data
+ mock_copy.write.assert_called_once_with(b'{"some": "json"}')
+ self.conn.commit.assert_called_once()
+
+ def test_copy_expert_to(self, mocker):
+ """Tests copy_expert with a 'COPY TO STDOUT' operation."""
+ statement = "COPY test_table TO STDOUT"
+ filename = "filename"
+
+ m_open = mocker.mock_open()
+ mocker.patch("airflow.providers.postgres.hooks.postgres.open", m_open)
+
+ # Set up the context manager chain properly
+ # self.conn needs to support context manager
+ self.conn.__enter__ = mocker.Mock(return_value=self.conn)
+ self.conn.__exit__ = mocker.Mock(return_value=None)
+
+ # cursor() returns something that also supports context manager
+ mock_cursor = mocker.MagicMock()
+ mock_cursor.__enter__ = mocker.Mock(return_value=mock_cursor)
+ mock_cursor.__exit__ = mocker.Mock(return_value=None)
+ self.conn.cursor.return_value = mock_cursor
+
+ # cursor.copy() returns a context manager that is iterable
+ mock_copy = mocker.MagicMock()
+ mock_copy.__enter__ = mocker.Mock(return_value=mock_copy)
+ mock_copy.__exit__ = mocker.Mock(return_value=None)
+ mock_copy.__iter__ = mocker.Mock(return_value=iter([b"db_data_1",
b"db_data_2"]))
+ mock_cursor.copy.return_value = mock_copy
+
+ # Call the method under test
+ self.db_hook.copy_expert(statement, filename)
+
+ # Assert that the file was opened for writing in binary mode
+ m_open.assert_any_call(filename, "wb")
+
+ # Assert that the data from the DB was written to the file
+ handle = m_open.return_value
+ handle.write.assert_has_calls(
+ [
+ mocker.call(b"db_data_1"),
+ mocker.call(b"db_data_2"),
+ ]
+ )
+ self.conn.commit.assert_called_once()
+
def test_insert_rows(self):
table = "table"
rows = [("hello",), ("world",)]
@@ -645,33 +1172,12 @@ class TestPostgresHook:
)
self.cur.executemany.assert_any_call(sql, rows)
- def test_rowcount(self):
- hook = PostgresHook()
- input_data = ["foo", "bar", "baz"]
-
- with hook.get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(f"CREATE TABLE {self.table} (c VARCHAR)")
- values = ",".join(f"('{data}')" for data in input_data)
- cur.execute(f"INSERT INTO {self.table} VALUES {values}")
- conn.commit()
- assert cur.rowcount == len(input_data)
-
- @pytest.mark.usefixtures("reset_logging_config")
- def test_get_all_db_log_messages(self, caplog):
- messages = ["a", "b", "c"]
-
- class FakeLogger:
- notices = messages
-
- with caplog.at_level(logging.INFO):
- hook = PostgresHook(enable_log_db_messages=True)
- hook.get_db_log_messages(FakeLogger)
- for msg in messages:
- assert msg in caplog.text
+ @pytest.mark.skip(reason="Notice handling is callback-based in psycopg3
and cannot be tested this way.")
+ def test_get_all_db_log_messages(self, mocker):
+ pass
@pytest.mark.usefixtures("reset_logging_config")
- def test_log_db_messages_by_db_proc(self, caplog):
+ def test_log_db_messages_by_db_proc(self, mocker):
proc_name = "raise_notice"
notice_proc = f"""
CREATE PROCEDURE {proc_name} (s text) LANGUAGE PLPGSQL AS
@@ -681,89 +1187,22 @@ class TestPostgresHook:
END;
$$;
"""
- with caplog.at_level(logging.INFO):
- hook = PostgresHook(enable_log_db_messages=True)
- try:
- hook.run(sql=notice_proc)
- hook.run(sql=f"call {proc_name}('42')")
- assert "NOTICE: Message from db: 42" in caplog.text
- finally:
- hook.run(sql=f"DROP PROCEDURE {proc_name} (s text)")
+
+ # Mock the logger
+ mock_logger =
mocker.patch("airflow.providers.postgres.hooks.postgres.PostgresHook.log")
+
+ hook = PostgresHook(enable_log_db_messages=True)
+ try:
+ hook.run(sql=notice_proc)
+ hook.run(sql=f"call {proc_name}('42')")
+
+ # Check if the notice message was logged
+ mock_logger.info.assert_any_call("Message from db: 42")
+ finally:
+ hook.run(sql=f"DROP PROCEDURE {proc_name} (s text)")
def test_dialect_name(self):
assert self.db_hook.dialect_name == "postgresql"
def test_dialect(self):
assert isinstance(self.db_hook.dialect, PostgresDialect)
-
- def test_reserved_words(self):
- hook = PostgresHook()
- assert hook.reserved_words ==
sqlalchemy.dialects.postgresql.base.RESERVED_WORDS
-
- def test_generate_insert_sql_without_already_escaped_column_name(self):
- values = [
- "1",
- "mssql_conn",
- "mssql",
- "MSSQL connection",
- "localhost",
- "airflow",
- "admin",
- "admin",
- 1433,
- False,
- False,
- {},
- ]
- target_fields = [
- "id",
- "conn_id",
- "conn_type",
- "description",
- "host",
- "schema",
- "login",
- "password",
- "port",
- "is_encrypted",
- "is_extra_encrypted",
- "extra",
- ]
- hook = PostgresHook()
- assert hook._generate_insert_sql(
- table="connection", values=values, target_fields=target_fields
- ) == INSERT_SQL_STATEMENT.format("schema")
-
- def test_generate_insert_sql_with_already_escaped_column_name(self):
- values = [
- "1",
- "mssql_conn",
- "mssql",
- "MSSQL connection",
- "localhost",
- "airflow",
- "admin",
- "admin",
- 1433,
- False,
- False,
- {},
- ]
- target_fields = [
- "id",
- "conn_id",
- "conn_type",
- "description",
- "host",
- '"schema"',
- "login",
- "password",
- "port",
- "is_encrypted",
- "is_extra_encrypted",
- "extra",
- ]
- hook = PostgresHook()
- assert hook._generate_insert_sql(
- table="connection", values=values, target_fields=target_fields
- ) == INSERT_SQL_STATEMENT.format('"schema"')