1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Driver\Sql\Expression;
6:
7: use Illuminate\Database\Eloquent\Builder;
8: use LaravelUi5\OData\Protocol\Planning\Expression\BinaryExpression;
9: use LaravelUi5\OData\Protocol\Planning\Expression\BinaryOperator;
10: use LaravelUi5\OData\Protocol\Planning\Expression\FilterExpression;
11: use LaravelUi5\OData\Protocol\Planning\Expression\FilterExpressionVisitor;
12: use LaravelUi5\OData\Protocol\Planning\Expression\FunctionCallExpression;
13: use LaravelUi5\OData\Protocol\Planning\Expression\LambdaExpression;
14: use LaravelUi5\OData\Protocol\Planning\Expression\LambdaOperator;
15: use LaravelUi5\OData\Protocol\Planning\Expression\LambdaVariableExpression;
16: use LaravelUi5\OData\Protocol\Planning\Expression\LiteralExpression;
17: use LaravelUi5\OData\Protocol\Planning\Expression\NullLiteralExpression;
18: use LaravelUi5\OData\Protocol\Planning\Expression\PropertyPathExpression;
19: use LaravelUi5\OData\Protocol\Planning\Expression\UnaryExpression;
20: use LaravelUi5\OData\Protocol\Planning\Expression\UnaryOperator;
21:
22: /**
23: * Translates a FilterExpression tree into Eloquent Builder WHERE clauses.
24: *
25: * Visitor methods receive the raw node and recurse into children themselves,
26: * following the .NET QueryBinder model (not Olingo's post-order accept).
27: */
28: final class FilterToEloquent implements FilterExpressionVisitor
29: {
30: public function __construct(private readonly Builder $builder) {}
31:
32: public function apply(FilterExpression $expression): void
33: {
34: $expression->accept($this);
35: }
36:
37: public function visitLiteral(LiteralExpression $node): mixed
38: {
39: return $node->value;
40: }
41:
42: public function visitNullLiteral(NullLiteralExpression $node): mixed
43: {
44: return null;
45: }
46:
47: public function visitPropertyPath(PropertyPathExpression $node): mixed
48: {
49: // Return the column name of the last segment in the path.
50: $last = $node->segments[count($node->segments) - 1];
51: return $last->getName();
52: }
53:
54: public function visitBinary(BinaryExpression $node): mixed
55: {
56: match ($node->operator) {
57: BinaryOperator::And => $this->applyAnd($node),
58: BinaryOperator::Or => $this->applyOr($node),
59: BinaryOperator::Eq => $this->applyComparison($node, '='),
60: BinaryOperator::Ne => $this->applyComparison($node, '<>'),
61: BinaryOperator::Gt => $this->applyComparison($node, '>'),
62: BinaryOperator::Ge => $this->applyComparison($node, '>='),
63: BinaryOperator::Lt => $this->applyComparison($node, '<'),
64: BinaryOperator::Le => $this->applyComparison($node, '<='),
65: BinaryOperator::In => $this->applyIn($node),
66: default => null, // arithmetic/has operators not supported at WHERE level
67: };
68:
69: return null;
70: }
71:
72: public function visitUnary(UnaryExpression $node): mixed
73: {
74: if ($node->operator === UnaryOperator::Not) {
75: $this->builder->whereNot(function (Builder $q) use ($node): void {
76: $node->operand->accept(new self($q));
77: });
78: }
79:
80: return null;
81: }
82:
83: public function visitFunctionCall(FunctionCallExpression $node): mixed
84: {
85: $args = $node->arguments;
86:
87: match (strtolower($node->name)) {
88: 'contains' => $this->applyLike($args[0], $args[1], '%', '%'),
89: 'startswith' => $this->applyLike($args[0], $args[1], '', '%'),
90: 'endswith' => $this->applyLike($args[0], $args[1], '%', ''),
91: default => null,
92: };
93:
94: return null;
95: }
96:
97: public function visitLambda(LambdaExpression $node): mixed
98: {
99: $navProperty = $node->collection->segments[0] ?? null;
100: if ($navProperty === null) {
101: return null;
102: }
103:
104: $relationName = $navProperty->getName();
105:
106: if ($node->operator === LambdaOperator::Any) {
107: $this->builder->whereHas($relationName, function (Builder $q) use ($node): void {
108: $node->predicate->accept(new self($q));
109: });
110: } else {
111: // all(): every related entity must match the predicate
112: // ≡ no related entity fails the predicate
113: $this->builder->whereDoesntHave($relationName, function (Builder $q) use ($node): void {
114: $q->whereNot(function (Builder $inner) use ($node): void {
115: $node->predicate->accept(new self($inner));
116: });
117: });
118: }
119:
120: return null;
121: }
122:
123: public function visitLambdaVariable(LambdaVariableExpression $node): mixed
124: {
125: return null;
126: }
127:
128: // -------------------------------------------------------------------------
129: // Private helpers
130: // -------------------------------------------------------------------------
131:
132: private function applyAnd(BinaryExpression $node): void
133: {
134: $this->builder->where(function (Builder $q) use ($node): void {
135: $node->left->accept(new self($q));
136: });
137: $this->builder->where(function (Builder $q) use ($node): void {
138: $node->right->accept(new self($q));
139: });
140: }
141:
142: private function applyOr(BinaryExpression $node): void
143: {
144: $this->builder->where(function (Builder $q) use ($node): void {
145: $node->left->accept(new self($q));
146: })->orWhere(function (Builder $q) use ($node): void {
147: $node->right->accept(new self($q));
148: });
149: }
150:
151: private function applyComparison(BinaryExpression $node, string $op): void
152: {
153: $column = $node->left->accept($this);
154: $value = $node->right->accept($this);
155:
156: if ($value === null) {
157: match ($op) {
158: '=' => $this->builder->whereNull($column),
159: '<>' => $this->builder->whereNotNull($column),
160: default => null,
161: };
162: return;
163: }
164:
165: $this->builder->where($column, $op, $value);
166: }
167:
168: private function applyIn(BinaryExpression $node): void
169: {
170: $column = $node->left->accept($this);
171: $values = $node->right->accept($this);
172:
173: $this->builder->whereIn($column, (array) $values);
174: }
175:
176: private function applyLike(FilterExpression $propExpr, FilterExpression $valExpr, string $prefix, string $suffix): void
177: {
178: $column = $this->unwrapCaseFunction($propExpr)->accept($this);
179: $value = $this->unwrapCaseFunction($valExpr)->accept($this);
180:
181: $this->builder->where($column, 'like', $prefix . $value . $suffix);
182: }
183:
184: /**
185: * Strip tolower()/toupper() wrappers — SQL LIKE is case-insensitive
186: * on the default collations used by MySQL and SQLite.
187: */
188: private function unwrapCaseFunction(FilterExpression $expr): FilterExpression
189: {
190: if ($expr instanceof FunctionCallExpression
191: && in_array(strtolower($expr->name), ['tolower', 'toupper'], true)
192: && count($expr->arguments) === 1
193: ) {
194: return $expr->arguments[0];
195: }
196:
197: return $expr;
198: }
199: }
200: