Skip to content

Commit 48dbac8

Browse files
Merge pull request #163 from forcedotcom/feat/write-dmo-delta-support
Add write_dmo_deltas to StreamingClient
2 parents 453ee59 + f57814b commit 48dbac8

10 files changed

Lines changed: 230 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
cp ../shared/helpers.py payload/helpers.py
2121
```
2222

23+
### Added
24+
25+
- **`StreamingClient.write_dmo_deltas(name, dataframe)`** – start a streaming query that writes each micro-batch to the target DMO and return the `StreamingQuery` handle. It is the DMO counterpart to `write_dlo_deltas` and pairs with `read_dmo_deltas()`.
26+
2327
## 6.1.0
2428

2529
### Added

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ For a streaming (delta) transform, use `StreamingClient`, which exposes the stre
158158
* `read_dlo_deltas()` – Read the streaming change feed (deltas) of a Data Lake Object as a streaming DataFrame.
159159
* `read_dmo_deltas()` – Read the streaming change feed (deltas) of a Data Model Object as a streaming DataFrame.
160160
* `write_dlo_deltas(name, spark_dataframe)` – Write a streaming DataFrame of deltas to a Data Lake Object; returns the started `StreamingQuery`
161+
* `write_dmo_deltas(name, spark_dataframe)` – Write a streaming DataFrame of deltas to a Data Model Object; returns the started `StreamingQuery`
161162

162163
`find_file_path`, `llm_gateway_generate_text`, and `einstein_predict` are available on both clients.
163164

@@ -222,6 +223,15 @@ query = client.write_dlo_deltas("Output__dll", transformed)
222223
query.awaitTermination()
223224
```
224225

226+
A streaming transform can also read and write Data Model Objects with `read_dmo_deltas()` and `write_dmo_deltas()`:
227+
228+
```python
229+
deltas = client.read_dmo_deltas()
230+
transformed = deltas.withColumn("description__c", upper(col("description__c")))
231+
query = client.write_dmo_deltas("Output__dlm", transformed)
232+
query.awaitTermination()
233+
```
234+
225235
Notes:
226236

227237
- These methods only run inside the Data Cloud streaming (`DELTA_SYNC`) runtime. Locally (`datacustomcode run`) they raise `NotImplementedError`, since there is no change feed to stream.

‎src/datacustomcode/client.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,25 @@ def write_dlo_deltas(
726726
self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DMO)
727727
return self._writer.write_dlo_deltas(name, dataframe, **kwargs) # type: ignore[no-any-return]
728728

