CamelCaseAwareLinksHandler.php 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. <?php
  2. declare(strict_types=1);
  3. namespace Webkul\BagistoApi\State;
  4. use ApiPlatform\Laravel\Eloquent\State\LinksHandlerInterface;
  5. use ApiPlatform\Metadata\Exception\OperationNotFoundException;
  6. use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
  7. use ApiPlatform\Metadata\GraphQl\Query;
  8. use ApiPlatform\Metadata\Link;
  9. use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
  10. use Illuminate\Contracts\Foundation\Application;
  11. use Illuminate\Database\Eloquent\Builder;
  12. use Illuminate\Database\Eloquent\Model;
  13. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  14. use Illuminate\Database\Eloquent\Relations\BelongsToMany;
  15. use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
  16. use Illuminate\Database\Eloquent\Relations\MorphTo;
  17. use Illuminate\Database\Eloquent\Relations\Relation;
  18. use Illuminate\Support\Str;
  19. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  20. /**
  21. * Fixes an ApiPlatform Laravel adapter bug where GraphQL sub-collections
  22. * return every row in the child table regardless of the parent.
  23. *
  24. * Root cause:
  25. * - ApiPlatform\Metadata\Resource\Factory\LinkFactory::createLinksFromRelations
  26. * uses snake_case Eloquent property names ('flexible_variants') for each
  27. * auto-discovered Link's $fromProperty.
  28. * - ApiPlatform\GraphQl\State\Provider\ReadProvider writes the raw GraphQL
  29. * field name ($info->fieldName, e.g. 'flexibleVariants') as
  30. * $context['linkProperty'] without applying any name converter.
  31. * - The default LinksHandler compares $linkProperty strictly against
  32. * $link->getFromProperty(); the camelCase/snake_case mismatch always
  33. * fails, so no WHERE clause is added and the CollectionProvider streams
  34. * the entire table back to the client.
  35. *
  36. * This handler replays the vendor logic but also matches Links by their
  37. * snake_case normalization, so 'flexibleVariants' finds the auto-generated
  38. * 'flexible_variants' Link and the relation is followed correctly.
  39. *
  40. * @implements LinksHandlerInterface<Model>
  41. */
  42. final class CamelCaseAwareLinksHandler implements LinksHandlerInterface
  43. {
  44. public function __construct(
  45. private readonly Application $application,
  46. private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
  47. ) {
  48. }
  49. public function handleLinks(Builder $builder, array $uriVariables, array $context): Builder
  50. {
  51. $operation = $context['operation'] ?? null;
  52. if (! ($linkClass = $context['linkClass'] ?? false) || ! $operation) {
  53. return $builder;
  54. }
  55. $linkProperty = $context['linkProperty'] ?? null;
  56. if (null === $linkProperty) {
  57. return $builder;
  58. }
  59. $linkedOperation = null;
  60. try {
  61. $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass);
  62. $linkedOperation = $resourceMetadataCollection->getOperation($operation->getName());
  63. } catch (OperationNotFoundException) {
  64. foreach ($resourceMetadataCollection as $resourceMetadata) {
  65. foreach ($resourceMetadata->getGraphQlOperations() as $op) {
  66. if ($op instanceof Query) {
  67. $linkedOperation = $op;
  68. }
  69. }
  70. }
  71. }
  72. if (! $linkedOperation instanceof GraphQlOperation) {
  73. return $builder;
  74. }
  75. $resourceClass = $builder->getModel()::class;
  76. $linkPropertySnake = Str::snake($linkProperty);
  77. $newLink = null;
  78. foreach ($linkedOperation->getLinks() ?? [] as $link) {
  79. if ($resourceClass !== $link->getToClass()) {
  80. continue;
  81. }
  82. $from = $link->getFromProperty();
  83. if ($from === $linkProperty || $from === $linkPropertySnake || Str::snake((string) $from) === $linkPropertySnake) {
  84. $newLink = $link;
  85. break;
  86. }
  87. }
  88. if (! $newLink) {
  89. return $builder;
  90. }
  91. $identifierKey = $newLink->getIdentifiers()[0] ?? null;
  92. if (! $identifierKey || ! array_key_exists($identifierKey, $uriVariables)) {
  93. return $builder;
  94. }
  95. return $this->buildQuery($builder, $newLink, $uriVariables[$identifierKey]);
  96. }
  97. /**
  98. * Mirrors the private LinksHandler::buildQuery from the vendor so that
  99. * callers get the same behaviour as the default handler once the link
  100. * has been resolved.
  101. *
  102. * @param Builder<Model> $builder
  103. */
  104. private function buildQuery(Builder $builder, Link $link, mixed $identifier): Builder
  105. {
  106. if ($to = $link->getToProperty()) {
  107. return $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier);
  108. }
  109. if ($from = $link->getFromProperty()) {
  110. /** @var Model $relatedInstance */
  111. $relatedInstance = $this->application->make($link->getFromClass());
  112. $identifierField = $link->getIdentifiers()[0];
  113. if ($identifierField !== $relatedInstance->getKeyName()) {
  114. $relatedInstance = $relatedInstance
  115. ->newQuery()
  116. ->where($identifierField, $identifier)
  117. ->first();
  118. } else {
  119. $relatedInstance->setAttribute($identifierField, $identifier);
  120. $relatedInstance->exists = true;
  121. }
  122. if (! $relatedInstance) {
  123. throw new NotFoundHttpException('Not Found');
  124. }
  125. // Resolve the actual method name on the related model. The Link's
  126. // $fromProperty is typically snake_case (Eloquent convention) but
  127. // GraphQL fieldnames (and therefore user-facing names) are
  128. // camelCase. Try both.
  129. $candidates = array_unique([$from, Str::camel($from)]);
  130. $relation = null;
  131. foreach ($candidates as $candidate) {
  132. if (method_exists($relatedInstance, $candidate)) {
  133. $relation = $relatedInstance->{$candidate}();
  134. if ($relation instanceof Relation) {
  135. break;
  136. }
  137. $relation = null;
  138. }
  139. }
  140. if (! $relation instanceof Relation) {
  141. return $builder;
  142. }
  143. if ($relation instanceof MorphTo) {
  144. return $builder;
  145. }
  146. if ($relation instanceof BelongsTo) {
  147. return $builder->getModel()
  148. ->join(
  149. $relation->getParent()->getTable(),
  150. $relation->getParent()->getQualifiedKeyName(),
  151. $identifier
  152. );
  153. }
  154. if ($relation instanceof HasOneOrMany || $relation instanceof BelongsToMany) {
  155. return $relation->getQuery();
  156. }
  157. if (method_exists($relation, 'getQualifiedForeignKeyName')) {
  158. return $relation->getQuery()->where(
  159. $relation->getQualifiedForeignKeyName(),
  160. $identifier
  161. );
  162. }
  163. return $builder;
  164. }
  165. return $builder->where(
  166. $builder->getModel()->qualifyColumn($link->getIdentifiers()[0]),
  167. $identifier
  168. );
  169. }
  170. }