Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
56c4f81
implement batch API:
Feb 25, 2026
3034fcb
refactor: rename response body attribute from 'body' to 'data' across…
Feb 25, 2026
7ce015e
Merge origin/main into batch branch: bring in files namespace and mod…
Feb 27, 2026
fa16258
feat: implement batch upsert operation and related methods
Feb 27, 2026
fff4185
Merge remote-tracking branch 'origin/main' into users/sagebree/batch
Feb 27, 2026
d9d4bc8
test: Fix to _upsert_multiple: alternate key fields no longer merged …
Feb 27, 2026
d24eee2
feat: add comprehensive batch operations testing and update documenta…
Feb 27, 2026
181f974
style: format print statements for better readability and consistency
Feb 27, 2026
2a831a3
feat: enhance error handling in batch operations and improve related …
Feb 27, 2026
b8ef1f4
Merge remote-tracking branch 'origin/main' into users/sagebree/batch
Feb 27, 2026
b19da41
feat: add SQL encoding verification test for batch operations
Feb 28, 2026
f43577d
feat: implement shared Content-ID counter for batch changesets and en…
Feb 28, 2026
63bdd67
feat: implement pagination handling in SQL queries
Apr 8, 2026
ba3352a
Merge origin/main into users/sagebree/issue157_fix_sql_truncation
Apr 9, 2026
3282b69
fix: remove unnecessary blank line in README.md
Apr 9, 2026
d637725
feat: enhance SQL pagination handling with warnings for infinite loop…
Apr 9, 2026
d28c960
Merge remote-tracking branch 'origin/main' into users/sagebree/issue1…
Apr 9, 2026
8f255f8
Merge branch 'main' into users/sagebree/issue157_fix_sql_truncation
sagebree Apr 10, 2026
af444d5
test: add tests for _extract_pagingcookie and handle pagination excep…
Apr 10, 2026
33e6c54
Merge branch 'users/sagebree/issue157_fix_sql_truncation' of https://…
Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- `client.query.sql()` silently truncated results at 5,000 rows. The method now follows `@odata.nextLink` pagination and returns all matching rows (#157).

Comment thread
sagebree marked this conversation as resolved.
## [0.1.0b7] - 2026-03-17

### Added
Expand Down
107 changes: 99 additions & 8 deletions src/PowerPlatform/Dataverse/data/_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
import re
import json
import uuid
import warnings
from datetime import datetime, timezone
import importlib.resources as ir
from contextlib import contextmanager
from contextvars import ContextVar

from urllib.parse import quote as _url_quote
from urllib.parse import quote as _url_quote, parse_qs, urlparse

from ..core._http import _HttpClient
from ._upload import _FileUploadMixin
Expand Down Expand Up @@ -54,6 +55,34 @@
_DEFAULT_EXPECTED_STATUSES: tuple[int, ...] = (200, 201, 202, 204)


def _extract_pagingcookie(next_link: str) -> Optional[str]:
"""Extract the raw pagingcookie value from a SQL ``@odata.nextLink`` URL.

The Dataverse SQL endpoint has a server-side bug where the pagingcookie
(containing first/last record GUIDs) does not advance between pages even
though ``pagenumber`` increments. Detecting a repeated cookie lets the
pagination loop break instead of looping indefinitely.

Returns the pagingcookie string if present, or ``None`` if not found.
"""
try:
qs = parse_qs(urlparse(next_link).query)
skiptoken = qs.get("$skiptoken", [None])[0]
if not skiptoken:
return None
# parse_qs already URL-decodes the value once, giving the outer XML with
# pagingcookie still percent-encoded (e.g. pagingcookie="%3ccookie...").
# A second decode is intentionally omitted: decoding again would turn %22
# into " inside the cookie XML, breaking the regex and causing every page
# to extract the same truncated prefix regardless of the actual GUIDs.
m = re.search(r'pagingcookie="([^"]+)"', skiptoken)
if m:
return m.group(1)
except Exception:
pass
return None


@dataclass
class _RequestContext:
"""Structured request context used by ``_request`` to clarify payload and metadata."""
Expand Down Expand Up @@ -776,15 +805,77 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]:
body = r.json()
except ValueError:
return []
if isinstance(body, dict):
value = body.get("value")
if isinstance(value, list):
# Ensure dict rows only
return [row for row in value if isinstance(row, dict)]
# Fallbacks: if body itself is a list

# Collect first page
results: list[dict[str, Any]] = []
if isinstance(body, list):
return [row for row in body if isinstance(row, dict)]
return []
if not isinstance(body, dict):
return results

value = body.get("value")
if isinstance(value, list):
results = [row for row in value if isinstance(row, dict)]

# Follow pagination links until exhausted
raw_link = body.get("@odata.nextLink") or body.get("odata.nextLink")
next_link: str | None = raw_link if isinstance(raw_link, str) else None
visited: set[str] = set()
seen_cookies: set[str] = set()
while next_link:
# Guard 1: exact URL cycle (same next_link returned twice)
if next_link in visited:
break
visited.add(next_link)
Comment thread
sagebree marked this conversation as resolved.
# Guard 2: server-side bug where pagingcookie does not advance between
# pages (pagenumber increments but cookie GUIDs stay the same), which
# causes an infinite loop even though URLs differ.
cookie = _extract_pagingcookie(next_link)
if cookie is not None:
if cookie in seen_cookies:
warnings.warn(
f"SQL pagination stopped after {len(results)} rows — "
"the Dataverse server returned the same pagingcookie twice "
"(pagenumber incremented but the paging position did not advance). "
"This is a server-side bug. Returning the rows collected so far. "
"To avoid pagination entirely, add a TOP clause to your query.",
RuntimeWarning,
stacklevel=4,
)
break
seen_cookies.add(cookie)
try:
page_resp = self._request("get", next_link)
except Exception as exc:
warnings.warn(
f"SQL pagination stopped after {len(results)} rows — "
f"the next-page request failed: {exc}. "
"Add a TOP clause to your query to limit results to a single page.",
RuntimeWarning,
stacklevel=5,
)
break
try:
page_body = page_resp.json()
except ValueError as exc:
warnings.warn(
f"SQL pagination stopped after {len(results)} rows — "
f"the next-page response was not valid JSON: {exc}. "
"Add a TOP clause to your query to limit results to a single page.",
RuntimeWarning,
stacklevel=5,
)
break
if not isinstance(page_body, dict):
break
page_value = page_body.get("value")
if not isinstance(page_value, list) or not page_value:
break
results.extend(row for row in page_value if isinstance(row, dict))
raw_link = page_body.get("@odata.nextLink") or page_body.get("odata.nextLink")
next_link = raw_link if isinstance(raw_link, str) else None

return results

@staticmethod
def _extract_logical_table(sql: str) -> str:
Expand Down
Loading
Loading