Skip to content

Commit e5b318d

Browse files
authored
Refactor SQLSource from a monolith into a proper class hierarchy (#36)
1 parent 6cb1833 commit e5b318d

9 files changed

Lines changed: 687 additions & 138 deletions

File tree

data-transfer/pontoon/pontoon/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from pontoon.logging_config import configure_logging, logger
22
from pontoon.source.sql_source import SQLSource
3+
from pontoon.source.postgresql_source import PostgreSQLSource
4+
from pontoon.source.redshift_source import RedshiftSource
5+
from pontoon.source.bigquery_source import BigQuerySource
6+
from pontoon.source.snowflake_source import SnowflakeSource
37
from pontoon.source.memory_source import MemorySource
48
from pontoon.destination.glue_destination import GlueDestination
59
from pontoon.destination.stdout_destination import StdoutDestination
@@ -53,6 +57,10 @@ def get_destination_by_vendor(vendor_type:str) -> str:
5357

5458
# register source and destination connectors
5559
__sources['source-sql'] = SQLSource
60+
__sources['source-postgresql'] = PostgreSQLSource
61+
__sources['source-redshift'] = RedshiftSource
62+
__sources['source-bigquery'] = BigQuerySource
63+
__sources['source-snowflake'] = SnowflakeSource
5664
__sources['source-memory'] = MemorySource
5765
__destinations['destination-sql'] = SQLDestination
5866
__destinations['destination-glue'] = GlueDestination
@@ -75,10 +83,10 @@ def get_destination_by_vendor(vendor_type:str) -> str:
7583
# map from vendor types to source and destination connectors
7684
__vendor_source_map = {
7785
'memory': 'source-memory',
78-
'redshift': 'source-sql',
79-
'snowflake': 'source-sql',
80-
'bigquery': 'source-sql',
81-
'postgresql': 'source-sql',
86+
'redshift': 'source-redshift',
87+
'snowflake': 'source-snowflake',
88+
'bigquery': 'source-bigquery',
89+
'postgresql': 'source-postgresql',
8290
'mysql': 'source-sql',
8391
'athena': 'source-sql'
8492
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import json
2+
from typing import List
3+
from sqlalchemy import create_engine, inspect
4+
from sqlalchemy.engine import Engine
5+
from pontoon.base import Namespace
6+
from pontoon.source.sql_source import SQLSource
7+
8+
9+
class BigQuerySource(SQLSource):
10+
"""BigQuery-specific implementation of SQLSource"""
11+
12+
def _create_engine(self, connect_config: dict) -> Engine:
13+
"""Create BigQuery-specific SQLAlchemy engine with service account authentication"""
14+
project_id = connect_config.get('project_id')
15+
service_account = connect_config.get('service_account')
16+
17+
if not project_id:
18+
raise ValueError("BigQuery connection config must include 'project_id' field")
19+
20+
if not service_account:
21+
raise ValueError("BigQuery connection config must include 'service_account' field")
22+
23+
# Parse service account JSON
24+
try:
25+
credentials_info = json.loads(service_account)
26+
except json.JSONDecodeError as e:
27+
raise ValueError(f"Invalid service account JSON: {e}")
28+
29+
# Build BigQuery connection string and create engine
30+
connection_string = f"bigquery://{project_id}"
31+
32+
# Get chunk size from connect config, defaulting to 1024 if not specified
33+
chunk_size = connect_config.get('chunk_size', 1024)
34+
35+
return create_engine(
36+
connection_string,
37+
credentials_info=credentials_info,
38+
arraysize=chunk_size
39+
)
40+
41+
def _validate_auth_type(self, auth_type: str) -> None:
42+
"""Validate authentication type for BigQuery - only 'service_account' is supported"""
43+
if auth_type != 'service_account':
44+
raise ValueError(f"BigQuery source only supports 'service_account' authentication, got '{auth_type}'")
45+
46+
def _get_namespace(self, connect_config: dict) -> Namespace:
47+
"""Extract namespace from BigQuery connection config using project_id"""
48+
project_id = connect_config.get('project_id')
49+
if not project_id:
50+
raise ValueError("BigQuery connection config must include 'project_id' field")
51+
52+
return Namespace(project_id)
53+
54+
def _inspect_streams_impl(self) -> List[dict]:
55+
"""BigQuery-specific stream inspection logic"""
56+
streams = []
57+
58+
with self._connect() as conn:
59+
# Use the inspector to get schema and table information
60+
inspector = inspect(conn)
61+
62+
# Get all available schemas (datasets in BigQuery terminology)
63+
schemas = inspector.get_schema_names()
64+
65+
for schema in schemas:
66+
# For each table in the schema
67+
for table in inspector.get_table_names(schema=schema):
68+
# BigQuery table names come in format "project.dataset.table"
69+
# We need to extract just the table name
70+
if '.' in table:
71+
_, table_name = table.split('.', 1)
72+
if '.' in table_name:
73+
# Handle case where table is "dataset.table"
74+
_, table_name = table_name.split('.', 1)
75+
else:
76+
table_name = table
77+
78+
# Get column information using the full table reference
79+
project_id = self._config['connect']['project_id']
80+
full_table_name = f"{project_id}.{schema}.{table_name}"
81+
82+
try:
83+
columns = inspector.get_columns(full_table_name)
84+
streams.append({
85+
'schema_name': schema,
86+
'stream_name': table_name,
87+
'fields': [{'name': col['name'], 'type': str(col['type'])} for col in columns]
88+
})
89+
except Exception:
90+
# Skip tables that can't be inspected (e.g., views, external tables)
91+
continue
92+
93+
return streams
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.engine import Engine
3+
from pontoon.base import Namespace
4+
from pontoon.source.sql_source import SQLSource
5+
6+
7+
class PostgreSQLSource(SQLSource):
8+
"""PostgreSQL-specific implementation of SQLSource"""
9+
10+
def _create_engine(self, connect_config: dict) -> Engine:
11+
"""Create PostgreSQL-specific SQLAlchemy engine"""
12+
host = connect_config.get('host')
13+
port = connect_config.get('port', '5432')
14+
user = connect_config.get('user')
15+
password = connect_config.get('password')
16+
database = connect_config.get('database')
17+
18+
# Build PostgreSQL connection string using psycopg2 driver
19+
connection_string = f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{database}"
20+
21+
return create_engine(connection_string)
22+
23+
def _validate_auth_type(self, auth_type: str) -> None:
24+
"""Validate authentication type for PostgreSQL - only 'basic' is supported"""
25+
if auth_type != 'basic':
26+
raise ValueError(f"PostgreSQL source only supports 'basic' authentication, got '{auth_type}'")
27+
28+
def _get_namespace(self, connect_config: dict) -> Namespace:
29+
"""Extract namespace from PostgreSQL connection config """
30+
31+
# Check for separate database field
32+
database = connect_config.get('database')
33+
if not database:
34+
raise ValueError("PostgreSQL connection config must include 'database' field")
35+
36+
return Namespace(database)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from pontoon.source.postgresql_source import PostgreSQLSource
2+
3+
4+
class RedshiftSource(PostgreSQLSource):
5+
"""Redshift-specific implementation of SQLSource
6+
7+
Inherits from PostgreSQLSource since Redshift uses the PostgreSQL protocol
8+
and wire format. The connection string format, authentication method (basic),
9+
and namespace extraction logic are identical to PostgreSQL.
10+
11+
This class overrides the authentication validation to provide Redshift-specific
12+
error messages and can be extended in the future if Redshift-specific behaviors
13+
are needed (e.g., specific query optimizations, column type handling, etc.).
14+
"""
15+
16+
def _validate_auth_type(self, auth_type: str) -> None:
17+
"""Validate authentication type for Redshift - only 'basic' is supported"""
18+
if auth_type != 'basic':
19+
raise ValueError(f"Redshift source only supports 'basic' authentication, got '{auth_type}'")
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.engine import Engine
3+
from pontoon.base import Namespace
4+
from pontoon.source.sql_source import SQLSource
5+
6+
7+
class SnowflakeSource(SQLSource):
8+
"""Snowflake-specific implementation of SQLSource"""
9+
10+
def _create_engine(self, connect_config: dict) -> Engine:
11+
"""Create Snowflake-specific SQLAlchemy engine"""
12+
user = connect_config.get('user')
13+
access_token = connect_config.get('access_token')
14+
account = connect_config.get('account')
15+
database = connect_config.get('database')
16+
warehouse = connect_config.get('warehouse')
17+
18+
# Validate required fields
19+
if not user:
20+
raise ValueError("Snowflake connection config must include 'user' field")
21+
if not access_token:
22+
raise ValueError("Snowflake connection config must include 'access_token' field")
23+
if not account:
24+
raise ValueError("Snowflake connection config must include 'account' field")
25+
if not database:
26+
raise ValueError("Snowflake connection config must include 'database' field")
27+
if not warehouse:
28+
raise ValueError("Snowflake connection config must include 'warehouse' field")
29+
30+
# Build Snowflake connection string
31+
# Format: snowflake://user:access_token@account/database/schema?warehouse=warehouse
32+
# Note: We'll use 'public' as default schema since it's commonly available
33+
schema = connect_config.get('schema', 'public')
34+
connection_string = f"snowflake://{user}:{access_token}@{account}/{database}/{schema}?warehouse={warehouse}"
35+
36+
return create_engine(connection_string)
37+
38+
def _validate_auth_type(self, auth_type: str) -> None:
39+
"""Validate authentication type for Snowflake - only 'access_token' is supported"""
40+
if auth_type != 'access_token':
41+
raise ValueError(f"Snowflake source only supports 'access_token' authentication, got '{auth_type}'")
42+
43+
def _get_namespace(self, connect_config: dict) -> Namespace:
44+
"""Extract namespace from Snowflake connection config using database field"""
45+
database = connect_config.get('database')
46+
if not database:
47+
raise ValueError("Snowflake connection config must include 'database' field")
48+
49+
return Namespace(database)

0 commit comments

Comments
 (0)