├── phpunit.phar ├── .editorconfig ├── .gitignore ├── ObjectQueryOrder.php ├── QueryContextInterface.php ├── Operation ├── Count.php ├── Max.php ├── Min.php ├── Select.php ├── Each.php ├── SelectOne.php ├── Sum.php ├── Average.php ├── Concat.php ├── SelectMany.php └── AbstractQueryOperation.php ├── Tests ├── Fixtures │ ├── Child.php │ ├── Person.php │ └── City.php ├── Operation │ ├── ConcatTest.php │ ├── EachTest.php │ ├── CountTest.php │ ├── MaxTest.php │ ├── MinTest.php │ ├── SelectTest.php │ ├── SumTest.php │ ├── AverageTest.php │ ├── SelectManyTest.php │ └── SelectOneTest.php ├── AbstractQueryTest.php ├── Modifier │ ├── OffsetTest.php │ ├── WhereTest.php │ ├── LimitTest.php │ └── OrderByTest.php ├── QueryContextTest.php └── QueryTest.php ├── Exception ├── NonUniqueResultException.php ├── IncompatibleFieldException.php ├── InvalidModifierConfigurationException.php ├── AliasAlreadyTakenInQueryContextException.php ├── IncompatibleCollectionException.php ├── InvalidAliasException.php └── AlreadyRegisteredWhereFunctionException.php ├── Modifier ├── AbstractQueryModifier.php ├── Limit.php ├── Offset.php ├── Where.php └── OrderBy.php ├── ObjectQueryContextEnvironment.php ├── QueryOperationInterface.php ├── .github └── workflows │ └── php.yml ├── phpunit.xml.dist ├── QueryModifierInterface.php ├── composer.json ├── LICENSE ├── ObjectQueryContext.php ├── QueryInterface.php ├── ObjectQuery.php └── README.md /phpunit.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandre-daubois/poq/HEAD/phpunit.phar -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 4 7 | charset = utf-8 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ###> symfony/framework-bundle ### 3 | /.env.local 4 | /.env.local.php 5 | /.env.*.local 6 | /config/secrets/prod/prod.decrypt.private.php 7 | /public/bundles/ 8 | /var/ 9 | /vendor/ 10 | ###< symfony/framework-bundle ### 11 | 12 | ###> symfony/phpunit-bridge ### 13 | .phpunit.result.cache 14 | /phpunit.xml 15 | ###< symfony/phpunit-bridge ### 16 | 17 | composer.lock 18 | -------------------------------------------------------------------------------- /ObjectQueryOrder.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | enum ObjectQueryOrder { 13 | case Ascending; 14 | case Descending; 15 | case None; 16 | case Shuffle; 17 | } 18 | -------------------------------------------------------------------------------- /QueryContextInterface.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | /** 13 | * Defines the context of a Query. This could be used to pass additional data to the query, such as 14 | * already used alias in the current Query context. 15 | */ 16 | interface QueryContextInterface 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /Operation/Count.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\QueryInterface; 13 | 14 | final class Count extends AbstractQueryOperation 15 | { 16 | public function apply(QueryInterface $query): int 17 | { 18 | return \count((array) $this->applySelect($query)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/Fixtures/Child.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Fixtures; 11 | 12 | class Child 13 | { 14 | public string $name; 15 | 16 | public int $age; 17 | 18 | public function __construct(string $name, int $age) 19 | { 20 | $this->name = $name; 21 | $this->age = $age; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/Fixtures/Person.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Fixtures; 11 | 12 | class Person 13 | { 14 | public array $children; 15 | 16 | public int $height; 17 | 18 | public function __construct(array $children, int $height) 19 | { 20 | $this->children = $children; 21 | $this->height = $height; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Exception/NonUniqueResultException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | class NonUniqueResultException extends \Exception 13 | { 14 | protected $message = 'The query returned %d result(s). You may use "select" instead of "selectOne"'; 15 | 16 | public function __construct(int $count) 17 | { 18 | parent::__construct(\sprintf($this->message, $count)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Exception/IncompatibleFieldException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | class IncompatibleFieldException extends \Exception 13 | { 14 | protected $message = 'The given field is incompatible with "%s" because of the following reason: %s.'; 15 | 16 | public function __construct(string $place, string $message) 17 | { 18 | parent::__construct(\sprintf($this->message, $place, $message)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Exception/InvalidModifierConfigurationException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | final class InvalidModifierConfigurationException extends \Exception 13 | { 14 | protected $message = 'The modifier "%s" is wrongly configured: %s.'; 15 | 16 | public function __construct(string $modifier, string $message) 17 | { 18 | parent::__construct(\sprintf($this->message, $modifier, $message)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/Fixtures/City.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Fixtures; 11 | 12 | class City 13 | { 14 | public string $name; 15 | 16 | public array $persons; 17 | 18 | public int $minimalAge; 19 | 20 | public function __construct(string $name, array $persons, int $minimalAge) 21 | { 22 | $this->name = $name; 23 | $this->persons = $persons; 24 | $this->minimalAge = $minimalAge; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Modifier/AbstractQueryModifier.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Modifier; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryModifierInterface; 14 | 15 | abstract class AbstractQueryModifier implements QueryModifierInterface 16 | { 17 | protected readonly ObjectQuery $parentQuery; 18 | 19 | public function __construct(ObjectQuery $parentQuery) 20 | { 21 | $this->parentQuery = $parentQuery; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Exception/AliasAlreadyTakenInQueryContextException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | class AliasAlreadyTakenInQueryContextException extends \Exception 13 | { 14 | protected $message = 'Alias "%s" is already taken in the query. You should choose another name for your alias.'; 15 | 16 | public function __construct(string $alias) 17 | { 18 | parent::__construct(\sprintf($this->message, $alias)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Exception/IncompatibleCollectionException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | class IncompatibleCollectionException extends \Exception 13 | { 14 | protected $message = 'The given collection is incompatible with "%s" because of the following reason: %s.'; 15 | 16 | public function __construct(string $place, string $message) 17 | { 18 | parent::__construct(\sprintf($this->message, $place, $message)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Exception/InvalidAliasException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | class InvalidAliasException extends \Exception 13 | { 14 | protected $message = 'Alias "%s" is not defined in the context. Available alias are: %s.'; 15 | 16 | public function __construct(string $alias, array $availableAliases) 17 | { 18 | parent::__construct(\sprintf($this->message, $alias, \implode(', ', $availableAliases))); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Exception/AlreadyRegisteredWhereFunctionException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Exception; 11 | 12 | class AlreadyRegisteredWhereFunctionException extends \Exception 13 | { 14 | protected $message = 'Function "%s" has already been globally registered to be used in the "where" clause of ObjectQuery.'; 15 | 16 | public function __construct(string $functionName) 17 | { 18 | parent::__construct(\sprintf($this->message, $functionName)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/Operation/ConcatTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | 15 | class ConcatTest extends AbstractQueryTest 16 | { 17 | public function testConcat(): void 18 | { 19 | $query = ObjectQuery::from($this->cities); 20 | 21 | $query->selectMany('persons', 'p') 22 | ->selectMany('children', 'c'); 23 | 24 | $this->assertSame('Hubert, Alex, Will, Fabien, Nicolas, Salah, Bob', $query->concat(', ', 'name')); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Operation/Max.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryInterface; 14 | 15 | final class Max extends AbstractQueryOperation 16 | { 17 | public function __construct(ObjectQuery $parentQuery, string $field) 18 | { 19 | parent::__construct($parentQuery, $field); 20 | } 21 | 22 | public function apply(QueryInterface $query): mixed 23 | { 24 | $source = (array) $this->applySelect($query); 25 | 26 | return empty($source) ? null : \max($source); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Operation/Min.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryInterface; 14 | 15 | final class Min extends AbstractQueryOperation 16 | { 17 | public function __construct(ObjectQuery $parentQuery, string $field) 18 | { 19 | parent::__construct($parentQuery, $field); 20 | } 21 | 22 | public function apply(QueryInterface $query): mixed 23 | { 24 | $source = (array) $this->applySelect($query); 25 | 26 | return empty($source) ? null : \min($source); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Operation/Select.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryInterface; 14 | 15 | final class Select extends AbstractQueryOperation 16 | { 17 | public function __construct(ObjectQuery $parentQuery, array|string|null $fields = null) 18 | { 19 | parent::__construct($parentQuery, $fields); 20 | 21 | $this->parentQuery = $parentQuery; 22 | } 23 | 24 | public function apply(QueryInterface $query): iterable 25 | { 26 | return $this->applySelect($query); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/Operation/EachTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | 15 | class EachTest extends AbstractQueryTest 16 | { 17 | public function testEach(): void 18 | { 19 | $query = ObjectQuery::from($this->cities); 20 | 21 | $query->selectMany('persons', 'p'); 22 | 23 | $result = $query 24 | ->each(fn($element) => $element->height * 2); 25 | 26 | $this->assertSame(362, $result[0]); 27 | $this->assertSame(352, $result[1]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/Operation/CountTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | 15 | class CountTest extends AbstractQueryTest 16 | { 17 | public function testCount(): void 18 | { 19 | $query = ObjectQuery::from($this->cities); 20 | $query 21 | ->selectMany('persons', 'person') 22 | ->selectMany('children', 'child') 23 | ; 24 | 25 | $query->where(fn($child) => $child->age < 9 || $child->age > 28); 26 | 27 | $this->assertSame(3, $query->count()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Operation/Each.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryInterface; 14 | 15 | final class Each extends AbstractQueryOperation 16 | { 17 | private \Closure $callback; 18 | 19 | public function __construct(ObjectQuery $parentQuery, callable $callback) 20 | { 21 | parent::__construct($parentQuery); 22 | 23 | $this->callback = $callback(...); 24 | } 25 | 26 | public function apply(QueryInterface $query): array 27 | { 28 | return \array_map($this->callback, (array) $this->applySelect($query)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ObjectQueryContextEnvironment.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | use ObjectQuery\Exception\InvalidAliasException; 13 | 14 | class ObjectQueryContextEnvironment 15 | { 16 | private readonly array $environment; 17 | 18 | public function __construct(array $environment) 19 | { 20 | $this->environment = $environment; 21 | } 22 | 23 | public function get(string $alias): object 24 | { 25 | if (!\array_key_exists($alias, $this->environment)) { 26 | throw new InvalidAliasException($alias, \array_keys($this->environment)); 27 | } 28 | 29 | return $this->environment[$alias]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /QueryOperationInterface.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | /** 13 | * Defines a final operation done to the source, after modifiers has been applied. An operation can be 14 | * a simple concatenation, selecting data, get an average/min/max value, etc. 15 | */ 16 | interface QueryOperationInterface 17 | { 18 | /** 19 | * @param QueryInterface $query 20 | * The source to apply the operation on. 21 | * 22 | * @return mixed 23 | * The result of the operation. This can be any type of data, depending on what the operation actually does. 24 | */ 25 | public function apply(QueryInterface $query): mixed; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | - name: Run test suite 36 | run: composer run-script test 37 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./Tests 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /QueryModifierInterface.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | /** 13 | * Defines a Query modifier. A modifier is applied before any operation. This could be a `where` clause, as well 14 | * as an ordering, a shuffling, limiting the max number of results, etc. 15 | */ 16 | interface QueryModifierInterface 17 | { 18 | /** 19 | * Applies the modifier to the given source, and returns the result of this modifier. 20 | * 21 | * @param QueryInterface $query 22 | * The source to apply the modifier on. 23 | * 24 | * Optional context if needed by the modifier. 25 | * 26 | * @return iterable 27 | * The modified source. 28 | */ 29 | public function apply(QueryInterface $query): iterable; 30 | } 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexandre-daubois/poq", 3 | "type": "library", 4 | "description": "Provides an object-oriented API to query in-memory collections in a SQL-style.", 5 | "keywords": ["query", "linq", "collection", "object"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Alexandre Daubois", 10 | "email": "alex.daubois@gmail.com" 11 | } 12 | ], 13 | "minimum-stability": "stable", 14 | "prefer-stable": true, 15 | "require": { 16 | "php": ">=8.1", 17 | "symfony/property-access": "^6.1" 18 | }, 19 | "config": { 20 | "optimize-autoloader": true, 21 | "preferred-install": { 22 | "*": "dist" 23 | }, 24 | "sort-packages": true 25 | }, 26 | "autoload": { 27 | "psr-4": { "ObjectQuery\\": "" }, 28 | "exclude-from-classmap": [ 29 | "/Tests/" 30 | ] 31 | }, 32 | "scripts": { 33 | "test": "./phpunit.phar" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Operation/SelectOne.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\Exception\NonUniqueResultException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\QueryInterface; 15 | 16 | final class SelectOne extends AbstractQueryOperation 17 | { 18 | public function __construct(ObjectQuery $parentQuery, ?string $fields = null) 19 | { 20 | parent::__construct($parentQuery, $fields); 21 | 22 | $this->parentQuery = $parentQuery; 23 | } 24 | 25 | public function apply(QueryInterface $query): mixed 26 | { 27 | $result = (array) $this->applySelect($query); 28 | 29 | $resultCount = \count($result); 30 | if ($resultCount > 1) { 31 | throw new NonUniqueResultException($resultCount); 32 | } 33 | 34 | return $result[0] ?? null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Operation/Sum.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\Exception\IncompatibleCollectionException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\QueryInterface; 15 | 16 | final class Sum extends AbstractQueryOperation 17 | { 18 | public function __construct(ObjectQuery $parentQuery, string $field) 19 | { 20 | parent::__construct($parentQuery, $field); 21 | } 22 | 23 | public function apply(QueryInterface $query): int|float 24 | { 25 | $source = (array) $this->applySelect($query); 26 | 27 | if (\count($source) !== \count(\array_filter($source, 'is_numeric'))) { 28 | throw new IncompatibleCollectionException('sum', 'Operation can only be applied to a collection of numerics'); 29 | } 30 | 31 | return \array_sum($source); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/Operation/MaxTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | 15 | class MaxTest extends AbstractQueryTest 16 | { 17 | public function testMax(): void 18 | { 19 | $query = (ObjectQuery::from($this->cities)) 20 | ->selectMany('persons', 'person') 21 | ->selectMany('children', 'child'); 22 | 23 | $this->assertSame(45, $query->max('age')); 24 | } 25 | 26 | public function testMaxWithoutResult(): void 27 | { 28 | $query = (ObjectQuery::from($this->cities)) 29 | ->selectMany('persons', 'person') 30 | ->where(fn($person) => $person->height > 190) 31 | ->selectMany('children', 'child'); 32 | 33 | $this->assertNull($query->max('age')); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Operation/Average.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\Exception\IncompatibleCollectionException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\QueryInterface; 15 | 16 | final class Average extends AbstractQueryOperation 17 | { 18 | public function __construct(ObjectQuery $parentQuery, string $field) 19 | { 20 | parent::__construct($parentQuery, $field); 21 | } 22 | 23 | public function apply(QueryInterface $query): float 24 | { 25 | $source = (array) $this->applySelect($query); 26 | 27 | $count = \count($source); 28 | if ($count !== \count(\array_filter($source, 'is_numeric'))) { 29 | throw new IncompatibleCollectionException('average', 'Operation can only be applied to a collection of numerics'); 30 | } 31 | 32 | return (float) (\array_sum($source) / $count); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/Operation/MinTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | 15 | class MinTest extends AbstractQueryTest 16 | { 17 | public function testMin(): void 18 | { 19 | $query = (ObjectQuery::from($this->cities)) 20 | ->selectMany('persons', 'person') 21 | ->selectMany('children', 'child'); 22 | 23 | $this->assertSame(8, $query->min('age')); 24 | } 25 | 26 | public function testMinWithoutResult(): void 27 | { 28 | $query = (ObjectQuery::from($this->cities)) 29 | ->from($this->cities, alias: 'city') 30 | ->selectMany('persons', 'person') 31 | ->selectMany('children', 'child') 32 | ->where(fn($child) => $child->age < 0); 33 | 34 | $this->assertNull($query->min('age')); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexandre Daubois 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Operation/Concat.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryInterface; 14 | 15 | final class Concat extends AbstractQueryOperation 16 | { 17 | private readonly string $separator; 18 | 19 | public function __construct(ObjectQuery $parentQuery, string $field, string $separator = ' ') 20 | { 21 | parent::__construct($parentQuery, $field); 22 | 23 | $this->separator = $separator; 24 | } 25 | 26 | public function apply(QueryInterface $query): string 27 | { 28 | $source = (array) $this->applySelect($query); 29 | 30 | $string = ''; 31 | foreach ($source as $key => $value) { 32 | $string .= $value; 33 | 34 | if ($key !== \array_key_last($source)) { 35 | $string .= $this->separator; 36 | } 37 | } 38 | 39 | return $string; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/Operation/SelectTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | 15 | class SelectTest extends AbstractQueryTest 16 | { 17 | public function testObjectsSelect(): void 18 | { 19 | $query = ObjectQuery::from($this->cities); 20 | $result = $query->select('name'); 21 | 22 | $this->assertSame('Lyon', $result[0]); 23 | $this->assertSame('Paris', $result[1]); 24 | } 25 | 26 | public function testObjectsMultipleSelect(): void 27 | { 28 | $query = ObjectQuery::from($this->cities); 29 | $result = $query->select(['name', 'minimalAge']); 30 | 31 | $this->assertSame('Lyon', $result[0]['name']); 32 | $this->assertSame(21, $result[0]['minimalAge']); 33 | $this->assertSame('Paris', $result[1]['name']); 34 | $this->assertSame(10, $result[1]['minimalAge']); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Modifier/Limit.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Modifier; 11 | 12 | use ObjectQuery\Exception\InvalidModifierConfigurationException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\QueryInterface; 15 | 16 | final class Limit extends AbstractQueryModifier 17 | { 18 | private readonly ?int $limit; 19 | 20 | public function __construct(ObjectQuery $parentQuery, ?int $limit) 21 | { 22 | parent::__construct($parentQuery); 23 | 24 | $this->limit = $limit; 25 | } 26 | 27 | public function apply(QueryInterface $query): iterable 28 | { 29 | if (null === $this->limit) { 30 | return $query->getSource(); 31 | } 32 | 33 | if ($this->limit <= 0) { 34 | throw new InvalidModifierConfigurationException('limit', 'The limit must be a positive integer or null to set no limit'); 35 | } 36 | 37 | return \array_slice((array) $query->getSource(), 0, $this->limit); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Modifier/Offset.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Modifier; 11 | 12 | use ObjectQuery\Exception\InvalidModifierConfigurationException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\QueryInterface; 15 | 16 | final class Offset extends AbstractQueryModifier 17 | { 18 | private readonly ?int $offset; 19 | 20 | public function __construct(ObjectQuery $parentQuery, ?int $offset) 21 | { 22 | parent::__construct($parentQuery); 23 | 24 | $this->offset = $offset; 25 | } 26 | 27 | public function apply(QueryInterface $query): iterable 28 | { 29 | if (null === $this->offset) { 30 | return $query->getSource(); 31 | } 32 | 33 | if ($this->offset <= 0) { 34 | throw new InvalidModifierConfigurationException('offset', 'The offset must be a positive integer or null to set no offset.'); 35 | } 36 | 37 | return \array_slice((array) $query->getSource(), $this->offset); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/AbstractQueryTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests; 11 | 12 | use ObjectQuery\Tests\Fixtures\Child; 13 | use ObjectQuery\Tests\Fixtures\City; 14 | use ObjectQuery\Tests\Fixtures\Person; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | abstract class AbstractQueryTest extends TestCase 18 | { 19 | protected const NUMBERS = [5, 4, 1, 3, 9, 8, 6, 7, 2, 0]; 20 | 21 | protected array $cities = []; 22 | 23 | protected function setUp(): void 24 | { 25 | $this->cities[] = new City('Lyon', [ 26 | new Person([ 27 | new Child('Hubert', 30), 28 | new Child('Alex', 26), 29 | new Child('Will', 22), 30 | ], 181), 31 | new Person([ 32 | new Child('Fabien', 10), 33 | new Child('Nicolas', 8), 34 | new Child('Salah', 11), 35 | new Child('Bob', 45), 36 | ], 176) 37 | ], 21); 38 | 39 | $this->cities[] = new City('Paris', [], 10); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Modifier/Where.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Modifier; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\ObjectQueryContextEnvironment; 14 | use ObjectQuery\QueryInterface; 15 | 16 | final class Where extends AbstractQueryModifier 17 | { 18 | private readonly \Closure $callback; 19 | 20 | public function __construct(ObjectQuery $parentQuery, callable $callback) 21 | { 22 | parent::__construct($parentQuery); 23 | 24 | $this->callback = $callback(...)->bindTo(null); 25 | } 26 | 27 | public function apply(QueryInterface $query): array 28 | { 29 | $final = []; 30 | foreach ($query->getSource() as $item) { 31 | $localContext = $query->getContext() 32 | ->withEnvironment($item, [$this->parentQuery->getSourceAlias() => $item]) 33 | ->getEnvironment($item); 34 | 35 | if (true === \call_user_func($this->callback, $item, new ObjectQueryContextEnvironment($localContext))) { 36 | $final[] = $item; 37 | } 38 | } 39 | 40 | return $final; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/Operation/SumTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\Exception\IncompatibleCollectionException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\Tests\AbstractQueryTest; 15 | 16 | class SumTest extends AbstractQueryTest 17 | { 18 | public function testSum(): void 19 | { 20 | $query = (ObjectQuery::from($this->cities)) 21 | ->selectMany('persons', 'person'); 22 | 23 | $query->selectMany('children', 'child') 24 | ->where(fn($child) => $child->age > 20); 25 | 26 | $this->assertSame(123, $query->sum('age')); 27 | } 28 | 29 | public function testSumOnNonNumericCollection(): void 30 | { 31 | $foo = new class { 32 | public array $collection = [1, 2, 3, 'average']; 33 | }; 34 | 35 | $query = ObjectQuery::from([$foo]); 36 | 37 | $this->expectException(IncompatibleCollectionException::class); 38 | $this->expectExceptionMessage('The given collection is incompatible with "sum" because of the following reason: Operation can only be applied to a collection of numerics.'); 39 | $query->sum('collection'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/Operation/AverageTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\Exception\IncompatibleCollectionException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\Tests\AbstractQueryTest; 15 | 16 | class AverageTest extends AbstractQueryTest 17 | { 18 | public function testAverage(): void 19 | { 20 | $query = ObjectQuery::from($this->cities); 21 | 22 | $query->selectMany('persons', 'person'); 23 | 24 | $query->selectMany('children', 'child') 25 | ->where(fn($child) => $child->age > 20); 26 | 27 | $this->assertSame(30.75, $query->average('age')); 28 | } 29 | 30 | public function testAverageOnNonNumericCollection(): void 31 | { 32 | $foo = new class { 33 | public array $collection = [1, 2, 3, 'average']; 34 | }; 35 | 36 | $query = ObjectQuery::from([$foo]); 37 | 38 | $this->expectException(IncompatibleCollectionException::class); 39 | $this->expectExceptionMessage('The given collection is incompatible with "average" because of the following reason: Operation can only be applied to a collection of numerics.'); 40 | $query->average('collection'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/Modifier/OffsetTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Modifier; 11 | 12 | use ObjectQuery\Exception\InvalidModifierConfigurationException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\Tests\AbstractQueryTest; 15 | 16 | class OffsetTest extends AbstractQueryTest 17 | { 18 | public function testOffset(): void 19 | { 20 | $query = ObjectQuery::from($this->cities); 21 | 22 | $result = $query->offset(1) 23 | ->select(); 24 | 25 | $this->assertCount(1, $result); 26 | $this->assertSame('Paris', $result[0]->name); 27 | } 28 | 29 | public function testNullOffset(): void 30 | { 31 | $query = ObjectQuery::from($this->cities); 32 | 33 | $result = $query->offset(null) 34 | ->select(); 35 | 36 | $this->assertCount(\count($this->cities), $result); 37 | } 38 | 39 | public function testNegativeOffset(): void 40 | { 41 | $query = ObjectQuery::from($this->cities); 42 | 43 | $this->expectException(InvalidModifierConfigurationException::class); 44 | $this->expectExceptionMessage('The offset must be a positive integer or null to set no offset.'); 45 | $query->offset(-1) 46 | ->select(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/Operation/SelectManyTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\Exception\IncompatibleFieldException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\Tests\AbstractQueryTest; 15 | 16 | class SelectManyTest extends AbstractQueryTest 17 | { 18 | public function testSelectMany(): void 19 | { 20 | $cityQuery = ObjectQuery::from($this->cities); 21 | $result = $cityQuery 22 | ->where(fn($city) => $city->name === 'Lyon') 23 | ->selectMany('persons', '__') 24 | ->where(fn($person) => $person->height > 180) 25 | ->select(); 26 | 27 | $this->assertCount(1, $result); 28 | $this->assertSame(181, $result[0]->height); 29 | } 30 | 31 | public function testSelectManyOnScalarField(): void 32 | { 33 | $cityQuery = ObjectQuery::from($this->cities); 34 | 35 | $this->expectException(IncompatibleFieldException::class); 36 | $this->expectExceptionMessage('The given field is incompatible with "selectMany" because of the following reason: You can only selectMany on fields that are collections of objects.'); 37 | $cityQuery 38 | ->from($this->cities) 39 | ->selectMany('minimalAge'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ObjectQueryContext.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | /** 13 | * @author Alexandre Daubois 14 | * @author Hubert Lenoir 15 | */ 16 | class ObjectQueryContext implements QueryContextInterface 17 | { 18 | public function __construct( 19 | /** @var \SplObjectStorage> */ 20 | readonly private \SplObjectStorage $environments = new \SplObjectStorage, 21 | /** @var array */ 22 | readonly private array $usedAliases = [], 23 | ) 24 | {} 25 | 26 | public function getEnvironment(object $environment): array 27 | { 28 | return $this->environments[$environment] ?? []; 29 | } 30 | 31 | public function withEnvironment(object $environment, array $info): QueryContextInterface 32 | { 33 | $environments = clone($this->environments); 34 | $environments[$environment] = $info + $this->getEnvironment($environment); 35 | 36 | return new self($environments, $this->usedAliases); 37 | } 38 | 39 | public function isUsedAlias(string $alias): bool 40 | { 41 | return isset($this->usedAliases[$alias]); 42 | } 43 | 44 | public function withUsedAlias(string $alias): QueryContextInterface 45 | { 46 | return new self(clone($this->environments), $this->usedAliases + [$alias => true]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/QueryContextTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests; 11 | 12 | use ObjectQuery\ObjectQueryContext; 13 | use PHPUnit\Framework\TestCase; 14 | 15 | class QueryContextTest extends TestCase 16 | { 17 | public function testWithEnvironment(): void 18 | { 19 | $context = new ObjectQueryContext(); 20 | 21 | $environmentA = new \stdClass; 22 | $environmentB = new \stdClass; 23 | $newContext = $context 24 | ->withEnvironment($environmentA, ['a' => 1]) 25 | ->withEnvironment($environmentA, ['b' => 2]) 26 | ->withEnvironment($environmentB, ['c' => 3]) 27 | ; 28 | 29 | $this->assertEmpty((array) $context->getEnvironment(new \stdClass)); 30 | $this->assertEmpty($context->getEnvironment($environmentA)); 31 | $this->assertEmpty($context->getEnvironment($environmentB)); 32 | 33 | $this->assertEmpty($newContext->getEnvironment(new \stdClass)); 34 | $this->assertEquals(['a' => 1, 'b' => 2], $newContext->getEnvironment($environmentA)); 35 | $this->assertEquals(['c' => 3], $newContext->getEnvironment($environmentB)); 36 | } 37 | 38 | public function testWithUsedAlias(): void 39 | { 40 | $context = new ObjectQueryContext(); 41 | 42 | $newContext = $context->withUsedAlias('a'); 43 | 44 | $this->assertFalse($context->isUsedAlias('a')); 45 | $this->assertFalse($context->isUsedAlias('b')); 46 | $this->assertTrue($newContext->isUsedAlias('a')); 47 | $this->assertFalse($newContext->isUsedAlias('b')); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/Modifier/WhereTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Modifier; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\ObjectQueryContextEnvironment; 14 | use ObjectQuery\Tests\AbstractQueryTest; 15 | 16 | class WhereTest extends AbstractQueryTest 17 | { 18 | public function testObjectsSelectWhere(): void 19 | { 20 | $query = ObjectQuery::from($this->cities); 21 | $query->where(fn($city): bool => $city->minimalAge > 20); 22 | 23 | $this->assertSame('Lyon', \current($query->select())->name); 24 | } 25 | 26 | public function testWhereWithoutResult(): void 27 | { 28 | $query = ObjectQuery::from($this->cities); 29 | $query->where(fn($city): bool => $city->minimalAge < 1); 30 | 31 | $this->assertEmpty($query->select()); 32 | } 33 | 34 | public function testWhereWithAncestorNode(): void 35 | { 36 | $cityQuery = ObjectQuery::from($this->cities, alias: 'city'); 37 | 38 | $result = $cityQuery 39 | ->where(fn($city) => \str_contains($city->name, 'Lyon')) 40 | ->selectMany('persons', 'person') 41 | ->where(fn($person) => $person->height > 180) 42 | ->selectMany('children', 'child') 43 | ->where(fn($child, ObjectQueryContextEnvironment $context): bool => $child->age > $context->get('city')->minimalAge) 44 | ->select('name'); 45 | 46 | $this->assertCount(3, $result); 47 | $this->assertSame('Hubert', $result[0]); 48 | $this->assertSame('Alex', $result[1]); 49 | $this->assertSame('Will', $result[2]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/Modifier/LimitTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Modifier; 11 | 12 | use ObjectQuery\Exception\InvalidModifierConfigurationException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\ObjectQueryOrder; 15 | use ObjectQuery\Tests\AbstractQueryTest; 16 | 17 | class LimitTest extends AbstractQueryTest 18 | { 19 | public function testLimit(): void 20 | { 21 | $query = ObjectQuery::from($this->cities); 22 | $result = $query->limit(1) 23 | ->select(); 24 | 25 | $this->assertCount(1, $result); 26 | } 27 | 28 | public function testNullLimit(): void 29 | { 30 | $query = ObjectQuery::from($this->cities); 31 | $result = $query->limit(null) 32 | ->select(); 33 | 34 | $this->assertCount(\count($this->cities), $result); 35 | } 36 | 37 | public function testNegativeLimit(): void 38 | { 39 | $query = ObjectQuery::from($this->cities); 40 | 41 | $this->expectException(InvalidModifierConfigurationException::class); 42 | $this->expectExceptionMessage('The limit must be a positive integer or null to set no limit.'); 43 | $query->from($this->cities) 44 | ->limit(-1) 45 | ->select(); 46 | } 47 | 48 | public function testLimitWithObjects(): void 49 | { 50 | $query = ObjectQuery::from($this->cities); 51 | 52 | $query->orderBy(ObjectQueryOrder::Descending, 'minimalAge') 53 | ->limit(1); 54 | 55 | $result = $query->select(); 56 | $this->assertCount(1, $result); 57 | $this->assertSame('Lyon', $result[0]->name); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/Operation/SelectOneTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\Tests\AbstractQueryTest; 14 | use ObjectQuery\Tests\Fixtures\City; 15 | 16 | class SelectOneTest extends AbstractQueryTest 17 | { 18 | public function testObjectsSelectOne(): void 19 | { 20 | $query = ObjectQuery::from($this->cities); 21 | $result = $query 22 | ->where(fn($city) => $city->name === 'Lyon') 23 | ->selectOne(); 24 | 25 | $this->assertInstanceOf(City::class, $result); 26 | $this->assertSame('Lyon', $result->name); 27 | } 28 | 29 | public function testObjectsSelectOneWithoutResult(): void 30 | { 31 | $query = ObjectQuery::from($this->cities); 32 | $result = $query 33 | ->where(fn($city) => $city->name === 'Invalid City') 34 | ->selectOne(); 35 | 36 | $this->assertNull($result); 37 | } 38 | 39 | public function testSelectOneWithField(): void 40 | { 41 | $query = ObjectQuery::from($this->cities); 42 | $result = $query 43 | ->where(fn($city) => $city->name === 'Lyon') 44 | ->selectOne('name'); 45 | 46 | $this->assertIsString($result); 47 | $this->assertSame('Lyon', $result); 48 | } 49 | 50 | public function testSelectOneWithFieldWithoutResult(): void 51 | { 52 | $query = ObjectQuery::from($this->cities); 53 | $result = $query 54 | ->where(fn($city) => $city->name === 'Rouen') 55 | ->selectOne('name'); 56 | 57 | $this->assertNull($result); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /QueryInterface.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | /** 13 | * Defines an object which will execute modifiers and operations to query data. 14 | * A QueryInterface could receive any iterable depending on the implementation. This could go from the 15 | * basic array of data to generators. 16 | */ 17 | interface QueryInterface 18 | { 19 | /** 20 | * Creates a new object to query data. 21 | * 22 | * @param iterable $source 23 | * The source to apply manipulations on. 24 | * 25 | * @param QueryContextInterface|null $context 26 | * The optional context that forward needed information to the Query for its execution. 27 | * 28 | * @return QueryInterface 29 | */ 30 | public static function from(iterable $source, QueryContextInterface $context = null): QueryInterface; 31 | 32 | /** 33 | * Get the context of the current Query, possibly modified by the latter. 34 | * 35 | * @return QueryContextInterface 36 | */ 37 | public function getContext(): QueryContextInterface; 38 | 39 | /** 40 | * Gets the source of the Query. 41 | * 42 | * @return iterable 43 | */ 44 | public function getSource(): iterable; 45 | 46 | /** 47 | * Applies a modifier to the Query. 48 | * 49 | * @param QueryModifierInterface $modifier 50 | * The actual modifier to apply. 51 | * 52 | * @return QueryInterface 53 | */ 54 | public function applyModifier(QueryModifierInterface $modifier): QueryInterface; 55 | 56 | /** 57 | * Applies an operation to the Query. 58 | * 59 | * @param QueryOperationInterface $operation 60 | * The actual operation to apply. 61 | * 62 | * @return mixed 63 | */ 64 | public function applyOperation(QueryOperationInterface $operation): mixed; 65 | } 66 | -------------------------------------------------------------------------------- /Operation/SelectMany.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\Exception\IncompatibleFieldException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\QueryInterface; 15 | use Symfony\Component\PropertyAccess\PropertyAccess; 16 | 17 | final class SelectMany extends AbstractQueryOperation 18 | { 19 | private readonly string $field; 20 | private readonly string $alias; 21 | 22 | public function __construct(ObjectQuery $parentQuery, string $field, string $alias) 23 | { 24 | parent::__construct($parentQuery); 25 | 26 | $this->field = $field; 27 | $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); 28 | $this->alias = $alias; 29 | } 30 | 31 | public function apply(QueryInterface $query): ObjectQuery 32 | { 33 | $source = $this->applySelect($query); 34 | 35 | $final = []; 36 | $context = $this->parentQuery->getContext(); 37 | foreach ($source as $item) { 38 | $subfields = $this->propertyAccessor->getValue($item, $this->field); 39 | 40 | if (!\is_array($subfields) || \count(\array_filter($subfields, 'is_object')) !== \count($subfields)) { 41 | throw new IncompatibleFieldException('selectMany', 'You can only selectMany on fields that are collections of objects'); 42 | } 43 | 44 | foreach ($subfields as $subfield) { 45 | $final[] = $subfield; 46 | 47 | $context = $context->withEnvironment($subfield, [$this->parentQuery->getSourceAlias() => $item]); 48 | 49 | // Transmit current context to descendants 50 | $context = $context->withEnvironment($subfield, $context->getEnvironment($item)); 51 | } 52 | } 53 | 54 | return ObjectQuery::from($final, $context, $this->alias); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/Modifier/OrderByTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests\Modifier; 11 | 12 | use ObjectQuery\Exception\InvalidModifierConfigurationException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\ObjectQueryOrder; 15 | use ObjectQuery\Tests\AbstractQueryTest; 16 | 17 | class OrderByTest extends AbstractQueryTest 18 | { 19 | public function testObjectsAscendingOrderBy(): void 20 | { 21 | $query = ObjectQuery::from($this->cities); 22 | 23 | $query->orderBy(ObjectQueryOrder::Ascending, 'minimalAge'); 24 | 25 | $result = $query->select(); 26 | $this->assertSame('Paris', $result[0]->name); 27 | $this->assertSame('Lyon', $result[1]->name); 28 | } 29 | 30 | public function testObjectsDescendingOrderBy(): void 31 | { 32 | $query = ObjectQuery::from($this->cities); 33 | $query->orderBy(ObjectQueryOrder::Descending, 'minimalAge'); 34 | 35 | $result = $query->select(); 36 | $this->assertSame('Lyon', $result[0]->name); 37 | $this->assertSame('Paris', $result[1]->name); 38 | } 39 | 40 | public function testObjectsShuffleWithOrderFieldFailure(): void 41 | { 42 | $query = ObjectQuery::from($this->cities); 43 | $query->orderBy(ObjectQueryOrder::Shuffle, 'minimalAge'); 44 | 45 | $this->expectException(InvalidModifierConfigurationException::class); 46 | $this->expectExceptionMessage('The modifier "orderBy" is wrongly configured: An order field must not be provided when shuffling a collection.'); 47 | $query->select(); 48 | } 49 | 50 | public function testObjectsShuffle(): void 51 | { 52 | $query = ObjectQuery::from($this->cities); 53 | 54 | $query->selectMany('persons', 'person') 55 | ->selectMany('children', 'child') 56 | ->orderBy(ObjectQueryOrder::Shuffle); 57 | 58 | $firstShuffle = $query->concat(', ', 'name'); 59 | $secondShuffle = $query->concat(', ', 'name'); 60 | 61 | $this->assertNotSame($firstShuffle, $secondShuffle); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Operation/AbstractQueryOperation.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Operation; 11 | 12 | use ObjectQuery\ObjectQuery; 13 | use ObjectQuery\QueryInterface; 14 | use ObjectQuery\QueryOperationInterface; 15 | use Symfony\Component\PropertyAccess\PropertyAccess; 16 | use Symfony\Component\PropertyAccess\PropertyAccessor; 17 | 18 | abstract class AbstractQueryOperation implements QueryOperationInterface 19 | { 20 | protected readonly array|string|null $fields; 21 | protected ObjectQuery $parentQuery; 22 | protected PropertyAccessor $propertyAccessor; 23 | 24 | public function __construct(ObjectQuery $parentQuery, array|string|null $fields = null) 25 | { 26 | $this->parentQuery = $parentQuery; 27 | $this->fields = $fields; 28 | $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); 29 | } 30 | 31 | protected function applySelect(QueryInterface $query): iterable 32 | { 33 | if ($where = $query->getWhere()) { 34 | $query->applyModifier($where); 35 | } 36 | 37 | if ($offset = $query->getOffset()) { 38 | $query->applyModifier($offset); 39 | } 40 | 41 | if ($limit = $query->getLimit()) { 42 | $query->applyModifier($limit); 43 | } 44 | 45 | if ($orderBy = $query->getOrderBy()) { 46 | $query->applyModifier($orderBy); 47 | } 48 | 49 | $source = $query->getSource(); 50 | if (null !== $this->fields) { 51 | $filteredResult = []; 52 | 53 | foreach ($source as $item) { 54 | if (\is_string($this->fields)) { 55 | $fieldsValues = $this->propertyAccessor->getValue($item, $this->fields); 56 | } else { 57 | $fieldsValues = []; 58 | foreach ($this->fields as $field) { 59 | $fieldsValues[$field] = $this->propertyAccessor->getValue($item, $field); 60 | } 61 | } 62 | 63 | $filteredResult[] = $fieldsValues; 64 | } 65 | 66 | $source = $filteredResult; 67 | } 68 | 69 | return $source; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Modifier/OrderBy.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Modifier; 11 | 12 | use ObjectQuery\Exception\InvalidModifierConfigurationException; 13 | use ObjectQuery\ObjectQuery; 14 | use ObjectQuery\ObjectQueryOrder; 15 | use ObjectQuery\QueryInterface; 16 | use Symfony\Component\PropertyAccess\PropertyAccess; 17 | use Symfony\Component\PropertyAccess\PropertyAccessor; 18 | 19 | final class OrderBy extends AbstractQueryModifier 20 | { 21 | private readonly ObjectQueryOrder $orderBy; 22 | private readonly ?string $orderField; 23 | 24 | protected PropertyAccessor $propertyAccessor; 25 | 26 | public function __construct(ObjectQuery $parentQuery, ObjectQueryOrder $orderBy = ObjectQueryOrder::None, ?string $orderField = null) 27 | { 28 | parent::__construct($parentQuery); 29 | 30 | $this->orderBy = $orderBy; 31 | $this->orderField = $orderField; 32 | $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); 33 | } 34 | 35 | public function apply(QueryInterface $query): iterable 36 | { 37 | if (null !== $this->orderField && ObjectQueryOrder::Shuffle === $this->orderBy) { 38 | throw new InvalidModifierConfigurationException('orderBy', 'An order field must not be provided when shuffling a collection'); 39 | } 40 | 41 | $source = (array) $query->getSource(); 42 | 43 | if (ObjectQueryOrder::Shuffle === $this->orderBy) { 44 | \shuffle($source); 45 | 46 | return $source; 47 | } 48 | 49 | if (ObjectQueryOrder::None !== $this->orderBy) { 50 | if (null === $this->orderField) { 51 | throw new InvalidModifierConfigurationException('orderBy', 'An order field must be provided'); 52 | } 53 | 54 | \usort($source, function ($elementA, $elementB) { 55 | return $this->propertyAccessor->getValue($elementA, $this->orderField) <=> $this->propertyAccessor->getValue($elementB, $this->orderField); 56 | }); 57 | 58 | if (ObjectQueryOrder::Descending === $this->orderBy) { 59 | $source = \array_reverse($source); 60 | } 61 | 62 | return $source; 63 | } 64 | 65 | return $source; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/QueryTest.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery\Tests; 11 | 12 | use ObjectQuery\Exception\AliasAlreadyTakenInQueryContextException; 13 | use ObjectQuery\Exception\IncompatibleCollectionException; 14 | use ObjectQuery\Exception\InvalidAliasException; 15 | use ObjectQuery\ObjectQuery; 16 | use ObjectQuery\ObjectQueryContextEnvironment; 17 | use ObjectQuery\ObjectQueryOrder; 18 | use ObjectQuery\QueryContextInterface; 19 | 20 | class QueryTest extends AbstractQueryTest 21 | { 22 | public function testSimpleAlias(): void 23 | { 24 | $query = ObjectQuery::from($this->cities, alias: 'city') 25 | ->selectMany('persons', 'person') 26 | ->where(fn($person, ObjectQueryContextEnvironment $context) => $context->get('city')->name === 'Lyon'); 27 | 28 | $this->assertCount(2, $query->select()); 29 | } 30 | 31 | public function testWrongAlias(): void 32 | { 33 | $query = ObjectQuery::from($this->cities, alias: 'element') 34 | ->selectMany('persons', 'person') 35 | ->where(fn($city, ObjectQueryContextEnvironment $context) => $context->get('city')->name === 'Lyon'); 36 | 37 | $this->expectException(InvalidAliasException::class); 38 | $this->expectExceptionMessage('Alias "city" is not defined in the context. Available alias are: person, element.'); 39 | $query->select(); 40 | } 41 | 42 | public function testAliasAlreadyInUse(): void 43 | { 44 | $this->expectException(AliasAlreadyTakenInQueryContextException::class); 45 | $this->expectExceptionMessage('Alias "__" is already taken in the query. You should choose another name for your alias.'); 46 | 47 | $query = ObjectQuery::from($this->cities, alias: '__'); 48 | $query->selectMany('persons', '__'); 49 | } 50 | 51 | public function testBadContextClass(): void 52 | { 53 | $this->expectException(\InvalidArgumentException::class); 54 | ObjectQuery::from($this->cities, new class implements QueryContextInterface {}); 55 | } 56 | 57 | public function testFromScalarCollection(): void 58 | { 59 | $this->expectException(IncompatibleCollectionException::class); 60 | $this->expectExceptionMessage('The given collection is incompatible with "from" because of the following reason: Mixed and scalar collections are not supported. Collection must only contain objects to be used by ObjectQuery.'); 61 | ObjectQuery::from(self::NUMBERS); 62 | } 63 | 64 | public function testFromMixedCollection(): void 65 | { 66 | $this->expectException(IncompatibleCollectionException::class); 67 | $this->expectExceptionMessage('The given collection is incompatible with "from" because of the following reason: Mixed and scalar collections are not supported. Collection must only contain objects to be used by ObjectQuery.'); 68 | ObjectQuery::from($this->cities + self::NUMBERS); 69 | } 70 | 71 | public function testSelectOnInitialQueryWithSubQueries(): void 72 | { 73 | $query = ObjectQuery::from($this->cities); 74 | $query 75 | ->orderBy(ObjectQueryOrder::Ascending, 'name') 76 | ->limit(1) 77 | ; 78 | 79 | $this->assertSame('Lyon', $query->selectOne('name')); 80 | 81 | $query 82 | ->selectMany('persons', '__') 83 | ; 84 | 85 | $query 86 | ->selectMany('children', '___') 87 | ->where(fn($child) => $child->age >= 30) 88 | ; 89 | 90 | $this->assertSame('Hubert, Bob', $query->concat(', ', 'name')); 91 | } 92 | 93 | public function testSelectOnInitialQueryWithSubQueriesAndIntermediateWhere(): void 94 | { 95 | $query = ObjectQuery::from($this->cities); 96 | $query 97 | ->orderBy(ObjectQueryOrder::Ascending, 'name') 98 | ->limit(1) 99 | ; 100 | 101 | $this->assertSame('Lyon', $query->selectOne('name')); 102 | 103 | $query 104 | ->selectMany('persons', '__') 105 | ->where(fn($person) => $person->height > 180) 106 | ; 107 | 108 | $query 109 | ->selectMany('children', '___') 110 | ->where(fn($child) => $child->age >= 30) 111 | ; 112 | 113 | $this->assertSame('Hubert', $query->selectOne('name')); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ObjectQuery.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace ObjectQuery; 11 | 12 | use ObjectQuery\Exception\AliasAlreadyTakenInQueryContextException; 13 | use ObjectQuery\Exception\IncompatibleCollectionException; 14 | use ObjectQuery\Modifier\Limit; 15 | use ObjectQuery\Modifier\Offset; 16 | use ObjectQuery\Modifier\OrderBy; 17 | use ObjectQuery\Modifier\Where; 18 | use ObjectQuery\Operation\Average; 19 | use ObjectQuery\Operation\Concat; 20 | use ObjectQuery\Operation\Count; 21 | use ObjectQuery\Operation\Each; 22 | use ObjectQuery\Operation\Max; 23 | use ObjectQuery\Operation\Min; 24 | use ObjectQuery\Operation\Select; 25 | use ObjectQuery\Operation\SelectMany; 26 | use ObjectQuery\Operation\SelectOne; 27 | use ObjectQuery\Operation\Sum; 28 | 29 | class ObjectQuery implements QueryInterface 30 | { 31 | private iterable $source; 32 | private string $sourceAlias; 33 | 34 | private ?Where $where = null; 35 | private ?OrderBy $orderBy = null; 36 | private ?Limit $limit = null; 37 | private ?Offset $offset = null; 38 | 39 | private ObjectQueryContext $context; 40 | 41 | private ?ObjectQuery $subQuery = null; 42 | 43 | private function __construct(iterable $source, string $alias = '_', ObjectQueryContext $context = null) 44 | { 45 | $this->source = $source; 46 | $this->sourceAlias = $alias; 47 | $this->context = $context; 48 | } 49 | 50 | public static function from(iterable $source, QueryContextInterface $context = null, string $alias = '_'): QueryInterface 51 | { 52 | if (null !== $context && !$context instanceof ObjectQueryContext) { 53 | throw new \InvalidArgumentException(\sprintf('Context of class %s is not compatible with ObjectQuery.', $context::class)); 54 | } 55 | 56 | $context ??= new ObjectQueryContext(); 57 | 58 | if ($context->isUsedAlias($alias)) { 59 | throw new AliasAlreadyTakenInQueryContextException($alias); 60 | } 61 | 62 | foreach ($source as $item) { 63 | if (!\is_object($item)) { 64 | throw new IncompatibleCollectionException('from', 'Mixed and scalar collections are not supported. Collection must only contain objects to be used by ObjectQuery'); 65 | } 66 | } 67 | 68 | return new ObjectQuery($source, $alias, $context->withUsedAlias($alias)); 69 | } 70 | 71 | public function where(callable $callback): ObjectQuery 72 | { 73 | if ($this->subQuery) { 74 | return $this->subQuery->where($callback); 75 | } 76 | 77 | $this->where = new Where($this, $callback); 78 | 79 | return $this; 80 | } 81 | 82 | public function orderBy(ObjectQueryOrder $order, ?string $field = null): ObjectQuery 83 | { 84 | if ($this->subQuery) { 85 | return $this->subQuery->orderBy($order, $field); 86 | } 87 | 88 | $this->orderBy = new OrderBy($this, $order, $field); 89 | 90 | return $this; 91 | } 92 | 93 | public function limit(?int $limit): ObjectQuery 94 | { 95 | if ($this->subQuery) { 96 | return $this->subQuery->limit($limit); 97 | } 98 | 99 | $this->limit = new Limit($this, $limit); 100 | 101 | return $this; 102 | } 103 | 104 | public function offset(?int $offset): ObjectQuery 105 | { 106 | if ($this->subQuery) { 107 | return $this->subQuery->offset($offset); 108 | } 109 | 110 | $this->offset = new Offset($this, $offset); 111 | 112 | return $this; 113 | } 114 | 115 | public function selectMany(string $field, ?string $alias = '_'): ObjectQuery 116 | { 117 | if ($this->subQuery) { 118 | $this->subQuery->selectMany($field, $alias); 119 | 120 | return $this; 121 | } 122 | 123 | $this->subQuery = (new SelectMany($this, $field, $alias)) 124 | ->apply($this); 125 | 126 | return $this; 127 | } 128 | 129 | public function select(array|string|null $fields = null): array 130 | { 131 | return $this->applyOperation(new Select($this, $fields)); 132 | } 133 | 134 | public function selectOne(string|null $fields = null): mixed 135 | { 136 | return $this->applyOperation(new SelectOne($this, $fields)); 137 | } 138 | 139 | public function count(): int 140 | { 141 | return $this->applyOperation(new Count($this)); 142 | } 143 | 144 | public function concat(string $separator = ' ', ?string $field = null): string 145 | { 146 | return $this->applyOperation(new Concat($this, $field, $separator)); 147 | } 148 | 149 | public function each(callable $callback): array 150 | { 151 | return $this->applyOperation(new Each($this, $callback)); 152 | } 153 | 154 | public function max(?string $field = null): mixed 155 | { 156 | return $this->applyOperation(new Max($this, $field)); 157 | } 158 | 159 | public function min(?string $field = null): mixed 160 | { 161 | return $this->applyOperation(new Min($this, $field)); 162 | } 163 | 164 | public function average(?string $field = null): float 165 | { 166 | return $this->applyOperation(new Average($this, $field)); 167 | } 168 | 169 | public function sum(?string $field = null): int|float 170 | { 171 | return $this->applyOperation(new Sum($this, $field)); 172 | } 173 | 174 | public function getSourceAlias(): string 175 | { 176 | return $this->sourceAlias; 177 | } 178 | 179 | public function getWhere(): ?Where 180 | { 181 | return $this->where; 182 | } 183 | 184 | public function getOrderBy(): ?OrderBy 185 | { 186 | return $this->orderBy; 187 | } 188 | 189 | public function getLimit(): ?Limit 190 | { 191 | return $this->limit; 192 | } 193 | 194 | public function getOffset(): ?Offset 195 | { 196 | return $this->offset; 197 | } 198 | 199 | public function getContext(): ObjectQueryContext 200 | { 201 | return $this->context; 202 | } 203 | 204 | public function getSource(): iterable 205 | { 206 | return $this->source; 207 | } 208 | 209 | public function applyOperation(QueryOperationInterface $operation): mixed 210 | { 211 | if ($this->subQuery) { 212 | return $this->subQuery->applyOperation($operation); 213 | } 214 | 215 | return $operation->apply($this); 216 | } 217 | 218 | public function applyModifier(QueryModifierInterface $modifier): QueryInterface 219 | { 220 | if ($this->subQuery) { 221 | return $this->subQuery->applyModifier($modifier); 222 | } 223 | 224 | $this->source = $modifier->apply($this); 225 | 226 | return $this; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # POQ - PHP Object Query 2 | 3 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg?style=flat-square)](https://php.net/) 4 | ![CI](https://github.com/alexandre-daubois/poq/actions/workflows/php.yml/badge.svg) 5 | [![Latest Unstable Version](http://poser.pugx.org/alexandre-daubois/poq/v/unstable)](https://packagist.org/packages/alexandre-daubois/poq) 6 | [![License](http://poser.pugx.org/alexandre-daubois/poq/license)](https://packagist.org/packages/alexandre-daubois/poq) 7 | 8 | ## Install 9 | 10 | ```bash 11 | composer require alexandre-daubois/poq 1.0.0-beta2 12 | ``` 13 | 14 | That's it, ready to go! 🎉 15 | 16 | ## Usage 17 | 18 | Here is the set of data we're going to use in the follow examples: 19 | 20 | ```php 21 | $cities = [ 22 | new City('Lyon', [ 23 | new Person([ 24 | new Child('Hubert', age: 30), 25 | new Child('Aleksandr', age: 18), 26 | new Child('Alexandre', age: 26), 27 | new Child('Alex', age: 25), 28 | ], height: 181), 29 | new Person([ 30 | new Child('Fabien', age: 23), 31 | new Child('Nicolas', age: 8), 32 | ], height: 176), 33 | new Person([ 34 | new Child('Alexis', age: 33), 35 | new Child('Pierre', age: 5), 36 | ], height: 185) 37 | ], minimalAge: 21), 38 | new City('Paris', [ 39 | new Person([ 40 | new Child('Will', age: 33), 41 | new Child('Alix', age: 32), 42 | new Child('Alan', age: 45), 43 | ], height: 185) 44 | ], minimalAge: 45) 45 | ]; 46 | ``` 47 | 48 | ## The ObjectQuery object 49 | 50 | The `ObjectQuery` object allows you to easily fetch deep information in your collections with ease. Just like Doctrine's `QueryBuilder`, plenty of utils methods are present for easy manipulation. 51 | 52 | Moreover, you're able to create your query step-by-step and conditionally if needed. To create a simple query, all you need to do is call `ObjectQuery`'s `from` factory, and pass your collection as its source: 53 | 54 | ```php 55 | use ObjectQuery\ObjectQuery; 56 | 57 | $query = ObjectQuery::from($this->cities, 'city'); 58 | ``` 59 | 60 | From here, you're able to manipulate your collections and fetch data. First, let's see how to filter your collections. Note that the `city` argument is optional and defines an alias for the current collection. By default, the alias is `_`. Each alias must be unique in the query, meaning it is mandatory you pass an alias if you are dealing with deep collections. Defining an alias allows you to reference to the object later in the query. See the `selectMany` operation explanation for more details. 61 | 62 | ## Modifiers (filtering, ordering, limiting, etc.) 63 | 64 | Modifiers allow to filter results, order them, limit them and so on. 65 | 66 | > 🔀 Modifiers can be given to the query in any order, as they are only applied when an operation is called. 67 | 68 | ### Where 69 | 70 | #### Usage 71 | 72 | `where`, which takes a callback as an argument. This callback must return a boolean value. 73 | 74 | ```php 75 | use ObjectQuery\ObjectQuery; 76 | 77 | $query = (ObjectQuery::from($this->cities, 'city')) 78 | ->where( 79 | function(City $city) { 80 | return \str_contains($city->name, 'Lyon') || \in_array($city->name, ['Paris', 'Rouen']); 81 | } 82 | ); 83 | ``` 84 | 85 | ### Order by 86 | 87 | `orderBy`, which will order the collection. If the collection only contains scalar values, then you only have to pass an order. If your collection contains objects, you have to pass the order as well as the field to order on. Available orders are: `QueryOrder::Ascending`, `QueryOrder::Descending`, `QueryOrder::None` and `QueryOrder::Shuffle`. 88 | 89 | ```php 90 | use ObjectQuery\ObjectQuery; 91 | use ObjectQuery\ObjectQueryOrder; 92 | 93 | $query = (ObjectQuery::from($this->cities)) 94 | ->orderBy(ObjectQueryOrder::Ascending, 'name'); 95 | ``` 96 | 97 | ### Offset 98 | 99 | `offset` modifier changes the position of the first element that will be retrieved from the collection. This is particularly useful when doing pagination, in conjunction with the `limit` modifier. The offset must be a positive integer, or `null` to remove any offset. 100 | 101 | ```php 102 | use ObjectQuery\ObjectQuery; 103 | 104 | $query = ObjectQuery::from($this->cities); 105 | 106 | // Skip the 2 first cities of the collection and fetch the rest 107 | $query->offset(2) 108 | ->select(); 109 | 110 | // Unset any offset, no data will be skipped 111 | $query->offset(null) 112 | ->select(); 113 | ``` 114 | 115 | ### Limit 116 | 117 | The `limit` modifier limit the number of results that will be used by different operations, such as `select`. The limit must be a positive integer, or `null` to remove any limit. 118 | 119 | ```php 120 | use ObjectQuery\ObjectQuery; 121 | 122 | $query = ObjectQuery::from($this->cities); 123 | 124 | // Only the first 2 results will be fetched by the `select` operation 125 | $query->limit(2) 126 | ->select(); 127 | 128 | // Unset any limitation, all matching results will be used in the `select` operations 129 | $query->limit(null) 130 | ->select(); 131 | ``` 132 | 133 | ## Operations 134 | 135 | Operations allow you to fetch filtered data in a certain format. Here is a list of the available operations and how to use them. 136 | 137 | ### Select 138 | 139 | This is the most basic operation. It returns filtered data of the query. It is possible to pass the exact field we want to retrieve, as well as multiple fields. If no argument is passed to `select`, it will retrieve the whole object. You must not pass any argument when dealing with scalar collections. 140 | 141 | ```php 142 | use ObjectQuery\ObjectQuery; 143 | 144 | $query = ObjectQuery::from($this->cities); 145 | 146 | // Retrieve the whole object 147 | $query->select(); 148 | 149 | // Retrieve one field 150 | $query->select('name'); 151 | 152 | // Retrieve multiple fields 153 | $query->select(['name', 'minimalAge']); 154 | ``` 155 | 156 | ### Select One 157 | 158 | When querying a collection, and we know in advance that only one result is going to match, this could be redundant to use `select` and retrieve result array's first element everytime. `selectOne` is designed exactly for this case. The behavior of this operation is the following: 159 | 160 | * If a single result is found, it will be returned directly without enclosing it in an array of 1 element. 161 | * If no result is found, the `selectOne` operation returns `null`. 162 | * If more than on result is found, then a `NonUniqueResultException` is thrown. 163 | 164 | ```php 165 | use ObjectQuery\Exception\NonUniqueResultException; 166 | 167 | $query = (ObjectQuery::from($this->cities, 'city')) 168 | ->where(fn($city) => $city->name === 'Lyon'); 169 | 170 | try { 171 | $city = $query->selectOne(); // $city is an instance of City 172 | 173 | // You can also query a precise field 174 | $cityName = $query->selectOne('name'); // $cityName is a string 175 | } catch (NonUniqueResultException) { 176 | // ... 177 | } 178 | ``` 179 | 180 | ### Select Many 181 | 182 | This operation allows you to go deeper in a collection. Let's say your collection contains many objects with collections inside them, this is what you're going to use to fetch and filter collections. 183 | 184 | Note that we defined an alias for city, **which allows to reference the parent city in the last `where` call**. 185 | 186 | ```php 187 | use ObjectQuery\ObjectQuery; 188 | use ObjectQuery\ObjectQueryContextEnvironment; 189 | 190 | $query = (ObjectQuery::from($this->cities, 'city')) 191 | ->where(fn($city) => \in_array($city->name, ['Paris', 'Rouen'])) 192 | ->selectMany('persons', 'person') 193 | ->where(fn($person) => $person->height >= 180) 194 | ->selectMany('children', 'child') 195 | ->where(fn($child, ObjectQueryContextEnvironment $context) => \str_starts_with($child->name, 'Al') && $child->age >= $context->get('city')->minimalAge); 196 | ``` 197 | 198 | Like `from`, `selectMany` also takes an alias as an argument. This way, you will be able to reference ancestors in your `where` calls, as shown in the above example. 199 | 200 | ### Count 201 | 202 | This operation returns the size of the current filtered collection: 203 | 204 | ```php 205 | $query = ObjectQuery::from($this->cities); 206 | 207 | $query->count(); 208 | ``` 209 | 210 | ### Concat 211 | 212 | This operation will concatenate the collection with a given separator. If you're dealing with a scalar collection, there is no mandatory argument. If dealing with collections of objects, the `field` argument must be passed. 213 | 214 | ```php 215 | $query = ObjectQuery::from($this->cities); 216 | 217 | $query->concat(', ', 'name'); 218 | ``` 219 | 220 | ### Each 221 | 222 | This operation allows you to pass a callback, which will be applied to each element of the filtered collection. You can see this as a `foreach`. 223 | 224 | ```php 225 | $query = ObjectQuery::from($this->cities); 226 | 227 | // Append an exclamation point to every city name 228 | $query->each(fn($element) => $element->name.' !'); 229 | ``` 230 | 231 | ### Min and Max 232 | 233 | These operations will return the maximum and the minimum of the collection. You can use this on scalar collections. Internally, these operations use `min()` and `max()` functions of the Standard PHP Library, so the same rules apply. 234 | 235 | ```php 236 | use ObjectQuery\ObjectQuery; 237 | 238 | $query = (ObjectQuery::from($this->cities)) 239 | ->selectMany('persons', 'person') 240 | ->selectMany('children', 'child'); 241 | 242 | $query->min('age'); // 5 243 | $query->max('age'); // 45 244 | $query->min('name'); // "Alan" 245 | $query->max('name'); // "Will" 246 | ``` 247 | 248 | ### Sum 249 | 250 | `sum` returns the sum of a collection. If the collection contains objects, a field must be provided in order to calculate the sum of it. This only works with collections of numerics, and an exception is thrown if any item of the collection returns `false` to the `\is_numeric()` function. 251 | 252 | ```php 253 | use ObjectQuery\ObjectQuery; 254 | 255 | $query = (ObjectQuery::from($this->cities)) 256 | ->selectMany('persons', 'person') 257 | ->selectMany('children', 'child'); 258 | 259 | $query->sum('age'); 260 | ``` 261 | 262 | ### Average 263 | 264 | `average` returns the average of a collection. If the collection contains objects, a field must be provided in order to calculate the average of it. This only works with collections of numerics, and an exception is thrown if any item of the collection returns `false` to the `\is_numeric()` function. 265 | 266 | ```php 267 | use ObjectQuery\ObjectQuery; 268 | 269 | $query = (ObjectQuery::from($this->cities)) 270 | ->selectMany('persons', 'person') 271 | ->selectMany('children', 'child'); 272 | 273 | $query->average('age'); 274 | ``` 275 | --------------------------------------------------------------------------------