Skip to content

Commit c1139a6

Browse files
authored
feat: Apply adavanced filter expression on array provider (#442)
1 parent 2f86666 commit c1139a6

4 files changed

Lines changed: 644 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Zhortein\DatatableBundle\Filter\Expression;
6+
7+
final readonly class ArrayExpressionEvaluator
8+
{
9+
/**
10+
* @param array<string, mixed> $row
11+
*/
12+
public function evaluate(AdvancedFilterExpression $expression, array $row): bool
13+
{
14+
return $this->evaluateExpression($expression->root, $row);
15+
}
16+
17+
/**
18+
* @param array<string, mixed> $row
19+
*/
20+
private function evaluateExpression(ExpressionInterface $expression, array $row): bool
21+
{
22+
if ($expression instanceof Group) {
23+
return $this->evaluateGroup($expression, $row);
24+
}
25+
26+
if ($expression instanceof Condition) {
27+
return $this->evaluateCondition($expression, $row);
28+
}
29+
30+
return false;
31+
}
32+
33+
/**
34+
* @param array<string, mixed> $row
35+
*/
36+
private function evaluateGroup(Group $group, array $row): bool
37+
{
38+
if (LogicOperator::And === $group->logic) {
39+
foreach ($group->children as $child) {
40+
if (!$this->evaluateExpression($child, $row)) {
41+
return false;
42+
}
43+
}
44+
45+
return true;
46+
}
47+
48+
foreach ($group->children as $child) {
49+
if ($this->evaluateExpression($child, $row)) {
50+
return true;
51+
}
52+
}
53+
54+
return false;
55+
}
56+
57+
/**
58+
* @param array<string, mixed> $row
59+
*/
60+
private function evaluateCondition(Condition $condition, array $row): bool
61+
{
62+
$rowValue = $this->readFieldValue($row, $condition->field);
63+
64+
return match ($condition->operator) {
65+
ComparisonOperator::Equals => $this->compareEquals($rowValue, $condition->value),
66+
ComparisonOperator::NotEquals => !$this->compareEquals($rowValue, $condition->value),
67+
ComparisonOperator::Contains => $this->compareContains($rowValue, $condition->value),
68+
ComparisonOperator::NotContains => !$this->compareContains($rowValue, $condition->value),
69+
ComparisonOperator::StartsWith => $this->compareStartsWith($rowValue, $condition->value),
70+
ComparisonOperator::EndsWith => $this->compareEndsWith($rowValue, $condition->value),
71+
ComparisonOperator::GreaterThan => $this->compareGreaterThan($rowValue, $condition->value),
72+
ComparisonOperator::GreaterThanOrEquals => $this->compareGreaterThanOrEquals($rowValue, $condition->value),
73+
ComparisonOperator::LessThan => $this->compareLessThan($rowValue, $condition->value),
74+
ComparisonOperator::LessThanOrEquals => $this->compareLessThanOrEquals($rowValue, $condition->value),
75+
ComparisonOperator::Between => $this->compareBetween($rowValue, $condition->value),
76+
ComparisonOperator::IsNull => null === $rowValue,
77+
ComparisonOperator::IsNotNull => null !== $rowValue,
78+
ComparisonOperator::In => $this->compareIn($rowValue, $condition->value),
79+
ComparisonOperator::NotIn => !$this->compareIn($rowValue, $condition->value),
80+
};
81+
}
82+
83+
private function compareEquals(mixed $rowValue, mixed $conditionValue): bool
84+
{
85+
if (is_numeric($rowValue) && is_numeric($conditionValue)) {
86+
return (float) $rowValue === (float) $conditionValue;
87+
}
88+
89+
if ($rowValue instanceof \DateTimeInterface || $this->isDateString($rowValue)) {
90+
$rowDate = $this->normalizeDateString($rowValue);
91+
$conditionDate = $this->normalizeDateString($conditionValue);
92+
93+
return null !== $rowDate && $rowDate === $conditionDate;
94+
}
95+
96+
if (is_bool($rowValue) || $this->isBooleanRepresentable($conditionValue)) {
97+
return $this->normalizeBooleanValue($rowValue) === $this->normalizeBooleanValue($conditionValue);
98+
}
99+
100+
return mb_strtolower(is_scalar($rowValue) ? (string) $rowValue : '') === mb_strtolower(is_scalar($conditionValue) ? (string) $conditionValue : '');
101+
}
102+
103+
private function compareContains(mixed $rowValue, mixed $conditionValue): bool
104+
{
105+
if (!is_scalar($rowValue) || !is_scalar($conditionValue)) {
106+
return false;
107+
}
108+
109+
return str_contains(
110+
mb_strtolower((string) $rowValue),
111+
mb_strtolower((string) $conditionValue),
112+
);
113+
}
114+
115+
private function compareStartsWith(mixed $rowValue, mixed $conditionValue): bool
116+
{
117+
if (!is_scalar($rowValue) || !is_scalar($conditionValue)) {
118+
return false;
119+
}
120+
121+
return str_starts_with(
122+
mb_strtolower((string) $rowValue),
123+
mb_strtolower((string) $conditionValue),
124+
);
125+
}
126+
127+
private function compareEndsWith(mixed $rowValue, mixed $conditionValue): bool
128+
{
129+
if (!is_scalar($rowValue) || !is_scalar($conditionValue)) {
130+
return false;
131+
}
132+
133+
return str_ends_with(
134+
mb_strtolower((string) $rowValue),
135+
mb_strtolower((string) $conditionValue),
136+
);
137+
}
138+
139+
private function compareGreaterThan(mixed $rowValue, mixed $conditionValue): bool
140+
{
141+
if (is_numeric($rowValue) && is_numeric($conditionValue)) {
142+
return (float) $rowValue > (float) $conditionValue;
143+
}
144+
145+
$rowDate = $this->normalizeDateString($rowValue);
146+
$conditionDate = $this->normalizeDateString($conditionValue);
147+
148+
return null !== $rowDate && null !== $conditionDate && $rowDate > $conditionDate;
149+
}
150+
151+
private function compareGreaterThanOrEquals(mixed $rowValue, mixed $conditionValue): bool
152+
{
153+
if (is_numeric($rowValue) && is_numeric($conditionValue)) {
154+
return (float) $rowValue >= (float) $conditionValue;
155+
}
156+
157+
$rowDate = $this->normalizeDateString($rowValue);
158+
$conditionDate = $this->normalizeDateString($conditionValue);
159+
160+
return null !== $rowDate && null !== $conditionDate && $rowDate >= $conditionDate;
161+
}
162+
163+
private function compareLessThan(mixed $rowValue, mixed $conditionValue): bool
164+
{
165+
if (is_numeric($rowValue) && is_numeric($conditionValue)) {
166+
return (float) $rowValue < (float) $conditionValue;
167+
}
168+
169+
$rowDate = $this->normalizeDateString($rowValue);
170+
$conditionDate = $this->normalizeDateString($conditionValue);
171+
172+
return null !== $rowDate && null !== $conditionDate && $rowDate < $conditionDate;
173+
}
174+
175+
private function compareLessThanOrEquals(mixed $rowValue, mixed $conditionValue): bool
176+
{
177+
if (is_numeric($rowValue) && is_numeric($conditionValue)) {
178+
return (float) $rowValue <= (float) $conditionValue;
179+
}
180+
181+
$rowDate = $this->normalizeDateString($rowValue);
182+
$conditionDate = $this->normalizeDateString($conditionValue);
183+
184+
return null !== $rowDate && null !== $conditionDate && $rowDate <= $conditionDate;
185+
}
186+
187+
private function compareBetween(mixed $rowValue, mixed $conditionValue): bool
188+
{
189+
if (!is_array($conditionValue) || 2 !== count($conditionValue)) {
190+
return false;
191+
}
192+
193+
[$min, $max] = array_values($conditionValue);
194+
195+
return $this->compareGreaterThanOrEquals($rowValue, $min) && $this->compareLessThanOrEquals($rowValue, $max);
196+
}
197+
198+
private function compareIn(mixed $rowValue, mixed $conditionValue): bool
199+
{
200+
if (!is_array($conditionValue)) {
201+
return false;
202+
}
203+
204+
foreach ($conditionValue as $value) {
205+
if ($this->compareEquals($rowValue, $value)) {
206+
return true;
207+
}
208+
}
209+
210+
return false;
211+
}
212+
213+
/**
214+
* @param array<string, mixed> $row
215+
*/
216+
private function readFieldValue(array $row, string $field): mixed
217+
{
218+
foreach ($this->getFieldCandidateKeys($field) as $candidateKey) {
219+
if (array_key_exists($candidateKey, $row)) {
220+
return $row[$candidateKey];
221+
}
222+
}
223+
224+
return null;
225+
}
226+
227+
/**
228+
* @return list<string>
229+
*/
230+
private function getFieldCandidateKeys(string $field): array
231+
{
232+
$candidateKeys = [$field];
233+
234+
if (str_contains($field, '.')) {
235+
$candidateKeys[] = str_replace('.', '_', $field);
236+
237+
$parts = explode('.', $field);
238+
$lastPart = $parts[array_key_last($parts)];
239+
240+
if ('' !== $lastPart) {
241+
$candidateKeys[] = $lastPart;
242+
}
243+
}
244+
245+
return array_values(array_unique($candidateKeys));
246+
}
247+
248+
private function normalizeBooleanValue(mixed $value): ?bool
249+
{
250+
if (is_bool($value)) {
251+
return $value;
252+
}
253+
254+
if (is_int($value)) {
255+
return match ($value) {
256+
1 => true,
257+
0 => false,
258+
default => null,
259+
};
260+
}
261+
262+
if (!is_string($value)) {
263+
return null;
264+
}
265+
266+
return match (mb_strtolower(trim($value))) {
267+
'1', 'true', 'yes', 'on' => true,
268+
'0', 'false', 'no', 'off' => false,
269+
default => null,
270+
};
271+
}
272+
273+
private function isBooleanRepresentable(mixed $value): bool
274+
{
275+
return null !== $this->normalizeBooleanValue($value);
276+
}
277+
278+
private function normalizeDateString(mixed $value): ?string
279+
{
280+
if ($value instanceof \DateTimeInterface) {
281+
return $value->format('Y-m-d');
282+
}
283+
284+
if (!is_scalar($value)) {
285+
return null;
286+
}
287+
288+
$value = trim((string) $value);
289+
290+
if ('' === $value) {
291+
return null;
292+
}
293+
294+
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
295+
296+
if (!$date instanceof \DateTimeImmutable) {
297+
return null;
298+
}
299+
300+
return $date->format('Y-m-d');
301+
}
302+
303+
private function isDateString(mixed $value): bool
304+
{
305+
return null !== $this->normalizeDateString($value);
306+
}
307+
}

src/Provider/ArrayDataProvider.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Zhortein\DatatableBundle\Definition\UserFilterDefinition;
1111
use Zhortein\DatatableBundle\Enum\FilterType;
1212
use Zhortein\DatatableBundle\Enum\SortDirection;
13+
use Zhortein\DatatableBundle\Filter\Expression\ArrayExpressionEvaluator;
1314
use Zhortein\DatatableBundle\Request\DatatableRequest;
1415
use Zhortein\DatatableBundle\Result\DatatableResult;
1516

@@ -31,6 +32,7 @@ public function getData(DatatableDefinition $definition, DatatableRequest $reque
3132
$totalItems = count($rows);
3233

3334
$rows = $this->applyUserFilters($rows, $definition, $request);
35+
$rows = $this->applyAdvancedFilters($rows, $request);
3436
$rows = $this->applySearch($rows, $definition, $request);
3537

3638
$filteredItems = count($rows);
@@ -424,4 +426,29 @@ private function compareValues(mixed $leftValue, mixed $rightValue): int
424426

425427
return strnatcasecmp(get_debug_type($leftValue), get_debug_type($rightValue));
426428
}
429+
430+
/**
431+
* @param list<array<string, mixed>> $rows
432+
*
433+
* @return list<array<string, mixed>>
434+
*/
435+
private function applyAdvancedFilters(array $rows, DatatableRequest $request): array
436+
{
437+
if (!$request->hasAdvancedFilters()) {
438+
return $rows;
439+
}
440+
441+
$expression = $request->getAdvancedFilterExpression();
442+
443+
if (null === $expression) {
444+
return $rows;
445+
}
446+
447+
$evaluator = new ArrayExpressionEvaluator();
448+
449+
return array_values(array_filter(
450+
$rows,
451+
fn (array $row): bool => $evaluator->evaluate($expression, $row),
452+
));
453+
}
427454
}

0 commit comments

Comments
 (0)