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 8491de96d78 Added specialized insert_rows in PostgresHook which uses 
faster psycopg execute_batch method (#54988)
8491de96d78 is described below

commit 8491de96d784dd088bebe9e7f63306dfd0133f0e
Author: David Blain <[email protected]>
AuthorDate: Sat Aug 30 16:07:59 2025 +0200

    Added specialized insert_rows in PostgresHook which uses faster psycopg 
execute_batch method (#54988)
    
    * refactor: Added specialized insert_rows in PostgresHook which is way 
faster than executemany as it reduced the number of round trips with database
    
    * refactor: Take into account the fast_executemany parameter in insert_rows 
of PostgresHook, if enabled, use optimized execute_batch from psycopg2, 
otherwise fallback to default implementation of DbApiHook
    
    * refactor: Fixed PostgresHook
    
    * refactor: Added test_insert_rows_fast_executemany
    
    * refactor: Reformatted insert_rows of PostgresHook
    
    * refactor: Patch _serialize_cells in test_insert_rows_fast_executemany
    
    * refactor: Try fixing test_insert_rows_fast_executemany
    
    * refactor: Removed mocking of _sanitize_cells method in 
test_insert_rows_fast_executemany
---
 .../airflow/providers/postgres/hooks/postgres.py   | 79 +++++++++++++++++++++-
 .../tests/unit/postgres/hooks/test_postgres.py     | 28 +++++++-
 2 files changed, 104 insertions(+), 3 deletions(-)

diff --git 
a/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py 
b/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
index 9c159deba74..7a3be0ff4e3 100644
--- a/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
+++ b/providers/postgres/src/airflow/providers/postgres/hooks/postgres.py
@@ -26,7 +26,8 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, 
TypeAlias, cast, overl
 import psycopg2
 import psycopg2.extensions
 import psycopg2.extras
-from psycopg2.extras import DictCursor, NamedTupleCursor, RealDictCursor
+from more_itertools import chunked
+from psycopg2.extras import DictCursor, NamedTupleCursor, RealDictCursor, 
execute_batch
 from sqlalchemy.engine import URL
 
 from airflow.exceptions import (
@@ -574,3 +575,79 @@ class PostgresHook(DbApiHook):
 
         for output in conn.notices:
             self.log.info(output)
+
+    def insert_rows(
+        self,
+        table,
+        rows,
+        target_fields=None,
+        commit_every=1000,
+        replace=False,
+        *,
+        executemany=False,
+        fast_executemany=False,
+        autocommit=False,
+        **kwargs,
+    ):
+        """
+        Insert a collection of tuples into a table.
+
+        Rows are inserted in chunks, each chunk (of size ``commit_every``) is
+        done in a new transaction.
+
+        :param table: Name of the target table
+        :param rows: The rows to insert into the table
+        :param target_fields: The names of the columns to fill in the table
+        :param commit_every: The maximum number of rows to insert in one
+            transaction. Set to 0 to insert all rows in one transaction.
+        :param replace: Whether to replace instead of insert
+        :param executemany: If True, all rows are inserted at once in
+            chunks defined by the commit_every parameter. This only works if 
all rows
+            have same number of column names, but leads to better performance.
+        :param fast_executemany: If True, rows will be inserted using an 
optimized
+            bulk execution strategy (``psycopg2.extras.execute_batch``). This 
can
+            significantly improve performance for large inserts. If set to 
False,
+            the method falls back to the default implementation from
+            ``DbApiHook.insert_rows``.
+        :param autocommit: What to set the connection's autocommit setting to
+            before executing the query.
+        """
+        # if fast_executemany is disabled, defer to default implementation of 
insert_rows in DbApiHook
+        if not fast_executemany:
+            return super().insert_rows(
+                table,
+                rows,
+                target_fields=target_fields,
+                commit_every=commit_every,
+                replace=replace,
+                executemany=executemany,
+                autocommit=autocommit,
+                **kwargs,
+            )
+
+        # if fast_executemany is enabled, use optimized execute_batch from 
psycopg
+        nb_rows = 0
+        with self._create_autocommit_connection(autocommit) as conn:
+            conn.commit()
+            with closing(conn.cursor()) as cur:
+                for chunked_rows in chunked(rows, commit_every):
+                    values = list(
+                        map(
+                            lambda row: self._serialize_cells(row, conn),
+                            chunked_rows,
+                        )
+                    )
+                    sql = self._generate_insert_sql(table, values[0], 
target_fields, replace, **kwargs)
+                    self.log.debug("Generated sql: %s", sql)
+
+                    try:
+                        execute_batch(cur, sql, values, page_size=commit_every)
+                    except Exception as e:
+                        self.log.error("Generated sql: %s", sql)
+                        self.log.error("Parameters: %s", values)
+                        raise e
+
+                    conn.commit()
+                    nb_rows += len(chunked_rows)
+                    self.log.info("Loaded %s rows into %s so far", nb_rows, 
table)
+        self.log.info("Done loading. Loaded a total of %s rows into %s", 
nb_rows, table)
diff --git a/providers/postgres/tests/unit/postgres/hooks/test_postgres.py 
b/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
index 5a842666a5a..f394c561c2c 100644
--- a/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
+++ b/providers/postgres/tests/unit/postgres/hooks/test_postgres.py
@@ -30,7 +30,7 @@ import sqlalchemy
 from airflow.exceptions import AirflowException
 from airflow.models import Connection
 from airflow.providers.postgres.dialects.postgres import PostgresDialect
-from airflow.providers.postgres.hooks.postgres import PostgresHook
+from airflow.providers.postgres.hooks.postgres import CompatConnection, 
PostgresHook
 from airflow.utils.types import NOTSET
 
 from tests_common.test_utils.common_sql import mock_db_hook
@@ -65,7 +65,7 @@ def postgres_hook_setup():
     """Set up mock PostgresHook for testing."""
     table = "test_postgres_hook_table"
     cur = mock.MagicMock(rowcount=0)
-    conn = mock.MagicMock()
+    conn = mock.MagicMock(spec=CompatConnection)
     conn.cursor.return_value = cur
 
     class UnitTestPostgresHook(PostgresHook):
@@ -812,6 +812,30 @@ class TestPostgresHookPPG2:
         sql = f"INSERT INTO {table}  VALUES (%s)"
         setup.cur.executemany.assert_any_call(sql, rows)
 
+    @mock.patch("airflow.providers.postgres.hooks.postgres.execute_batch")
+    def test_insert_rows_fast_executemany(self, mock_execute_batch, 
postgres_hook_setup):
+        setup = postgres_hook_setup
+        table = "table"
+        rows = [("hello",), ("world",)]
+
+        setup.db_hook.insert_rows(table, rows, fast_executemany=True)
+
+        assert setup.conn.close.call_count == 1
+        assert setup.cur.close.call_count == 1
+
+        commit_count = 2  # The first and last commit
+        assert setup.conn.commit.call_count == commit_count
+
+        mock_execute_batch.assert_called_once_with(
+            setup.cur,
+            f"INSERT INTO {table}  VALUES (%s)",  # expected SQL
+            [("hello",), ("world",)],  # expected values
+            page_size=1000,
+        )
+
+        # executemany should NOT be called in this mode
+        setup.cur.executemany.assert_not_called()
+
     def test_insert_rows_replace(self, postgres_hook_setup):
         setup = postgres_hook_setup
         table = "table"

Reply via email to