1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Persistence\Array_;
6:
7: use Atk4\Data\Exception;
8: use Atk4\Data\Field;
9: use Atk4\Data\Model;
10:
11: /**
12: * Returned by Model::action(). Compatible with DSQL to a certain point as it implements
13: * specific methods such as getOne() or getRows().
14: */
15: class Action
16: {
17: /** @var \Iterator<int, array<string, mixed>> */
18: public $generator;
19:
20: /** @var list<\Closure(array<string, mixed>): bool> hack for GC for PHP 8.1.3 or older */
21: private array $_filterFxs = [];
22:
23: /**
24: * @param array<int, array<string, mixed>> $data
25: */
26: public function __construct(array $data)
27: {
28: $this->generator = new \ArrayIterator($data);
29: }
30:
31: /**
32: * Applies FilterIterator making sure that values of $field equal to $value.
33: *
34: * @return $this
35: */
36: public function filter(Model\Scope\AbstractScope $condition)
37: {
38: if (!$condition->isEmpty()) {
39: // CallbackFilterIterator with circular reference (bound function) is not GCed
40: // https://github.com/php/php-src/commit/afab9eb48c883766b7870f76f2e2b0a4bd575786
41: // https://github.com/php/php-src/commit/fb70460d8e7593e32abdaaf8ae8849345d49c8fd
42: // remove the if below once PHP 8.1.3 (or older) is no longer supported
43: $filterFx = function (array $row) use ($condition): bool {
44: return $this->match($row, $condition);
45: };
46: if (\PHP_VERSION_ID < 80104 && count($this->_filterFxs) !== \PHP_INT_MAX) {
47: $this->_filterFxs[] = $filterFx; // prevent filter function to be GCed
48: $filterFxWeakRef = \WeakReference::create($filterFx);
49: $this->generator = new \CallbackFilterIterator($this->generator, static function (array $row) use ($filterFxWeakRef) {
50: return $filterFxWeakRef->get()($row);
51: });
52: } else {
53: $this->generator = new \CallbackFilterIterator($this->generator, $filterFx);
54: }
55: // initialize filter iterator, it is not rewound by default
56: // https://github.com/php/php-src/issues/7952
57: $this->generator->rewind();
58: }
59:
60: return $this;
61: }
62:
63: /**
64: * Calculates SUM|AVG|MIN|MAX aggregate values for $field.
65: *
66: * @return $this
67: */
68: public function aggregate(string $fx, string $field, bool $coalesce = false)
69: {
70: $result = 0;
71: $column = array_column($this->getRows(), $field);
72:
73: switch (strtoupper($fx)) {
74: case 'SUM':
75: $result = array_sum($column);
76:
77: break;
78: case 'AVG':
79: if (!$coalesce) { // TODO add tests and verify against SQL
80: $column = array_filter($column, static fn ($v) => $v !== null);
81: }
82:
83: $result = array_sum($column) / count($column);
84:
85: break;
86: case 'MAX':
87: $result = max($column);
88:
89: break;
90: case 'MIN':
91: $result = min($column);
92:
93: break;
94: default:
95: throw (new Exception('Array persistence driver action unsupported format'))
96: ->addMoreInfo('action', $fx);
97: }
98:
99: $this->generator = new \ArrayIterator([['v' => $result]]);
100:
101: return $this;
102: }
103:
104: /**
105: * Checks if $row matches $condition.
106: *
107: * @param array<string, mixed> $row
108: */
109: protected function match(array $row, Model\Scope\AbstractScope $condition): bool
110: {
111: if ($condition instanceof Model\Scope\Condition) { // simple condition
112: $args = $condition->toQueryArguments();
113:
114: $field = $args[0];
115: $operator = $args[1] ?? null;
116: $value = $args[2] ?? null;
117: if (count($args) === 2) {
118: $value = $operator;
119:
120: $operator = '=';
121: }
122:
123: if (!is_a($field, Field::class)) {
124: throw (new Exception('Array persistence driver condition unsupported format'))
125: ->addMoreInfo('reason', 'Unsupported object instance ' . get_class($field))
126: ->addMoreInfo('condition', $condition);
127: }
128:
129: return $this->evaluateIf($row[$field->shortName] ?? null, $operator, $value);
130: } elseif ($condition instanceof Model\Scope) { // nested conditions
131: $isOr = $condition->isOr();
132: $res = true;
133: foreach ($condition->getNestedConditions() as $nestedCondition) {
134: $submatch = $this->match($row, $nestedCondition);
135:
136: if ($isOr) {
137: // do not check all conditions if any match required
138: if ($submatch) {
139: break;
140: }
141: } elseif (!$submatch) {
142: $res = false;
143:
144: break;
145: }
146: }
147:
148: return $res;
149: }
150:
151: throw (new Exception('Unexpected condition type'))
152: ->addMoreInfo('class', get_class($condition));
153: }
154:
155: /**
156: * @param mixed $v1
157: * @param mixed $v2
158: */
159: protected function evaluateIf($v1, string $operator, $v2): bool
160: {
161: if ($v2 instanceof self) {
162: $v2 = $v2->getRows();
163: }
164:
165: if ($v2 instanceof \Traversable) {
166: throw (new Exception('Unexpected v2 type'))
167: ->addMoreInfo('class', get_class($v2));
168: }
169:
170: switch (strtoupper($operator)) {
171: case '=':
172: $result = is_array($v2) ? $this->evaluateIf($v1, 'IN', $v2) : $v1 === $v2;
173:
174: break;
175: case '>':
176: $result = $v1 > $v2;
177:
178: break;
179: case '>=':
180: $result = $v1 >= $v2;
181:
182: break;
183: case '<':
184: $result = $v1 < $v2;
185:
186: break;
187: case '<=':
188: $result = $v1 <= $v2;
189:
190: break;
191: case '!=':
192: $result = !$this->evaluateIf($v1, '=', $v2);
193:
194: break;
195: case 'LIKE':
196: $pattern = str_ireplace('%', '(.*?)', preg_quote($v2, '~'));
197:
198: $result = (bool) preg_match('~^' . $pattern . '$~', (string) $v1);
199:
200: break;
201: case 'NOT LIKE':
202: $result = !$this->evaluateIf($v1, 'LIKE', $v2);
203:
204: break;
205: case 'IN':
206: $result = false;
207: foreach ($v2 as $v2Item) { // TODO flatten rows, this looses column names!
208: if ($this->evaluateIf($v1, '=', $v2Item)) {
209: $result = true;
210:
211: break;
212: }
213: }
214:
215: break;
216: case 'NOT IN':
217: $result = !$this->evaluateIf($v1, 'IN', $v2);
218:
219: break;
220: case 'REGEXP':
221: $result = (bool) preg_match('/' . $v2 . '/', $v1);
222:
223: break;
224: case 'NOT REGEXP':
225: $result = !$this->evaluateIf($v1, 'REGEXP', $v2);
226:
227: break;
228: default:
229: throw (new Exception('Unsupported operator'))
230: ->addMoreInfo('operator', $operator);
231: }
232:
233: return $result;
234: }
235:
236: /**
237: * Applies sorting on Iterator.
238: *
239: * @param array<int, array{string, 'asc'|'desc'}> $fields
240: *
241: * @return $this
242: */
243: public function order(array $fields)
244: {
245: $data = $this->getRows();
246:
247: // prepare arguments for array_multisort()
248: $args = [];
249: foreach ($fields as [$field, $direction]) {
250: $args[] = array_column($data, $field);
251: $args[] = strtolower($direction) === 'desc' ? \SORT_DESC : \SORT_ASC;
252: }
253: $args[] = &$data;
254:
255: // call sorting
256: array_multisort(...$args);
257:
258: // put data back in generator
259: $this->generator = new \ArrayIterator(array_pop($args));
260:
261: return $this;
262: }
263:
264: /**
265: * Limit Iterator.
266: *
267: * @return $this
268: */
269: public function limit(?int $limit, int $offset = 0)
270: {
271: $this->generator = new \LimitIterator($this->generator, $offset, $limit ?? -1);
272:
273: return $this;
274: }
275:
276: /**
277: * Counts number of rows and replaces our generator with just a single number.
278: *
279: * @return $this
280: */
281: public function count()
282: {
283: $this->generator = new \ArrayIterator([['v' => iterator_count($this->generator)]]);
284:
285: return $this;
286: }
287:
288: /**
289: * Checks if iterator has any rows.
290: *
291: * @return $this
292: */
293: public function exists()
294: {
295: $this->generator->rewind();
296: $this->generator = new \ArrayIterator([['v' => $this->generator->valid() ? 1 : 0]]);
297:
298: return $this;
299: }
300:
301: /**
302: * Return all data inside array.
303: *
304: * @return array<int, array<string, mixed>>
305: */
306: public function getRows(): array
307: {
308: return iterator_to_array($this->generator, true);
309: }
310:
311: /**
312: * Return one row of data.
313: *
314: * @return array<string, mixed>|null
315: */
316: public function getRow(): ?array
317: {
318: $this->generator->rewind(); // TODO alternatively allow to fetch only once
319: $row = $this->generator->current();
320: $this->generator->next();
321:
322: return $row;
323: }
324:
325: /**
326: * Return one value from one row of data.
327: *
328: * @return mixed
329: */
330: public function getOne()
331: {
332: $data = $this->getRow();
333:
334: return reset($data);
335: }
336: }
337: