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