Skip to content

Commit 48fdd43

Browse files
committed
sdks/python: enrich data with CloudSQL
1 parent 3595a33 commit 48fdd43

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

  • sdks/python/apache_beam/transforms/enrichment_handlers
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
import logging
18+
from collections.abc import Callable
19+
from enum import Enum
20+
from typing import Any
21+
from typing import Optional
22+
23+
from google.cloud.sql.connector import Connector
24+
25+
import apache_beam as beam
26+
from apache_beam.transforms.enrichment import EnrichmentSourceHandler
27+
from apache_beam.transforms.enrichment_handlers.utils import ExceptionLevel
28+
29+
__all__ = [
30+
'CloudSQLEnrichmentHandler',
31+
]
32+
33+
RowKeyFn = Callable[[beam.Row], str]
34+
35+
_LOGGER = logging.getLogger(__name__)
36+
37+
38+
class DatabaseTypeAdapter(Enum):
39+
POSTGRESQL = "pg8000"
40+
MYSQL = "pymysql"
41+
SQLSERVER = "pytds"
42+
43+
def __str__(self):
44+
return self.value
45+
46+
47+
class CloudSQLEnrichmentHandler(EnrichmentSourceHandler[beam.Row, beam.Row]):
48+
"""A handler for :class:`apache_beam.transforms.enrichment.Enrichment`
49+
transform to interact with Google Cloud SQL databases.
50+
51+
Args:
52+
project_id (str): GCP project-id of the Cloud SQL instance.
53+
region_id (str): GCP region-id of the Cloud SQL instance.
54+
instance_id (str): GCP instance-id of the Cloud SQL instance.
55+
database_type_adapter (DatabaseTypeAdapter): The type of database adapter to use.
56+
Supported adapters are: POSTGRESQL (pg8000), MYSQL (pymysql), and SQLSERVER (pytds).
57+
database_name (str): The name of the database to connect to.
58+
database_user (str): The username for connecting to the database.
59+
database_password (str): The password for connecting to the database.
60+
table_id (str): The name of the table to query.
61+
row_key (str): Field name from the input `beam.Row` object to use as
62+
identifier for database querying.
63+
row_key_fn: A lambda function that returns a string key from the
64+
input row. Used to build/extract the identifier for the database query.
65+
exception_level: A `enum.Enum` value from
66+
``apache_beam.transforms.enrichment_handlers.utils.ExceptionLevel``
67+
to set the level when no matching record is found from the database query.
68+
Defaults to ``ExceptionLevel.WARN``.
69+
"""
70+
def __init__(
71+
self,
72+
region_id: str,
73+
project_id: str,
74+
instance_id: str,
75+
database_type_adapter: DatabaseTypeAdapter,
76+
database_name: str,
77+
database_user: str,
78+
database_password: str,
79+
table_id: str,
80+
row_key: str = "",
81+
*,
82+
row_key_fn: Optional[RowKeyFn] = None,
83+
exception_level: ExceptionLevel = ExceptionLevel.WARN,
84+
):
85+
self._project_id = project_id
86+
self._region_id = region_id
87+
self._instance_id = instance_id
88+
self._database_type_adapter = database_type_adapter
89+
self._database_name = database_name
90+
self._database_user = database_user
91+
self._database_password = database_password
92+
self._table_id = table_id
93+
self._row_key = row_key
94+
self._row_key_fn = row_key_fn
95+
self._exception_level = exception_level
96+
if ((not self._row_key_fn and not self._row_key) or
97+
bool(self._row_key_fn and self._row_key)):
98+
raise ValueError(
99+
"Please specify exactly one of `row_key` or a lambda "
100+
"function with `row_key_fn` to extract the row key "
101+
"from the input row.")
102+
103+
def __enter__(self):
104+
"""Connect to the the Cloud SQL instance."""
105+
connector = Connector()
106+
self.client = connector.connect(
107+
f"{self._project_id}:{self._region_id}:{self._instance_id}",
108+
driver=self._database_type_adapter.value,
109+
user=self._database_user,
110+
password=self._database_password,
111+
db=self._database_name)
112+
self.cursor = self.client.cursor()
113+
114+
def __call__(self, request: beam.Row, *args, **kwargs):
115+
"""
116+
Executes a query to the Cloud SQL instance and returns
117+
a `Tuple` of request and response.
118+
119+
Args:
120+
request: the input `beam.Row` to enrich.
121+
"""
122+
response_dict: dict[str, Any] = {}
123+
row_key_str: str = ""
124+
125+
try:
126+
if self._row_key_fn:
127+
row_key = self._row_key_fn(request)
128+
else:
129+
request_dict = request._asdict()
130+
row_key_str = str(request_dict[self._row_key])
131+
row_key = row_key_str
132+
133+
query = f"SELECT * FROM {self._table_id} WHERE {self._row_key} = %s"
134+
self.cursor.execute(query, (row_key, ))
135+
result = self.cursor.fetchone()
136+
137+
if result:
138+
columns = [col[0] for col in self.cursor.description]
139+
for i, value in enumerate(result):
140+
response_dict[columns[i]] = value
141+
elif self._exception_level == ExceptionLevel.WARN:
142+
_LOGGER.warning(
143+
'No matching record found for row_key: %s in table: %s',
144+
row_key_str,
145+
self._table_id)
146+
elif self._exception_level == ExceptionLevel.RAISE:
147+
raise ValueError(
148+
'No matching record found for row_key: %s in table: %s' %
149+
(row_key_str, self._table_id))
150+
except KeyError:
151+
raise KeyError('row_key %s not found in input PCollection.' % row_key_str)
152+
except Exception as e:
153+
raise e
154+
155+
return request, beam.Row(**response_dict)
156+
157+
def __exit__(self, exc_type, exc_val, exc_tb):
158+
"""Clean the instantiated the Cloud SQL client."""
159+
self.cursor.close()
160+
self.client.close()
161+
self.cursor, self.client = None, None
162+
163+
164+
def get_cache_key(self, request: beam.Row) -> str:
165+
"""Returns a string formatted with row key since it is unique to
166+
a request made to the Cloud SQL instance."""
167+
if self._row_key_fn:
168+
return f"row_key: {str(self._row_key_fn(request))}"
169+
return f"{self._row_key}: {request._asdict()[self._row_key]}"

0 commit comments

Comments
 (0)