Repository: phpstan/phpstan-doctrine (php)

Issue:
Invalid map in collection.
After update PHPStan to version 1.5.4, the map function of collection does not work anymore.

Example:

```php

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class Item {

    private ?Parent  $parent = null;
    
    public function setParent(?Parent $parent): void
    {
        $this->parent = $parent;
    }
}

class Parent {

    /**
     * @var Collection<int, Item>
     */
    private Collection $items;

    public function __construct()
    {
        $this->items = new ArrayCollection();
    }

    public function __clone()
    {
        $this->items = $this->items->map(
            fn (Item $item): Item => (clone $item)->setParent($this)
        );
    }
}
```

The follwing error is raised:

```
------ -------------------------------------------------------------------------------------------------
  Line   src\Parent.php
 ------ -------------------------------------------------------------------------------------------------
  81     Property Parent::$items (Doctrine\Common\Collections\Collection<int, Item>)
         does not accept Doctrine\Common\Collections\ReadableCollection<int, Item>.
         🪪  assign.propertyType
 ------ ------------------------------------------------------------------------------------------------- 
```

Maybe, the `Collection` and `ArrayCollection` stubs must be also updated.

Pull request description:
Add phpdoc for transformation methods into other Collection stubs
Fixes #621 

This is basically the same road as Doctrine itself took - repeat the modified phpdoc in the child interfaces/classes.


Patch:
diff --git a/extension.neon b/extension.neon
index 63365d3..3510826 100644
--- a/extension.neon
+++ b/extension.neon
@@ -43,7 +43,6 @@ parameters:
 		- stubs/Persistence/ObjectRepository.stub
 		- stubs/RepositoryFactory.stub
 		- stubs/Collections/ArrayCollection.stub
-		- stubs/Collections/ReadableCollection.stub
 		- stubs/Collections/Selectable.stub
 		- stubs/ORM/AbstractQuery.stub
 		- stubs/ORM/Exception/ORMException.stub
diff --git a/src/Stubs/Doctrine/StubFilesExtensionLoader.php b/src/Stubs/Doctrine/StubFilesExtensionLoader.php
index 0b3a69d..390e35a 100644
--- a/src/Stubs/Doctrine/StubFilesExtensionLoader.php
+++ b/src/Stubs/Doctrine/StubFilesExtensionLoader.php
@@ -64,8 +64,10 @@ class StubFilesExtensionLoader implements StubFilesExtension
 			$collectionVersion = null;
 		}
 		if ($collectionVersion !== null && strpos($collectionVersion, '1.') === 0) {
+			$files[] = $stubsDir . '/Collections/ReadableCollection1.stub';
 			$files[] = $stubsDir . '/Collections/Collection1.stub';
 		} else {
+			$files[] = $stubsDir . '/Collections/ReadableCollection.stub';
 			$files[] = $stubsDir . '/Collections/Collection.stub';
 		}
 
diff --git a/stubs/Collections/ArrayCollection.stub b/stubs/Collections/ArrayCollection.stub
index 2ecb9ea..34d0745 100644
--- a/stubs/Collections/ArrayCollection.stub
+++ b/stubs/Collections/ArrayCollection.stub
@@ -2,6 +2,8 @@
 
 namespace Doctrine\Common\Collections;
 
+use Closure;
+
 /**
  * @template TKey of array-key
  * @template T
@@ -11,4 +13,33 @@ namespace Doctrine\Common\Collections;
 class ArrayCollection implements Collection, Selectable
 {
 
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(T, TKey):bool $p
+     *
+     * @return static<TKey, T>
+     */
+    public function filter(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $func
+     *
+     * @param Closure(T):U $func
+     *
+     * @return static<TKey, U>
+     *
+     * @template U
+     */
+    public function map(Closure $func);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return array{0: static<TKey, T>, 1: static<TKey, T>}
+     */
+    public function partition(Closure $p);
+
 }
diff --git a/stubs/Collections/Collection.stub b/stubs/Collections/Collection.stub
index be162ef..05139ed 100644
--- a/stubs/Collections/Collection.stub
+++ b/stubs/Collections/Collection.stub
@@ -3,6 +3,7 @@
 namespace Doctrine\Common\Collections;
 
 use ArrayAccess;
+use Closure;
 use Countable;
 use IteratorAggregate;
 
@@ -43,4 +44,33 @@ interface Collection extends Countable, IteratorAggregate, ArrayAccess, Readable
      */
     public function removeElement($element) {}
 
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(T, TKey):bool $p
+     *
+     * @return Collection<TKey, T>
+     */
+    public function filter(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $func
+     *
+     * @param Closure(T):U $func
+     *
+     * @return Collection<TKey, U>
+     *
+     * @template U
+     */
+    public function map(Closure $func);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return array{0: Collection<TKey, T>, 1: Collection<TKey, T>}
+     */
+    public function partition(Closure $p);
+
 }