729+
def write_dmo_deltas(
730+
self, name: str, dataframe: PySparkDataFrame, **kwargs
731+
) -> StreamingQuery:
732+
"""Write a streaming DataFrame of deltas to a DMO in Data Cloud.
733+
734+
Starts a streaming query that writes each micro-batch to the
735+
target DMO and returns the ``StreamingQuery`` handle; the caller
736+
typically calls ``query.awaitTermination()``.
737+
738+
Args:
739+
name: The name of the DMO to write to.
740+
dataframe: The streaming PySpark DataFrame to write.
741+
742+
Returns:
743+
The started ``StreamingQuery``.
744+
"""
745+
self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DLO)
746+
return self._writer.write_dmo_deltas(name, dataframe, **kwargs) # type: ignore[no-any-return]
747+
729748
def auto_write_to_dlo(self, name: str, dataframe: PySparkDataFrame) -> None:
730749
"""Write a PySpark DataFrame to a DLO in Data Cloud automatically picking
731750
the WriteMode.

‎src/datacustomcode/deploy.py‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,7 @@ def is_streaming(self) -> bool:
415415
def _validate_layers(self) -> "DataTransformConfig":
416416
read_is_dlo = isinstance(self.permissions.read, DloPermission)
417417
write_is_dlo = isinstance(self.permissions.write, DloPermission)
418-
if self.is_streaming:
419-
if not write_is_dlo:
420-
raise ValueError(
421-
"A streaming transform must write to a DLO "
422-
"(permissions.write must be a 'dlo' entry)."
423-
)
424-
elif read_is_dlo != write_is_dlo:
418+
if read_is_dlo != write_is_dlo:
425419
raise ValueError(
426420
"permissions.read and permissions.write must both reference "
427421
"DLOs or both reference DMOs (got "

‎src/datacustomcode/io/writer/base.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def write_dlo_deltas(
8787
Args:
8888
name: Target Data Lake Object name.
8989
dataframe: Streaming PySpark DataFrame produced from a
90-
``read_dlo_deltas`` / ``read_dmo_deltas`` source.
90+
``read_dlo_deltas`` source.
9191
9292
Returns:
9393
The started ``StreamingQuery``; the caller drives its lifecycle
@@ -102,3 +102,35 @@ def write_dlo_deltas(
102102
"streaming runtime; the local writer does not support streaming "
103103
"deltas."
104104
)
105+
106+
def write_dmo_deltas(
107+
self, name: str, dataframe: PySparkDataFrame
108+
) -> StreamingQuery:
109+
"""Write a streaming DataFrame of deltas to a Data Model Object.
110+
111+
Streaming counterpart to :meth:`write_to_dmo`. Starts a streaming query
112+
that writes each micro-batch to the target DMO via the Data Cloud
113+
streaming sink and returns the resulting ``StreamingQuery`` handle. The
114+
runtime owns the trigger and checkpoint location; callers pass only the
115+
table name. Concrete streaming behavior is provided by the deployed
116+
Data Cloud runtime; the base implementation raises
117+
:class:`NotImplementedError`.
118+
119+
Args:
120+
name: Target Data Model Object name.
121+
dataframe: Streaming PySpark DataFrame produced from a
122+
``read_dmo_deltas`` source.
123+
124+
Returns:
125+
The started ``StreamingQuery``; the caller drives its lifecycle
126+
(typically ``query.awaitTermination()``).
127+
128+
Raises:
129+
NotImplementedError: If the active writer does not support streaming
130+
deltas (e.g. the local development writers).
131+
"""
132+
raise NotImplementedError(
133+
"write_dmo_deltas is only supported when running in the Data Cloud "
134+
"streaming runtime; the local writer does not support streaming "
135+
"deltas."
136+
)

‎src/datacustomcode/scan.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
DATA_ACCESS_METHODS = ["read_dlo", "read_dmo", "write_to_dlo", "write_to_dmo"]
3636
STREAMING_READ_METHODS = ["read_dlo_deltas", "read_dmo_deltas"]
37-
STREAMING_WRITE_METHODS = ["write_dlo_deltas"]
37+
STREAMING_WRITE_METHODS = ["write_dlo_deltas", "write_dmo_deltas"]
3838

3939
DATA_TRANSFORM_CONFIG_TEMPLATE = {
4040
"sdkVersion": get_version(),
@@ -181,6 +181,7 @@ class StreamingDataAccessLayerCalls(pydantic.BaseModel):
181181
read_dlo_deltas: bool
182182
read_dmo_deltas: bool
183183
write_dlo_deltas: frozenset[str]
184+
write_dmo_deltas: frozenset[str]
184185

185186
@pydantic.model_validator(mode="after")
186187
def validate_access_layer(self) -> StreamingDataAccessLayerCalls:
@@ -193,10 +194,19 @@ def validate_access_layer(self) -> StreamingDataAccessLayerCalls:
193194
"A streaming transform must read from at least one DLO or DMO "
194195
"delta stream (read_dlo_deltas / read_dmo_deltas)."
195196
)
196-
if not self.write_dlo_deltas:
197+
if not self.write_dlo_deltas and not self.write_dmo_deltas:
197198
raise ValueError(
198-
"A streaming transform must write to at least one DLO via "
199-
"write_dlo_deltas."
199+
"A streaming transform must write to at least one DLO or DMO "
200+
"via write_dlo_deltas / write_dmo_deltas."
201+
)
202+
if self.write_dlo_deltas and self.write_dmo_deltas:
203+
raise ValueError(
204+
"Cannot write DLO and DMO deltas in the same streaming transform."
205+
)
206+
if self.read_layer != self.write_layer:
207+
raise ValueError(
208+
"A streaming transform must read and write the same layer "
209+
f"(got read={self.read_layer}, write={self.write_layer})."
200210
)
201211
return self
202212

@@ -205,6 +215,11 @@ def read_layer(self) -> str:
205215
"""Return the read source layer, ``"dlo"`` or ``"dmo"``."""
206216
return "dlo" if self.read_dlo_deltas else "dmo"
207217

218+
@property
219+
def write_layer(self) -> str:
220+
"""Return the write target layer, ``"dlo"`` or ``"dmo"``."""
221+
return "dlo" if self.write_dlo_deltas else "dmo"
222+
208223

209224
class ClientMethodVisitor(ast.NodeVisitor):
210225
"""AST Visitor that finds all instances of Client read/write method calls."""
@@ -217,6 +232,7 @@ def __init__(self) -> None:
217232
self._read_dlo_deltas: bool = False
218233
self._read_dmo_deltas: bool = False
219234
self._write_dlo_deltas_instances: set[str] = set()
235+
self._write_dmo_deltas_instances: set[str] = set()
220236
self.variable_values: Dict[str, Union[str, None]] = {}
221237

222238
def visit_Assign(self, node: ast.Assign) -> None:
@@ -261,6 +277,8 @@ def visit_Call(self, node: ast.Call) -> None:
261277
name = self._resolve_name_arg(node.args[0])
262278
if name and method_name == "write_dlo_deltas":
263279
self._write_dlo_deltas_instances.add(name)
280+
elif name and method_name == "write_dmo_deltas":
281+
self._write_dmo_deltas_instances.add(name)
264282
self.generic_visit(node)
265283

266284
def _resolve_name_arg(self, arg: ast.expr) -> Union[str, None]:
@@ -278,6 +296,7 @@ def is_streaming(self) -> bool:
278296
self._read_dlo_deltas
279297
or self._read_dmo_deltas
280298
or bool(self._write_dlo_deltas_instances)
299+
or bool(self._write_dmo_deltas_instances)
281300
)
282301

283302
def found(self) -> DataAccessLayerCalls:
@@ -293,6 +312,7 @@ def found_streaming(self) -> StreamingDataAccessLayerCalls:
293312
read_dlo_deltas=self._read_dlo_deltas,
294313
read_dmo_deltas=self._read_dmo_deltas,
295314
write_dlo_deltas=frozenset(self._write_dlo_deltas_instances),
315+
write_dmo_deltas=frozenset(self._write_dmo_deltas_instances),
296316
)
297317

298318

@@ -516,10 +536,10 @@ def _update_streaming_config(existing_config: dict[str, Any], file_path: str) ->
516536
)
517537

518538
read_names = [source_name] if source_name else []
519-
write_names = list(output.write_dlo_deltas)
539+
write_names = list(output.write_dlo_deltas or output.write_dmo_deltas)
520540
existing_config["permissions"] = {
521541
"read": {read_layer: read_names},
522-
"write": {"dlo": write_names},
542+
"write": {output.write_layer: write_names},
523543
}
524544

525545

‎tests/io/writer/test_print.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ def test_write_dlo_deltas_not_supported_locally(self, print_writer, mock_datafra
6565
print_writer.write_dlo_deltas("test_dll", mock_dataframe)
6666
assert "write_dlo_deltas" in str(exc_info.value)
6767

68+
def test_write_dmo_deltas_not_supported_locally(self, print_writer, mock_dataframe):
69+
"""Streaming delta writes are not supported by the local writer."""
70+
with pytest.raises(NotImplementedError) as exc_info:
71+
print_writer.write_dmo_deltas("test_dlm", mock_dataframe)
72+
assert "write_dmo_deltas" in str(exc_info.value)
73+
6874
def test_config_name(self):
6975
"""Test that the CONFIG_NAME class variable is set correctly."""
7076
assert PrintDataCloudWriter.CONFIG_NAME == "PrintDataCloudWriter"

‎tests/test_client.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,40 @@ def test_write_dlo_deltas_after_dmo_read_raises_exception(
440440
assert "test_dmo" in str(exc_info.value)
441441
writer.write_dlo_deltas.assert_not_called()
442442

443+
def test_write_dmo_deltas(self, reset_client, mock_spark):
444+
reader = MagicMock(spec=BaseDataCloudReader)
445+
writer = MagicMock(spec=BaseDataCloudWriter)
446+
mock_df = MagicMock(spec=DataFrame)
447+
mock_query = MagicMock()
448+
writer.write_dmo_deltas.return_value = mock_query
449+
450+
client = StreamingClient(reader=reader, writer=writer)
451+
client._record_dmo_access("some_dmo")
452+
453+
result = client.write_dmo_deltas("test_dmo", mock_df, extra_param=True)
454+
455+
writer.write_dmo_deltas.assert_called_once_with(
456+
"test_dmo", mock_df, extra_param=True
457+
)
458+
assert result is mock_query
459+
460+
def test_write_dmo_deltas_after_dlo_read_raises_exception(
461+
self, reset_client, mock_spark
462+
):
463+
"""Streaming DMO write is subject to the same DLO/DMO mixing guard."""
464+
reader = MagicMock(spec=BaseDataCloudReader)
465+
writer = MagicMock(spec=BaseDataCloudWriter)
466+
mock_df = MagicMock(spec=DataFrame)
467+
468+
client = StreamingClient(reader=reader, writer=writer)
469+
client._record_dlo_access("test_dlo")
470+
471+
with pytest.raises(DataCloudAccessLayerException) as exc_info:
472+
client.write_dmo_deltas("test_dmo", mock_df)
473+
474+
assert "test_dlo" in str(exc_info.value)
475+
writer.write_dmo_deltas.assert_not_called()
476+
443477
def test_streaming_read_write_flow(self, reset_client, mock_spark):
444478
"""A read_dlo_deltas → write_dlo_deltas flow stays within the DLO layer."""
445479
reader = MagicMock(spec=BaseDataCloudReader)
@@ -458,6 +492,24 @@ def test_streaming_read_write_flow(self, reset_client, mock_spark):
458492
writer.write_dlo_deltas.assert_called_once_with("target_dll", stream_df)
459493
assert "source_dll" in client._data_layer_history[DataCloudObjectType.DLO]
460494

495+
def test_streaming_dmo_read_write_flow(self, reset_client, mock_spark):
496+
"""A read_dmo_deltas → write_dmo_deltas flow stays within the DMO layer."""
497+
reader = MagicMock(spec=BaseDataCloudReader)
498+
writer = MagicMock(spec=BaseDataCloudWriter)
499+
stream_df = MagicMock(spec=DataFrame)
500+
reader.read_dmo_deltas.return_value = stream_df
501+
502+
client = StreamingClient(reader=reader, writer=writer)
503+
504+
with patch("datacustomcode.client.config") as mock_config:
505+
mock_config.streaming_source = "source_dlm"
506+
df = client.read_dmo_deltas()
507+
client.write_dmo_deltas("target_dlm", df)
508+
509+
reader.read_dmo_deltas.assert_called_once_with()
510+
writer.write_dmo_deltas.assert_called_once_with("target_dlm", stream_df)
511+
assert "source_dlm" in client._data_layer_history[DataCloudObjectType.DMO]
512+
461513
def test_auto_write_to_dlo(self, reset_client, mock_spark):
462514
reader = MagicMock(spec=BaseDataCloudReader)
463515
writer = MagicMock(spec=BaseDataCloudWriter)

‎tests/test_deploy.py‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,34 +1683,32 @@ def test_is_streaming_false_when_source_absent(self):
16831683
)
16841684
assert config.is_streaming is False
16851685

1686-
def test_streaming_allows_dmo_read_dlo_write(self):
1687-
"""Streaming may read a DMO change feed and write a DLO (writes are
1688-
DLO-only)"""
1686+
def test_streaming_allows_dmo_read_dmo_write(self):
16891687
config = DataTransformConfig(
16901688
sdkVersion="1.0.0",
16911689
entryPoint="entrypoint.py",
16921690
dataspace="default",
16931691
streamingSource=StreamingSource(type="dmo", name="input_dmo__dlm"),
16941692
permissions=Permissions(
16951693
read=DmoPermission(dmo=["input_dmo__dlm"]),
1696-
write=DloPermission(dlo=["output_dlo"]),
1694+
write=DmoPermission(dmo=["output_dmo__dlm"]),
16971695
),
16981696
)
16991697
assert config.is_streaming is True
17001698
assert isinstance(config.permissions.read, DmoPermission)
1701-
assert isinstance(config.permissions.write, DloPermission)
1699+
assert isinstance(config.permissions.write, DmoPermission)
17021700

1703-
def test_streaming_rejects_dmo_write(self):
1704-
"""Streaming writes must target a DLO."""
1705-
with pytest.raises(ValueError, match="must write to a DLO"):
1701+
def test_streaming_rejects_dmo_read_dlo_write(self):
1702+
"""Streaming follows the same same-layer rule as batch."""
1703+
with pytest.raises(ValueError, match="both reference"):
17061704
DataTransformConfig(
17071705
sdkVersion="1.0.0",
17081706
entryPoint="entrypoint.py",
17091707
dataspace="default",
1710-
streamingSource=StreamingSource(type="dlo", name="input_dlo"),
1708+
streamingSource=StreamingSource(type="dmo", name="input_dmo__dlm"),
17111709
permissions=Permissions(
1712-
read=DloPermission(dlo=["input_dlo"]),
1713-
write=DmoPermission(dmo=["output_dmo__dlm"]),
1710+
read=DmoPermission(dmo=["input_dmo__dlm"]),
1711+
write=DloPermission(dlo=["output_dlo"]),
17141712
),
17151713
)
17161714

0 commit comments

Comments
 (0)