Bläddra i källkod

修复关联bug

chengwl 1 dag sedan
förälder
incheckning
938acb213b

+ 84 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -103,6 +103,8 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->singleton(IterableType::class);
         $this->app->tag(IterableType::class, 'api_platform.graphql.type');
 
+        $this->overrideApiPlatformLinksHandler();
+
         $this->app->singleton(StorefrontKeyService::class, function ($app) {
             return new StorefrontKeyService;
         });
@@ -438,6 +440,88 @@ class BagistoApiServiceProvider extends ServiceProvider
         });
     }
 
+    /**
+     * Replace ApiPlatform's default CollectionProvider/ItemProvider so that
+     * sub-collection relations (GraphQL) use our CamelCaseAwareLinksHandler.
+     *
+     * ApiPlatform's own LinksHandler compares GraphQL camelCase fieldnames
+     * (e.g. "flexibleVariants") with snake_case Link::fromProperty (e.g.
+     * "flexible_variants"); the mismatch causes sub-collection queries to
+     * skip the parent-id WHERE clause and return every row in the child
+     * table. Our handler normalizes both sides before comparing and
+     * re-resolves the relation by method name.
+     */
+    /**
+     * Replace ApiPlatform's CollectionProvider/ItemProvider so the inner
+     * LinksHandler is our camelCase-aware variant.
+     *
+     * ApiPlatform ships its bindings via ApiPlatformDeferredProvider (a
+     * DeferrableProvider whose register() only runs when the binding is
+     * first resolved — i.e. after every other ServiceProvider::register()).
+     * We use Container::extend() so our decorator runs *after* ApiPlatform
+     * has built the original singleton. extend() also remembers our closure
+     * for bindings that don't exist yet, so registration order is safe.
+     *
+     * The provider's $linksHandler is a readonly promoted property and
+     * therefore cannot be replaced via Reflection on PHP 8.1+. Instead we
+     * rebuild the singleton with the same dependencies.
+     */
+    protected function overrideApiPlatformLinksHandler(): void
+    {
+        $buildHandler = fn ($app) => new \Webkul\BagistoApi\State\CamelCaseAwareLinksHandler(
+            $app,
+            $app->make(\ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface::class)
+        );
+
+        $this->app->extend(
+            \ApiPlatform\Laravel\Eloquent\State\CollectionProvider::class,
+            function ($original, $app) use ($buildHandler) {
+                if (! $original instanceof \ApiPlatform\Laravel\Eloquent\State\CollectionProvider) {
+                    return $original;
+                }
+                $ref            = new \ReflectionObject($original);
+                $queryExtensions = $this->readPrivate($ref, $original, 'queryExtensions');
+                $handleLinksLoc  = $this->readPrivate($ref, $original, 'handleLinksLocator');
+
+                return new \ApiPlatform\Laravel\Eloquent\State\CollectionProvider(
+                    $app->make(\ApiPlatform\State\Pagination\Pagination::class),
+                    $buildHandler($app),
+                    $queryExtensions ?? [],
+                    $handleLinksLoc
+                );
+            }
+        );
+
+        $this->app->extend(
+            \ApiPlatform\Laravel\Eloquent\State\ItemProvider::class,
+            function ($original, $app) use ($buildHandler) {
+                if (! $original instanceof \ApiPlatform\Laravel\Eloquent\State\ItemProvider) {
+                    return $original;
+                }
+                $ref            = new \ReflectionObject($original);
+                $handleLinksLoc  = $this->readPrivate($ref, $original, 'handleLinksLocator');
+                $queryExtensions = $this->readPrivate($ref, $original, 'queryExtensions');
+
+                return new \ApiPlatform\Laravel\Eloquent\State\ItemProvider(
+                    $buildHandler($app),
+                    $handleLinksLoc,
+                    $queryExtensions ?? []
+                );
+            }
+        );
+    }
+
+    private function readPrivate(\ReflectionObject $ref, object $instance, string $propertyName): mixed
+    {
+        if (! $ref->hasProperty($propertyName)) {
+            return null;
+        }
+        $prop = $ref->getProperty($propertyName);
+        $prop->setAccessible(true);
+
+        return $prop->getValue($instance);
+    }
+
     /**
      * Bootstrap services.
      */

+ 198 - 0
packages/Webkul/BagistoApi/src/State/CamelCaseAwareLinksHandler.php

@@ -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
+        );
+    }
+}