diff --git a/CHANGELOG.md b/CHANGELOG.md index 5622db824..fac6740e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - Enh #917: Rename `ColumnSchemaInterface` to `ColumnInterface` (@Tigrov) - Enh #919: Replace `name()` with immutable `withName()` method in `ColumnInterface` interface (@Tigrov) - Enh #921: Move `DataType` class to `Yiisoft\Db\Constant` namespace (@Tigrov) +- Enh #926: Refactor `DbArrayHelper` (@Tigrov) ## 1.3.0 March 21, 2024 diff --git a/UPGRADE.md b/UPGRADE.md index 2c8b0c6a7..f581eb4d1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -124,19 +124,22 @@ Each table column has its own class in the `Yiisoft\Db\Schema\Column` namespace ### Remove methods -- `AbstractQueryBuilder::getColumnType()` -- `AbstractDMLQueryBuilder::getTypecastValue()` -- `TableSchemaInterface::compositeForeignKey()` -- `SchemaInterface::createColumn()` -- `SchemaInterface::isReadQuery()` -- `SchemaInterface::getRawTableName()` -- `AbstractSchema::isReadQuery()` -- `AbstractSchema::getRawTableName()` -- `AbstractSchema::normalizeRowKeyCase()` -- `Quoter::unquoteParts()` -- `AbstractPdoCommand::logQuery()` -- `ColumnSchemaInterface::phpType()` -- `ConnectionInterface::getServerVersion()` +- `AbstractQueryBuilder::getColumnType()` - use `AbstractQueryBuilder::buildColumnDefinition()` instead; +- `AbstractDMLQueryBuilder::getTypecastValue()`; +- `TableSchemaInterface::compositeForeignKey()`; +- `SchemaInterface::createColumn()` - use `ColumnBuilder` instead; +- `SchemaInterface::isReadQuery()` - use `DbStringHelper::isReadQuery()` instead; +- `SchemaInterface::getRawTableName()` - use `QuoterInterface::getRawTableName()` instead; +- `AbstractSchema::isReadQuery()` - use `DbStringHelper::isReadQuery()` instead; +- `AbstractSchema::getRawTableName()` - use `QuoterInterface::getRawTableName()` instead; +- `AbstractSchema::normalizeRowKeyCase()` - use `array_change_key_case()` instead; +- `Quoter::unquoteParts()`; +- `AbstractPdoCommand::logQuery()`; +- `ColumnSchemaInterface::phpType()`; +- `ConnectionInterface::getServerVersion()` - use `ConnectionInterface::getServerInfo()` instead; +- `DbArrayHelper::getColumn()` - use `array_column()` instead; +- `DbArrayHelper::getValueByPath()`; +- `DbArrayHelper::populate()` - use `DbArrayHelper::index()` instead; ### Remove deprecated parameters @@ -164,3 +167,4 @@ Each table column has its own class in the `Yiisoft\Db\Schema\Column` namespace - Allow `QueryInterface::all()` to return array of objects; - Change `Quoter::quoteValue()` parameter type and return type from `mixed` to `string`; - Move `DataType` class to `Yiisoft\Db\Constant` namespace; +- Change `DbArrayHelper::index()` parameter names and allow to accept `Closure` for `$indexBy` parameter; diff --git a/src/Helper/DbArrayHelper.php b/src/Helper/DbArrayHelper.php index cb824dccd..b35bb327b 100644 --- a/src/Helper/DbArrayHelper.php +++ b/src/Helper/DbArrayHelper.php @@ -5,139 +5,24 @@ namespace Yiisoft\Db\Helper; use Closure; -use Exception; -use function array_key_exists; +use Yiisoft\Db\Query\QueryInterface; + +use function array_column; +use function array_combine; use function array_map; use function array_multisort; use function count; -use function is_array; -use function is_object; -use function property_exists; +use function is_string; use function range; -use function strrpos; -use function substr; /** * Array manipulation methods. + * + * @psalm-import-type IndexBy from QueryInterface */ final class DbArrayHelper { - /** - * Returns the values of a specified column in an array. - * - * The input array should be multidimensional or an array of objects. - * - * For example, - * - * ```php - * $array = [ - * ['id' => '123', 'data' => 'abc'], - * ['id' => '345', 'data' => 'def'], - * ]; - * $result = DbArrayHelper::getColumn($array, 'id'); - * // the result is: ['123', '345'] - * - * // using anonymous function - * $result = DbArrayHelper::getColumn($array, function ($element) { - * return $element['id']; - * }); - * ``` - * - * @param array $array Array to extract values from. - * @param string $name The column name. - * - * @return array The list of column values. - */ - public static function getColumn(array $array, string $name): array - { - return array_map( - static fn (array|object $element): mixed => self::getValueByPath($element, $name), - $array - ); - } - - /** - * Retrieves the value of an array element or object property with the given key or property name. - * - * If the key doesn't exist in the array, the default value will be returned instead. - * - * Not used when getting value from an object. - * - * The key may be specified in a dot format to retrieve the value of a sub-array or the property of an embedded - * object. - * - * In particular, if the key is `x.y.z`, then the returned value would be `$array['x']['y']['z']` or - * `$array->x->y->z` (if `$array` is an object). - * - * If `$array['x']` or `$array->x` is neither an array nor an object, the default value will be returned. - * - * Note that if the array already has an element `x.y.z`, then its value will be returned instead of going through - * the sub-arrays. - * - * So it's better to be done specifying an array of key names like `['x', 'y', 'z']`. - * - * Below are some usage examples. - * - * ```php - * // working with array - * $username = DbArrayHelper::getValueByPath($_POST, 'username'); - * // working with object - * $username = DbArrayHelper::getValueByPath($user, 'username'); - * // working with anonymous function - * $fullName = DbArrayHelper::getValueByPath($user, function ($user, $defaultValue) { - * return $user->firstName . ' ' . $user->lastName; - * }); - * // using dot format to retrieve the property of embedded object - * $street = \yii\helpers\DbArrayHelper::getValue($users, 'address.street'); - * // using an array of keys to retrieve the value - * $value = \yii\helpers\DbArrayHelper::getValue($versions, ['1.0', 'date']); - * ``` - * - * @param array|object $array Array or object to extract value from. - * @param Closure|string $key Key name of the array element, an array of keys or property name of the object, or an - * anonymous function returning the value. The anonymous function signature should be: - * `function($array, $defaultValue)`. - * @param mixed|null $default The default value to be returned if the specified array key doesn't exist. Not used - * when getting value from an object. - * - * @return mixed The value of the element if found, default value otherwise - */ - public static function getValueByPath(object|array $array, Closure|string $key, mixed $default = null): mixed - { - if ($key instanceof Closure) { - return $key($array, $default); - } - - if (is_object($array) && property_exists($array, $key)) { - return $array->$key; - } - - if (is_array($array) && array_key_exists($key, $array)) { - return $array[$key]; - } - - if ($key && ($pos = strrpos($key, '.')) !== false) { - /** @psalm-var array|object $array */ - $array = self::getValueByPath($array, substr($key, 0, $pos), $default); - $key = substr($key, $pos + 1); - } - - if (is_object($array)) { - /** - * This is expected to fail if the property doesn't exist, or __get() isn't implemented it isn't reliably - * possible to check whether a property is accessible beforehand - */ - return $array->$key; - } - - if (array_key_exists($key, $array)) { - return $array[$key]; - } - - return $default; - } - /** * Indexes and/or groups the array according to a specified key. * @@ -215,55 +100,62 @@ public static function getValueByPath(object|array $array, Closure|string $key, * ] * ``` * - * @param array $array The array that needs to be indexed or grouped. - * @param string|null $key The column name or anonymous function which result will be used to index the array. - * @param array $groups The array of keys that will be used to group the input array by one or more keys. If the - * $key attribute or its value for the particular element is null and $groups aren't defined. - * The array element will be discarded. - * Otherwise, if $groups are specified, an array element will be added to the result array without any key. + * @param array[] $array The array that needs to be indexed or arranged. + * @param Closure|string|null $indexBy The column name or anonymous function which result will be used to index the + * array. If the array does not have the key, the ordinal indexes will be used if `$arrangeBy` is not specified or + * a warning will be triggered if `$arrangeBy` is specified. + * @param string[] $arrangeBy The array of keys that will be used to arrange the input array by one or more keys. * - * @throws Exception - * - * @return array The indexed and/or grouped array. - * - * @psalm-param array[] $array The array that needs to be indexed or grouped. - * @psalm-param string[] $groups The array of keys that will be used to group the input array by one or more keys. + * @return array[] The indexed and/or arranged array. * + * @psalm-param IndexBy|null $indexBy * @psalm-suppress MixedArrayAssignment */ - public static function index(array $array, string|null $key = null, array $groups = []): array + public static function index(array $array, Closure|string|null $indexBy = null, array $arrangeBy = []): array { + if (empty($array) || $indexBy === null && empty($arrangeBy)) { + return $array; + } + + if (empty($arrangeBy)) { + if (is_string($indexBy)) { + return array_column($array, null, $indexBy); + } + + return array_combine(array_map($indexBy, $array), $array); + } + $result = []; foreach ($array as $element) { $lastArray = &$result; - foreach ($groups as $group) { - /** @psalm-var string $value */ - $value = self::getValueByPath($element, $group); - if (!array_key_exists($value, $lastArray)) { + foreach ($arrangeBy as $group) { + $value = (string) $element[$group]; + + if (!isset($lastArray[$value])) { $lastArray[$value] = []; } + $lastArray = &$lastArray[$value]; } - if ($key === null) { - if (!empty($groups)) { - $lastArray[] = $element; - } + if ($indexBy === null) { + $lastArray[] = $element; } else { - /** @psalm-var mixed $value */ - $value = self::getValueByPath($element, $key); - - if ($value !== null) { - $lastArray[(string) $value] = $element; + if (is_string($indexBy)) { + $value = $element[$indexBy]; + } else { + $value = $indexBy($element); } + + $lastArray[(string) $value] = $element; } unset($lastArray); } - /** @psalm-var array $result */ + /** @var array[] $result */ return $result; } @@ -298,6 +190,10 @@ public static function isAssociative(array $array): bool * * @param array $array The array to be sorted. The array will be modified after calling this method. * @param string $key The key(s) to be sorted by. + * + * @psalm-template T + * @psalm-param array $array + * @psalm-param-out array $array */ public static function multisort( array &$array, @@ -307,7 +203,7 @@ public static function multisort( return; } - $column = self::getColumn($array, $key); + $column = array_column($array, $key); array_multisort( $column, @@ -324,30 +220,4 @@ public static function multisort( $array ); } - - /** - * Returns the value of an array element or object property with the given path. - * - * This method is internally used to convert the data fetched from a database into the format as required by this - * query. - * - * @param array[] $rows The raw query result from a database. - * - * @return array[] - */ - public static function populate(array $rows, Closure|string|null $indexBy = null): array - { - if ($indexBy === null) { - return $rows; - } - - $result = []; - - foreach ($rows as $row) { - /** @psalm-suppress MixedArrayOffset */ - $result[self::getValueByPath($row, $indexBy)] = $row; - } - - return $result; - } } diff --git a/src/Query/BatchQueryResultInterface.php b/src/Query/BatchQueryResultInterface.php index 585ad50d9..510bdbbdc 100644 --- a/src/Query/BatchQueryResultInterface.php +++ b/src/Query/BatchQueryResultInterface.php @@ -36,7 +36,8 @@ * * @extends Iterator * - * @psalm-type PopulateClosure=Closure(array[],Closure|string|null): mixed + * @psalm-import-type IndexBy from QueryInterface + * @psalm-type PopulateClosure = Closure(array[],IndexBy|null): array[] */ interface BatchQueryResultInterface extends Iterator { diff --git a/src/Query/Query.php b/src/Query/Query.php index 0c6921cbc..144763242 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -72,6 +72,7 @@ * Query internally uses the {@see \Yiisoft\Db\QueryBuilder\AbstractQueryBuilder} class to generate the SQL statement. * * @psalm-import-type SelectValue from QueryPartsInterface + * @psalm-import-type IndexBy from QueryInterface */ class Query implements QueryInterface { @@ -87,7 +88,7 @@ class Query implements QueryInterface protected array $params = []; protected array $union = []; protected array $withQueries = []; - /** @psalm-var Closure(array):array-key|string|null $indexBy */ + /** @psalm-var IndexBy|null $indexBy */ protected Closure|string|null $indexBy = null; protected ExpressionInterface|int|null $limit = null; protected ExpressionInterface|int|null $offset = null; @@ -230,7 +231,7 @@ public function all(): array return []; } - return DbArrayHelper::populate($this->createCommand()->queryAll(), $this->indexBy); + return DbArrayHelper::index($this->createCommand()->queryAll(), $this->indexBy); } public function average(string $sql): int|float|null|string @@ -246,7 +247,7 @@ public function batch(int $batchSize = 100): BatchQueryResultInterface return $this->db ->createBatchQueryResult($this) ->batchSize($batchSize) - ->setPopulatedMethod(fn (array $rows, Closure|string|null $indexBy = null): array => DbArrayHelper::populate($rows, $indexBy)) + ->setPopulatedMethod(fn (array $rows, Closure|string|null $indexBy = null): array => DbArrayHelper::index($rows, $indexBy)) ; } @@ -323,7 +324,7 @@ public function each(int $batchSize = 100): BatchQueryResultInterface return $this->db ->createBatchQueryResult($this, true) ->batchSize($batchSize) - ->setPopulatedMethod(fn (array $rows, Closure|string|null $indexBy = null): array => DbArrayHelper::populate($rows, $indexBy)) + ->setPopulatedMethod(fn (array $rows, Closure|string|null $indexBy = null): array => DbArrayHelper::index($rows, $indexBy)) ; } diff --git a/src/Query/QueryInterface.php b/src/Query/QueryInterface.php index da42a8fcc..8a4f68fd5 100644 --- a/src/Query/QueryInterface.php +++ b/src/Query/QueryInterface.php @@ -27,6 +27,7 @@ * * Sorting is supported via {@see orderBy()} and items can be limited to match some conditions using {@see where()}. * + * @psalm-type IndexBy = Closure(array):array-key|string * @psalm-import-type ParamsType from ConnectionInterface * @psalm-import-type SelectValue from QueryPartsInterface */ @@ -171,7 +172,7 @@ public function getHaving(): string|array|ExpressionInterface|null; /** * @return Closure|string|null The "index by" value. * - * @psalm-return Closure(array):array-key|string|null + * @psalm-return IndexBy|null */ public function getIndexBy(): Closure|string|null; diff --git a/tests/Db/Helper/DbArrayHelperTest.php b/tests/Db/Helper/DbArrayHelperTest.php index 0f7039fe1..64aecdcc3 100644 --- a/tests/Db/Helper/DbArrayHelperTest.php +++ b/tests/Db/Helper/DbArrayHelperTest.php @@ -21,49 +21,84 @@ public function testIsAssociative(): void } /** - * @dataProvider \Yiisoft\Db\Tests\Provider\PopulateProvider::populate + * @dataProvider \Yiisoft\Db\Tests\Provider\DbArrayHelperProvider::index */ - public function testPopulate(array $rows): void + public function testIndex(array $rows): void { - $this->assertSame($rows, DbArrayHelper::populate($rows)); + $this->assertSame($rows, DbArrayHelper::index($rows)); } /** - * @dataProvider \Yiisoft\Db\Tests\Provider\PopulateProvider::populateWithIndexBy - * @dataProvider \Yiisoft\Db\Tests\Provider\PopulateProvider::populateWithIncorrectIndexBy - * @dataProvider \Yiisoft\Db\Tests\Provider\PopulateProvider::populateWithIndexByClosure + * @dataProvider \Yiisoft\Db\Tests\Provider\DbArrayHelperProvider::indexWithIndexBy + * @dataProvider \Yiisoft\Db\Tests\Provider\DbArrayHelperProvider::indexWithIncorrectIndexBy + * @dataProvider \Yiisoft\Db\Tests\Provider\DbArrayHelperProvider::indexWithIndexByClosure */ public function testPopulateWithIndexBy(Closure|string|null $indexBy, array $rows, array $expected): void { - $this->assertSame($expected, DbArrayHelper::populate($rows, $indexBy)); + $this->assertSame($expected, DbArrayHelper::index($rows, $indexBy)); } /** - * @dataProvider \Yiisoft\Db\Tests\Provider\PopulateProvider::populateWithIndexBy + * @dataProvider \Yiisoft\Db\Tests\Provider\DbArrayHelperProvider::indexWithIndexBy */ - public function testPopulateWithIndexByWithObject(Closure|string|null $indexBy, array $rows, array $expected): void + public function testIndexWithIndexByWithObject(Closure|string|null $indexBy, array $rows, array $expected): void { $rows = json_decode(json_encode($rows)); - $populated = json_decode(json_encode(DbArrayHelper::populate($rows, $indexBy)), true); + $populated = json_decode(json_encode(DbArrayHelper::index($rows, $indexBy)), true); $this->assertSame($expected, $populated); } - /** - * @dataProvider \Yiisoft\Db\Tests\Provider\PopulateProvider::populateWithIncorrectIndexBy - */ - public function testPopulateWithIncorrectIndexByWithObject(Closure|string|null $indexBy, array $rows): void + public function testIndexWithNonExistingIndexBy(): void { - $rows = json_decode(json_encode($rows)); + $rows = [ + ['key' => 'value1'], + ['key' => 'value2'], + ]; + + $this->assertSame($rows, DbArrayHelper::index($rows, 'non-existing-key')); + + set_error_handler(static function (int $errno, string $errstr) { + restore_error_handler(); + throw new \Exception('E_WARNING: ' . $errstr, $errno); + }, E_WARNING); + + $this->expectExceptionMessage('E_WARNING: Undefined array key "non-existing-key"'); + + DbArrayHelper::index($rows, 'non-existing-key', ['key']); + } + + public function testIndexWithArrangeBy(): void + { + $rows = [ + ['key' => 'value1'], + ['key' => 'value2'], + ]; set_error_handler(static function (int $errno, string $errstr) { + restore_error_handler(); throw new \Exception('E_WARNING: ' . $errstr, $errno); }, E_WARNING); - $this->expectExceptionMessageMatches('/^E_WARNING: /'); + $this->expectExceptionMessage('E_WARNING: Undefined array key "non-existing-key"'); + + DbArrayHelper::index($rows, null, ['non-existing-key']); + } - DbArrayHelper::populate($rows, $indexBy); + public function testIndexWithClosureIndexByAndArrangeBy(): void + { + $rows = [ + ['key' => 'value1'], + ['key' => 'value2'], + ]; - restore_error_handler(); + $this->assertSame([ + 'value1' => [ + 'value1' => ['key' => 'value1'], + ], + 'value2' => [ + 'value2' => ['key' => 'value2'], + ], + ], DbArrayHelper::index($rows, fn ($row) => $row['key'], ['key'])); } } diff --git a/tests/Provider/PopulateProvider.php b/tests/Provider/DbArrayHelperProvider.php similarity index 57% rename from tests/Provider/PopulateProvider.php rename to tests/Provider/DbArrayHelperProvider.php index cfd4d0126..321c9fe72 100644 --- a/tests/Provider/PopulateProvider.php +++ b/tests/Provider/DbArrayHelperProvider.php @@ -4,9 +4,9 @@ namespace Yiisoft\Db\Tests\Provider; -class PopulateProvider +class DbArrayHelperProvider { - public static function populate(): array + public static function index(): array { return [ [ @@ -24,7 +24,7 @@ public static function populate(): array ]; } - public static function populateWithIndexByClosure(): array + public static function indexWithIndexByClosure(): array { return [ [ @@ -41,7 +41,7 @@ public static function populateWithIndexByClosure(): array ]; } - public static function populateWithIncorrectIndexBy(): array + public static function indexWithIncorrectIndexBy(): array { return [ 'not existed key' => [ @@ -50,34 +50,15 @@ public static function populateWithIncorrectIndexBy(): array ['table.key' => 'value1'], ['table.key' => 'value2'], ], - [ - '' => ['table.key' => 'value2'], - ], - ], - 'empty key (not found key behavior)' => [ - '', [ ['table.key' => 'value1'], ['table.key' => 'value2'], ], - [ - '' => ['table.key' => 'value2'], - ], - ], - 'key and composite key (not found key behavior)' => [ - 'key', - [ - ['table.key' => 'value1'], - ['table.key' => 'value2'], - ], - [ - '' => ['table.key' => 'value2'], - ], ], ]; } - public static function populateWithIndexBy(): array + public static function indexWithIndexBy(): array { return [ 'null key with empty rows' => [ @@ -129,50 +110,6 @@ public static function populateWithIndexBy(): array 'value2' => ['table key' => 'value2'], ], ], - 'composite-key and simple key' => [ - 't.key', - [ - [ - 'key' => 'value1', - 't' => [ - 'key' => 'value2', - ], - ], - ], - [ - 'value2' => [ - 'key' => 'value1', - 't' => [ - 'key' => 'value2', - ], - ], - ], - ], - 'composite-3-key and simple key' => [ - 't1.t2.key', - [ - [ - 'key' => 'value1', - 't1' => [ - 'key' => 'value2', - 't2' => [ - 'key' => 'value3', - ], - ], - ], - ], - [ - 'value3' => [ - 'key' => 'value1', - 't1' => [ - 'key' => 'value2', - 't2' => [ - 'key' => 'value3', - ], - ], - ], - ], - ], 'composite-key and composite key' => [ 'table.key', [