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

shahar1 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 c4a5f19e67c Add SQLBulkLoadOperator to Common SQL Provider (#69362)
c4a5f19e67c is described below

commit c4a5f19e67cd25143cc000d6ae0255e13c142b95
Author: SameerMesiah97 <[email protected]>
AuthorDate: Sun Jul 26 21:33:04 2026 +0100

    Add SQLBulkLoadOperator to Common SQL Provider (#69362)
    
    Add SQLBulkLoadOperator to bulk load tab-delimited files into SQL tables 
using the underlying hook's native bulk loading implementation. The operator 
supports optional pre- and post-load SQL statements for common setup and 
cleanup tasks. Includes unit tests, documentation, and an example DAG.
---
 providers/common/sql/docs/operators.rst            |  29 ++++++
 .../airflow/providers/common/sql/operators/sql.py  |  76 ++++++++++++++++
 .../system/common/sql/example_sql_bulk_load.py     | 101 +++++++++++++++++++++
 .../tests/unit/common/sql/operators/test_sql.py    |  84 +++++++++++++++++
 4 files changed, 290 insertions(+)

diff --git a/providers/common/sql/docs/operators.rst 
b/providers/common/sql/docs/operators.rst
index 88e326a25fe..265832301c0 100644
--- a/providers/common/sql/docs/operators.rst
+++ b/providers/common/sql/docs/operators.rst
@@ -239,6 +239,35 @@ The example below shows how to instantiate the 
SQLInsertRowsOperator task.
     :start-after: [START howto_operator_sql_insert_rows]
     :end-before: [END howto_operator_sql_insert_rows]
 
+.. _howto/operator:SQLBulkLoadOperator:
+
+Bulk load data into a table
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Use the 
:class:`~airflow.providers.common.sql.operators.sql.SQLBulkLoadOperator`
+to bulk load a tab-delimited file into a database table using the database's
+native bulk loading mechanism. Parameters of the operator are:
+
+- ``table`` - name of the target table (templated).
+- ``tmp_file`` - path to the tab-delimited file to load (templated).
+- ``conn_id`` - the Airflow connection ID used to connect to the database.
+- ``database`` (optional) - name of the database which overrides the one 
defined
+  in the connection.
+- ``preoperator`` (optional) - SQL statement or list of statements to execute
+  before bulk loading (templated).
+- ``postoperator`` (optional) - SQL statement or list of statements to execute
+  after bulk loading (templated).
+- ``hook_params`` (optional) - dictionary of additional parameters passed to 
the
+  underlying hook.
+
+The example below shows how to instantiate the SQLBulkLoadOperator task.
+
+.. exampleinclude:: /../tests/system/common/sql/example_sql_bulk_load.py
+    :language: python
+    :dedent: 4
+    :start-after: [START howto_operator_sql_bulk_load]
+    :end-before: [END howto_operator_sql_bulk_load]
+
 .. _howto/operator:GenericTransfer:
 
 Generic Transfer
diff --git 
a/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py 
b/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
index 21c8b6502b0..3593c258214 100644
--- a/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
+++ b/providers/common/sql/src/airflow/providers/common/sql/operators/sql.py
@@ -1916,3 +1916,79 @@ def _initialize_partition_clause(clause: str | None) -> 
str | None:
         raise ValueError("Invalid partition_clause: semicolons (;) not 
allowed.")
 
     return clause
+
+
+class SQLBulkLoadOperator(BaseSQLOperator):
+    """
+    Bulk load a tab-delimited file into a database table.
+
+    This operator delegates to the underlying hook's ``bulk_load`` 
implementation,
+    allowing each provider to use its native bulk loading mechanism.
+
+    .. seealso::
+        For more information on how to use this operator, take a look at the 
guide:
+        :ref:`howto/operator:SQLBulkLoadOperator`
+
+    :param table: Name of the target table (templated).
+    :param tmp_file: Path to the tab-delimited file to load (templated).
+    :param conn_id: The connection ID used to connect to the database. 
Defaults to ``None``.
+    :param database: Name of the database which overrides the one defined in 
the connection.
+        Defaults to ``None``.
+    :param preoperator: SQL statement or list of statements to execute before 
bulk loading
+        (templated). Defaults to ``None``.
+    :param postoperator: SQL statement or list of statements to execute after 
bulk loading
+        (templated). Defaults to ``None``.
+    """
+
+    template_fields: Sequence[str] = (
+        "table",
+        "tmp_file",
+        "preoperator",
+        "postoperator",
+        *BaseSQLOperator.template_fields,
+    )
+
+    def __init__(
+        self,
+        *,
+        table: str,
+        tmp_file: str,
+        conn_id: str | None = None,
+        database: str | None = None,
+        preoperator: str | list[str] | None = None,
+        postoperator: str | list[str] | None = None,
+        **kwargs,
+    ) -> None:
+        super().__init__(
+            conn_id=conn_id,
+            database=database,
+            **kwargs,
+        )
+        self.table = table
+        self.tmp_file = tmp_file
+        self.preoperator = preoperator
+        self.postoperator = postoperator
+
+    def execute(self, context: Context) -> None:
+        hook = self.get_db_hook()
+
+        if self.preoperator:
+            self.log.info("Executing preoperator.")
+            hook.run(self.preoperator)
+
+        self.log.info(
+            "Bulk loading '%s' into table '%s'.",
+            self.tmp_file,
+            self.table,
+        )
+
+        hook.bulk_load(
+            table=self.table,
+            tmp_file=self.tmp_file,
+        )
+
+        if self.postoperator:
+            self.log.info("Executing postoperator.")
+            hook.run(self.postoperator)
+
+        self.log.info("Bulk load completed.")
diff --git 
a/providers/common/sql/tests/system/common/sql/example_sql_bulk_load.py 
b/providers/common/sql/tests/system/common/sql/example_sql_bulk_load.py
new file mode 100644
index 00000000000..8cea0c93a32
--- /dev/null
+++ b/providers/common/sql/tests/system/common/sql/example_sql_bulk_load.py
@@ -0,0 +1,101 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from pathlib import Path
+
+from airflow import DAG
+from airflow.providers.common.sql.operators.sql import (
+    SQLBulkLoadOperator,
+)
+from airflow.providers.standard.operators.python import PythonOperator
+from airflow.utils.timezone import datetime
+
+TMP_FILE = "/tmp/actors.tsv"
+
+connection_args = {
+    "conn_id": "airflow_db",
+    "conn_type": "Postgres",
+    "host": "postgres",
+    "schema": "postgres",
+    "login": "postgres",
+    "password": "postgres",
+    "port": 5432,
+}
+
+
+def create_bulk_load_file():
+    Path(TMP_FILE).write_text(
+        "\n".join(
+            [
+                "Stallone\tSylvester\t78",
+                "Statham\tJason\t57",
+                "Li\tJet\t61",
+                "Lundgren\tDolph\t66",
+                "Norris\tChuck\t84",
+            ]
+        ),
+        encoding="utf-8",
+    )
+
+
+with DAG(
+    "example_sql_bulk_load",
+    description="Example DAG for SQLBulkLoadOperator.",
+    default_args=connection_args,
+    start_date=datetime(2021, 1, 1),
+    schedule=None,
+    catchup=False,
+) as dag:
+    """
+    ### Example SQL bulk load DAG
+
+    Runs the SQLBulkLoadOperator against the Airflow metadata DB.
+    """
+
+    create_file = PythonOperator(
+        task_id="create_file",
+        python_callable=create_bulk_load_file,
+    )
+
+    # [START howto_operator_sql_bulk_load]
+    bulk_load = SQLBulkLoadOperator(
+        task_id="bulk_load",
+        table="actors",
+        tmp_file=TMP_FILE,
+        preoperator=[
+            """
+            CREATE TABLE IF NOT EXISTS actors (
+                name TEXT NOT NULL,
+                firstname TEXT NOT NULL,
+                age BIGINT NOT NULL
+            );
+            """,
+            "TRUNCATE TABLE actors;",
+        ],
+        postoperator="DROP TABLE IF EXISTS actors;",
+    )
+    # [END howto_operator_sql_bulk_load]
+
+    create_file >> bulk_load
+
+
+from tests_common.test_utils.system_tests import get_test_run  # noqa: E402
+
+# Needed to run the example DAG with pytest (see: 
contributing-docs/testing/system_tests.rst)
+test_run = get_test_run(dag)
diff --git a/providers/common/sql/tests/unit/common/sql/operators/test_sql.py 
b/providers/common/sql/tests/unit/common/sql/operators/test_sql.py
index 14380b71736..ef37630d440 100644
--- a/providers/common/sql/tests/unit/common/sql/operators/test_sql.py
+++ b/providers/common/sql/tests/unit/common/sql/operators/test_sql.py
@@ -34,6 +34,7 @@ from airflow.providers.common.sql.hooks.sql import DbApiHook
 from airflow.providers.common.sql.operators.sql import (
     BaseSQLOperator,
     BranchSQLOperator,
+    SQLBulkLoadOperator,
     SQLCheckOperator,
     SQLCheckResult,
     SQLColumnCheckOperator,
@@ -2957,3 +2958,86 @@ class TestSQLInsertRowsOperator:
             (4, "Lundgren", "Dolph", 66),
             (5, "Norris", "Chuck", 84),
         ]
+
+
+class TestSQLBulkLoadOperator:
+    def _construct_operator(self, table, tmp_file, **kwargs):
+        dag = DAG("test_dag", schedule=None, 
start_date=datetime.datetime(2017, 1, 1))
+        return SQLBulkLoadOperator(
+            task_id="test_task",
+            conn_id="default_conn",
+            table=table,
+            tmp_file=tmp_file,
+            dag=dag,
+            **kwargs,
+        )
+
+    @mock.patch.object(SQLBulkLoadOperator, "get_db_hook")
+    def test_execute(self, mock_get_db_hook):
+        operator = self._construct_operator(
+            table="users",
+            tmp_file="/tmp/users.tsv",
+            preoperator="CREATE TABLE users (id INT);",
+            postoperator="DROP TABLE users;",
+        )
+
+        operator.execute(context=MagicMock())
+
+        hook = mock_get_db_hook.return_value
+
+        assert hook.mock_calls == [
+            mock.call.run("CREATE TABLE users (id INT);"),
+            mock.call.bulk_load(
+                table="users",
+                tmp_file="/tmp/users.tsv",
+            ),
+            mock.call.run("DROP TABLE users;"),
+        ]
+
+    @mock.patch.object(SQLBulkLoadOperator, "get_db_hook")
+    def test_execute_templated_fields(self, mock_get_db_hook):
+        operator = self._construct_operator(
+            table="{{ params.table }}",
+            tmp_file="{{ params.file }}",
+            preoperator="TRUNCATE TABLE {{ params.table }};",
+            postoperator="DROP TABLE {{ params.table }};",
+        )
+
+        operator.render_template_fields(
+            {
+                "params": {
+                    "table": "users",
+                    "file": "/tmp/users.tsv",
+                }
+            }
+        )
+
+        operator.execute(context=MagicMock())
+
+        hook = mock_get_db_hook.return_value
+
+        assert hook.mock_calls == [
+            mock.call.run("TRUNCATE TABLE users;"),
+            mock.call.bulk_load(
+                table="users",
+                tmp_file="/tmp/users.tsv",
+            ),
+            mock.call.run("DROP TABLE users;"),
+        ]
+
+    @mock.patch.object(SQLBulkLoadOperator, "get_db_hook")
+    def test_execute_without_pre_or_post_operator(self, mock_get_db_hook):
+        operator = self._construct_operator(
+            table="users",
+            tmp_file="/tmp/users.tsv",
+        )
+
+        operator.execute(context=MagicMock())
+
+        hook = mock_get_db_hook.return_value
+
+        hook.run.assert_not_called()
+        hook.bulk_load.assert_called_once_with(
+            table="users",
+            tmp_file="/tmp/users.tsv",
+        )

Reply via email to