Skip to content

Commit ad677c0

Browse files
authored
add readFromDeltaLake transform (#4027)
* add readFromDeltaLake transform draft * address gemini comments * change to java xlang * fix Cham's comments
1 parent 84bfec4 commit ad677c0

4 files changed

Lines changed: 185 additions & 1 deletion

File tree

python/src/main/python/job-builder-util-transforms/provider_listing_template.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
packages:
44
- https://storage.googleapis.com/<package_path>
55
transforms:
6-
CopyFilesToGCS: "copy_files_to_gcs.CopyFilesToGCS"
6+
CopyFilesToGCS: "copy_files_to_gcs.CopyFilesToGCS"
7+
ReadFromDeltaLake: "read_from_delta_lake.ReadFromDeltaLake"

python/src/main/python/job-builder-util-transforms/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "Utility transforms for job builder"
55
authors = ["Google Cloud Platform"]
66
packages = [
77
{ include = "copy_files_to_gcs.py" },
8+
{ include = "read_from_delta_lake.py" },
89
]
910

1011
[tool.poetry.dependencies]
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Module containing transforms to read data from Delta Lake tables."""
2+
3+
from typing import Mapping, Optional
4+
from apache_beam.transforms import PTransform
5+
from apache_beam.transforms import managed
6+
from apache_beam.transforms.external import BeamJarExpansionService
7+
from apache_beam.transforms.external import SchemaAwareExternalTransform
8+
9+
DELTA_LAKE_READ_URN = "beam:schematransform:org.apache.beam:delta_lake_read:v1"
10+
11+
12+
class ReadFromDeltaLake(PTransform):
13+
"""A PTransform that reads data from a Delta Lake table.
14+
15+
Args:
16+
table: Identifier or path of the Delta Lake table.
17+
version: Version of the Delta Lake table to read.
18+
timestamp: Timestamp of the Delta Lake table to read.
19+
hadoop_config: Properties passed to Hadoop Configuration.
20+
"""
21+
22+
def __init__(
23+
self,
24+
table: str,
25+
version: Optional[int] = None,
26+
timestamp: Optional[str] = None,
27+
hadoop_config: Optional[Mapping[str, str]] = None,
28+
):
29+
super().__init__()
30+
self.table = table
31+
self.version = version
32+
self.timestamp = timestamp
33+
self.hadoop_config = hadoop_config
34+
35+
36+
def expand(self, pbegin):
37+
"""Expands the ReadFromDeltaLake transform."""
38+
config = {
39+
'table': self.table,
40+
}
41+
if self.version is not None:
42+
config['version'] = self.version
43+
if self.timestamp is not None:
44+
config['timestamp'] = self.timestamp
45+
if self.hadoop_config is not None:
46+
config['hadoop_config'] = dict(self.hadoop_config)
47+
48+
delta_source = getattr(managed, 'DELTA', 'delta')
49+
if hasattr(managed, 'Read') and delta_source in getattr(
50+
managed.Read, '_READ_TRANSFORMS', {}
51+
):
52+
return pbegin | managed.Read(delta_source, config=config)
53+
else:
54+
return pbegin | SchemaAwareExternalTransform(
55+
identifier=DELTA_LAKE_READ_URN,
56+
expansion_service=BeamJarExpansionService(
57+
'sdks:java:io:expansion-service:shadowJar'
58+
),
59+
**config,
60+
)
61+
62+
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import os
2+
import tempfile
3+
import unittest
4+
from unittest.mock import ANY, MagicMock, patch
5+
import apache_beam as beam
6+
from apache_beam.testing.test_pipeline import TestPipeline
7+
from apache_beam.testing.util import assert_that, equal_to
8+
from apache_beam.transforms import managed
9+
import pyarrow as pa
10+
import pyarrow.parquet as pq
11+
from read_from_delta_lake import DELTA_LAKE_READ_URN, ReadFromDeltaLake
12+
13+
14+
class ReadFromDeltaLakeTest(unittest.TestCase):
15+
16+
@patch('read_from_delta_lake.SchemaAwareExternalTransform')
17+
@patch.object(managed, 'DELTA', 'delta', create=True)
18+
@patch('read_from_delta_lake.managed.Read')
19+
def test_read_from_delta_lake_managed_read(self, mock_managed_read, mock_saet):
20+
mock_managed_read._READ_TRANSFORMS = {'delta': DELTA_LAKE_READ_URN}
21+
mock_transform = MagicMock()
22+
mock_managed_read.return_value = mock_transform
23+
24+
25+
table = 'gs://bucket/delta_table'
26+
version = 2
27+
timestamp = '2026-05-01T12:00:00Z'
28+
hadoop_config = {'fs.gs.project.id': 'test-project'}
29+
30+
transform = ReadFromDeltaLake(
31+
table=table,
32+
version=version,
33+
timestamp=timestamp,
34+
hadoop_config=hadoop_config,
35+
)
36+
37+
pbegin = MagicMock()
38+
transform.expand(pbegin)
39+
40+
mock_managed_read.assert_called_once_with(
41+
'delta',
42+
config={
43+
'table': table,
44+
'version': version,
45+
'timestamp': timestamp,
46+
'hadoop_config': hadoop_config,
47+
},
48+
)
49+
50+
51+
@patch('read_from_delta_lake.SchemaAwareExternalTransform')
52+
@patch('read_from_delta_lake.managed.Read')
53+
def test_read_from_delta_lake_fallback(self, mock_managed_read, mock_saet):
54+
mock_managed_read._READ_TRANSFORMS = {}
55+
mock_transform = MagicMock()
56+
mock_saet.return_value = mock_transform
57+
58+
table = '/path/to/table'
59+
hadoop_config = {'fs.gs.project.id': 'test-project'}
60+
61+
transform = ReadFromDeltaLake(
62+
table=table,
63+
hadoop_config=hadoop_config,
64+
)
65+
66+
pbegin = MagicMock()
67+
transform.expand(pbegin)
68+
69+
mock_saet.assert_called_once_with(
70+
identifier=DELTA_LAKE_READ_URN,
71+
expansion_service=ANY,
72+
table=table,
73+
hadoop_config=hadoop_config,
74+
)
75+
76+
def test_read_from_delta_lake_local_integration(self):
77+
with tempfile.TemporaryDirectory() as temp_dir:
78+
table_dir = os.path.join(temp_dir, 'delta-table')
79+
os.makedirs(table_dir)
80+
81+
# create parquet file
82+
parquet_file_path = os.path.join(table_dir, 'part-00000.parquet')
83+
table = pa.table({'name': ['test-name-1', 'test-name-2']})
84+
pq.write_table(table, parquet_file_path)
85+
86+
file_size = os.path.getsize(parquet_file_path)
87+
88+
log_dir = os.path.join(table_dir, '_delta_log')
89+
os.makedirs(log_dir)
90+
91+
commit_content = (
92+
'{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}\n'
93+
'{"metaData":{"id":"test-id","format":{"provider":"parquet","options":{}},'
94+
'"schemaString":"{\\"type\\":\\"struct\\",\\"fields\\":[{\\"name\\":\\"name\\",\\'
95+
'"type\\":\\"string\\",\\"nullable\\":true,\\"metadata\\":{}}]}",'
96+
'"partitionColumns":[],"configuration":{},"createdAt":123456789}}\n'
97+
f'{{"add":{{"path":"part-00000.parquet","partitionValues":{{}},"size":{file_size},'
98+
'"modificationTime":123456789,"dataChange":true}}\n'
99+
)
100+
101+
# create delta log
102+
with open(os.path.join(log_dir, '00000000000000000000.json'), 'w') as f:
103+
f.write(commit_content)
104+
105+
with TestPipeline() as p:
106+
output = (
107+
p
108+
| ReadFromDeltaLake(table=table_dir)
109+
| beam.Map(lambda row: row._asdict())
110+
)
111+
112+
expected = [{'name': 'test-name-1'}, {'name': 'test-name-2'}]
113+
assert_that(output, equal_to(expected))
114+
115+
116+
117+
if __name__ == '__main__':
118+
unittest.main()
119+
120+

0 commit comments

Comments
 (0)