{"id":48890547,"url":"https://github.com/simplesquid/saloonphp-odata","last_synced_at":"2026-04-20T11:01:43.278Z","repository":{"id":351180615,"uuid":"1209902814","full_name":"simplesquid/saloonphp-odata","owner":"simplesquid","description":"A Saloon plugin providing a fluent, version-aware (v3 + v4) OData query builder and server-driven paginator.","archived":false,"fork":false,"pushed_at":"2026-04-14T16:52:41.000Z","size":65,"stargazers_count":0,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-16T07:44:08.910Z","etag":null,"topics":["exact-online","odata","odata-v3","odata-v4","php","query-builder","saloon","saloonphp"],"latest_commit_sha":null,"homepage":"https://github.com/simplesquid/saloonphp-odata","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/simplesquid.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":".github/CONTRIBUTING.md","funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":".github/SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-13T22:37:05.000Z","updated_at":"2026-04-14T16:52:34.000Z","dependencies_parsed_at":"2026-04-17T08:00:44.546Z","dependency_job_id":null,"html_url":"https://github.com/simplesquid/saloonphp-odata","commit_stats":null,"previous_names":["simplesquid/saloonphp-odata"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/simplesquid/saloonphp-odata","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simplesquid%2Fsaloonphp-odata","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simplesquid%2Fsaloonphp-odata/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simplesquid%2Fsaloonphp-odata/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simplesquid%2Fsaloonphp-odata/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/simplesquid","download_url":"https://codeload.github.com/simplesquid/saloonphp-odata/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simplesquid%2Fsaloonphp-odata/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31920518,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-16T18:22:33.417Z","status":"online","status_checked_at":"2026-04-17T02:00:06.879Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["exact-online","odata","odata-v3","odata-v4","php","query-builder","saloon","saloonphp"],"created_at":"2026-04-16T07:35:51.766Z","updated_at":"2026-04-17T08:01:04.122Z","avatar_url":"https://github.com/simplesquid.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# saloonphp-odata\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/simplesquid/saloonphp-odata.svg?style=flat-square)](https://packagist.org/packages/simplesquid/saloonphp-odata)\n[![Tests](https://img.shields.io/github/actions/workflow/status/simplesquid/saloonphp-odata/run-tests.yml?branch=main\u0026label=tests\u0026style=flat-square)](https://github.com/simplesquid/saloonphp-odata/actions/workflows/run-tests.yml)\n[![PHPStan](https://img.shields.io/github/actions/workflow/status/simplesquid/saloonphp-odata/phpstan.yml?branch=main\u0026label=phpstan\u0026style=flat-square)](https://github.com/simplesquid/saloonphp-odata/actions/workflows/phpstan.yml)\n[![Total Downloads](https://img.shields.io/packagist/dt/simplesquid/saloonphp-odata.svg?style=flat-square)](https://packagist.org/packages/simplesquid/saloonphp-odata)\n\nA [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.\n\n```php\nuse Saloon\\Enums\\Method;\nuse Saloon\\Http\\Request;\nuse SimpleSquid\\SaloonOData\\Concerns\\HasODataQuery;\nuse SimpleSquid\\SaloonOData\\Filter\\FilterBuilder;\n\nclass GetPeople extends Request\n{\n    use HasODataQuery;\n    protected Method $method = Method::GET;\n    public function resolveEndpoint(): string { return '/People'; }\n}\n\n$req = (new GetPeople)-\u003eodataQuery()\n    -\u003eselect('FirstName', 'LastName')\n    -\u003efilter(fn (FilterBuilder $f) =\u003e $f\n        -\u003ewhereEquals('Status', 'Active')\n        -\u003eor()\n        -\u003ewhere('Age', 'gt', 30))\n    -\u003eorderBy('LastName')\n    -\u003etop(10)\n    -\u003ecount();\n\n$connector-\u003esend($req);\n// GET /People?$select=FirstName,LastName\u0026$filter=Status eq 'Active' or Age gt 30\u0026$orderby=LastName asc\u0026$top=10\u0026$count=true\n```\n\n## Installation\n\n```bash\ncomposer require simplesquid/saloonphp-odata\n```\n\nFor the paginator, also install Saloon's pagination plugin:\n\n```bash\ncomposer require saloonphp/pagination-plugin\n```\n\nRequires PHP 8.4+ and Saloon v4.\n\n## Usage\n\n### As a Request trait\n\n```php\nuse Saloon\\Enums\\Method;\nuse Saloon\\Http\\Request;\nuse SimpleSquid\\SaloonOData\\Concerns\\HasODataQuery;\nuse SimpleSquid\\SaloonOData\\Filter\\FilterBuilder;\n\nclass GetPeople extends Request\n{\n    use HasODataQuery;\n\n    protected Method $method = Method::GET;\n\n    public function resolveEndpoint(): string\n    {\n        return '/People';\n    }\n}\n\n$request = new GetPeople;\n\n$request-\u003eodataQuery()\n    -\u003eselect('FirstName', 'LastName', 'Email')\n    -\u003efilter(fn (FilterBuilder $f) =\u003e $f\n        -\u003ewhereEquals('Status', 'Active')\n        -\u003eand()\n        -\u003ewhere('Age', 'gt', 30))\n    -\u003eorderByDesc('CreatedAt')\n    -\u003etop(25)\n    -\u003ecount();\n\n$response = $connector-\u003esend($request);\n// GET /People?$select=FirstName,LastName,Email\u0026$filter=Status eq 'Active' and Age gt 30\u0026$orderby=CreatedAt desc\u0026$top=25\u0026$count=true\n```\n\nThe trait exposes `$request-\u003eodataQuery()` 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 `-\u003etoArray()` yourself. If the builder is never touched and no class-level attributes apply, no middleware runs.\n\n### As a standalone builder (e.g. inside `defaultQuery()`)\n\n```php\npublic function defaultQuery(): array\n{\n    return ODataQueryBuilder::make()\n        -\u003eselect('Id', 'Name')\n        -\u003etop(50)\n        -\u003etoArray();\n}\n```\n\nOr, if you have a Request/PendingRequest in hand, use `applyTo()`:\n\n```php\nODataQueryBuilder::make()-\u003eselect('Id')-\u003eapplyTo($request);\n```\n\n### Declarative configuration with attributes\n\n```php\nuse SimpleSquid\\SaloonOData\\Attributes\\DefaultODataQuery;\nuse SimpleSquid\\SaloonOData\\Attributes\\ODataEntity;\nuse SimpleSquid\\SaloonOData\\Attributes\\UsesODataVersion;\nuse SimpleSquid\\SaloonOData\\Enums\\ODataVersion;\n\n#[UsesODataVersion(ODataVersion::V3)]\n#[ODataEntity('SalesInvoices')]\n#[DefaultODataQuery(\n    select: ['ID', 'InvoiceDate', 'AmountDC'],\n    top: 50,\n    count: true,\n    filterRaw: \"Division eq 12345\",\n)]\nclass GetSalesInvoices extends Request\n{\n    use HasODataQuery;\n    protected Method $method = Method::GET;\n    public function resolveEndpoint(): string\n    {\n        return '/'.$this-\u003eodataEntity();\n    }\n}\n```\n\nDefaults 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.\n\nThe version is resolved in this order:\n1. Explicit `ODataQueryBuilder::make($version)` call.\n2. `#[UsesODataVersion]` on the Request class (or any parent).\n3. `#[UsesODataVersion]` on the Connector class (applied at request boot).\n4. Default: v4.\n\n\u003e Filters and nested `$expand` are rendered lazily, so a connector-level version still applies cleanly even after the user has chained `-\u003efilter(...)` on the builder. The exception is `filterRaw()` — those strings are version-baked by the caller.\n\n## Builder reference\n\n### Selection\n\n```php\n$q-\u003eselect('FirstName', 'LastName', 'Email');\n$q-\u003ereplaceSelect('FirstName');   // discard previous, set anew\n$q-\u003eclearSelect();                 // discard all\n```\n\n### Filtering\n\n```php\nuse SimpleSquid\\SaloonOData\\Filter\\FilterBuilder;\n\n$q-\u003efilter(fn (FilterBuilder $f) =\u003e $f\n    -\u003ewhereEquals('Status', 'Active')      // shorthand for eq\n    -\u003ewhereNotEquals('Type', 'Draft')      // shorthand for ne\n    -\u003ewhere('Age', 'gt', 30)               // operators: eq, ne, gt, ge, lt, le, has*, in*\n    -\u003eor()                                  // switch the trailing join (default is `and`)\n    -\u003enot()                                 // negate the next clause\n    -\u003egroup(fn (FilterBuilder $g) =\u003e ...)   // wrap in parentheses\n    -\u003ein('Status', ['A', 'B'])              // v4 only\n    -\u003ehas('Roles', 'Admin')                 // v4 only\n    -\u003econtains('Name', 'foo')               // becomes substringof('foo', Name) on v3\n    -\u003estartsWith('Name', 'A')\n    -\u003eendsWith('Name', 'Z')\n    -\u003eraw('year(Created) eq 2025')          // pre-encoded escape hatch (UNSAFE for user input)\n);\n\n$q-\u003efilterRaw(\"Status eq 'Active'\");        // bypass the closure entirely (UNSAFE for user input)\n$q-\u003eclearFilter();                          // wipe all filter fragments\n```\n\nOperators accept a `ComparisonOperator` enum or a string; strings are validated. Property names are validated to prevent filter injection.\n\n### Date-only and GUID literals\n\n```php\nuse SimpleSquid\\SaloonOData\\Support\\DateOnly;\nuse SimpleSquid\\SaloonOData\\Support\\Literal;\n\n// Some endpoints prefer date-only over full datetime:\n$q-\u003efilter(fn ($f) =\u003e $f-\u003ewhere('Date', 'gt', Literal::dateOnly($dt)));\n$q-\u003efilter(fn ($f) =\u003e $f-\u003ewhere('Date', 'gt', DateOnly::from($dt)));   // equivalent\n\n// GUIDs render unquoted in v4 and as guid'...' in v3 — wrap explicitly:\n$q-\u003efilter(fn ($f) =\u003e $f-\u003ewhereEquals('Id', Literal::guid('11111111-2222-3333-4444-555555555555')));\n```\n\n### Expansion\n\n```php\n$q-\u003eexpand('Trips');                       // flat (works on v3 and v4)\n$q-\u003eexpand('Trips/Stops');                 // path style (v3 + v4)\n\n$q-\u003eexpand('Trips', fn (ExpandBuilder $e) =\u003e $e\n    -\u003eselect('Name', 'Budget')\n    -\u003efilter(fn (FilterBuilder $f) =\u003e $f-\u003ewhere('Status', 'eq', 'Completed'))\n    -\u003eorderBy('Name')\n    -\u003eorderByDesc('Budget')\n    -\u003etop(5)\n);                                         // v4 nested options; throws on v3\n$q-\u003eclearExpand();\n```\n\n### Ordering \u0026 paging\n\n```php\n$q-\u003eorderBy('LastName');                   // asc by default\n$q-\u003eorderBy('LastName', SortDirection::Desc);\n$q-\u003eorderByDesc('CreatedAt');\n$q-\u003eclearOrderBy();\n\n$q-\u003etop(50)-\u003eskip(100);\n$q-\u003eskipToken('cursor-from-server');\n$q-\u003ecount();                               // $count=true (v4) or $inlinecount=allpages (v3)\n```\n\n### Other system options\n\n```php\n$q-\u003esearch('foo bar');                     // v4 only — throws at render time on v3\n$q-\u003eformat('json');\n$q-\u003eparam('apikey', 'secret');             // arbitrary non-system param ($-prefixed keys rejected)\n```\n\n### Output\n\n```php\n$q-\u003etoArray();                             // ['$select' =\u003e '...', ...]\n$q-\u003etoQueryString();                       // RFC 3986 encoded query string\n(string) $q;                               // alias for toQueryString()\n$q-\u003eapplyTo($requestOrPendingRequest);\n$q-\u003eclone();                               // independent fork (no shared state)\n$q-\u003efresh();                               // empty builder, same version\n```\n\n## Pagination\n\n```php\nuse SimpleSquid\\SaloonOData\\Pagination\\ODataPaginator;\nuse Saloon\\PaginationPlugin\\Contracts\\Paginatable;\n\nclass GetPeople extends Request implements Paginatable { /* ... */ }\n\n// Version is resolved from #[UsesODataVersion] attributes; pass explicitly only to override.\n$paginator = new ODataPaginator($connector, new GetPeople);\n\nforeach ($paginator-\u003eitems() as $item) {\n    // single record from any page\n}\n```\n\nReads spec-defined envelope keys only:\n\n| Version | Next-link key  | Items key         |\n|---------|----------------|-------------------|\n| v4      | `@odata.nextLink` | `value`        |\n| v3 JSON-Light | `__next`     | `value`        |\n| v3 JSON-Verbose | `d.__next` | `d.results`    |\n\nThe 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.\n\nRequests that need custom item extraction can implement Saloon's `MapPaginatedResponseItems` contract.\n\n## Literal encoding\n\nAll `$filter` literal encoding goes through `Support\\Literal::encode($value, $version)`. Supported types:\n\n| PHP type | v4 output | v3 output |\n|---|---|---|\n| `null` | `null` | `null` |\n| `bool` | `true`/`false` | `true`/`false` |\n| `int` / `float` | `42` / `3.14` | `42` / `3.14` |\n| `string` | `'value'` (single-quote escape: `''`) | `'value'` |\n| `Guid` (via `Literal::guid()`) | bare `xxxxxxxx-...` | `guid'xxxxxxxx-...'` |\n| `DateTimeInterface` | `2025-01-15T10:30:00Z` | `datetime'2025-01-15T10:30:00'` |\n| `DateOnly` (via `Literal::dateOnly()` or `DateOnly::from()`) | `2025-01-15` | `datetime'2025-01-15'` |\n| `BackedEnum` | encoded `value` | encoded `value` |\n| `UnitEnum` | encoded case `name` | encoded case `name` |\n| `array` | tuple `(a,b,c)` | tuple |\n\nGUID detection is **opt-in** via `Literal::guid()` to prevent user-supplied strings that happen to look like GUIDs from silently changing semantics.\n\n## Security\n\n- 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`.\n- Literal values are version-correctly quote-escaped through `Support\\Literal`.\n- The paginator only extracts `$skiptoken` from server-supplied next-link URLs; it does not follow arbitrary URLs.\n- `filterRaw()` and `FilterBuilder::raw()` are documented escape hatches. **Never pass untrusted input to either.**\n\n## Testing\n\n```bash\ncomposer test\ncomposer analyse\ncomposer format\n```\n\n## License\n\nThe MIT License (MIT). See [LICENSE.md](LICENSE.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsimplesquid%2Fsaloonphp-odata","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsimplesquid%2Fsaloonphp-odata","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsimplesquid%2Fsaloonphp-odata/lists"}