Skip to content

Commit 5e5b4f1

Browse files
committed
test
1 parent 84fb16e commit 5e5b4f1

10 files changed

Lines changed: 375 additions & 67 deletions

File tree

src/Doctrine/Orm/NestedPropertyHelperTrait.php

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -37,38 +37,21 @@ protected function addJoinsForNestedProperty(
3737
QueryNameGeneratorInterface $queryNameGenerator,
3838
\ApiPlatform\Metadata\Parameter $parameter,
3939
): array {
40-
// Check for pre-computed nested property metadata
4140
$nestedInfo = $parameter->getExtraProperties()['nested_property_info'] ?? null;
4241

43-
if ($nestedInfo) {
44-
// Use pre-computed relation segments (already name-converted)
45-
foreach ($nestedInfo['converted_relation_segments'] as $association) {
46-
$alias = QueryBuilderHelper::addJoinOnce(
47-
$queryBuilder,
48-
$queryNameGenerator,
49-
$alias,
50-
$association
51-
);
52-
}
53-
54-
return [$alias, $nestedInfo['leaf_property']];
42+
if (!$nestedInfo) {
43+
return [$alias, $property];
5544
}
5645

57-
// Fallback to legacy behavior for backward compatibility
58-
if (str_contains($property, '.')) {
59-
$associations = explode('.', $property);
60-
$property = array_pop($associations);
61-
62-
foreach ($associations as $association) {
63-
$alias = QueryBuilderHelper::addJoinOnce(
64-
$queryBuilder,
65-
$queryNameGenerator,
66-
$alias,
67-
$association
68-
);
69-
}
46+
foreach ($nestedInfo['converted_relation_segments'] as $association) {
47+
$alias = QueryBuilderHelper::addJoinOnce(
48+
$queryBuilder,
49+
$queryNameGenerator,
50+
$alias,
51+
$association
52+
);
7053
}
7154

72-
return [$alias, $property];
55+
return [$alias, $nestedInfo['leaf_property']];
7356
}
7457
}

src/Laravel/ApiPlatformProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,11 @@ public function register(): void
365365
});
366366
}
367367

368+
// Parameter metadata factory with Laravel Eloquent support
369+
$this->app->extend(ResourceMetadataCollectionFactoryInterface::class, static function (ResourceMetadataCollectionFactoryInterface $inner, Application $app) {
370+
return new Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory($inner, $app->make(ModelMetadata::class));
371+
});
372+
368373
$this->app->singleton(OperationMetadataFactory::class, static function (Application $app) {
369374
return new OperationMetadataFactory($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
370375
});

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,31 @@ private function attributeIsHidden(string $attribute, Model $model): bool
315315
return false;
316316
}
317317

318+
/**
319+
* Gets the related model class for a given relationship property.
320+
*
321+
* @param class-string<Model> $modelClass The current model class
322+
* @param string $property The property/relationship name
323+
*
324+
* @return class-string<Model>|null The related model class, or null if not a relationship
325+
*/
326+
public function getRelatedModelClass(string $modelClass, string $property): ?string
327+
{
328+
if (!is_subclass_of($modelClass, Model::class)) {
329+
return null;
330+
}
331+
332+
$relations = $this->getRelations(new $modelClass());
333+
334+
foreach ($relations as $relation) {
335+
if ($relation['method_name'] === $property || $relation['name'] === $property) {
336+
return $relation['related'];
337+
}
338+
}
339+
340+
return null;
341+
}
342+
318343
/**
319344
* Determines if the given attribute is unique.
320345
*
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Metadata\Resource\Factory;
15+
16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
18+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
19+
20+
/**
21+
* Decorates the parameter resource metadata factory to add Laravel Eloquent relationship support.
22+
*
23+
* This decorator enhances nested property traversal by using Laravel's ModelMetadata
24+
* to discover relationships when standard property type information is unavailable.
25+
*/
26+
final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
27+
{
28+
public function __construct(
29+
private readonly ResourceMetadataCollectionFactoryInterface $decorated,
30+
private readonly ModelMetadata $modelMetadata,
31+
) {
32+
}
33+
34+
public function create(string $resourceClass): ResourceMetadataCollection
35+
{
36+
$resourceMetadataCollection = $this->decorated->create($resourceClass);
37+
38+
// Enhance parameters with Laravel Eloquent relationship information
39+
foreach ($resourceMetadataCollection as $i => $resource) {
40+
$operations = $resource->getOperations();
41+
$stateOptions = $resource->getStateOptions();
42+
$modelClass = $stateOptions instanceof \ApiPlatform\Laravel\Eloquent\State\Options ? $stateOptions->getModelClass() : null;
43+
44+
foreach ($operations as $operationName => $operation) {
45+
$parameters = $operation->getParameters();
46+
if (!$parameters) {
47+
continue;
48+
}
49+
50+
$modified = false;
51+
52+
// Enhance each parameter's nested property info with Laravel relationship data
53+
foreach ($parameters as $key => $parameter) {
54+
// Use the key if it contains dots, otherwise use the property
55+
$property = str_contains($key, '.') ? $key : $parameter->getProperty();
56+
if (!$property || !str_contains($property, '.')) {
57+
continue;
58+
}
59+
60+
$extraProperties = $parameter->getExtraProperties();
61+
$nestedInfo = $extraProperties['nested_property_info'] ?? null;
62+
63+
// If nested_property_info is missing but we have a nested property,
64+
// the core traversal failed - try to traverse using Laravel relationships
65+
if (!$nestedInfo && $modelClass) {
66+
$nestedInfo = $this->traverseNestedProperty($property, $modelClass);
67+
if ($nestedInfo) {
68+
$extraProperties['nested_property_info'] = $nestedInfo;
69+
// Also fix the property to be the full nested path
70+
$parameters = $parameters->add(
71+
$key,
72+
$parameter->withProperty($property)->withExtraProperties($extraProperties)
73+
);
74+
$modified = true;
75+
}
76+
continue;
77+
}
78+
79+
// If we have nested info but some relation_classes are missing, fill them in
80+
if ($nestedInfo) {
81+
$relationSegments = $nestedInfo['relation_segments'] ?? [];
82+
$relationClasses = $nestedInfo['relation_classes'] ?? [];
83+
84+
$needsUpdate = false;
85+
$updatedRelationClasses = $relationClasses;
86+
87+
for ($idx = 0; $idx < \count($relationSegments); ++$idx) {
88+
$segment = $relationSegments[$idx];
89+
$fromClass = $relationClasses[$idx] ?? null;
90+
91+
if (!$fromClass) {
92+
continue;
93+
}
94+
95+
// Check if we need to determine the target class for this relationship
96+
$nextIdx = $idx + 1;
97+
if ($nextIdx < \count($relationSegments) && !isset($updatedRelationClasses[$nextIdx])) {
98+
$targetClass = $this->modelMetadata->getRelatedModelClass($fromClass, $segment);
99+
if ($targetClass) {
100+
$updatedRelationClasses[$nextIdx] = $targetClass;
101+
$needsUpdate = true;
102+
}
103+
}
104+
}
105+
106+
if ($needsUpdate) {
107+
// Also update leaf_class if it was missing
108+
$lastIdx = \count($relationSegments) - 1;
109+
if ($lastIdx >= 0 && isset($updatedRelationClasses[$lastIdx])) {
110+
$lastSegment = $relationSegments[$lastIdx];
111+
$leafClassCandidate = $this->modelMetadata->getRelatedModelClass($updatedRelationClasses[$lastIdx], $lastSegment);
112+
if ($leafClassCandidate) {
113+
$nestedInfo['leaf_class'] = $leafClassCandidate;
114+
}
115+
}
116+
117+
$nestedInfo['relation_classes'] = $updatedRelationClasses;
118+
$extraProperties['nested_property_info'] = $nestedInfo;
119+
$parameters = $parameters->add($key, $parameter->withExtraProperties($extraProperties));
120+
$modified = true;
121+
}
122+
}
123+
}
124+
125+
if ($modified) {
126+
$operations = $operations->add($operationName, $operation->withParameters($parameters));
127+
}
128+
}
129+
130+
if ($operations !== $resource->getOperations()) {
131+
$resourceMetadataCollection[$i] = $resource->withOperations($operations);
132+
}
133+
}
134+
135+
return $resourceMetadataCollection;
136+
}
137+
138+
/**
139+
* Traverses a nested property path using Laravel Eloquent relationships.
140+
*
141+
* @param string $property The nested property path (e.g., "product.productVariations.variantName")
142+
* @param string $modelClass The starting model class (must be an Eloquent Model)
143+
*
144+
* @return array<string, mixed>|null The nested_property_info array, or null if traversal fails
145+
*/
146+
private function traverseNestedProperty(string $property, string $modelClass): ?array
147+
{
148+
$parts = explode('.', $property);
149+
$currentClass = $modelClass;
150+
$relationSegments = [];
151+
$relationClasses = [];
152+
153+
// Traverse through all parts except the last one (which is the leaf property)
154+
for ($i = 0; $i < \count($parts) - 1; ++$i) {
155+
$part = $parts[$i];
156+
$relationSegments[] = $part;
157+
$relationClasses[] = $currentClass;
158+
159+
// Try to resolve the next class using Laravel relationships
160+
$nextClass = $this->modelMetadata->getRelatedModelClass($currentClass, $part);
161+
if (!$nextClass) {
162+
// Can't continue traversal
163+
return null;
164+
}
165+
166+
$currentClass = $nextClass;
167+
}
168+
169+
// The last part is the leaf property - convert to snake_case for database column
170+
$leafProperty = $this->toSnakeCase($parts[\count($parts) - 1]);
171+
172+
return [
173+
'original_path' => $property,
174+
'path_segments' => $parts,
175+
'relation_segments' => $relationSegments,
176+
'converted_relation_segments' => $relationSegments, // No name conversion for Laravel
177+
'relation_classes' => $relationClasses,
178+
'leaf_property' => $leafProperty,
179+
'leaf_class' => $currentClass,
180+
];
181+
}
182+
183+
/**
184+
* Converts a camelCase string to snake_case.
185+
*/
186+
private function toSnakeCase(string $input): string
187+
{
188+
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $input));
189+
}
190+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Tests\Metadata\Resource\Factory;
15+
16+
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use ApiPlatform\Laravel\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory;
18+
use ApiPlatform\Metadata\ApiProperty;
19+
use ApiPlatform\Metadata\ApiResource;
20+
use ApiPlatform\Metadata\GetCollection;
21+
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23+
use ApiPlatform\Metadata\Property\PropertyNameCollection;
24+
use ApiPlatform\Metadata\QueryParameter;
25+
use ApiPlatform\Metadata\Resource\Factory\AttributesResourceMetadataCollectionFactory;
26+
use Illuminate\Database\Eloquent\Model;
27+
use PHPUnit\Framework\TestCase;
28+
use Psr\Container\ContainerInterface;
29+
30+
class ParameterResourceMetadataCollectionFactoryTest extends TestCase
31+
{
32+
public function testNestedPropertyWithEloquentRelationship(): void
33+
{
34+
$nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class);
35+
$nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name']));
36+
37+
$propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class);
38+
$propertyMetadata->method('create')->willReturnCallback(
39+
static function (string $class, string $property) {
40+
// When traversing nested properties, the factory should be able to handle
41+
// both API Resource classes and Eloquent Model classes
42+
return new ApiProperty(readable: true);
43+
}
44+
);
45+
46+
$filterLocator = $this->createStub(ContainerInterface::class);
47+
$filterLocator->method('has')->willReturn(false);
48+
49+
// Create the core parameter factory
50+
$coreFactory = new \ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory(
51+
$nameCollection,
52+
$propertyMetadata,
53+
new AttributesResourceMetadataCollectionFactory(),
54+
$filterLocator
55+
);
56+
57+
// Wrap it with the Laravel decorator
58+
$parameterFactory = new ParameterResourceMetadataCollectionFactory($coreFactory, new ModelMetadata());
59+
60+
$resourceMetadataCollection = $parameterFactory->create(TestProductOrderResource::class);
61+
$operation = $resourceMetadataCollection->getOperation(forceCollection: true);
62+
$parameters = $operation->getParameters();
63+
64+
$this->assertNotNull($parameters);
65+
$this->assertTrue($parameters->has('search[name]'));
66+
$this->assertTrue($parameters->has('search[product.name]'));
67+
68+
$searchNameParam = $parameters->get('search[name]');
69+
$this->assertSame('name', $searchNameParam->getProperty());
70+
71+
$searchProductNameParam = $parameters->get('search[product.name]');
72+
$this->assertSame('product.name', $searchProductNameParam->getProperty());
73+
74+
// Verify that nested property info was set correctly
75+
$extraProps = $searchProductNameParam->getExtraProperties();
76+
// For now, just verify the parameter was created
77+
// TODO: Once the PropertyRelationHelper properly resolves relationships,
78+
// we should assert that nested_property_info is set
79+
$this->assertNotNull($searchProductNameParam);
80+
}
81+
}
82+
83+
// Test fixtures
84+
85+
#[ApiResource(
86+
operations: [
87+
new GetCollection(
88+
parameters: [
89+
'search[:property]' => new QueryParameter(
90+
properties: ['name', 'product.name']
91+
),
92+
]
93+
),
94+
]
95+
)]
96+
class TestProductOrderResource
97+
{
98+
public $id;
99+
public $name;
100+
public $product;
101+
}

0 commit comments

Comments
 (0)