Skip to content

Commit a0bfdd4

Browse files
Copilotanderly
andauthored
Add lambda operators (any/all) support for OData queries (#182)
* Initial plan * Implement lambda operators (any/all) support for OData queries Co-authored-by: anderly <[email protected]> * Update README with lambda operators usage examples and examples file reference Co-authored-by: anderly <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: anderly <[email protected]>
1 parent f7fa627 commit a0bfdd4

File tree

5 files changed

+493
-0
lines changed

5 files changed

+493
-0
lines changed

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,75 @@ This approach allows you to customize request creation without having to overrid
231231

232232
For a complete working example, see [`examples/custom_headers_example.php`](examples/custom_headers_example.php).
233233

234+
### Lambda Operators (any/all)
235+
236+
The OData Client supports lambda operators `any` and `all` for filtering collections within entities. These operators allow you to filter based on conditions within related navigation properties.
237+
238+
#### Basic Usage
239+
240+
```php
241+
<?php
242+
243+
use SaintSystems\OData\ODataClient;
244+
use SaintSystems\OData\GuzzleHttpProvider;
245+
246+
$httpProvider = new GuzzleHttpProvider();
247+
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);
248+
249+
// Find people who have any completed trips
250+
$peopleWithCompletedTrips = $client->from('People')
251+
->whereAny('Trips', function($query) {
252+
$query->where('Status', 'Completed');
253+
})
254+
->get();
255+
// Generates: People?$filter=Trips/any(t: t/Status eq 'Completed')
256+
257+
// Find people where all their trips are high-budget
258+
$peopleWithAllHighBudgetTrips = $client->from('People')
259+
->whereAll('Trips', function($query) {
260+
$query->where('Budget', '>', 1000);
261+
})
262+
->get();
263+
// Generates: People?$filter=Trips/all(t: t/Budget gt 1000)
264+
```
265+
266+
#### Available Lambda Methods
267+
268+
- `whereAny($navigationProperty, $callback)` - Returns true if any element matches the condition
269+
- `whereAll($navigationProperty, $callback)` - Returns true if all elements match the condition
270+
- `orWhereAny($navigationProperty, $callback)` - OR version of whereAny
271+
- `orWhereAll($navigationProperty, $callback)` - OR version of whereAll
272+
273+
#### Complex Conditions
274+
275+
```php
276+
// Multiple conditions within lambda
277+
$peopleWithQualifiedTrips = $client->from('People')
278+
->whereAny('Trips', function($query) {
279+
$query->where('Status', 'Completed')
280+
->where('Budget', '>', 500);
281+
})
282+
->get();
283+
// Generates: People?$filter=Trips/any(t: t/Status eq 'Completed' and t/Budget gt 500)
284+
285+
// Combining with regular conditions
286+
$activePeopleWithTrips = $client->from('People')
287+
->where('Status', 'Active')
288+
->whereAny('Trips', function($query) {
289+
$query->where('Status', 'Pending');
290+
})
291+
->get();
292+
// Generates: People?$filter=Status eq 'Active' and Trips/any(t: t/Status eq 'Pending')
293+
```
294+
295+
**Key Features:**
296+
- **Automatic variable generation**: Uses first letter of navigation property (e.g., `Trips``t`)
297+
- **Full operator support**: Supports all comparison operators (eq, ne, gt, ge, lt, le)
298+
- **Nested conditions**: Handles complex where clauses within lambda expressions
299+
- **Fluent interface**: Works seamlessly with other query builder methods
300+
301+
For comprehensive examples and advanced usage patterns, see [`examples/lambda_operators.php`](examples/lambda_operators.php).
302+
234303
## Develop
235304

236305
### Run Tests

examples/lambda_operators.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
/**
4+
* Lambda Operators Usage Examples
5+
*
6+
* This file demonstrates how to use the new lambda operators (any/all)
7+
* with the OData Client for PHP.
8+
*/
9+
10+
require_once 'vendor/autoload.php';
11+
12+
use SaintSystems\OData\ODataClient;
13+
use SaintSystems\OData\GuzzleHttpProvider;
14+
15+
// Initialize the OData client
16+
$httpProvider = new GuzzleHttpProvider();
17+
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);
18+
19+
// Example 1: Find customers who have any completed orders
20+
$customersWithCompletedOrders = $client->from('People')
21+
->whereAny('Orders', function($query) {
22+
$query->where('Status', 'Completed');
23+
})
24+
->get();
25+
26+
// Generates: People?$filter=Orders/any(o: o/Status eq 'Completed')
27+
28+
// Example 2: Find customers where all their orders are high-value
29+
$customersWithAllHighValueOrders = $client->from('People')
30+
->whereAll('Orders', function($query) {
31+
$query->where('Amount', '>', 100);
32+
})
33+
->get();
34+
35+
// Generates: People?$filter=Orders/all(o: o/Amount gt 100)
36+
37+
// Example 3: Complex conditions with multiple criteria
38+
$customersWithQualifiedOrders = $client->from('People')
39+
->whereAny('Orders', function($query) {
40+
$query->where('Status', 'Completed')
41+
->where('Amount', '>', 50);
42+
})
43+
->get();
44+
45+
// Generates: People?$filter=Orders/any(o: o/Status eq 'Completed' and o/Amount gt 50)
46+
47+
// Example 4: Combining lambda operators with regular conditions
48+
$activeCustomersWithOrders = $client->from('People')
49+
->where('Status', 'Active')
50+
->whereAny('Orders', function($query) {
51+
$query->where('Status', 'Pending');
52+
})
53+
->get();
54+
55+
// Generates: People?$filter=Status eq 'Active' and Orders/any(o: o/Status eq 'Pending')
56+
57+
// Example 5: Using orWhereAny and orWhereAll
58+
$flexibleCustomerQuery = $client->from('People')
59+
->where('Status', 'Active')
60+
->orWhereAny('Orders', function($query) {
61+
$query->where('Priority', 'High');
62+
})
63+
->get();
64+
65+
// Generates: People?$filter=Status eq 'Active' or Orders/any(o: o/Priority eq 'High')
66+
67+
// Example 6: Nested conditions within lambda
68+
$complexCustomerQuery = $client->from('People')
69+
->whereAny('Orders', function($query) {
70+
$query->where(function($nested) {
71+
$nested->where('Status', 'Completed')
72+
->orWhere('Status', 'Shipped');
73+
})->where('Amount', '>', 100);
74+
})
75+
->get();
76+
77+
// Generates: People?$filter=Orders/any(o: (o/Status eq 'Completed' or o/Status eq 'Shipped') and o/Amount gt 100)
78+
79+
// Example 7: Multiple lambda operators on different navigation properties
80+
$qualifiedCustomers = $client->from('People')
81+
->whereAny('Orders', function($query) {
82+
$query->where('Status', 'Completed');
83+
})
84+
->whereAll('Reviews', function($query) {
85+
$query->where('Rating', '>=', 4);
86+
})
87+
->get();
88+
89+
// Generates: People?$filter=Orders/any(o: o/Status eq 'Completed') and Reviews/all(r: r/Rating ge 4)
90+
91+
// Example 8: Lambda operators with select
92+
$customerSummary = $client->from('People')
93+
->select('FirstName', 'LastName', 'Email')
94+
->whereAny('Orders', function($query) {
95+
$query->where('OrderDate', '>=', '2023-01-01');
96+
})
97+
->get();
98+
99+
// Generates: People?$select=FirstName,LastName,Email&$filter=Orders/any(o: o/OrderDate ge '2023-01-01')
100+
101+
echo "Lambda operators are now available!\n";
102+
echo "Check the examples above for usage patterns.\n";

src/Query/Builder.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,74 @@ public function orWhereNotContains($column, $value)
10151015
return $this->whereNotContains($column, $value, 'or');
10161016
}
10171017

1018+
/**
1019+
* Add a "where any" lambda clause to the query.
1020+
*
1021+
* @param string $navigationProperty
1022+
* @param \Closure $callback
1023+
* @param string $boolean
1024+
* @return $this
1025+
*/
1026+
public function whereAny($navigationProperty, Closure $callback, $boolean = 'and')
1027+
{
1028+
$type = 'Any';
1029+
1030+
// Create a new query instance for the lambda condition
1031+
call_user_func($callback, $query = $this->forNestedWhere());
1032+
1033+
$this->wheres[] = compact('type', 'navigationProperty', 'query', 'boolean');
1034+
1035+
$this->addBinding($query->getBindings(), 'where');
1036+
1037+
return $this;
1038+
}
1039+
1040+
/**
1041+
* Add an "or where any" lambda clause to the query.
1042+
*
1043+
* @param string $navigationProperty
1044+
* @param \Closure $callback
1045+
* @return Builder|static
1046+
*/
1047+
public function orWhereAny($navigationProperty, Closure $callback)
1048+
{
1049+
return $this->whereAny($navigationProperty, $callback, 'or');
1050+
}
1051+
1052+
/**
1053+
* Add a "where all" lambda clause to the query.
1054+
*
1055+
* @param string $navigationProperty
1056+
* @param \Closure $callback
1057+
* @param string $boolean
1058+
* @return $this
1059+
*/
1060+
public function whereAll($navigationProperty, Closure $callback, $boolean = 'and')
1061+
{
1062+
$type = 'All';
1063+
1064+
// Create a new query instance for the lambda condition
1065+
call_user_func($callback, $query = $this->forNestedWhere());
1066+
1067+
$this->wheres[] = compact('type', 'navigationProperty', 'query', 'boolean');
1068+
1069+
$this->addBinding($query->getBindings(), 'where');
1070+
1071+
return $this;
1072+
}
1073+
1074+
/**
1075+
* Add an "or where all" lambda clause to the query.
1076+
*
1077+
* @param string $navigationProperty
1078+
* @param \Closure $callback
1079+
* @return Builder|static
1080+
*/
1081+
public function orWhereAll($navigationProperty, Closure $callback)
1082+
{
1083+
return $this->whereAll($navigationProperty, $callback, 'or');
1084+
}
1085+
10181086

10191087

10201088
/**

src/Query/Grammar.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,94 @@ protected function whereNotContains(Builder $query, $where)
835835
return 'indexof(' . $where['column'] . ',' . $value . ') eq -1';
836836
}
837837

838+
/**
839+
* Compile a "where any" lambda clause.
840+
*
841+
* @param Builder $query
842+
* @param array $where
843+
* @return string
844+
*/
845+
protected function whereAny(Builder $query, $where)
846+
{
847+
$lambdaVariable = strtolower(substr($where['navigationProperty'], 0, 1));
848+
$nestedWhere = $this->compileWheres($where['query']);
849+
850+
// Extract the condition part from the nested where clause
851+
$condition = $this->extractLambdaCondition($nestedWhere, $lambdaVariable);
852+
853+
return $where['navigationProperty'] . '/any(' . $lambdaVariable . ': ' . $condition . ')';
854+
}
855+
856+
/**
857+
* Compile a "where all" lambda clause.
858+
*
859+
* @param Builder $query
860+
* @param array $where
861+
* @return string
862+
*/
863+
protected function whereAll(Builder $query, $where)
864+
{
865+
$lambdaVariable = strtolower(substr($where['navigationProperty'], 0, 1));
866+
$nestedWhere = $this->compileWheres($where['query']);
867+
868+
// Extract the condition part from the nested where clause
869+
$condition = $this->extractLambdaCondition($nestedWhere, $lambdaVariable);
870+
871+
return $where['navigationProperty'] . '/all(' . $lambdaVariable . ': ' . $condition . ')';
872+
}
873+
874+
/**
875+
* Extract the lambda condition from nested where clause and prefix columns with lambda variable.
876+
*
877+
* @param string $nestedWhere
878+
* @param string $lambdaVariable
879+
* @return string
880+
*/
881+
protected function extractLambdaCondition($nestedWhere, $lambdaVariable)
882+
{
883+
// Remove the $filter= prefix from nested where clause
884+
$offset = (substr($nestedWhere, 0, 1) === '&') ? 9 : 8;
885+
$condition = substr($nestedWhere, $offset);
886+
887+
// If the condition starts with '(' and ends with ')', it's already properly nested
888+
// This happens when multiple where clauses are used in the lambda
889+
if (substr($condition, 0, 1) === '(' && substr($condition, -1) === ')') {
890+
// Remove outer parentheses temporarily to process inner content
891+
$innerCondition = substr($condition, 1, -1);
892+
$processedInner = $this->prefixColumnsWithLambdaVariable($innerCondition, $lambdaVariable);
893+
return '(' . $processedInner . ')';
894+
} else {
895+
// Single condition, process normally
896+
return $this->prefixColumnsWithLambdaVariable($condition, $lambdaVariable);
897+
}
898+
}
899+
900+
/**
901+
* Prefix column names with lambda variable.
902+
*
903+
* @param string $condition
904+
* @param string $lambdaVariable
905+
* @return string
906+
*/
907+
protected function prefixColumnsWithLambdaVariable($condition, $lambdaVariable)
908+
{
909+
// Replace column references with lambda variable prefix
910+
// Use a more precise pattern that only matches property names at the start of comparisons
911+
return preg_replace_callback('/\b([a-zA-Z_][a-zA-Z0-9_]*)\s+(eq|ne|gt|ge|lt|le)\s+/', function($matches) use ($lambdaVariable) {
912+
$property = $matches[1];
913+
$operator = $matches[2];
914+
915+
// Don't prefix if it's already prefixed, a keyword, or looks like it's already processed
916+
if (strpos($property, '/') !== false ||
917+
in_array($property, ['and', 'or', 'not', 'eq', 'ne', 'gt', 'ge', 'lt', 'le', 'true', 'false', 'null']) ||
918+
strlen($property) < 2) {
919+
return $matches[0];
920+
}
921+
922+
return $lambdaVariable . '/' . $property . ' ' . $operator . ' ';
923+
}, $condition);
924+
}
925+
838926
/**
839927
* Append query param to existing uri
840928
*

0 commit comments

Comments
 (0)