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