Skip to content

feat: support nested replication keys via dotted path#3690

Open
daria-hrabar wants to merge 19 commits into
meltano:mainfrom
daria-hrabar:fix-issue-1198
Open

feat: support nested replication keys via dotted path#3690
daria-hrabar wants to merge 19 commits into
meltano:mainfrom
daria-hrabar:fix-issue-1198

Conversation

@daria-hrabar

@daria-hrabar daria-hrabar commented Jun 30, 2026

Copy link
Copy Markdown

What does this PR do?

Adds support for nested replication keys in the Singer SDK by introducing a get_nested_value() helper in singer_sdk/helpers/_util.py that traverses a record dictionary level by level using a dotted key path (e.g., "attributes.updated"). The _increment_stream_state() method and is_timestamp_replication_key property in singer_sdk/streams/core.py are updated to use this traversal instead of a flat dictionary lookup, enabling tap developers to point replication keys at timestamp fields nested inside sub-objects rather than only at top-level fields.

Why was this PR needed?

Many real-world APIs — including HubSpot, Salesforce, and GitHub — return timestamps nested inside sub-objects. Before this fix, setting replication_key = "attributes.updated" caused the SDK to perform a flat lookup (record.get("attributes.updated")), which returned None because no top-level key with that literal name exists. As a result, the state was never updated, and the tap silently re-synced all records on every run.

Investigation traced the root cause to two locations in singer_sdk/streams/core.py:

  • _increment_stream_state() passed the raw record directly to increment_state(), which performed a flat key lookup returning None for dotted paths
  • is_timestamp_replication_key called schema.get("properties", {}).get(replication_key) which only checked the schema's top level, incorrectly raising InvalidReplicationKeyException even when the field existed at a deeper level

The fix keeps singer_sdk/streams/_state.py unchanged by flattening the extracted value into a flat record before passing it to the state manager, limiting the scope of the change and reducing regression risk.

What are the relevant issue numbers?

Closes #1198

Does this PR meet the acceptance criteria?

  • Tests added for new/changed behavior
  • All tests passing
  • Follows project style guide
  • No breaking changes introduced

Summary by Sourcery

Support incremental replication using dotted-path replication keys that point to nested timestamp fields, updating schema validation, state management, and tests to reliably handle nested structures.

New Features:

  • Support nested replication keys for stream replication using dotted key paths.
  • Add a get_nested_value helper for resolving values from nested record dictionaries via dotted paths.

Bug Fixes:

  • Ensure timestamp replication key validation correctly traverses nested schema properties instead of only top-level fields.
  • Fix state increment logic so replication keys referencing nested fields update bookmarks rather than always returning None.

Enhancements:

  • Improve type hints and docstrings in stream tests and core stream methods for clarity.

Tests:

  • Add unit tests covering nested replication key resolution, edge cases, and invalid schema configurations.
  • Introduce reproduction and verification scripts for Issue feat: Support nested replication keys #1198 to demonstrate the previous bug and the applied fix.

Add a get_nested_value() utility function to singer_sdk/helpers/_util.py that resolves dotted key paths (e.g. 'attributes.updated') against a nested dictionary by traversing each level in sequence.
…ation_key

Update the is_timestamp_replication_key property in core.py to traverse
>> nested schema properties using a dotted key path instead of performing a single-level dictionary lookup.
…ream_state

Update _increment_stream_state() in core.py to extract nested replication key values before passing the record to the state manager.
…key fix

Add verifying_fix_1198.py to confirm that the fix for issue meltano#1198 works correctly. The script uses get_nested_value() directly to traverse a dotted replication key path against fake records with nested timestamps.
@sourcery-ai

sourcery-ai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds nested replication key support using dotted paths by introducing a generic nested-dict accessor, wiring it into stream state incrementing and replication key type detection, and covering the behavior with tests plus example scripts that reproduce and verify Issue #1198.

Sequence diagram for nested replication key state update

sequenceDiagram
    participant Stream
    participant get_nested_value
    participant StateManager

    Stream->>Stream: _increment_stream_state(latest_record, context)
    alt replication_method == REPLICATION_INCREMENTAL and '.' in replication_key
        Stream->>get_nested_value: get_nested_value(latest_record, replication_key)
        get_nested_value-->>Stream: value
        Stream->>StateManager: increment_state({replication_key: value}, context, replication_key)
    else replication_method == REPLICATION_INCREMENTAL and no '.' in replication_key
        Stream->>StateManager: increment_state(latest_record, context, replication_key)
    end
Loading

File-Level Changes

Change Details Files
Introduce a helper to resolve dotted-path keys against nested record dictionaries.
  • Add get_nested_value(record, dotted_key) utility that walks a dict along a dot‑separated key path and returns None if any level is missing or non‑dict.
  • Document behavior and examples in the helper docstring, including early returns for invalid intermediate values.
singer_sdk/helpers/_util.py
Use nested key traversal when detecting timestamp replication keys in schemas.
  • Import get_nested_value in streams core module (for consistency across helpers).
  • Change is_timestamp_replication_key to split replication_key on dots and iteratively descend through nested JSON Schema properties, falling back to error when any level is missing or not an object.
  • Improve error message formatting and docstrings to use literal markup for constants in the stream class.
singer_sdk/streams/core.py
Use nested key traversal when incrementing stream state so dotted replication keys advance bookmarks correctly.
  • Update _increment_stream_state to detect dotted replication_key, resolve its value via get_nested_value, and construct a flat record with the replication_key as a single field for increment_state.
  • Preserve existing behavior for flat replication keys and keep state_manager interface unchanged to minimize regression risk.
  • Tighten docstring wording for _increment_stream_state regarding exceptions and replication method constants.
singer_sdk/streams/core.py
Add tests that validate nested key resolution utility and nested replication-key schema validation, plus minor type-hint cleanups.
  • Import get_nested_value in tests and add multiple fixtures/records (Salesforce, HubSpot, GitHub, deep nested) exercising flat, two-level, and three-level nested paths and various failure modes (missing keys, non-dict intermediates, None values).
  • Add test_invalid_nested_replication_key_raises to assert InvalidReplicationKeyException when replication_key points to a nested path absent from the schema.
  • Refine existing tests with explicit type annotations for fixtures and parameters (e.g., stream(tap: SimpleTestTap), caplog: LogCaptureFixture, tap_class typing for stream class selection).
tests/core/test_streams.py
Provide runnable scripts to reproduce the original nested replication key bug and to demonstrate the fix.
  • Add reproducing_issue_1198.py script that defines a minimal Tap/Stream with replication_key='attributes.updated' and shows that the pre-fix SDK resolves the key via a flat dict lookup to None, confirming the bug.
  • Add verifying_fix_1198.py script that uses the new get_nested_value helper with the same nested replication key, showing correct resolution of the nested timestamp and serving as a manual verification aid.
reproducing_issue_1198.py
verifying_fix_1198.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1198 Allow streams to use nested replication keys (e.g., 'attributes.updated') so that state/bookmarks are correctly advanced for nested timestamp fields.
#1198 Ensure replication key type detection and validation (including InvalidReplicationKeyException) work with nested keys by traversing the JSON Schema structure rather than only top-level properties.
#1198 Add tests and helper utilities to robustly support and verify nested replication key behavior in taps/streams.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The standalone scripts reproducing_issue_1198.py and verifying_fix_1198.py look like ad‑hoc debugging helpers; consider moving them under an examples/tests directory or omitting them from the library package to avoid shipping them as top-level modules.
  • The nested schema traversal in is_timestamp_replication_key assumes each intermediate key corresponds to an object with properties; if your JSON Schemas can use patterns like additionalProperties or arrays of objects, it may be worth hardening this traversal or clearly constraining which schema shapes are supported for nested replication keys.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The standalone scripts `reproducing_issue_1198.py` and `verifying_fix_1198.py` look like ad‑hoc debugging helpers; consider moving them under an examples/tests directory or omitting them from the library package to avoid shipping them as top-level modules.
- The nested schema traversal in `is_timestamp_replication_key` assumes each intermediate key corresponds to an object with `properties`; if your JSON Schemas can use patterns like `additionalProperties` or arrays of objects, it may be worth hardening this traversal or clearly constraining which schema shapes are supported for nested replication keys.

## Individual Comments

### Comment 1
<location path="singer_sdk/helpers/_util.py" line_range="78-87" />
<code_context>
     return datetime.datetime.now(datetime.timezone.utc)
