|
|
@@ -0,0 +1,198 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace Webkul\BagistoApi\State;
|
|
|
+
|
|
|
+use ApiPlatform\Laravel\Eloquent\State\LinksHandlerInterface;
|
|
|
+use ApiPlatform\Metadata\Exception\OperationNotFoundException;
|
|
|
+use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
|
|
|
+use ApiPlatform\Metadata\GraphQl\Query;
|
|
|
+use ApiPlatform\Metadata\Link;
|
|
|
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
|
|
|
+use Illuminate\Contracts\Foundation\Application;
|
|
|
+use Illuminate\Database\Eloquent\Builder;
|
|
|
+use Illuminate\Database\Eloquent\Model;
|
|
|
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
|
+use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
|
|
|
+use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
|
+use Illuminate\Database\Eloquent\Relations\Relation;
|
|
|
+use Illuminate\Support\Str;
|
|
|
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Fixes an ApiPlatform Laravel adapter bug where GraphQL sub-collections
|
|
|
+ * return every row in the child table regardless of the parent.
|
|
|
+ *
|
|
|
+ * Root cause:
|
|
|
+ * - ApiPlatform\Metadata\Resource\Factory\LinkFactory::createLinksFromRelations
|
|
|
+ * uses snake_case Eloquent property names ('flexible_variants') for each
|
|
|
+ * auto-discovered Link's $fromProperty.
|
|
|
+ * - ApiPlatform\GraphQl\State\Provider\ReadProvider writes the raw GraphQL
|
|
|
+ * field name ($info->fieldName, e.g. 'flexibleVariants') as
|
|
|
+ * $context['linkProperty'] without applying any name converter.
|
|
|
+ * - The default LinksHandler compares $linkProperty strictly against
|
|
|
+ * $link->getFromProperty(); the camelCase/snake_case mismatch always
|
|
|
+ * fails, so no WHERE clause is added and the CollectionProvider streams
|
|
|
+ * the entire table back to the client.
|
|
|
+ *
|
|
|
+ * This handler replays the vendor logic but also matches Links by their
|
|
|
+ * snake_case normalization, so 'flexibleVariants' finds the auto-generated
|
|
|
+ * 'flexible_variants' Link and the relation is followed correctly.
|
|
|
+ *
|
|
|
+ * @implements LinksHandlerInterface<Model>
|
|
|
+ */
|
|
|
+final class CamelCaseAwareLinksHandler implements LinksHandlerInterface
|
|
|
+{
|
|
|
+ public function __construct(
|
|
|
+ private readonly Application $application,
|
|
|
+ private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
|
|
|
+ ) {
|
|
|
+ }
|
|
|
+
|
|
|
+ public function handleLinks(Builder $builder, array $uriVariables, array $context): Builder
|
|
|
+ {
|
|
|
+ $operation = $context['operation'] ?? null;
|
|
|
+
|
|
|
+ if (! ($linkClass = $context['linkClass'] ?? false) || ! $operation) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ $linkProperty = $context['linkProperty'] ?? null;
|
|
|
+
|
|
|
+ if (null === $linkProperty) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ $linkedOperation = null;
|
|
|
+ try {
|
|
|
+ $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass);
|
|
|
+ $linkedOperation = $resourceMetadataCollection->getOperation($operation->getName());
|
|
|
+ } catch (OperationNotFoundException) {
|
|
|
+ foreach ($resourceMetadataCollection as $resourceMetadata) {
|
|
|
+ foreach ($resourceMetadata->getGraphQlOperations() as $op) {
|
|
|
+ if ($op instanceof Query) {
|
|
|
+ $linkedOperation = $op;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! $linkedOperation instanceof GraphQlOperation) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ $resourceClass = $builder->getModel()::class;
|
|
|
+ $linkPropertySnake = Str::snake($linkProperty);
|
|
|
+
|
|
|
+ $newLink = null;
|
|
|
+ foreach ($linkedOperation->getLinks() ?? [] as $link) {
|
|
|
+ if ($resourceClass !== $link->getToClass()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $from = $link->getFromProperty();
|
|
|
+ if ($from === $linkProperty || $from === $linkPropertySnake || Str::snake((string) $from) === $linkPropertySnake) {
|
|
|
+ $newLink = $link;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! $newLink) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ $identifierKey = $newLink->getIdentifiers()[0] ?? null;
|
|
|
+ if (! $identifierKey || ! array_key_exists($identifierKey, $uriVariables)) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->buildQuery($builder, $newLink, $uriVariables[$identifierKey]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mirrors the private LinksHandler::buildQuery from the vendor so that
|
|
|
+ * callers get the same behaviour as the default handler once the link
|
|
|
+ * has been resolved.
|
|
|
+ *
|
|
|
+ * @param Builder<Model> $builder
|
|
|
+ */
|
|
|
+ private function buildQuery(Builder $builder, Link $link, mixed $identifier): Builder
|
|
|
+ {
|
|
|
+ if ($to = $link->getToProperty()) {
|
|
|
+ return $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($from = $link->getFromProperty()) {
|
|
|
+ /** @var Model $relatedInstance */
|
|
|
+ $relatedInstance = $this->application->make($link->getFromClass());
|
|
|
+
|
|
|
+ $identifierField = $link->getIdentifiers()[0];
|
|
|
+
|
|
|
+ if ($identifierField !== $relatedInstance->getKeyName()) {
|
|
|
+ $relatedInstance = $relatedInstance
|
|
|
+ ->newQuery()
|
|
|
+ ->where($identifierField, $identifier)
|
|
|
+ ->first();
|
|
|
+ } else {
|
|
|
+ $relatedInstance->setAttribute($identifierField, $identifier);
|
|
|
+ $relatedInstance->exists = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! $relatedInstance) {
|
|
|
+ throw new NotFoundHttpException('Not Found');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Resolve the actual method name on the related model. The Link's
|
|
|
+ // $fromProperty is typically snake_case (Eloquent convention) but
|
|
|
+ // GraphQL fieldnames (and therefore user-facing names) are
|
|
|
+ // camelCase. Try both.
|
|
|
+ $candidates = array_unique([$from, Str::camel($from)]);
|
|
|
+ $relation = null;
|
|
|
+ foreach ($candidates as $candidate) {
|
|
|
+ if (method_exists($relatedInstance, $candidate)) {
|
|
|
+ $relation = $relatedInstance->{$candidate}();
|
|
|
+ if ($relation instanceof Relation) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ $relation = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! $relation instanceof Relation) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($relation instanceof MorphTo) {
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($relation instanceof BelongsTo) {
|
|
|
+ return $builder->getModel()
|
|
|
+ ->join(
|
|
|
+ $relation->getParent()->getTable(),
|
|
|
+ $relation->getParent()->getQualifiedKeyName(),
|
|
|
+ $identifier
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($relation instanceof HasOneOrMany || $relation instanceof BelongsToMany) {
|
|
|
+ return $relation->getQuery();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (method_exists($relation, 'getQualifiedForeignKeyName')) {
|
|
|
+ return $relation->getQuery()->where(
|
|
|
+ $relation->getQualifiedForeignKeyName(),
|
|
|
+ $identifier
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return $builder;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $builder->where(
|
|
|
+ $builder->getModel()->qualifyColumn($link->getIdentifiers()[0]),
|
|
|
+ $identifier
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|