api-platform / api-platform/core
ObjectMapper: PersistProcessor::handleLazyObjectRelations() breaks cascade persist of new relations with app-assigned ids (UUID in constructor)
- Langage dominant
- PHP
- Étoiles
- 2.6k
- Forks
- 980
- Merge moyen
- 2 j 4 h
- PR mergées (30 j)
- 49
Description
**API Platform version(s) affected**: 4.3.5 (also present on `4.3` and `main` branches)
**Description**
When using the ObjectMapper (`stateOptions` + input DTO) on a `Post` operation, creating a **new** nested related entity fails with a foreign key constraint violation when the related entity uses an **application-assigned identifier** (e.g. a UUID generated in the constructor).
`PersistProcessor::handleLazyObjectRelations()` runs for every mapped operation (`$operation->canMap()`) and replaces any relation value that is *not managed* but *has non-null identifiers* with `$manager->getReference($class, $identifiers)`:
```php
// Do not get reference for partial objects or objects with null identifiers
if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn ($v) => null !== $v))) {
continue;
}
$reflectionProperty->setValue($data, $relManager->getReference($relClass, $identifiers));
```
This heuristic assumes "has an id" means "row exists in the database". That holds for `#[ORM\GeneratedValue]` entities (id is null pre-flush, so the swap is skipped and `cascade: ['persist']` works — this is the case covered by the `Issue7689` fixtures where `Issue7689Category` has a nullable auto-increment id). But with app-assigned ids (UUIDv4/v7 assigned in the constructor, a common pattern), a freshly mapped nested entity is indistinguishable from a reference to an existing one:
1. The mapper creates a new related entity; its constructor assigns a UUID.
2. `handleLazyObjectRelations()` sees an unmanaged object with a non-null identifier and swaps it for a `getReference()` proxy.
3. The proxy counts as managed, so `cascade: ['persist']` on the owning side never persists it.
4. Flush inserts the owning entity with an FK pointing to a nonexistent row:
```
SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row:
a foreign key constraint fails (FOREIGN KEY ("condition_id") REFERENCES "..." ("id"))
```
**How to reproduce**
Entities (owning side cascades persist to the relation; both use constructor-assigned UUIDs):
```php
#[ORM\Entity]
class TaskRule
{
public function __construct(
#[ORM\ManyToOne(targetEntity: TaskRuleCondition::class, cascade: ['persist'])]
#[ORM\JoinColumn(nullable: true)]
public ?TaskRuleCondition $condition = null,
#[ORM\Id, ORM\Column]
private(set) UuidV7 $id = new UuidV7(),
) {
}
}
#[ORM\Entity]
class TaskRuleCondition
{
public function __construct(
#[ORM\Column]
public string $completeWhen,
#[ORM\Id, ORM\Column]
private(set) UuidV7 $id = new UuidV7(),
) {
}
}
```
Input DTOs mapped with the ObjectMapper:
```php
#[Map(target: TaskRule::class)]
final readonly class TaskRulePostInput
{
public function __construct(
public ?TaskRuleConditionInput $condition = null,
) {
}
}
```
`POST` with a nested `condition` payload -> FK violation on flush. Persisting the owning entity manually in a custom processor *before* `PersistProcessor` runs works around it, because the cascade marks the relation managed and `handleLazyObjectRelations()` then skips it via the `contains()` check.
**Possible Solution**
Only swap values that are genuinely placeholders for existing rows, instead of assuming any unmanaged object with an id is one:
- only replace actual uninitialized lazy objects (`ReflectionClass::isUninitializedLazyObject()` on PHP >= 8.4), and/or
- consult `UnitOfWork::getEntityState($value)` — `STATE_NEW` objects should be left alone so cascade persist can handle them.
**Additional Context**
- Introduced/extended in cc2f8855 ("fix(doctrine): post with mapped relation", #7689) — the accompanying test fixtures only cover DB-generated ids, where new entities still have null ids pre-flush.
- Related but distinct: #7735/#7738 fixed uninitialized-property crashes in the same method; #7801 concerns nested mapping on the output side.
Guide de contribution
Ouvrir le guide de contribution
Piste de recherche
Start by locating PersistProcessor::handleLazyObjectRelations() and the existing Issue7689 fixtures/tests mentioned in the issue. Reproduce a mapped POST with a nested relation whose UUID is assigned in the constructor. Done means new unmanaged related entities with non-null ids are left for cascade persist, while references to existing rows still behave correctly.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- php, symfony
- Domaine
- api, backend, database
- Type d'issue
- Bug
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Activité
- Calme
- Clarté
- Clairement spécifiée
- Accessibilité débutants
- 55/100