Skip to content

feat: add NestedRecordExtractor for extracting nested collections with parent context - #1164

Closed
Anatolii Yatsuk (tolik0) wants to merge 2 commits into
mainfrom
tolik0/cdk/nested-record-extractor
Closed

Anatolii Yatsuk (tolik0) wants to merge 2 commits into
mainfrom
tolik0/cdk/nested-record-extractor

Conversation

@tolik0

Copy link
Copy Markdown
Contributor

What

Adds a new declarative component, NestedRecordExtractor, which yields the records of a collection nested inside another record while carrying fields down from the record that contains it.

type: NestedRecordExtractor
parent_extractor:
  type: DpathExtractor
  field_path: ["data", "repository", "pullRequests", "nodes"]
child_field_path: ["reviews", "nodes"]
parent_fields:
  - parent_path: ["url"]
    record_path: ["pull_request_url"]

For every record the parent_extractor yields, child_field_path is read out of that record and each element is emitted, with each parent_fields entry copied from the parent onto the element first. Both paths are lists, so a value nested inside the parent can be copied into a nested position on the child; intermediate objects on record_path are created as needed. Paths are interpolated against config and $parameters.

Changes:

  • airbyte_cdk/sources/declarative/extractors/nested_record_extractor.py — new, with NestedRecordExtractor and the ParentFieldPath copy instruction.
  • Exported from airbyte_cdk.sources.declarative.extractors and from the top-level airbyte_cdk package, following DpathExtractor.
  • declarative_component_schema.yaml — the NestedRecordExtractor and ParentFieldPath definitions, plus NestedRecordExtractor added to the RecordSelector.extractor anyOf.
  • model_to_component_factory.pycreate_nested_record_extractor, create_parent_field_path, and the PYDANTIC_MODEL_TO_CONSTRUCTOR entry.
  • Tests: a new unit_tests/sources/declarative/extractors/test_nested_record_extractor.py (34 tests) and four factory tests.

Why

A DpathExtractor pointed at a child collection has already discarded the node that held it, so a record that needs a field from its parent cannot be produced declaratively at all. Connectors work around this with a custom components.py, which makes the connector ineligible for the Connector Builder and for manifest-only packaging.

Two verified instances, both on the unmerged branch tolik0/source-github/graphql-streams in airbytehq/airbyte, file airbyte-integrations/connectors/source-github/source_github/components.py:

  1. NestedGraphQLRecordExtractor, lines 291–337. Walks data.repository.<list_connection>.nodes[*] — or, for the drill-down document, data.repository.<drilldown_field> — descends into each parent's <child_connection>.nodes, and copies parent fields onto every child. reviews.pull_request_url comes from the pull request's url.
  2. DeepNestedGraphQLRecordExtractor, lines 490–543. The same idea one level deeper: reactions are stamped with comment_id taken from the comment they hang off.

Both shapes are reproduced end to end in the tests against realistic response bodies — the reviews listing document and drill-down document, and pull_request_comment_reactions from the repository-listing root and from each of the three node roots.

What this does and does not buy

It removes those two extractor classes from source-github. It does not make source-github manifest-only or Builder-eligible: the two GraphQL pagination strategies and ReleasesRecordTransformation stay custom, so the connector keeps its components.py. A previous PR in this area claimed source-github as a motivating connector in a stronger sense than was true and had to retract that during review; this is the narrow, accurate version of the claim.

Decisions

A parent_path that is absent on the parent copies None

Matches the source-github code, which does parent.get(source). Skipping the field is equally defensible, but copying None keeps the record shape stable across the stream instead of making a field's presence depend on the parent — which matters downstream, where a missing column and a null column are not the same thing to destination normalization. Tests: test_missing_parent_path_copies_none, test_missing_nested_parent_path_copies_none.

An existing key on the child is overwritten

Also matches source-github. The parent is the authoritative source for the fields the manifest explicitly names, and silently keeping the child's value would hide a misconfigured record_path. Test: test_existing_key_on_child_is_overwritten.

parent_extractor may itself be a NestedRecordExtractor

