From c149e49d5d4427113c341458b9878e78ec5df79e Mon Sep 17 00:00:00 2001 From: HECHT Axel Date: Wed, 25 Feb 2026 21:06:34 +0100 Subject: [PATCH] feat: better query builder Add expression builder, limit, order by --- src/Dialect/DataApiBuilderDialect.php | 22 +++++ src/Dialect/DefaultDialect.php | 20 +++++ src/Dialect/GraphqlQueryDialect.php | 14 +++ src/Query/Direction.php | 11 +++ src/Query/Expr/ComparisonExpression.php | 24 +++++ src/Query/Expr/ExpressionBuilder.php | 61 +++++++++++++ src/Query/Expr/FilterExpressionInterface.php | 13 +++ src/Query/Expr/LogicalExpression.php | 24 +++++ src/Query/GraphqlQueryBuilder.php | 36 +++++++- src/Query/GraphqlQueryStringBuilder.php | 32 ++++++- src/Query/QueryOptions.php | 17 ++++ src/Query/Walker/AbstractGraphqlWalker.php | 54 +----------- src/Query/Walker/DABGraphqlWalker.php | 69 +++++++++++++++ src/Query/Walker/DefaultGraphqlWalker.php | 54 ++++++++++++ tests/Fixtures/FakeGraphqlClient.php | 8 +- tests/GraphqlOrmIntegrationTest.php | 6 +- tests/Query/Expr/ComparisonExpressionTest.php | 39 +++++++++ tests/Query/Expr/ExpressionBuilderTest.php | 86 ++++++++++++++++++ tests/Query/Expr/LogicalExpressionTest.php | 37 ++++++++ tests/Query/GraphqlQueryBuilderTest.php | 15 ++-- tests/QueryBuilderEndToEndTest.php | 87 +++++++++++++++++++ 21 files changed, 664 insertions(+), 65 deletions(-) create mode 100644 src/Query/Direction.php create mode 100644 src/Query/Expr/ComparisonExpression.php create mode 100644 src/Query/Expr/ExpressionBuilder.php create mode 100644 src/Query/Expr/FilterExpressionInterface.php create mode 100644 src/Query/Expr/LogicalExpression.php create mode 100644 src/Query/QueryOptions.php create mode 100644 tests/Query/Expr/ComparisonExpressionTest.php create mode 100644 tests/Query/Expr/ExpressionBuilderTest.php create mode 100644 tests/Query/Expr/LogicalExpressionTest.php create mode 100644 tests/QueryBuilderEndToEndTest.php diff --git a/src/Dialect/DataApiBuilderDialect.php b/src/Dialect/DataApiBuilderDialect.php index b0a96ac..b1f04ae 100644 --- a/src/Dialect/DataApiBuilderDialect.php +++ b/src/Dialect/DataApiBuilderDialect.php @@ -4,6 +4,8 @@ namespace GraphqlOrm\Dialect; +use GraphqlOrm\Query\Expr\FilterExpressionInterface; +use GraphqlOrm\Query\QueryOptions; use GraphqlOrm\Query\Walker\DABGraphqlWalker; use GraphqlOrm\Query\Walker\GraphqlWalkerInterface; @@ -21,4 +23,24 @@ public function createWalker(): GraphqlWalkerInterface { return new DABGraphqlWalker(); } + + public function applyQueryOptions(array $arguments, QueryOptions $options): array + { + if ($options->limit !== null) { + $arguments['first'] = $options->limit; + } + + $arguments['orderBy'] = $options->orderBy; + + return $arguments; + } + + public function applyFilter(?FilterExpressionInterface $filter): array + { + if (!$filter) { + return []; + } + + return ['filter' => $filter->toArray()]; + } } diff --git a/src/Dialect/DefaultDialect.php b/src/Dialect/DefaultDialect.php index 78187c3..73e76f6 100644 --- a/src/Dialect/DefaultDialect.php +++ b/src/Dialect/DefaultDialect.php @@ -4,6 +4,8 @@ namespace GraphqlOrm\Dialect; +use GraphqlOrm\Query\Expr\FilterExpressionInterface; +use GraphqlOrm\Query\QueryOptions; use GraphqlOrm\Query\Walker\DefaultGraphqlWalker; use GraphqlOrm\Query\Walker\GraphqlWalkerInterface; @@ -18,4 +20,22 @@ public function createWalker(): GraphqlWalkerInterface { return new DefaultGraphqlWalker(); } + + public function applyQueryOptions(array $arguments, QueryOptions $options): array + { + if ($options->limit !== null) { + $arguments['first'] = $options->limit; + } + + return $arguments; + } + + public function applyFilter(?FilterExpressionInterface $filter): array + { + if (!$filter) { + return []; + } + + return $filter->toArray(); + } } diff --git a/src/Dialect/GraphqlQueryDialect.php b/src/Dialect/GraphqlQueryDialect.php index 00aff89..2cea8da 100644 --- a/src/Dialect/GraphqlQueryDialect.php +++ b/src/Dialect/GraphqlQueryDialect.php @@ -4,6 +4,8 @@ namespace GraphqlOrm\Dialect; +use GraphqlOrm\Query\Expr\FilterExpressionInterface; +use GraphqlOrm\Query\QueryOptions; use GraphqlOrm\Query\Walker\GraphqlWalkerInterface; interface GraphqlQueryDialect @@ -16,4 +18,16 @@ interface GraphqlQueryDialect public function extractCollection(array $data): array; public function createWalker(): GraphqlWalkerInterface; + + /** + * @param array $arguments + * + * @return array + */ + public function applyQueryOptions(array $arguments, QueryOptions $options): array; + + /** + * @return array + */ + public function applyFilter(?FilterExpressionInterface $filter): array; } diff --git a/src/Query/Direction.php b/src/Query/Direction.php new file mode 100644 index 0000000..33ab649 --- /dev/null +++ b/src/Query/Direction.php @@ -0,0 +1,11 @@ +field => [ + $this->operator => $this->value, + ], + ]; + } +} diff --git a/src/Query/Expr/ExpressionBuilder.php b/src/Query/Expr/ExpressionBuilder.php new file mode 100644 index 0000000..2e7546c --- /dev/null +++ b/src/Query/Expr/ExpressionBuilder.php @@ -0,0 +1,61 @@ + + */ + public function toArray(): array; +} diff --git a/src/Query/Expr/LogicalExpression.php b/src/Query/Expr/LogicalExpression.php new file mode 100644 index 0000000..ddfc1ac --- /dev/null +++ b/src/Query/Expr/LogicalExpression.php @@ -0,0 +1,24 @@ +operator => array_map(fn ($expr) => $expr->toArray(), $this->expressions), + ]; + } +} diff --git a/src/Query/GraphqlQueryBuilder.php b/src/Query/GraphqlQueryBuilder.php index 5b5244f..0924c47 100644 --- a/src/Query/GraphqlQueryBuilder.php +++ b/src/Query/GraphqlQueryBuilder.php @@ -5,6 +5,8 @@ namespace GraphqlOrm\Query; use GraphqlOrm\GraphqlManager; +use GraphqlOrm\Query\Expr\ExpressionBuilder; +use GraphqlOrm\Query\Expr\FilterExpressionInterface; /** * @template T of object @@ -16,6 +18,7 @@ final class GraphqlQueryBuilder /** @var string[]|null */ private ?array $selectedFields = null; private ?string $graphql = null; + private ?FilterExpressionInterface $filter = null; /** * @param class-string $entityClass @@ -24,6 +27,7 @@ final class GraphqlQueryBuilder public function __construct( private readonly string $entityClass, private readonly GraphqlManager $manager, + private readonly QueryOptions $options = new QueryOptions(), ) { } @@ -53,13 +57,39 @@ public function addSelect(string $field): self /** * @return GraphqlQueryBuilder */ - public function where(string $field, mixed $value): self + public function where(FilterExpressionInterface $expr): self { - $this->criteria[$field] = $value; + $this->filter = $expr; return $this; } + /** + * @return GraphqlQueryBuilder + */ + public function limit(int $limit): self + { + $this->options->limit = $limit; + + return $this; + } + + /** + * @return GraphqlQueryBuilder + */ + public function orderBy(string $orderBy, Direction $direction): self + { + $this->options->orderBy ??= []; + $this->options->orderBy[$orderBy] = $direction; + + return $this; + } + + public function expr(): ExpressionBuilder + { + return new ExpressionBuilder(); + } + /** * @return GraphqlQueryBuilder */ @@ -99,6 +129,8 @@ public function getQuery(): GraphqlQuery ->entity($this->entityClass) ->root($metadata->name) ->arguments($this->criteria) + ->options($this->options) + ->filter($this->filter) ->fields($fields, $manualSelect) ->build(); diff --git a/src/Query/GraphqlQueryStringBuilder.php b/src/Query/GraphqlQueryStringBuilder.php index 7802d05..a8d9044 100644 --- a/src/Query/GraphqlQueryStringBuilder.php +++ b/src/Query/GraphqlQueryStringBuilder.php @@ -11,6 +11,7 @@ use GraphqlOrm\Query\Ast\FieldNode; use GraphqlOrm\Query\Ast\QueryNode; use GraphqlOrm\Query\Ast\SelectionSetNode; +use GraphqlOrm\Query\Expr\FilterExpressionInterface; /** * @template T of object @@ -27,6 +28,8 @@ final class GraphqlQueryStringBuilder private ?bool $manualSelect = false; /** @var array */ private array $visited = []; + private QueryOptions $options; + private ?FilterExpressionInterface $filter = null; /** * @param GraphqlManager $manager @@ -34,6 +37,7 @@ final class GraphqlQueryStringBuilder public function __construct( private readonly GraphqlManager $manager, ) { + $this->options = new QueryOptions(); } /** @@ -83,13 +87,39 @@ public function entity(string $entityClass): self return $this; } + /** + * @return GraphqlQueryStringBuilder + */ + public function options(QueryOptions $options): self + { + $this->options = $options; + + return $this; + } + + /** + * @return GraphqlQueryStringBuilder + */ + public function filter(?FilterExpressionInterface $filter): self + { + $this->filter = $filter; + + return $this; + } + public function build(): QueryNode { $query = new QueryNode(); + $dialect = $this->manager->getDialect(); + + $options = $dialect->applyQueryOptions($this->arguments, $this->options); + $filter = $dialect->applyFilter($this->filter); + $args = [...$options, ...$filter]; + $root = new FieldNode( $this->root, - $this->arguments, + $args, new SelectionSetNode() ); diff --git a/src/Query/QueryOptions.php b/src/Query/QueryOptions.php new file mode 100644 index 0000000..02bda5d --- /dev/null +++ b/src/Query/QueryOptions.php @@ -0,0 +1,17 @@ +|null */ + public ?array $orderBy = null; +} diff --git a/src/Query/Walker/AbstractGraphqlWalker.php b/src/Query/Walker/AbstractGraphqlWalker.php index f22fba9..a6799e8 100644 --- a/src/Query/Walker/AbstractGraphqlWalker.php +++ b/src/Query/Walker/AbstractGraphqlWalker.php @@ -4,7 +4,6 @@ namespace GraphqlOrm\Query\Walker; -use GraphqlOrm\Exception\InvalidArgumentException; use GraphqlOrm\Query\Ast\FieldNode; use GraphqlOrm\Query\Ast\SelectionSetNode; use GraphqlOrm\Query\Printer\GraphqlPrinter; @@ -34,61 +33,12 @@ protected function walkField(FieldNode $field): void $this->printer->line('}'); } - protected function formatValue(mixed $value): string - { - if (\is_string($value)) { - return '"' . $value . '"'; - } - - if (\is_int($value) || \is_float($value)) { - return (string) $value; - } - - if (\is_bool($value)) { - return $value ? 'true' : 'false'; - } - - if ($value === null) { - return 'null'; - } - - if (\is_array($value)) { - $items = array_map( - fn ($v) => $this->formatValue($v), - $value - ); - - return '[' . implode(', ', $items) . ']'; - } - - if ($value instanceof \BackedEnum) { - return '"' . $value->value . '"'; - } - - if ($value instanceof \DateTimeInterface) { - return '"' . $value->format(DATE_ATOM) . '"'; - } - - throw new InvalidArgumentException(\sprintf('Unsupported GraphQL argument type "%s".', get_debug_type($value))); - } + abstract protected function formatValue(mixed $value): string; /** * @param array $arguments */ - protected function formatArguments(array $arguments): string - { - if ($arguments === []) { - return ''; - } - - $pairs = []; - - foreach ($arguments as $key => $value) { - $pairs[] = \sprintf('%s: %s', $key, $this->formatValue($value)); - } - - return '(' . implode(', ', $pairs) . ')'; - } + abstract protected function formatArguments(array $arguments): string; protected function walkSelectionSet(SelectionSetNode $selectionSet): void { diff --git a/src/Query/Walker/DABGraphqlWalker.php b/src/Query/Walker/DABGraphqlWalker.php index 01b642b..e7e76a0 100644 --- a/src/Query/Walker/DABGraphqlWalker.php +++ b/src/Query/Walker/DABGraphqlWalker.php @@ -4,8 +4,10 @@ namespace GraphqlOrm\Query\Walker; +use GraphqlOrm\Exception\InvalidArgumentException; use GraphqlOrm\Query\Ast\FieldNode; use GraphqlOrm\Query\Ast\QueryNode; +use GraphqlOrm\Query\Direction; use GraphqlOrm\Query\Printer\GraphqlPrinter; final class DABGraphqlWalker extends AbstractGraphqlWalker @@ -53,4 +55,71 @@ private function walkRootField(FieldNode $field): void $this->printer->line('}'); } + + protected function formatValue(mixed $value): string + { + if (\is_string($value)) { + return '"' . $value . '"'; + } + + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null) { + return 'null'; + } + + if (\is_array($value)) { + if (array_is_list($value)) { + $items = array_map(fn ($v) => $this->formatValue($v), $value); + + return '[' . implode(', ', $items) . ']'; + } + + $fields = []; + foreach ($value as $key => $item) { + $fields[] = $key . ': ' . $this->formatValue($item); + } + + return '{ ' . implode(', ', $fields) . ' }'; + } + + if ($value instanceof \BackedEnum) { + return $value instanceof Direction + ? $value->value + : '"' . $value->value . '"'; + } + + if ($value instanceof \DateTimeInterface) { + return '"' . $value->format(DATE_ATOM) . '"'; + } + + throw new InvalidArgumentException(\sprintf('Unsupported GraphQL argument type "%s".', get_debug_type($value))); + } + + /** + * @param array $arguments + */ + protected function formatArguments(array $arguments): string + { + if ($arguments === []) { + return ''; + } + + $pairs = []; + + foreach ($arguments as $key => $value) { + if ($value === null) { + continue; + } + $pairs[] = \sprintf('%s: %s', $key, $this->formatValue($value)); + } + + return '(' . implode(', ', $pairs) . ')'; + } } diff --git a/src/Query/Walker/DefaultGraphqlWalker.php b/src/Query/Walker/DefaultGraphqlWalker.php index b5eab2d..cda336b 100644 --- a/src/Query/Walker/DefaultGraphqlWalker.php +++ b/src/Query/Walker/DefaultGraphqlWalker.php @@ -4,6 +4,7 @@ namespace GraphqlOrm\Query\Walker; +use GraphqlOrm\Exception\InvalidArgumentException; use GraphqlOrm\Query\Ast\QueryNode; use GraphqlOrm\Query\Printer\GraphqlPrinter; @@ -27,4 +28,57 @@ public function walk(QueryNode $query): string return $this->printer->get(); } + + protected function formatValue(mixed $value): string + { + if (\is_string($value)) { + return '"' . $value . '"'; + } + + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null) { + return 'null'; + } + + if (\is_array($value)) { + $items = array_map(fn ($v) => $this->formatValue($v), $value); + + return '[' . implode(', ', $items) . ']'; + } + + if ($value instanceof \BackedEnum) { + return '"' . $value->value . '"'; + } + + if ($value instanceof \DateTimeInterface) { + return '"' . $value->format(DATE_ATOM) . '"'; + } + + throw new InvalidArgumentException(\sprintf('Unsupported GraphQL argument type "%s".', get_debug_type($value))); + } + + /** + * @param array $arguments + */ + protected function formatArguments(array $arguments): string + { + if ($arguments === []) { + return ''; + } + + $pairs = []; + + foreach ($arguments as $key => $value) { + $pairs[] = \sprintf('%s: %s', $key, $this->formatValue($value)); + } + + return '(' . implode(', ', $pairs) . ')'; + } } diff --git a/tests/Fixtures/FakeGraphqlClient.php b/tests/Fixtures/FakeGraphqlClient.php index 1d58e89..ce4eebc 100644 --- a/tests/Fixtures/FakeGraphqlClient.php +++ b/tests/Fixtures/FakeGraphqlClient.php @@ -7,15 +7,19 @@ use GraphqlOrm\Client\GraphqlClientInterface; use GraphqlOrm\Execution\GraphqlExecutionContext; -final readonly class FakeGraphqlClient implements GraphqlClientInterface +final class FakeGraphqlClient implements GraphqlClientInterface { + public string $lastQuery = ''; + public function __construct( - private array $response, + private readonly array $response, ) { } public function query(string $query, GraphqlExecutionContext $context, array $variables = []): array { + $this->lastQuery = $query; + return $this->response; } } diff --git a/tests/GraphqlOrmIntegrationTest.php b/tests/GraphqlOrmIntegrationTest.php index 35df5b9..d3e89b2 100644 --- a/tests/GraphqlOrmIntegrationTest.php +++ b/tests/GraphqlOrmIntegrationTest.php @@ -77,10 +77,10 @@ public function testQueryBuilderEndToEnd(): void $repo = new GraphqlEntityRepository($manager, Task::class); - $result = $repo - ->createQueryBuilder() + $qb = $repo->createQueryBuilder(); + $result = $qb ->select('title') - ->where('id', 2) + ->where($qb->expr()->eq('title', 2)) ->getQuery() ->getResult(); diff --git a/tests/Query/Expr/ComparisonExpressionTest.php b/tests/Query/Expr/ComparisonExpressionTest.php new file mode 100644 index 0000000..cf9343c --- /dev/null +++ b/tests/Query/Expr/ComparisonExpressionTest.php @@ -0,0 +1,39 @@ + [ + 'eq' => 'Task 1', + ], + ], + $expr->toArray() + ); + } + + public function testSupportsArrayValue(): void + { + $expr = new ComparisonExpression('status', 'in', ['OPEN', 'DONE']); + + self::assertSame( + [ + 'status' => [ + 'in' => ['OPEN', 'DONE'], + ], + ], + $expr->toArray() + ); + } +} diff --git a/tests/Query/Expr/ExpressionBuilderTest.php b/tests/Query/Expr/ExpressionBuilderTest.php new file mode 100644 index 0000000..eeb64c2 --- /dev/null +++ b/tests/Query/Expr/ExpressionBuilderTest.php @@ -0,0 +1,86 @@ +expr = new ExpressionBuilder(); + } + + public function testEq(): void + { + $expr = $this->expr->eq('title', 'Task'); + + self::assertSame( + [ + 'title' => [ + 'eq' => 'Task', + ], + ], + $expr->toArray() + ); + } + + public function testContains(): void + { + $expr = $this->expr->contains('title', 'Task'); + + self::assertSame( + [ + 'title' => [ + 'contains' => 'Task', + ], + ], + $expr->toArray() + ); + } + + public function testIsNull(): void + { + $expr = $this->expr->isNull('deleted_at'); + + self::assertSame( + [ + 'deleted_at' => [ + 'isNull' => true, + ], + ], + $expr->toArray() + ); + } + + public function testOrX(): void + { + $expr = $this->expr->orX( + $this->expr->eq('title', 'A'), + $this->expr->eq('title', 'B') + ); + + self::assertSame( + [ + 'or' => [ + [ + 'title' => [ + 'eq' => 'A', + ], + ], + [ + 'title' => [ + 'eq' => 'B', + ], + ], + ], + ], + $expr->toArray() + ); + } +} diff --git a/tests/Query/Expr/LogicalExpressionTest.php b/tests/Query/Expr/LogicalExpressionTest.php new file mode 100644 index 0000000..576c611 --- /dev/null +++ b/tests/Query/Expr/LogicalExpressionTest.php @@ -0,0 +1,37 @@ + [ + [ + 'title' => [ + 'contains' => 'Task', + ], + ], + ], + ], + + $expr->toArray() + ); + } +} diff --git a/tests/Query/GraphqlQueryBuilderTest.php b/tests/Query/GraphqlQueryBuilderTest.php index e17eb2e..6c590c8 100644 --- a/tests/Query/GraphqlQueryBuilderTest.php +++ b/tests/Query/GraphqlQueryBuilderTest.php @@ -6,6 +6,7 @@ use GraphqlOrm\Client\GraphqlClient; use GraphqlOrm\DataCollector\GraphqlOrmDataCollector; +use GraphqlOrm\Dialect\DataApiBuilderDialect; use GraphqlOrm\GraphqlManager; use GraphqlOrm\Hydrator\EntityHydrator; use GraphqlOrm\Metadata\GraphqlEntityMetadata; @@ -79,15 +80,19 @@ public function testAddSelectAddsField(): void public function testWhereAddsArguments(): void { $manager = $this->createManager(); - - $query = (new GraphqlQueryBuilder(FakeEntity::class, $manager)) - ->where('id', 1) - ->where('status', 'OPEN') + $manager->dialect = new DataApiBuilderDialect(); + + $query = (new GraphqlQueryBuilder(FakeEntity::class, $manager)); + $query = $query + ->where($query->expr()->andX( + $query->expr()->eq('id', 1), + $query->expr()->eq('status', 'OPEN') + )) ->getQuery(); $graphql = $query->getGraphQL(); - self::assertStringContainsString('(id: 1, status: "OPEN")', $graphql); + self::assertStringContainsString('and: [{ id: { eq: 1 } }, { status: { eq: "OPEN" } }]', $graphql); } public function testAddSelectWithoutCallingSelectFirst(): void diff --git a/tests/QueryBuilderEndToEndTest.php b/tests/QueryBuilderEndToEndTest.php new file mode 100644 index 0000000..a0b34da --- /dev/null +++ b/tests/QueryBuilderEndToEndTest.php @@ -0,0 +1,87 @@ +createStub(GraphqlOrmDataCollector::class), + 5 + ); + } + + public function testFilteringPaginationOrderingAndRelations(): void + { + $client = new FakeGraphqlClient([ + 'data' => [ + 'tasks' => [ + 'items' => [ + [ + 'id' => 1, + 'title' => 'User Task', + 'user' => [ + 'id' => 10, + 'name' => 'John', + ], + ], + ], + ], + ], + ]); + + $manager = $this->createManager($client); + $manager->dialect = new DataApiBuilderDialect(); + + $repo = new GraphqlEntityRepository($manager, Task::class); + + $qb = $repo->createQueryBuilder(); + $result = $qb + ->select('id', 'title', 'user.name') + ->where( + $qb->expr()->orX( + $qb->expr()->contains('title', 'User'), + $qb->expr()->eq('title', 'Task') + )) + ->limit(10) + ->orderBy('title', Direction::ASC) + ->getQuery() + ->getResult(); + + self::assertCount(1, $result); + + $task = $result[0]; + + self::assertSame(1, $task->id); + self::assertSame('User Task', $task->title); + self::assertSame('John', $task->user->name); + + $query = $client->lastQuery; + + self::assertNotEmpty($query); + self::assertStringContainsString('filter:', $query); + self::assertStringContainsString('or:', $query); + self::assertStringContainsString('contains:', $query); + self::assertStringContainsString('first: 10', $query); + self::assertStringContainsString('orderBy:', $query); + self::assertStringContainsString('user {', $query); + self::assertStringContainsString('items {', $query); + } +}