feat: add ForEach transformation for transforming nested collections - #1163
Anatolii Yatsuk (tolik0) wants to merge 4 commits into
Conversation
Connectors that need to apply the same fix-up to every element of a nested collection have no declarative way to write a loop, so they drop to a CustomTransformation. A single Python class forfeits the Connector Builder for the entire connector, and it does so for logic that is otherwise a plain AddFields. ForEach resolves a dpath field_path to a collection inside the record and runs its nested transformations against each element. RecordTransformation.transform mutates in place, so rebinding the nested call's `record` argument to the element mutates that element inside the parent record - no copy and no copy-back, which is what keeps this cheap and safe. source-monday's MondayTransformation (components.py:463-472) collapses entirely into YAML, and the release-asset loop in source-github's ReleasesRecordTransformation (components.py:148-151) becomes an AddFields plus a RemoveFields inside a ForEach. A missing or non-collection field_path is a silent no-op, because a collection that is absent from some records is normal and raising would fail the sync. A non-object element raises a config-flavoured AirbyteTracedException naming the stream and the path, because silently skipping it would hide a manifest mistake behind missing data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@tolik0/cdk/foreach-transformation#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch tolik0/cdk/foreach-transformationPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
Behaviour
- A `null` inside the collection is now skipped instead of aborting the sync.
A null in an array is ordinary API payload, and `MondayTransformation` — the
Python code this component replaces — skips it, so raising was a regression.
Any other non-object element still raises, but as `system_error` rather than
`config_error`: a mis-pointed `field_path` is a connector-development fault the
end user cannot fix from their connection configuration, and sibling decoders
already classify read-time payload-shape faults that way.
- Every collection a glob expands to is resolved and validated before any element
is transformed, so the documented "a bad element leaves the record untouched"
contract now holds per record, not just per collection.
- A `field_path` segment that interpolates to something other than a non-empty
string is now a `config_error` instead of silently turning the whole
transformation into a no-op for the entire sync.
- The error message no longer promises a stream name. `name` is a
`DeclarativeStream` field, not a `$parameter`, so `self._parameters.get("name")`
was always empty for a modern manifest. Threading the real name through
`_create_component_from_model` was rejected: the kwarg would reach
`create_custom_component`, which merges unknown kwargs into the custom class's
own fields and would override a manifest-declared `name` on any existing custom
transformation. The message still names the `field_path` and the element type.
- Messages shortened and de-duplicated per the error-message guidelines.
Performance
- `dpath.get` resolves by folding over the entire object graph, so it costs
O(total nodes in the record) per record: 17.7 ms measured on a record with a
10,000-element *unrelated* sibling. Paths without a glob segment now use a plain
walk — 0.4 us on the same record, ~45,000x faster. `dpath.values` is used for
every glob form, which also removes the bare `ValueError: globs must match only
one leaf` that `**`, `?` and `a[ab]` used to raise.
Manifest contract
- `"ForEach.transformations": "CustomTransformation"` added to
`CUSTOM_COMPONENTS_MAPPING`. Without it a `class_name`-only custom
transformation — the style used in six production manifests — gets no `type`
injected when nested in a `ForEach` and fails schema validation.
- `field_path` declares `interpolation_context: [config, parameters]`, matching
the examples the block advertises. The generated model mirrors the reworded
description exactly; `interpolation_context` is not emitted by codegen.
- The redundant `__eq__` override is gone. The base class body is byte-identical,
and the dataclass-generated one correctly returns False for foreign types
instead of raising `AttributeError`.
Tests
- Null elements skipped; non-object elements raise `system_error`; no stream name
in the message; nothing mutated when a later collection under a glob is invalid
(the previous assertion was vacuous — every element in its fixture was a
non-dict, so nothing could have mutated). Both new guards were confirmed to fail
against the pre-fix implementation.
- `field_path: []`, `$parameters` interpolation of `field_path`, `stream_state`
forwarding, non-`*` globs, equality, and a deep copy in the no-op test.
- `dpath` is not called for a path without a glob, plus a wall-clock guard that a
large unrelated sibling does not dominate the lookup.
- A `ForEach` manifest fragment is validated against the JSON schema, including
all four `$ref` sites — `DeclarativeStream.transformations`,
`DynamicSchemaLoader.schema_transformations`,
`JsonSchemaPropertySelector.transformations` and `ForEach.transformations`.
Nothing validated against the schema before, so dropping `ForEach` from one of
those `anyOf` lists would only have surfaced in a real connector.
- A factory-built nested `class_name`-only `CustomTransformation`.
Notes for the PR body
- Do not run `/poe build` on this branch. The hand-applied model delta was
reproduced against pinned codegen and is byte-identical to it; `main`'s
generated file is itself ~240 lines divergent from codegen (including an
`OAuthScope` -> `Scope`/`OptionalScope` public rename), so regenerating here
would import that drift and would make #1162 conflict. The drift belongs in a
separate re-sync PR against `main`.
- The earlier `ForwardRef` rationale for the class placement was wrong. Codegen
also emits `ForEach` before `JsonSchemaPropertySelector`; there is no hazard and
CI regeneration would not reintroduce the failure.
- The `source-github` `ReleasesRecordTransformation` citation is from the unmerged
`tolik0/source-github/graphql-streams` branch, not monorepo `main`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The round-2 performance fix replaced `dpath.get` with a hand-rolled walk for non-glob paths. The walk is worth keeping -- it takes path resolution from 18 ms to 0.35 us per record -- but it diverged from `dpath` in two ways, and a third divergence was introduced alongside it. * Negative list indices were dropped. `"-1".isdigit()` is `False`, so the walk fell through to `return []` where `dpath` returns the last element. * `isdigit()` is the wrong predicate: it is `True` for superscripts such as `"²"` that `int()` rejects, so such a segment raised an uncaught `ValueError` against a list node, where the contract is a no-op. `dpath` decides with `int()` itself (`dpath.segments.match`, through `ListIndex.__eq__`), so the walk now does too -- which also covers the `+1`, `" 1"` and `"1_0"` forms that `isdecimal()` would have rejected. * Neither was reachable in the first place. `_evaluate_path`, added in the same round, rejects any segment that is not a string, and Jinja renders `"-1"` as an `int` -- so every list index, `"0"` as much as `"-1"`, was a `config_error` before the walk ever ran. Integer segments are now accepted and stringified; `dpath` takes an integer list index too. Also: a literal empty `field_path` segment was classified `config_error`, but an empty string cannot be the render of a template, so it is a manifest fault. It is now a `system_error` raised once at construction, consistent with how `_elements_of` classifies a mis-pointed `field_path`. Drops the now-dead `self._parameters`, matching `AddFields`/`RemoveFields`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Review of 7c4a4d49b8ca931f16c68a5d235a65b69fcc6298
Checked out the branch and executed everything below; every finding has a repro.
Breaking-change call: NON_BREAKING
Search in airbytehq/airbyte (airbyte-integrations/connectors, *.yaml|*.py|*.json):
rg -l "ForEach" airbyte-integrations/connectors --glob '*.yaml' --glob '*.py' --glob '*.json' | wc -l # 0
rg -l "^\s*transformations:" airbyte-integrations/connectors --glob manifest.yaml | wc -l # 174
0 of 174 manifests using transformations reference ForEach, and no components.py defines a class by that name, so the new type: ForEach discriminator cannot collide with an existing custom component. Schema/model changes are purely additive (new definition + appended anyOf/Union members); no existing definition changed.
What I ran
pytest unit_tests/sources/declarative/transformations/test_for_each.py unit_tests/sources/declarative/parsers/test_model_to_component_factory.py→ 234 passedpytest unit_tests/ -x→ 2729 passed, then stopped onfile_based/test_file_based_stream_reader.py::test_parse_start_date. That test also fails on cleanorigin/main(2 params fail there) → pre-existing/local-env, not this PR. CI at this SHA reports the full suite green.ruff check/ruff format --check→ clean.mypy --config-file mypy.ini airbyte_cdk→ 459 files, no issues.test_a_large_unrelated_sibling_does_not_slow_down_the_lookup×5 → 0.01s call time each run.- Codegen: regenerated the model with the pinned
datamodel-code-generator==0.26.3inpython:3.10against bothorigin/mainand this branch's schema. The only PR-attributable delta is theForEachclass, itsUnionentries andupdate_forward_refs(); the remaining diff (e.g.JsonSchemaPropertySelectorplacement) is the pre-existing drift onmain. The hand-applied model matches what codegen would emit for the new definition. - Repro'd
MondayTransformationon anullelement:values.get(...)onNone→AttributeError. It does not skip nulls (see inline P3 on the docstring).
Findings
No P0/P1. Inline:
- P2 glob path over a scalar list silently no-ops while the plain path raises (
_elements_of). - P2
**glob visits the same nested object twice (_resolve_collections). - P3 literal keys containing
*,?,[cannot be addressed; int dict keys differ from dpath (_GLOB_CHARACTERS/ plain walk). - P3 "leaves the record untouched" docstring only holds for validation, not for a nested transformation raising mid-loop.
- P3 docstring claim that the replaced Python transformations skip
nullis false forMondayTransformation.
Also: no connector in the CI matrix exercises this component, so the green CI here is only evidence for the unit tests, not for end-to-end behaviour — worth pairing with the source-monday migration PR before merging.
| def _elements_of(collection: Any, path: List[str]) -> List[Dict[str, Any]]: | ||
| if isinstance(collection, dict): | ||
| return [collection] | ||
| if not isinstance(collection, list): |
There was a problem hiding this comment.
P2 — Plain and glob paths disagree on non-object elements.
The docstring (line 65) says "Any other non-object element (a string, a number) raises". That holds for a plain path, but with a glob dpath.values yields the scalars themselves and this branch swallows them:
r = {"items": ["a", "b"]}
fe(["items"]).transform(copy.deepcopy(r)) # raises AirbyteTracedException
fe(["items", "*"]).transform(copy.deepcopy(r)) # no exception, record unchangedSo the "mis-pointed field_path hidden behind missing data" case the error is meant to catch is exactly what happens once someone adds a *. Suggest either raising here too when the collection came from a glob over a list, or documenting that globs are lenient.
There was a problem hiding this comment.
Confirmed, and taken the second option: globs are lenient, now documented.
Raising here would make * unusable. A glob selects rather than addresses, so it routinely lands on scalars that have nothing to do with the intended collection — field_path: ["*", "items"] on a record with a top-level "name": "abc" would fail the sync on every record. The asymmetry is the point rather than an oversight: ["items"] addresses the list itself, so a scalar element there is unambiguously a mis-pointed field_path; ["items", "*"] asks for whatever matches, so a non-match is a non-match.
Docstring bullet and the field_path schema description now say so explicitly, and test_a_glob_match_that_is_not_an_object_is_skipped_instead_of_raising pins both halves of the asymmetry in one test.
Fixed in 282023f.
| if not path: | ||
| return [record] | ||
| if any(character in segment for segment in path for character in _GLOB_CHARACTERS): | ||
| return list(dpath.values(record, path)) |
There was a problem hiding this comment.
P2 — ** transforms the same object more than once.
dpath.values(record, ["**"]) yields every container at every depth, so a nested object is returned both as a member of its parent list and as its own match:
r = {"a": [{"id": 1, "b": [{"id": 2}]}]}
ForEach(config={}, field_path=["**"], transformations=[Counter()], parameters={}).transform(r)
# {'a': [{'id': 1, 'b': [{'id': 2, 'n': 2}], 'n': 2}]} <- each object hit twiceFor idempotent nested transformations this is just wasted work; for AddFields with a non-idempotent expression or a RemoveFields inside a ForEach it changes results. Either dedupe by id() before applying, or reject/document **.
There was a problem hiding this comment.
Real bug, fixed. Reproduced your snippet exactly — {"a": [{"id": 1, "b": [{"id": 2, "n": 2}], "n": 2}]}.
Went with de-duplication by id() rather than rejecting **, because the overlap is not specific to **: any two glob patterns in one path can match the same object. Applying a transformation twice to one object is never what the manifest asked for, and JSON parsing never produces two references to one object, so identity de-duplication cannot collapse two legitimately distinct elements.
Order is preserved, and the de-duplication sits after collection resolution and element validation, so the untouched-record contract is unaffected. test_overlapping_glob_matches_transform_each_object_exactly_once asserts applied == 1 on both objects of your record.
Fixed in 282023f.
| # Segments containing any of these need `dpath` glob matching. Everything else is resolved by a | ||
| # plain walk, because `dpath` traverses the whole record to glob-match and that cost is paid per | ||
| # record. | ||
| _GLOB_CHARACTERS = ("*", "?", "[") |
There was a problem hiding this comment.
P3 — Any segment containing *, ? or [ is routed to dpath, so a literal key with one of those characters can't be addressed at all:
r = {"a[0]": [{"id": 1}]}
fe(["a[0]"]).transform(r) # no-op: treated as glob `a[0]`, never matches the literal keySame class of parity gap on the plain walk (line 180): dpath matches string segment "1" against int dict key 1, the plain walk doesn't (dpath.values(r, ["a","1"]) finds {"a": {1: [...]}}, fe(["a","1"]) is a no-op). Both are edge cases, but the docstring sells the plain walk as "the way dpath resolves it" — worth a sentence listing the known divergences, or an escape hatch.
There was a problem hiding this comment.
Split these two, they have different answers.
The integer dict key is a real parity gap — fixed. The plain walk now falls back to an int() interpretation of the segment when the string key is absent, so ["a", "1"] reaches {"a": {1: [...]}} as dpath does. This mattered more than it looks: without it, adding a glob anywhere else in the path changed which values the same field_path resolved. test_the_plain_walk_matches_an_integer_dict_key_like_dpath asserts _resolve_collections equals dpath.values on that record.
The literal a[0] key is not a divergence — dpath cannot address it either. It glob-matches through fnmatch, so the pattern a[0] matches the key a0, not a[0]:
>>> list(dpath.values({"a[0]": [{"id": 1}]}, ["a[0]"]))
[]
>>> list(dpath.values({"a0": [{"id": 1}]}, ["a[0]"]))
[[{"id": 1}]]So ForEach behaves exactly like DpathExtractor and DpathFlattenFields here, and an escape hatch would make it the only dpath-based component with one. Documented as an inherited limitation instead, with test_a_key_that_literally_contains_a_glob_character_cannot_be_addressed asserting the dpath half alongside the ForEach half so the claim cannot rot.
Fixed in 282023f.
| and silently skipping it would hide a mis-pointed `field_path` behind missing data. | ||
|
|
||
| Every collection the path matches is resolved and validated before any element is transformed, so a bad | ||
| element anywhere under a glob leaves the whole record untouched. |
There was a problem hiding this comment.
P3 — This guarantee covers element validation only. If a nested transformation raises on the second element, the first stays mutated:
r = {"items": [{}, {}]}
ForEach(config={}, field_path=["items"], transformations=[Boom()], parameters={}).transform(r) # raises on 2nd
# r == {'items': [{'x': 1}, {}]}That's consistent with how other transformations behave (no atomicity anywhere in the pipeline), so I'd just narrow the wording to "a non-object element … leaves the record untouched".
There was a problem hiding this comment.
Agreed, the wording overclaimed. Narrowed to element validation, and added the reason so nobody re-widens it: nothing in the transformation pipeline is atomic, and ForEach should not be the one component that pretends otherwise.
Also added test_a_nested_transformation_that_raises_leaves_the_earlier_elements_mutated, which pins your exact scenario — {"items": [{"id": 1, "added": "value"}, {"id": 2}]} after the raise on the second element.
Fixed in 282023f.
| * An empty `field_path` resolves to the record itself, the same way `DpathExtractor` treats an empty | ||
| `field_path`. | ||
| * A `null` element inside the collection is skipped. A `null` in an array is ordinary API payload, not a | ||
| manifest mistake, and the Python transformations this component replaces skip it too. |
There was a problem hiding this comment.
P3 — "the Python transformations this component replaces skip it too" is not true for the cited precedent. MondayTransformation does values.get("display_value") on each element; on a None element that is:
AttributeError: 'NoneType' object has no attribute 'get'
Skipping null is still the right choice here, just drop the parity claim (the PR description makes the same claim).
There was a problem hiding this comment.
Correct, the parity claim was wrong — MondayTransformation calls values.get("display_value") and raises AttributeError on a None element. Claim dropped from the docstring and from the PR description. Skipping stays, on the merits: a null in an array is ordinary API payload rather than a manifest mistake, so it must not fail the sync.
Fixed in 282023f.
…claims Addresses the review findings on the ForEach transformation: - Overlapping glob matches no longer transform the same object twice. `dpath.values(record, ["**"])` yields every container at every depth, so a nested object came back both on its own and as a member of its parent list, and a non-idempotent nested transformation ran on it twice. Matches are now de-duplicated by identity. - The plain walk now matches an integer dict key from a string segment, the way `dpath` does (it glob-matches against `str(key)`). Without this, adding a glob elsewhere in the path changed which values the same `field_path` resolved. - Documented that globs are lenient: a glob selects rather than addresses, so a match that is neither an object nor a list of objects is skipped instead of raising. Raising there would make `*` unusable, since it routinely lands on unrelated scalars. - Documented that a key literally containing `*`, `?` or `[` cannot be addressed. This is inherited from `dpath`/`fnmatch`, which offer no escape syntax, and is identical to `DpathExtractor`. - Narrowed the "leaves the whole record untouched" guarantee to element validation. Nothing in the transformation pipeline is atomic, and a nested transformation that raises part-way through leaves earlier elements mutated. - Dropped the claim that the Python transformations this component replaces skip `null` elements. `MondayTransformation` raises `AttributeError` on one. Skipping is still the right behavior, just not for that reason. Tests cover each of the above, including a parity assertion against `dpath.values` for the integer dict key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Re-review of 282023fd2154c350aaa1f312ccae6122db23a7d9
Checked out 282023fd and re-ran everything against it. All five findings from the previous review are resolved; no new P0–P2.
Verification of the fixes (same repros as before, now on 282023fd)
| Finding | Before | Now |
|---|---|---|
| P2 glob over scalar list | silent no-op, undocumented | still no-op — now documented in docstring + schema description as intentional leniency; plain path still raises |
P2 ** double-transform |
n: 2 |
{'a': [{'id': 1, 'b': [{'id': 2, 'n': 1}], 'n': 1}]} — each object once |
| P3 int dict key | plain walk no-op | {'a': {1: [{'id': 1, 'n': 1}]}} — matches dpath; negative int key (-1) also works |
| P3 glob-char literal keys | unaddressable | still unaddressable, now documented as inherited dpath behaviour + test |
| P3 atomicity wording | overclaimed | narrowed to element validation, non-atomic nested raise now stated + tested |
| P3 Monday null claim | false | removed from docstring; PR description updated |
What I ran
pytest unit_tests/sources/declarative/transformations/test_for_each.py unit_tests/sources/declarative/parsers/test_model_to_component_factory.py→ 239 passed (5 new tests, one per finding)ruff check/ruff format --checkon the changed files → clean;mypy --config-file mypy.ini airbyte_cdk/sources/declarative/transformations/for_each.py→ no issues- Schema ↔ hand-applied model:
field_path.descriptionindeclarative_component_schema.yamland in the generatedForEachmodel are byte-identical (diffed programmatically). Nothing else changed in the model. - Extra probes: a single dict object referenced from two collections under
["*"]is transformed once (identity dedupe works as described); a nested list inside a glob-matched list is transformed leniently, the same nested list under a plain path raises — consistent with the documented plain/glob split.
One residual P3 (informational, no change requested)
With {"a": {1: {...}, "1": {...}}} and path ["a", "1"], dpath.values yields both objects while the plain walk takes only the "1" key (string key wins, int fallback is skipped). Mixed int/str keys colliding on the same digit string is not a realistic API payload, so I'd leave it; noting it only so the "parity with dpath" docstring is read as best-effort.
Breaking-change call: unchanged, NON_BREAKING
The delta since 7c4a4d49 touches only for_each.py, its tests, and the ForEach.field_path description string in schema/model. The airbytehq/airbyte search from the previous review (0 of 174 transformations manifests reference ForEach, no components.py defines the name) still applies.
CI at this SHA: Pytest (All, 3.10–3.13), Pytest (Fast) and source-shopify still running at the time of writing; destination-motherduck red (known baseline).
| # `dpath` glob-matches a segment against `str(key)`, so a string segment | ||
| # addresses an integer dict key too. Keep the walk in parity with it. | ||
| index = _as_list_index_or_none(segment) | ||
| if index is None or index not in node: |
There was a problem hiding this comment.
P3 (informational) — Verified this now matches dpath for {1: ...} and {-1: ...} keys. One remaining divergence: when a dict has both 1 and "1", dpath yields both values, this walk only the string key. Not worth code; just noting the docstring's "parity" is best-effort.
What
Adds a
ForEachdeclarative transformation: aRecordTransformationthat applies a list of nested transformations to every element of a collection inside the record, instead of to the record as a whole.Why
Today a connector that needs to apply the same fix-up to every element of a nested list has no declarative way to write a loop, so it drops to a
CustomTransformation. A single Python class forfeits the Connector Builder for the entire connector — and it does so for logic that is otherwise a plainAddFields.RecordTransformation.transform()returnsNoneand mutates the record in place (airbyte_cdk/sources/declarative/transformations/transformation.py:19-34; seeadd_fields.py:128-146, which doeskwargs = {"record": record, ...}thendpath.new(record, ...)). That is what makesForEachcheap: rebinding the nested call'srecordargument to a collection element mutates that element inside the parent record. No copy, no copy-back.Verified connector precedents
Each was checked by running the replacement against the same records as the custom class, not by comparing shape.
MondayTransformation,airbyte-integrations/connectors/source-monday/components.py:462-472. Loopsrecord["column_values"]and copiesdisplay_valueintotextwhentextis falsy. The class collapses into the YAML above only withvalue_type: stringon the innerAddFields. monday copiesdisplay_valueverbatim, always a string;AddFieldswithoutvalue_typeruns the rendered value throughJinjaInterpolation._literal_eval, so adisplay_valueof"42"lands as the integer42,"True"as a boolean and"1000.0"as a float. Measured on six values:[(42, int), (True, bool), ('2024-01-01', str), ([1, 2], list), ('hello', str), (1000.0, float)]without the flag, identical to monday with it. One difference remains and is an improvement: anullcolumn_valuesis a no-op here where monday raises. monday keeps six other custom classes, so it does not become manifest-only.ReleasesRecordTransformation._assetson the unmergedtolik0/source-github/graphql-streamsbranch. Not an adopter. The per-asset loop popsuploaderintouploader_id, whichForEachwithAddFieldsandRemoveFieldsexpresses, and then recovers the asset's database id by base64-decoding a node ID and unpacking its last four bytes, which no Jinja filter does. Because the loop has to stay in Python for the second half,ForEachsaves that connector nothing. An earlier revision listed it as a precedent.components.py:88, a per-Settings[]fix-up that wrapsDetails, casts*Idstrings to integers under dynamic key names and rewritesPageFeedIds. Partially expressible; the dynamic-key casts keep it custom.No other
RecordTransformationin the 74components.pyfiles of the monorepo loops over a list-valued field.Before / after —
MondayTransformationBefore (Python, forfeits the Builder for the whole connector):
After (manifest only;
value_type: stringis what keeps"42"a string):Design decisions
field_pathresolutionInterpolatedString, resolved per record, as in the other dpath components. From there the paths diverge: a path without a glob is resolved by a plain dict/list walk that deliberately bypassesdpath, and only a glob path calls intodpath.values.dpathresolves by folding over the whole object graph, so it costs O(total nodes in the record) per record — 18 ms on a record carrying a 10 k-element unrelated sibling, against 0.35 µs for the walk. The walk is written to matchdpath's semantics exactly rather than to approximate them: negative list indices count from the end (["a", "-1"]is the last element), an index outside the list matches nothing, and whether a segment is an index at all is decided byint()— the predicatedpathitself uses (dpath.segments.match,dpath.types.ListIndex.__eq__), notisdigit(), which isTruefor superscriptsint()rejects. A parity test asserts the walk agrees withdpath.valuessegment for segment.field_pathsegment that cannot be used as a pathAirbyteTracedException. A segment that interpolates to something other than a non-empty string or an integer — empty string, float, boolean,null, list — is aconfig_error. A segment written as an empty string literally in the manifest is asystem_error, raised once at construction instead of per record.intanddpathaccepts an integer list index — rejecting them would make list indexing unreachable.*,?or[— so*,**,item*,?,[ab]— routes the whole path throughdpath.values, and every matching value is iterated as its own collection. Matches are then de-duplicated by identity, because two glob patterns in one path (and**on its own) can match the same object twice.DpathExtractorandDpathFlattenFieldsboth accept globs;dpath.valuesreturns references, not copies, so in-place mutation still holds.dpath.valuesrather thandpath.getbecausegetraises a bareValueErroras soon as a glob matches more than one leaf. De-duplication because applying a nested transformation twice to one object is never what the manifest asked for, and JSON parsing never yields two references to one object, so identity cannot collapse two distinct elements.["*", "items"]on a record with a top-level"name": "abc"would otherwise fail the sync on every record. A plain path addresses the list itself, so a scalar element there is unambiguously a mis-pointedfield_path.*,?or[a[0]matches the keya0, nota[0].dpath, which matches throughfnmatchand offers no escape syntax —dpath.values({"a[0]": …}, ["a[0]"])is empty on its own.DpathExtractorandDpathFlattenFieldsbehave identically, so an escape hatch here would makeForEachthe only dpath-based component with one.DpathExtractor, which wraps an object as a single record.null/ empty listnullnullinside an array is ordinary API payload rather than a manifest mistake, so it must not fail the sync. (Not for parity withMondayTransformation— that one callsvalues.get(...)and raisesAttributeErroron aNoneelement. An earlier revision of this description claimed otherwise; the claim was wrong.)nullAirbyteTracedExceptionwithFailureType.system_error, naming thefield_path. Applies to a list addressed without a glob; a glob match is lenient (row above).field_pathbehind missing data.system_errorrather thanconfig_errorbecause a mis-pointedfield_pathis a connector-developer fault the end user cannot fix. The check runs over every element, across all wildcard expansions, before any element is transformed, so a non-object element leaves the record untouched. This covers element shape only: nothing in the transformation pipeline is atomic, so a nested transformation that raises part-way through leaves the elements already transformed mutated.field_pathDpathExtractorwithfield_path: [].record, but the realconfig,stream_stateandstream_slice.{{ config[...] }}and{{ stream_slice[...] }}must keep working inside the loop. Tested.field_pathparity withdpathdpathdoes (it glob-matches againststr(key)).field_pathresolves. Pinned by a parity assertion againstdpath.values.record["items"][0] is element).ForEachinForEachanyOfis self-referential.Backward compatibility: additive only
Nothing existing changes behaviour. The PR adds one new component class, one new schema definition, one new entry in
PYDANTIC_MODEL_TO_CONSTRUCTOR, and one new member (ForEach) in each of the three existing transformationanyOflists (DeclarativeStream.transformations,DynamicSchemaLoader.schema_transformations,JsonSchemaPropertySelector.transformations). No existing definition, field, default, or code path is modified or removed, so every manifest that validates today still validates and still builds the same components. A manifest that does not useForEachproduces a byte-identical component tree.Changes
airbyte_cdk/sources/declarative/transformations/for_each.py— the new component.airbyte_cdk/sources/declarative/transformations/__init__.py— exportForEach.airbyte_cdk/sources/declarative/declarative_component_schema.yaml—ForEachdefinition, plusForEachadded to all three transformationanyOfsites (found viagrep "#/definitions/RemoveFields").airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py—ForEachModel: self.create_for_eachpluscreate_for_each, which builds the nested transformations throughself._create_component_from_model, the same call path the stream-leveltransformationslist uses.airbyte_cdk/sources/declarative/models/declarative_component_schema.py— generated model (see note below).Generated models — hand-applied, and please do NOT run
/poe buildhereThe generated
declarative_component_schema.pyin this PR was updated by hand to match the schema, rather than by committing the localpoe assembleoutput — and unlike the first revision of this description, that is not because of a machine-specific quirk.main's committed generated file is itself divergent from the codegen pipeline checked into this repo. Regenerating from an unmodifiedorigin/mainYAML produces ~240 lines of drift, including anOAuthScope→Scope/OptionalScopepublic rename. So running/poe buildon this branch would import that unrelated drift into this PR, and would make it conflict with #1162. The drift is real and worth fixing — in a separate re-sync PR againstmain, not here.What was hand-applied is exactly what codegen produces for this schema change, and nothing else. It was reproduced against pinned codegen (
datamodel_code_generator==0.26.3inpython:3.10, the repo's exact flags and post-processing) and the added lines are byte-identical to it (the file is not, because codegen also relocatesJsonSchemaPropertySelectorfurther down — see the next paragraph):ForEachmodel class;ForEachadded to the three transformationUnions;ForEach.update_forward_refs(), in codegen's position.Correction to the first revision of this description: it claimed the class had to be moved because placing
ForEachat codegen's position would leaveJsonSchemaPropertySelector.transformationsan unresolvedForwardRef. That rationale was wrong. Codegen also emitsForEachbeforeJsonSchemaPropertySelector— it placesForEachwhere that class was and relocates it further down the file. There is noForwardRefhazard, and CI regeneration would not reintroduce the failure. There are zero unresolved forward refs at head onForEach,JsonSchemaPropertySelector,DeclarativeStreamandDynamicSchemaLoader.The self-referential
$refcycle (ForEachinside the sameanyOfit contains) is handled bydatamodel-codegenitself: the file hasfrom __future__ import annotationsand codegen emitsForEach.update_forward_refs(). No hand-invented workaround.Tests
unit_tests/sources/declarative/transformations/test_for_each.py(79 tests):column_valueswith a nestedAddFieldsand acondition(the test does not setvalue_type; the coercion caveat above is about the connector manifest, not the component);is), so an accidental copy fails the test;transform()returnsNone, matching the ABC;field_path;*wildcard iterating several matching collections and leaving non-matching siblings alone;null, string, number, empty list, sibling-only, and a missing nested path;nullelements are skipped; only-nulls is a no-op; a string / number / list element raisessystem_errornaming the path and leaves the record untouched;{{ config[...] }}and{{ stream_slice[...] }};field_pathitself is interpolated from config;RemoveFieldsinside the loop (different mutation path —dpath.delete, notdpath.new);KeysToLower) inside the loop;ForEachnested insideForEach;field_pathlist indices:-1,-2,-3and-0counted from the end,0and2from the start, each selecting exactly one element;-4,3,abc, and the numeric-but-not-decimal²and⅕("²".isdigit()isTruebutint("²")raises, soisdigit()would have produced an uncaughtValueErrorhere);dpath.values, over fourteen segments including+1," 1"and"1_0";**over nested collections transforms each object exactly once (identity de-duplication);dpath.values;[is a no-op, with thedpathhalf of the claim asserted alongside it;field_pathsegment raisessystem_errorat construction; a segment interpolating to an empty string, float, boolean,nullor list raisesconfig_error; a numeric config value is a valid list index rather than an error;dpath(asserted by patching the module), and a large unrelated sibling does not slow the lookup down.unit_tests/sources/declarative/parsers/test_model_to_component_factory.py(2 tests inTestCreateTransformations):test_for_each— full manifest round trip: schema validation, model registration,create_for_each, the nestedAddFields+RemoveFieldsbuilt recursively through the factory, and the resulting component actually transforming a record;test_for_each_nested_in_for_each— the recursive schema round trip.No new test is marked
slow,flaky,super_sloworlinting.Verification run locally
poetry run pytest unit_tests/sources/declarative/transformations -q— 174 passedpoetry run pytest unit_tests/sources/declarative/parsers -q— 201 passedpoetry run pytest unit_tests/sources/declarative -q -k "not memory_usage"— 1877 passed, 2 deselected (the two*_memory_usagetests crash this arm64 machine with SIGILL onmainas well; unrelated)poe type-check— Success, no issues in 459 source filespoe lint-fix/poetry run ruff format .— clean🤖 Generated with Claude Code