+
+
+def get_nested_value(record: dict, dotted_key: str) -> t.Any | None:  # noqa: ANN401
+    """Retrieve a value from a nested dictionary using a dotted key path.
+
+    Args:
+        record: The record dictionary to traverse.
+        dotted_key: A dot-separated key path (e.g. ``"attributes.updated"``).
+
+    Returns:
+        The value at the nested path, or ``None`` if any level is missing.
+
+    Examples:
+        >>> get_nested_value({"a": {"b": 1}}, "a.b")
+        1
+        >>> get_nested_value({"a": 1}, "a.b")
+        None
+    """
+    keys = dotted_key.split(".")
+    current: t.Any = record
+    for key in keys:
+        if not isinstance(current, dict):
+            return None
+        current = current.get(key)
+        if current is None:
+            return None
+    return current
</code_context>
<issue_to_address>
**suggestion:** Broaden `get_nested_value` input type and consider clearer semantics for `None` values.

Right now this helper only accepts `record: dict`, but many SDK records are `Mapping[str, Any]`. Widening the type to `t.Mapping[str, t.Any]` (or `MutableMapping`) would make it usable in more places.

Also, the function returns `None` both when the path is missing and when the leaf value is explicitly `None`, which differs from the docstring’s “None if any level is missing”. If callers need to distinguish these cases, consider a dedicated sentinel return value or an optional flag to control the behavior.

Suggested implementation:

```python
def get_nested_value(
    record: t.Mapping[str, t.Any],
    dotted_key: str,
    *,
    missing: object | None = None,
) -> t.Any | None:  # noqa: ANN401
    """Retrieve a value from a nested mapping using a dotted key path.

    Args:
        record: The record mapping to traverse.
        dotted_key: A dot-separated key path (e.g. ``"attributes.updated"``).
        missing: Sentinel value to return when any level in the path is missing.
            Defaults to ``None`` for backward-compatible behavior.

    Returns:
        The value at the nested path if it exists. If any level in the path is
        missing, returns ``missing`` instead. This allows callers to distinguish
        between an explicitly stored ``None`` value and a missing path by passing
        a dedicated sentinel (e.g. ``missing=object()``).

    Examples:
        >>> get_nested_value({"a": {"b": 1}}, "a.b")
        1
        >>> get_nested_value({"a": 1}, "a.b")
        None
        >>> sentinel = object()
        >>> get_nested_value({"a": 1}, "a.b", missing=sentinel) is sentinel
        True
        >>> get_nested_value({"a": {"b": None}}, "a.b", missing=sentinel) is None
        True
    """
    keys = dotted_key.split(".")
    current: t.Any = record
    for key in keys:
        # Stop if the current value is not a mapping.
        if not isinstance(current, Mapping):
            return missing
        # Distinguish between a missing key and a present key with value None.
        if key not in current:
            return missing
        current = current[key]
    return current

```

1. Ensure `Mapping` is imported from `collections.abc` at the top of the module, e.g.:
   `from collections.abc import Mapping`.
2. If the project uses strict type checking, you may want to refine the return type to
   reflect that it can be either the stored value or the sentinel type (e.g. using
   `t.Any` for simplicity or a type variable constrained to `object` for `missing`).
3. Call sites that need to distinguish between missing paths and explicit `None` values
   should be updated to pass a dedicated sentinel object to the `missing` parameter and
   compare against it (e.g. `sentinel = object(); value = get_nested_value(..., missing=sentinel)`).
</issue_to_address>

### Comment 2
<location path="tests/core/test_streams.py" line_range="888-891" />
<code_context>
+}
+
+
+def test_flat_replication_key_unchanged() -> None:
+    """Flat keys must behave exactly as before — no regression."""
+    result = get_nested_value(GITHUB_RECORD, "updated_at")
+    assert result == "2024-06-15T14:22:10Z"
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Extend tests to cover stream state increment behavior for flat vs nested replication keys

These tests cover `get_nested_value`, but they don’t verify `_increment_stream_state`, which flattens nested values before calling the state manager. Please add tests that:

1. For a stream with a flat `replication_key` (e.g. `updated_at`), assert `_increment_stream_state` passes the original record unchanged to `state_manager.increment_state`.
2. For a stream with a nested `replication_key` (e.g. `attributes.updated`), assert `_increment_stream_state` passes a flattened record like `{"attributes.updated": <timestamp>}` to `state_manager.increment_state`.

A fake/stub `state_manager` or inspection of the resulting state is sufficient to validate this integration without tightly coupling to the state implementation.

Suggested implementation:

```python
CONFIG_START_DATE = "2021-01-01"


class _FakeStateManager:
    """Simple state manager stub that records increment_state calls."""

    def __init__(self) -> None:
        self.calls: t.List[t.Tuple[dict, dict]] = []

    def increment_state(
        self,
        stream_name: str,
        partition_state: dict,
        replication_key_value: dict,
    ) -> None:
        # Store the arguments so tests can assert on them.
        self.calls.append(
            (
                {
                    "stream_name": stream_name,
                    "partition_state": partition_state,
                },
                replication_key_value,
            )
        )


def test_increment_stream_state_flat_replication_key(
    stream_with_flat_replication_key: "Stream",
) -> None:
    """For flat replication keys, the original record must be passed unchanged."""
    state_manager = _FakeStateManager()
    record = GITHUB_RECORD  # uses flat `updated_at` key

    stream_with_flat_replication_key._increment_stream_state(
        state_manager=state_manager,
        context=None,
        record=record,
    )

    assert len(state_manager.calls) == 1
    call_metadata, replication_key_value = state_manager.calls[0]

    # The stream metadata is not the focus here; we only care about the record
    # passed to increment_state for the replication key.
    assert replication_key_value == {"updated_at": record["updated_at"]}


def test_increment_stream_state_nested_replication_key(
    stream_with_nested_replication_key: "Stream",
) -> None:
    """For nested replication keys, a flattened record must be passed."""
    state_manager = _FakeStateManager()
    record = DEEP_NESTED_RECORD

    stream_with_nested_replication_key._increment_stream_state(
        state_manager=state_manager,
        context=None,
        record=record,
    )

    assert len(state_manager.calls) == 1
    call_metadata, replication_key_value = state_manager.calls[0]

    # Nested value must be flattened before passing to the state manager.
    expected_value = get_nested_value(record, "meta.audit.last_modified")
    assert replication_key_value == {"meta.audit.last_modified": expected_value}

```

1. Ensure `t` (typing) is imported at the top of `tests/core/test_streams.py` (e.g. `import typing as t`) if not already present.
2. Provide fixtures `stream_with_flat_replication_key` and `stream_with_nested_replication_key` in this test module or a shared `conftest.py`. They should:
   - Construct concrete `Stream` instances (or test doubles) whose `replication_key` is `"updated_at"` for the flat case and `"meta.audit.last_modified"` (or your actual nested key string) for the nested case.
   - Be wired so that `_increment_stream_state` uses the provided `state_manager`’s `increment_state` method. Adapt the argument names and count in `_FakeStateManager.increment_state` to exactly match what `_increment_stream_state` calls in your codebase.
3. If `_increment_stream_state` has a different signature (e.g. additional parameters like `partition` or `bookmark`), update the test calls accordingly and capture those extra arguments in `_FakeStateManager.increment_state` as needed, keeping the assertions focused on the shape of `replication_key_value`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread singer_sdk/helpers/_util.py Outdated
Comment on lines +888 to +891
def test_flat_replication_key_unchanged() -> None:
"""Flat keys must behave exactly as before — no regression."""
result = get_nested_value(GITHUB_RECORD, "updated_at")
assert result == "2024-06-15T14:22:10Z"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Extend tests to cover stream state increment behavior for flat vs nested replication keys

These tests cover get_nested_value, but they don’t verify _increment_stream_state, which flattens nested values before calling the state manager. Please add tests that:

  1. For a stream with a flat replication_key (e.g. updated_at), assert _increment_stream_state passes the original record unchanged to state_manager.increment_state.
  2. For a stream with a nested replication_key (e.g. attributes.updated), assert _increment_stream_state passes a flattened record like {"attributes.updated": <timestamp>} to state_manager.increment_state.

A fake/stub state_manager or inspection of the resulting state is sufficient to validate this integration without tightly coupling to the state implementation.

Suggested implementation:

CONFIG_START_DATE = "2021-01-01"


class _FakeStateManager:
    """Simple state manager stub that records increment_state calls."""

    def __init__(self) -> None:
        self.calls: t.List[t.Tuple[dict, dict]] = []

    def increment_state(
        self,
        stream_name: str,
        partition_state: dict,
        replication_key_value: dict,
    ) -> None:
        # Store the arguments so tests can assert on them.
        self.calls.append(
            (
                {
                    "stream_name": stream_name,
                    "partition_state": partition_state,
                },
                replication_key_value,
            )
        )


def test_increment_stream_state_flat_replication_key(
    stream_with_flat_replication_key: "Stream",
) -> None:
    """For flat replication keys, the original record must be passed unchanged."""
    state_manager = _FakeStateManager()
    record = GITHUB_RECORD  # uses flat `updated_at` key

    stream_with_flat_replication_key._increment_stream_state(
        state_manager=state_manager,
        context=None,
        record=record,
    )

    assert len(state_manager.calls) == 1
    call_metadata, replication_key_value = state_manager.calls[0]

    # The stream metadata is not the focus here; we only care about the record
    # passed to increment_state for the replication key.
    assert replication_key_value == {"updated_at": record["updated_at"]}


def test_increment_stream_state_nested_replication_key(
    stream_with_nested_replication_key: "Stream",
) -> None:
    """For nested replication keys, a flattened record must be passed."""
    state_manager = _FakeStateManager()
    record = DEEP_NESTED_RECORD

    stream_with_nested_replication_key._increment_stream_state(
        state_manager=state_manager,
        context=None,
        record=record,
    )

    assert len(state_manager.calls) == 1
    call_metadata, replication_key_value = state_manager.calls[0]

    # Nested value must be flattened before passing to the state manager.
    expected_value = get_nested_value(record, "meta.audit.last_modified")
    assert replication_key_value == {"meta.audit.last_modified": expected_value}
  1. Ensure t (typing) is imported at the top of tests/core/test_streams.py (e.g. import typing as t) if not already present.
  2. Provide fixtures stream_with_flat_replication_key and stream_with_nested_replication_key in this test module or a shared conftest.py. They should:
    • Construct concrete Stream instances (or test doubles) whose replication_key is "updated_at" for the flat case and "meta.audit.last_modified" (or your actual nested key string) for the nested case.
    • Be wired so that _increment_stream_state uses the provided state_manager’s increment_state method. Adapt the argument names and count in _FakeStateManager.increment_state to exactly match what _increment_stream_state calls in your codebase.
  3. If _increment_stream_state has a different signature (e.g. additional parameters like partition or bookmark), update the test calls accordingly and capture those extra arguments in _FakeStateManager.increment_state as needed, keeping the assertions focused on the shape of replication_key_value.

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.23%. Comparing base (dd1d816) to head (de62857).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3690      +/-   ##
==========================================
+ Coverage   94.21%   94.23%   +0.02%     
==========================================
  Files          73       73              
  Lines        6203     6227      +24     
  Branches      761      768       +7     
==========================================
+ Hits         5844     5868      +24     
  Misses        267      267              
  Partials       92       92              
Flag Coverage Δ
core 83.10% <100.00%> (+0.06%) ⬆️
end-to-end 75.89% <30.76%> (-0.19%) ⬇️
optional-components 44.75% <11.53%> (-0.13%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@read-the-docs-community

read-the-docs-community Bot commented Jun 30, 2026

Copy link
Copy Markdown

Documentation build overview

📚 Meltano SDK | 🛠️ Build #33488097 | 📁 Comparing de62857 against latest (24237d2)

  🔍 Preview build  

2 files changed
± stream_maps.html
± classes/singer_sdk.Stream.html

@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 14 untouched benchmarks


Comparing daria-hrabar:fix-issue-1198 (de62857) with main (dd1d816)

Open in CodSpeed

@daria-hrabar daria-hrabar changed the title feat(streams): support nested replication keys via dotted path feat: support nested replication keys via dotted path Jul 8, 2026
@daria-hrabar

Copy link
Copy Markdown
Author

@edgarrmondragon I've addressed the Sourcery bot feedback, improved test coverage, and updated the PR title to follow the semantic PR conventions. Would appreciate your review and feedback when you have a chance.

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.

feat: Support nested replication keys

1 participant