diff --git a/stubs/Collections/Collection1.stub b/stubs/Collections/Collection1.stub
index 455733c..0f5ad70 100644
--- a/stubs/Collections/Collection1.stub
+++ b/stubs/Collections/Collection1.stub
@@ -3,6 +3,7 @@
 namespace Doctrine\Common\Collections;
 
 use ArrayAccess;
+use Closure;
 use Countable;
 use IteratorAggregate;
 
@@ -43,4 +44,33 @@ interface Collection extends Countable, IteratorAggregate, ArrayAccess, Readable
      */
     public function removeElement($element) {}
 
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(T, TKey):bool $p
+     *
+     * @return Collection<TKey, T>
+     */
+    public function filter(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $func
+     *
+     * @param Closure(T):U $func
+     *
+     * @return Collection<TKey, U>
+     *
+     * @template U
+     */
+    public function map(Closure $func);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return array{0: Collection<TKey, T>, 1: Collection<TKey, T>}
+     */
+    public function partition(Closure $p);
+
 }
diff --git a/stubs/Collections/ReadableCollection1.stub b/stubs/Collections/ReadableCollection1.stub
new file mode 100644
index 0000000..dec73af
--- /dev/null
+++ b/stubs/Collections/ReadableCollection1.stub
@@ -0,0 +1,86 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+use Closure;
+use Countable;
+use IteratorAggregate;
+
+/**
+ * @template TKey of array-key
+ * @template-covariant T
+ * @extends IteratorAggregate<TKey, T>
+ */
+interface ReadableCollection extends Countable, IteratorAggregate
+{
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return bool
+     */
+    public function exists(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(T, TKey):bool $p
+     *
+     * @return ReadableCollection<TKey, T>
+     */
+    public function filter(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $func
+     *
+     * @param Closure(T):U $func
+     *
+     * @return Collection<TKey, U>
+     *
+     * @template U
+     */
+    public function map(Closure $func);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return array{0: ReadableCollection<TKey, T>, 1: ReadableCollection<TKey, T>}
+     */
+    public function partition(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return bool TRUE, if the predicate yields TRUE for all elements, FALSE otherwise.
+     */
+    public function forAll(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $p
+     *
+     * @param Closure(TKey, T):bool $p
+     *
+     * @return T|null
+     */
+    public function findFirst(Closure $p);
+
+    /**
+     * @param-immediately-invoked-callable $func
+     *
+     * @param Closure(TReturn|TInitial, T):TReturn $func
+     * @param TInitial $initial
+     *
+     * @return TReturn|TInitial
+     *
+     * @template TReturn
+     * @template TInitial
+     */
+    public function reduce(Closure $func, mixed $initial = null);
+
+}


Test patch:
diff --git a/tests/DoctrineIntegration/TypeInferenceTest.php b/tests/DoctrineIntegration/TypeInferenceTest.php
index 82a4c23..ba2c4fd 100644
--- a/tests/DoctrineIntegration/TypeInferenceTest.php
+++ b/tests/DoctrineIntegration/TypeInferenceTest.php
@@ -14,6 +14,7 @@ class TypeInferenceTest extends TypeInferenceTestCase
 	{
 		yield from $this->gatherAssertTypes(__DIR__ . '/data/getRepository.php');
 		yield from $this->gatherAssertTypes(__DIR__ . '/data/isEmpty.php');
+		yield from $this->gatherAssertTypes(__DIR__ . '/data/Collection.php');
 	}
 
 	/**
diff --git a/tests/DoctrineIntegration/data/Collection.php b/tests/DoctrineIntegration/data/Collection.php
new file mode 100644
index 0000000..f56e69d
--- /dev/null
+++ b/tests/DoctrineIntegration/data/Collection.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Bug621;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use function PHPStan\Testing\assertType;
+
+class Foo
+{
+
+	/** @var Collection<int, Item> */
+	private $items;
+
+	public function __construct()
+	{
+		/** @var ArrayCollection<int, int> $numbers */
+		$numbers = new ArrayCollection([1, 2, 3]);
+
+		$filteredNumbers = $numbers->filter(function (int $number): bool {
+			return $number % 2 === 1;
+		});
+		assertType('Doctrine\Common\Collections\ArrayCollection<int, int>', $filteredNumbers);
+
+		$items = $filteredNumbers->map(static function (int $number): Item {
+			return new Item();
+		});
+		assertType('Doctrine\Common\Collections\ArrayCollection<int, Bug621\Item>', $items);
+
+		$this->items = $items;
+	}
+
+	public function removeOdd(): void
+	{
+		$this->items = $this->items->filter(function (Item $item, int $idx): bool {
+			return $idx % 2 === 1;
+		});
+		assertType('Doctrine\Common\Collections\Collection<int, Bug621\Item>', $this->items);
+	}
+
+	public function __clone()
+	{
+		$this->items = $this->items->map(
+			static function (Item $item): Item {
+				return clone $item;
+			}
+		);
+		assertType('Doctrine\Common\Collections\Collection<int, Bug621\Item>', $this->items);
+	}
+
+}
+
+class Item
+{
+
+}