Allowed. This is the widening beyond DpathExtractor / CustomRecordExtractor, and the reason is concrete: it is the only way to copy fields from two different ancestor levels onto one record. A DpathExtractor with * wildcards can flatten pullRequests.nodes.*.reviews.nodes.*.comments.nodes.*, but flattening discards every intermediate node, so the flattened form can never stamp both pull_request_url and review_id. Nesting costs nothing to support — a NestedRecordExtractor already satisfies RecordExtractor and stays lazy — so this is a schema line, not code. Tests: test_nested_parent_extractor (a comment carrying both review_id and the grandparent's pull_request_id), test_create_nested_record_extractor_nested_in_itself.

The AsyncRetriever and FileUploader extractor unions are deliberately left alone; there is no demonstrated need and each one widens the contract.

Config-time rejection

child_field_path must be non-empty. An empty path would make the child the parent itself — a self-stamping identity extractor, which is never what was meant. Enforced twice: minItems: 1 in the schema, which rejects it during manifest validation before any record is read (test_create_nested_record_extractor_rejects_empty_child_field_path), and a guard in __post_init__ for anyone constructing the component directly (test_empty_child_field_path_is_rejected). ParentFieldPath rejects an empty parent_path or record_path the same way.

Other documented behaviour, each pinned by a test

  • Child records are mutated in place, not copied. The dicts come out of the body this extractor just decoded and nothing else reads them, so a copy would only cost memory. Said so in the docstring and pinned by test_children_are_mutated_in_place so nobody "fixes" it into a copy without noticing they changed the contract.
  • A parent that is a single object works, because DpathExtractor already wraps one into a single-element list. The source-github drill-down document depends on this, and the same child_field_path therefore serves both documents.
  • A child_field_path that is missing, null, or resolves to an empty collection yields nothing rather than raising, matching how DpathExtractor treats a path that does not resolve. An empty object yields nothing for the same reason.
  • child_field_path does not support the * wildcard. Flattening across several collections belongs in the parent_extractor; the last hop has to stay a single collection for its parentage to be well defined — and dpath.values would build a list, breaking laziness.
  • Parent records and child elements that are not objects are skipped. A scalar cannot carry the parent fields, and emitting it would produce a record that silently lacks them.

Laziness and concurrency

Nothing is materialised: _children yields straight out of the list dpath.get returns, and extract_records is a generator throughout. test_is_lazy_and_does_not_materialise_children uses a counting stub parent extractor and asserts that exactly one parent has been pulled after the first record is produced. The response is read exactly once, by the innermost extractor — test_response_is_read_once pins that. CombinedExtractor (#1162) documents why re-reading matters: a streaming decoder is drained by the first reader and the loss is silent. There is only one sub-extractor here, so that is a constraint on this code, not a guard it needs.

The component holds no mutable state — the only per-read values are locals in extract_records — because one instance is shared by every partition of a stream and partitions are read concurrently. test_no_mutable_state_on_the_component interleaves two reads from one instance and asserts they do not see each other.

One test pinning a specific risk

test_typename_dispatch_equivalence_is_query_dependent. When the four reaction roots are expressed as separate paths, the __typename dispatch in DeepNestedGraphQLRecordExtractor looks redundant, because a PullRequestReview has no reviews field. That equivalence holds only because the drill-down documents do not select PullRequest.comments, which is a real GraphQL connection. The test builds a response where a PullRequest node carries both reviews and comments and asserts the purely structural, path-based behaviour, so a future reader knows the equivalence is query-dependent rather than structural, and that selecting PullRequest.comments means adding a root.

Generated model

declarative_component_schema.py is generated, but the output of poe assemble is not committed here. The pinned datamodel-code-generator==0.26.3 invocation in bin/generate_component_manifest_files.py omits --field-constraints, so every numeric minimum/exclusiveMinimum in the YAML is emitted as conint(...) / confloat(...), which mypy rejects with Invalid type comment or annotation [valid-type]. The committed file is also behind the YAML in other ways, so a full regeneration produces a large unrelated diff plus a red MyPy Check made mostly of errors that are not from this PR.

So the ParentFieldPath and NestedRecordExtractor model classes, the widened RecordSelector.extractor union, and the NestedRecordExtractor.update_forward_refs() call were hand-written into the committed file in codegen style, then verified: poe assemble was run, ruff format applied to its output, and the two class blocks diffed against the regenerated ones. Both are byte-identical, the union line and the update_forward_refs() line each appear exactly once in both, and everything else the generator produced was discarded.

Breaking change: NON_BREAKING

Verified rather than asserted. What I checked:

  • Schema. The only change to an existing definition is adding a third member to the RecordSelector.extractor anyOf. A union that gains a member accepts strictly more manifests than before; no existing definition, required-field list, or property type changed. AsyncRetriever and FileUploader are untouched.
  • Generated models. The diff is additive apart from the same RecordSelector.extractor union. DpathExtractor remains the first member, so an existing DpathExtractor manifest still validates against it under pydantic v1's left-to-right union resolution, and a NestedRecordExtractor dict cannot be mistaken for it because type is a Literal.
  • Python API. Only new names are exported (NestedRecordExtractor, ParentFieldPath); nothing was removed, renamed, or re-typed.
  • No other registry needed updating. I grepped for every place that enumerates extractor types and the only hits outside the schema, models, and factory are the two __init__.py export lists and manifest_component_transformer.py's "RecordSelector.extractor": "DpathExtractor" default, which is unchanged — an omitted type on RecordSelector.extractor still defaults to DpathExtractor exactly as before. I deliberately did not add a NestedRecordExtractor.parent_extractor default type: the field accepts three types including itself, so an explicit type is the unambiguous thing to require.
  • Existing behaviour. poetry run pytest unit_tests/sources/declarative passes 1833 tests with no changes to any existing test, other than the four added factory tests and the import line they need.

#1162 (CombinedExtractor) has not merged to main, so NestedRecordExtractor was not added to the CombinedExtractor.extractors anyOf and nothing here depends on that branch.

Verification

poetry run pytest unit_tests/sources/declarative/extractors unit_tests/sources/declarative/parsers -q \
  --deselect unit_tests/sources/declarative/extractors/test_response_to_file_extractor.py::test_response_to_file_extractor_memory_usage
  -> 318 passed, 1 deselected

poetry run pytest unit_tests/sources/declarative -q   (same deselect, plus the decoder memory-usage module)
  -> 1833 passed, 2 deselected

poetry run mypy airbyte_cdk       -> Success: no issues found in 459 source files
poetry run ruff check airbyte_cdk -> All checks passed!
poetry run ruff format <files touched>

🤖 Generated with Claude Code

…h parent context

A DpathExtractor that reaches a child collection has already discarded the
node that held it, so records needing a field from their parent cannot be
produced declaratively. NestedRecordExtractor keeps the parent in scope: for
every record its parent_extractor yields it reads child_field_path out of that
record, yields each element, and copies the configured parent_fields onto it
first.

parent_extractor accepts DpathExtractor, CustomRecordExtractor, or another
NestedRecordExtractor. Extraction stays lazy: nothing is materialised into a
list and the response is read exactly once, by the innermost extractor.

The generated model class was hand-applied rather than regenerated, and
verified byte-identical against the output of `poe assemble`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

Testing This CDK Version

You 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/nested-record-extractor#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/nested-record-extractor

PR Slash Commands

Airbyte Maintainers can execute the following slash commands on your PR:

  • /autofix - Fixes most formatting and linting issues
  • /poetry-lock - Updates poetry.lock file
  • /test - Runs connector tests with the updated CDK
  • /prerelease - Triggers a prerelease publish with default arguments
  • /poe build - Regenerate git-committed build artifacts, such as the pydantic models which are generated from the manifest JSON schema in YAML.
  • /poe <command> - Runs any poe command in the CDK environment
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

PyTest Results (Fast)

4 513 tests  +125   4 501 ✅ +125   9m 30s ⏱️ -8s
    1 suites ±  0      12 💤 ±  0 
    1 files   ±  0       0 ❌ ±  0 

Results for commit 9b20219. ± Comparison against base commit 96a7c0a.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

PyTest Results (Full)

4 516 tests  +125   4 504 ✅ +125   11m 17s ⏱️ - 2m 44s
    1 suites ±  0      12 💤 ±  0 
    1 files   ±  0       0 ❌ ±  0 

Results for commit 9b20219. ± Comparison against base commit 96a7c0a.

♻️ This comment has been updated with latest results.

Replace the `dpath` calls in the component with explicit path walks. That is
what resolves most of these findings: `dpath` reads globs out of literal
segments, refuses to overwrite a non-object intermediate, and turns a numeric
segment into a list, none of which this component wants.

* Read each `parent_field` once per parent instead of once per child. The read
  is invariant across the children, and because it walked a parent that
  contains the child collection the extractor was quadratic in the size of
  that collection: 100 children per parent cost 807 us/record, now 4.0.
* Copy mutable values per child. The parent's value was shared by reference,
  so a downstream `AddFields` writing into it rewrote records that had already
  been emitted — they are queued unserialized. Scalars are still shared.
* Replace a `record_path` intermediate that is present but is not an object,
  so the documented "a key already present on the child is overwritten" holds
  for a nested `record_path` too. It previously raised `PathNotFound` or
  `TypeError` mid-sync, surfaced as a `system_error` naming no manifest field.
* Reject the `*` wildcard, which the schema already documented as
  unsupported, at parse time and after interpolation. It used to resolve when
  a body had one match and raise when the next page had two. `?` and `[...]`
  now address the fields that spell them.
* Accept a `Mapping` that is not a `MutableMapping`. The `RecordExtractor`
  contract is `Mapping` and the schema admits a `CustomRecordExtractor` as a
  `parent_extractor`, so such a parent silently produced an empty stream.
* Count parents, not children, in `OffsetIncrement` and `PageIncrement`. The
  three `extractor_model` unions did not admit the new model, and the
  strategies advance by the number of records the response held, so the
  offset over-advanced and records were skipped.
* Reject a `parent_path` that addresses the child collection or an ancestor of
  it, and a segment that interpolated to nothing.
* Raise rather than copy `None` when a `parent_path` runs into a value holding
  no such field, which was indistinguishable from the documented absent-key
  case on a field used as a primary key or cursor. A null still copies `None`.
* Register `NestedRecordExtractor.parent_extractor` in
  `CUSTOM_COMPONENTS_MAPPING`, so a custom extractor declared with only a
  `class_name` keeps working when wrapped.
* Export `ParentFieldPath` and register `ParentFieldPathModel`; log every skip
  path; narrow the mutable-state claim in the docstring; scope the comment
  about not copying the child to the child dict.

Also document that `DpathExtractor` with a `record_expander` plus `AddFields`
already covers the single-level case, and the three shapes it cannot express,
which is why this component exists.

The schema changes are descriptions and `parent_fields` examples only. No
`pattern` is added: it would emit `constr(regex=...)`, the construct MyPy
rejects for the same reason as `conint`. The three edited blocks in the
generated models were re-checked byte-for-byte against pinned
`datamodel-code-generator==0.26.3` output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tolik0

Copy link
Copy Markdown
Contributor Author

Closing in favour of #1165.

While reviewing this PR together with #1149, #1162 and #1163 against the source-github migration, it turned out that main already has the component this PR proposes: DpathExtractor.record_expander (RecordExpander) expands a nested list into one record per element and, with remain_original_record: true, carries the parent onto each child. Neither this description nor the source-github PR mentioned it, which is how the same gap got proposed twice.

The check that settles it: both NestedGraphQLRecordExtractor and DeepNestedGraphQLRecordExtractor from the source-github branch were rebuilt out of DpathExtractor + RecordExpander + AddFields/RemoveFields, wrapped in the union mode of #1162 to cover the listing and drill-down documents, and run against the connector's seven fixture shapes (reviews listing, drill-down, null repository; reactions from the repository root and from PullRequest, Review and Comment node roots). All seven matched record-for-record, including the four-level pull_request_comment_reactions traversal.

What this PR genuinely added over RecordExpander was the named copy (parent_fields), which avoids deep-copying the whole parent once per child. That is now #1165, as an option on the existing component, and the same seven-shape check passes there without a RemoveFields step. Multi-level nesting, the other thing this PR could do, has two fleet instances (source-zendesk-talk IVR routes, source-hubspot associations) and is scoped as a follow-up on RecordExpander rather than a second component.

Shipping this as a parallel component would have left two ways to write "explode this list and stamp the parent onto each child" with no way for a Connector Builder user to choose between them, so it is closed rather than merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant