Skip to content

Commit 468d9e8

Browse files
fix: prevent record filters from prematurely stopping PageIncrement pagination (#1073)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent dd9558c commit 468d9e8

3 files changed

Lines changed: 79 additions & 2 deletions

File tree

airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3032,10 +3032,23 @@ def create_offset_increment(
30323032
parameters=model.parameters or {},
30333033
)
30343034

3035-
@staticmethod
30363035
def create_page_increment(
3037-
model: PageIncrementModel, config: Config, **kwargs: Any
3036+
self,
3037+
model: PageIncrementModel,
3038+
config: Config,
3039+
decoder: Optional[Decoder] = None,
3040+
extractor_model: Optional[Union[CustomRecordExtractorModel, DpathExtractorModel]] = None,
3041+
**kwargs: Any,
30383042
) -> PageIncrement:
3043+
# Like OffsetIncrement, we instantiate a separate extractor with identical behavior to the
3044+
# RecordSelector's so the strategy can count the raw records in the response. This ensures
3045+
# pagination is driven by the API's page size, not the post-filter record count.
3046+
extractor = (
3047+
self._create_component_from_model(model=extractor_model, config=config, decoder=decoder)
3048+
if extractor_model
3049+
else None
3050+
)
3051+
30393052
# Pydantic v1 Union type coercion can convert int to string depending on Union order.
30403053
# If page_size is a string that represents an integer (not an interpolation), convert it back.
30413054
page_size = model.page_size
@@ -3047,6 +3060,7 @@ def create_page_increment(
30473060
config=config,
30483061
start_from_page=model.start_from_page or 0,
30493062
inject_on_first_request=model.inject_on_first_request or False,
3063+
extractor=extractor,
30503064
parameters=model.parameters or {},
30513065
)
30523066

airbyte_cdk/sources/declarative/requesters/paginators/strategies/page_increment.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import requests
99

10+
from airbyte_cdk.sources.declarative.extractors.record_extractor import RecordExtractor
1011
from airbyte_cdk.sources.declarative.interpolation import InterpolatedString
1112
from airbyte_cdk.sources.declarative.requesters.paginators.strategies.pagination_strategy import (
1213
PaginationStrategy,
@@ -29,6 +30,7 @@ class PageIncrement(PaginationStrategy):
2930
parameters: InitVar[Mapping[str, Any]]
3031
start_from_page: int = 0
3132
inject_on_first_request: bool = False
33+
extractor: Optional[RecordExtractor] = None
3234

3335
def __post_init__(self, parameters: Mapping[str, Any]) -> None:
3436
if isinstance(self.page_size, int) or (self.page_size is None):
@@ -52,6 +54,12 @@ def next_page_token(
5254
last_record: Optional[Record],
5355
last_page_token_value: Optional[Any],
5456
) -> Optional[Any]:
57+
if self.extractor:
58+
# The record count is dependent on the records returned from the response which may not always
59+
# align with the size of pages emitted. For example, a record filter can reduce the number of
60+
# records observed below the page size even though the API returned a full page.
61+
last_page_size = len(list(self.extractor.extract_records(response=response)))
62+
5563
# Stop paginating when there are fewer records than the page size or the current page has no records
5664
if (self._page_size and last_page_size < self._page_size) or last_page_size == 0:
5765
return None

unit_tests/sources/declarative/requesters/paginators/test_page_increment.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,66 @@
88
import pytest
99
import requests
1010

11+
from airbyte_cdk.sources.declarative.extractors import DpathExtractor
1112
from airbyte_cdk.sources.declarative.requesters.paginators.strategies.page_increment import (
1213
PageIncrement,
1314
)
1415

1516

17+
@pytest.mark.parametrize(
18+
"response_results, last_page_size, last_page_token_value, expected_next_page_token",
19+
[
20+
pytest.param(
21+
[{"id": 1}, {"id": 2}],
22+
0,
23+
3,
24+
4,
25+
id="test_full_page_continues_even_if_all_records_filtered",
26+
),
27+
pytest.param(
28+
[{"id": 1}, {"id": 2}],
29+
1,
30+
3,
31+
4,
32+
id="test_full_page_continues_even_if_some_records_filtered",
33+
),
34+
pytest.param(
35+
[{"id": 1}],
36+
1,
37+
3,
38+
None,
39+
id="test_partial_page_stops_pagination",
40+
),
41+
pytest.param(
42+
[],
43+
0,
44+
3,
45+
None,
46+
id="test_empty_page_stops_pagination",
47+
),
48+
],
49+
)
50+
def test_page_increment_paginator_strategy_with_extractor(
51+
response_results, last_page_size, last_page_token_value, expected_next_page_token
52+
):
53+
extractor = DpathExtractor(field_path=["results"], parameters={}, config={})
54+
paginator_strategy = PageIncrement(
55+
page_size=2, parameters={}, start_from_page=1, extractor=extractor, config={}
56+
)
57+
58+
response = requests.Response()
59+
response.headers = {"A_HEADER": "HEADER_VALUE"}
60+
response._content = json.dumps({"results": response_results}).encode("utf-8")
61+
62+
next_page_token = paginator_strategy.next_page_token(
63+
response,
64+
last_page_size,
65+
response_results[-1] if response_results else None,
66+
last_page_token_value,
67+
)
68+
assert expected_next_page_token == next_page_token
69+
70+
1671
@pytest.mark.parametrize(
1772
"page_size, start_from, last_page_size, last_record, last_page_token_value, expected_next_page_token, expected_offset",
1873
[

0 commit comments

Comments
 (0)