Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,75 @@ This approach allows you to customize request creation without having to overrid

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

### Lambda Operators (any/all)

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.

#### Basic Usage

```php
<?php

use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;

$httpProvider = new GuzzleHttpProvider();
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);

// Find people who have any completed trips
$peopleWithCompletedTrips = $client->from('People')
->whereAny('Trips', function($query) {
$query->where('Status', 'Completed');
})
->get();
// Generates: People?$filter=Trips/any(t: t/Status eq 'Completed')

// Find people where all their trips are high-budget
$peopleWithAllHighBudgetTrips = $client->from('People')
->whereAll('Trips', function($query) {
$query->where('Budget', '>', 1000);
})
->get();
// Generates: People?$filter=Trips/all(t: t/Budget gt 1000)
```

#### Available Lambda Methods

- `whereAny($navigationProperty, $callback)` - Returns true if any element matches the condition
- `whereAll($navigationProperty, $callback)` - Returns true if all elements match the condition
- `orWhereAny($navigationProperty, $callback)` - OR version of whereAny
- `orWhereAll($navigationProperty, $callback)` - OR version of whereAll

#### Complex Conditions

```php
// Multiple conditions within lambda
$peopleWithQualifiedTrips = $client->from('People')
->whereAny('Trips', function($query) {
$query->where('Status', 'Completed')
->where('Budget', '>', 500);
})
->get();
// Generates: People?$filter=Trips/any(t: t/Status eq 'Completed' and t/Budget gt 500)

// Combining with regular conditions
$activePeopleWithTrips = $client->from('People')
->where('Status', 'Active')
->whereAny('Trips', function($query) {
$query->where('Status', 'Pending');
})
->get();
// Generates: People?$filter=Status eq 'Active' and Trips/any(t: t/Status eq 'Pending')
```

**Key Features:**
- **Automatic variable generation**: Uses first letter of navigation property (e.g., `Trips` → `t`)
- **Full operator support**: Supports all comparison operators (eq, ne, gt, ge, lt, le)
- **Nested conditions**: Handles complex where clauses within lambda expressions
- **Fluent interface**: Works seamlessly with other query builder methods

For comprehensive examples and advanced usage patterns, see [`examples/lambda_operators.php`](examples/lambda_operators.php).

## Develop

### Run Tests
Expand Down
102 changes: 102 additions & 0 deletions examples/lambda_operators.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

/**
* Lambda Operators Usage Examples
*
* This file demonstrates how to use the new lambda operators (any/all)
* with the OData Client for PHP.
*/

require_once 'vendor/autoload.php';

use SaintSystems\OData\ODataClient;
use SaintSystems\OData\GuzzleHttpProvider;

// Initialize the OData client
$httpProvider = new GuzzleHttpProvider();
$client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider);

// Example 1: Find customers who have any completed orders
$customersWithCompletedOrders = $client->from('People')
->whereAny('Orders', function($query) {
$query->where('Status', 'Completed');
})
->get();

// Generates: People?$filter=Orders/any(o: o/Status eq 'Completed')

// Example 2: Find customers where all their orders are high-value
$customersWithAllHighValueOrders = $client->from('People')
->whereAll('Orders', function($query) {
$query->where('Amount', '>', 100);
})
->get();

// Generates: People?$filter=Orders/all(o: o/Amount gt 100)

// Example 3: Complex conditions with multiple criteria
$customersWithQualifiedOrders = $client->from('People')
->whereAny('Orders', function($query) {
$query->where('Status', 'Completed')
->where('Amount', '>', 50);
})
->get();

// Generates: People?$filter=Orders/any(o: o/Status eq 'Completed' and o/Amount gt 50)

// Example 4: Combining lambda operators with regular conditions
$activeCustomersWithOrders = $client->from('People')
->where('Status', 'Active')
->whereAny('Orders', function($query) {
$query->where('Status', 'Pending');
})
->get();

// Generates: People?$filter=Status eq 'Active' and Orders/any(o: o/Status eq 'Pending')

// Example 5: Using orWhereAny and orWhereAll
$flexibleCustomerQuery = $client->from('People')
->where('Status', 'Active')
->orWhereAny('Orders', function($query) {
$query->where('Priority', 'High');
})
->get();

// Generates: People?$filter=Status eq 'Active' or Orders/any(o: o/Priority eq 'High')

// Example 6: Nested conditions within lambda
$complexCustomerQuery = $client->from('People')
->whereAny('Orders', function($query) {
$query->where(function($nested) {
$nested->where('Status', 'Completed')
->orWhere('Status', 'Shipped');
})->where('Amount', '>', 100);
})
->get();

// Generates: People?$filter=Orders/any(o: (o/Status eq 'Completed' or o/Status eq 'Shipped') and o/Amount gt 100)

// Example 7: Multiple lambda operators on different navigation properties
$qualifiedCustomers = $client->from('People')
->whereAny('Orders', function($query) {
$query->where('Status', 'Completed');
})
->whereAll('Reviews', function($query) {
$query->where('Rating', '>=', 4);
})
->get();

// Generates: People?$filter=Orders/any(o: o/Status eq 'Completed') and Reviews/all(r: r/Rating ge 4)

// Example 8: Lambda operators with select
$customerSummary = $client->from('People')
->select('FirstName', 'LastName', 'Email')
->whereAny('Orders', function($query) {
$query->where('OrderDate', '>=', '2023-01-01');
})
->get();

