1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Driver\Sql;
6:
7: use Illuminate\Database\Query\Builder;
8: use LaravelUi5\OData\Driver\Sql\Expression\FilterToQuery;
9: use LaravelUi5\OData\Edm\EdmPrimitiveType;
10: use LaravelUi5\OData\Edm\Type\PrimitiveType;
11: use LaravelUi5\OData\Http\CustomQueryOptions;
12: use LaravelUi5\OData\Protocol\Planning\EntityQueryPlan;
13: use LaravelUi5\OData\Protocol\Planning\EntitySetQueryPlan;
14: use LaravelUi5\OData\Protocol\Planning\Expression\PropertyPathExpression;
15: use LaravelUi5\OData\Protocol\Planning\OrderDirection;
16: use LaravelUi5\OData\Protocol\Planning\PropertySelectItem;
17: use LaravelUi5\OData\Service\Contracts\EntityResolverInterface;
18: use LaravelUi5\OData\Service\Contracts\EntitySetResolverInterface;
19: use LaravelUi5\OData\Service\Contracts\EntitySetSourceInterface;
20: use LaravelUi5\OData\Service\Contracts\QueryPlanInterface;
21:
22: /**
23: * Resolves entity-set and single-entity plans against a SQL data source.
24: *
25: * The data source is provided via {@see EntitySetSourceInterface}, which
26: * supplies a fresh Query Builder on each call. This keeps the resolver
27: * decoupled from how the query is constructed (table, view, subquery,
28: * tenant-scoped, etc.).
29: */
30: readonly class SqlEntitySetResolver implements EntitySetResolverInterface, EntityResolverInterface
31: {
32: public function __construct(private EntitySetSourceInterface $source) {}
33:
34: /**
35: * @param QueryPlanInterface $plan At runtime always EntitySetQueryPlan.
36: * @return \Generator<array<string, mixed>>
37: */
38: public function resolve(QueryPlanInterface $plan): \Generator
39: {
40: /** @var EntitySetQueryPlan $plan */
41: $query = $this->baseQuery($plan->customQueryOptions);
42:
43: $this->applyFilter($query, $plan);
44: $this->applySearch($query, $plan);
45: $this->applySelect($query, $plan);
46: $this->applyOrderBy($query, $plan);
47: $this->applyPagination($query, $plan);
48:
49: foreach ($query->cursor() as $row) {
50: $row = (array) $row;
51: yield $this->applyCompute($row, $plan);
52: }
53: }
54:
55: /**
56: * @param QueryPlanInterface $plan At runtime always EntityQueryPlan.
57: * @return array<string, mixed>|null
58: */
59: public function resolveOne(QueryPlanInterface $plan): ?array
60: {
61: /** @var EntityQueryPlan $plan */
62: $query = $this->baseQuery($plan->customQueryOptions);
63:
64: foreach ($plan->key->values as $column => $literal) {
65: $query->where($column, '=', $literal->value);
66: }
67:
68: if (!$plan->select->isSelectAll()) {
69: $columns = [];
70: foreach ($plan->select->items as $item) {
71: if ($item instanceof PropertySelectItem) {
72: $columns[] = $item->property->getName();
73: }
74: }
75: if ($columns !== []) {
76: $query->select($columns);
77: }
78: }
79:
80: $row = $query->first();
81: return $row !== null ? (array) $row : null;
82: }
83:
84: /**
85: * @param QueryPlanInterface $plan At runtime always EntitySetQueryPlan.
86: */
87: public function count(QueryPlanInterface $plan): int
88: {
89: /** @var EntitySetQueryPlan $plan */
90: $query = $this->baseQuery($plan->customQueryOptions);
91:
92: $this->applyFilter($query, $plan);
93: $this->applySearch($query, $plan);
94:
95: return $query->count();
96: }
97:
98: // ── Internal ─────────────────────────────────────────────────────────────
99:
100: private function baseQuery(CustomQueryOptions $options): Builder
101: {
102: return $this->source->query($options);
103: }
104:
105: private function applyFilter(Builder $query, EntitySetQueryPlan $plan): void
106: {
107: if ($plan->filter === null) {
108: return;
109: }
110:
111: $query->where(function (Builder $q) use ($plan): void {
112: (new FilterToQuery($q))->apply($plan->filter);
113: });
114: }
115:
116: private function applySearch(Builder $query, EntitySetQueryPlan $plan): void
117: {
118: if ($plan->search === null || $plan->search === '') {
119: return;
120: }
121:
122: $term = trim($plan->search, '"\'');
123: $entityType = $plan->target->getEntityType();
124: $stringColumns = [];
125:
126: foreach ($entityType->getDeclaredProperties() as $prop) {
127: $type = $prop->getType();
128: if ($type instanceof PrimitiveType) {
129: if ($type->getPrimitiveType() === EdmPrimitiveType::String) {
130: $stringColumns[] = $prop->getName();
131: }
132: }
133: }
134:
135: if ($stringColumns === []) {
136: return;
137: }
138:
139: $query->where(function (Builder $q) use ($stringColumns, $term) {
140: foreach ($stringColumns as $col) {
141: $q->orWhere($col, 'LIKE', '%' . $term . '%');
142: }
143: });
144: }
145:
146: private function applySelect(Builder $query, EntitySetQueryPlan $plan): void
147: {
148: if ($plan->select->isSelectAll()) {
149: return;
150: }
151:
152: if ($plan->compute !== []) {
153: return;
154: }
155:
156: $columns = [];
157: foreach ($plan->select->items as $item) {
158: if ($item instanceof PropertySelectItem) {
159: $columns[] = $item->property->getName();
160: }
161: }
162:
163: if ($columns !== []) {
164: $query->select($columns);
165: }
166: }
167:
168: private function applyOrderBy(Builder $query, EntitySetQueryPlan $plan): void
169: {
170: foreach ($plan->orderBy->items as $item) {
171: if (!($item->expression instanceof PropertyPathExpression)) {
172: continue;
173: }
174:
175: $segments = $item->expression->segments;
176: $column = $segments[count($segments) - 1]->getName();
177: $direction = $item->direction === OrderDirection::Desc ? 'desc' : 'asc';
178: $query->orderBy($column, $direction);
179: }
180: }
181:
182: private function applyPagination(Builder $query, EntitySetQueryPlan $plan): void
183: {
184: if ($plan->skip !== null) {
185: $query->skip($plan->skip);
186: if ($plan->top === null) {
187: $query->limit(PHP_INT_MAX);
188: }
189: }
190:
191: if ($plan->top !== null) {
192: $query->limit($plan->top);
193: }
194: }
195:
196: /**
197: * Evaluate $compute expressions and add computed properties to the row.
198: *
199: * @return array<string, mixed>
200: */
201: private function applyCompute(array $row, EntitySetQueryPlan $plan): array
202: {
203: if ($plan->compute === []) {
204: return $row;
205: }
206:
207: foreach ($plan->compute as $computed) {
208: $row[$computed->alias] = $this->evaluateComputeExpression($computed->expression, $row);
209: }
210:
211: return $row;
212: }
213:
214: private function evaluateComputeExpression(string $expression, array $row): mixed
215: {
216: $expr = trim($expression);
217:
218: if (preg_match('/^concat\((.+)\)$/i', $expr, $m)) {
219: $args = $this->splitComputeArgs($m[1]);
220: $parts = array_map(fn($a) => (string) $this->evaluateComputeExpression(trim($a), $row), $args);
221: return implode('', $parts);
222: }
223:
224: if (preg_match('/^(year|month|day)\((.+)\)$/i', $expr, $m)) {
225: $fn = strtolower($m[1]);
226: $inner = $this->evaluateComputeExpression(trim($m[2]), $row);
227: if ($inner === null) {
228: return null;
229: }
230: $date = new \DateTimeImmutable((string) $inner);
231: return match ($fn) {
232: 'year' => (int) $date->format('Y'),
233: 'month' => (int) $date->format('m'),
234: 'day' => (int) $date->format('d'),
235: };
236: }
237:
238: if (preg_match('/^(tolower|toupper)\((.+)\)$/i', $expr, $m)) {
239: $inner = (string) $this->evaluateComputeExpression(trim($m[2]), $row);
240: return strtolower($m[1]) === 'tolower' ? strtolower($inner) : strtoupper($inner);
241: }
242:
243: if (preg_match('/^(.+)\s+(add|sub|mul|div)\s+(.+)$/', $expr, $m)) {
244: $left = $this->evaluateComputeExpression(trim($m[1]), $row);
245: $right = $this->evaluateComputeExpression(trim($m[3]), $row);
246: return match ($m[2]) {
247: 'add' => $left + $right,
248: 'sub' => $left - $right,
249: 'mul' => $left * $right,
250: 'div' => $right != 0 ? $left / $right : null,
251: };
252: }
253:
254: if (str_starts_with($expr, "'") && str_ends_with($expr, "'")) {
255: return substr($expr, 1, -1);
256: }
257:
258: if (is_numeric($expr)) {
259: return str_contains($expr, '.') ? (float) $expr : (int) $expr;
260: }
261:
262: return $row[$expr] ?? null;
263: }
264:
265: /**
266: * @return list<string>
267: */
268: private function splitComputeArgs(string $input): array
269: {
270: $args = [];
271: $current = '';
272: $depth = 0;
273:
274: for ($i = 0, $len = strlen($input); $i < $len; $i++) {
275: $ch = $input[$i];
276: if ($ch === '(') {
277: $depth++;
278: } elseif ($ch === ')') {
279: $depth--;
280: } elseif ($ch === ',' && $depth === 0) {
281: $args[] = $current;
282: $current = '';
283: continue;
284: }
285: $current .= $ch;
286: }
287:
288: if ($current !== '') {
289: $args[] = $current;
290: }
291:
292: return $args;
293: }
294: }
295: