Skip to content

Commit d2d9d80

Browse files
committed
Create MongoDB Provider without Doctrine ODM
1 parent 417fef5 commit d2d9d80

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\MongoDB\Extension;
15+
16+
use ApiPlatform\Doctrine\Odm\Extension\AggregationResultCollectionExtensionInterface;
17+
use ApiPlatform\Doctrine\Odm\Paginator;
18+
use ApiPlatform\Metadata\Exception\RuntimeException;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\State\Pagination\Pagination;
21+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
22+
use Doctrine\ODM\MongoDB\DocumentManager;
23+
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
24+
use Doctrine\Persistence\ManagerRegistry;
25+
26+
/**
27+
* Applies pagination on the Doctrine aggregation for resource collection when enabled.
28+
*
29+
* @author Kévin Dunglas <[email protected]>
30+
* @author Samuel ROZE <[email protected]>
31+
* @author Alan Poulain <[email protected]>
32+
*/
33+
final class PaginationExtension implements AggregationResultCollectionExtensionInterface
34+
{
35+
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly Pagination $pagination)
36+
{
37+
}
38+
39+
/**
40+
* {@inheritdoc}
41+
*
42+
* @throws RuntimeException
43+
*/
44+
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
45+
{
46+
if (!$this->pagination->isEnabled($operation, $context)) {
47+
return;
48+
}
49+
50+
if (($context['graphql_operation_name'] ?? false) && !$this->pagination->isGraphQlEnabled($operation, $context)) {
51+
return;
52+
}
53+
54+
$context = $this->addCountToContext(clone $aggregationBuilder, $context);
55+
56+
[, $offset, $limit] = $this->pagination->getPagination($operation, $context);
57+
58+
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
59+
if (!$manager instanceof DocumentManager) {
60+
throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class));
61+
}
62+
63+
/**
64+
* @var DocumentRepository
65+
*/
66+
$repository = $manager->getRepository($resourceClass);
67+
$resultsAggregationBuilder = $repository->createAggregationBuilder()->skip($offset);
68+
if ($limit > 0) {
69+
$resultsAggregationBuilder->limit($limit);
70+
} else {
71+
// Results have to be 0 but MongoDB does not support a limit equal to 0.
72+
$resultsAggregationBuilder->match()->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->equals(Paginator::LIMIT_ZERO_MARKER);
73+
}
74+
75+
$aggregationBuilder
76+
->facet()
77+
->field('results')->pipeline(
78+
$resultsAggregationBuilder
79+
)
80+
->field('count')->pipeline(
81+
$repository->createAggregationBuilder()
82+
->count('count')
83+
);
84+
}
85+
86+
/**
87+
* {@inheritdoc}
88+
*/
89+
public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool
90+
{
91+
if ($context['graphql_operation_name'] ?? false) {
92+
return $this->pagination->isGraphQlEnabled($operation, $context);
93+
}
94+
95+
return $this->pagination->isEnabled($operation, $context);
96+
}
97+
98+
/**
99+
* {@inheritdoc}
100+
*
101+
* @throws RuntimeException
102+
*/
103+
public function getResult(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array $context = []): iterable
104+
{
105+
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
106+
if (!$manager instanceof DocumentManager) {
107+
throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class));
108+
}
109+
110+
$attribute = $operation?->getExtraProperties()['doctrine_mongodb'] ?? [];
111+
$executeOptions = $attribute['execute_options'] ?? [];
112+
113+
return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline());
114+
}
115+
116+
private function addCountToContext(Builder $aggregationBuilder, array $context): array
117+
{
118+
if (!($context['graphql_operation_name'] ?? false)) {
119+
return $context;
120+
}
121+
122+
if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
123+
$context['count'] = $aggregationBuilder->count('count')->execute()->toArray()[0]['count'];
124+
}
125+
126+
return $context;
127+
}
128+
}
+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace ApiPlatform\MongoDB\State;
4+
5+
use ApiPlatform\Elasticsearch\Paginator;
6+
use ApiPlatform\Elasticsearch\State\Options;
7+
use ApiPlatform\Metadata\InflectorInterface;
8+
use ApiPlatform\Metadata\Operation;
9+
use ApiPlatform\Metadata\Util\Inflector;
10+
use ApiPlatform\State\Pagination\Pagination;
11+
use ApiPlatform\State\ProviderInterface;
12+
use MongoDB\Collection;
13+
use MongoDB\Database;
14+
use MongoDB\Driver\CursorInterface;
15+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
16+
17+
class CollectionProvider implements ProviderInterface
18+
{
19+
public function __construct(
20+
private readonly Database $database,
21+
private readonly ?DenormalizerInterface $denormalizer = null,
22+
private readonly ?Pagination $pagination = null,
23+
//private readonly iterable $collectionExtensions = [],
24+
private readonly ?InflectorInterface $inflector = new Inflector()
25+
) {
26+
}
27+
28+
/**
29+
* {@inheritdoc}
30+
*/
31+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Paginator
32+
{
33+
$resourceClass = $operation->getClass();
34+
35+
// @todo support collection extensions
36+
$filter = [];
37+
38+
$limit = $this->pagination->getLimit($operation, $context);
39+
$offset = $this->pagination->getOffset($operation, $context);
40+
41+
$pipeline = [
42+
['$match' => $filter],
43+
// Use $facet to get total count and data in a single query
44+
[
45+
'$facet' => [
46+
'count' => [['$count' => 'total']],
47+
'data' => [
48+
['$skip' => $limit],
49+
['$limit' => $limit],
50+
]
51+
]
52+
],
53+
[
54+
'$project' => [
55+
'total' => ['$arrayElemAt' => ['$count.total', 0]],
56+
'data' => 1,
57+
]
58+
]
59+
];
60+
61+
$options = [
62+
'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array'],
63+
];
64+
65+
$documents = $this->getCollection($operation)->aggregate($pipeline, $options);
66+
67+
if ($documents instanceof CursorInterface) {
68+
$documents = $documents->toArray();
69+
}
70+
71+
return new Paginator(
72+
$this->denormalizer,
73+
$documents,
74+
$resourceClass,
75+
$limit,
76+
$offset,
77+
$context
78+
);
79+
}
80+
81+
private function getCollection(Operation $operation): Collection
82+
{
83+
$name = $this->inflector->tableize($operation->getShortName());
84+
85+
return $this->database->selectCollection($name);
86+
}
87+
}

src/MongoDB/State/ItemProvider.php

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace ApiPlatform\MongoDB\State;
4+
5+
use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer;
6+
use ApiPlatform\Elasticsearch\State\Options;
7+
use ApiPlatform\Metadata\Exception\RuntimeException;
8+
use ApiPlatform\Metadata\InflectorInterface;
9+
use ApiPlatform\Metadata\Operation;
10+
use ApiPlatform\Metadata\Util\Inflector;
11+
use ApiPlatform\State\ProviderInterface;
12+
use MongoDB\BSON\ObjectId;
13+
use MongoDB\Collection;
14+
use MongoDB\Database;
15+
use MongoDB\Exception\InvalidArgumentException;
16+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
17+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
18+
19+
/**
20+
* Item provider for MongoDB.
21+
*
22+
* @author Jérôme Tamarelle <[email protected]>
23+
*/
24+
class ItemProvider implements ProviderInterface
25+
{
26+
public function __construct(
27+
private Database $database,
28+
private readonly ?DenormalizerInterface $denormalizer = null,
29+
private readonly ?InflectorInterface $inflector = new Inflector()
30+
) {
31+
}
32+
33+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
34+
{
35+
$resourceClass = $operation->getClass();
36+
$options = $operation->getStateOptions() instanceof Options ? $operation->getStateOptions() : new Options(index: $this->getIndex($operation));
37+
if (!$options instanceof Options) {
38+
throw new RuntimeException(\sprintf('The "%s" provider was called without "%s".', self::class, Options::class));
39+
}
40+
41+
try {
42+
// @todo check type of "_id" field
43+
$filter = ['_id' => new ObjectId(reset($uriVariables))];
44+
} catch (InvalidArgumentException) {
45+
return null;
46+
}
47+
48+
$options = [
49+
'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array'],
50+
// @todo add projection
51+
];
52+
53+
$document = $this->getCollection($operation)->findOne($filter, $options);
54+
55+
$item = $this->denormalizer->denormalize($document, $resourceClass, DocumentNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true]);
56+
if (!\is_object($item) && null !== $item) {
57+
throw new \UnexpectedValueException('Expected item to be an object or null.');
58+
}
59+
60+
return $item;
61+
}
62+
63+
private function getCollection(Operation $operation): Collection
64+
{
65+
$name = $this->inflector->tableize($operation->getShortName());
66+
67+
return $this->database->selectCollection($name);
68+
}
69+
}

0 commit comments

Comments
 (0)