Skip to content

Drupal entity types and typed entity storage - #534

Merged
AJenbo merged 7 commits into
PHPantom-dev:mainfrom
giuseppe-esposito-87:drupal-dr3
Nov 5, 2026
Merged

AJenbo merged 7 commits into
PHPantom-dev:mainfrom
giuseppe-esposito-87:drupal-dr3

Conversation

@giuseppe-esposito-87

Copy link
Copy Markdown
Contributor

🤖 This was written by an AI agent on behalf of @giuseppe-esposito-87 (claude-code/claude-opus-5-5).

Adds DR3: entity type IDs map to their entity class and storage, so loaded entities have their real type.

// From the playground, where the acme module declares the acme_note entity type.
$storage = $this->entityTypeManager->getStorage('acme_note'); // SqlContentEntityStorage<Note>
$note = $storage->load(1);                                     // ?Note
$ids = \Drupal::entityQuery('acme_note')->accessCheck(FALSE)->execute(); // array<int, string>
  • Entity type IDs declared by a #[ContentEntityType] / #[ConfigEntityType] attribute or the older annotation complete, jump to their declaration and list their references wherever the entity type manager is asked for them.
  • Typed storage and loads. getStorage() is typed as that entity type's storage, so load(), loadMultiple(), create() and the other loads return the entity class.
  • Entity queries. \Drupal::entityQuery() and a storage's getQuery() give the IDs of that entity type, or their number after count(). An entity class PHPantom cannot load keeps core's int|array.

On drupal/redirect and drupal/flag, 17 false positives are gone, and one remaining report is a real mistake in flag (a method returns a Flag where bool is declared). Time and memory are unchanged.

Why this PR comes before DR5 and DR6

Sprint 8 in docs/todo.md schedules DR1, DR2, DR5 and DR6, but not DR3. Two of those need entity types:

  • DR5 types a controller's parameter as the entity Drupal loads for the route (view(Note $acme_note)), which needs to know which class acme_note is.
  • DR6 handles entity hooks such as hook_node_insert(), which needs the list of entity types.

So DR3 comes first, and the DR5 and DR6 PRs build on it.

Same-named methods on different classes

How a call is read today. The extractor first maps a method's name (and argument position) to one receiver group, and only then checks the receiver's class against that group. The first matching arm wins:

match index {
    0 if is("get") || is("has") => Some((ReceiverClass::ContainerLookup, false)),
    0 if is("getDefinition") || is("hasDefinition") /* … */ => {
        Some((ReceiverClass::ContainerBuilder, false))
    }
    0 if is("generate") /* … */ => Some((ReceiverClass::UrlGenerator, false)),
    // …
}

The collision. Drupal's entity type manager also has getDefinition('node') and hasDefinition('node'), taking an entity type ID. The name maps to the ContainerBuilder group, the entity type manager is not in it, so the Drupal call was silently never read.

What this PR does. The two names get a shared group that lists both classes, each with its own kind of name, the same way upstream already shares generate() between Symfony and Yii. Symfony behaves as before:

Self::DefinitionLookup => &[
    (SYMFONY_CONTAINER_BUILDER, ServiceId),
    (DRUPAL_ENTITY_TYPE_MANAGER, StringKeyKind::DrupalEntityType),
],

The general case. Any two frameworks with a same-named method will collide the same way, and nothing reports it. A table keyed by class and method would avoid that, since the receiver's class picks the row:

// (class, method, argument, kind of name)
(SYMFONY_CONTAINER_BUILDER, "getDefinition", 0, ServiceId),
(DRUPAL_ENTITY_TYPE_MANAGER, "getDefinition", 0, DrupalEntityType),

It is a larger change to shared code, so it is not in this PR. I'd be glad to discuss it separately.

Builds on #533.

Checklist

If applicable:

  • I have updated CHANGELOG.md
  • I have updated the documentation (README.md, docs/, examples/)
  • I have updated the config schema (config-schema.json). Not applicable: this PR adds or changes no setting.
  • I have added/updated tests to cover my changes
  • If I used AI tools, I have reviewed what they produced and can explain
    the changes and why they are needed.
  • I have read through any AI-generated text, especially documentation,
    the changelog, and other user-facing content, and removed or corrected
    anything inaccurate, unclear, or unnecessary.

@codecov-commenter

codecov-commenter commented Nov 5, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 94.21265% with 43 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/drupal/types.rs 88.88% 19 Missing ⚠️
...e_engine/call_resolution/return_types/call_arms.rs 74.13% 15 Missing ⚠️
src/symbol_map/extraction/drupal.rs 97.67% 6 Missing ⚠️
src/completion/laravel_string_keys/mod.rs 50.00% 1 Missing ⚠️
src/drupal/mod.rs 97.61% 1 Missing ⚠️
src/hover/laravel.rs 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

giuseppe-esposito-87 and others added 7 commits November 5, 2026 15:38
An entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it. getStorage() returns the entity type's storage handler tagged with its entity class, so load(), loadMultiple(), create(), and the other loads return that class, chained or through a variable. Test modules' entity types count only while open.

Assisted-by: claude-code/claude-opus-5-5
The demo loads its note entity through the entity type manager, and the assertions check the entity type's class and storage with Drupal's own attribute. DR3 leaves the backlog.

Assisted-by: claude-code/claude-opus-5-5
…ed type

Hovering a method name shows the method as declared, so the inferred types of a container lookup and an entity load are shown on the variables that receive them.

Assisted-by: claude-code/claude-opus-5-5
A load or getStorage() chained with no variable in between is typed like an assigned one, and getStorage(entity_type_id: 'x') is read like the positional form. A nested annotation's own pairs no longer override the entity type's ID or storage, a load on a union with other classes keeps their types, and looking an entity type up no longer runs the type engine over every declaring file. The Drupal hooks check the method name and the project before reading arguments, and reading entity types allocates nothing for classes that declare none.

Assisted-by: claude-code/claude-opus-5-5
The mixed workspace also checks that a Symfony container builder's getDefinition() still names a service, and a unit test checks that every entity type manager method Drupal reads reaches the manager's target, so a same-named arm placed before Drupal's fails it.

Assisted-by: claude-code/claude-opus-5-5
\Drupal::entityQuery(), \Drupal::entityQueryAggregate(), and a tagged storage's getQuery() start a query on an entity type, whose ID completes, navigates, and lists among its references. The query is tagged with the entity classes the way a storage is, and execute() gives that type's IDs, keyed by revision ID for a content entity and by ID for a configuration entity, or int<0, max> once count() was called, as phpstan-drupal types them. An aggregate query keeps core's type. DR3 leaves the backlog.

Assisted-by: claude-code/claude-opus-5-5
loadOverrideFree(), loadRevisionUnchanged(), createWithSampleValues(),
loadMultipleRevisions() and loadMultipleOverrideFree() on a typed
storage now return the entity class, which removes false reports such
as grantPermission() on a role loaded override-free in user.module.

The playground no longer claims acme_note's query IDs are keyed by
revision ID (it has no revisions), and its assertion checks the entity
type definition instead of only the demo's source text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AJenbo
AJenbo enabled auto-merge (squash) November 5, 2026 14:48
@AJenbo
AJenbo disabled auto-merge November 5, 2026 14:48
@AJenbo
AJenbo merged commit 0e08e61 into PHPantom-dev:main Nov 5, 2026
13 of 15 checks passed
@AJenbo

AJenbo commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Thank you for working on this, saves me a lot of work and probably gives a better result as well.

@giuseppe-esposito-87
giuseppe-esposito-87 deleted the drupal-dr3 branch November 5, 2026 15:00
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