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.
composer require novabytes/odata-query-parseruse 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; // trueYou 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');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$items = SelectParser::parse('Name,Price,Address/City');
// [SelectItem(['Name']), SelectItem(['Price']), SelectItem(['Address', 'City'])]
$items = SelectParser::parse('*');
// [SelectItem([], isWildcard: true)]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))$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)]Parsed as part of QueryOptionParser::parse():
$query = QueryOptionParser::parse('$top=10&$skip=20&$count=true');
$query->top; // 10
$query->skip; // 20
$query->count; // trueParse 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)');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')) |
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'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
}| 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 |
| 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 |
| 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 |
| 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 |
Generate OData metadata documents and OpenAPI specifications from entity type definitions.
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'],
);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'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 annotationsGenerate 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);- 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.
- PHP >= 8.2
MIT