novabytes / odata-query-parser
Framework-agnostic OData 4 query string parser for PHP. Parses $filter, $select, $expand, $orderby, and more into AST objects.
Requires
- php: ^8.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-04-25 07:38:00 UTC
README
A framework-agnostic OData 4 parser for PHP 8.2+. Parses query strings ($filter, $select, $expand, $orderby, $top, $skip, $count) and resource paths (/Products(1)/Category) into immutable AST objects.
Zero runtime dependencies.
Installation
composer require novabytes/odata-query-parser
Quick Start
use NovaBytes\OData\Parser\QueryOptionParser; $query = QueryOptionParser::parse( '$filter=Price gt 100 and contains(Name,\'Widget\')' . '&$select=Name,Price' . '&$expand=Category($select=Name;$top=5)' . '&$orderby=Price desc' . '&$top=50&$skip=10&$count=true' ); $query->filter; // BinaryExpression (and) $query->select; // [SelectItem('Name'), SelectItem('Price')] $query->expand; // [ExpandItem('Category', nestedOptions: ...)] $query->orderby; // [OrderByItem(PropertyPath('Price'), Desc)] $query->top; // 50 $query->skip; // 10 $query->count; // true
You can also parse individual query options directly:
use NovaBytes\OData\Parser\FilterParser; use NovaBytes\OData\Parser\SelectParser; use NovaBytes\OData\Parser\OrderByParser; $filter = FilterParser::parse('Price gt 100 and contains(Name,\'Widget\')'); $select = SelectParser::parse('Name,Price,Address/City'); $orderby = OrderByParser::parse('Name asc,Price desc');
Supported Query Options
$filter
Full expression language with correct operator precedence:
// Comparison operators: eq, ne, gt, ge, lt, le FilterParser::parse('Price gt 100'); FilterParser::parse('Name eq \'Milk\''); // Logical operators: and, or, not FilterParser::parse('Price gt 5 and Price lt 20'); FilterParser::parse('not contains(Name,\'test\')'); // Arithmetic operators: add, sub, mul, div, divby, mod FilterParser::parse('Price mul Quantity gt 1000'); // Grouping with parentheses FilterParser::parse('(Name eq \'A\' or Name eq \'B\') and Price lt 10'); // Property paths FilterParser::parse('Address/City eq \'London\''); // The in operator FilterParser::parse('Name in (\'Milk\',\'Cheese\',\'Butter\')');
30+ built-in functions:
// String functions FilterParser::parse('contains(Name,\'milk\')'); FilterParser::parse('startswith(Name,\'Ch\')'); FilterParser::parse('endswith(Name,\'ilk\')'); FilterParser::parse('length(Name) gt 5'); FilterParser::parse('indexof(Name,\'lk\') eq 2'); FilterParser::parse('substring(Name,1,3) eq \'ilk\''); FilterParser::parse('tolower(Name) eq \'milk\''); FilterParser::parse('toupper(Name) eq \'MILK\''); FilterParser::parse('trim(Name) eq \'Milk\''); FilterParser::parse('concat(FirstName,LastName) eq \'JohnDoe\''); // Date/time functions FilterParser::parse('year(BirthDate) eq 1990'); FilterParser::parse('month(BirthDate) eq 3'); FilterParser::parse('day(BirthDate) eq 20'); FilterParser::parse('hour(StartTime) ge 9'); FilterParser::parse('Date gt now()'); // Math functions FilterParser::parse('round(Price) eq 10'); FilterParser::parse('floor(Price) eq 9'); FilterParser::parse('ceiling(Price) eq 10');
Lambda expressions:
// any — true if any element matches FilterParser::parse('Items/any(d:d/Qty gt 100)'); // any without predicate — true if collection is non-empty FilterParser::parse('Tags/any()'); // all — true if all elements match FilterParser::parse('Items/all(d:d/Price gt 0)');
All literal types:
FilterParser::parse('Active eq true'); // boolean FilterParser::parse('Name eq null'); // null FilterParser::parse('Count eq 42'); // integer FilterParser::parse('Price lt 9.99'); // decimal FilterParser::parse('Name eq \'O\'\'Brien\''); // string (escaped quotes) FilterParser::parse('Id eq 01234567-89ab-cdef-0123-456789abcdef'); // GUID FilterParser::parse('BirthDate eq 2023-01-15'); // date FilterParser::parse('Created eq 2023-01-15T14:30:00Z'); // DateTimeOffset FilterParser::parse('Duration eq duration\'P1DT2H30M\''); // duration
$select
$items = SelectParser::parse('Name,Price,Address/City'); // [SelectItem(['Name']), SelectItem(['Price']), SelectItem(['Address', 'City'])] $items = SelectParser::parse('*'); // [SelectItem([], isWildcard: true)]
$expand
use NovaBytes\OData\Parser\ExpandParser; $items = ExpandParser::parse('Products,Category'); // [ExpandItem(['Products']), ExpandItem(['Category'])] // With nested query options (semicolon-separated inside parentheses) $items = ExpandParser::parse('Products($filter=Price gt 100;$select=Name;$top=5)'); // ExpandItem(['Products'], nestedOptions: QueryOptions(filter: ..., select: ..., top: 5))
$orderby
$items = OrderByParser::parse('Name asc,Price desc'); // [OrderByItem(PropertyPath('Name'), Asc), OrderByItem(PropertyPath('Price'), Desc)] // Default direction is ascending $items = OrderByParser::parse('Name'); // [OrderByItem(PropertyPath('Name'), Asc)]
$top, $skip, $count
Parsed as part of QueryOptionParser::parse():
$query = QueryOptionParser::parse('$top=10&$skip=20&$count=true'); $query->top; // 10 $query->skip; // 20 $query->count; // true
Resource Path Parsing
Parse OData resource paths (the URL path portion) into structured AST nodes:
use NovaBytes\OData\Parser\ResourcePathParser; // Entity set collection $path = ResourcePathParser::parse('/Products'); $path->entitySet; // 'Products' $path->key; // null $path->isSingleEntity(); // false // Single entity by key $path = ResourcePathParser::parse('/Products(1)'); $path->key->getSingleValue(); // 1 // String keys $path = ResourcePathParser::parse("/Products('abc')"); $path->key->getSingleValue(); // 'abc' // Composite keys $path = ResourcePathParser::parse('/OrderItems(OrderId=1,ItemId=2)'); $path->key->values; // ['OrderId' => 1, 'ItemId' => 2] // Navigation segments $path = ResourcePathParser::parse('/Products(1)/Category'); $path->navigationSegments[0]->property; // 'Category' // GUID keys $path = ResourcePathParser::parse('/Products(01234567-89ab-cdef-0123-456789abcdef)');
AST Structure
Every parsed result is an immutable (readonly class) AST node. The $filter expression tree uses these node types:
| Node | Description |
|---|---|
BinaryExpression |
left operator right (e.g. Price gt 100, A and B) |
UnaryExpression |
operator operand (e.g. not expr, -5) |
PropertyPath |
Dotted/slashed property reference (e.g. Address/City) |
Literal |
Typed value: null, boolean, integer, decimal, string, GUID, date, etc. |
FunctionCall |
Built-in function with arguments (e.g. contains(Name,'x')) |
LambdaExpression |
collection/any(var:predicate) or collection/all(var:predicate) |
ListExpression |
Parenthesized list for in operator (e.g. ('A','B','C')) |
Visitor Pattern
Implement ExpressionVisitor to transform the AST into whatever you need:
use NovaBytes\OData\AST\Filter\BinaryExpression; use NovaBytes\OData\AST\Filter\Literal; use NovaBytes\OData\AST\Filter\PropertyPath; use NovaBytes\OData\Visitor\ExpressionVisitor; class SqlWhereVisitor implements ExpressionVisitor { public function visitBinaryExpression(BinaryExpression $expr): string { $left = $this->visit($expr->left); $right = $this->visit($expr->right); $op = match($expr->operator) { BinaryOperator::Eq => '=', BinaryOperator::Ne => '!=', BinaryOperator::Gt => '>', // ... }; return "{$left} {$op} {$right}"; } public function visitPropertyPath(PropertyPath $expr): string { return implode('.', $expr->segments); } public function visitLiteral(Literal $expr): string { // Use parameterized queries in real code! return match($expr->type) { LiteralType::String => "'{$expr->value}'", LiteralType::Null => 'NULL', default => (string) $expr->value, }; } // ... implement remaining visit methods }
A StringifyVisitor is included for round-tripping AST back to OData syntax:
use NovaBytes\OData\Visitor\StringifyVisitor; $expr = FilterParser::parse('Price gt 100 and Name eq \'Milk\''); $visitor = new StringifyVisitor(); echo $visitor->stringify($expr); // Price gt 100 and Name eq 'Milk'
Error Handling
All parse errors throw NovaBytes\OData\Exception\ParseException with position information:
try { FilterParser::parse('Price gtt 100'); } catch (ParseException $e) { $e->getMessage(); // "Unexpected 'gtt' at position 6; expected ..." $e->position; // 6 }
OData 4 Support
System Query Options
| Query Option | Status | Notes |
|---|---|---|
$filter |
Supported | Full expression language with correct operator precedence |
$select |
Supported | Property paths, wildcards (*), nested options |
$expand |
Supported | Navigation paths, nested query options ($filter, $select, $top, etc.) |
$orderby |
Supported | Expressions with asc/desc, multiple sort keys |
$top |
Supported | |
$skip |
Supported | |
$count |
Supported | Inline count (true/false) |
$search |
Not yet | Planned for a future release |
$compute |
Not yet | Planned for a future release |
$apply |
Not yet | Data aggregation extension, planned for a future release |
$format |
Not yet | |
$index |
Not yet | |
$schemaversion |
Not yet | |
$skiptoken |
Not yet | Opaque server-driven paging token |
$deltatoken |
Not yet | Opaque server-driven delta token |
Filter Operators
| Category | Operators | Status |
|---|---|---|
| Comparison | eq, ne, gt, ge, lt, le |
Supported |
| Logical | and, or, not |
Supported |
| Arithmetic | add, sub, mul, div, divby, mod |
Supported |
| Membership | in |
Supported |
| Enum flags | has |
Supported |
| Grouping | ( ) |
Supported |
| Lambda | any, all |
Supported |
| Negation | - (unary minus) |
Supported |
Filter Functions
| Category | Functions | Status |
|---|---|---|
| String | contains, startswith, endswith, length, indexof, substring, tolower, toupper, trim, concat, matchesPattern |
Supported |
| Date/Time | year, month, day, hour, minute, second, fractionalseconds, totalseconds, date, time, totaloffsetminutes, now, mindatetime, maxdatetime |
Supported |
| Math | round, floor, ceiling |
Supported |
| Geo | geo.distance, geo.length, geo.intersects |
Supported |
| Collection | hassubset, hassubsequence |
Supported |
| Type | cast, isof |
Supported |
Literal Types
| Type | Example | Status |
|---|---|---|
| Null | null |
Supported |
| Boolean | true, false |
Supported |
| Integer | 42, -1 |
Supported |
| Decimal | 3.14, 1.5e10 |
Supported |
| String | 'Milk', 'O''Brien' |
Supported |
| GUID | 01234567-89ab-cdef-0123-456789abcdef |
Supported |
| Date | 2023-01-15 |
Supported |
| DateTimeOffset | 2023-01-15T14:30:00Z |
Supported |
| TimeOfDay | 14:30:00 |
Supported |
| Duration | duration'P1DT2H30M' |
Supported |
| NaN / Infinity | NaN, INF, -INF |
Supported |
| Binary | binary'T0RhdGE=' |
Not yet |
| Enum | Namespace.Color'Red' |
Not yet |
| Geography/Geometry | geography'SRID=0;Point(...)' |
Not yet |
Metadata
Generate OData metadata documents and OpenAPI specifications from entity type definitions.
Entity Types
Define your entity types using the metadata value objects:
use NovaBytes\OData\Metadata\EntityType; use NovaBytes\OData\Metadata\PropertyMetadata; use NovaBytes\OData\Metadata\NavigationPropertyMetadata; $product = new EntityType( name: 'Product', entitySetName: 'Products', keyProperty: 'Id', properties: [ new PropertyMetadata('Id', 'Edm.Int64', nullable: false, filterable: true, sortable: true, selectable: true), new PropertyMetadata('Name', 'Edm.String', nullable: false, filterable: true, sortable: true, selectable: true, creatable: true, updatable: true), new PropertyMetadata('Price', 'Edm.Decimal', nullable: false, filterable: true, sortable: true, selectable: true, creatable: true, updatable: true), ], navigationProperties: [ new NavigationPropertyMetadata('Category', 'Category', isCollection: false), new NavigationPropertyMetadata('Reviews', 'Review', isCollection: true), ], operations: ['read', 'create', 'update', 'delete'], );
EDM Type Resolver
Map database column types to OData EDM types:
use NovaBytes\OData\Metadata\EdmTypeResolver; EdmTypeResolver::resolve('integer'); // 'Edm.Int32' EdmTypeResolver::resolve('varchar'); // 'Edm.String' EdmTypeResolver::resolve('timestamp'); // 'Edm.DateTimeOffset' EdmTypeResolver::resolve('boolean'); // 'Edm.Boolean'
CSDL Generation
Generate an OData v4 CSDL XML metadata document. When entity types have CRUD operations, capability annotations (InsertRestrictions, UpdateRestrictions, DeleteRestrictions) are included automatically:
use NovaBytes\OData\Metadata\CsdlGenerator; $xml = CsdlGenerator::generate('MyApp', [$product, $category]); // Returns valid OData v4 CSDL XML with capability annotations
OpenAPI Generation
Generate an OpenAPI 3.0 specification. CRUD operations are reflected as separate paths and schemas:
use NovaBytes\OData\Metadata\OpenApiGenerator; $spec = OpenApiGenerator::generate([$product, $category], [ 'title' => 'My OData API', 'version' => '1.0.0', ]); // Generates: // GET /Products — list entities // POST /Products — create entity (with ProductCreate schema) // GET /Products({Id}) — get single entity // PUT /Products({Id}) — full replace (with ProductUpdate schema) // PATCH /Products({Id}) — partial update // DELETE /Products({Id}) — delete entity echo json_encode($spec, JSON_PRETTY_PRINT);
Design Decisions
- Framework-agnostic -- zero dependencies, works with any PHP 8.2+ project. Use it with Laravel, Symfony, API Platform, Slim, or plain PHP.
- Schema-unaware -- the parser does not validate property names or types against a data model. It parses syntax only. Schema validation belongs in a separate layer.
- Immutable AST -- all nodes are
readonly class, safe to cache and share. - Pratt parser -- the
$filterexpression parser uses top-down operator precedence parsing for correct handling of all 11 precedence levels defined in the OData 4.01 spec.
Requirements
- PHP >= 8.2
License
MIT