Newer
Older
<?php
if (!defined('SORT_NATURAL')) {
define('SORT_NATURAL', 6);
}
if (!defined('SORT_FLAG_CASE')) {
define('SORT_FLAG_CASE', 8);
}
/**
* SimpleCollection.class.php
* collection of assoc arrays with convenience
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License, or (at your option) any later version.
*
* @author André Noack <noack@data-quest.de>
* @copyright 2013 Stud.IP Core-Group
* @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
* @category Stud.IP

Marcus Eibrink-Lunzenauer
committed
*
* @template T
*/
class SimpleCollection extends StudipArrayObject
{
/**
* callable to initialize collection
*

Marcus Eibrink-Lunzenauer
committed
* @var ?callable(): array<T>
*/
protected $finder;
/**
* number of records after last init
*
* @var int
*/
protected $last_count;
/**
* collection with deleted records

Marcus Eibrink-Lunzenauer
committed
* @var static
*/
protected $deleted;
/**
* creates a collection from an array of arrays
* all arrays should contain same keys, but is not enforced
*

Marcus Eibrink-Lunzenauer
committed
* @param array<T> $data array containing assoc arrays
* @return SimpleCollection<T>

Marcus Eibrink-Lunzenauer
committed
public static function createFromArray(array $data)
{
return new self($data);
}
/**
* converts arrays or objects to ArrayObject objects
* if ArrayAccess interface is not available
*
* @param mixed $a

Marcus Eibrink-Lunzenauer
committed
* @return StudipArrayObject|ArrayAccess
*/
public static function arrayToArrayObject($a)
{
if ($a instanceof StudipArrayObject) {
$a->setFlags(StudipArrayObject::ARRAY_AS_PROPS);
return $a;
}
if ($a instanceof ArrayObject) {
return new StudipArrayObject($a->getArrayCopy(), StudipArrayObject::ARRAY_AS_PROPS);
}
if ($a instanceof ArrayAccess) {
return $a;
}
return new StudipArrayObject((array) $a, StudipArrayObject::ARRAY_AS_PROPS);
}
/**
* returns closure to compare a value against given arguments
* using given operator
*

Marcus Eibrink-Lunzenauer
committed
* @param string|callable(mixed, mixed|array): bool $operator
* @param mixed|array $args
* @throws InvalidArgumentException

Marcus Eibrink-Lunzenauer
committed
* @return callable(mixed): bool comparison function
*/
public static function getCompFunc($operator, $args)
{

Marcus Eibrink-Lunzenauer
committed
if (is_callable($operator)) {
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
$comp_func = function ($a) use ($args, $operator) {
return $operator($a, $args);
};
} else {
if (!is_array($args)) {
$args = [$args];
}
switch ($operator) {
case '==':
$comp_func = function ($a) use ($args) {
return in_array($a, $args);
};
break;
case '===':
$comp_func = function ($a) use ($args) {
return in_array($a, $args, true);
};
break;
case '!=':
case '<>':
$comp_func = function ($a) use ($args) {
return !in_array($a, $args);
};
break;
case '!==':
$comp_func = function ($a) use ($args) {
return !in_array($a, $args, true);
};
break;
case '<':
case '>':
case '<=':
case '>=':
$op_func = function ($a, $b) use ($operator) {
if ($operator === '<') {
return $a < $b;
} elseif ($operator === '<=') {
return $a <= $b;
} elseif ($operator === '>=') {
return $a >= $b;
} elseif ($operator === '>') {
return $a > $b;
}
};
$comp_func = function ($a) use ($op_func, $args) {
return $op_func($a, $args[0]);
};
break;
case '><':
$comp_func = function ($a) use ($args) {
return $a > $args[0] && $a < $args[1];
};
break;
case '>=<=':
$comp_func = function ($a) use ($args) {
return $a >= $args[0] && $a <= $args[1];
};
break;
case '%=':
$comp_func = function ($a) use ($args) {
$a = mb_strtolower(static::translitLatin1($a));
$args = array_map([static::class, 'translitLatin1'], $args);
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
$args = array_map('mb_strtolower', $args);
return in_array($a, $args);
};
break;
case '*=':
$comp_func = function ($a) use ($args) {
foreach ($args as $arg) {
if (mb_strpos($a, $arg) !== false) {
return true;
}
}
return false;
};
break;
case '^=':
$comp_func = function ($a) use ($args) {
foreach ($args as $arg) {
if (mb_strpos($a, $arg) === 0) {
return true;
}
}
return false;
};
break;
case '$=':
$comp_func = function ($a) use ($args) {
foreach ($args as $arg) {
$found = mb_strrpos($a, $arg);
if ($found !== false && ($found + mb_strlen($arg)) === mb_strlen($a)) {
return true;
}
}
return false;
};
break;
case '~=':
$comp_func = function ($a) use ($args) {
foreach ($args as $arg) {
if (preg_match($arg, $a) === 1) {
return true;
}
}
return false;
};
break;
default:
throw new InvalidArgumentException('unknown operator: ' . $operator);
}
}
return $comp_func;
}
/**
* transliterates latin1 string to ascii
*
* @param string $text
* @return string
*/
public static function translitLatin1($text)
{
if (!preg_match('/[\200-\377]/', $text)) {
return $text;
}
$text = str_replace(['ä','Ä','ö','Ö','ü','Ü','ß'], ['a','A','o','O','u','U','s'], $text);
$text = str_replace(['À','Á','Â','Ã','Å','Æ'], 'A' , $text);
$text = str_replace(['à','á','â','ã','å','æ'], 'a' , $text);
$text = str_replace(['È','É','Ê','Ë'], 'E' , $text);
$text = str_replace(['è','é','ê','ë'], 'e' , $text);
$text = str_replace(['Ì','Í','Î','Ï'], 'I' , $text);
$text = str_replace(['ì','í','î','ï'], 'i' , $text);
$text = str_replace(['Ò','Ó','Õ','Ô','Ø'], 'O' , $text);
$text = str_replace(['ò','ó','ô','õ','ø'], 'o' , $text);
$text = str_replace(['Ù','Ú','Û'], 'U' , $text);
$text = str_replace(['ù','ú','û'], 'u' , $text);
$text = str_replace(['Ç','ç','Ð','Ñ','Ý','ñ','ý','ÿ'], ['C','c','D','N','Y','n','y','y'] , $text);
return $text;
}
/**
* Constructor
*

Marcus Eibrink-Lunzenauer
committed
* @param array<T>|callable(): array<T> $data array or closure to fill collection
*/
public function __construct($data = [])
{
parent::__construct();

Marcus Eibrink-Lunzenauer
committed
$this->finder = is_callable($data) ? $data : null;
$this->deleted = clone $this;

Marcus Eibrink-Lunzenauer
committed
if (is_callable($data)) {
$this->refresh();
} else {
$this->exchangeArray($data);
}
}
/**
* @param array $input
* @return array
*/
public function exchangeArray($input)
{
return parent::exchangeArray(array_map(
[static::class, 'arrayToArrayObject'],
$input
));
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
}
/**
* converts the object and all elements to plain arrays
*
* @return array
*/
public function toArray()
{
$args = func_get_args();
return $this->map(function ($a) use ($args) {
if (method_exists($a, 'toArray')) {
return call_user_func_array([$a, 'toArray'], $args);
}
if (method_exists($a, 'getArrayCopy')) {
return $a->getArrayCopy();
}
return (array) $a;
}
);
}
/**
*
* @see ArrayObject::append()
*/
public function append($newval)
{

Marcus Eibrink-Lunzenauer
committed
parent::append(static::arrayToArrayObject($newval));
}
/**
* Sets the value at the specified index
* ensures the value has ArrayAccess
*
* @param mixed $index
* @param mixed $newval
*
* @see ArrayObject::offsetSet()
*/
public function offsetSet($index, $newval): void
{
if (is_numeric($index)) {
$index = (int) $index;
}

Marcus Eibrink-Lunzenauer
committed
parent::offsetSet($index, static::arrayToArrayObject($newval));
}
/**
* Unsets the value at the specified index
* value is moved to internal deleted collection
*
* @see ArrayObject::offsetUnset()
* @throws InvalidArgumentException
*/
public function offsetUnset($index): void
{
if ($this->offsetExists($index)) {
$this->deleted[] = $this->offsetGet($index);
}

Marcus Eibrink-Lunzenauer
committed
parent::offsetUnset($index);
}
/**
* sets the finder function
*

Marcus Eibrink-Lunzenauer
committed
* @param callable(): array<T> $finder
* @return void

Marcus Eibrink-Lunzenauer
committed
public function setFinder(callable $finder)
{
$this->finder = $finder;
}
/**
* get deleted records collection

Marcus Eibrink-Lunzenauer
committed
* @return SimpleCollection<T>
*/
public function getDeleted()
{
return $this->deleted;
}
/**
* reloads the elements of the collection
* by calling the finder function
*

Marcus Eibrink-Lunzenauer
committed
* @return ?int of records after refresh
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
*/
public function refresh()
{
if (is_callable($this->finder)) {
$data = call_user_func($this->finder);
$this->exchangeArray($data);
$this->deleted->exchangeArray([]);
return $this->last_count = $this->count();
}
}
/**
* returns a new collection containing all elements
* where given columns value matches given value(s) using passed operator
* pass array for multiple values
*
* operators:
* == equal, like php
* === identical, like php
* !=,<> not equal, like php
* !== not identical, like php
* <,>,<=,>= less,greater,less or equal,greater or equal
* >< between without borders, needs two arguments
* >=<= between including borders, needs two arguments
* %= like string, transliterate to ascii,case insensitive
* *= contains string
* ^= begins with string
* $= ends with string
* ~= regex
*
* @param string $key the column name

Marcus Eibrink-Lunzenauer
committed
* @param mixed $values value to search for
* @param string|callable $op operator to find
* @return SimpleCollection<T> with found records
*/
public function findBy($key, $values, $op = '==')
{
$comp_func = self::getCompFunc($op, $values);
return $this->filter(function ($record) use ($comp_func, $key) {
return $comp_func($record[$key]);
});
}
/**
* returns the first element
* where given column has given value(s)
* pass array for multiple values
*
* @param string $key the column name

Marcus Eibrink-Lunzenauer
committed
* @param mixed $values value to search for,
* @param string|callable $op operator to find
* @return ?T found record
*/
public function findOneBy($key, $values, $op = '==')
{
$comp_func = self::getCompFunc($op, $values);
return $this->filter(function ($record) use ($comp_func, $key) {
return $comp_func($record[$key]);
}, 1)->first();
}
/**
* apply given callback to all elements of
* collection
*

Marcus Eibrink-Lunzenauer
committed
* @param callable(T): int $func the function to call
* @return int|false addition of return values

Marcus Eibrink-Lunzenauer
committed
public function each(callable $func)
{
$result = false;
foreach ($this->storage as $record) {
$result += call_user_func($func, $record);
}
return $result;
}
/**
* apply given callback to all elements of
* collection and give back array of return values
*

Marcus Eibrink-Lunzenauer
committed
* @param callable(T, mixed): mixed $func the function to call
* @return array<mixed>

Marcus Eibrink-Lunzenauer
committed
public function map(callable $func)
{
$results = [];
foreach ($this->storage as $key => $value) {
$results[$key] = call_user_func($func, $value, $key);
}
return $results;
}
/**
* filter elements
* if given callback returns true
*

Marcus Eibrink-Lunzenauer
committed
* @param ?callable(T, mixed): bool $func the function to call
* @param ?integer $limit limit number of found records
* @return SimpleCollection<T> containing filtered elements

Marcus Eibrink-Lunzenauer
committed
public function filter(callable $func = null, $limit = null)
{
$results = [];
$found = 0;
foreach ($this->storage as $key => $value) {
if (call_user_func($func, $value, $key)) {
$results[$key] = $value;
if ($limit && (++$found == $limit)) {
break;
}
}
}
return self::createFromArray($results);
}
/**
* Returns whether any element of the collection returns true for the
* given callback.
*

Marcus Eibrink-Lunzenauer
committed
* @param callable(T, mixed): bool $func the function to call

Marcus Eibrink-Lunzenauer
committed
public function any(callable $func)
{
foreach ($this->storage as $key => $value) {
if (call_user_func($func, $value, $key)) {
return true;
}
}
return false;
}
/**
* Returns whether every element of the collection returns true for the
* given callback.
*

Marcus Eibrink-Lunzenauer
committed
* @param callable(T, mixed): bool $func the function to call

Marcus Eibrink-Lunzenauer
committed
public function every(callable $func)
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
{
foreach ($this->storage as $key => $value) {
if (!call_user_func($func, $value, $key)) {
return false;
}
}
return true;
}
/**
* extract array of columns values
* pass array or space-delimited string for multiple columns
*
* @param string|array $columns the column(s) to extract
* @return array of extracted values
*/
public function pluck($columns)
{
if (!is_array($columns)) {
$columns = words($columns);
}
$func = function ($r) use ($columns) {
$result = [];
foreach ($columns as $c) {
$result[] = $r[$c];
}
return $result;
};
$result = $this->map($func);
return count($columns) === 1 ? array_map('current', $result) : $result;
}
/**
* returns the collection as grouped array
* first param is the column to group by, it becomes the key in
* the resulting array, default is pk. Limit returned fields with second param
* The grouped entries can optoionally go through the given
* callback. If no callback is provided, only the first grouped
* entry is returned, suitable for grouping by unique column
*
* @param string $group_by the column to group by, pk if ommitted

Marcus Eibrink-Lunzenauer
committed
* @param string|array|null $only_these_fields limit returned fields
* @param ?callable $group_func closure to aggregate grouped entries
* @return array assoc array
*/

Marcus Eibrink-Lunzenauer
committed
public function toGroupedArray($group_by = 'id', $only_these_fields = null, callable $group_func = null)
{
$result = [];
if (is_string($only_these_fields)) {
$only_these_fields = words($only_these_fields);
}
foreach ($this->toArray() as $record) {
$key = $record[$group_by];
$ret = [];
if (is_array($only_these_fields)) {
$result[$key][] = array_intersect_key($record, array_flip($only_these_fields));
} else {
$result[$key][] = $record;
}
}
if ($group_func === null) {
$group_func = 'current';
}
return array_map($group_func, $result);
}
/**
* get the first element
*

Marcus Eibrink-Lunzenauer
committed
* @return ?T first element or null
*/
public function first()
{
$keys = array_keys($this->storage);
$first_offset = reset($keys);
return $this->offsetGet($first_offset ?: 0);
}
/**
* get the last element
*

Marcus Eibrink-Lunzenauer
committed
* @return ?T last element or null
*/
public function last()
{
$keys = array_keys($this->storage);
$last_offset = end($keys);
return $this->offsetGet($last_offset ?: 0);
}
/**
* get the the value from given key from first element
*

Marcus Eibrink-Lunzenauer
committed
* @param string $key
* @return mixed
*/
public function val($key)
{
$first = $this->first();
}
/**
* mark element(s) for deletion
* where given column has given value(s)
* element(s) are moved to
* internal deleted collection
* pass array for multiple values
*
* operators:
* == equal, like php
* === identical, like php
* !=,<> not equal, like php
* !== not identical, like php
* <,>,<=,>= less,greater,less or equal,greater or equal
* >< between without borders, needs two arguments
* >=<= between including borders, needs two arguments
* %= like string, transliterate to ascii,case insensitive
* *= contains string
* ^= begins with string
* $= ends with string
* ~= regex
*
* @param string $key
* @param mixed $values

Marcus Eibrink-Lunzenauer
committed
* @param string|callable(mixed, mixed|array): bool $op operator to find elements
* @return int|false number of unsetted elements
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
*/
public function unsetBy($key, $values, $op = '==')
{
$ret = false;
$comp_func = self::getCompFunc($op, $values);
foreach ($this->storage as $k => $record) {
if ($comp_func($record[$key])) {
$this->offsetunset($k);
$ret += 1;
}
}
return $ret;
}
/**
* sorts the collection by columns of contained elements and returns it
*
* works like sql order by:
* first param is a string containing combinations of column names
* and sort direction, separated by comma e.g.
* 'name asc, nummer desc '
* sorts first by name ascending and then by nummer descending
* second param denotes the sort type (using PHP sort constants):
* SORT_LOCALE_STRING:
* compare items as strings, transliterate latin1 to ascii, case insensitiv, natural order for numbers
* SORT_NUMERIC:
* compare items as integers
* SORT_STRING:
* compare items as strings
* SORT_NATURAL:
* compare items as strings using "natural ordering"
* SORT_FLAG_CASE:
* can be combined (bitwise OR) with SORT_STRING or SORT_NATURAL to sort strings case-insensitively
*
* @param string $order columns to order by
* @param integer $sort_flags

Marcus Eibrink-Lunzenauer
committed
* @return $this the sorted collection
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
*/
public function orderBy($order, $sort_flags = SORT_LOCALE_STRING)
{
//('name asc, nummer desc ')
$sort_locale = false;
switch ($sort_flags) {
case SORT_NATURAL:
$sort_func = 'strnatcmp';
break;
case SORT_NATURAL | SORT_FLAG_CASE:
$sort_func = 'strnatcasecmp';
break;
case SORT_STRING | SORT_FLAG_CASE:
$sort_func = 'strcasecmp';
break;
case SORT_STRING:
$sort_func = 'strcmp';
break;
case SORT_NUMERIC:
$sort_func = function ($a, $b) {
return (int) $a - (int) $b;
};
break;
case SORT_LOCALE_STRING:
default:
$sort_func = 'strnatcasecmp';
$sort_locale = true;
}
$sorter = [];
foreach (explode(',', $order) as $one) {
$sorter[] = array_values(array_filter(array_map('trim', explode(' ', $one))));
}
$func = function ($d1, $d2) use ($sorter, $sort_func, $sort_locale) {
do {
$current_sorter = current($sorter);
$field = $current_sorter[0];
$dir = $current_sorter[1] ?? '';
if (!$sort_locale) {
$value1 = $d1[$field];
$value2 = $d2[$field];
} else {
$value1 = static::translitLatin1(mb_substr($d1[$field], 0, 100));
$value2 = static::translitLatin1(mb_substr($d2[$field], 0, 100));
}
$ret = $sort_func($value1, $value2);
if (strtolower($dir) == 'desc') $ret = $ret * -1;
} while ($ret === 0 && next($sorter));
return $ret;
};
if (count($sorter)) {
$this->uasort($func);
}
return $this;
}
/**
* returns a new collection contaning a sequence of original collection
* mimics the sql limit constrain:
* used with one parameter, the first x elements are extracted
* used with two parameters, the first parameter denotes the offset, the second the
* number of elements
*
* @param integer $arg1

Marcus Eibrink-Lunzenauer
committed
* @param ?integer $arg2
* @return SimpleCollection<T>
*/
public function limit($arg1, $arg2 = null)
{
if (is_null($arg2)) {
if ($arg1 > 0) {
$row_count = $arg1;
$offset = 0;
} else {
$row_count = abs($arg1);
$offset = $arg1;
}
} else {
$offset = $arg1;
$row_count = $arg2;
}
return self::createFromArray(array_slice($this->storage, $offset, $row_count, true));
}
/**
* calls the given method on all elements
* of the collection

Marcus Eibrink-Lunzenauer
committed
* @param literal-string $method methodname to call
* @param array $params parameters for methodcall
* @return array of all return values
*/
public function sendMessage($method, $params = []) {
$results = [];
foreach ($this->storage as $record) {
$results[] = call_user_func_array([$record, $method], $params);
}
return $results;
}
/**
* magic version of sendMessage
* calls undefineds methods on all elements of the collection
* But beware of the dark side...
*

Marcus Eibrink-Lunzenauer
committed
* @param literal-string $method methodname to call
* @param array $params parameters for methodcall
* @return array of all return values
*/
public function __call($method, $params)
{
return $this->sendMessage($method, $params);
}
/**
* merge in another collection, elements are appended
*

Marcus Eibrink-Lunzenauer
committed
* @param SimpleCollection<T> $a_collection
* @return void
*/
public function merge(SimpleCollection $a_collection)
{
$this->storage = array_merge($this->storage, $a_collection->getArrayCopy());
}
}