Skip to content

Commit 967b0b6

Browse files
Copilotanderly
andauthored
Improve custom query options: add merging, validation, and comprehensive documentation (#181)
* Initial plan * Improve custom query options: add merging, validation, tests, and documentation Co-authored-by: anderly <[email protected]> * Add validation for '@' character in custom option keys per OData specification Co-authored-by: anderly <[email protected]> * Fix CustomOptionsTest: provide required parameter for ODataResponse constructor Co-authored-by: anderly <[email protected]> * Fix CustomOptionsTest: Mock PSR-7 ResponseInterface instead of ODataResponse - Fix type mismatch error in CustomOptionsTest by updating the mock to return PSR-7 ResponseInterface instead of ODataResponse - IHttpProvider::send() method declares ResponseInterface as return type, but tests were mocking it to return ODataResponse - ODataResponse does not implement ResponseInterface, causing type constraint violations in PHP 7.4+ - Updated createMockHttpProvider() to create proper PSR-7 response mocks with required methods - This resolves all 19 test failures in CustomOptionsTest across PHP matrix (7.4, 8.0, 8.1, 8.2, 8.3, 8.4) Co-authored-by: anderly <[email protected]> * Fix custom options test and concatenate method. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: anderly <[email protected]> Co-authored-by: Adam Anderly <[email protected]>
1 parent d6add66 commit 967b0b6

File tree

4 files changed

+526
-17
lines changed

4 files changed

+526
-17
lines changed

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,59 @@ $airlines = $odataClient->from('Airlines')
136136
->get();
137137
```
138138

139+
### Custom Query Options
140+
141+
You can add custom query parameters to your OData requests that are not part of the standard OData specification. This is useful for passing additional parameters to your OData service:
142+
143+
```php
144+
<?php
145+
146+
use SaintSystems\OData\ODataClient;
147+
use SaintSystems\OData\GuzzleHttpProvider;
148+
149+
$httpProvider = new GuzzleHttpProvider();
150+
$odataClient = new ODataClient($odataServiceUrl, null, $httpProvider);
151+
152+
// Method 1: Add custom options using string format
153+
$people = $odataClient->from('People')
154+
->addOption('timeout=30')
155+
->addOption('format=minimal')
156+
->get();
157+
// Results in: /People?timeout=30&format=minimal
158+
159+
// Method 2: Add custom options using array format
160+
$people = $odataClient->from('People')
161+
->addOption(['timeout' => '30', 'debug' => 'true'])
162+
->get();
163+
// Results in: /People?timeout=30&debug=true
164+
165+
// Method 3: Mix with standard OData parameters
166+
$people = $odataClient->from('People')
167+
->select('FirstName', 'LastName')
168+
->where('FirstName', 'Russell')
169+
->addOption('version=2.0')
170+
->get();
171+
// Results in: /People?$select=FirstName,LastName&$filter=FirstName eq 'Russell'&version=2.0
172+
173+
// Method 4: Multiple addOption calls are merged (not overwritten)
174+
$people = $odataClient->from('People')
175+
->addOption('timeout=30')
176+
->addOption('format=minimal')
177+
->addOption(['debug' => 'true']);
178+
// Results in: /People?timeout=30&format=minimal&debug=true
179+
180+
// Custom option keys are validated:
181+
// ✓ Valid: 'timeout', 'custom_param', 'kebab-case', 'camelCase'
182+
// ✗ Invalid: '$reserved' (starts with $), 'invalid key!' (special chars)
183+
```
184+
185+
**Key Features:**
186+
- **Merging**: Multiple `addOption()` calls merge instead of overwriting
187+
- **Flexible**: Supports both string (`'key=value'`) and array (`['key' => 'value']`) formats
188+
- **Validated**: Custom option keys are validated to prevent conflicts with OData system parameters
189+
- **URL Encoded**: Special characters in keys and values are automatically URL encoded
190+
- **Fluent**: Chain with other query methods for clean, readable code
191+
139192
### Custom Timeout Configuration
140193

141194
If you need to configure custom network timeouts for your OData requests, you can create a subclass of `ODataClient` and override the `createRequest` method:

src/Query/Builder.php

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,124 @@ public function whereKey($id)
261261

262262
/**
263263
* Add custom option to query parameters.
264-
*
265-
* @param string $options
264+
*
265+
* This method merges custom query options instead of overwriting them.
266+
* It supports both string and array formats:
267+
*
268+
* String format: 'key=value'
269+
* Array format: ['key' => 'value', 'key2' => 'value2']
270+
*
271+
* Custom option keys must follow OData naming conventions:
272+
* - Must not start with '$' or '@' (reserved for standard OData parameters)
273+
* - Must be valid identifiers (alphanumeric and underscores)
274+
* - Cannot be empty
275+
*
276+
* Examples:
277+
* $query->addOption('custom_param=value1')
278+
* ->addOption('another_param=value2');
279+
*
280+
* $query->addOption(['timeout' => '30', 'format' => 'minimal']);
281+
*
282+
* @param string|array $option The custom option to add (string 'key=value' or associative array)
266283
*
267284
* @return $this
285+
* @throws \InvalidArgumentException If option key is invalid
268286
*/
269287
public function addOption($option)
270288
{
271-
$this->customOption = $option;
289+
if ($option === null || $option === '') {
290+
return $this;
291+
}
292+
293+
// Initialize customOption as array if not set
294+
if (!isset($this->customOption)) {
295+
$this->customOption = [];
296+
}
297+
298+
// Convert existing string format to array for merging
299+
if (is_string($this->customOption)) {
300+
$this->customOption = $this->parseCustomOptionString($this->customOption);
301+
}
302+
303+
// Convert current option to array format for processing
304+
if (is_string($option)) {
305+
$newOptions = $this->parseCustomOptionString($option);
306+
} elseif (is_array($option)) {
307+
$newOptions = $option;
308+
} else {
309+
throw new \InvalidArgumentException('Custom option must be a string or array');
310+
}
311+
312+
// Validate and merge options
313+
foreach ($newOptions as $key => $value) {
314+
$this->validateCustomOptionKey($key);
315+
$this->customOption[$key] = $value;
316+
}
317+
272318
return $this;
319+
}
320+
321+
/**
322+
* Parse a custom option string in 'key=value' format into an array.
323+
*
324+
* @param string $optionString The option string to parse
325+
* @return array Parsed options as associative array
326+
* @throws \InvalidArgumentException If string format is invalid
327+
*/
328+
protected function parseCustomOptionString($optionString)
329+
{
330+
$options = [];
331+
332+
if (strpos($optionString, '=') === false) {
333+
throw new \InvalidArgumentException('Custom option string must contain "=" separator');
334+
}
335+
336+
$pairs = explode(',', $optionString);
337+
foreach ($pairs as $pair) {
338+
$pair = trim($pair);
339+
if (empty($pair)) {
340+
continue;
341+
}
342+
343+
$parts = explode('=', $pair, 2);
344+
if (count($parts) !== 2) {
345+
throw new \InvalidArgumentException("Invalid custom option format: '$pair'. Expected 'key=value'");
346+
}
347+
348+
$key = trim($parts[0]);
349+
$value = trim($parts[1]);
350+
351+
if (empty($key)) {
352+
throw new \InvalidArgumentException('Custom option key cannot be empty');
353+
}
354+
355+
$options[$key] = $value;
356+
}
357+
358+
return $options;
359+
}
360+
361+
/**
362+
* Validate a custom option key according to OData conventions.
363+
*
364+
* @param string $key The option key to validate
365+
* @throws \InvalidArgumentException If key is invalid
366+
*/
367+
protected function validateCustomOptionKey($key)
368+
{
369+
if (empty($key) || !is_string($key)) {
370+
throw new \InvalidArgumentException('Custom option key must be a non-empty string');
371+
}
372+
373+
// Check if key starts with '$' or '@' (reserved for OData system parameters)
374+
if (strpos($key, '$') === 0 || strpos($key, '@') === 0) {
375+
throw new \InvalidArgumentException("Custom option key '$key' cannot start with '\$' or '@' (reserved for OData system parameters)");
376+
}
377+
378+
// Check for valid identifier pattern (alphanumeric, underscores, hyphens)
379+
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $key)) {
380+
throw new \InvalidArgumentException("Custom option key '$key' must be a valid identifier (alphanumeric, underscores, hyphens, starting with letter or underscore)");
381+
}
273382
}
274383

275384
/**

src/Query/Grammar.php

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -478,14 +478,14 @@ protected function compileOrdersToArray(Builder $query, $orders)
478478
/**
479479
* Compile the custom options portion of the query.
480480
*
481-
* @param Builder $query
482-
* @param string $customOption
481+
* @param Builder $query The query builder instance
482+
* @param string|array|null $customOption The custom options to compile
483483
*
484-
* @return string
484+
* @return string The compiled custom options as query parameters
485485
*/
486486
protected function compileCustomOption(Builder $query, $customOption)
487487
{
488-
if (is_null($customOption)) {
488+
if (is_null($customOption) || (is_array($customOption) && empty($customOption))) {
489489
return '';
490490
}
491491

@@ -497,21 +497,26 @@ protected function compileCustomOption(Builder $query, $customOption)
497497
}
498498

499499
/**
500-
* Compile the composite Custom Options key portion of the query.
500+
* Compile the composite Custom Options into a query parameter string.
501501
*
502-
* @param Builder $query
503-
* @param mixed $customOption
502+
* Converts an associative array of custom options into a 'key=value&key2=value2' format
503+
* suitable for URL query parameters.
504504
*
505-
* @return string
505+
* @param array $customOption Associative array of custom options
506+
*
507+
* @return string Compiled custom options string
506508
*/
507509
public function compileCompositeCustomOption($customOption)
508510
{
509511
$customOptions = [];
510512
foreach ($customOption as $key => $value) {
511-
$customOptions[] = $key . '=' . $value;
513+
// URL encode both key and value to handle special characters
514+
$encodedKey = urlencode($key);
515+
$encodedValue = urlencode($value);
516+
$customOptions[] = $encodedKey . '=' . $encodedValue;
512517
}
513518

514-
return implode(',', $customOptions);
519+
return implode('&', $customOptions);
515520
}
516521

517522
/**
@@ -590,15 +595,49 @@ public function columnize(array $properties)
590595
*/
591596
protected function concatenate($segments)
592597
{
593-
// return implode('', array_filter($segments, function ($value) {
594-
// return (string) $value !== '';
595-
// }));
596598
$uri = '';
599+
$queryParams = [];
600+
$hasQueryString = false;
601+
$hasEntitySet = false;
602+
597603
foreach ($segments as $segment => $value) {
598604
if ((string) $value !== '') {
599-
$uri.= strpos($uri, '?$') ? '&' . $value : $value;
605+
if ($segment === 'entitySet') {
606+
$hasEntitySet = true;
607+
$uri .= $value;
608+
} else if ($segment === 'entityKey' || $segment === 'count') {
609+
// These are path segments, not query parameters
610+
$uri .= $value;
611+
} else if ($segment === 'queryString') {
612+
// queryString already includes the '?'
613+
$hasQueryString = true;
614+
// Skip it if empty or just '?'
615+
if ($value !== '?') {
616+
$uri .= $value;
617+
}
618+
} else {
619+
// This is a query parameter - collect it
620+
$queryParams[] = $value;
621+
}
600622
}
601623
}
624+
625+
// Add query parameters if any
626+
if (!empty($queryParams)) {
627+
// Only add '?' if we have an entity set or already have content
628+
if ($hasEntitySet || strlen($uri) > 0) {
629+
// If we already have a queryString with '?', use '&' to join
630+
if ($hasQueryString && strpos($uri, '?') !== false) {
631+
$uri .= '&' . implode('&', $queryParams);
632+
} else {
633+
$uri .= '?' . implode('&', $queryParams);
634+
}
635+
} else {
636+
// No entity set, just return the query params without '?'
637+
$uri .= implode('&', $queryParams);
638+
}
639+
}
640+
602641
return $uri;
603642
}
604643

0 commit comments

Comments
 (0)