// Generates: People?$select=FirstName,LastName,Email&$filter=Orders/any(o: o/OrderDate ge '2023-01-01')

echo "Lambda operators are now available!\n";
echo "Check the examples above for usage patterns.\n";
68 changes: 68 additions & 0 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,74 @@ public function orWhereNotContains($column, $value)
return $this->whereNotContains($column, $value, 'or');
}

/**
* Add a "where any" lambda clause to the query.
*
* @param string $navigationProperty
* @param \Closure $callback
* @param string $boolean
* @return $this
*/
public function whereAny($navigationProperty, Closure $callback, $boolean = 'and')
{
$type = 'Any';

// Create a new query instance for the lambda condition
call_user_func($callback, $query = $this->forNestedWhere());

$this->wheres[] = compact('type', 'navigationProperty', 'query', 'boolean');

$this->addBinding($query->getBindings(), 'where');

return $this;
}

/**
* Add an "or where any" lambda clause to the query.
*
* @param string $navigationProperty
* @param \Closure $callback
* @return Builder|static
*/
public function orWhereAny($navigationProperty, Closure $callback)
{
return $this->whereAny($navigationProperty, $callback, 'or');
}

/**
* Add a "where all" lambda clause to the query.
*
* @param string $navigationProperty
* @param \Closure $callback
* @param string $boolean
* @return $this
*/
public function whereAll($navigationProperty, Closure $callback, $boolean = 'and')
{
$type = 'All';

// Create a new query instance for the lambda condition
call_user_func($callback, $query = $this->forNestedWhere());

$this->wheres[] = compact('type', 'navigationProperty', 'query', 'boolean');

$this->addBinding($query->getBindings(), 'where');

return $this;
}

/**
* Add an "or where all" lambda clause to the query.
*
* @param string $navigationProperty
* @param \Closure $callback
* @return Builder|static
*/
public function orWhereAll($navigationProperty, Closure $callback)
{
return $this->whereAll($navigationProperty, $callback, 'or');
}



/**
Expand Down
88 changes: 88 additions & 0 deletions src/Query/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,94 @@ protected function whereNotContains(Builder $query, $where)
return 'indexof(' . $where['column'] . ',' . $value . ') eq -1';
}

/**
* Compile a "where any" lambda clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function whereAny(Builder $query, $where)
{
$lambdaVariable = strtolower(substr($where['navigationProperty'], 0, 1));
$nestedWhere = $this->compileWheres($where['query']);

// Extract the condition part from the nested where clause
$condition = $this->extractLambdaCondition($nestedWhere, $lambdaVariable);

return $where['navigationProperty'] . '/any(' . $lambdaVariable . ': ' . $condition . ')';
}

/**
* Compile a "where all" lambda clause.
*
* @param Builder $query
* @param array $where
* @return string
*/
protected function whereAll(Builder $query, $where)
{
$lambdaVariable = strtolower(substr($where['navigationProperty'], 0, 1));
$nestedWhere = $this->compileWheres($where['query']);

// Extract the condition part from the nested where clause
$condition = $this->extractLambdaCondition($nestedWhere, $lambdaVariable);

return $where['navigationProperty'] . '/all(' . $lambdaVariable . ': ' . $condition . ')';
}

/**
* Extract the lambda condition from nested where clause and prefix columns with lambda variable.
*
* @param string $nestedWhere
* @param string $lambdaVariable
* @return string
*/
protected function extractLambdaCondition($nestedWhere, $lambdaVariable)
{
// Remove the $filter= prefix from nested where clause
$offset = (substr($nestedWhere, 0, 1) === '&') ? 9 : 8;
$condition = substr($nestedWhere, $offset);

// If the condition starts with '(' and ends with ')', it's already properly nested
// This happens when multiple where clauses are used in the lambda
if (substr($condition, 0, 1) === '(' && substr($condition, -1) === ')') {
// Remove outer parentheses temporarily to process inner content
$innerCondition = substr($condition, 1, -1);
$processedInner = $this->prefixColumnsWithLambdaVariable($innerCondition, $lambdaVariable);
return '(' . $processedInner . ')';
} else {
// Single condition, process normally
return $this->prefixColumnsWithLambdaVariable($condition, $lambdaVariable);
}
}

/**
* Prefix column names with lambda variable.
*
* @param string $condition
* @param string $lambdaVariable
* @return string
*/
protected function prefixColumnsWithLambdaVariable($condition, $lambdaVariable)
{
// Replace column references with lambda variable prefix
// Use a more precise pattern that only matches property names at the start of comparisons
return preg_replace_callback('/\b([a-zA-Z_][a-zA-Z0-9_]*)\s+(eq|ne|gt|ge|lt|le)\s+/', function($matches) use ($lambdaVariable) {
$property = $matches[1];
$operator = $matches[2];

// Don't prefix if it's already prefixed, a keyword, or looks like it's already processed
if (strpos($property, '/') !== false ||
in_array($property, ['and', 'or', 'not', 'eq', 'ne', 'gt', 'ge', 'lt', 'le', 'true', 'false', 'null']) ||
strlen($property) < 2) {
return $matches[0];
}

return $lambdaVariable . '/' . $property . ' ' . $operator . ' ';
}, $condition);
}

/**
* Append query param to existing uri
*
Expand Down
Loading