Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Lift the asObject(Entity) model-casts limitation with a cast-aware entity type (working prototype) #46

@paulbalandan

Description

@paulbalandan

Context

The v2.0.1 "known limitation" (docs/upgrading.md):

An asObject(SomeEntity::class) override uses the casts of the entity's own model. An entity's properties are typed from the $casts of the model whose $returnType is that entity. Fetching the same entity through a different model via asObject() or asArray() does not pick up that model's $casts.

Concretely, given a model with $casts = ['email' => 'json-array'] and $returnType = 'array':

$user = $model->asObject(User::class)->find($id); // User|null
$user->email; // runtime: array (model DataConverter casts before hydration)
              // PHPStan: string (raw VARCHAR column; model $casts ignored)

This produces false-positive offsetAssign.dimType / offset-access errors on legitimately-array values. I prototyped a fix to check whether the limitation is fundamental or liftable. It is liftable.

Why the cheap approaches do not work

  1. Returning an intersection (ObjectType(User) & object{email: array}) collapses to *NEVER*. The EntityPropertiesClassReflectionExtension still fires on the User member and contributes string, which PHPStan intersects with the shape's array to never. Verified with a @var User & object{email: list<string>} probe (dumpType($x->email) => *NEVER*).
  2. The property extension cannot see the producing model. EntityPropertiesClassReflectionExtension only receives the entity ClassReflection, with no call-site context, so it cannot know $this->model was the model carrying the cast. That is exactly why v2.0.1 falls back to the entity's own model.

What works

At the asObject(User::class)->find() call site the producing model is statically known to ModelFetchedReturnTypeHelper (it is the $classReflection argument). The fix is to emit a custom ObjectType subclass that overrides the producing model's cast columns at the type level, delegating everything else to the normal entity reflection. Because the override happens on the type instance (not via an intersection), there is no second contribution to intersect with, so the *NEVER* problem disappears.

Key detail: PHPStan 2.x resolves instance property fetches through getUnresolvedInstancePropertyPrototype() (via Scope::getInstancePropertyReflection()), not getUnresolvedPropertyPrototype(). Overriding only the latter has no effect on $x->prop. The transform is done with CallbackUnresolvedPropertyPrototypeReflection, whose callback replaces the readable/writable/native/phpDoc types wholesale.

New type

<?php

declare(strict_types=1);

namespace CodeIgniter\PHPStan\Type;

use PHPStan\Reflection\ClassMemberAccessAnswerer;
use PHPStan\Reflection\Type\CallbackUnresolvedPropertyPrototypeReflection;
use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

final class ModelCastEntityType extends ObjectType
{
    /** @param array<string, Type> $castedProperties Column/property name => cast-resolved type */
    public function __construct(string $className, private readonly array $castedProperties)
    {
        parent::__construct($className);
    }

    public function getUnresolvedInstancePropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection
    {
        return $this->withCastOverride($propertyName, parent::getUnresolvedInstancePropertyPrototype($propertyName, $scope));
    }

    public function getUnresolvedPropertyPrototype(string $propertyName, ClassMemberAccessAnswerer $scope): UnresolvedPropertyPrototypeReflection
    {
        return $this->withCastOverride($propertyName, parent::getUnresolvedPropertyPrototype($propertyName, $scope));
    }

    private function withCastOverride(string $propertyName, UnresolvedPropertyPrototypeReflection $prototype): UnresolvedPropertyPrototypeReflection
    {
        if (! isset($this->castedProperties[$propertyName])) {
            return $prototype;
        }

        $naked        = $prototype->getNakedProperty();
        $overrideType = $this->castedProperties[$propertyName];

        return new CallbackUnresolvedPropertyPrototypeReflection(
            $naked,
            $naked->getDeclaringClass(),
            false,
            static fn (Type $type): Type => $overrideType,
        );
    }
}

ModelFetchedReturnTypeHelper change

Replace the entity branch of getFetchedReturnType():

// before
if ($this->reflectionProvider->hasClass($returnType)) {
    return new ObjectType($returnType);
}

// after
if ($this->reflectionProvider->hasClass($returnType)) {
    return $this->entityTypeWithModelCasts($classReflection, $returnType);
}
private function entityTypeWithModelCasts(ClassReflection $modelReflection, string $entityClass): Type
{
    $modelCasts = $this->readStringMap($modelReflection, 'casts');

    if ($modelCasts === []) {
        return new ObjectType($entityClass);
    }

    $modelCastHandlers = $this->readStringMap($modelReflection, 'castHandlers');
    $entityCasts       = $this->readStringMap($this->reflectionProvider->getClass($entityClass), 'casts');

    $tableName = $modelReflection->getNativeReflection()->getDefaultProperties()['table'] ?? null;
    $table     = is_string($tableName) && $tableName !== '' ? $this->schemaProvider->get()->getTable($tableName) : null;

    $overrides = [];

    foreach ($modelCasts as $column => $cast) {
        if (isset($entityCasts[$column])) {
            continue; // entity's own cast wins; it is applied last in __get()
        }

        $schemaColumn       = $table?->getColumn($column);
        $overrides[$column] = $this->castFieldTypeResolver->resolve(
            $cast,
            $modelCastHandlers,
            $schemaColumn !== null && ! $schemaColumn->nullable,
        );
    }

    if ($overrides === []) {
        return new ObjectType($entityClass);
    }

    return new ModelCastEntityType($entityClass, $overrides);
}

Verification (against v2.0.1, the CI4 framework test suite)

Model UserCastsTimestampModel ($casts['email'] = 'json-array'), entity User (no own casts), via asObject(User::class):

Access Before After
$user->email (find($id)) string array
$all[0]->email (findAll()) string array
$first?->email (first()) string array|null
  • The corresponding offsetAssign.dimType false positive is gone.
  • Result cache round-trips: a second analysis run on the warm cache still resolves array, with no serialization/exception errors.
  • No regressions elsewhere in the suite (whole-codebase run unchanged apart from the fixed site).

Caveats / notes for a real implementation

  • /** @var Entity $x */ annotations override the inferred type with a plain ObjectType, discarding the cast info. This is correct @var behavior. Such annotations (often added precisely because inference was wrong) become unnecessary once this lands. Worth a doc note.
  • Cache-key collision is benign. A ModelCastEntityType and a plain ObjectType(User) share the prototype cache key (describeCache() for a subclass falls back to describe(cache())). The override never writes the wrapped prototype into the shared static cache (it only borrows the naked property), so plain User types are unaffected. A real impl may still prefer a distinct describe() for clarity in error messages.
  • $datamap (property name vs column name) is not handled in the prototype; the entity reflection already maps it and a real fix should too.
  • Nullability is honored via the existing CastFieldTypeResolver + column nullable.
  • The producing model must be statically resolvable; a dynamic/unknown model falls back to today's behavior.

Related

Separately, with the array return type now correctly typed, passing a cast row back into Model::update() / save() reports argument.type because their $row value-type union omits array. That looks like a CI4 framework annotation gap rather than an extension issue.

Happy to open a PR with the cleaned-up version (datamap handling, distinct describe(), tests for find/findAll/first/asObject) if the approach looks good.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions