b1sl-python 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
b1sl/b1sl/__init__.py CHANGED
@@ -13,9 +13,20 @@ from b1sl.b1sl.client import B1Client
13
13
  from b1sl.b1sl.config import B1Config
14
14
  from b1sl.b1sl.config_manager import B1Environment
15
15
  from b1sl.b1sl.environment import B1Env
16
- from b1sl.b1sl.exceptions.exceptions import SAPConcurrencyError
16
+ from b1sl.b1sl.exceptions.exceptions import (
17
+ B1SqlNotAllowedError,
18
+ B1SqlParamError,
19
+ B1SqlSyntaxError,
20
+ SAPConcurrencyError,
21
+ )
17
22
  from b1sl.b1sl.resources.async_base import AsyncGenericResource
18
23
  from b1sl.b1sl.resources.base import GenericResource, ODataQuery
24
+ from b1sl.b1sl.resources.sql_queries import (
25
+ DEFAULT_ACCESSIBLE_TABLES,
26
+ KNOWN_INACCESSIBLE_PATTERNS,
27
+ SQLQueryInfo,
28
+ SQLRunResult,
29
+ )
19
30
  from b1sl.b1sl.rest_adapter import RestAdapter
20
31
 
21
32
  try:
@@ -42,9 +53,16 @@ __all__ = [
42
53
  "RestAdapter",
43
54
  "B1Env",
44
55
  "SAPConcurrencyError",
56
+ "B1SqlSyntaxError",
57
+ "B1SqlNotAllowedError",
58
+ "B1SqlParamError",
45
59
  "AsyncGenericResource",
46
60
  "GenericResource",
47
61
  "ODataQuery",
62
+ "DEFAULT_ACCESSIBLE_TABLES",
63
+ "KNOWN_INACCESSIBLE_PATTERNS",
64
+ "SQLQueryInfo",
65
+ "SQLRunResult",
48
66
  "entities",
49
67
  "fields",
50
68
  "HookContext",
b1sl/b1sl/async_client.py CHANGED
@@ -17,6 +17,11 @@ if TYPE_CHECKING:
17
17
  from b1sl.b1sl.models._generated.entities.inventory import Item
18
18
  from b1sl.b1sl.models.base import B1Model
19
19
  from b1sl.b1sl.resources.async_base import AsyncGenericResource
20
+ from b1sl.b1sl.resources.crossjoin import (
21
+ AsyncCrossJoinQueryBuilder,
22
+ AsyncQueryServiceBuilder,
23
+ )
24
+ from b1sl.b1sl.resources.sql_queries import AsyncSQLQueriesResource
20
25
  from b1sl.b1sl.resources.udo import AsyncUDOResource
21
26
 
22
27
 
@@ -351,6 +356,15 @@ class AsyncB1Client:
351
356
  from b1sl.b1sl.models._generated.entities.general import Document
352
357
  return self.get_resource(Document, "CorrectionPurchaseInvoiceReversal")
353
358
 
359
+ # --- SQL Queries ---
360
+
361
+ @property
362
+ def sql_queries(self) -> "AsyncSQLQueriesResource":
363
+ """Access the 'SQLQueries' endpoint (supports ETags, run() / run_stream())."""
364
+ from b1sl.b1sl.resources.sql_queries import AsyncSQLQueriesResource
365
+ resource = AsyncSQLQueriesResource(self._adapter)
366
+ return resource
367
+
354
368
  def udo(self, table_name: str) -> "AsyncUDOResource":
355
369
  """
356
370
  Asynchronously access a User Defined Object (UDO) or User Table.
@@ -360,4 +374,45 @@ class AsyncB1Client:
360
374
  from b1sl.b1sl.resources.udo import AsyncUDOResource
361
375
  return AsyncUDOResource(adapter=self._adapter, table_name=table_name)
362
376
 
377
+ def crossjoin(self, *entities: str) -> "AsyncCrossJoinQueryBuilder":
378
+ """Build an async ``$crossjoin`` query (SAP HANA only, B1 9.2 patch 07+).
379
+
380
+ At least 2 entity names are required. A bare crossjoin without
381
+ ``$expand`` or ``$apply`` will raise ``ValueError`` before the request.
382
+
383
+ Example::
384
+
385
+ rows = await (
386
+ client.crossjoin("Orders", "BusinessPartners")
387
+ .expand({"Orders": ["DocEntry", "DocNum"], "BusinessPartners": ["CardCode"]})
388
+ .filter("Orders/CardCode eq BusinessPartners/CardCode")
389
+ .execute()
390
+ )
391
+ """
392
+ from b1sl.b1sl.resources.crossjoin import AsyncCrossJoinQueryBuilder
393
+
394
+ return AsyncCrossJoinQueryBuilder(self._adapter, *entities)
395
+
396
+ def query_service(self, query_path: str) -> "AsyncQueryServiceBuilder":
397
+ """Async ``QueryService_PostQuery`` row-level filter (SAP HANA only, B1 9.2 PL11+).
398
+
399
+ Example::
400
+
401
+ rows = await (
402
+ client.query_service("$crossjoin(Orders,Orders/DocumentLines)")
403
+ .expand({
404
+ "Orders": ["DocEntry", "DocNum"],
405
+ "Orders/DocumentLines": ["ItemCode", "LineNum"],
406
+ })
407
+ .filter(
408
+ "Orders/DocEntry eq Orders/DocumentLines/DocEntry"
409
+ " and Orders/DocumentLines/ItemCode eq 'WIDGET'"
410
+ )
411
+ .execute()
412
+ )
413
+ """
414
+ from b1sl.b1sl.resources.crossjoin import AsyncQueryServiceBuilder
415
+
416
+ return AsyncQueryServiceBuilder(self._adapter, query_path)
417
+
363
418
 
@@ -16,6 +16,7 @@ from b1sl.b1sl.exceptions.exceptions import (
16
16
  B1ValidationError,
17
17
  )
18
18
  from b1sl.b1sl.models.result import Result
19
+ from b1sl.b1sl.pagination import extract_next_link
19
20
 
20
21
  _HTTP_STATUS_TO_EXC: dict[int, type] = {
21
22
  400: B1ValidationError,
@@ -278,7 +279,10 @@ class AsyncRestAdapter(BaseRestAdapter):
278
279
  self._raise_if_concurrency_error(
279
280
  e.response.status_code, sap_code, sap_msg, endpoint_path, body
280
281
  )
281
-
282
+ self._raise_if_sql_error(
283
+ e.response.status_code, sap_code, sap_msg, body
284
+ )
285
+
282
286
  # Use specialized exception based on status code if available
283
287
  exc_cls = _HTTP_STATUS_TO_EXC.get(e.response.status_code, B1Exception)
284
288
  raise exc_cls(f"SAP Error {sap_code}: {sap_msg}", details=body) from e
@@ -335,13 +339,11 @@ class AsyncRestAdapter(BaseRestAdapter):
335
339
  status_code=response.status_code,
336
340
  message=response.reason_phrase,
337
341
  data=data_out,
338
- next_link=data_out.get("odata.nextLink")
339
- if isinstance(data_out, dict)
340
- else None,
341
- next_params=self._get_ep_params(data_out.get("odata.nextLink"))
342
- if isinstance(data_out, dict) and data_out.get("odata.nextLink")
342
+ next_link=extract_next_link(data_out) if isinstance(data_out, dict) else None,
343
+ next_params=self._get_ep_params(extract_next_link(data_out))
344
+ if isinstance(data_out, dict) and extract_next_link(data_out)
343
345
  else None,
344
- metadata=data_out.get("odata.metadata")
346
+ metadata=(data_out.get("@odata.context") or data_out.get("odata.metadata"))
345
347
  if isinstance(data_out, dict)
346
348
  else None,
347
349
  )
b1sl/b1sl/base_adapter.py CHANGED
@@ -16,7 +16,12 @@ from datetime import datetime
16
16
  from typing import Any, Callable, Dict, List, Optional
17
17
  from urllib.parse import parse_qs, urlencode, urlparse
18
18
 
19
- from b1sl.b1sl.exceptions.exceptions import SAPConcurrencyError
19
+ from b1sl.b1sl.exceptions.exceptions import (
20
+ B1SqlNotAllowedError,
21
+ B1SqlParamError,
22
+ B1SqlSyntaxError,
23
+ SAPConcurrencyError,
24
+ )
20
25
 
21
26
 
22
27
  @dataclass
@@ -403,6 +408,44 @@ class BaseRestAdapter:
403
408
  details=response_body,
404
409
  )
405
410
 
411
+ @staticmethod
412
+ def _raise_if_sql_error(
413
+ status_code: int,
414
+ sap_code: str,
415
+ sap_message: str,
416
+ response_body: dict | None,
417
+ ) -> None:
418
+ """Convert SAP 400 responses with SQL-specific codes into typed exceptions.
419
+
420
+ SAP error codes handled:
421
+ - ``"702"`` / ``"703"`` → ``B1SqlNotAllowedError`` (table or column blocked)
422
+ - ``"704"`` → ``B1SqlParamError`` (missing, extra, or mis-typed parameter)
423
+
424
+ Called from ``_do`` in both sync and async adapters, right before the
425
+ generic ``B1ValidationError`` fallback, so callers can catch the
426
+ granular subclass or the broader ``B1ValidationError``.
427
+
428
+ Note: SAP returns ``code`` as a JSON string (e.g. ``"702"``), not an int.
429
+ """
430
+ if status_code != 400:
431
+ return
432
+ if sap_code == B1SqlSyntaxError.SAP_ERROR_CODE:
433
+ raise B1SqlSyntaxError(
434
+ f"SAP SQL syntax error: {sap_message}",
435
+ details=response_body,
436
+ )
437
+ if sap_code in ("702", "703"):
438
+ raise B1SqlNotAllowedError(
439
+ f"SAP SQL allowlist violation [{sap_code}]: {sap_message}",
440
+ sap_code=sap_code,
441
+ details=response_body,
442
+ )
443
+ if sap_code == B1SqlParamError.SAP_ERROR_CODE:
444
+ raise B1SqlParamError(
445
+ f"SAP SQL parameter error: {sap_message}",
446
+ details=response_body,
447
+ )
448
+
406
449
  def _redact_data(self, data: dict | None) -> dict:
407
450
  """Mask sensitive information in data dictionaries before logging."""
408
451
  return {k: "***" if k == "Password" else v for k, v in (data or {}).items()}
@@ -18,6 +18,7 @@ class PendingRequest:
18
18
  endpoint: str
19
19
  data: dict | None = None
20
20
  ep_params: dict | None = None
21
+ headers: dict | None = None
21
22
  model_type: Type[B1Model] | None = None
22
23
  changeset_id: str | None = None
23
24
  content_id: str = field(default_factory=lambda: str(uuid.uuid4()))
@@ -31,7 +32,8 @@ class _RecordingAdapter:
31
32
  self._batch = batch_client
32
33
  self._current_model: Type[B1Model] | None = None
33
34
 
34
- def _record(self, method: str, endpoint: str, ep_params: dict | None = None, data: dict | None = None):
35
+ def _record(self, method: str, endpoint: str, ep_params: dict | None = None,
36
+ data: dict | None = None, headers: dict | None = None):
35
37
  """Captures the request and adds it to the client's queue."""
36
38
  if method == "GET" and self._batch.active_changeset_id:
37
39
  raise ValueError("OData Batch Error: GET operations are not allowed within a ChangeSet.")
@@ -41,27 +43,28 @@ class _RecordingAdapter:
41
43
  endpoint=endpoint,
42
44
  ep_params=ep_params,
43
45
  data=data,
46
+ headers=headers,
44
47
  model_type=self._current_model,
45
48
  changeset_id=self._batch.active_changeset_id
46
49
  )
47
50
  self._batch._pending.append(req)
48
-
51
+
49
52
  # Return a simulated Result so Pydantic doesn't crash
50
53
  return Result(status_code=202, data={})
51
54
 
52
55
  # Asynchronous implementation for AsyncGenericResource
53
- async def get(self, endpoint, ep_params=None, data=None):
54
- return self._record("GET", endpoint, ep_params, data)
56
+ async def get(self, endpoint, ep_params=None, data=None, headers=None):
57
+ return self._record("GET", endpoint, ep_params, data, headers)
55
58
 
56
- async def post(self, endpoint, ep_params=None, data=None):
57
- return self._record("POST", endpoint, ep_params, data)
59
+ async def post(self, endpoint, ep_params=None, data=None, headers=None):
60
+ return self._record("POST", endpoint, ep_params, data, headers)
58
61
 
59
- async def patch(self, endpoint, ep_params=None, data=None):
60
- return self._record("PATCH", endpoint, ep_params, data)
62
+ async def patch(self, endpoint, ep_params=None, data=None, headers=None):
63
+ return self._record("PATCH", endpoint, ep_params, data, headers)
61
64
 
62
- async def delete(self, endpoint, ep_params=None, data=None):
63
- return self._record("DELETE", endpoint, ep_params, data)
65
+ async def delete(self, endpoint, ep_params=None, data=None, headers=None):
66
+ return self._record("DELETE", endpoint, ep_params, data, headers)
64
67
 
