1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Driver\Sql;
6:
7: use Illuminate\Database\Eloquent\Builder;
8: use Illuminate\Database\Eloquent\Model;
9: use Illuminate\Database\Eloquent\Relations\BelongsToMany;
10: use LaravelUi5\OData\Driver\Sql\Expression\FilterToEloquent;
11: use LaravelUi5\OData\Protocol\Planning\EntitySetQueryPlan;
12: use LaravelUi5\OData\Protocol\Planning\ExpandList;
13: use LaravelUi5\OData\Protocol\Planning\Expression\PropertyPathExpression;
14: use LaravelUi5\OData\Protocol\Planning\NavigationAnchor;
15: use LaravelUi5\OData\Protocol\Planning\OrderDirection;
16: use LaravelUi5\OData\Protocol\Planning\PropertySelectItem;
17: use LaravelUi5\OData\Protocol\Planning\EntityQueryPlan;
18: use LaravelUi5\OData\Service\Contracts\EntityResolverInterface;
19: use LaravelUi5\OData\Service\Contracts\EntitySetResolverInterface;
20: use LaravelUi5\OData\Service\Contracts\QueryPlanInterface;
21: use LaravelUi5\OData\Service\Contracts\RuntimeSchemaInterface;
22: use LaravelUi5\OData\Service\Contracts\VirtualExpandResolverInterface;
23:
24: /**
25: * Resolves entity-set and single-entity plans against an Eloquent model class.
26: *
27: * Implements EntitySetResolverInterface for collection queries and
28: * EntityResolverInterface for single-entity key lookups. Both are bound
29: * from the same model class, so registering one resolver per entity set
30: * covers both access patterns without additional wiring.
31: */
32: final class EloquentEntitySetResolver implements EntitySetResolverInterface, EntityResolverInterface
33: {
34: private ?RuntimeSchemaInterface $schema = null;
35:
36: /**
37: * @param class-string<Model> $modelClass
38: */
39: public function __construct(private readonly string $modelClass) {}
40:
41: /**
42: * @return class-string<Model>
43: */
44: public function getModelClass(): string
45: {
46: return $this->modelClass;
47: }
48:
49: /**
50: * Set the runtime schema for virtual expand resolution.
51: *
52: * Called by the RuntimeSchemaBuilder after all resolvers are registered,
53: * so Eloquent resolvers can look up virtual expand resolvers by target set.
54: */
55: public function setSchema(RuntimeSchemaInterface $schema): void
56: {
57: $this->schema = $schema;
58: }
59:
60: /**
61: * @param QueryPlanInterface $plan At runtime always EntitySetQueryPlan.
62: * @return \Generator<array<string, mixed>>
63: */
64: public function resolve(QueryPlanInterface $plan): \Generator
65: {
66: /** @var EntitySetQueryPlan $plan */
67:
68: // When the plan has a NavigationAnchor, resolve the intermediate chain
69: // to get the parent model, then use its relationship as the query base.
70: if ($plan->anchor !== null) {
71: $parent = $this->resolveAnchor($plan->anchor);
72: if ($parent === null) {
73: return;
74: }
75: $relation = $parent->{$plan->anchor->finalNav}();
76: $query = $relation->getQuery();
77: // BelongsToMany joins the pivot table; SELECT * would include pivot
78: // columns (including its own `id`) that collide with the target table's
79: // columns. Qualify the select to avoid column-name collisions.
80: if ($relation instanceof BelongsToMany) {
81: $query->select($relation->getRelated()->getTable() . '.*');
82: }
83: } else {
84: $query = ($this->modelClass)::query();
85: }
86:
87: $this->applyFilter($query, $plan);
88: $this->applySearch($query, $plan);
89: $this->applySelect($query, $plan);
90: $this->applyOrderBy($query, $plan);
91: $this->applyPagination($query, $plan);
92: if ($plan->expand->isEmpty()) {
93: foreach ($query->cursor() as $model) {
94: $row = $model->toArray();
95: yield $this->applyCompute($row, $plan);
96: }
97: } else {
98: // Eager loading requires get() — cursor() does not support with().
99: $this->applyExpand($query, $plan->expand);
100: foreach ($query->get() as $model) {
101: $row = $model->toArray();
102: $row = $this->attachExpandedRelations($row, $model, $plan->expand);
103: yield $this->applyCompute($row, $plan);
104: }
105: }
106: }
107:
108: /**
109: * @param QueryPlanInterface $plan At runtime always EntityQueryPlan.
110: * @return array<string, mixed>|null
111: */
112: public function resolveOne(QueryPlanInterface $plan): ?array
113: {
114: /** @var EntityQueryPlan $plan */
115:
116: if ($plan->anchor !== null) {
117: $parent = $this->resolveAnchor($plan->anchor);
118: if ($parent === null) {
119: return null;
120: }
121: $relation = $parent->{$plan->anchor->finalNav}();
122: $query = $relation->getQuery();
123: if ($relation instanceof BelongsToMany) {
124: $query->select($relation->getRelated()->getTable() . '.*');
125: }
126: } else {
127: $query = ($this->modelClass)::query();
128: }
129:
130: foreach ($plan->key->values as $column => $literal) {
131: $query->where($column, '=', $literal->value);
132: }
133:
134: if (!$plan->select->isSelectAll()) {
135: $columns = [];
136: foreach ($plan->select->items as $item) {
137: if ($item instanceof PropertySelectItem) {
138: $columns[] = $item->property->getName();
139: }
140: }
141: // When expanding, always include the PK for relation matching.
142: if (!$plan->expand->isEmpty()) {
143: $keyName = (new ($this->modelClass))->getKeyName();
144: if (!in_array($keyName, $columns, true)) {
145: $columns[] = $keyName;
146: }
147: }
148: if ($columns !== []) {
149: $query->select($columns);
150: }
151: }
152:
153: $this->applyExpand($query, $plan->expand);
154:
155: $model = $query->first();
156: if ($model === null) {
157: return null;
158: }
159:
160: $row = $model->toArray();
161: return $this->attachExpandedRelations($row, $model, $plan->expand);
162: }
163:
164: /**
165: * @param QueryPlanInterface $plan At runtime always EntitySetQueryPlan.
166: */
167: public function count(QueryPlanInterface $plan): int
168: {
169: /** @var EntitySetQueryPlan $plan */
170:
171: if ($plan->anchor !== null) {
172: $parent = $this->resolveAnchor($plan->anchor);
173: if ($parent === null) {
174: return 0;
175: }
176: $relation = $parent->{$plan->anchor->finalNav}();
177: $query = $relation->getQuery();
178: if ($relation instanceof BelongsToMany) {
179: $query->select($relation->getRelated()->getTable() . '.*');
180: }
181: } else {
182: $query = ($this->modelClass)::query();
183: }
184:
185: $this->applyFilter($query, $plan);
186: $this->applySearch($query, $plan);
187:
188: return $query->count();
189: }
190:
191: /**
192: * Resolve a NavigationAnchor by loading the root entity and following
193: * intermediate single-entity navigations to reach the parent model.
194: *
195: * Returns null if the root entity or any intermediate entity is not found.
196: */
197: private function resolveAnchor(NavigationAnchor $anchor): ?Model
198: {
199: // Load the root entity from its resolver's model class.
200: $rootResolver = $this->schema?->getResolver($anchor->rootSet);
201: if (!$rootResolver instanceof self) {
202: return null;
203: }
204:
205: $rootModelClass = $rootResolver->getModelClass();
206: $rootQuery = $rootModelClass::query();
207: foreach ($anchor->rootKey->values as $column => $literal) {
208: $rootQuery->where($column, '=', $literal->value);
209: }
210:
211: $current = $rootQuery->first();
212: if ($current === null) {
213: return null;
214: }
215:
216: // Follow each intermediate navigation step.
217: foreach ($anchor->steps as $navName) {
218: $current = $current->$navName;
219: if ($current === null) {
220: return null;
221: }
222: }
223:
224: return $current;
225: }
226:
227: private function applyExpand(Builder $query, ExpandList $expand): void
228: {
229: if ($expand->isEmpty()) {
230: return;
231: }
232:
233: $withs = $this->collectEagerLoads($expand, '', $this->modelClass);
234: $query->with($withs);
235: }
236:
237: /**
238: * Recursively collect Eloquent eager-load definitions from the expand tree.
239: *
240: * Uses dot-notation for nested relations (e.g. "contact_project.contact").
241: *
242: * @param class-string<Model> $modelClass The model class at the current nesting depth.
243: * @return array<int|string, string|callable>
244: */
245: private function collectEagerLoads(ExpandList $expand, string $prefix, string $modelClass): array
246: {
247: $withs = [];
248:
249: foreach ($expand->items as $item) {
250: $navName = $item->property->getName();
251: $fullPath = $prefix !== '' ? $prefix . '.' . $navName : $navName;
252:
253: // Skip virtual navigation properties — they're handled in attachExpandedRelations()
254: if (!method_exists($modelClass, $navName)) {
255: continue;
256: }
257:
258: $hasConstraints = $item->filter !== null || !$item->select->isSelectAll()
259: || $item->orderBy !== null || $item->top !== null || $item->skip !== null;
260:
261: if (!$hasConstraints) {
262: $withs[] = $fullPath;
263: } else {
264: // Constrained expand — pass a closure to with().
265: $withs[$fullPath] = function ($relQuery) use ($item) {
266: if ($item->filter !== null) {
267: $relQuery->where(function ($q) use ($item) {
268: (new FilterToEloquent($q))->apply($item->filter);
269: });
270: }
271:
272: if (!$item->select->isSelectAll()) {
273: $columns = [];
274: foreach ($item->select->items as $selectItem) {
275: if ($selectItem instanceof PropertySelectItem) {
276: $columns[] = $selectItem->property->getName();
277: }
278: }
279: // Always include PK + FK for relation matching.
280: if ($columns !== []) {
281: // For BelongsToMany, qualify the PK with the table name
282: // to avoid ambiguity with the pivot table's own `id`.
283: $model = $relQuery->getModel();
284: $keyName = $model->getKeyName();
285: $columns[] = $relQuery instanceof BelongsToMany
286: ? $model->getTable() . '.' . $keyName
287: : $keyName;
288: if (method_exists($relQuery, 'getForeignKeyName')
289: && !$relQuery instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo) {
290: $columns[] = $relQuery->getForeignKeyName();
291: }
292: // Include FK columns needed by nested BelongsTo expands,
293: // otherwise the nested relation cannot be matched.
294: if (!$item->expand->isEmpty()) {
295: $relatedModel = $relQuery->getModel();
296: foreach ($item->expand->items as $nestedItem) {
297: $nestedNavName = $nestedItem->property->getName();
298: if (method_exists($relatedModel, $nestedNavName)) {
299: $nestedRel = $relatedModel->$nestedNavName();
300: if ($nestedRel instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo) {
301: $columns[] = $nestedRel->getForeignKeyName();
302: }
303: }
304: }
305: }
306: $relQuery->select(array_unique($columns));
307: }
308: }
309:
310: if ($item->orderBy !== null) {
311: foreach ($item->orderBy->items as $orderItem) {
312: if ($orderItem->expression instanceof PropertyPathExpression) {
313: $segs = $orderItem->expression->segments;
314: $col = $segs[count($segs) - 1]->getName();
315: $dir = $orderItem->direction === OrderDirection::Desc ? 'desc' : 'asc';
316: $relQuery->orderBy($col, $dir);
317: }
318: }
319: }
320:
321: if ($item->top !== null) {
322: $relQuery->limit($item->top);
323: }
324:
325: if ($item->skip !== null) {
326: $relQuery->skip($item->skip);
327: if ($item->top === null) {
328: $relQuery->limit(PHP_INT_MAX);
329: }
330: }
331: };
332: }
333:
334: // Recurse into nested expands.
335: if (!$item->expand->isEmpty()) {
336: // Resolve the related model class for the next depth.
337: $relatedModel = (new $modelClass)->$navName()->getRelated();
338: $nested = $this->collectEagerLoads($item->expand, $fullPath, get_class($relatedModel));
339: foreach ($nested as $key => $value) {
340: if (is_int($key)) {
341: $withs[] = $value;
342: } else {
343: $withs[$key] = $value;
344: }
345: }
346: }
347: }
348:
349: return $withs;
350: }
351:
352: /**
353: * Move Eloquent-loaded relations into the row array under the nav property name.
354: *
355: * @return array<string, mixed>
356: */
357: private function attachExpandedRelations(array $row, Model $model, ExpandList $expand): array
358: {
359: $entityTypeName = (new \ReflectionClass($model))->getShortName();
360:
361: foreach ($expand->items as $item) {
362: $navName = $item->property->getName();
363:
364: // Virtual navigation property — delegate to its custom resolver
365: if (!method_exists($model, $navName)) {
366: $row[$navName] = $this->resolveVirtualExpand($row, $entityTypeName, $item);
367: continue;
368: }
369:
370: $relation = $model->getRelation($navName);
371:
372: if ($item->property->isCollection()) {
373: if ($item->expand->isEmpty()) {
374: $row[$navName] = $relation?->map(fn(Model $m) => $m->toArray())->all() ?? [];
375: } else {
376: $row[$navName] = $relation?->map(
377: fn(Model $m) => $this->attachExpandedRelations($m->toArray(), $m, $item->expand)
378: )->all() ?? [];
379: }
380: } else {
381: if ($relation === null) {
382: $row[$navName] = null;
383: } elseif ($item->expand->isEmpty()) {
384: $row[$navName] = $relation->toArray();
385: } else {
386: $row[$navName] = $this->attachExpandedRelations($relation->toArray(), $relation, $item->expand);
387: }
388: }
389: }
390:
391: return $row;
392: }
393:
394: /**
395: * Resolve a virtual navigation expand by delegating to the target entity set's resolver.
396: *
397: * @return list<array<string, mixed>>
398: */
399: private function resolveVirtualExpand(array $parentRow, string $parentEntityType, \LaravelUi5\OData\Protocol\Planning\ExpandItem $item): array
400: {
401: if ($this->schema === null) {
402: return [];
403: }
404:
405: $resolver = $this->schema->getResolver($item->targetSet);
406:
407: if ($resolver instanceof VirtualExpandResolverInterface) {
408: return $resolver->resolveExpand($parentRow, $parentEntityType, $item);
409: }
410:
411: return [];
412: }
413:
414: private function applySearch(Builder $query, EntitySetQueryPlan $plan): void
415: {
416: if ($plan->search === null || $plan->search === '') {
417: return;
418: }
419:
420: // Simple search: LIKE '%term%' on all string properties.
421: $term = trim($plan->search, '"\'');
422: $entityType = $plan->target->getEntityType();
423: $stringColumns = [];
424:
425: foreach ($entityType->getDeclaredProperties() as $prop) {
426: $type = $prop->getType();
427: if ($type instanceof \LaravelUi5\OData\Edm\Type\PrimitiveType) {
428: if ($type->getPrimitiveType() === \LaravelUi5\OData\Edm\EdmPrimitiveType::String) {
429: $stringColumns[] = $prop->getName();
430: }
431: }
432: }
433:
434: if ($stringColumns === []) {
435: return;
436: }
437:
438: $query->where(function (Builder $q) use ($stringColumns, $term) {
439: foreach ($stringColumns as $col) {
440: $q->orWhere($col, 'LIKE', '%' . $term . '%');
441: }
442: });
443: }
444:
445: /**
446: * Evaluate $compute expressions and add computed properties to the row.
447: *
448: * @return array<string, mixed>
449: */
450: private function applyCompute(array $row, EntitySetQueryPlan $plan): array
451: {
452: if ($plan->compute === []) {
453: return $row;
454: }
455:
456: foreach ($plan->compute as $computed) {
457: $row[$computed->alias] = $this->evaluateComputeExpression($computed->expression, $row);
458: }
459:
460: return $row;
461: }
462:
463: /**
464: * Simple expression evaluator for $compute.
465: *
466: * Supports: property references, concat(a, b), arithmetic (add, sub, mul, div),
467: * string literals, numeric literals.
468: */
469: private function evaluateComputeExpression(string $expression, array $row): mixed
470: {
471: $expr = trim($expression);
472:
473: // concat(expr1, expr2)
474: if (preg_match('/^concat\((.+)\)$/i', $expr, $m)) {
475: $args = $this->splitComputeArgs($m[1]);
476: $parts = array_map(fn($a) => (string) $this->evaluateComputeExpression(trim($a), $row), $args);
477: return implode('', $parts);
478: }
479:
480: // year(prop), month(prop), day(prop)
481: if (preg_match('/^(year|month|day)\((.+)\)$/i', $expr, $m)) {
482: $fn = strtolower($m[1]);
483: $inner = $this->evaluateComputeExpression(trim($m[2]), $row);
484: if ($inner === null) {
485: return null;
486: }
487: $date = new \DateTimeImmutable((string) $inner);
488: return match ($fn) {
489: 'year' => (int) $date->format('Y'),
490: 'month' => (int) $date->format('m'),
491: 'day' => (int) $date->format('d'),
492: };
493: }
494:
495: // tolower(expr), toupper(expr)
496: if (preg_match('/^(tolower|toupper)\((.+)\)$/i', $expr, $m)) {
497: $inner = (string) $this->evaluateComputeExpression(trim($m[2]), $row);
498: return strtolower($m[1]) === 'tolower' ? strtolower($inner) : strtoupper($inner);
499: }
500:
501: // Arithmetic: expr add|sub|mul|div expr
502: if (preg_match('/^(.+)\s+(add|sub|mul|div)\s+(.+)$/', $expr, $m)) {
503: $left = $this->evaluateComputeExpression(trim($m[1]), $row);
504: $right = $this->evaluateComputeExpression(trim($m[3]), $row);
505: return match ($m[2]) {
506: 'add' => $left + $right,
507: 'sub' => $left - $right,
508: 'mul' => $left * $right,
509: 'div' => $right != 0 ? $left / $right : null,
510: };
511: }
512:
513: // String literal: 'value'
514: if (str_starts_with($expr, "'") && str_ends_with($expr, "'")) {
515: return substr($expr, 1, -1);
516: }
517:
518: // Numeric literal
519: if (is_numeric($expr)) {
520: return str_contains($expr, '.') ? (float) $expr : (int) $expr;
521: }
522:
523: // Property reference
524: return $row[$expr] ?? null;
525: }
526:
527: /**
528: * Split comma-separated arguments respecting parentheses nesting.
529: *
530: * @return list<string>
531: */
532: private function splitComputeArgs(string $input): array
533: {
534: $args = [];
535: $current = '';
536: $depth = 0;
537:
538: for ($i = 0, $len = strlen($input); $i < $len; $i++) {
539: $ch = $input[$i];
540: if ($ch === '(') {
541: $depth++;
542: } elseif ($ch === ')') {
543: $depth--;
544: } elseif ($ch === ',' && $depth === 0) {
545: $args[] = $current;
546: $current = '';
547: continue;
548: }
549: $current .= $ch;
550: }
551:
552: if ($current !== '') {
553: $args[] = $current;
554: }
555:
556: return $args;
557: }
558:
559: private function applyFilter(Builder $query, EntitySetQueryPlan $plan): void
560: {
561: if ($plan->filter === null) {
562: return;
563: }
564:
565: $query->where(function (Builder $q) use ($plan): void {
566: (new FilterToEloquent($q))->apply($plan->filter);
567: });
568: }
569:
570: private function applySelect(Builder $query, EntitySetQueryPlan $plan): void
571: {
572: if ($plan->select->isSelectAll()) {
573: return;
574: }
575:
576: // When $compute is present, skip SQL-level select — computed expressions
577: // may reference any column. The handler does response-level projection.
578: if ($plan->compute !== []) {
579: return;
580: }
581:
582: $columns = [];
583: foreach ($plan->select->items as $item) {
584: if ($item instanceof PropertySelectItem) {
585: $columns[] = $item->property->getName();
586: }
587: }
588:
589: // When expanding, always include the model's key column so Eloquent
590: // can match eager-loaded relations to their parent rows.
591: if (!$plan->expand->isEmpty()) {
592: $keyName = (new ($this->modelClass))->getKeyName();
593: if (!in_array($keyName, $columns, true)) {
594: $columns[] = $keyName;
595: }
596: }
597:
598: if ($columns !== []) {
599: $query->select($columns);
600: }
601: }
602:
603: private function applyOrderBy(Builder $query, EntitySetQueryPlan $plan): void
604: {
605: foreach ($plan->orderBy->items as $item) {
606: if (!($item->expression instanceof PropertyPathExpression)) {
607: continue;
608: }
609:
610: $segments = $item->expression->segments;
611: $column = $segments[count($segments) - 1]->getName();
612: $direction = $item->direction === OrderDirection::Desc ? 'desc' : 'asc';
613: $query->orderBy($column, $direction);
614: }
615: }
616:
617: private function applyPagination(Builder $query, EntitySetQueryPlan $plan): void
618: {
619: if ($plan->skip !== null) {
620: $query->skip($plan->skip);
621: // SQLite requires a LIMIT when OFFSET is used; use max int as a sentinel.
622: if ($plan->top === null) {
623: $query->limit(PHP_INT_MAX);
624: }
625: }
626:
627: if ($plan->top !== null) {
628: $query->limit($plan->top);
629: }
630: }
631: }
632: