https://github.com/simplesquid/saloonphp-odata
A Saloon plugin providing a fluent, version-aware (v3 + v4) OData query builder and server-driven paginator.
https://github.com/simplesquid/saloonphp-odata
exact-online odata odata-v3 odata-v4 php query-builder saloon saloonphp
Last synced: about 2 months ago
JSON representation
A Saloon plugin providing a fluent, version-aware (v3 + v4) OData query builder and server-driven paginator.
- Host: GitHub
- URL: https://github.com/simplesquid/saloonphp-odata
- Owner: simplesquid
- License: mit
- Created: 2026-04-13T22:37:05.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-14T16:52:41.000Z (2 months ago)
- Last Synced: 2026-04-16T07:44:08.910Z (about 2 months ago)
- Topics: exact-online, odata, odata-v3, odata-v4, php, query-builder, saloon, saloonphp
- Language: PHP
- Homepage: https://github.com/simplesquid/saloonphp-odata
- Size: 63.5 KB
- Stars: 0
- Watchers: 0
- Forks: 1
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: .github/CONTRIBUTING.md
- License: LICENSE.md
- Security: .github/SECURITY.md
Awesome Lists containing this project
README
# saloonphp-odata
[](https://packagist.org/packages/simplesquid/saloonphp-odata)
[](https://github.com/simplesquid/saloonphp-odata/actions/workflows/run-tests.yml)
[](https://github.com/simplesquid/saloonphp-odata/actions/workflows/phpstan.yml)
[](https://packagist.org/packages/simplesquid/saloonphp-odata)
A [Saloon](https://github.com/saloonphp/saloon) plugin providing a fluent, version-aware OData query builder and a server-driven paginator. Supports OData v3 and v4. Bring your own Connector — Saloon handles HTTP.
```php
use Saloon\Enums\Method;
use Saloon\Http\Request;
use SimpleSquid\SaloonOData\Concerns\HasODataQuery;
use SimpleSquid\SaloonOData\Filter\FilterBuilder;
class GetPeople extends Request
{
use HasODataQuery;
protected Method $method = Method::GET;
public function resolveEndpoint(): string { return '/People'; }
}
$req = (new GetPeople)->odataQuery()
->select('FirstName', 'LastName')
->filter(fn (FilterBuilder $f) => $f
->whereEquals('Status', 'Active')
->or()
->where('Age', 'gt', 30))
->orderBy('LastName')
->top(10)
->count();
$connector->send($req);
// GET /People?$select=FirstName,LastName&$filter=Status eq 'Active' or Age gt 30&$orderby=LastName asc&$top=10&$count=true
```
## Installation
```bash
composer require simplesquid/saloonphp-odata
```
For the paginator, also install Saloon's pagination plugin:
```bash
composer require saloonphp/pagination-plugin
```
Requires PHP 8.4+ and Saloon v4.
## Usage
### As a Request trait
```php
use Saloon\Enums\Method;
use Saloon\Http\Request;
use SimpleSquid\SaloonOData\Concerns\HasODataQuery;
use SimpleSquid\SaloonOData\Filter\FilterBuilder;
class GetPeople extends Request
{
use HasODataQuery;
protected Method $method = Method::GET;
public function resolveEndpoint(): string
{
return '/People';
}
}
$request = new GetPeople;
$request->odataQuery()
->select('FirstName', 'LastName', 'Email')
->filter(fn (FilterBuilder $f) => $f
->whereEquals('Status', 'Active')
->and()
->where('Age', 'gt', 30))
->orderByDesc('CreatedAt')
->top(25)
->count();
$response = $connector->send($request);
// GET /People?$select=FirstName,LastName,Email&$filter=Status eq 'Active' and Age gt 30&$orderby=CreatedAt desc&$top=25&$count=true
```
The trait exposes `$request->odataQuery()` returning the underlying `ODataQueryBuilder`. The builder's params are merged into the request's query string immediately before send via Saloon middleware — you never call `->toArray()` yourself. If the builder is never touched and no class-level attributes apply, no middleware runs.
### As a standalone builder (e.g. inside `defaultQuery()`)
```php
public function defaultQuery(): array
{
return ODataQueryBuilder::make()
->select('Id', 'Name')
->top(50)
->toArray();
}
```
Or, if you have a Request/PendingRequest in hand, use `applyTo()`:
```php
ODataQueryBuilder::make()->select('Id')->applyTo($request);
```
### Declarative configuration with attributes
```php
use SimpleSquid\SaloonOData\Attributes\DefaultODataQuery;
use SimpleSquid\SaloonOData\Attributes\ODataEntity;
use SimpleSquid\SaloonOData\Attributes\UsesODataVersion;
use SimpleSquid\SaloonOData\Enums\ODataVersion;
#[UsesODataVersion(ODataVersion::V3)]
#[ODataEntity('SalesInvoices')]
#[DefaultODataQuery(
select: ['ID', 'InvoiceDate', 'AmountDC'],
top: 50,
count: true,
filterRaw: "Division eq 12345",
)]
class GetSalesInvoices extends Request
{
use HasODataQuery;
protected Method $method = Method::GET;
public function resolveEndpoint(): string
{
return '/'.$this->odataEntity();
}
}
```
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.
The version is resolved in this order:
1. Explicit `ODataQueryBuilder::make($version)` call.
2. `#[UsesODataVersion]` on the Request class (or any parent).
3. `#[UsesODataVersion]` on the Connector class (applied at request boot).
4. Default: v4.
> Filters and nested `$expand` are rendered lazily, so a connector-level version still applies cleanly even after the user has chained `->filter(...)` on the builder. The exception is `filterRaw()` — those strings are version-baked by the caller.
## Builder reference
### Selection
```php
$q->select('FirstName', 'LastName', 'Email');
$q->replaceSelect('FirstName'); // discard previous, set anew
$q->clearSelect(); // discard all
```
### Filtering
```php
use SimpleSquid\SaloonOData\Filter\FilterBuilder;
$q->filter(fn (FilterBuilder $f) => $f
->whereEquals('Status', 'Active') // shorthand for eq
->whereNotEquals('Type', 'Draft') // shorthand for ne
->where('Age', 'gt', 30) // operators: eq, ne, gt, ge, lt, le, has*, in*
->or() // switch the trailing join (default is `and`)
->not() // negate the next clause
->group(fn (FilterBuilder $g) => ...) // wrap in parentheses
->in('Status', ['A', 'B']) // v4 only
->has('Roles', 'Admin') // v4 only
->contains('Name', 'foo') // becomes substringof('foo', Name) on v3
->startsWith('Name', 'A')
->endsWith('Name', 'Z')
->raw('year(Created) eq 2025') // pre-encoded escape hatch (UNSAFE for user input)
);
$q->filterRaw("Status eq 'Active'"); // bypass the closure entirely (UNSAFE for user input)
$q->clearFilter(); // wipe all filter fragments
```
Operators accept a `ComparisonOperator` enum or a string; strings are validated. Property names are validated to prevent filter injection.
### Date-only and GUID literals
```php
use SimpleSquid\SaloonOData\Support\DateOnly;
use SimpleSquid\SaloonOData\Support\Literal;
// Some endpoints prefer date-only over full datetime:
$q->filter(fn ($f) => $f->where('Date', 'gt', Literal::dateOnly($dt)));
$q->filter(fn ($f) => $f->where('Date', 'gt', DateOnly::from($dt))); // equivalent
// GUIDs render unquoted in v4 and as guid'...' in v3 — wrap explicitly:
$q->filter(fn ($f) => $f->whereEquals('Id', Literal::guid('11111111-2222-3333-4444-555555555555')));
```
### Expansion
```php
$q->expand('Trips'); // flat (works on v3 and v4)
$q->expand('Trips/Stops'); // path style (v3 + v4)
$q->expand('Trips', fn (ExpandBuilder $e) => $e
->select('Name', 'Budget')
->filter(fn (FilterBuilder $f) => $f->where('Status', 'eq', 'Completed'))
->orderBy('Name')
->orderByDesc('Budget')
->top(5)
); // v4 nested options; throws on v3
$q->clearExpand();
```
### Ordering & paging
```php
$q->orderBy('LastName'); // asc by default
$q->orderBy('LastName', SortDirection::Desc);
$q->orderByDesc('CreatedAt');
$q->clearOrderBy();
$q->top(50)->skip(100);
$q->skipToken('cursor-from-server');
$q->count(); // $count=true (v4) or $inlinecount=allpages (v3)
```
### Other system options
```php
$q->search('foo bar'); // v4 only — throws at render time on v3
$q->format('json');
$q->param('apikey', 'secret'); // arbitrary non-system param ($-prefixed keys rejected)
```
### Output
```php
$q->toArray(); // ['$select' => '...', ...]
$q->toQueryString(); // RFC 3986 encoded query string
(string) $q; // alias for toQueryString()
$q->applyTo($requestOrPendingRequest);
$q->clone(); // independent fork (no shared state)
$q->fresh(); // empty builder, same version
```
## Pagination
```php
use SimpleSquid\SaloonOData\Pagination\ODataPaginator;
use Saloon\PaginationPlugin\Contracts\Paginatable;
class GetPeople extends Request implements Paginatable { /* ... */ }
// Version is resolved from #[UsesODataVersion] attributes; pass explicitly only to override.
$paginator = new ODataPaginator($connector, new GetPeople);
foreach ($paginator->items() as $item) {
// single record from any page
}
```
Reads spec-defined envelope keys only:
| Version | Next-link key | Items key |
|---------|----------------|-------------------|
| v4 | `@odata.nextLink` | `value` |
| v3 JSON-Light | `__next` | `value` |
| v3 JSON-Verbose | `d.__next` | `d.results` |
The paginator extracts only the `$skiptoken` from the next-link URL and applies it to the original request — it does not follow the full server-supplied URL.
Requests that need custom item extraction can implement Saloon's `MapPaginatedResponseItems` contract.
## Literal encoding
All `$filter` literal encoding goes through `Support\Literal::encode($value, $version)`. Supported types:
| PHP type | v4 output | v3 output |
|---|---|---|
| `null` | `null` | `null` |
| `bool` | `true`/`false` | `true`/`false` |
| `int` / `float` | `42` / `3.14` | `42` / `3.14` |
| `string` | `'value'` (single-quote escape: `''`) | `'value'` |
| `Guid` (via `Literal::guid()`) | bare `xxxxxxxx-...` | `guid'xxxxxxxx-...'` |
| `DateTimeInterface` | `2025-01-15T10:30:00Z` | `datetime'2025-01-15T10:30:00'` |
| `DateOnly` (via `Literal::dateOnly()` or `DateOnly::from()`) | `2025-01-15` | `datetime'2025-01-15'` |
| `BackedEnum` | encoded `value` | encoded `value` |
| `UnitEnum` | encoded case `name` | encoded case `name` |
| `array` | tuple `(a,b,c)` | tuple |
GUID detection is **opt-in** via `Literal::guid()` to prevent user-supplied strings that happen to look like GUIDs from silently changing semantics.
## Security
- Property names passed to `select()`, `where()`, `orderBy()`, `expand()`, etc. are validated against an OData identifier pattern. Anything containing spaces, quotes, parens, or other syntax characters throws `InvalidODataQueryException`.
- Literal values are version-correctly quote-escaped through `Support\Literal`.
- The paginator only extracts `$skiptoken` from server-supplied next-link URLs; it does not follow arbitrary URLs.
- `filterRaw()` and `FilterBuilder::raw()` are documented escape hatches. **Never pass untrusted input to either.**
## Testing
```bash
composer test
composer analyse
composer format
```
## License
The MIT License (MIT). See [LICENSE.md](LICENSE.md).