1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Driver\Sql\Expression;
6:
7: use Illuminate\Database\Query\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\LambdaVariableExpression;
15: use LaravelUi5\OData\Protocol\Planning\Expression\LiteralExpression;
16: use LaravelUi5\OData\Protocol\Planning\Expression\NullLiteralExpression;
17: use LaravelUi5\OData\Protocol\Planning\Expression\PropertyPathExpression;
18: use LaravelUi5\OData\Protocol\Planning\Expression\UnaryExpression;
19: use LaravelUi5\OData\Protocol\Planning\Expression\UnaryOperator;
20:
21: /**
22: * Translates a FilterExpression tree into Query\Builder WHERE clauses.
23: *
24: * Identical logic to FilterToEloquent but typed against the base
25: * Query\Builder (DB facade) instead of the Eloquent Builder.
26: */
27: final class FilterToQuery implements FilterExpressionVisitor
28: {
29: public function __construct(private readonly Builder $builder) {}
30:
31: public function apply(FilterExpression $expression): void
32: {
33: $expression->accept($this);
34: }
35:
36: public function visitLiteral(LiteralExpression $node): mixed
37: {
38: return $node->value;
39: }
40:
41: public function visitNullLiteral(NullLiteralExpression $node): mixed
42: {
43: return null;
44: }
45:
46: public function visitPropertyPath(PropertyPathExpression $node): mixed
47: {
48: $last = $node->segments[count($node->segments) - 1];
49: return $last->getName();
50: }
51:
52: public function visitBinary(BinaryExpression $node): mixed
53: {
54: match ($node->operator) {
55: BinaryOperator::And => $this->applyAnd($node),
56: BinaryOperator::Or => $this->applyOr($node),
57: BinaryOperator::Eq => $this->applyComparison($node, '='),
58: BinaryOperator::Ne => $this->applyComparison($node, '<>'),
59: BinaryOperator::Gt => $this->applyComparison($node, '>'),
60: BinaryOperator::Ge => $this->applyComparison($node, '>='),
61: BinaryOperator::Lt => $this->applyComparison($node, '<'),
62: BinaryOperator::Le => $this->applyComparison($node, '<='),
63: BinaryOperator::In => $this->applyIn($node),
64: default => null,
65: };
66:
67: return null;
68: }
69:
70: public function visitUnary(UnaryExpression $node): mixed
71: {
72: if ($node->operator === UnaryOperator::Not) {
73: $this->builder->whereNot(function (Builder $q) use ($node): void {
74: $node->operand->accept(new self($q));
75: });
76: }
77:
78: return null;
79: }
80:
81: public function visitFunctionCall(FunctionCallExpression $node): mixed
82: {
83: $args = $node->arguments;
84:
85: match (strtolower($node->name)) {
86: 'contains' => $this->applyLike($args[0], $args[1], '%', '%'),
87: 'startswith' => $this->applyLike($args[0], $args[1], '', '%'),
88: 'endswith' => $this->applyLike($args[0], $args[1], '%', ''),
89: default => null,
90: };
91:
92: return null;
93: }
94:
95: public function visitLambda(LambdaExpression $node): mixed
96: {
97: return null;
98: }
99:
100: public function visitLambdaVariable(LambdaVariableExpression $node): mixed
101: {
102: return null;
103: }
104:
105: // -------------------------------------------------------------------------
106:
107: private function applyAnd(BinaryExpression $node): void
108: {
109: $this->builder->where(function (Builder $q) use ($node): void {
110: $node->left->accept(new self($q));
111: });
112: $this->builder->where(function (Builder $q) use ($node): void {
113: $node->right->accept(new self($q));
114: });
115: }
116:
117: private function applyOr(BinaryExpression $node): void
118: {
119: $this->builder->where(function (Builder $q) use ($node): void {
120: $node->left->accept(new self($q));
121: })->orWhere(function (Builder $q) use ($node): void {
122: $node->right->accept(new self($q));
123: });
124: }
125:
126: private function applyComparison(BinaryExpression $node, string $op): void
127: {
128: $column = $node->left->accept($this);
129: $value = $node->right->accept($this);
130:
131: if ($value === null) {
132: match ($op) {
133: '=' => $this->builder->whereNull($column),
134: '<>' => $this->builder->whereNotNull($column),
135: default => null,
136: };
137: return;
138: }
139:
140: $this->builder->where($column, $op, $value);
141: }
142:
143: private function applyIn(BinaryExpression $node): void
144: {
145: $column = $node->left->accept($this);
146: $values = $node->right->accept($this);
147:
148: $this->builder->whereIn($column, (array) $values);
149: }
150:
151: private function applyLike(FilterExpression $propExpr, FilterExpression $valExpr, string $prefix, string $suffix): void
152: {
153: $column = $this->unwrapCaseFunction($propExpr)->accept($this);
154: $value = $this->unwrapCaseFunction($valExpr)->accept($this);
155:
156: $this->builder->where($column, 'like', $prefix . $value . $suffix);
157: }
158:
159: /**
160: * Strip tolower()/toupper() wrappers — SQL LIKE is case-insensitive
161: * on the default collations used by MySQL and SQLite.
162: */
163: private function unwrapCaseFunction(FilterExpression $expr): FilterExpression
164: {
165: if ($expr instanceof FunctionCallExpression
166: && in_array(strtolower($expr->name), ['tolower', 'toupper'], true)
167: && count($expr->arguments) === 1
168: ) {
169: return $expr->arguments[0];
170: }
171:
172: return $expr;
173: }
174: }
175: