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.
Tempest version
3.18
PHP version
8.5
Operating system
Linux
Description
When selecting a model with an eager nullable single relation (
HasOne,BelongsTo, orHasOneThrough) whose related model also has eager nullable relations, Tempest may try to instantiate the missing parent relation instead of resolving it tonull.This fails when the missing relation has a
PrimaryKeyproperty, becauseArrayToObjectMappereventually callsPrimaryKeyCaster::cast(null):Example Model Shape
When a
Userexists without a relatedProfile, the generated left joins produce a row where allprofile.*,profile.createdBy.*, andprofile.updatedBy.*columns arenull.Actual Behavior
The normalized data keeps
profileas an array instead of collapsing it tonull:[ 'id' => 1, 'profile' => [ 'id' => null, // other profile fields are null... 'createdBy' => null, 'updatedBy' => null, ], ]Then
ArrayToObjectMappertries to mapprofiletoProfile, and theProfile::$idcast fails because the value isnull.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():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 becomenull, but the parent relation is not checked again and remains an array withid => null.