Skip to content

Commit 2f49c8f

Browse files
committed
Address final review: missing replace* methods, stale docs, lazy expand filter
- Add replaceFilter/replaceOrderBy/replaceExpand for symmetry with replaceSelect - Fix HasODataQuery PHPDoc references to old #[ODataVersion] name - CLAUDE.md: Saloon v3 -> v4, drop phantom LogicalOperator from layout - Defer ExpandBuilder::filter() rendering for consistency with parent builder - Document the count: false skip-sentinel behaviour on DefaultODataQuery - Add note about OData 4.01 short nextLink form on the paginator - Fix trailing comma in composer.json config block - Tests for replace* methods and attribute-default override scenarios
1 parent 9fe79db commit 2f49c8f

10 files changed

Lines changed: 120 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A Saloon PHP plugin providing a fluent, version-aware OData query-string builder
77
## Stack
88

99
- PHP `^8.4` (uses asymmetric visibility, `#[\Override]`, readonly value objects)
10-
- Saloon v3
10+
- Saloon v4
1111
- Pest 3 (with arch plugin)
1212
- Laravel Pint
1313
- PHPStan level 10
@@ -21,7 +21,7 @@ src/
2121
Filter/FilterBuilder.php closure target for ->filter()
2222
Expand/ExpandBuilder.php closure target for ->expand() (v4 only)
2323
Order/OrderByClause.php readonly value object
24-
Enums/ ODataVersion, ComparisonOperator, LogicalOperator, SortDirection
24+
Enums/ ODataVersion, ComparisonOperator, SortDirection
2525
Attributes/ UsesODataVersion, ODataEntity, DefaultODataQuery
2626
Support/ Literal (version-aware encoder), DateOnly, Guid, PropertyName, SkipToken, AttributeReader
2727
Pagination/ODataPaginator.php Saloon Paginator: walks @odata.nextLink / __next / d.__next

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class GetSalesInvoices extends Request
9494
}
9595
```
9696

97-
Defaults are applied on first access to `odataQuery()`. Runtime calls layer over them; use `clearSelect()` / `replaceSelect(...)` (and the equivalents for `Filter`, `OrderBy`, `Expand`) when you need to override rather than append.
97+
Defaults are applied on first access to `odataQuery()`. Runtime calls layer over them; use `clearSelect()` / `replaceSelect()` / `clearFilter()` / `replaceFilter()` / `clearOrderBy()` / `replaceOrderBy()` / `clearExpand()` / `replaceExpand()` when you need to override rather than append.
9898

9999
The version is resolved in this order:
100100
1. Explicit `ODataQueryBuilder::make($version)` call.

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"allow-plugins": {
5555
"pestphp/pest-plugin": true,
5656
"phpstan/extension-installer": true
57-
},
57+
}
5858
},
5959
"minimum-stability": "stable",
6060
"prefer-stable": true

src/Attributes/DefaultODataQuery.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
* @param list<string> $select
1414
* @param list<string> $expand
1515
* @param array<string, 'asc'|'desc'> $orderBy
16+
* @param bool $count Set to true to emit `$count=true` (v4) / `$inlinecount=allpages` (v3).
17+
* false (the default) skips the parameter entirely; if you need to
18+
* explicitly emit `$count=false`, call `->count(false)` on the builder.
1619
* @param array<string, scalar> $params
1720
*/
1821
public function __construct(

src/Concerns/HasODataQuery.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
* Add OData query-building to a Saloon Request.
1515
*
1616
* On first access, lazily creates an {@see ODataQueryBuilder} using the
17-
* version from `#[ODataVersion]` on the Request (or any parent), falling
17+
* version from `#[UsesODataVersion]` on the Request (or any parent), falling
1818
* back to v4. Any `#[DefaultODataQuery]` on the Request is applied.
1919
*
2020
* At boot, if the Request didn't declare a version, the Connector's
21-
* `#[ODataVersion]` attribute is consulted and applied via `withVersion()`.
21+
* `#[UsesODataVersion]` attribute is consulted and applied via `withVersion()`.
2222
* Filters and nested $expand are rendered lazily, so the late switch is
2323
* applied consistently to all chained calls. (Pre-encoded `filterRaw()`
2424
* fragments are version-baked by the caller.)

src/Expand/ExpandBuilder.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ final class ExpandBuilder
3636

3737
private ?bool $count = null;
3838

39-
private ?string $filter = null;
39+
/** @var Closure(FilterBuilder): mixed|null */
40+
private ?Closure $filter = null;
4041

4142
public function __construct(public readonly ODataVersion $version)
4243
{
@@ -71,10 +72,7 @@ public function expand(string $navigation): self
7172
*/
7273
public function filter(Closure $build): self
7374
{
74-
$filter = new FilterBuilder($this->version);
75-
$build($filter);
76-
77-
$this->filter = $filter->render();
75+
$this->filter = $build;
7876

7977
return $this;
8078
}
@@ -132,8 +130,13 @@ public function render(): ?string
132130
if ($this->select !== []) {
133131
$parts[] = '$select='.implode(',', $this->select);
134132
}
135-
if ($this->filter !== null && $this->filter !== '') {
136-
$parts[] = '$filter='.$this->filter;
133+
if ($this->filter !== null) {
134+
$filterBuilder = new FilterBuilder($this->version);
135+
($this->filter)($filterBuilder);
136+
$rendered = $filterBuilder->render();
137+
if ($rendered !== '') {
138+
$parts[] = '$filter='.$rendered;
139+
}
137140
}
138141
if ($this->orderBy !== []) {
139142
$parts[] = '$orderby='.implode(',', $this->orderBy);

src/ODataQueryBuilder.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,16 @@ public function clearFilter(): self
146146
return $this;
147147
}
148148

149+
/**
150+
* Discard any existing filter fragments, then apply the given closure.
151+
*
152+
* @param Closure(FilterBuilder): mixed $build
153+
*/
154+
public function replaceFilter(Closure $build): self
155+
{
156+
return $this->clearFilter()->filter($build);
157+
}
158+
149159
/**
150160
* Add a navigation property to $expand.
151161
*
@@ -170,6 +180,14 @@ public function clearExpand(): self
170180
return $this;
171181
}
172182

183+
/**
184+
* Discard any existing $expand entries, then add the given navigation.
185+
*/
186+
public function replaceExpand(string $navigation, ?Closure $build = null): self
187+
{
188+
return $this->clearExpand()->expand($navigation, $build);
189+
}
190+
173191
public function orderBy(string $property, SortDirection|string $direction = SortDirection::Asc): self
174192
{
175193
PropertyName::assert($property);
@@ -190,6 +208,14 @@ public function clearOrderBy(): self
190208
return $this;
191209
}
192210

211+
/**
212+
* Discard any existing $orderby clauses, then add the given one.
213+
*/
214+
public function replaceOrderBy(string $property, SortDirection|string $direction = SortDirection::Asc): self
215+
{
216+
return $this->clearOrderBy()->orderBy($property, $direction);
217+
}
218+
193219
public function top(int $top): self
194220
{
195221
if ($top < 0) {

src/Pagination/ODataPaginator.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
* The paginator only parses spec-defined fields. Vendor extensions are out of
3030
* scope; a Request that needs to extract items differently can implement
3131
* Saloon's MapPaginatedResponseItems contract.
32+
*
33+
* Note: OData 4.01 also permits a short `nextLink` form (without the
34+
* `@odata.` prefix) under minimal-metadata negotiation. This paginator looks
35+
* only for the canonical `@odata.nextLink` form, which is what every
36+
* mainstream OData v4 server (Microsoft Graph, Dynamics, SAP, Exact Online)
37+
* emits in practice.
3238
*/
3339
final class ODataPaginator extends Paginator
3440
{
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Saloon\Http\Faking\MockClient;
6+
use Saloon\Http\Faking\MockResponse;
7+
use SimpleSquid\SaloonOData\Support\AttributeReader;
8+
use SimpleSquid\SaloonOData\Tests\Fixtures\AttributedRequest;
9+
use SimpleSquid\SaloonOData\Tests\Fixtures\TestConnector;
10+
11+
beforeEach(fn () => AttributeReader::flush());
12+
13+
it('replaceSelect overrides the attribute-supplied $select', function (): void {
14+
$mock = new MockClient([MockResponse::make([])]);
15+
$connector = new TestConnector;
16+
$connector->withMockClient($mock);
17+
18+
$request = new AttributedRequest;
19+
// Attribute selects ID, InvoiceDate, AmountDC. Replace with a single field.
20+
$request->odataQuery()->replaceSelect('ID');
21+
22+
$connector->send($request);
23+
24+
expect($mock->getLastPendingRequest()?->query()->all())
25+
->toMatchArray(['$select' => 'ID']);
26+
});
27+
28+
it('clearSelect drops the attribute-supplied $select entirely', function (): void {
29+
$mock = new MockClient([MockResponse::make([])]);
30+
$connector = new TestConnector;
31+
$connector->withMockClient($mock);
32+
33+
$request = new AttributedRequest;
34+
$request->odataQuery()->clearSelect();
35+
36+
$connector->send($request);
37+
38+
expect($mock->getLastPendingRequest()?->query()->all())
39+
->not->toHaveKey('$select');
40+
});

tests/Unit/BuilderClearReplaceTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,35 @@
4545
expect($params['$expand'])->toBe('C');
4646
});
4747

48+
it('replaceFilter discards prior fragments and replaces them', function (): void {
49+
$params = ODataQueryBuilder::make()
50+
->filter(fn (FilterBuilder $f) => $f->whereEquals('A', 1))
51+
->replaceFilter(fn (FilterBuilder $f) => $f->whereEquals('B', 2))
52+
->toArray();
53+
54+
expect($params['$filter'])->toBe('B eq 2');
55+
});
56+
57+
it('replaceOrderBy discards prior clauses and replaces with a single one', function (): void {
58+
$params = ODataQueryBuilder::make()
59+
->orderBy('A')
60+
->orderByDesc('B')
61+
->replaceOrderBy('C')
62+
->toArray();
63+
64+
expect($params['$orderby'])->toBe('C asc');
65+
});
66+
67+
it('replaceExpand discards prior expansions', function (): void {
68+
$params = ODataQueryBuilder::make()
69+
->expand('A')
70+
->expand('B')
71+
->replaceExpand('C')
72+
->toArray();
73+
74+
expect($params['$expand'])->toBe('C');
75+
});
76+
4877
it('clearFilter wipes filter fragments', function (): void {
4978
$params = ODataQueryBuilder::make()
5079
->filter(fn (FilterBuilder $f) => $f->whereEquals('A', 1))

0 commit comments

Comments
 (0)