Skip to content

Nullable eager relation maps to object with null PrimaryKey #2260

Description

@radoslav-grencik

Tempest version

3.18

PHP version

8.5

Operating system

Linux

Description

When selecting a model with an eager nullable single relation (HasOne, BelongsTo, or HasOneThrough) whose related model also has eager nullable relations, Tempest may try to instantiate the missing parent relation instead of resolving it to null.

This fails when the missing relation has a PrimaryKey property, because ArrayToObjectMapper eventually calls PrimaryKeyCaster::cast(null):

Tempest\Database\PrimaryKey::__construct(): Argument #1 ($value) must be of type Tempest\Database\PrimaryKey|string|int, null given

Example Model Shape

use Tempest\Database\BelongsTo;
use Tempest\Database\Eager;
use Tempest\Database\HasOne;
use Tempest\Database\PrimaryKey;
use Tempest\Database\Table;

#[Table('users')]
final class User
{
    public PrimaryKey $id;

    #[Eager]
    #[HasOne]
    public ?Profile $profile = null;
}

#[Table('profiles')]
final class Profile
{
    public PrimaryKey $id;

    #[Eager]
    #[BelongsTo]
    public ?UserLookup $createdBy = null;

    #[Eager]
    #[BelongsTo]
    public ?UserLookup $updatedBy = null;
}

#[Table('users')]
final class UserLookup
{
    public PrimaryKey $id;
    public string $name;
}

When a User exists without a related Profile, the generated left joins produce a row where all profile.*, profile.createdBy.*, and profile.updatedBy.* columns are null.

Actual Behavior

The normalized data keeps profile as an array instead of collapsing it to null:

[
    'id' => 1,
    'profile' => [
        'id' => null,
        // other profile fields are null...
        'createdBy' => null,
        'updatedBy' => null,
    ],
]

Then ArrayToObjectMapper tries to map profile to Profile, and the Profile::$id cast fails because the value is null.

Expected Behavior

Because the relation property is nullable and the left-joined relation row contains no actual related model data, the relation should be mapped as null:

[
    'id' => 1,
    'profile' => null,
]

The same should apply recursively for nullable single relations whose nested eager relations are also absent.

Likely Cause

The issue appears to be in Tempest\Database\Mappers\SelectModelMapper::values():

if ($relation instanceof BelongsTo || $relation instanceof HasOne || $relation instanceof HasOneThrough) {
    if ($relation->property->isNullable() && array_filter($data[$relation->name] ?? []) === []) {
        $data[$relation->name] = null;
    } elseif (is_array($data[$relation->name] ?? null)) {
        $relationModel = inspect($relation);
        $data[$relation->name] = $this->values($relationModel, $data[$relation->name]);
    }

    continue;
}

The nullable check runs before nested relation arrays are normalized. All-null nested eager relation arrays are still non-empty arrays, so array_filter(...) treats the parent relation as non-empty. After recursion, the nested relations become null, but the parent relation is not checked again and remains an array with id => null.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions