Repository: phpstan/phpstan-doctrine (php)

Issue:
EntityRepository findBy~, findOneBy~ should allow optional arguments
When I called magic method findOneBy{Property}, I got these error.

```
Method Doctrine\Persistence\ObjectRepository<PHPStan\Rules\Doctrine\ORM\MyEntity>::findByTitle() invoked with 2 parameters, 1 required.
```

EntityRepository's findBy{Property}, findOneBy{Property} could be allow optional parameters.

Pull request description:
magic method call findBy~, findOneBy~ should allow optional arguments
fix #226

Patch:
diff --git a/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php b/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php
index 4837f91..a62362a 100644
--- a/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php
+++ b/src/Reflection/Doctrine/MagicRepositoryMethodReflection.php
@@ -6,9 +6,14 @@ use PHPStan\Reflection\ClassReflection;
 use PHPStan\Reflection\FunctionVariant;
 use PHPStan\Reflection\MethodReflection;
 use PHPStan\TrinaryLogic;
+use PHPStan\Type\ArrayType;
 use PHPStan\Type\Generic\TemplateTypeMap;
+use PHPStan\Type\IntegerType;
 use PHPStan\Type\MixedType;
+use PHPStan\Type\NullType;
+use PHPStan\Type\StringType;
 use PHPStan\Type\Type;
+use PHPStan\Type\UnionType;
 
 class MagicRepositoryMethodReflection implements MethodReflection
 {
@@ -70,13 +75,31 @@ class MagicRepositoryMethodReflection implements MethodReflection
 
 	public function getVariants(): array
 	{
+		if (strpos($this->name, 'findBy') === 0) {
+			$arguments = [
+				new DummyParameter('argument', new MixedType(), false, null, false, null),
+				new DummyParameter('orderBy', new UnionType([new ArrayType(new StringType(), new StringType()), new NullType()]), true, null, false, null),
+				new DummyParameter('limit', new UnionType([new IntegerType(), new NullType()]), true, null, false, null),
+				new DummyParameter('offset', new UnionType([new IntegerType(), new NullType()]), true, null, false, null),
+			];
+		} elseif (strpos($this->name, 'findOneBy') === 0) {
+			$arguments = [
+				new DummyParameter('argument', new MixedType(), false, null, false, null),
+				new DummyParameter('orderBy', new UnionType([new ArrayType(new StringType(), new StringType()), new NullType()]), true, null, false, null),
+			];
+		} elseif (strpos($this->name, 'countBy') === 0) {
+			$arguments = [
+				new DummyParameter('argument', new MixedType(), false, null, false, null),
+			];
+		} else {
+			throw new \PHPStan\ShouldNotHappenException();
+		}
+
 		return [
 			new FunctionVariant(
 				TemplateTypeMap::createEmpty(),
 				null,
-				[
-					new DummyParameter('parameter', new MixedType(), false, null, false, null),
-				],
+				$arguments,
 				false,
 				$this->type
 			),
diff --git a/stubs/EntityRepository.stub b/stubs/EntityRepository.stub
index c9c9202..044e20d 100644
--- a/stubs/EntityRepository.stub
+++ b/stubs/EntityRepository.stub
@@ -29,8 +29,8 @@ class EntityRepository implements ObjectRepository
 	public function findAll();
 
 	/**
-	 * @phpstan-param mixed[] $criteria
-	 * @phpstan-param string[]|null $orderBy
+	 * @phpstan-param array<string, mixed> $criteria
+	 * @phpstan-param array<string, string>|null $orderBy
 	 * @phpstan-param int|null $limit
 	 * @phpstan-param int|null $offset
 	 * @phpstan-return TEntityClass[]
@@ -38,8 +38,8 @@ class EntityRepository implements ObjectRepository
 	public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null);
 
 	/**
-	 * @phpstan-param mixed[] $criteria The criteria.
-	 * @phpstan-param mixed[]|null $orderBy
+	 * @phpstan-param array<string, mixed> $criteria The criteria.
+	 * @phpstan-param array<string, string>|null $orderBy
 	 * @phpstan-return TEntityClass|null
 	 */
 	public function findOneBy(array $criteria, array $orderBy = null);
diff --git a/stubs/Persistence/ObjectRepository.stub b/stubs/Persistence/ObjectRepository.stub
index a469fc2..5cc5c33 100644
--- a/stubs/Persistence/ObjectRepository.stub
+++ b/stubs/Persistence/ObjectRepository.stub
@@ -20,8 +20,8 @@ interface ObjectRepository
 	public function findAll();
 
 	/**
-	 * @phpstan-param mixed[] $criteria
-	 * @phpstan-param string[]|null $orderBy
+	 * @phpstan-param array<string, mixed> $criteria
+	 * @phpstan-param array<string, string>|null $orderBy
 	 * @phpstan-param int|null $limit
 	 * @phpstan-param int|null $offset
 	 * @phpstan-return TEntityClass[]
@@ -29,7 +29,7 @@ interface ObjectRepository
 	public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null);
 
 	/**
-	 * @phpstan-param mixed[] $criteria The criteria.
+	 * @phpstan-param array<string, mixed> $criteria The criteria.
 	 * @phpstan-return TEntityClass|null
 	 */
 	public function findOneBy(array $criteria);


Test patch:
diff --git a/tests/Rules/Doctrine/ORM/MagicRepositoryMethodCallRuleTest.php b/tests/Rules/Doctrine/ORM/MagicRepositoryMethodCallRuleTest.php
index 402dbc4..7d3c6cf 100644
--- a/tests/Rules/Doctrine/ORM/MagicRepositoryMethodCallRuleTest.php
+++ b/tests/Rules/Doctrine/ORM/MagicRepositoryMethodCallRuleTest.php
@@ -68,6 +68,26 @@ class MagicRepositoryMethodCallRuleTest extends RuleTestCase
 				'Call to an undefined method PHPStan\Rules\Doctrine\ORM\TestRepository<PHPStan\Rules\Doctrine\ORM\MySecondEntity>::findByNonexistent().',
 				56,
 			],
+			[
+				'Parameter #2 $orderBy of method Doctrine\Persistence\ObjectRepository<PHPStan\Rules\Doctrine\ORM\MyEntity>::findByTitle() expects array<string, string>|null, array<int, string> given.',
+				65,
+			],
+			[
+				'Parameter #3 $limit of method Doctrine\Persistence\ObjectRepository<PHPStan\Rules\Doctrine\ORM\MyEntity>::findByTitle() expects int|null, string given.',
+				68,
+			],
+			[
+				'Parameter #4 $offset of method Doctrine\Persistence\ObjectRepository<PHPStan\Rules\Doctrine\ORM\MyEntity>::findByTitle() expects int|null, string given.',
+				71,
+			],
+			[
+				'Parameter #2 $orderBy of method Doctrine\Persistence\ObjectRepository<PHPStan\Rules\Doctrine\ORM\MyEntity>::findOneByTitle() expects array<string, string>|null, array<int, string> given.',
+				79,
+			],
+			[
+				'Method Doctrine\Persistence\ObjectRepository<PHPStan\Rules\Doctrine\ORM\MyEntity>::countByTitle() invoked with 2 parameters, 1 required.',
+				85,
+			],
 		]);
 	}
 
diff --git a/tests/Rules/Doctrine/ORM/data/magic-repository.php b/tests/Rules/Doctrine/ORM/data/magic-repository.php
index 2a031e4..87c6760 100644
--- a/tests/Rules/Doctrine/ORM/data/magic-repository.php
+++ b/tests/Rules/Doctrine/ORM/data/magic-repository.php
@@ -56,4 +56,32 @@ class MagicRepositoryCalls
 		$entityRepository->findByNonexistent('test');
 		$entityRepository->findByCustomMethod();
 	}
+
+	public function doFindByWithOptionals(): void
+	{
+		$entityRepository = $this->entityManager->getRepository(MyEntity::class);
+		$entityRepository->findByTitle('test', ['id' => 'DESC']);
+		$entityRepository->findByTitle('test', null);
+		$entityRepository->findByTitle('test', [1 => 'DESC']);
+		$entityRepository->findByTitle('test', ['id' => 'DESC'], 1);
+		$entityRepository->findByTitle('test', ['id' => 'DESC'], null);
+		$entityRepository->findByTitle('test', ['id' => 'DESC'], '1');
+		$entityRepository->findByTitle('test', ['id' => 'DESC'], 1, 1);
+		$entityRepository->findByTitle('test', ['id' => 'DESC'], 1, null);
+		$entityRepository->findByTitle('test', ['id' => 'DESC'], 1, '1');
+	}
+
+	public function doFindOneByWithOptionals(): void
+	{
+		$entityRepository = $this->entityManager->getRepository(MyEntity::class);
+		$entityRepository->findOneByTitle('test', ['id' => 'DESC']);
+		$entityRepository->findOneByTitle('test', null);
+		$entityRepository->findOneByTitle('test', [1 => 'DESC']);
+	}
+
+	public function doCountByWithOptionals(): void
+	{
+		$entityRepository = $this->entityManager->getRepository(MyEntity::class);
+		$entityRepository->countByTitle('test', ['id' => 'DESC']);
+	}
 }