【PHPフレームワークFlow】RepositoryのマジックメソッドをPHPStanに認識させる

# 初めに
FlowのRepositoryには、DBへのシンプルな問い合わせを定義するマジックメソッドが用意されています。これにより、簡単な問い合わせでメソッドを作成することなくDBアクセスができます。しかし、PHPStanではデフォルトでマジックメソッドを認識してくれません。
今回はFlowのRepositoryクラスのマジックメソッドをPHPStanに認識させる方法を紹介します。

# Flowのマジックメソッド
FlowのRepositoryクラスにはDBからデータを取得するためのマジックメソッドがいくつか存在します。
* **findBy～**()
* **findOneBy～** ()
* **countBy～**()

※～の部分にはドメインモデルのメンバ変数が入ります

## findBy～
条件に合うエンティティを配列で取得するマジックメソッドです。
例えば、`$name`が`test`という値のエンティティを取得したい場合、`findByName('test')`のように書きます。

```php: findByの例
/**
 * 例：メンバ変数$nameを持つHogeクラスがあるとする
 */
class Hoge
{
    /**
     * @ORM\Column(type="string")
     * @var string
     */
    protected $name;
}

// 取得したいドメインモデルに対応するRepositoryを用いる
$hogeRepository = new HogeRepository()
// Hogeのメンバ変数$nameが引数と等しいエンティティを全て取得する
$resultArray = $hogeRepository->findByName('test');
```

## findOneBy～
findBy～の取得結果が1つだけバージョンです。条件に当てはまるエンティティが複数ある場合、一番最初のエンティティを取得します。

```php: findOneByの例
/**
 * 例：メンバ変数$nameを持つHogeクラスがあるとする
 */
class Hoge
{
    /**
     * @ORM\Column(type="string")
     * @var string
     */
    protected $name;
}

// 取得したいドメインモデルに対応するRepositoryを用いる
$hogeRepository = new HogeRepository()
// Hogeのメンバ変数$nameが引数と等しいエンティティを一つだけ取得する
$result = $hogeRepository->findOneByName('test');
```
## countBy～
前二つと同様の使い方で、条件に合う数を取得するマジックメソッドです。

```php: countByの例
/**
 * 例：メンバ変数$nameを持つHogeクラスがあるとする
 */
class Hoge
{
    /**
     * @ORM\Column(type="string")
     * @var string
     */
    protected $name;
}

// 取得したいドメインモデルに対応するRepositoryを用いる
$hogeRepository = new HogeRepository()
// Hogeのメンバ変数$nameが引数と等しいエンティティの数を取得する
$resultCount = $hogeRepository->countBy('test');
```


# PHPStanとマジックメソッド
これらのマジックメソッドは非常に便利です。
しかし、PHPStanはマジックメソッドを認識してくれません。`Call to an undefined method`というエラーになります。

```php: 解析対象
$result = $this->functionalRepository->findOneById('test');
```

```sh: 解析結果
> ./bin/phpstan analyse .\Packages\Application\Neos.Welcome\Classes\Controller\FunctionalTestController.php 
Note: Using configuration file C:\Path\To\Project\phpstan.neon.
 1/1 [============================] 100%

 ------ ------------------------------------------------------------------------------------------------- 
  Line   Packages/Application/Neos.Welcome/Classes/Controller/FunctionalTestController.php
 ------ ---------------------------------------------------------------------------------------
  :62    Call to an undefined method Neos\Welcome\Domain\Repository\FunctionalRepository::findOneById().
 ------ ---------------------------------------------------------------------------------------
```

# PHPStanにマジックメソッドを認識させる
ということでマジックメソッドを認識させましょう。
今回は`MethodsClassReflectionExtension`という機能を用いてみます。
また、FlowはDoctrineを内包しているということもあるので、「PHPStanのdoctrineExtensionも一部利用します。

https://github.com/phpstan/phpstan-doctrine

## MethodsClassReflectionExtension
`MethodsClassReflectionExtension`はメソッドを認識させるための拡張機能です。

https://phpstan.org/developing-extensions/class-reflection-extensions#methods-class-reflection-extensions

`hasMethod`にはPHPStanに認識させたいメソッドを定義します。
`getMethod`には`hasMethod`で認識させたメソッドの定義（引数や戻り値など）を定義します。返却値の`MethodReflection`がメソッドの定義を実装するためのInterfaceになります。

```php: MethodsClassReflectionExtension
namespace PHPStan\Reflection;

interface MethodsClassReflectionExtension
{

	public function hasMethod(ClassReflection $classReflection, string $methodName): bool;

	public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection;

}
```

```php: MethodReflection
namespace PHPStan\Reflection;

use PHPStan\TrinaryLogic;
use PHPStan\Type\Type;

interface MethodReflection
{

	public function getDeclaringClass(): ClassReflection;

	public function isStatic(): bool;

	public function isPrivate(): bool;

	public function isPublic(): bool;

	public function getDocComment(): ?string;

	public function getName(): string;

	public function getPrototype(): ClassMemberReflection;

	/**
	 * @return \PHPStan\Reflection\ParametersAcceptor[]
	 */
	public function getVariants(): array;

	public function isDeprecated(): TrinaryLogic;

	public function getDeprecatedDescription(): ?string;

	public function isFinal(): TrinaryLogic;

	public function isInternal(): TrinaryLogic;

	public function getThrowType(): ?Type;

	public function hasSideEffects(): TrinaryLogic;

}
```

## Repositoryのマジックメソッドを読み込ませる
というわけで、拡張機能を作成してみました。

```php
<?php declare(strict_types = 1);

namespace Neos\Welcome\PHPStan\Reflection;

use Neos\Flow\Persistence\Repository;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\MethodsClassReflectionExtension;
use PHPStan\Reflection\Doctrine\MagicRepositoryMethodReflection;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\ArrayType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\NullType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\TypeCombinator;
use function lcfirst;
use function str_replace;
use function strlen;
use function strpos;
use function substr;
use function ucwords;

class RepositoriesMagicMethodClassReflectionExtension implements MethodsClassReflectionExtension
{
	public function hasMethod(ClassReflection $classReflection, string $methodName): bool
	{
        // メソッド名をマジックメソッドに限定
		if (
			strpos($methodName, 'findBy') === 0
			&& strlen($methodName) > strlen('findBy')
		) {
			$methodFieldName = substr($methodName, strlen('findBy'));
		} elseif (
			strpos($methodName, 'findOneBy') === 0
			&& strlen($methodName) > strlen('findOneBy')
		) {
			$methodFieldName = substr($methodName, strlen('findOneBy'));
		} elseif (
			strpos($methodName, 'countBy') === 0
			&& strlen($methodName) > strlen('countBy')
		) {
			$methodFieldName = substr($methodName, strlen('countBy'));
		} else {
			return false;
		} 

        // Repositoryクラスを継承したクラスに限定
		$repositoryAncesor = $classReflection->getAncestorWithClassName(Repository::class);
		if ($repositoryAncesor === null) {
			return false;
		}

        // Repository配下のクラスに限定
        $repositoryClassName = $classReflection->getName();
		if (!str_starts_with($repositoryClassName, 'Neos\\Welcome\\Domain\\Repository')) {
			return false;
		}

		$fieldName = $this->classify($methodFieldName);
        $repositoryClassName = $classReflection->getName();

        // メソッド名のサフィックスがメンバ変数かどうか判定
		$withoutRepository = preg_replace('/Repository$/', '', $repositoryClassName);
		$domainModelClassName = str_replace('Repository', 'Model', $withoutRepository);
		$reflection = new \ReflectionClass($domainModelClassName);
		$properties = $reflection->getProperties();
		foreach ($properties as $property) {
			if ($property->getName() === $fieldName) {
				return true;
			};
		}

		return false;
	}

	private function classify(string $word): string
	{
		return lcfirst(str_replace([' ', '_', '-'], '', ucwords($word, ' _-')));
	}

	public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
	{
		$repositoryAncesor = $classReflection->getAncestorWithClassName(Repository::class);
		if ($repositoryAncesor === null) {
			$repositoryAncesor = $classReflection->getAncestorWithClassName(Repository::class);
			if ($repositoryAncesor === null) {
				throw new ShouldNotHappenException();
			}
		}

        $repositoryClassName = $classReflection->getName();
		if ($repositoryClassName !== \Neos\Flow\Persistence\Repository::class) {
			$withoutRepository = preg_replace('/Repository$/', '', $repositoryClassName);
			$domainModelClassName = str_replace('Repository', 'Model', $withoutRepository);
		}
        $domainModelClassType = new ObjectType($domainModelClassName ?? $repositoryClassName);
		if ($domainModelClassType === null) {
			throw new ShouldNotHappenException();
		}

		if (
			strpos($methodName, 'findBy') === 0
		) {
			$returnType = new ArrayType(new IntegerType(), $domainModelClassType);
		} elseif (
			strpos($methodName, 'findOneBy') === 0
		) {
			$returnType = TypeCombinator::union($domainModelClassType, new NullType());
		} elseif (
			strpos($methodName, 'countBy') === 0
		) {
			$returnType = new IntegerType();
		} else {
			throw new ShouldNotHappenException();
		}

		return new MagicRepositoryMethodReflection($classReflection, $methodName, $returnType);
	}

}

```

MethodReflectionについてはPHPStanのdoctrine拡張内にある`MagicRepositoryMethodReflection`クラスを利用しました。

https://github.com/phpstan/phpstan-doctrine/blob/815d5ae40ffe3a7788df1cd557e30e474f3aba1c/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php

それでは実際に試してみましょう。
以下のコードに対して解析をかけてみます。

```php: 解析対象
$resultArray = $this->functionalRepository->findById('test');
\PHPStan\dumpType($resultArray);
$result = $this->functionalRepository->findOneById('test');
\PHPStan\dumpType($result);
$resultCount = $this->functionalRepository->countById('test');
\PHPStan\dumpType($resultCount);
```

以下が解析結果です。
無事に解析ができました！

```sh: 解析結果
> ./bin/phpstan analyse .\Packages\Application\Neos.Welcome\Classes\Controller\FunctionalTestController.php 
Note: Using configuration file C:\Path\To\Project\phpstan.neon.
 1/1 [============================] 100%

 ------ -------------------------------------------------------------------------------------- 
  Line   Packages/Application/Neos.Welcome/Classes/Controller/FunctionalTestController.php     
 ------ --------------------------------------------------------------------------------------
  :63    Dumped type: array<int, Neos\Welcome\Domain\Model\Functional>
         ✏️  Packages/Application/Neos.Welcome/Classes/Controller/FunctionalTestController.php
  :65    Dumped type: Neos\Welcome\Domain\Model\Functional|null
         ✏️  Packages/Application/Neos.Welcome/Classes/Controller/FunctionalTestController.php
  :67    Dumped type: int
         ✏️  Packages/Application/Neos.Welcome/Classes/Controller/FunctionalTestController.php
 ------ --------------------------------------------------------------------------------------
```

# 参考
FlowはDoctrineを利用しているということもあり、このマジックメソッドについてもdoctrineと似たつくりになっています。今回実装した拡張機能も、PHPStanのDoctrine拡張に含まれる以下のクラスを参考に作成しました。

https://github.com/phpstan/phpstan-doctrine/blob/815d5ae40ffe3a7788df1cd557e30e474f3aba1c/src/Reflection/Doctrine/EntityRepositoryClassReflectionExtension.php

# 終わりに
今回はPHPStanでDoctrineExtensionを利用する方法を紹介しました。FlowはDoctrineを利用しているものの、PHPStanのDoctrine拡張をそのまま利用できるわけではなく、このような独自の拡張機能を作成する必要があります。今後も何か分かったことがあれば書こうと思います。

ここまでご覧いただきありがとうございました！