1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Protocol\Planning;
6:
7: use LaravelUi5\OData\Edm\Contracts\Container\EntitySetInterface;
8: use LaravelUi5\OData\Edm\Contracts\Container\FunctionImportInterface;
9: use LaravelUi5\OData\Edm\Contracts\Property\NavigationPropertyInterface;
10: use LaravelUi5\OData\Edm\Contracts\Property\PropertyInterface;
11: use LaravelUi5\OData\Edm\Contracts\Type\EntityTypeInterface;
12: use LaravelUi5\OData\Exception\BadRequestException;
13: use LaravelUi5\OData\Http\ODataRequest;
14: use LaravelUi5\OData\Protocol\Planning\Expression\BinaryExpression;
15: use LaravelUi5\OData\Protocol\Planning\Expression\BinaryOperator;
16: use LaravelUi5\OData\Protocol\Planning\Expression\FilterExpression;
17: use LaravelUi5\OData\Protocol\Planning\Expression\LiteralExpression;
18: use LaravelUi5\OData\Protocol\Planning\Expression\PropertyPathExpression;
19: use LaravelUi5\OData\Service\Contracts\RuntimeSchemaInterface;
20:
21: final readonly class QueryPlanner
22: {
23: /**
24: * Produce a fully validated, schema-resolved QueryPlan from a request.
25: *
26: * @throws BadRequestException on unknown entity set, unknown property, or invalid key.
27: */
28: public function plan(ODataRequest $request, RuntimeSchemaInterface $schema): QueryPlan
29: {
30: $segments = $request->pathSegments();
31:
32: if ($segments === []) {
33: return new ServiceDocumentQueryPlan($schema->getEdmx());
34: }
35:
36: $first = $segments[0];
37:
38: if ($first === '$metadata') {
39: return new MetadataQueryPlan($schema->getEdmx());
40: }
41:
42: if ($first === '$batch') {
43: return new BatchQueryPlan([], false);
44: }
45:
46: // Extract entity-set name and optional key from the first segment.
47: // Matches: "Products", "Products(1)", "Products(id=1)", "Products(id=1,code='A')"
48: if (!preg_match('/^([^(]+)(?:\((.*)\))?$/', $first, $m)) {
49: throw new BadRequestException('invalid_path', "Invalid path segment: {$first}");
50: }
51:
52: $setName = $m[1];
53: $keyString = $m[2] ?? null;
54:
55: $container = $schema->getEdmx()->getEntityContainer();
56: $entitySet = $container->getEntitySet($setName);
57:
58: if ($entitySet === null) {
59: $funcImport = $container->getFunctionImport($setName);
60: if ($funcImport !== null) {
61: $params = $this->parseFunctionParameters($funcImport, $keyString);
62: return new FunctionInvocationPlan($funcImport, $params);
63: }
64:
65: $singleton = $container->getSingleton($setName);
66: if ($singleton !== null) {
67: return new SingletonQueryPlan(
68: $singleton,
69: $this->parseSelectList($request->select, null, $singleton->getEntityType()),
70: );
71: }
72:
73: throw new BadRequestException('unknown_entity_set', "Unknown entity set: {$setName}");
74: }
75:
76: if ($keyString !== null && count($segments) > 1) {
77: // Navigation path: /Flights(1)/passengers or /Flights(1)/passengers(5)
78: return $this->buildNavigationPlan($entitySet, $keyString, array_slice($segments, 1), $request, $schema);
79: }
80:
81: if ($keyString !== null) {
82: return $this->buildEntityQueryPlan($entitySet, $keyString, $request, $schema);
83: }
84:
85: return $this->buildEntitySetQueryPlan($entitySet, $request, $schema);
86: }
87:
88: // -------------------------------------------------------------------------
89: // Entity query (single entity by key)
90: // -------------------------------------------------------------------------
91:
92: private function buildEntityQueryPlan(
93: EntitySetInterface $entitySet,
94: string $keyString,
95: ODataRequest $request,
96: RuntimeSchemaInterface $schema,
97: ): EntityQueryPlan {
98: $key = $this->parseKeyExpression($keyString, $entitySet);
99: $select = $this->parseSelectList($request->select, $entitySet);
100: $expand = $this->parseExpandList($request->expand, $entitySet, $schema);
101:
102: return new EntityQueryPlan(
103: target: $entitySet,
104: key: $key,
105: select: $select,
106: expand: $expand,
107: );
108: }
109:
110: // -------------------------------------------------------------------------
111: // Entity set query (collection)
112: // -------------------------------------------------------------------------
113:
114: private function buildEntitySetQueryPlan(
115: EntitySetInterface $entitySet,
116: ODataRequest $request,
117: RuntimeSchemaInterface $schema,
118: ): EntitySetQueryPlan {
119: $entityType = $entitySet->getEntityType();
120:
121: $filter = $request->filter !== null
122: ? $this->parseFilterExpression($request->filter, $entityType)
123: : null;
124:
125: $select = $this->parseSelectList($request->select, $entitySet);
126: $orderBy = $this->parseOrderByList($request->orderBy, $entityType);
127: $expand = $this->parseExpandList($request->expand, $entitySet, $schema);
128:
129: return new EntitySetQueryPlan(
130: target: $entitySet,
131: filter: $filter,
132: select: $select,
133: expand: $expand,
134: orderBy: $orderBy,
135: top: $request->top,
136: skip: $request->skip,
137: skipToken: $request->skipToken,
138: count: $request->count,
139: search: $request->search,
140: compute: $this->parseCompute($request->compute),
141: maxPageSize: $request->maxPageSize,
142: );
143: }
144:
145: // -------------------------------------------------------------------------
146: // Navigation path segments (e.g. /Flights(1)/passengers)
147: // -------------------------------------------------------------------------
148:
149: /**
150: * Build a plan for navigation paths: /EntitySet(key)/navProperty[/navProperty(key)...]
151: *
152: * Resolves the navigation chain to a target entity set and injects an
153: * implicit filter on the parent's key. For example, /Flights(1)/passengers
154: * becomes an EntitySetQueryPlan on the Passengers set with filter flight_id eq 1.
155: *
156: * Multi-segment navigation (e.g. /Projects(1)/customer/contact_customer) is
157: * handled by walking intermediate single-entity segments and building a
158: * NavigationAnchor that the resolver evaluates at execution time.
159: *
160: * @param list<string> $remainingSegments Path segments after the first (key) segment.
161: */
162: private function buildNavigationPlan(
163: EntitySetInterface $parentSet,
164: string $parentKeyString,
165: array $remainingSegments,
166: ODataRequest $request,
167: RuntimeSchemaInterface $schema,
168: ): QueryPlan {
169: $rootSet = $parentSet;
170: $rootKey = $this->parseKeyExpression($parentKeyString, $parentSet);
171: $container = $schema->getEdmx()->getEntityContainer();
172:
173: $currentSet = $parentSet;
174: $currentType = $parentSet->getEntityType();
175: $currentKey = $rootKey;
176: $anchorSteps = [];
177:
178: $segmentCount = count($remainingSegments);
179:
180: for ($i = 0; $i < $segmentCount; $i++) {
181: $isLast = ($i === $segmentCount - 1);
182: $navSegment = $remainingSegments[$i];
183:
184: if (!preg_match('/^([^(]+)(?:\((.*)\))?$/', $navSegment, $nm)) {
185: throw new BadRequestException('invalid_path', "Invalid navigation segment: {$navSegment}");
186: }
187:
188: $navName = $nm[1];
189: $navKey = $nm[2] ?? null;
190:
191: // Check for structural property access: /Flights(1)/origin
192: // or /Flights(1)/origin/$value (structural property followed by $value).
193: if ($navName !== '$value') {
194: $structProp = $currentType->getProperty($navName);
195: if ($structProp !== null) {
196: $rawValue = isset($remainingSegments[$i + 1]) && $remainingSegments[$i + 1] === '$value';
197: return new PropertyValuePlan(
198: target: $currentSet,
199: key: $currentKey,
200: property: $structProp,
201: rawValue: $rawValue,
202: );
203: }
204: }
205:
206: if ($navName === '$value') {
207: throw new BadRequestException('invalid_path', '$value must follow a structural property');
208: }
209:
210: $navProp = $currentType->getNavigationProperty($navName);
211: if ($navProp === null) {
212: throw new BadRequestException(
213: 'unknown_navigation_property',
214: sprintf('Unknown property or navigation "%s" on entity type "%s"', $navName, $currentType->getName())
215: );
216: }
217:
218: $binding = $currentSet->getNavigationPropertyBinding($navName);
219: if ($binding === null) {
220: throw new BadRequestException(
221: 'unbound_navigation_property',
222: sprintf('No navigation property binding for "%s" on entity set "%s"', $navName, $currentSet->getName())
223: );
224: }
225:
226: $targetSet = $container->getEntitySet($binding->getTarget());
227: if ($targetSet === null) {
228: throw new BadRequestException(
229: 'unknown_target_set',
230: sprintf('Target entity set "%s" not found', $binding->getTarget())
231: );
232: }
233:
234: if ($isLast) {
235: // Final segment — build the query plan.
236: return $this->buildFinalNavigationPlan(
237: $currentSet, $currentKey, $navProp, $navKey, $targetSet,
238: $anchorSteps, $rootSet, $rootKey, $request, $schema,
239: );
240: }
241:
242: // Intermediate segment — must resolve to a single entity.
243: // A collection navigation in the middle of a path requires a key.
244: if ($navProp->isCollection() && $navKey === null) {
245: throw new BadRequestException(
246: 'invalid_path',
247: sprintf('Navigation "%s" is a collection; a key is required in the middle of a path', $navName)
248: );
249: }
250:
251: // Advance to the target entity set for the next iteration.
252: $anchorSteps[] = $navName;
253: $currentSet = $targetSet;
254: $currentType = $targetSet->getEntityType();
255:
256: if ($navKey !== null) {
257: // Collection with key: /Flights(1)/passengers(5)/bookings
258: // Reset the anchor — the keyed entity becomes the new root.
259: $rootSet = $targetSet;
260: $rootKey = $this->parseKeyExpression($navKey, $targetSet);
261: $currentKey = $rootKey;
262: $anchorSteps = [];
263: }
264: }
265:
266: // Should never reach here — the loop always returns on the last segment.
267: throw new BadRequestException('invalid_path', 'Empty navigation path');
268: }
269:
270: /**
271: * Build the final query plan for a navigation path's last segment.
272: *
273: * When anchorSteps is non-empty, the plan includes a NavigationAnchor
274: * so the resolver can walk intermediate single-entity navigations at
275: * execution time to determine the parent entity.
276: */
277: private function buildFinalNavigationPlan(
278: EntitySetInterface $parentSet,
279: KeyExpression $parentKey,
280: NavigationPropertyInterface $navProp,
281: ?string $navKey,
282: EntitySetInterface $targetSet,
283: array $anchorSteps,
284: EntitySetInterface $rootSet,
285: KeyExpression $rootKey,
286: ODataRequest $request,
287: RuntimeSchemaInterface $schema,
288: ): QueryPlan {
289: $anchor = $anchorSteps !== []
290: ? new NavigationAnchor($rootSet, $rootKey, $anchorSteps, $navProp->getName())
291: : null;
292:
293: if ($navKey !== null) {
294: $targetKeyExpr = $this->parseKeyExpression($navKey, $targetSet);
295: $select = $this->parseSelectList($request->select, $targetSet);
296: $expand = $this->parseExpandList($request->expand, $targetSet, $schema);
297:
298: return new EntityQueryPlan(
299: target: $targetSet,
300: key: $targetKeyExpr,
301: select: $select,
302: expand: $expand,
303: anchor: $anchor,
304: );
305: }
306:
307: // Build implicit filter on the parent's key (only when no anchor).
308: // When an anchor is present, the resolver builds the FK filter at
309: // execution time after resolving the intermediate navigation chain.
310: $targetType = $targetSet->getEntityType();
311: $userFilter = $request->filter !== null
312: ? $this->parseFilterExpression($request->filter, $targetType)
313: : null;
314:
315: if ($anchor === null) {
316: $constraints = $navProp->getReferentialConstraints();
317:
318: if ($constraints === [] && $navProp->isCollection()) {
319: // BelongsToMany: no referential constraints and collection-valued.
320: // Cannot build a direct FK filter because the FK lives on the pivot
321: // table, not the target table. Force an anchor so the resolver uses
322: // Eloquent's relationship query builder (which joins through the pivot).
323: $anchor = new NavigationAnchor($rootSet, $rootKey, [], $navProp->getName());
324: $combinedFilter = $userFilter;
325: } else {
326: $implicitFilter = $this->buildParentKeyFilter($parentKey, $constraints, $parentSet);
327: $combinedFilter = $userFilter !== null
328: ? new BinaryExpression($implicitFilter, BinaryOperator::And, $userFilter)
329: : $implicitFilter;
330: }
331: } else {
332: $combinedFilter = $userFilter;
333: }
334:
335: $select = $this->parseSelectList($request->select, $targetSet);
336: $orderBy = $this->parseOrderByList($request->orderBy, $targetType);
337: $expand = $this->parseExpandList($request->expand, $targetSet, $schema);
338:
339: return new EntitySetQueryPlan(
340: target: $targetSet,
341: filter: $combinedFilter,
342: select: $select,
343: expand: $expand,
344: orderBy: $orderBy,
345: top: $request->top,
346: skip: $request->skip,
347: skipToken: $request->skipToken,
348: count: $request->count,
349: anchor: $anchor,
350: );
351: }
352:
353: /**
354: * Build a FilterExpression that constrains the target set by the parent's key.
355: *
356: * Uses referential constraints if declared, otherwise falls back to convention:
357: * the FK column is the lowercase parent entity set name (singular) + '_id'.
358: *
359: * @param array<string, string> $constraints dependent → principal property names
360: */
361: private function buildParentKeyFilter(
362: KeyExpression $parentKey,
363: array $constraints,
364: EntitySetInterface $parentSet,
365: ): Expression\FilterExpression {
366: // If referential constraints are declared, use the first one.
367: if ($constraints !== []) {
368: $dependentPropName = array_key_first($constraints);
369: $principalPropName = $constraints[$dependentPropName];
370:
371: $parentKeyValue = $parentKey->values[$principalPropName]
372: ?? array_values($parentKey->values)[0];
373:
374: return new BinaryExpression(
375: new PropertyPathExpression([
376: new \LaravelUi5\OData\Edm\Property\Property(
377: $dependentPropName,
378: new \LaravelUi5\OData\Edm\Type\PrimitiveType(
379: \LaravelUi5\OData\Edm\EdmPrimitiveType::Int32
380: )
381: ),
382: ]),
383: BinaryOperator::Eq,
384: new LiteralExpression($parentKeyValue->value, $parentKeyValue->edmType),
385: );
386: }
387:
388: // Convention: parent set "Flights" → FK "flight_id", key value from parentKey.
389: $parentName = rtrim($parentSet->getName(), 's'); // naive singularization
390: $fkColumn = strtolower($parentName) . '_id';
391: $keyValue = array_values($parentKey->values)[0];
392:
393: return new BinaryExpression(
394: new PropertyPathExpression([
395: new \LaravelUi5\OData\Edm\Property\Property(
396: $fkColumn,
397: new \LaravelUi5\OData\Edm\Type\PrimitiveType(
398: \LaravelUi5\OData\Edm\EdmPrimitiveType::Int32
399: )
400: ),
401: ]),
402: BinaryOperator::Eq,
403: new LiteralExpression($keyValue->value, $keyValue->edmType),
404: );
405: }
406:
407: // -------------------------------------------------------------------------
408: // Key parsing
409: // -------------------------------------------------------------------------
410:
411: private function parseKeyExpression(string $keyString, EntitySetInterface $entitySet): KeyExpression
412: {
413: $entityType = $entitySet->getEntityType();
414: $keyProperties = $entityType->getKey();
415:
416: if (str_contains($keyString, '=')) {
417: // Named-key syntax: id=1,code='A'
418: $values = [];
419: foreach (array_filter(array_map('trim', explode(',', $keyString))) as $pair) {
420: [$name, $rawValue] = array_map('trim', explode('=', $pair, 2));
421: $keyProp = $this->findKeyProperty($name, $keyProperties);
422: $values[$name] = $this->parseLiteralForEdmType(
423: $rawValue,
424: $keyProp->getType()->getQualifiedName()
425: );
426: }
427: return new KeyExpression($values);
428: }
429:
430: // Positional key: must have exactly one key property
431: if (count($keyProperties) !== 1) {
432: throw new BadRequestException(
433: 'invalid_key',
434: 'Composite key requires named-key syntax (property=value,...)'
435: );
436: }
437:
438: $keyProp = $keyProperties[0];
439: return new KeyExpression([
440: $keyProp->getName() => $this->parseLiteralForEdmType(
441: trim($keyString),
442: $keyProp->getType()->getQualifiedName()
443: ),
444: ]);
445: }
446:
447: /** @param list<PropertyInterface> $keyProperties */
448: private function findKeyProperty(string $name, array $keyProperties): PropertyInterface
449: {
450: foreach ($keyProperties as $kp) {
451: if ($kp->getName() === $name) {
452: return $kp;
453: }
454: }
455: throw new BadRequestException('unknown_key_property', "Unknown key property: {$name}");
456: }
457:
458: private function parseLiteralForEdmType(string $raw, string $edmType): LiteralExpression
459: {
460: return match (true) {
461: in_array($edmType, ['Edm.Int16', 'Edm.Int32', 'Edm.Int64', 'Edm.Byte', 'Edm.SByte'], true)
462: => new LiteralExpression((int) $raw, $edmType),
463: in_array($edmType, ['Edm.Double', 'Edm.Decimal', 'Edm.Single'], true)
464: => new LiteralExpression((float) $raw, $edmType),
465: $edmType === 'Edm.Boolean'
466: => new LiteralExpression(strtolower($raw) === 'true', $edmType),
467: $edmType === 'Edm.String'
468: => new LiteralExpression(trim($raw, "'"), $edmType),
469: default
470: => new LiteralExpression($raw, $edmType),
471: };
472: }
473:
474: // -------------------------------------------------------------------------
475: // $select
476: // -------------------------------------------------------------------------
477:
478: private function parseSelectList(
479: ?string $selectString,
480: ?EntitySetInterface $entitySet = null,
481: ?EntityTypeInterface $entityType = null,
482: ): SelectList {
483: if ($selectString === null) {
484: return new SelectList();
485: }
486:
487: if ($selectString === '*') {
488: return new SelectList([new WildcardSelectItem()]);
489: }
490:
491: $entityType = $entityType ?? $entitySet->getEntityType();
492: $items = [];
493:
494: foreach (array_filter(array_map('trim', explode(',', $selectString))) as $name) {
495: if ($name === '*') {
496: $items[] = new WildcardSelectItem();
497: continue;
498: }
499:
500: $property = $entityType->getProperty($name);
501: if ($property === null) {
502: throw new BadRequestException(
503: 'unknown_property',
504: "Unknown property in \$select: {$name}"
505: );
506: }
507:
508: $items[] = new PropertySelectItem($property);
509: }
510:
511: return new SelectList($items);
512: }
513:
514: // -------------------------------------------------------------------------
515: // $orderby
516: // -------------------------------------------------------------------------
517:
518: private function parseOrderByList(?string $orderByString, EntityTypeInterface $entityType): OrderByList
519: {
520: if ($orderByString === null) {
521: return new OrderByList();
522: }
523:
524: $items = [];
525:
526: foreach (array_filter(array_map('trim', explode(',', $orderByString))) as $clause) {
527: $parts = array_values(array_filter(array_map('trim', explode(' ', $clause))));
528: $propName = $parts[0] ?? '';
529: $direction = strtolower($parts[1] ?? 'asc');
530:
531: if (!in_array($direction, ['asc', 'desc'], true)) {
532: throw new BadRequestException('invalid_orderby_direction', "Invalid \$orderby direction: {$direction}");
533: }
534:
535: $property = $entityType->getProperty($propName);
536: if ($property === null) {
537: throw new BadRequestException(
538: 'unknown_property',
539: "Unknown property in \$orderby: {$propName}"
540: );
541: }
542:
543: $items[] = new OrderByItem(
544: expression: new PropertyPathExpression([$property]),
545: direction: $direction === 'desc' ? OrderDirection::Desc : OrderDirection::Asc,
546: );
547: }
548:
549: return new OrderByList($items);
550: }
551:
552: // -------------------------------------------------------------------------
553: // $expand
554: // -------------------------------------------------------------------------
555:
556: private function parseExpandList(
557: ?string $expandString,
558: EntitySetInterface $entitySet,
559: RuntimeSchemaInterface $schema,
560: ): ExpandList {
561: if ($expandString === null || $expandString === '') {
562: return new ExpandList();
563: }
564:
565: $entityType = $entitySet->getEntityType();
566: $container = $schema->getEdmx()->getEntityContainer();
567: $items = [];
568:
569: // Split on commas that are NOT inside parentheses.
570: foreach ($this->splitExpandClauses($expandString) as $clause) {
571: // Parse optional nested options: "navName($select=a;$top=5)"
572: if (preg_match('/^([^(]+)\((.+)\)$/', $clause, $em)) {
573: $navName = trim($em[1]);
574: $nestedString = $em[2];
575: } else {
576: $navName = trim($clause);
577: $nestedString = null;
578: }
579:
580: $navProp = $entityType->getNavigationProperty($navName);
581: if ($navProp === null) {
582: throw new BadRequestException(
583: 'unknown_navigation_property',
584: sprintf('Unknown navigation property "%s" on entity type "%s"', $navName, $entityType->getName())
585: );
586: }
587:
588: $binding = $entitySet->getNavigationPropertyBinding($navName);
589: if ($binding === null) {
590: throw new BadRequestException(
591: 'unbound_navigation_property',
592: sprintf('No navigation property binding for "%s" on entity set "%s"', $navName, $entitySet->getName())
593: );
594: }
595:
596: $targetSet = $container->getEntitySet($binding->getTarget());
597: if ($targetSet === null) {
598: throw new BadRequestException(
599: 'unknown_target_set',
600: sprintf('Target entity set "%s" not found for navigation "%s"', $binding->getTarget(), $navName)
601: );
602: }
603:
604: // Parse nested options if present.
605: $nestedOpts = $this->parseNestedExpandOptions($nestedString, $targetSet, $schema);
606:
607: $items[] = new ExpandItem(
608: property: $navProp,
609: targetSet: $targetSet,
610: filter: $nestedOpts['filter'],
611: select: $nestedOpts['select'],
612: expand: $nestedOpts['expand'],
613: orderBy: $nestedOpts['orderBy'],
614: top: $nestedOpts['top'],
615: skip: $nestedOpts['skip'],
616: count: $nestedOpts['count'],
617: );
618: }
619:
620: return new ExpandList($items);
621: }
622:
623: /**
624: * Split top-level expand clauses on commas, respecting parentheses nesting.
625: *
626: * @return list<string>
627: */
628: private function splitExpandClauses(string $expandString): array
629: {
630: $clauses = [];
631: $current = '';
632: $depth = 0;
633:
634: for ($i = 0, $len = strlen($expandString); $i < $len; $i++) {
635: $ch = $expandString[$i];
636: if ($ch === '(') {
637: $depth++;
638: } elseif ($ch === ')') {
639: $depth--;
640: } elseif ($ch === ',' && $depth === 0) {
641: $clauses[] = trim($current);
642: $current = '';
643: continue;
644: }
645: $current .= $ch;
646: }
647:
648: if (trim($current) !== '') {
649: $clauses[] = trim($current);
650: }
651:
652: return $clauses;
653: }
654:
655: /**
656: * Parse semicolon-separated nested options inside $expand parentheses.
657: *
658: * @return array{filter: ?FilterExpression, select: SelectList, orderBy: ?OrderByList, top: ?int, skip: ?int, count: bool}
659: */
660: private function parseNestedExpandOptions(
661: ?string $nestedString,
662: EntitySetInterface $targetSet,
663: RuntimeSchemaInterface $schema,
664: ): array {
665: $result = [
666: 'filter' => null,
667: 'select' => new SelectList(),
668: 'expand' => new ExpandList(),
669: 'orderBy' => null,
670: 'top' => null,
671: 'skip' => null,
672: 'count' => false,
673: ];
674:
675: if ($nestedString === null || $nestedString === '') {
676: return $result;
677: }
678:
679: $targetType = $targetSet->getEntityType();
680:
681: // Split on semicolons: $select=name;$top=5;$filter=...
682: foreach (explode(';', $nestedString) as $option) {
683: $option = trim($option);
684: if ($option === '') {
685: continue;
686: }
687:
688: $eqPos = strpos($option, '=');
689: if ($eqPos === false) {
690: continue;
691: }
692:
693: $key = trim(substr($option, 0, $eqPos));
694: $value = trim(substr($option, $eqPos + 1));
695:
696: match ($key) {
697: '$select' => $result['select'] = $this->parseSelectList($value, $targetSet),
698: '$filter' => $result['filter'] = $this->parseFilterExpression($value, $targetType),
699: '$expand' => $result['expand'] = $this->parseExpandList($value, $targetSet, $schema),
700: '$orderby' => $result['orderBy'] = $this->parseOrderByList($value, $targetType),
701: '$top' => $result['top'] = (int) $value,
702: '$skip' => $result['skip'] = (int) $value,
703: '$count' => $result['count'] = $value === 'true',
704: default => null, // ignore unknown options
705: };
706: }
707:
708: return $result;
709: }
710:
711: // -------------------------------------------------------------------------
712: // $filter — direct FilterExpression parsing
713: // -------------------------------------------------------------------------
714:
715: private function parseFilterExpression(string $filterString, EntityTypeInterface $entityType): FilterExpression
716: {
717: $parser = new \LaravelUi5\OData\Protocol\Parser\FilterParser();
718: $resolver = new \LaravelUi5\OData\Protocol\Parser\PropertyResolver();
719:
720: $unresolved = $parser->parse($filterString);
721: return $resolver->resolve($unresolved, $entityType);
722: }
723:
724: // -------------------------------------------------------------------------
725: // Function import parameters
726: // -------------------------------------------------------------------------
727:
728: /**
729: * Parse function import parameters from the URL parentheses.
730: *
731: * Supports: FuncName(param='value',num=42) and FuncName() (no params).
732: *
733: * @return array<string, LiteralExpression>
734: */
735: private function parseFunctionParameters(FunctionImportInterface $import, ?string $paramString): array
736: {
737: $function = $import->getFunction();
738: $declared = $function->getParameters();
739:
740: if ($paramString === null || $paramString === '') {
741: return [];
742: }
743:
744: $pairs = explode(',', $paramString);
745: $result = [];
746:
747: foreach ($pairs as $pair) {
748: $eqPos = strpos($pair, '=');
749: if ($eqPos === false) {
750: throw new BadRequestException(
751: 'invalid_function_parameter',
752: sprintf('Invalid function parameter syntax: "%s"', $pair)
753: );
754: }
755:
756: $name = trim(substr($pair, 0, $eqPos));
757: $raw = trim(substr($pair, $eqPos + 1));
758:
759: $param = $function->getParameter($name);
760: if ($param === null) {
761: throw new BadRequestException(
762: 'unknown_function_parameter',
763: sprintf('Unknown parameter "%s" for function "%s"', $name, $function->getName())
764: );
765: }
766:
767: $result[$name] = $this->parseLiteralValue($raw);
768: }
769:
770: return $result;
771: }
772:
773: /**
774: * Parse a raw literal value from a URL into a LiteralExpression.
775: */
776: private function parseLiteralValue(string $raw): LiteralExpression
777: {
778: // String literal: 'value'
779: if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
780: return new LiteralExpression(substr($raw, 1, -1), 'Edm.String');
781: }
782:
783: // Boolean
784: if ($raw === 'true' || $raw === 'false') {
785: return new LiteralExpression($raw === 'true', 'Edm.Boolean');
786: }
787:
788: // Null
789: if ($raw === 'null') {
790: return new LiteralExpression(null, 'Edm.Null');
791: }
792:
793: // Integer
794: if (preg_match('/^-?\d+$/', $raw)) {
795: return new LiteralExpression((int) $raw, 'Edm.Int32');
796: }
797:
798: // Decimal / float
799: if (is_numeric($raw)) {
800: return new LiteralExpression((float) $raw, 'Edm.Decimal');
801: }
802:
803: // Default: treat as unquoted string
804: return new LiteralExpression($raw, 'Edm.String');
805: }
806:
807: // -------------------------------------------------------------------------
808: // $compute
809: // -------------------------------------------------------------------------
810:
811: /**
812: * Parse $compute string into ComputedProperty definitions.
813: *
814: * Format: "expression as alias[,expression as alias,...]"
815: *
816: * @return list<ComputedProperty>
817: */
818: private function parseCompute(?string $computeString): array
819: {
820: if ($computeString === null || $computeString === '') {
821: return [];
822: }
823:
824: $result = [];
825:
826: // Split on commas that are NOT inside parentheses.
827: foreach ($this->splitExpandClauses($computeString) as $clause) {
828: $clause = trim($clause);
829: // Match "expression as alias" — last " as " separator.
830: $asPos = strrpos($clause, ' as ');
831: if ($asPos === false) {
832: throw new BadRequestException(
833: 'invalid_compute',
834: sprintf('Invalid $compute clause: "%s" (missing "as" alias)', $clause)
835: );
836: }
837:
838: $expression = trim(substr($clause, 0, $asPos));
839: $alias = trim(substr($clause, $asPos + 4));
840:
841: if ($expression === '' || $alias === '') {
842: throw new BadRequestException(
843: 'invalid_compute',
844: sprintf('Invalid $compute clause: "%s"', $clause)
845: );
846: }
847:
848: $result[] = new ComputedProperty($alias, $expression);
849: }
850:
851: return $result;
852: }
853: }
854: