Skip to content

Make FormatterInterface stub generic (2.x) #846

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from

Conversation

Niklan
Copy link
Contributor

@Niklan Niklan commented Mar 29, 2025

From #822: This PR changes FormatterStub to be a proper generic instead of narrowing types for others.

Conflict with #844.

@Niklan Niklan changed the title Make FormatterInterface stub generic Make FormatterInterface stub generic (2.x) Mar 29, 2025
@Niklan
Copy link
Contributor Author

Niklan commented Mar 29, 2025

@ceesgeene, if possible, please take a look and give feedback on the generic proposal.

@Niklan Niklan marked this pull request as draft March 29, 2025 09:09
@Niklan
Copy link
Contributor Author

Niklan commented Mar 29, 2025

The only problem I faced is that I can't define these generic subtypes using a docblock above the class.

/**
 * @implements FormatterInterface<CommentFieldItemList>
 */

This leads to an error: Class {class} has @implements tag, but does not implement any interface, because it actually extends FormatterBase, which implements the FormatterInterface. This appears to make it impossible to declare generics TFieldItemList for both methods at the same time:

Method {formatter}::viewElements() has parameter $items with generic interface Drupal\Core\Field\FieldItemListInterface but does not specify its types: T

This forces us to add types directly at the method level:

/**
 * @param CommentFieldItemList $items
 */
public function viewElements(FieldItemListInterface $items, $langcode): array {

Then it works. But in that case, it's unclear what the generic is actually doing. You can simply remove it and get the same result.

So it appears that we also should provide stub for FormatterBase:

<?php

namespace Drupal\Core\Field;

use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Field\FormatterInterface;
use Drupal\Core\Field\PluginSettingsBase;

/**
 * @template TFieldItemList of \Drupal\Core\Field\FieldItemListInterface
 * @implements FormatterInterface<TFieldItemList>
 */
abstract class FormatterBase extends PluginSettingsBase implements FormatterInterface, ContainerFactoryPluginInterface {

}

In that case it is possible to tell PHPStan what types we are using:

/**
 * @extends FormatterBase<CommentFieldItemList>
 */
final class FooFormatter extends FormatterBase {

It also looks like it's working well with generics as a type:

/**
 * @extends FormatterBase<FieldItemList<StringItem>>
 */
final class FooFormatter extends FormatterBase {

@Niklan
Copy link
Contributor Author

Niklan commented Mar 29, 2025

@mglaman how to handle these test failures? It means we also should add stubs for ContainerFactoryPluginInterface and PluginSettingsBase or add them into baseline?

@mglaman
Copy link
Owner

mglaman commented Mar 29, 2025

@Niklan it means we need the missing stubs added. Making a stub requires providing stubs for any extends or implementation, even if they're empty. See https://github.com/mglaman/phpstan-drupal/blob/main/stubs/Drupal/Component/Plugin/PluginInspectionInterface.stub as an example

@Niklan
Copy link
Contributor Author

Niklan commented Mar 30, 2025

@mglaman this one and #847 (1.x backport) are ready for review. I think there is nothing I can do about test failures in HEAD baseline check action.

If you are planning to merge these two, then these should be closed:

@Niklan Niklan marked this pull request as ready for review March 31, 2025 05:25
Comment on lines 5 to 17
/**
* @template TFieldItemList of \Drupal\Core\Field\FieldItemListInterface
*/
interface FormatterInterface {

/**
* @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\FieldItemInterface> $items
* @param TFieldItemList $items
* @param string $langcode
*
* @return array<int, array<int|string, mixed>>
*/
public function viewElements(FieldItemListInterface $items, $langcode): array;

Copy link
Owner

@mglaman mglaman Apr 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't we want

/**
 * @template T of \Drupal\Core\Field\FieldItemInterface
 */
interface FormatterInterface extends PluginSettingsInterface {

so that way the T template of the field type is properly passed down into FieldItemListInterface

  /**
   * @param \Drupal\Core\Field\FieldItemListInterface<T> $items
   * @param string $langcode
   *
   * @return array
   */
  public function viewElements(FieldItemListInterface $items, $langcode);

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MissingMethodParameterTypehintRule returns the error, we could make a test which calls it to see if this fixes. But there should be some kind of type test we can write instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the template name makes any difference; it's just a preference or an alias, as I understood it. It won't pass it to other inherited generics automatically anyway.

The reason I gave it such an explicit alias is that FormatterInterface works not only with FieldItemListInterface. There is a chance that in the future FieldDefinitionInterface or FormStateInterface (form object can be specified through it) might also require a generic template. In this case, TFieldItemList will be self-documenting, while T might cause some confusion.

I don't mind changing it to T, as PHPStan allows changing that name freely; the only thing we should care about for BC is the order of templates. So when we need a second one, we can make the name clearer.

I fully agree about tests; I will try to create one.

# Conflicts:
#	stubs/Drupal/Core/Field/FormatterInterface.stub
@mglaman
Copy link
Owner

mglaman commented Apr 3, 2025

I pushed e158c6d (#846) as I think it's the correct way to pass the template down to the field item list and provide the generic for formatters

@mglaman
Copy link
Owner

mglaman commented Apr 3, 2025

Ah I overlooked this example:

/**
 * @extends FormatterBase<FieldItemList<StringItem>>
 */
final class FooFormatter extends FormatterBase {

So maybe it was the same all along and it comes down to style of writing generics?

@mglaman mglaman force-pushed the 2.x-formatter-generic branch from e158c6d to 3b11f51 Compare April 3, 2025 21:00
@ceesgeene
Copy link
Contributor

Note that it is possible to provide template defaults since phpstan/phpstan-src#3457

The FormatterInterface could have two templates l, one for the field item and one for the list, were the one for the list is optional and defaults to FieldItemListInterface

/**
 * @template TFieldItem
 * @template TFieldItemList = FieldItemListInterface 
 */
interface FormatterInterface

Formatters then could only provide the class for the TFieldItem template if they don't need methods of a more specific field item list interface.

Not sure if I would use this feature though. E.g. I don't know how well this feature is supported by IDEs.

@mglaman
Copy link
Owner

mglaman commented Apr 7, 2025

I'm going to revert the commits I made and merge the original

@Niklan
Copy link
Contributor Author

Niklan commented Apr 7, 2025

@mglaman I think your approach to testing generic is much better than mine. Maybe we should try to improve the test to cover more cases, and revert the generic definition back to its initial state. Since the problem version of the generic has been reverted and released, we can do this properly without rushing anything.

@Niklan
Copy link
Contributor Author

Niklan commented Apr 7, 2025

@mglaman I've removed my tests, reverted the generic definition to its initial state (but with the changes you've made to ::prepareView()). I'll try to improve the tests.

Btw, how should the generic template be called? I've reverted it to TFieldItemList. As mentioned already, it doesn't matter; we can freely change its name. The only thing we should care about is the order of template notations when multiple data become generics. So, I can make it T or keep it as TFieldItemList.

The only downside to changing the name of the generic is that if someone adds it into the baseline or ignoreErrors using regular expressions, it would fail then.

@Niklan
Copy link
Contributor Author

Niklan commented Apr 7, 2025

@mglaman also, I have a question regarding the formatter-interface.php. Any reason why you've added methods required by FormatterInterface?

For example, this one:

/**
 * @implements \Drupal\Core\Field\FormatterInterface<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem>
 */
class StringFormatter implements FormatterInterface {

    public function settingsForm(array $form, FormStateInterface $form_state) {
        return [];
    }

    public function settingsSummary() {
        return [];
    }

    public function prepareView(array $entities_items) {
        $first_item = $entities_items[0]->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
    }

    public function view(FieldItemListInterface $items, $langcode = NULL) {
        assertType('Drupal\Core\Field\FieldItemListInterface<Drupal\Core\Field\Plugin\Field\FieldType\StringItem>', $items);
        $first_item = $items->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
        return [];
    }

    public function viewElements(FieldItemListInterface $items, $langcode) {
        $first_item = $items->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
        return [];
    }

    public static function isApplicable(FieldDefinitionInterface $field_definition) {
        return true;
    }

    public function getSettings() {
        return [];
    }

    public function getSetting($key) {
        return null;
    }

    public function setSetting($key, $value) {
        return $this;
    }

    public function setSettings(array $settings) {
        return $this;
    }

    public function getDefaultSettings() {
        return [];
    }

    public function getPluginId() {
        return 'string_formatter';
    }

    public function getPluginDefinition() {
        return [];
    }

    public function getThirdPartySettings($provider = NULL) {
        return [];
    }

    public function getThirdPartySetting($provider, $key, $default = NULL) {
        return $default;
    }

    public function setThirdPartySetting($provider, $key, $value) {
        return $this;
    }

    public function unsetThirdPartySetting($provider, $key) {
        return $this;
    }

    public function getThirdPartyProviders() {
        return [];
    }
}

could be simplified to

/**
 * @implements \Drupal\Core\Field\FormatterInterface<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem>
 */
class StringFormatter implements FormatterInterface {

    public function prepareView(array $entities_items) {
        $first_item = $entities_items[0]->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
    }

    public function view(FieldItemListInterface $items, $langcode = NULL) {
        assertType('Drupal\Core\Field\FieldItemListInterface<Drupal\Core\Field\Plugin\Field\FieldType\StringItem>', $items);
        $first_item = $items->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
        return [];
    }

    public function viewElements(FieldItemListInterface $items, $langcode) {
        $first_item = $items->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
        return [];
    }
    
}

Yes, it's wrong from the PHP interpreter (and LSP) point of view because the class does not implement required methods. But it's not real PHP code; PHPStan doesn't care about it since all **/data/* files are explicitly excluded. Tests work properly that way because PHPStan is a static analyzer; it doesn't execute that file, so its syntax validity is not important.

But doing it without "empty" methods just to please PHP LSP makes tests much smaller and clearer about what is actually tested. These empty and useless methods add too much noise and distract from what is actually tested.

If I remove all such methods, the tests become so much smaller, like literally 238 lines of code to 111, basically twice as small, doing exactly the same thing.

<?php

namespace DrupalFormatterInterface;

use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Field\FormatterInterface;
use function PHPStan\Testing\assertType;

/**
 * @implements \Drupal\Core\Field\FormatterInterface<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem>
 */
class StringFormatter implements FormatterInterface {

    public function prepareView(array $entities_items) {
        $first_item = $entities_items[0]->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
    }

    public function view(FieldItemListInterface $items, $langcode = NULL) {
        assertType('Drupal\Core\Field\FieldItemListInterface<Drupal\Core\Field\Plugin\Field\FieldType\StringItem>', $items);
        $first_item = $items->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
        return [];
    }

    public function viewElements(FieldItemListInterface $items, $langcode) {
        $first_item = $items->first();
        // Assert that the first item in the list is a StringItem
        assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $first_item);
        return [];
    }

}

/**
 * @template T of \Drupal\Core\Field\FieldItemInterface
 * @extends \Drupal\Core\Field\FormatterBase<T>
 */
class GenericExtendsFormatterBase extends FormatterBase {
    /**
     * @param \Drupal\Core\Field\FieldItemListInterface<T> $items
     */
    public function viewElements(FieldItemListInterface $items, $langcode) {
        $first = $items->first();
        assertType('T of Drupal\Core\Field\FieldItemInterface (class DrupalFormatterInterface\GenericExtendsFormatterBase, argument)|null', $first);
        return [];
    }
}

/**
 * @template T of \Drupal\Core\Field\FieldItemInterface
 */
class GenericFormatter implements FormatterInterface {

    /**
     * @param \Drupal\Core\Field\FieldItemListInterface<T> $items
     */
    public function view(FieldItemListInterface $items, $langcode = NULL) {
        $first = $items->first();
        assertType('T of Drupal\Core\Field\FieldItemInterface (class DrupalFormatterInterface\GenericFormatter, argument)|null', $first);
        return [];
    }

    /**
     * @param \Drupal\Core\Field\FieldItemListInterface<T> $items
     */
    public function viewElements(FieldItemListInterface $items, $langcode) {
        $first = $items->first();
        assertType('T of Drupal\Core\Field\FieldItemInterface (class DrupalFormatterInterface\GenericFormatter, argument)|null', $first);
        return [];
    }
}

/**
 * Test usage of the formatter through a function.
 *
 * @param \Drupal\Core\Field\FormatterInterface<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem> $formatter
 * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem> $items
 */
function testFormatterWithStringItems(FormatterInterface $formatter, FieldItemListInterface $items): void {
    $elements = $formatter->viewElements($items, 'en');
    assertType('array<int, array<int|string, mixed>>', $elements);

    // Test with implementation
    $stringFormatter = new StringFormatter();
    $elements = $stringFormatter->viewElements($items, 'en');
    assertType('array<int, array<int|string, mixed>>', $elements);

    $item = $items->first();
    // The generic type from FormatterInterface should flow to $item
    assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $item);
}

/**
 * Test that FormatterBase correctly implements FormatterInterface with generics.
 *
 * @param \Drupal\Core\Field\FormatterBase<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem> $formatter
 * @param \Drupal\Core\Field\FieldItemListInterface<\Drupal\Core\Field\Plugin\Field\FieldType\StringItem> $items
 */
function testFormatterBaseWithStringItems(FormatterBase $formatter, FieldItemListInterface $items): void {
    $output = $formatter->view($items, 'en');
    assertType('array<int|string, mixed>', $output);

    $item = $items->first();
    // The generic type from FormatterBase should flow to $item
    assertType('Drupal\Core\Field\Plugin\Field\FieldType\StringItem|null', $item);
}

I want your input on this. I won't delete anything for now. But what do you think about it?

@Niklan
Copy link
Contributor Author

Niklan commented Apr 8, 2025

@mglaman I've updated the tests, and the last two of them are failing: ExtendedDeepFormatter and ExtendsBooleanItemFormatter.

They are failing very weirdly, for example, this one:

/**
 * @extends FormatterBase<FakeBooleanFieldItemList>
 */
class ExtendsBooleanItemFormatter extends FormatterBase {

    public function prepareView(array $entities_items): void {
        assertType('array<DrupalFormatterInterface\FakeBooleanFieldItemList>', $entities_items);

Fails with an error:

Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'array<DrupalFormatterInterface\FakeBooleanFieldItemList>'
+'array<Drupal\Core\Field\TFieldItemList>'

For some reason, it resolves TFieldItemList as a some kind of class/interface. Even if I replace it with T, it reports array<Drupal\Core\Field\T> (FQN doesn't helps here as well).

Another weird part is that it doesn't report problems in ::viewElements() for both classes, which is odd, because they have the same generic interface and PHPDoc, exactly the same asserts, but they are not failing, while ::view() and ::prepareView() are failing.

Maybe we faced this bug: phpstan/phpstan#9708, but I'm unsure, because it should report ::viewElements() as well then.

P.S. I have a feeling I'm doing tests wrong for that case. PHPStan itself does it completely different: https://github.com/phpstan/phpstan-src/tree/2.1.x/tests/PHPStan/Generics/data. I'm also thinking we should have such tests under the Generics namespace to clearly separate them from Type.

@mglaman
Copy link
Owner

mglaman commented Apr 8, 2025

I'm all for moving to a Generics namespace for the data to keep it isolated

@Niklan
Copy link
Contributor Author

Niklan commented Apr 8, 2025

@mglaman will move them next time. 👍

I found a pattern: when extending FormatterBase, the ::prepareView() and ::view() methods actually have overrides in the Drupal implementation, but ::viewElements() does not. Maybe it leads to conflicts in some way, or it is a bug in PHPStan.

Making a stub that looks like this helps:

<?php

namespace Drupal\Core\Field;

use Drupal\Core\Plugin\ContainerFactoryPluginInterface;

/**
 * @template TFieldItemList of Drupal\Core\Field\FieldItemListInterface
 * @implements FormatterInterface<TFieldItemList>
 */
abstract class FormatterBase extends PluginSettingsBase implements FormatterInterface, ContainerFactoryPluginInterface {

  /**
   * @param TFieldItemList $items
   */
  public function view(FieldItemListInterface $items, $langcode = NULL) {}

  /**
   * @param array<TFieldItemList> $items
   */
  public function prepareView(array $entities_items) {}

}

But I don't like it. It basically duplicates information from the FormatterInterface partially, which looks weird.

As I understand it: the stub doesn't define these methods in any way → the original class has implementations of them, which takes priority over interface → PHPStan can't figure out the generic types in the PHPDoc for these methods.

Original overrides are simple:

  /**
   * {@inheritdoc}
   */
  public function prepareView(array $entities_items) {}
  
  /**
   * {@inheritdoc}
   */
  public function prepareView(array $entities_items) {}

Assumption: this {@inheritdoc} overrides interface @param array<TFieldItemList> $entities_items which leads to a problem. Looks like PHPStan doesn't check interface PHPDoc in that case, at least for generics.

@mglaman
Copy link
Owner

mglaman commented Apr 8, 2025

We also need to stub EntityReferenceFormatterBase

@mglaman
Copy link
Owner

mglaman commented Apr 8, 2025

@Niklan I think we're just going to have to be extra verbose in phpstan-drupal with our stubs until we can fix core, unfortunately. So basically we're going to be over-stubbing to avoid any confusion in PHPStan, correct?

@Niklan
Copy link
Contributor Author

Niklan commented Apr 9, 2025

@mglaman sounds correct, simply because with overrides Drupal technically can "break" generics by forcing/reset the other types, but it should be detected in advance, so it's not a major problem. But yes, looks like we're going to stub much more than needed, because for now, it looks like the order how PHPStan is resolving generic is: stub → original → parent (inherited, class/interface) stubs → originals → repeat. If some data is missing in one, it looks it in the other. FormatterBase stub does not have overrides (for now) ::view() and ::prepareView(), so it gets the data from original class, which has overrides with {@inheritdocs}, and I assume it doesn't check the interface because of it, like it treats "PHPDoc as certain". But this PHPDoc doesn't define parameters, so it is kind of resets them to typehints of the method (in that case test errors makes sense and they are correct). But on the other hand, ::viewElements() is not reporting any errors, because it is not overridden in original FormatterBase.

It all sounds logical, but the problem is that I can't reproduce it with PHPStan playground: https://phpstan.org/r/03d78262-0b50-469a-99dc-0ef4e1ee37f5. BazFormatter::viewFoo() should actually fail here if my assumption is correct, but it doesn't.

On the other hand, adding methods to the FormatterBase stub in our case solves the problem, so we can move forward. I still wish to understand why we have such a problem and Playground doesn't. The only difference is that there are no stubs in the playground that could cause issues. I'll try to dig more into that, maybe I'll be able to find out why it's failing. I prefer not to stub methods which should be properly inherited from other stubs/original classes/interfaces and doesn't require overrides for PHPStan.

@Niklan
Copy link
Contributor Author

Niklan commented Apr 9, 2025

This one looks like a bug:

/**
* @template T of \Drupal\Core\Field\FieldItemInterface
* @extends ItemList<T>
* @implements FieldItemListInterface<T>
*/
class FieldItemList extends ItemList implements FieldItemListInterface {
/**
* @return \Drupal\Core\Field\FieldItemInterface
*/

Shouldn't it be:

  /**
   * @return T
   */

@Niklan Niklan marked this pull request as draft April 16, 2025 15:05
@mglaman
Copy link
Owner

mglaman commented Apr 16, 2025

I'm clicking "update branch" so we can get a granular check on the 11.x-dev fail – to see if it's 11.0, 11.1, 11.2.x-dev or stuff for 12.x

@mglaman
Copy link
Owner

mglaman commented Apr 16, 2025

💀 so it was something in 11.x

@Niklan Niklan marked this pull request as ready for review April 23, 2025 15:02
Copy link
Owner

@mglaman mglaman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thought, sorry. I just think the "small" change in ItemList stub is worth it's own PR release note line

*/
protected function createItem(int $offset = 0, ?mixed $value = NULL): \Drupal\Core\Field\FieldItemInterface {
}
protected function createItem(int $offset = 0, mixed $value = NULL): FieldItemInterface {}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically it needs to stay ?mixed? or does mixed imply null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?mixed is wrong from the syntax point of view, as it implies NULL. I thought it could lead to some issues because this could lead to a wrong AST. But it clearly wasn't an issue, so I can revert it back, no problem. However, it would be better to consider removing the nullable type.

https://www.php.net/manual/en/language.types.mixed.php:

The mixed type accepts every value. It is equivalent to the union type object|resource|array|string|float|int|bool|null. Available as of PHP 8.0.0.

@Niklan
Copy link
Contributor Author

Niklan commented Apr 24, 2025

Converting both PRs to draft to work on: #846 (comment)

@Niklan Niklan marked this pull request as draft April 24, 2025 13:38
@Niklan Niklan marked this pull request as ready for review April 29, 2025 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants