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 1948af1fedb Add unit tests for PrestoToGCSOperator (#75559)
1948af1fedb is described below

commit 1948af1fedbab529e506ee6864c07be39966462d
Author: Liam <[email protected]>
AuthorDate: Thu Nov 5 08:59:31 2026 +0200

    Add unit tests for PrestoToGCSOperator (#75559)
---
 .../tests/unit/always/test_project_structure.py    |   1 -
 .../google/cloud/transfers/test_presto_to_gcs.py   | 314 +++++++++++++++++++++
 2 files changed, 314 insertions(+), 1 deletion(-)

diff --git a/airflow-core/tests/unit/always/test_project_structure.py 
b/airflow-core/tests/unit/always/test_project_structure.py
index 570393b33a7..3429e1f8bee 100644
--- a/airflow-core/tests/unit/always/test_project_structure.py
+++ b/airflow-core/tests/unit/always/test_project_structure.py
@@ -97,7 +97,6 @@ class TestProjectStructure:
             
"providers/google/tests/unit/google/cloud/operators/vertex_ai/test_pipeline_job.py",
             
"providers/google/tests/unit/google/cloud/sensors/vertex_ai/test_feature_store.py",
             
"providers/google/tests/unit/google/cloud/transfers/test_bigquery_to_sql.py",
-            
"providers/google/tests/unit/google/cloud/transfers/test_presto_to_gcs.py",
             
"providers/microsoft/azure/tests/unit/microsoft/azure/operators/test_adls.py",
         ]
         # Deliberately untested: link modules whose tests would only assert 
constants.
diff --git 
a/providers/google/tests/unit/google/cloud/transfers/test_presto_to_gcs.py 
b/providers/google/tests/unit/google/cloud/transfers/test_presto_to_gcs.py
new file mode 100644
index 00000000000..8f26211bed2
--- /dev/null
+++ b/providers/google/tests/unit/google/cloud/transfers/test_presto_to_gcs.py
@@ -0,0 +1,314 @@
+#
+# 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 itertools import chain, repeat
+from unittest.mock import MagicMock, patch
+
+import pytest
+from prestodb.dbapi import Cursor as PrestoCursor
+
+from airflow.providers.google.cloud.transfers.presto_to_gcs import (
+    PrestoToGCSOperator,
+    _PrestoToGCSPrestoCursorAdapter,
+)
+
+TASK_ID = "test-presto-to-gcs"
+PRESTO_CONN_ID = "my-presto-conn"
+GCP_CONN_ID = "my-gcp-conn"
+IMPERSONATION_CHAIN = ["ACCOUNT_1", "ACCOUNT_2", "ACCOUNT_3"]
+SQL = "SELECT * FROM memory.default.test_multiple_types"
+BUCKET = "gs://test"
+FILENAME = "test_{}.ndjson"
+
+NDJSON_LINES = [
+    b'{"some_num": 42, "some_str": "mock_row_content_1"}\n',
+    b'{"some_num": 43, "some_str": "mock_row_content_2"}\n',
+    b'{"some_num": 44, "some_str": "mock_row_content_3"}\n',
+]
+CSV_LINES = [
+    b"some_num,some_str\r\n",
+    b"42,mock_row_content_1\r\n",
+    b"43,mock_row_content_2\r\n",
+    b"44,mock_row_content_3\r\n",
+]
+SCHEMA_FILENAME = "schema_test.json"
+SCHEMA_JSON = b'[{"name": "some_num", "type": "INT64"}, {"name": "some_str", 
"type": "STRING"}]'
+
+ROWS = [
+    [42, "mock_row_content_1"],
+    [43, "mock_row_content_2"],
+    [44, "mock_row_content_3"],
+]
+
+
+def _setup_mock_cursor(mock_presto_hook, str_type="VARCHAR"):
+    mock_cursor = mock_presto_hook.return_value.get_conn.return_value.cursor
+    mock_cursor.return_value.description = [
+        ("some_num", "INTEGER", None, None, None, None, None),
+        ("some_str", str_type, None, None, None, None, None),
+    ]
+    mock_cursor.return_value.fetchone.side_effect = [*ROWS, None]
+    return mock_cursor
+
+
+class TestPrestoToGCSPrestoCursorAdapter:
+    @pytest.fixture
+    def cursor(self):
+        cursor = MagicMock(spec=PrestoCursor)
+        cursor.fetchone.side_effect = chain(ROWS, repeat(None))
+        cursor.arraysize = 2
+        return cursor
+
+    def test_description_peeks_first_row_without_consuming_it(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        assert adapter.description is cursor.description
+        assert adapter.initialized
+        assert cursor.fetchone.call_count == 1
+        # The row peeked to load the description is buffered, so fetching it 
does not call the cursor.
+        assert adapter.fetchone() == ROWS[0]
+        assert cursor.fetchone.call_count == 1
+
+    def test_description_does_not_peek_twice(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        adapter.description
+        adapter.description
+
+        assert cursor.fetchone.call_count == 1
+
+    def test_peekone(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        assert adapter.peekone() == ROWS[0]
+        assert adapter.fetchone() == ROWS[0]
+        assert adapter.fetchone() == ROWS[1]
+
+    def test_fetchmany_uses_arraysize_by_default(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        assert adapter.fetchmany() == ROWS[:2]
+        assert adapter.fetchmany() == ROWS[2:]
+        assert adapter.fetchmany() == []
+
+    def test_fetchmany_with_size(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        assert adapter.fetchmany(5) == ROWS
+
+    def test_iteration(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        assert iter(adapter) is adapter
+        assert list(adapter) == ROWS
+
+    @pytest.mark.parametrize("method", ["execute", "executemany"])
+    def test_execute_drops_row_peeked_from_previous_query(self, cursor, 
method):
+        """A row peeked before running a new query must not be returned as a 
row of the new query."""
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+        assert adapter.peekone() == ROWS[0]
+
+        result = getattr(adapter, method)(SQL)
+
+        assert result is getattr(cursor, method).return_value
+        getattr(cursor, method).assert_called_once_with(SQL)
+        assert not adapter.initialized
+        # The next row comes from the cursor, not from the buffer.
+        assert adapter.fetchone() == ROWS[1]
+
+    def test_rowcount_and_close_delegate_to_cursor(self, cursor):
+        adapter = _PrestoToGCSPrestoCursorAdapter(cursor)
+
+        assert adapter.rowcount is cursor.rowcount
+        adapter.close()
+        cursor.close.assert_called_once_with()
+
+
+class TestPrestoToGCSOperator:
+    def test_init(self):
+        """Test PrestoToGCSOperator instance is properly initialized."""
+        op = PrestoToGCSOperator(
+            task_id=TASK_ID,
+            sql=SQL,
+            bucket=BUCKET,
+            filename=FILENAME,
+            impersonation_chain=IMPERSONATION_CHAIN,
+        )
+        assert op.task_id == TASK_ID
+        assert op.sql == SQL
+        assert op.bucket == BUCKET
+        assert op.filename == FILENAME
+        assert op.impersonation_chain == IMPERSONATION_CHAIN
+        assert op.presto_conn_id == "presto_default"
+        assert "presto_conn_id" in op.template_fields
+
+    @pytest.mark.parametrize(
+        ("field_type", "expected_type"),
+        [
+            ("integer", "INT64"),
+            ("BOOLEAN", "BOOL"),
+            ("DECIMAL(10, 2)", "NUMERIC"),
+            ("VARCHAR(20)", "STRING"),
+            ("TIMESTAMP", "TIMESTAMP"),
+            ("TIMESTAMP WITH TIME ZONE", "STRING"),
+            ("ARRAY(INTEGER)", "STRING"),
+        ],
+    )
+    def test_field_to_bigquery(self, field_type, expected_type):
+        op = PrestoToGCSOperator(task_id=TASK_ID, sql=SQL, bucket=BUCKET, 
filename=FILENAME)
+
+        assert op.field_to_bigquery(("col", field_type)) == {"name": "col", 
"type": expected_type}
+
+    def test_convert_type_returns_value_unchanged(self):
+        """
+        The base operator passes every value it writes through 
``convert_type``.
+
+        Presto values need no conversion, so they are written as the cursor 
returns them.
+        """
+        op = PrestoToGCSOperator(task_id=TASK_ID, sql=SQL, bucket=BUCKET, 
filename=FILENAME)
+        value = object()
+
+        assert op.convert_type(value, "STRING") is value
+
+    @pytest.mark.parametrize(
+        ("export_format", "expected_mime_type", "expected_content"),
+        [
+            pytest.param("json", "application/json", b"".join(NDJSON_LINES), 
id="json"),
+            pytest.param("csv", "text/csv", b"".join(CSV_LINES), id="csv"),
+        ],
+    )
+    
@patch("airflow.providers.google.cloud.transfers.presto_to_gcs.PrestoHook", 
autospec=True)
+    @patch("airflow.providers.google.cloud.transfers.sql_to_gcs.GCSHook", 
autospec=True)
+    def test_save(self, mock_gcs_hook, mock_presto_hook, export_format, 
expected_mime_type, expected_content):
+        def _assert_upload(bucket, obj, tmp_filename, mime_type, gzip, 
metadata=None):
+            assert bucket == BUCKET
+            assert FILENAME.format(0) == obj
+            assert mime_type == expected_mime_type
+            assert not gzip
+            with open(tmp_filename, "rb") as file:
+                assert expected_content == file.read()
+
+        mock_gcs_hook.return_value.upload.side_effect = _assert_upload
+        mock_cursor = _setup_mock_cursor(mock_presto_hook)
+
+        op = PrestoToGCSOperator(
+            task_id=TASK_ID,
+            sql=SQL,
+            bucket=BUCKET,
+            filename=FILENAME,
+            export_format=export_format,
+            presto_conn_id=PRESTO_CONN_ID,
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+        )
+
+        op.execute(None)
+
+        mock_presto_hook.assert_called_once_with(presto_conn_id=PRESTO_CONN_ID)
+        mock_cursor.return_value.execute.assert_called_once_with(SQL)
+        mock_gcs_hook.assert_called_once_with(
+            gcp_conn_id=GCP_CONN_ID,
+            impersonation_chain=IMPERSONATION_CHAIN,
+        )
+        mock_gcs_hook.return_value.upload.assert_called_once()
+
+    @pytest.mark.parametrize(
+        ("export_format", "expected_mime_type", "header", "lines"),
+        [
+            pytest.param("json", "application/json", b"", NDJSON_LINES, 
id="json"),
+            pytest.param("csv", "text/csv", CSV_LINES[0], CSV_LINES[1:], 
id="csv"),
+        ],
+    )
+    @pytest.mark.parametrize(
+        ("size_offset", "splits"),
+        [
+            pytest.param(-1, True, id="limit-below-two-rows"),
+            pytest.param(0, True, id="limit-at-two-rows"),
+            pytest.param(1, False, id="limit-above-two-rows"),
+        ],
+    )
+    
@patch("airflow.providers.google.cloud.transfers.presto_to_gcs.PrestoHook", 
autospec=True)
+    @patch("airflow.providers.google.cloud.transfers.sql_to_gcs.GCSHook", 
autospec=True)
+    def test_save_split_by_file_size(
+        self,
+        mock_gcs_hook,
+        mock_presto_hook,
+        size_offset,
+        splits,
+        export_format,
+        expected_mime_type,
+        header,
+        lines,
+    ):
+        """A new file is started once the current one reaches 
``approx_max_file_size_bytes``."""
+        two_rows = header + lines[0] + lines[1]
+        if splits:
+            expected_upload = {FILENAME.format(0): two_rows, 
FILENAME.format(1): header + lines[2]}
+        else:
+            expected_upload = {FILENAME.format(0): two_rows + lines[2]}
+
+        def _assert_upload(bucket, obj, tmp_filename, mime_type, gzip, 
metadata=None):
+            assert bucket == BUCKET
+            assert mime_type == expected_mime_type
+            assert not gzip
+            with open(tmp_filename, "rb") as file:
+                assert expected_upload[obj] == file.read()
+
+        mock_gcs_hook.return_value.upload.side_effect = _assert_upload
+        _setup_mock_cursor(mock_presto_hook, str_type="VARCHAR(20)")
+
+        op = PrestoToGCSOperator(
+            task_id=TASK_ID,
+            sql=SQL,
+            bucket=BUCKET,
+            filename=FILENAME,
+            approx_max_file_size_bytes=len(two_rows) + size_offset,
+            export_format=export_format,
+        )
+
+        op.execute(None)
+
+        assert mock_gcs_hook.return_value.upload.call_count == 
len(expected_upload)
+
+    @pytest.mark.parametrize("export_format", ["json", "csv"])
+    
@patch("airflow.providers.google.cloud.transfers.presto_to_gcs.PrestoHook", 
autospec=True)
+    @patch("airflow.providers.google.cloud.transfers.sql_to_gcs.GCSHook", 
autospec=True)
+    def test_save_schema_file(self, mock_gcs_hook, mock_presto_hook, 
export_format):
+        """Test writing schema files."""
+
+        def _assert_upload(bucket, obj, tmp_filename, mime_type, gzip, 
metadata=None):
+            if obj == SCHEMA_FILENAME:
+                with open(tmp_filename, "rb") as file:
+                    assert file.read() == SCHEMA_JSON
+
+        mock_gcs_hook.return_value.upload.side_effect = _assert_upload
+        _setup_mock_cursor(mock_presto_hook)
+
+        op = PrestoToGCSOperator(
+            task_id=TASK_ID,
+            sql=SQL,
+            bucket=BUCKET,
+            filename=FILENAME,
+            schema_filename=SCHEMA_FILENAME,
+            export_format=export_format,
+        )
+        op.execute(None)
+
+        # once for the file and once for the schema
+        assert mock_gcs_hook.return_value.upload.call_count == 2

Reply via email to