Skip to content

Commit 611e267

Browse files
committed
Add Redshift connector to Recon
1 parent 201f9b7 commit 611e267

9 files changed

Lines changed: 432 additions & 0 deletions

File tree

src/databricks/labs/lakebridge/reconcile/connectors/jdbc_reader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class JDBCReaderMixin:
1212
def _get_jdbc_reader(self, query, jdbc_url, driver, additional_options: dict | None = None):
1313
driver_class = {
1414
"oracle": "oracle.jdbc.OracleDriver",
15+
"redshift": "com.amazon.redshift.jdbc42.Driver",
1516
"snowflake": "net.snowflake.client.jdbc.SnowflakeDriver",
1617
"sqlserver": "com.microsoft.sqlserver.jdbc.SQLServerDriver",
1718
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import re
2+
import logging
3+
from collections.abc import Mapping
4+
from datetime import datetime
5+
6+
from pyspark.errors import PySparkException
7+
from pyspark.sql import DataFrame, DataFrameReader, SparkSession
8+
from pyspark.sql.functions import col
9+
from sqlglot import Dialect
10+
11+
from databricks.labs.lakebridge.reconcile.connectors.data_source import DataSource
12+
from databricks.labs.lakebridge.reconcile.connectors.jdbc_reader import JDBCReaderMixin
13+
from databricks.labs.lakebridge.reconcile.connectors.models import NormalizedIdentifier
14+
from databricks.labs.lakebridge.reconcile.connectors.secrets import SecretsMixin
15+
from databricks.labs.lakebridge.reconcile.connectors.dialect_utils import DialectUtils
16+
from databricks.labs.lakebridge.reconcile.recon_config import JdbcReaderOptions, Schema, OptionalPrimitiveType
17+
from databricks.sdk import WorkspaceClient
18+
19+
logger = logging.getLogger(__name__)
20+
21+
_SCHEMA_QUERY = """SELECT
22+
column_name,
23+
CASE
24+
WHEN data_type = 'numeric' AND numeric_precision IS NOT NULL
25+
THEN 'decimal(' || numeric_precision || ',' || numeric_scale || ')'
26+
WHEN data_type = 'real'
27+
THEN 'float'
28+
WHEN data_type = 'double precision'
29+
THEN 'double'
30+
WHEN data_type = 'character varying' AND character_maximum_length IS NOT NULL
31+
THEN 'varchar(' || character_maximum_length || ')'
32+
WHEN data_type = 'character' AND character_maximum_length IS NOT NULL
33+
THEN 'char(' || character_maximum_length || ')'
34+
WHEN data_type IN ('varbyte')
35+
THEN 'binary'
36+
ELSE data_type
37+
END AS data_type
38+
FROM
39+
information_schema.columns
40+
WHERE
41+
LOWER(table_name) = LOWER('{table}')
42+
AND LOWER(table_schema) = LOWER('{schema}')
43+
ORDER BY ordinal_position
44+
"""
45+
46+
47+
class RedshiftDataSource(DataSource, SecretsMixin, JDBCReaderMixin):
48+
_DRIVER = "redshift"
49+
_IDENTIFIER_DELIMITER = "\""
50+
51+
def __init__(
52+
self,
53+
engine: Dialect,
54+
spark: SparkSession,
55+
ws: WorkspaceClient,
56+
secret_scope: str,
57+
):
58+
self._engine = engine
59+
self._spark = spark
60+
self._ws = ws
61+
self._secret_scope = secret_scope
62+
63+
@property
64+
def get_jdbc_url(self) -> str:
65+
return (
66+
f"jdbc:{RedshiftDataSource._DRIVER}://{self._get_secret('host')}"
67+
f":{self._get_secret('port')}/{self._get_secret('database')}"
68+
)
69+
70+
def read_data(
71+
self,
72+
catalog: str | None,
73+
schema: str,
74+
table: str,
75+
query: str,
76+
options: JdbcReaderOptions | None,
77+
) -> DataFrame:
78+
# Redshift dialect in SQLGlot converts :tbl to %(tbl)s (PostgreSQL parameter syntax)
79+
table_query = query.replace("%(tbl)s", f"{schema}.{table}").replace(":tbl", f"{schema}.{table}")
80+
try:
81+
if options is None:
82+
df = self.reader(table_query).load()
83+
else:
84+
reader_options = self._get_jdbc_reader_options(options)
85+
df = self.reader(table_query, reader_options).load()
86+
return df.select([col(c).alias(c.lower()) for c in df.columns])
87+
except (RuntimeError, PySparkException) as e:
88+
return self.log_and_throw_exception(e, "data", table_query)
89+
90+
def get_schema(
91+
self,
92+
catalog: str | None,
93+
schema: str,
94+
table: str,
95+
normalize: bool = True,
96+
) -> list[Schema]:
97+
schema_query = re.sub(
98+
r'\s+',
99+
' ',
100+
_SCHEMA_QUERY.format(schema=schema, table=table),
101+
)
102+
try:
103+
logger.debug(f"Fetching schema using query: \n`{schema_query}`")
104+
logger.info(f"Fetching Schema: Started at: {datetime.now()}")
105+
df = self.reader(schema_query).load()
106+
schema_metadata = df.select([col(c).alias(c.lower()) for c in df.columns]).collect()
107+
logger.info(f"Schema fetched successfully. Completed at: {datetime.now()}")
108+
return [self._map_meta_column(field, normalize) for field in schema_metadata]
109+
except (RuntimeError, PySparkException) as e:
110+
return self.log_and_throw_exception(e, "schema", schema_query)
111+
112+
def reader(self, query: str, options: Mapping[str, OptionalPrimitiveType] | None = None) -> DataFrameReader:
113+
if options is None:
114+
options = {}
115+
user = self._get_secret('user')
116+
password = self._get_secret('password')
117+
return self._get_jdbc_reader(
118+
query, self.get_jdbc_url, RedshiftDataSource._DRIVER, {**options, "user": user, "password": password}
119+
)
120+
121+
def normalize_identifier(self, identifier: str) -> NormalizedIdentifier:
122+
return DialectUtils.normalize_identifier(
123+
identifier,
124+
source_start_delimiter=RedshiftDataSource._IDENTIFIER_DELIMITER,
125+
source_end_delimiter=RedshiftDataSource._IDENTIFIER_DELIMITER,
126+
)

src/databricks/labs/lakebridge/reconcile/connectors/source_adapter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from pyspark.sql import SparkSession
22
from sqlglot import Dialect
3+
from sqlglot.dialects.redshift import Redshift
34

45
from databricks.labs.lakebridge.reconcile.connectors.data_source import DataSource
56
from databricks.labs.lakebridge.reconcile.connectors.databricks import DatabricksDataSource
67
from databricks.labs.lakebridge.reconcile.connectors.oracle import OracleDataSource
8+
from databricks.labs.lakebridge.reconcile.connectors.redshift import RedshiftDataSource
79
from databricks.labs.lakebridge.reconcile.connectors.snowflake import SnowflakeDataSource
810
from databricks.labs.lakebridge.reconcile.connectors.tsql import TSQLServerDataSource
911
from databricks.labs.lakebridge.transpiler.sqlglot.generator.databricks import Databricks
@@ -27,4 +29,6 @@ def create_adapter(
2729
return DatabricksDataSource(engine, spark, ws, secret_scope)
2830
if isinstance(engine, Tsql):
2931
return TSQLServerDataSource(engine, spark, ws, secret_scope)
32+
if isinstance(engine, Redshift):
33+
return RedshiftDataSource(engine, spark, ws, secret_scope)
3034
raise ValueError(f"Unsupported source type --> {engine}")

src/databricks/labs/lakebridge/reconcile/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ReconSourceType(AutoName):
1818
DATABRICKS = auto()
1919
MSSQL = auto()
2020
ORACLE = auto()
21+
REDSHIFT = auto()
2122
SNOWFLAKE = auto()
2223
SYNAPSE = auto()
2324

src/databricks/labs/lakebridge/reconcile/query_builder/expression_generator.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@ def _get_is_string(column_types_dict: dict[str, DataType], column_name: str) ->
273273
partial(anonymous, func="COALESCE(CONVERT(VARCHAR(23), {0}, 120), '1900-01-01 00:00:00')")
274274
],
275275
},
276+
"redshift": {
277+
exp.DataType.Type.SUPER.value: [
278+
partial(anonymous, func="COALESCE(JSON_SERIALIZE({}), '_null_recon_')", dialect=get_dialect("redshift"))
279+
],
280+
},
276281
}
277282

278283
sha256_partial = partial(sha2, num_bits="256", is_expr=True)
@@ -306,4 +311,8 @@ def _get_is_string(column_types_dict: dict[str, DataType], column_name: str) ->
306311
),
307312
target=sha256_partial,
308313
),
314+
get_dialect("redshift"): HashAlgoMapping(
315+
source=sha256_partial,
316+
target=sha256_partial,
317+
),
309318
}

tests/conftest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,16 @@ def tsql_schema_fixture_factory(column_name: str, data_type: str) -> Schema:
283283
)
284284

285285

286+
def redshift_schema_fixture_factory(column_name: str, data_type: str) -> Schema:
287+
norm = DialectUtils.normalize_identifier(column_name, "\"", "\"")
288+
return schema_fixture_factory(
289+
norm.ansi_normalized,
290+
data_type,
291+
norm.ansi_normalized,
292+
norm.source_normalized,
293+
)
294+
295+
286296
def ansi_schema_fixture_factory(column_name: str, data_type: str) -> Schema:
287297
ansi = DialectUtils.ansi_normalize_identifier(column_name)
288298
return schema_fixture_factory(
@@ -342,6 +352,11 @@ def fake_databricks_datasource() -> FakeDataSource:
342352
return FakeDataSource("`", "`")
343353

344354

355+
@pytest.fixture
356+
def fake_redshift_datasource() -> FakeDataSource:
357+
return FakeDataSource('"', '"')
358+
359+
345360
@pytest.fixture
346361
def fake_tsql_datasource() -> FakeDataSource:
347362
return FakeDataSource("[", "]")
@@ -371,6 +386,19 @@ def snowflake_table_conf_with_opts(normalize_config_service: NormalizeReconConfi
371386
return conf
372387

373388

389+
@pytest.fixture
390+
def redshift_table_conf_with_opts(normalize_config_service: NormalizeReconConfigService, table_conf_with_opts):
391+
conf = normalize_config_service.normalize_recon_table_config(table_conf_with_opts)
392+
conf.transformations = [ # SQL has to be valid
393+
Transformation(column_name="`s_address`", source="trim(\"s_address\")", target="trim(`s_address_t`)"),
394+
Transformation(column_name="`s_phone`", source="trim(\"s_phone\")", target="trim(`s_phone_t`)"),
395+
Transformation(column_name="`s_name`", source="trim(\"s_name\")", target="trim(`s_name`)"),
396+
]
397+
if conf.filters:
398+
conf.filters.source = "\"s_name\"='t' and \"s_address\"='a'"
399+
return conf
400+
401+
374402
@pytest.fixture
375403
def tsql_table_conf_with_opts(normalize_config_service: NormalizeReconConfigService, table_conf_with_opts):
376404
conf = normalize_config_service.normalize_recon_table_config(table_conf_with_opts)
@@ -401,6 +429,14 @@ def table_schema_ansi_ansi(table_schema):
401429
return src_schema, tgt_schema
402430

403431

432+
@pytest.fixture
433+
def table_schema_redshift_ansi(table_schema):
434+
src_schema, tgt_schema = table_schema
435+
src_schema = [redshift_schema_fixture_factory(s.column_name, s.data_type) for s in src_schema]
436+
tgt_schema = [ansi_schema_fixture_factory(s.column_name, s.data_type) for s in tgt_schema]
437+
return src_schema, tgt_schema
438+
439+
404440
@pytest.fixture
405441
def table_schema_tsql_ansi(table_schema):
406442
src_schema, tgt_schema = table_schema

0 commit comments

Comments
 (0)