Skip to content

Commit ec57e79

Browse files
Add Redshift connector to Recon (#2339)
Add Redshift connector to Recon --------- Co-authored-by: M Abulazm <mohamed.abulazm@databricks.com>
1 parent 2f00757 commit ec57e79

16 files changed

Lines changed: 485 additions & 13 deletions

File tree

docs/lakebridge/docs/reconcile/recon_notebook.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Parameters:
7777

7878
- `report_type`: The type of report to be generated. Available report types are `schema`, `row`, `data` or `all`. For details check [here](./dataflow_example.mdx).
7979
- `source`: The configuration for connecting to the source database to be reconciled.
80-
- `dialect`: The dialect of the source. Supported values: `snowflake`, `oracle`, `mssql`, `synapse`, `databricks`.
80+
- `dialect`: The dialect of the source. Supported values: `snowflake`, `oracle`, `mssql`, `synapse`, `databricks`, `redshift`.
8181
- `catalog`: The source database/catalog name. catalog is used for consistency in naming
8282
- `schema`: The source schema name.
8383
- `uc_connection_name`: the connection name for the source as configured in workspace `Connections`. Not allowed for `databricks`

docs/lakebridge/docs/reconcile/reconcile_configuration.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import CodeBlock from '@theme/CodeBlock';
2626
| Snowflake | Yes | Yes | Yes | Yes |
2727
| MS SQL Server (incl. Synapse) | Yes | Yes | Yes | Yes |
2828
| Databricks | Yes | Yes | Yes | Yes |
29+
| Redshift | Yes | Yes | Yes | Yes |
2930

3031
[[back to top](#types-of-report-supported)]
3132

src/databricks/labs/lakebridge/deployment/job.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
)
1717
from databricks.labs.lakebridge.config import ReconcileConfig, ProfilerDashboardConfig
1818
from databricks.labs.lakebridge.deployment.dashboard import ProfilerDashboardManager
19-
from databricks.labs.lakebridge.reconcile.constants import ReconSourceType
2019

2120
logger = logging.getLogger(__name__)
2221

@@ -107,15 +106,6 @@ def _job_recon_task(
107106
compute.Library(whl=lakebridge_wheel_path),
108107
]
109108

110-
if recon_config.source.dialect == ReconSourceType.ORACLE.value:
111-
# TODO: Automatically fetch a version list for `ojdbc8`
112-
oracle_driver_version = "23.4.0.24.05"
113-
libraries.append(
114-
compute.Library(
115-
maven=compute.MavenLibrary(f"com.oracle.database.jdbc:ojdbc8:{oracle_driver_version}"),
116-
),
117-
)
118-
119109
task = Task(
120110
task_key=task_key,
121111
description=description,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import re
2+
import logging
3+
from datetime import datetime
4+
5+
from pyspark.errors import PySparkException
6+
from pyspark.sql import DataFrame
7+
from pyspark.sql.functions import col
8+
from sqlglot import Dialect
9+
10+
from databricks.labs.lakebridge.reconcile.connectors.data_source import DataSource
11+
from databricks.labs.lakebridge.reconcile.connectors.models import NormalizedIdentifier
12+
from databricks.labs.lakebridge.reconcile.connectors.remote_query_reader import RemoteQueryReader
13+
from databricks.labs.lakebridge.reconcile.connectors.dialect_utils import DialectUtils
14+
from databricks.labs.lakebridge.reconcile.recon_config import JdbcReaderOptions, Schema
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class RedshiftDataSource(DataSource):
20+
_IDENTIFIER_DELIMITER = "\""
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 = 'character varying' AND character_maximum_length IS NOT NULL
27+
THEN 'varchar(' || character_maximum_length || ')'
28+
WHEN data_type = 'character' AND character_maximum_length IS NOT NULL
29+
THEN 'char(' || character_maximum_length || ')'
30+
WHEN data_type IN ('binary varying')
31+
THEN 'binary'
32+
ELSE data_type
33+
END AS data_type
34+
FROM
35+
information_schema.columns
36+
WHERE
37+
LOWER(table_name) = LOWER('{table}')
38+
AND LOWER(table_schema) = LOWER('{schema}')
39+
ORDER BY ordinal_position
40+
"""
41+
42+
def __init__(
43+
self,
44+
engine: Dialect,
45+
reader: RemoteQueryReader,
46+
):
47+
self._engine = engine
48+
self._reader = reader
49+
50+
def read_data(
51+
self,
52+
catalog: str,
53+
schema: str,
54+
table: str,
55+
query: str,
56+
options: JdbcReaderOptions | None,
57+
) -> DataFrame:
58+
# Redshift dialect in SQLGlot converts :tbl to %(tbl)s (PostgreSQL parameter syntax)
59+
table_query = query.replace("%(tbl)s", f"{schema}.{table}").replace(":tbl", f"{schema}.{table}")
60+
try:
61+
logger.info(f"Fetching data using query: \n`{table_query}`")
62+
df = self._reader.read_data(table_query, catalog, "database", "query", options)
63+
return df.select([col(c).alias(c.lower()) for c in df.columns])
64+
except (RuntimeError, PySparkException) as e:
65+
return self.log_and_throw_exception(e, "data", table_query)
66+
67+
def get_schema(
68+
self,
69+
catalog: str,
70+
schema: str,
71+
table: str,
72+
normalize: bool = True,
73+
) -> list[Schema]:
74+
schema_query = re.sub(
75+
r'\s+',
76+
' ',
77+
RedshiftDataSource._SCHEMA_QUERY.format(schema=schema, table=table),
78+
)
79+
try:
80+
logger.debug(f"Fetching schema using query: \n`{schema_query}`")
81+
logger.info(f"Fetching Schema: Started at: {datetime.now()}")
82+
df = self._reader.read_data(schema_query, catalog, "database", "query")
83+
schema_metadata = df.select([col(c).alias(c.lower()) for c in df.columns]).collect()
84+
logger.info(f"Schema fetched successfully. Completed at: {datetime.now()}")
85+
return [self._map_meta_column(field, normalize) for field in schema_metadata]
86+
except (RuntimeError, PySparkException) as e:
87+
return self.log_and_throw_exception(e, "schema", schema_query)
88+
89+
def normalize_identifier(self, identifier: str) -> NormalizedIdentifier:
90+
return DialectUtils.normalize_identifier(
91+
identifier,
92+
source_start_delimiter=RedshiftDataSource._IDENTIFIER_DELIMITER,
93+
source_end_delimiter=RedshiftDataSource._IDENTIFIER_DELIMITER,
94+
)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
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 (
67
DatabricksDataSource,
78
DatabricksNonUnityCatalogDataSource,
89
)
910
from databricks.labs.lakebridge.reconcile.connectors.oracle import OracleDataSource
11+
from databricks.labs.lakebridge.reconcile.connectors.redshift import RedshiftDataSource
1012
from databricks.labs.lakebridge.reconcile.connectors.remote_query_reader import RemoteQueryReader
1113
from databricks.labs.lakebridge.reconcile.connectors.snowflake import SnowflakeDataSource
1214
from databricks.labs.lakebridge.reconcile.connectors.tsql import TSQLServerDataSource
@@ -36,4 +38,6 @@ def create_adapter(
3638
return DatabricksNonUnityCatalogDataSource(engine, spark, ws)
3739
if isinstance(engine, Tsql):
3840
return TSQLServerDataSource(engine, reader)
41+
if isinstance(engine, Redshift):
42+
return RedshiftDataSource(engine, reader)
3943
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
@@ -20,6 +20,7 @@ class ReconSourceType(AutoName):
2020
ORACLE = auto()
2121
SNOWFLAKE = auto()
2222
SYNAPSE = auto()
23+
REDSHIFT = auto()
2324

2425

2526
class ReconReportType(AutoName):

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,32 @@ 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+
exp.DataType.Type.DATE.value: [
281+
partial(
282+
anonymous,
283+
func="COALESCE(TO_CHAR({}, 'YYYY-MM-DD'), '_null_recon_')",
284+
dialect=get_dialect("redshift"),
285+
)
286+
],
287+
exp.DataType.Type.TIMESTAMP.value: [
288+
partial(
289+
anonymous,
290+
func="COALESCE(TO_CHAR({}, 'YYYY-MM-DD HH24:MI:SS.US'), '_null_recon_')",
291+
dialect=get_dialect("redshift"),
292+
)
293+
],
294+
exp.DataType.Type.TIMESTAMPTZ.value: [
295+
partial(
296+
anonymous,
297+
func="COALESCE(TO_CHAR({}, 'YYYY-MM-DD HH24:MI:SS.US'), '_null_recon_')",
298+
dialect=get_dialect("redshift"),
299+
)
300+
],
301+
},
276302
}
277303

278304
sha256_partial = partial(sha2, num_bits="256", is_expr=True)
@@ -306,4 +332,8 @@ def _get_is_string(column_types_dict: dict[str, DataType], column_name: str) ->
306332
),
307333
target=sha256_partial,
308334
),
335+
get_dialect("redshift"): HashAlgoMapping(
336+
source=sha256_partial,
337+
target=sha256_partial,
338+
),
309339
}

tests/conftest.py

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

284284

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

343353

354+
@pytest.fixture
355+
def fake_redshift_datasource() -> FakeDataSource:
356+
return FakeDataSource('"', '"')
357+
358+
344359
@pytest.fixture
345360
def fake_tsql_datasource() -> FakeDataSource:
346361
return FakeDataSource("[", "]")
@@ -370,6 +385,19 @@ def snowflake_table_conf_with_opts(normalize_config_service: NormalizeReconConfi
370385
return conf
371386

372387

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

402430

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

tests/integration/reconcile/conftest.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@
5555
SNOWFLAKE_CATALOG = "INTEGRATION"
5656
SNOWFLAKE_SCHEMA = "LAKEBRIDGE"
5757
SNOWFLAKE_TABLE = "DIAMONDS"
58+
REDSHIFT_CONNECTION = "sandbox_labs_tool_redshift"
59+
REDSHIFT_CATALOG = "labs"
60+
REDSHIFT_SCHEMA = "lakebridge"
61+
REDSHIFT_TABLE = "diamonds"
5862

5963

6064
@pytest.fixture
@@ -265,6 +269,53 @@ def snowflake_recon_config(recon_cluster: str, recon_schema: SchemaInfo, make_vo
265269
)
266270

267271

272+
@pytest.fixture
273+
def redshift_recon_table_config(recon_schema: SchemaInfo, recon_tables: tuple[TableInfo, TableInfo]) -> TableRecon:
274+
(_, tgt_table) = recon_tables
275+
assert tgt_table.name
276+
277+
return TableRecon(
278+
[
279+
Table(
280+
source_name=REDSHIFT_TABLE,
281+
target_name=tgt_table.name,
282+
join_columns=["color", "clarity"],
283+
)
284+
]
285+
)
286+
287+
288+
@pytest.fixture
289+
def redshift_recon_config(recon_cluster: str, recon_schema: SchemaInfo, make_volume) -> ReconcileConfig:
290+
volume = make_volume(catalog_name=recon_schema.catalog_name, schema_name=recon_schema.name, name=recon_schema.name)
291+
292+
deployment_overrides = ReconcileJobConfig(
293+
existing_cluster_id=recon_cluster,
294+
tags={"lakebridge": "reconcile_test"},
295+
)
296+
logger.info(f"Using recon job overrides: {deployment_overrides}")
297+
298+
assert recon_schema.catalog_name
299+
assert recon_schema.name
300+
return ReconcileConfig(
301+
report_type="all",
302+
source=SourceConnectionConfig(
303+
dialect="redshift",
304+
catalog=REDSHIFT_CATALOG,
305+
schema=REDSHIFT_SCHEMA,
306+
uc_connection_name=REDSHIFT_CONNECTION,
307+
),
308+
target=TargetConnectionConfig(
309+
catalog=recon_schema.catalog_name,
310+
schema=recon_schema.name,
311+
),
312+
metadata_config=ReconcileMetadataConfig(
313+
catalog=recon_schema.catalog_name, schema=recon_schema.name, volume=volume.name
314+
),
315+
job_overrides=deployment_overrides,
316+
)
317+
318+
268319
def recon_config_filename(recon_config: ReconcileConfig) -> str:
269320
connection_or_catalog = recon_config.source.uc_connection_name or recon_config.source.catalog
270321
return f"recon_config_{recon_config.source.dialect}_{connection_or_catalog}_{recon_config.report_type}.json"

tests/integration/reconcile/connectors/test_read_schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
DatabricksDataSource,
1010
DatabricksNonUnityCatalogDataSource,
1111
)
12+
from databricks.labs.lakebridge.reconcile.connectors.redshift import RedshiftDataSource
1213
from databricks.labs.lakebridge.reconcile.connectors.remote_query_reader import RemoteQueryReader
1314
from databricks.labs.lakebridge.reconcile.connectors.snowflake import SnowflakeDataSource
1415
from databricks.labs.lakebridge.reconcile.connectors.tsql import TSQLServerDataSource
@@ -70,6 +71,15 @@ def test_oracle_read_schema_happy(spark: SparkSession) -> None:
7071
assert columns
7172

7273

74+
def test_redshift_read_schema_happy(spark: SparkSession) -> None:
75+
connection = "sandbox_labs_tool_redshift"
76+
reader = RemoteQueryReader(spark, connection)
77+
connector = RedshiftDataSource(get_dialect("redshift"), reader)
78+
79+
columns = connector.get_schema("labs", "lakebridge", "diamonds")
80+
assert columns
81+
82+
7383
@pytest.mark.xfail(reason="Snowflake account unavailable", strict=True)
7484
def test_snowflake_read_schema_happy(spark: SparkSession) -> None:
7585
connection = TestEnvGetter(False).get("TEST_SNOWFLAKE_CONNECTION")

0 commit comments

Comments
 (0)