diff --git a/bigquery/transactions/bigquery_multi_statement_transaction.py b/bigquery/transactions/bigquery_multi_statement_transaction.py new file mode 100644 index 0000000000..30fa62f403 --- /dev/null +++ b/bigquery/transactions/bigquery_multi_statement_transaction.py @@ -0,0 +1,93 @@ +# Copyright 2026 Google LLC +# +# Licensed 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 +# +# https://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. + +# [START bigquery_multi_statement_transaction] +import sys + +from google.api_core import exceptions +from google.cloud import bigquery + + +def run_multi_statement_transaction( + project_id: str, + dataset_id: str, + source_table_id: str, + target_table_id: str, +) -> None: + """Executes a multi-statement SQL transaction with exception rollback handling. + + Args: + project_id: Google Cloud project ID (for example, 'example-project'). + dataset_id: BigQuery dataset ID containing the tables + (for example, 'example_dataset'). + source_table_id: Source staging table ID to merge and clear + (for example, 'example_new_arrivals'). + target_table_id: Target inventory table ID to update atomically + (for example, 'example_inventory'). + """ + client = bigquery.Client(project=project_id) + source_ref = f"{project_id}.{dataset_id}.{source_table_id}" + target_ref = f"{project_id}.{dataset_id}.{target_table_id}" + # BigQuery scopes a multi-statement transaction to a single script query job, spawning child + # jobs for each statement and rolling back uncommitted changes on error. + sql = f""" + BEGIN + BEGIN TRANSACTION; + MERGE `{target_ref}` AS inv + USING `{source_ref}` AS src + ON inv.product = src.product + WHEN MATCHED THEN + UPDATE SET quantity = inv.quantity + src.quantity + WHEN NOT MATCHED THEN + INSERT (product, quantity) VALUES (src.product, src.quantity); + DELETE FROM `{source_ref}` WHERE TRUE; + COMMIT TRANSACTION; + EXCEPTION WHEN ERROR THEN + ROLLBACK TRANSACTION; + RAISE USING MESSAGE = @@error.message; + END; + SELECT + COUNT(*) AS inventory_rows + FROM `{target_ref}`; + """ + + try: + print(f"Running multi-statement transaction into: {target_ref}") + query_job = client.query(sql) + rows = list(query_job.result()) + except exceptions.NotFound as err: + print( + f"error: tables '{source_table_id}' or '{target_table_id}' not found in dataset " + f"'{dataset_id}' for project '{project_id}': {err.message}", + file=sys.stderr, + ) + return + except exceptions.GoogleAPICallError as err: + print( + f"error: multi-statement transaction failed on '{source_table_id}' and " + f"'{target_table_id}' in dataset '{dataset_id}' for project '{project_id}': {err.message}", + file=sys.stderr, + ) + return + + inventory_rows = rows[0].inventory_rows if rows else 0 + print(f"Resource ID: {target_ref}") + print(f"Child Jobs: {query_job.num_child_jobs}") + print(f"Inventory Rows: {inventory_rows}") + print(f"Job ID: {query_job.job_id}") + print(f"Source Table: {source_ref}") + print("Transaction State: COMMITTED") + + +# [END bigquery_multi_statement_transaction] diff --git a/bigquery/transactions/bigquery_multi_statement_transaction_test.py b/bigquery/transactions/bigquery_multi_statement_transaction_test.py new file mode 100644 index 0000000000..7593c0a910 --- /dev/null +++ b/bigquery/transactions/bigquery_multi_statement_transaction_test.py @@ -0,0 +1,69 @@ +# Copyright 2026 Google LLC +# +# Licensed 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 +# +# https://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 unittest import mock + +import bigquery_multi_statement_transaction +from google.api_core import exceptions +import pytest + + +def test_run_multi_statement_transaction_success( + capsys: pytest.CaptureFixture[str], +) -> None: + with mock.patch("google.cloud.bigquery.Client") as mock_client_cls: + mock_client = mock_client_cls.return_value + mock_job = mock.MagicMock() + mock_job.job_id = "example-job-123" + mock_job.num_child_jobs = 3 + mock_row = mock.MagicMock() + mock_row.inventory_rows = 3 + mock_job.result.return_value = [mock_row] + mock_client.query.return_value = mock_job + + bigquery_multi_statement_transaction.run_multi_statement_transaction( + project_id="example-project", + dataset_id="example_dataset", + source_table_id="example_new_arrivals", + target_table_id="example_inventory", + ) + + mock_client.query.assert_called_once() + captured = capsys.readouterr() + assert ( + "Resource ID: example-project.example_dataset.example_inventory" + in captured.out + ) + assert "Child Jobs: 3" in captured.out + assert "Inventory Rows: 3" in captured.out + assert "Transaction State: COMMITTED" in captured.out + + +def test_run_multi_statement_transaction_not_found( + capsys: pytest.CaptureFixture[str], +) -> None: + with mock.patch("google.cloud.bigquery.Client") as mock_client_cls: + mock_client = mock_client_cls.return_value + mock_client.query.side_effect = exceptions.NotFound("Table not found") + + bigquery_multi_statement_transaction.run_multi_statement_transaction( + project_id="example-project", + dataset_id="example_dataset", + source_table_id="example_new_arrivals", + target_table_id="example_inventory", + ) + + captured = capsys.readouterr() + assert "error:" in captured.err + assert "example_inventory" in captured.err diff --git a/bigquery/transactions/noxfile_config.py b/bigquery/transactions/noxfile_config.py new file mode 100644 index 0000000000..63902aa8c7 --- /dev/null +++ b/bigquery/transactions/noxfile_config.py @@ -0,0 +1,23 @@ +# Copyright 2026 Google LLC +# +# Licensed 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 +# +# https://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. + +TEST_CONFIG_OVERRIDE = { + # You can opt out from the test for specific Python versions. + "ignored_versions": ["2.7", "3.7", "3.8", "3.9", "3.10", "3.11"], + # Enforce type hints on new samples. + "enforce_type_hints": True, + "gcloud_project_env": "GOOGLE_CLOUD_PROJECT", + "pip_version_override": None, + "envs": {}, +} diff --git a/bigquery/transactions/requirements-test.txt b/bigquery/transactions/requirements-test.txt new file mode 100644 index 0000000000..c9e154ba44 --- /dev/null +++ b/bigquery/transactions/requirements-test.txt @@ -0,0 +1 @@ +pytest==9.0.3; python_version >= "3.10" diff --git a/bigquery/transactions/requirements.txt b/bigquery/transactions/requirements.txt new file mode 100644 index 0000000000..dc6cf8e432 --- /dev/null +++ b/bigquery/transactions/requirements.txt @@ -0,0 +1 @@ +google-cloud-bigquery==3.47.0