1<?php 2/** 3 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) 4 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) 5 * 6 * Licensed under The MIT License 7 * For full copyright and license information, please see the LICENSE.txt 8 * Redistributions of files must retain the above copyright notice. 9 * 10 * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) 11 * @since 2.2.0 12 * @license https://opensource.org/licenses/mit-license.php MIT License 13 */ 14namespace Cake\Utility; 15 16use ArrayAccess; 17use InvalidArgumentException; 18use RuntimeException; 19 20/** 21 * Library of array functions for manipulating and extracting data 22 * from arrays or 'sets' of data. 23 * 24 * `Hash` provides an improved interface, more consistent and 25 * predictable set of features over `Set`. While it lacks the spotty 26 * support for pseudo Xpath, its more fully featured dot notation provides 27 * similar features in a more consistent implementation. 28 * 29 * @link https://book.cakephp.org/3/en/core-libraries/hash.html 30 */ 31class Hash 32{ 33 /** 34 * Get a single value specified by $path out of $data. 35 * Does not support the full dot notation feature set, 36 * but is faster for simple read operations. 37 * 38 * @param array|\ArrayAccess $data Array of data or object implementing 39 * \ArrayAccess interface to operate on. 40 * @param string|int|string[]|null $path The path being searched for. Either a dot 41 * separated string, or an array of path segments. 42 * @param mixed $default The return value when the path does not exist 43 * @throws \InvalidArgumentException 44 * @return mixed The value fetched from the array, or null. 45 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::get 46 */ 47 public static function get($data, $path, $default = null) 48 { 49 if (!(is_array($data) || $data instanceof ArrayAccess)) { 50 throw new InvalidArgumentException( 51 'Invalid data type, must be an array or \ArrayAccess instance.' 52 ); 53 } 54 55 if (empty($data) || $path === null) { 56 return $default; 57 } 58 59 if (is_string($path) || is_numeric($path)) { 60 $parts = explode('.', (string)$path); 61 } else { 62 if (!is_array($path)) { 63 throw new InvalidArgumentException(sprintf( 64 'Invalid Parameter %s, should be dot separated path or array.', 65 $path 66 )); 67 } 68 69 $parts = $path; 70 } 71 72 switch (count($parts)) { 73 case 1: 74 return isset($data[$parts[0]]) ? $data[$parts[0]] : $default; 75 case 2: 76 return isset($data[$parts[0]][$parts[1]]) ? $data[$parts[0]][$parts[1]] : $default; 77 case 3: 78 return isset($data[$parts[0]][$parts[1]][$parts[2]]) ? $data[$parts[0]][$parts[1]][$parts[2]] : $default; 79 default: 80 foreach ($parts as $key) { 81 if ((is_array($data) || $data instanceof ArrayAccess) && isset($data[$key])) { 82 $data = $data[$key]; 83 } else { 84 return $default; 85 } 86 } 87 } 88 89 return $data; 90 } 91 92 /** 93 * Gets the values from an array matching the $path expression. 94 * The path expression is a dot separated expression, that can contain a set 95 * of patterns and expressions: 96 * 97 * - `{n}` Matches any numeric key, or integer. 98 * - `{s}` Matches any string key. 99 * - `{*}` Matches any value. 100 * - `Foo` Matches any key with the exact same value. 101 * 102 * There are a number of attribute operators: 103 * 104 * - `=`, `!=` Equality. 105 * - `>`, `<`, `>=`, `<=` Value comparison. 106 * - `=/.../` Regular expression pattern match. 107 * 108 * Given a set of User array data, from a `$usersTable->find('all')` call: 109 * 110 * - `1.User.name` Get the name of the user at index 1. 111 * - `{n}.User.name` Get the name of every user in the set of users. 112 * - `{n}.User[id].name` Get the name of every user with an id key. 113 * - `{n}.User[id>=2].name` Get the name of every user with an id key greater than or equal to 2. 114 * - `{n}.User[username=/^paul/]` Get User elements with username matching `^paul`. 115 * - `{n}.User[id=1].name` Get the Users name with id matching `1`. 116 * 117 * @param array|\ArrayAccess $data The data to extract from. 118 * @param string $path The path to extract. 119 * @return array|\ArrayAccess An array of the extracted values. Returns an empty array 120 * if there are no matches. 121 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::extract 122 */ 123 public static function extract($data, $path) 124 { 125 if (!(is_array($data) || $data instanceof ArrayAccess)) { 126 throw new InvalidArgumentException( 127 'Invalid data type, must be an array or \ArrayAccess instance.' 128 ); 129 } 130 131 if (empty($path)) { 132 return $data; 133 } 134 135 // Simple paths. 136 if (!preg_match('/[{\[]/', $path)) { 137 $data = static::get($data, $path); 138 if ($data !== null && !(is_array($data) || $data instanceof ArrayAccess)) { 139 return [$data]; 140 } 141 142 return $data !== null ? (array)$data : []; 143 } 144 145 if (strpos($path, '[') === false) { 146 $tokens = explode('.', $path); 147 } else { 148 $tokens = Text::tokenize($path, '.', '[', ']'); 149 } 150 151 $_key = '__set_item__'; 152 153 $context = [$_key => [$data]]; 154 155 foreach ($tokens as $token) { 156 $next = []; 157 158 list($token, $conditions) = self::_splitConditions($token); 159 160 foreach ($context[$_key] as $item) { 161 if (is_object($item) && method_exists($item, 'toArray')) { 162 /** @var \Cake\Datasource\EntityInterface $item */ 163 $item = $item->toArray(); 164 } 165 foreach ((array)$item as $k => $v) { 166 if (static::_matchToken($k, $token)) { 167 $next[] = $v; 168 } 169 } 170 } 171 172 // Filter for attributes. 173 if ($conditions) { 174 $filter = []; 175 foreach ($next as $item) { 176 if ( 177 (is_array($item) || $item instanceof ArrayAccess) && 178 static::_matches($item, $conditions) 179 ) { 180 $filter[] = $item; 181 } 182 } 183 $next = $filter; 184 } 185 $context = [$_key => $next]; 186 } 187 188 return $context[$_key]; 189 } 190 191 /** 192 * Split token conditions 193 * 194 * @param string $token the token being splitted. 195 * @return array [token, conditions] with token splitted 196 */ 197 protected static function _splitConditions($token) 198 { 199 $conditions = false; 200 $position = strpos($token, '['); 201 if ($position !== false) { 202 $conditions = substr($token, $position); 203 $token = substr($token, 0, $position); 204 } 205 206 return [$token, $conditions]; 207 } 208 209 /** 210 * Check a key against a token. 211 * 212 * @param string $key The key in the array being searched. 213 * @param string $token The token being matched. 214 * @return bool 215 */ 216 protected static function _matchToken($key, $token) 217 { 218 switch ($token) { 219 case '{n}': 220 return is_numeric($key); 221 case '{s}': 222 return is_string($key); 223 case '{*}': 224 return true; 225 default: 226 return is_numeric($token) ? ($key == $token) : $key === $token; 227 } 228 } 229 230 /** 231 * Checks whether or not $data matches the attribute patterns 232 * 233 * @param array|\ArrayAccess $data Array of data to match. 234 * @param string $selector The patterns to match. 235 * @return bool Fitness of expression. 236 */ 237 protected static function _matches($data, $selector) 238 { 239 preg_match_all( 240 '/(\[ (?P<attr>[^=><!]+?) (\s* (?P<op>[><!]?[=]|[><]) \s* (?P<val>(?:\/.*?\/ | [^\]]+)) )? \])/x', 241 $selector, 242 $conditions, 243 PREG_SET_ORDER 244 ); 245 246 foreach ($conditions as $cond) { 247 $attr = $cond['attr']; 248 $op = isset($cond['op']) ? $cond['op'] : null; 249 $val = isset($cond['val']) ? $cond['val'] : null; 250 251 // Presence test. 252 if (empty($op) && empty($val) && !isset($data[$attr])) { 253 return false; 254 } 255 256 if (is_array($data)) { 257 $attrPresent = array_key_exists($attr, $data); 258 } else { 259 $attrPresent = $data->offsetExists($attr); 260 } 261 // Empty attribute = fail. 262 if (!$attrPresent) { 263 return false; 264 } 265 266 $prop = null; 267 if (isset($data[$attr])) { 268 $prop = $data[$attr]; 269 } 270 $isBool = is_bool($prop); 271 if ($isBool && is_numeric($val)) { 272 $prop = $prop ? '1' : '0'; 273 } elseif ($isBool) { 274 $prop = $prop ? 'true' : 'false'; 275 } elseif (is_numeric($prop)) { 276 $prop = (string)$prop; 277 } 278 279 // Pattern matches and other operators. 280 if ($op === '=' && $val && $val[0] === '/') { 281 if (!preg_match($val, $prop)) { 282 return false; 283 } 284 } elseif ( 285 ($op === '=' && $prop != $val) || 286 ($op === '!=' && $prop == $val) || 287 ($op === '>' && $prop <= $val) || 288 ($op === '<' && $prop >= $val) || 289 ($op === '>=' && $prop < $val) || 290 ($op === '<=' && $prop > $val) 291 ) { 292 return false; 293 } 294 } 295 296 return true; 297 } 298 299 /** 300 * Insert $values into an array with the given $path. You can use 301 * `{n}` and `{s}` elements to insert $data multiple times. 302 * 303 * @param array $data The data to insert into. 304 * @param string $path The path to insert at. 305 * @param mixed $values The values to insert. 306 * @return array The data with $values inserted. 307 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::insert 308 */ 309 public static function insert(array $data, $path, $values = null) 310 { 311 $noTokens = strpos($path, '[') === false; 312 if ($noTokens && strpos($path, '.') === false) { 313 $data[$path] = $values; 314 315 return $data; 316 } 317 318 if ($noTokens) { 319 $tokens = explode('.', $path); 320 } else { 321 $tokens = Text::tokenize($path, '.', '[', ']'); 322 } 323 324 if ($noTokens && strpos($path, '{') === false) { 325 return static::_simpleOp('insert', $data, $tokens, $values); 326 } 327 328 $token = array_shift($tokens); 329 $nextPath = implode('.', $tokens); 330 331 list($token, $conditions) = static::_splitConditions($token); 332 333 foreach ($data as $k => $v) { 334 if (static::_matchToken($k, $token)) { 335 if (!$conditions || static::_matches($v, $conditions)) { 336 $data[$k] = $nextPath 337 ? static::insert($v, $nextPath, $values) 338 : array_merge($v, (array)$values); 339 } 340 } 341 } 342 343 return $data; 344 } 345 346 /** 347 * Perform a simple insert/remove operation. 348 * 349 * @param string $op The operation to do. 350 * @param array $data The data to operate on. 351 * @param string[] $path The path to work on. 352 * @param mixed $values The values to insert when doing inserts. 353 * @return array data. 354 */ 355 protected static function _simpleOp($op, $data, $path, $values = null) 356 { 357 $_list =& $data; 358 359 $count = count($path); 360 $last = $count - 1; 361 foreach ($path as $i => $key) { 362 if ($op === 'insert') { 363 if ($i === $last) { 364 $_list[$key] = $values; 365 366 return $data; 367 } 368 if (!isset($_list[$key])) { 369 $_list[$key] = []; 370 } 371 $_list =& $_list[$key]; 372 if (!is_array($_list)) { 373 $_list = []; 374 } 375 } elseif ($op === 'remove') { 376 if ($i === $last) { 377 if (is_array($_list)) { 378 unset($_list[$key]); 379 } 380 381 return $data; 382 } 383 if (!isset($_list[$key])) { 384 return $data; 385 } 386 $_list =& $_list[$key]; 387 } 388 } 389 390 return $data; 391 } 392 393 /** 394 * Remove data matching $path from the $data array. 395 * You can use `{n}` and `{s}` to remove multiple elements 396 * from $data. 397 * 398 * @param array $data The data to operate on 399 * @param string $path A path expression to use to remove. 400 * @return array The modified array. 401 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::remove 402 */ 403 public static function remove(array $data, $path) 404 { 405 $noTokens = strpos($path, '[') === false; 406 $noExpansion = strpos($path, '{') === false; 407 408 if ($noExpansion && $noTokens && strpos($path, '.') === false) { 409 unset($data[$path]); 410 411 return $data; 412 } 413 414 $tokens = $noTokens ? explode('.', $path) : Text::tokenize($path, '.', '[', ']'); 415 416 if ($noExpansion && $noTokens) { 417 return static::_simpleOp('remove', $data, $tokens); 418 } 419 420 $token = array_shift($tokens); 421 $nextPath = implode('.', $tokens); 422 423 list($token, $conditions) = self::_splitConditions($token); 424 425 foreach ($data as $k => $v) { 426 $match = static::_matchToken($k, $token); 427 if ($match && is_array($v)) { 428 if ($conditions) { 429 if (static::_matches($v, $conditions)) { 430 if ($nextPath !== '') { 431 $data[$k] = static::remove($v, $nextPath); 432 } else { 433 unset($data[$k]); 434 } 435 } 436 } else { 437 $data[$k] = static::remove($v, $nextPath); 438 } 439 if (empty($data[$k])) { 440 unset($data[$k]); 441 } 442 } elseif ($match && $nextPath === '') { 443 unset($data[$k]); 444 } 445 } 446 447 return $data; 448 } 449 450 /** 451 * Creates an associative array using `$keyPath` as the path to build its keys, and optionally 452 * `$valuePath` as path to get the values. If `$valuePath` is not specified, all values will be initialized 453 * to null (useful for Hash::merge). You can optionally group the values by what is obtained when 454 * following the path specified in `$groupPath`. 455 * 456 * @param array $data Array from where to extract keys and values 457 * @param array|string|null $keyPath A dot-separated string. If null it will create a numbered array. 458 * @param array|string|null $valuePath A dot-separated string. 459 * @param string|null $groupPath A dot-separated string. 460 * @return array Combined array 461 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::combine 462 * @throws \RuntimeException When keys is an array, and keys and values count is unequal. 463 */ 464 public static function combine(array $data, $keyPath, $valuePath = null, $groupPath = null) 465 { 466 if (empty($data)) { 467 return []; 468 } 469 470 if (is_array($keyPath)) { 471 $format = array_shift($keyPath); 472 $keys = static::format($data, $keyPath, $format); 473 } elseif ($keyPath === null) { 474 $keys = $keyPath; 475 } else { 476 $keys = static::extract($data, $keyPath); 477 } 478 if ($keyPath !== null && empty($keys)) { 479 return []; 480 } 481 482 $vals = null; 483 if (!empty($valuePath) && is_array($valuePath)) { 484 $format = array_shift($valuePath); 485 $vals = static::format($data, $valuePath, $format); 486 } elseif (!empty($valuePath)) { 487 $vals = static::extract($data, $valuePath); 488 } 489 if (empty($vals)) { 490 $vals = array_fill(0, $keys === null ? count($data) : count($keys), null); 491 } 492 493 if (is_array($keys) && count($keys) !== count($vals)) { 494 throw new RuntimeException( 495 'Hash::combine() needs an equal number of keys + values.' 496 ); 497 } 498 499 if ($groupPath !== null) { 500 $group = static::extract($data, $groupPath); 501 if (!empty($group)) { 502 $c = is_array($keys) ? count($keys) : count($vals); 503 $out = []; 504 for ($i = 0; $i < $c; $i++) { 505 if (!isset($group[$i])) { 506 $group[$i] = 0; 507 } 508 if (!isset($out[$group[$i]])) { 509 $out[$group[$i]] = []; 510 } 511 if ($keys === null) { 512 $out[$group[$i]][] = $vals[$i]; 513 } else { 514 $out[$group[$i]][$keys[$i]] = $vals[$i]; 515 } 516 } 517 518 return $out; 519 } 520 } 521 if (empty($vals)) { 522 return []; 523 } 524 525 return array_combine($keys === null ? range(0, count($vals) - 1) : $keys, $vals); 526 } 527 528 /** 529 * Returns a formatted series of values extracted from `$data`, using 530 * `$format` as the format and `$paths` as the values to extract. 531 * 532 * Usage: 533 * 534 * ``` 535 * $result = Hash::format($users, ['{n}.User.id', '{n}.User.name'], '%s : %s'); 536 * ``` 537 * 538 * The `$format` string can use any format options that `vsprintf()` and `sprintf()` do. 539 * 540 * @param array $data Source array from which to extract the data 541 * @param string[] $paths An array containing one or more Hash::extract()-style key paths 542 * @param string $format Format string into which values will be inserted, see sprintf() 543 * @return string[]|null An array of strings extracted from `$path` and formatted with `$format` 544 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::format 545 * @see sprintf() 546 * @see \Cake\Utility\Hash::extract() 547 */ 548 public static function format(array $data, array $paths, $format) 549 { 550 $extracted = []; 551 $count = count($paths); 552 553 if (!$count) { 554 return null; 555 } 556 557 for ($i = 0; $i < $count; $i++) { 558 $extracted[] = static::extract($data, $paths[$i]); 559 } 560 $out = []; 561 $data = $extracted; 562 $count = count($data[0]); 563 564 $countTwo = count($data); 565 for ($j = 0; $j < $count; $j++) { 566 $args = []; 567 for ($i = 0; $i < $countTwo; $i++) { 568 if (array_key_exists($j, $data[$i])) { 569 $args[] = $data[$i][$j]; 570 } 571 } 572 $out[] = vsprintf($format, $args); 573 } 574 575 return $out; 576 } 577 578 /** 579 * Determines if one array contains the exact keys and values of another. 580 * 581 * @param array $data The data to search through. 582 * @param array $needle The values to file in $data 583 * @return bool true If $data contains $needle, false otherwise 584 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::contains 585 */ 586 public static function contains(array $data, array $needle) 587 { 588 if (empty($data) || empty($needle)) { 589 return false; 590 } 591 $stack = []; 592 593 while (!empty($needle)) { 594 $key = key($needle); 595 $val = $needle[$key]; 596 unset($needle[$key]); 597 598 if (array_key_exists($key, $data) && is_array($val)) { 599 $next = $data[$key]; 600 unset($data[$key]); 601 602 if (!empty($val)) { 603 $stack[] = [$val, $next]; 604 } 605 } elseif (!array_key_exists($key, $data) || $data[$key] != $val) { 606 return false; 607 } 608 609 if (empty($needle) && !empty($stack)) { 610 list($needle, $data) = array_pop($stack); 611 } 612 } 613 614 return true; 615 } 616 617 /** 618 * Test whether or not a given path exists in $data. 619 * This method uses the same path syntax as Hash::extract() 620 * 621 * Checking for paths that could target more than one element will 622 * make sure that at least one matching element exists. 623 * 624 * @param array $data The data to check. 625 * @param string $path The path to check for. 626 * @return bool Existence of path. 627 * @see \Cake\Utility\Hash::extract() 628 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::check 629 */ 630 public static function check(array $data, $path) 631 { 632 $results = static::extract($data, $path); 633 if (!is_array($results)) { 634 return false; 635 } 636 637 return count($results) > 0; 638 } 639 640 /** 641 * Recursively filters a data set. 642 * 643 * @param array $data Either an array to filter, or value when in callback 644 * @param callable|array $callback A function to filter the data with. Defaults to 645 * `static::_filter()` Which strips out all non-zero empty values. 646 * @return array Filtered array 647 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::filter 648 */ 649 public static function filter(array $data, $callback = ['self', '_filter']) 650 { 651 foreach ($data as $k => $v) { 652 if (is_array($v)) { 653 $data[$k] = static::filter($v, $callback); 654 } 655 } 656 657 return array_filter($data, $callback); 658 } 659 660 /** 661 * Callback function for filtering. 662 * 663 * @param mixed $var Array to filter. 664 * @return bool 665 */ 666 protected static function _filter($var) 667 { 668 return $var === 0 || $var === 0.0 || $var === '0' || !empty($var); 669 } 670 671 /** 672 * Collapses a multi-dimensional array into a single dimension, using a delimited array path for 673 * each array element's key, i.e. [['Foo' => ['Bar' => 'Far']]] becomes 674 * ['0.Foo.Bar' => 'Far'].) 675 * 676 * @param array $data Array to flatten 677 * @param string $separator String used to separate array key elements in a path, defaults to '.' 678 * @return array 679 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::flatten 680 */ 681 public static function flatten(array $data, $separator = '.') 682 { 683 $result = []; 684 $stack = []; 685 $path = null; 686 687 reset($data); 688 while (!empty($data)) { 689 $key = key($data); 690 $element = $data[$key]; 691 unset($data[$key]); 692 693 if (is_array($element) && !empty($element)) { 694 if (!empty($data)) { 695 $stack[] = [$data, $path]; 696 } 697 $data = $element; 698 reset($data); 699 $path .= $key . $separator; 700 } else { 701 $result[$path . $key] = $element; 702 } 703 704 if (empty($data) && !empty($stack)) { 705 list($data, $path) = array_pop($stack); 706 reset($data); 707 } 708 } 709 710 return $result; 711 } 712 713 /** 714 * Expands a flat array to a nested array. 715 * 716 * For example, unflattens an array that was collapsed with `Hash::flatten()` 717 * into a multi-dimensional array. So, `['0.Foo.Bar' => 'Far']` becomes 718 * `[['Foo' => ['Bar' => 'Far']]]`. 719 * 720 * @param array $data Flattened array 721 * @param string $separator The delimiter used 722 * @return array 723 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::expand 724 */ 725 public static function expand(array $data, $separator = '.') 726 { 727 $result = []; 728 foreach ($data as $flat => $value) { 729 $keys = explode($separator, (string)$flat); 730 $keys = array_reverse($keys); 731 $child = [ 732 $keys[0] => $value, 733 ]; 734 array_shift($keys); 735 foreach ($keys as $k) { 736 $child = [ 737 $k => $child, 738 ]; 739 } 740 741 $stack = [[$child, &$result]]; 742 static::_merge($stack, $result); 743 } 744 745 return $result; 746 } 747 748 /** 749 * This function can be thought of as a hybrid between PHP's `array_merge` and `array_merge_recursive`. 750 * 751 * The difference between this method and the built-in ones, is that if an array key contains another array, then 752 * Hash::merge() will behave in a recursive fashion (unlike `array_merge`). But it will not act recursively for 753 * keys that contain scalar values (unlike `array_merge_recursive`). 754 * 755 * Note: This function will work with an unlimited amount of arguments and typecasts non-array parameters into arrays. 756 * 757 * @param array $data Array to be merged 758 * @param mixed $merge Array to merge with. The argument and all trailing arguments will be array cast when merged 759 * @return array Merged array 760 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::merge 761 */ 762 public static function merge(array $data, $merge) 763 { 764 $args = array_slice(func_get_args(), 1); 765 $return = $data; 766 $stack = []; 767 768 foreach ($args as &$curArg) { 769 $stack[] = [(array)$curArg, &$return]; 770 } 771 unset($curArg); 772 static::_merge($stack, $return); 773 774 return $return; 775 } 776 777 /** 778 * Merge helper function to reduce duplicated code between merge() and expand(). 779 * 780 * @param array $stack The stack of operations to work with. 781 * @param array $return The return value to operate on. 782 * @return void 783 */ 784 protected static function _merge($stack, &$return) 785 { 786 while (!empty($stack)) { 787 foreach ($stack as $curKey => &$curMerge) { 788 foreach ($curMerge[0] as $key => &$val) { 789 $isArray = is_array($curMerge[1]); 790 if ($isArray && !empty($curMerge[1][$key]) && (array)$curMerge[1][$key] === $curMerge[1][$key] && (array)$val === $val) { 791 // Recurse into the current merge data as it is an array. 792 $stack[] = [&$val, &$curMerge[1][$key]]; 793 } elseif ((int)$key === $key && $isArray && isset($curMerge[1][$key])) { 794 $curMerge[1][] = $val; 795 } else { 796 $curMerge[1][$key] = $val; 797 } 798 } 799 unset($stack[$curKey]); 800 } 801 unset($curMerge); 802 } 803 } 804 805 /** 806 * Checks to see if all the values in the array are numeric 807 * 808 * @param array $data The array to check. 809 * @return bool true if values are numeric, false otherwise 810 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::numeric 811 */ 812 public static function numeric(array $data) 813 { 814 if (empty($data)) { 815 return false; 816 } 817 818 return $data === array_filter($data, 'is_numeric'); 819 } 820 821 /** 822 * Counts the dimensions of an array. 823 * Only considers the dimension of the first element in the array. 824 * 825 * If you have an un-even or heterogeneous array, consider using Hash::maxDimensions() 826 * to get the dimensions of the array. 827 * 828 * @param array $data Array to count dimensions on 829 * @return int The number of dimensions in $data 830 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::dimensions 831 */ 832 public static function dimensions(array $data) 833 { 834 if (empty($data)) { 835 return 0; 836 } 837 reset($data); 838 $depth = 1; 839 while ($elem = array_shift($data)) { 840 if (is_array($elem)) { 841 $depth++; 842 $data = $elem; 843 } else { 844 break; 845 } 846 } 847 848 return $depth; 849 } 850 851 /** 852 * Counts the dimensions of *all* array elements. Useful for finding the maximum 853 * number of dimensions in a mixed array. 854 * 855 * @param array $data Array to count dimensions on 856 * @return int The maximum number of dimensions in $data 857 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::maxDimensions 858 */ 859 public static function maxDimensions(array $data) 860 { 861 $depth = []; 862 if (is_array($data) && !empty($data)) { 863 foreach ($data as $value) { 864 if (is_array($value)) { 865 $depth[] = static::maxDimensions($value) + 1; 866 } else { 867 $depth[] = 1; 868 } 869 } 870 } 871 872 return empty($depth) ? 0 : max($depth); 873 } 874 875 /** 876 * Map a callback across all elements in a set. 877 * Can be provided a path to only modify slices of the set. 878 * 879 * @param array $data The data to map over, and extract data out of. 880 * @param string $path The path to extract for mapping over. 881 * @param callable $function The function to call on each extracted value. 882 * @return array An array of the modified values. 883 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::map 884 */ 885 public static function map(array $data, $path, $function) 886 { 887 $values = (array)static::extract($data, $path); 888 889 return array_map($function, $values); 890 } 891 892 /** 893 * Reduce a set of extracted values using `$function`. 894 * 895 * @param array $data The data to reduce. 896 * @param string $path The path to extract from $data. 897 * @param callable $function The function to call on each extracted value. 898 * @return mixed The reduced value. 899 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::reduce 900 */ 901 public static function reduce(array $data, $path, $function) 902 { 903 $values = (array)static::extract($data, $path); 904 905 return array_reduce($values, $function); 906 } 907 908 /** 909 * Apply a callback to a set of extracted values using `$function`. 910 * The function will get the extracted values as the first argument. 911 * 912 * ### Example 913 * 914 * You can easily count the results of an extract using apply(). 915 * For example to count the comments on an Article: 916 * 917 * ``` 918 * $count = Hash::apply($data, 'Article.Comment.{n}', 'count'); 919 * ``` 920 * 921 * You could also use a function like `array_sum` to sum the results. 922 * 923 * ``` 924 * $total = Hash::apply($data, '{n}.Item.price', 'array_sum'); 925 * ``` 926 * 927 * @param array $data The data to reduce. 928 * @param string $path The path to extract from $data. 929 * @param callable $function The function to call on each extracted value. 930 * @return mixed The results of the applied method. 931 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::apply 932 */ 933 public static function apply(array $data, $path, $function) 934 { 935 $values = (array)static::extract($data, $path); 936 937 return call_user_func($function, $values); 938 } 939 940 /** 941 * Sorts an array by any value, determined by a Set-compatible path 942 * 943 * ### Sort directions 944 * 945 * - `asc` Sort ascending. 946 * - `desc` Sort descending. 947 * 948 * ### Sort types 949 * 950 * - `regular` For regular sorting (don't change types) 951 * - `numeric` Compare values numerically 952 * - `string` Compare values as strings 953 * - `locale` Compare items as strings, based on the current locale 954 * - `natural` Compare items as strings using "natural ordering" in a human friendly way 955 * Will sort foo10 below foo2 as an example. 956 * 957 * To do case insensitive sorting, pass the type as an array as follows: 958 * 959 * ``` 960 * Hash::sort($data, 'some.attribute', 'asc', ['type' => 'regular', 'ignoreCase' => true]); 961 * ``` 962 * 963 * When using the array form, `type` defaults to 'regular'. The `ignoreCase` option 964 * defaults to `false`. 965 * 966 * @param array $data An array of data to sort 967 * @param string $path A Set-compatible path to the array value 968 * @param string $dir See directions above. Defaults to 'asc'. 969 * @param array|string $type See direction types above. Defaults to 'regular'. 970 * @return array Sorted array of data 971 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::sort 972 */ 973 public static function sort(array $data, $path, $dir = 'asc', $type = 'regular') 974 { 975 if (empty($data)) { 976 return []; 977 } 978 $originalKeys = array_keys($data); 979 $numeric = is_numeric(implode('', $originalKeys)); 980 if ($numeric) { 981 $data = array_values($data); 982 } 983 $sortValues = static::extract($data, $path); 984 $dataCount = count($data); 985 986 // Make sortValues match the data length, as some keys could be missing 987 // the sorted value path. 988 $missingData = count($sortValues) < $dataCount; 989 if ($missingData && $numeric) { 990 // Get the path without the leading '{n}.' 991 $itemPath = substr($path, 4); 992 foreach ($data as $key => $value) { 993 $sortValues[$key] = static::get($value, $itemPath); 994 } 995 } elseif ($missingData) { 996 $sortValues = array_pad($sortValues, $dataCount, null); 997 } 998 $result = static::_squash($sortValues); 999 $keys = static::extract($result, '{n}.id'); 1000 $values = static::extract($result, '{n}.value'); 1001 1002 $dir = strtolower($dir); 1003 $ignoreCase = false; 1004 1005 // $type can be overloaded for case insensitive sort 1006 if (is_array($type)) { 1007 $type += ['ignoreCase' => false, 'type' => 'regular']; 1008 $ignoreCase = $type['ignoreCase']; 1009 $type = $type['type']; 1010 } 1011 $type = strtolower($type); 1012 1013 if ($dir === 'asc') { 1014 $dir = \SORT_ASC; 1015 } else { 1016 $dir = \SORT_DESC; 1017 } 1018 if ($type === 'numeric') { 1019 $type = \SORT_NUMERIC; 1020 } elseif ($type === 'string') { 1021 $type = \SORT_STRING; 1022 } elseif ($type === 'natural') { 1023 $type = \SORT_NATURAL; 1024 } elseif ($type === 'locale') { 1025 $type = \SORT_LOCALE_STRING; 1026 } else { 1027 $type = \SORT_REGULAR; 1028 } 1029 if ($ignoreCase) { 1030 $values = array_map('mb_strtolower', $values); 1031 } 1032 array_multisort($values, $dir, $type, $keys, $dir, $type); 1033 $sorted = []; 1034 $keys = array_unique($keys); 1035 1036 foreach ($keys as $k) { 1037 if ($numeric) { 1038 $sorted[] = $data[$k]; 1039 continue; 1040 } 1041 if (isset($originalKeys[$k])) { 1042 $sorted[$originalKeys[$k]] = $data[$originalKeys[$k]]; 1043 } else { 1044 $sorted[$k] = $data[$k]; 1045 } 1046 } 1047 1048 return $sorted; 1049 } 1050 1051 /** 1052 * Helper method for sort() 1053 * Squashes an array to a single hash so it can be sorted. 1054 * 1055 * @param array $data The data to squash. 1056 * @param string|null $key The key for the data. 1057 * @return array 1058 */ 1059 protected static function _squash(array $data, $key = null) 1060 { 1061 $stack = []; 1062 foreach ($data as $k => $r) { 1063 $id = $k; 1064 if ($key !== null) { 1065 $id = $key; 1066 } 1067 if (is_array($r) && !empty($r)) { 1068 $stack = array_merge($stack, static::_squash($r, $id)); 1069 } else { 1070 $stack[] = ['id' => $id, 'value' => $r]; 1071 } 1072 } 1073 1074 return $stack; 1075 } 1076 1077 /** 1078 * Computes the difference between two complex arrays. 1079 * This method differs from the built-in array_diff() in that it will preserve keys 1080 * and work on multi-dimensional arrays. 1081 * 1082 * @param array $data First value 1083 * @param array $compare Second value 1084 * @return array Returns the key => value pairs that are not common in $data and $compare 1085 * The expression for this function is ($data - $compare) + ($compare - ($data - $compare)) 1086 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::diff 1087 */ 1088 public static function diff(array $data, array $compare) 1089 { 1090 if (empty($data)) { 1091 return (array)$compare; 1092 } 1093 if (empty($compare)) { 1094 return (array)$data; 1095 } 1096 $intersection = array_intersect_key($data, $compare); 1097 while (($key = key($intersection)) !== null) { 1098 if ($data[$key] == $compare[$key]) { 1099 unset($data[$key], $compare[$key]); 1100 } 1101 next($intersection); 1102 } 1103 1104 return $data + $compare; 1105 } 1106 1107 /** 1108 * Merges the difference between $data and $compare onto $data. 1109 * 1110 * @param array $data The data to append onto. 1111 * @param array $compare The data to compare and append onto. 1112 * @return array The merged array. 1113 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::mergeDiff 1114 */ 1115 public static function mergeDiff(array $data, array $compare) 1116 { 1117 if (empty($data) && !empty($compare)) { 1118 return $compare; 1119 } 1120 if (empty($compare)) { 1121 return $data; 1122 } 1123 foreach ($compare as $key => $value) { 1124 if (!array_key_exists($key, $data)) { 1125 $data[$key] = $value; 1126 } elseif (is_array($value) && is_array($data[$key])) { 1127 $data[$key] = static::mergeDiff($data[$key], $value); 1128 } 1129 } 1130 1131 return $data; 1132 } 1133 1134 /** 1135 * Normalizes an array, and converts it to a standard format. 1136 * 1137 * @param array $data List to normalize 1138 * @param bool $assoc If true, $data will be converted to an associative array. 1139 * @return array 1140 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::normalize 1141 */ 1142 public static function normalize(array $data, $assoc = true) 1143 { 1144 $keys = array_keys($data); 1145 $count = count($keys); 1146 $numeric = true; 1147 1148 if (!$assoc) { 1149 for ($i = 0; $i < $count; $i++) { 1150 if (!is_int($keys[$i])) { 1151 $numeric = false; 1152 break; 1153 } 1154 } 1155 } 1156 if (!$numeric || $assoc) { 1157 $newList = []; 1158 for ($i = 0; $i < $count; $i++) { 1159 if (is_int($keys[$i])) { 1160 $newList[$data[$keys[$i]]] = null; 1161 } else { 1162 $newList[$keys[$i]] = $data[$keys[$i]]; 1163 } 1164 } 1165 $data = $newList; 1166 } 1167 1168 return $data; 1169 } 1170 1171 /** 1172 * Takes in a flat array and returns a nested array 1173 * 1174 * ### Options: 1175 * 1176 * - `children` The key name to use in the resultset for children. 1177 * - `idPath` The path to a key that identifies each entry. Should be 1178 * compatible with Hash::extract(). Defaults to `{n}.$alias.id` 1179 * - `parentPath` The path to a key that identifies the parent of each entry. 1180 * Should be compatible with Hash::extract(). Defaults to `{n}.$alias.parent_id` 1181 * - `root` The id of the desired top-most result. 1182 * 1183 * @param array $data The data to nest. 1184 * @param array $options Options are: 1185 * @return array of results, nested 1186 * @see \Cake\Utility\Hash::extract() 1187 * @throws \InvalidArgumentException When providing invalid data. 1188 * @link https://book.cakephp.org/3/en/core-libraries/hash.html#Cake\Utility\Hash::nest 1189 */ 1190 public static function nest(array $data, array $options = []) 1191 { 1192 if (!$data) { 1193 return $data; 1194 } 1195 1196 $alias = key(current($data)); 1197 $options += [ 1198 'idPath' => "{n}.$alias.id", 1199 'parentPath' => "{n}.$alias.parent_id", 1200 'children' => 'children', 1201 'root' => null, 1202 ]; 1203 1204 $return = $idMap = []; 1205 $ids = static::extract($data, $options['idPath']); 1206 1207 $idKeys = explode('.', $options['idPath']); 1208 array_shift($idKeys); 1209 1210 $parentKeys = explode('.', $options['parentPath']); 1211 array_shift($parentKeys); 1212 1213 foreach ($data as $result) { 1214 $result[$options['children']] = []; 1215 1216 $id = static::get($result, $idKeys); 1217 $parentId = static::get($result, $parentKeys); 1218 1219 if (isset($idMap[$id][$options['children']])) { 1220 $idMap[$id] = array_merge($result, (array)$idMap[$id]); 1221 } else { 1222 $idMap[$id] = array_merge($result, [$options['children'] => []]); 1223 } 1224 if (!$parentId || !in_array($parentId, $ids)) { 1225 $return[] =& $idMap[$id]; 1226 } else { 1227 $idMap[$parentId][$options['children']][] =& $idMap[$id]; 1228 } 1229 } 1230 1231 if (!$return) { 1232 throw new InvalidArgumentException('Invalid data array to nest.'); 1233 } 1234 1235 if ($options['root']) { 1236 $root = $options['root']; 1237 } else { 1238 $root = static::get($return[0], $parentKeys); 1239 } 1240 1241 foreach ($return as $i => $result) { 1242 $id = static::get($result, $idKeys); 1243 $parentId = static::get($result, $parentKeys); 1244 if ($id !== $root && $parentId != $root) { 1245 unset($return[$i]); 1246 } 1247 } 1248 1249 return array_values($return); 1250 } 1251} 1252