65
68
  # Synchronous implementation for GenericResource (compatibility)
66
- def get_sync(self, endpoint, ep_params=None, data=None):
67
- return self._record("GET", endpoint, ep_params, data)
69
+ def get_sync(self, endpoint, ep_params=None, data=None, headers=None):
70
+ return self._record("GET", endpoint, ep_params, data, headers)
@@ -49,14 +49,25 @@ class BatchSerializer:
49
49
  lines.append(f"{req.method} /b1s/v2/{clean_endpoint} HTTP/1.1")
50
50
 
51
51
  # 5. Internal Headers
52
- if req.data:
52
+ # Any caller-supplied per-part headers (e.g. Prefer:
53
+ # odata.maxpagesize, If-Match) — skip Content-Type, we set our own
54
+ # below whenever a body is present.
55
+ if req.headers:
56
+ for hk, hv in req.headers.items():
57
+ if hk.lower() == "content-type":
58
+ continue
59
+ lines.append(f"{hk}: {hv}")
60
+ # A body is present whenever data is not None — note an empty dict
61
+ # ({}) is a valid, required body for some bounded functions (e.g.
62
+ # the SQLQueries /List POST), so we must not skip it on falsiness.
63
+ if req.data is not None:
53
64
  lines.append("Content-Type: application/json")
54
65
  lines.append("") # End of internal headers
55
66
 
56
67
  # 6. Body
57
- if req.data:
68
+ if req.data is not None:
58
69
  lines.append(json.dumps(req.data))
59
-
70
+
60
71
  lines.append("") # Blank line after body (OData multipart spec)
61
72
 
62
73
  # Close the last changeset if it remained open
b1sl/b1sl/client.py CHANGED
@@ -18,6 +18,8 @@ if TYPE_CHECKING:
18
18
  from b1sl.b1sl.models._generated.entities.inventory import Item
19
19
  from b1sl.b1sl.models.base import B1Model
20
20
  from b1sl.b1sl.resources.base import GenericResource
21
+ from b1sl.b1sl.resources.crossjoin import CrossJoinQueryBuilder, QueryServiceBuilder
22
+ from b1sl.b1sl.resources.sql_queries import SQLQueriesResource
21
23
  from b1sl.b1sl.resources.udo import UDOResource
22
24
 
23
25
 
@@ -348,6 +350,15 @@ class B1Client:
348
350
  from b1sl.b1sl.models._generated.entities.general import Document
349
351
  return self.get_resource(Document, "CorrectionPurchaseInvoiceReversal")
350
352
 
353
+ # --- SQL Queries ---
354
+
355
+ @property
356
+ def sql_queries(self) -> "SQLQueriesResource":
357
+ """Access the 'SQLQueries' endpoint (supports ETags, run() / run_stream())."""
358
+ from b1sl.b1sl.resources.sql_queries import SQLQueriesResource
359
+ resource = SQLQueriesResource(self._adapter)
360
+ return resource
361
+
351
362
  def udo(self, table_name: str) -> "UDOResource":
352
363
  """
353
364
  Access a User Defined Object (UDO) or User Table dynamically.
@@ -362,3 +373,48 @@ class B1Client:
362
373
  from b1sl.b1sl.resources.udo import UDOResource
363
374
 
364
375
  return UDOResource(adapter=self._adapter, table_name=table_name)
376
+
377
+ def crossjoin(self, *entities: str) -> "CrossJoinQueryBuilder":
378
+ """Build a ``$crossjoin`` query across entity sets (SAP HANA only, B1 9.2 patch 07+).
379
+
380
+ At least 2 entity names are required. A bare crossjoin without
381
+ ``$expand`` or ``$apply`` will raise ``ValueError`` before the request.
382
+
383
+ Example::
384
+
385
+ rows = (
386
+ client.crossjoin("Orders", "BusinessPartners")
387
+ .expand({"Orders": ["DocEntry", "DocNum"], "BusinessPartners": ["CardCode"]})
388
+ .filter("Orders/CardCode eq BusinessPartners/CardCode")
389
+ .execute()
390
+ )
391
+ """
392
+ from b1sl.b1sl.resources.crossjoin import CrossJoinQueryBuilder
393
+
394
+ return CrossJoinQueryBuilder(self._adapter, *entities)
395
+
396
+ def query_service(self, query_path: str) -> "QueryServiceBuilder":
397
+ """Build a ``QueryService_PostQuery`` row-level filter (SAP HANA only, B1 9.2 PL11+).
398
+
399
+ Joins a document header with its navigation lines via POST. Use this
400
+ when you need to filter by line-level fields (ItemCode, LineNum, etc.)
401
+ combined with header fields — which regular ``$filter`` cannot do.
402
+
403
+ Example::
404
+
405
+ rows = (
406
+ client.query_service("$crossjoin(Orders,Orders/DocumentLines)")
407
+ .expand({
408
+ "Orders": ["DocEntry", "DocNum"],
409
+ "Orders/DocumentLines": ["ItemCode", "LineNum"],
410
+ })
411
+ .filter(
412
+ "Orders/DocEntry eq Orders/DocumentLines/DocEntry"
413
+ " and Orders/DocumentLines/ItemCode eq 'WIDGET'"
414
+ )
415
+ .execute()
416
+ )
417
+ """
418
+ from b1sl.b1sl.resources.crossjoin import QueryServiceBuilder
419
+
420
+ return QueryServiceBuilder(self._adapter, query_path)
@@ -4,6 +4,9 @@ from b1sl.b1sl.exceptions.exceptions import (
4
4
  B1Exception,
5
5
  B1NotFoundError,
6
6
  B1ResponseError,
7
+ B1SqlNotAllowedError,
8
+ B1SqlParamError,
9
+ B1SqlSyntaxError,
7
10
  B1ValidationError,
8
11
  SAPConcurrencyError,
9
12
  )
@@ -14,6 +17,9 @@ __all__ = [
14
17
  "B1ConnectionError",
15
18
  "B1NotFoundError",
16
19
  "B1ValidationError",
20
+ "B1SqlSyntaxError",
21
+ "B1SqlNotAllowedError",
22
+ "B1SqlParamError",
17
23
  "B1ResponseError",
18
24
  "SAPConcurrencyError",
19
25
  ]
@@ -88,6 +88,101 @@ class B1ValidationError(B1Exception):
88
88
  """
89
89
 
90
90
 
91
+ class B1SqlNotAllowedError(B1ValidationError):
92
+ """Raised when SAP rejects a SQLQuery because a table or column is blocked.
93
+
94
+ SAP error codes:
95
+ - ``"702"`` — table not in the server allowlist (``b1s_sqltable.conf``).
96
+ - ``"703"`` — column excluded via ``ColumnExcludeList`` in the same config.
97
+
98
+ Neither condition can be fixed at runtime; the SQL text or the server
99
+ configuration must change. The ``sap_code`` attribute lets callers
100
+ distinguish table-level from column-level blocks::
101
+
102
+ try:
103
+ result = client.sql_queries.run("sql04")
104
+ except B1SqlNotAllowedError as e:
105
+ if e.sap_code == "702":
106
+ print(f"Table blocked: {e}")
107
+ else:
108
+ print(f"Column blocked: {e}")
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ message: str,
114
+ *,
115
+ sap_code: str = "702",
116
+ details: dict | None = None,
117
+ ) -> None:
118
+ super().__init__(message, details=details)
119
+ self.sap_code = sap_code
120
+
121
+
122
+ class B1SqlSyntaxError(B1ValidationError):
123
+ """Raised when SAP rejects a SQL definition or execution due to invalid syntax.
124
+
125
+ SAP error code ``"701"`` — covers multiple sub-cases, all requiring a fix
126
+ to the SQL text itself (no runtime adjustment can resolve a 701 error):
127
+
128
+ - **Grammar error**: typos like ``ORDER BY x dsc`` instead of ``DESC``.
129
+ - **Unsupported function**: e.g. ``length()``, ``CASE WHEN``, ``COALESCE``.
130
+ - **``SELECT *`` in the top-level query**: only allowed inside subqueries.
131
+ - **Computed column without alias**: ``SUM(col)`` must be aliased as
132
+ ``SUM(col) AS total``.
133
+ - **Duplicate alias**: two columns sharing the same alias are rejected.
134
+ - **DML statement**: ``UPDATE``, ``INSERT``, ``DELETE``, ``ALTER``, etc. —
135
+ the ``SQLQueries`` endpoint accepts only ``SELECT`` queries.
136
+
137
+ The SAP error message includes the character position and a description::
138
+
139
+ try:
140
+ client.sql_queries.create(en.SQLQuery(sql_code="q1",
141
+ sql_text="select * from ORDR"))
142
+ except B1SqlSyntaxError as e:
143
+ print(e) # "Invalid SQL syntax: ..., Cannot support asterisk(*)..."
144
+ """
145
+
146
+ SAP_ERROR_CODE = "701"
147
+
148
+ def __init__(self, message: str, *, details: dict | None = None) -> None:
149
+ super().__init__(message, details=details)
150
+ self.sap_code = self.SAP_ERROR_CODE
151
+
152
+
153
+ class B1SqlParamError(B1ValidationError):
154
+ """Raised when SAP rejects a ``/List`` invocation due to parameter problems.
155
+
156
+ SAP error code ``"704"`` — covers missing parameters, extra parameters, or
157
+ type mismatches. Unlike ``B1SqlNotAllowedError``, this is fixable by the
158
+ caller: check that the kwargs passed to ``run()`` / ``run_stream()`` match
159
+ the ``ParamList`` declared in the stored ``SqlText``::
160
+
161
+ try:
162
+ rows = client.sql_queries.run("sql01", doc_total=100)
163
+ except B1SqlParamError:
164
+ # Wrong param name — SAP expects :docTotal, not :doc_total
165
+ rows = client.sql_queries.run("sql01", docTotal=100)
166
+
167
+ Note: SAP returns a generic ``"Parameter error."`` message without naming
168
+ the offending parameter. Inspect the stored query's ``ParamList`` field
169
+ to cross-reference.
170
+ """
171
+
172
+ SAP_ERROR_CODE = "704"
173
+
174
+ def __init__(
175
+ self,
176
+ message: str,
177
+ *,
178
+ details: dict | None = None,
179
+ expected_params: list[str] | None = None,
180
+ ) -> None:
181
+ super().__init__(message, details=details)
182
+ self.sap_code = self.SAP_ERROR_CODE
183
+ self.expected_params: list[str] | None = expected_params
184
+
185
+
91
186
  class B1ResponseError(B1Exception):
92
187
  """Raised for unexpected non-2xx responses not covered by other subclasses."""
93
188
 
b1sl/b1sl/pagination.py CHANGED
@@ -1,6 +1,18 @@
1
1
  from urllib.parse import parse_qs, urlparse
2
2
 
3
3
 
4
+ def extract_next_link(data: dict) -> str | None:
5
+ """Return the OData nextLink from a response body, tolerating v3 and v4 formats.
6
+
7
+ SAP Service Layer uses different key names depending on the OData version:
8
+ - OData v3 (``b1s/v1``): ``"odata.nextLink"``
9
+ - OData v4 (``b1s/v2``): ``"@odata.nextLink"``
10
+
11
+ Returns the first non-empty value found, or ``None``.
12
+ """
13
+ return data.get("@odata.nextLink") or data.get("odata.nextLink") or None
14
+
15
+
4
16
  def extract_skip(next_link: str) -> int | None:
5
17
  """
6
18
  Extracts the $skip value from an odata.nextLink string.
@@ -96,6 +96,11 @@ class AsyncGenericResource(Generic[T]):
96
96
 
97
97
  return AsyncQueryBuilder(self).expand(value)
98
98
 
99
+ def apply(self, expression: str) -> AsyncQueryBuilder[T]:
100
+ from b1sl.b1sl.resources.odata import AsyncQueryBuilder
101
+
102
+ return AsyncQueryBuilder(self).apply(expression)
103
+
99
104
  async def list(self, query: ODataQuery | None = None) -> list[T]:
100
105
  """
101
106
  Retrieves a single page of results based on the provided query.
@@ -107,52 +112,54 @@ class AsyncGenericResource(Generic[T]):
107
112
  data = result.data or {}
108
113
  return [self.model.model_validate(item) for item in data.get("value", [])]
109
114
 
110
- async def stream(
111
- self,
112
- query: ODataQuery | None = None,
113
- page_size: int | None = None,
114
- max_pages: int | None = None
115
- ) -> AsyncGenerator[T, None]:
116
- """
117
- Execute the query asynchronously and yield individual entities,
118
- automatically fetching next pages until dataset exhausted or limits hit.
115
+ async def _iter_pages(
116
+ self,
117
+ url: str,
118
+ params: dict,
119
+ headers: dict,
120
+ max_pages: int | None = None,
121
+ ) -> AsyncGenerator[dict, None]:
122
+ """Yield raw item dicts from a paginated OData endpoint, following nextLink.
123
+
124
+ Async counterpart of ``GenericResource._iter_pages``. Shared by
125
+ ``stream()`` and ``AsyncSQLQueriesResource.run_stream()``.
119
126
  """
120
-
121
- params = query.to_params() if query else {}
122
- headers = {"B1-PageSize": str(page_size)} if page_size else {}
123
-
124
- global_top = query.top if query else None
125
- yielded_count = 0
126
- pages_fetched = 0
127
-
128
127
  current_params = params
129
-
128
+ pages_fetched = 0
130
129
  while True:
131
- result = await self._adapter.get(
132
- self.endpoint,
133
- ep_params=current_params,
134
- headers=headers
135
- )
130
+ result = await self._adapter.get(url, ep_params=current_params, headers=headers)
136
131
  data = result.data or {}
137
- items = data.get("value", [])
138
132
  pages_fetched += 1
139
-
140
- for raw_item in items:
141
- yield self.model.model_validate(raw_item)
142
- yielded_count += 1
143
-
144
- if global_top is not None and yielded_count >= global_top:
145
- return
146
-
133
+ for item in data.get("value", []):
134
+ yield item
147
135
  next_link = result.next_link
148
136
  if not next_link:
149
137
  break
150
-
151
138
  if max_pages is not None and pages_fetched >= max_pages:
152
139
  break
153
-
154
140
  current_params = build_next_params(current_params, next_link)
155
141
 
142
+ async def stream(
143
+ self,
144
+ query: ODataQuery | None = None,
145
+ page_size: int | None = None,
146
+ max_pages: int | None = None
147
+ ) -> AsyncGenerator[T, None]:
148
+ """
149
+ Execute the query asynchronously and yield individual entities,
150
+ automatically fetching next pages until dataset exhausted or limits hit.
151
+ """
152
+ params = query.to_params() if query else {}
153
+ headers = {"B1-PageSize": str(page_size)} if page_size else {}
154
+ global_top = query.top if query else None
155
+ yielded_count = 0
156
+
157
+ async for raw_item in self._iter_pages(self.endpoint, params, headers, max_pages):
158
+ yield self.model.model_validate(raw_item)
159
+ yielded_count += 1
160
+ if global_top is not None and yielded_count >= global_top:
161
+ return
162
+
156
163
  async def count(self) -> int:
157
164
  """GET Endpoint/$count"""
158
165
  result = await self._adapter.get(f"{self.endpoint}/$count")
@@ -222,12 +229,33 @@ class AsyncGenericResource(Generic[T]):
222
229
 
223
230
  # ── Actions / Functions ───────────────────────────────────────────────────
224
231
 
225
- async def _action(self, key: Any, name: str, payload: dict | None = None) -> Any:
226
- """POST Endpoint(key)/ActionName"""
232
+ async def _action(
233
+ self,
234
+ key: Any,
235
+ name: str,
236
+ payload: dict | None = None,
237
+ *,
238
+ params: dict | None = None,
239
+ headers: dict | None = None,
240
+ method: str = "POST",
241
+ ) -> Any:
242
+ """Invoke a bound action or function on a keyed entity.
243
+
244
+ Covers two SAP OData patterns:
245
+ - POST Endpoint('key')/ActionName (default, side-effecting actions)
246
+ - GET Endpoint('key')/FunctionName (read-only bounded functions, e.g. /List)
247
+
248
+ The full response ``data`` dict is returned — callers needing
249
+ ``@odata.nextLink`` should inspect it directly.
250
+ """
227
251
  id_str = f"'{key}'" if isinstance(key, str) else str(key)
228
- result = await self._adapter.post(
229
- f"{self.endpoint}({id_str})/{name}", data=payload or {}
230
- )
252
+ url = f"{self.endpoint}({id_str})/{name}"
253
+ if method == "GET":
254
+ result = await self._adapter.get(url, ep_params=params, headers=headers)
255
+ else:
256
+ result = await self._adapter.post(
257
+ url, ep_params=params, data=payload or {}, headers=headers
258
+ )
231
259
  return result.data if result else None
232
260
 
233
261
  async def _function(self, name: str, params: dict | None = None) -> Any: