Loading core/lib/Drupal/Core/Entity/ContentEntityDeleteForm.php +3 −1 Original line number Diff line number Diff line Loading @@ -62,7 +62,9 @@ public function submitForm(array &$form, FormStateInterface $form_state) { // Make sure that deleting a translation does not delete the whole entity. if (!$entity->isDefaultTranslation()) { $untranslated_entity = $entity->getUntranslated(); // Clone the entity before deleting, as other methods will need to access // the label and other data of the deleted translation. $untranslated_entity = clone $entity->getUntranslated(); $untranslated_entity->removeTranslation($entity->language()->getId()); $untranslated_entity->save(); $form_state->setRedirectUrl($untranslated_entity->toUrl('canonical')); Loading core/lib/Drupal/Core/Entity/EntityFieldValueTrait.php 0 → 100644 +74 −0 Original line number Diff line number Diff line <?php declare(strict_types=1); namespace Drupal\Core\Entity; use Drupal\Core\Language\LanguageInterface; /** * Adds a ::getFieldValue() method suitable for use with content entities. * * @ingroup entity_api */ trait EntityFieldValueTrait { /** * Gets the value of a field property directly, bypassing the typed data API. * * For certain use cases, it can be desirable to avoid the overhead of * creating FieldItemList and ItemList objects in order to access certain * properties of entities. This is particularly true where the access would be * the only interaction with the entity system for an entire response, or * where a very large number of entities are being dealt with at once. This * method can be used in those cases, but it is marked protected and @internal * to discourage use, since it is not robust for dealing with the full * lifecycle of entity creation or updates, or for computed fields and * properties. * * @param string $field_name * The field name. * @param string $property * The field property, usually 'value' for single property field types. * @param int $delta * The field delta. * * @return mixed * The value of the field property, or NULL. * * @internal */ protected function getFieldValue(string $field_name, string $property, int $delta = 0): mixed { // Attempt to get the value from the values directly if the field is not // initialized yet. if (!isset($this->fields[$field_name]) && isset($this->values[$field_name])) { $langcode = match(TRUE) { \array_key_exists($this->activeLangcode, $this->values[$field_name]) => $this->activeLangcode, \array_key_exists(LanguageInterface::LANGCODE_DEFAULT, $this->values[$field_name]) => LanguageInterface::LANGCODE_DEFAULT, default => NULL, }; if ($langcode !== NULL) { // If there are field values, try to get the property value. return match (TRUE) { // Configurable/Multi-value fields are stored differently, try // accessing with delta and property first, then without delta. isset($this->values[$field_name][$langcode][$delta][$property]) => $this->values[$field_name][$langcode][$delta][$property], isset($this->values[$field_name][$langcode][$property]) => $this->values[$field_name][$langcode][$property], // If the values are scalar, just return that. !is_array($this->values[$field_name][$langcode]) => $this->values[$field_name][$langcode], default => NULL, }; } } // Fall back to access the property through the field object. $field_value = $this->get($field_name)->get($delta); if ($field_value !== NULL) { return $field_value->$property; } // $delta does not exist in value list. return NULL; } } core/modules/user/src/Entity/User.php +6 −5 Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityChangedTrait; use Drupal\Core\Entity\EntityFieldValueTrait; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; Loading Loading @@ -78,7 +79,7 @@ )] class User extends ContentEntityBase implements UserInterface { use EntityChangedTrait; use EntityChangedTrait, EntityFieldValueTrait; /** * Stores a reference for a reusable anonymous user entity. Loading Loading @@ -288,7 +289,7 @@ public function getCreatedTime() { * {@inheritdoc} */ public function getLastAccessedTime() { return $this->get('access')->value; return $this->getFieldValue('access', 'value'); } /** Loading Loading @@ -318,7 +319,7 @@ public function setLastLoginTime($timestamp) { * {@inheritdoc} */ public function isActive() { return $this->get('status')->value == 1; return $this->getFieldValue('status', 'value') == 1; } /** Loading Loading @@ -351,7 +352,7 @@ public function block() { * {@inheritdoc} */ public function getTimeZone() { return $this->get('timezone')->value; return $this->getFieldValue('timezone', 'value'); } /** Loading Loading @@ -407,7 +408,7 @@ public function isAnonymous() { * {@inheritdoc} */ public function getAccountName() { return $this->get('name')->value ?: ''; return $this->getFieldValue('name', 'value') ?: ''; } /** Loading core/modules/user/tests/src/Kernel/UserFieldValueTest.php 0 → 100644 +196 −0 Original line number Diff line number Diff line <?php declare(strict_types=1); namespace Drupal\Tests\user\Kernel; use Drupal\KernelTests\KernelTestBase; use Drupal\user\Entity\User; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** * Tests fast scalar field access on the user entity. * * @see \Drupal\Core\Entity\EntityFieldValueTrait */ #[Group('user')] #[RunTestsInSeparateProcesses] class UserFieldValueTest extends KernelTestBase { /** * {@inheritdoc} */ protected static $modules = [ 'user', ]; /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); $this->installEntitySchema('user'); } /** * Tests that fast scalar access matches typed data without field init. */ public function testFastPathMatchesTypedDataOnLoadedEntity(): void { $saved = $this->createUserEntity(); /** @var \Drupal\user\Entity\User $fast_user */ $fast_user = User::load($saved->id()); /** @var \Drupal\user\Entity\User $typed_user */ $typed_user = User::load($saved->id()); $this->assertSame($typed_user->get('name')->value, $fast_user->getAccountName()); $this->assertSame($typed_user->get('timezone')->value, $fast_user->getTimeZone()); $this->assertSame($typed_user->get('status')->value == 1, $fast_user->isActive()); $this->assertEquals($typed_user->get('access')->value, $fast_user->getLastAccessedTime()); } /** * Tests that the fast path falls back to initialized field objects. */ public function testFastPathFallsBackToInitializedFieldObjects(): void { $user = User::create([ 'name' => 'initial-name', 'mail' => 'initial@example.com', ]); $user->get('name')->value = 'updated-name'; $user->get('status')->value = 0; $user->get('access')->value = 987654321; $user->get('timezone')->value = 'UTC'; $this->assertSame('updated-name', $user->getAccountName()); $this->assertFalse($user->isActive()); $this->assertSame('UTC', $user->getTimeZone()); $this->assertEquals($user->get('access')->value, $user->getLastAccessedTime()); } /** * Tests that missing raw values still match the regular field API. */ public function testFastPathHandlesMissingRawValues(): void { $fast_user = User::create([ 'name' => 'fresh-user', 'mail' => 'fresh-user@example.com', ]); $typed_user = User::create([ 'name' => 'fresh-user', 'mail' => 'fresh-user@example.com', ]); $this->assertSame($typed_user->get('timezone')->value, $fast_user->getTimeZone()); $this->assertSame($typed_user->get('status')->value == 1, $fast_user->isActive()); $this->assertEquals($typed_user->get('access')->value, $fast_user->getLastAccessedTime()); } /** * Profiles memory usage of getFieldValue() vs typed data field access. * * Not a pass/fail test — reports measurements to help developers understand * the memory characteristics of each access path. Run with --display-notices * or check test output for the report. * * Example output (PHP 8.5, SQLite): * Entity load: ~23KB, getFieldValue: ~280 bytes, get()->value: ~4KB * Batch with resetCache: 0 bytes growth for both paths. */ public function testMemoryProfile(): void { $storage = \Drupal::entityTypeManager()->getStorage('user'); for ($i = 0; $i < 10; $i++) { $this->createUserEntity(['name' => "profiler$i", 'mail' => "profiler$i@example.com"]); } $storage->resetCache(); gc_collect_cycles(); $uids = array_values($storage->getQuery()->accessCheck(FALSE)->execute()); $uid = $uids[1]; // Measure single entity. gc_collect_cycles(); $before = memory_get_usage(); $account = $storage->load($uid); $after_load = memory_get_usage(); $account->getAccountName(); $after_trait = memory_get_usage(); $account->get('mail')->value; $after_typed = memory_get_usage(); $storage->resetCache([$uid]); unset($account); gc_collect_cycles(); $after_cleanup = memory_get_usage(); // Measure batch — trait path. gc_collect_cycles(); $b1 = memory_get_usage(); foreach ($uids as $u) { $a = $storage->load($u); $a->getAccountName(); $a->isActive(); $a->getTimeZone(); $storage->resetCache([$u]); } unset($a); gc_collect_cycles(); $a1 = memory_get_usage(); // Measure batch — typed data path. gc_collect_cycles(); $b2 = memory_get_usage(); foreach ($uids as $u) { $a = $storage->load($u); $a->get('name')->value; $a->get('status')->value; $a->get('timezone')->value; $storage->resetCache([$u]); } unset($a); gc_collect_cycles(); $a2 = memory_get_usage(); $report = sprintf( "Memory profile (PHP %s):\n" . " Entity load: %d bytes\n" . " getFieldValue: %d bytes\n" . " get()->value: %d bytes\n" . " After cleanup: %d bytes residual\n" . " Batch trait (%d): %d bytes growth\n" . " Batch typed (%d): %d bytes growth", PHP_VERSION, $after_load - $before, $after_trait - $after_load, $after_typed - $after_trait, $after_cleanup - $before, count($uids), $a1 - $b1, count($uids), $a2 - $b2, ); // Report via notice so it shows with --display-notices. trigger_error($report, E_USER_NOTICE); $this->addToAssertionCount(1); } /** * Creates and saves a user entity for field-value tests. */ private function createUserEntity(array $values = []): User { $name = $values['name'] ?? $this->randomMachineName(); $mail = $values['mail'] ?? $name . '@example.com'; $user = User::create($values + [ 'name' => $name, 'mail' => $mail, 'status' => 1, 'access' => 123456789, 'timezone' => 'Europe/Berlin', ]); $user->save(); return $user; } } core/tests/Drupal/KernelTests/Core/Entity/EntityFieldValueTraitTest.php 0 → 100644 +218 −0 Original line number Diff line number Diff line <?php declare(strict_types=1); namespace Drupal\KernelTests\Core\Entity; use Drupal\KernelTests\KernelTestBase; use Drupal\user\Entity\User; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** * Tests the EntityFieldValueTrait. */ #[Group('entity_api')] #[RunTestsInSeparateProcesses] class EntityFieldValueTraitTest extends KernelTestBase { /** * {@inheritdoc} */ protected static $modules = ['user', 'field']; /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); $this->installEntitySchema('user'); } /** * Calls protected getFieldValue() method via reflection. */ private function getFieldValue(User $account, string $field_name, string $property, int $delta = 0): mixed { $reflection = new \ReflectionClass($account); return $reflection->getMethod('getFieldValue')->invoke($account, $field_name, $property, $delta); } /** * Tests getFieldValue() via User entity methods. */ public function testGetFieldValueViaUserMethods(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'test@example.com', 'status' => 1, 'timezone' => 'UTC', ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->assertTrue($account->isActive()); $this->assertSame('user1', $account->getAccountName()); $this->assertSame('UTC', $account->getTimeZone()); $this->assertIsNumeric($account->getLastAccessedTime()); } /** * Tests getFieldValue() returns correct values. */ public function testGetFieldValueCorrectness(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'test@example.com', 'status' => 0, ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value')); $this->assertSame('test@example.com', $this->getFieldValue($account, 'mail', 'value')); $this->assertEquals(0, $this->getFieldValue($account, 'status', 'value')); } /** * Tests getFieldValue() without typed data initialization. */ public function testGetFieldValueWithoutTypedDataInitialization(): void { $account = User::create(['name' => 'user1']); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value')); $this->assertSame('', $this->getFieldValue($account, 'mail', 'value')); } /** * Tests getFieldValue() falls back to initialized field objects. */ public function testGetFieldValueFallsBackToInitializedFieldObjects(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'before@example.com', ]); $account->get('name')->value = 'user2'; $account->get('mail')->value = 'after@example.com'; $account->get('status')->value = 0; $account->get('timezone')->value = 'UTC'; $this->assertSame('user2', $this->getFieldValue($account, 'name', 'value')); $this->assertSame('after@example.com', $this->getFieldValue($account, 'mail', 'value')); $this->assertEquals(0, $this->getFieldValue($account, 'status', 'value')); $this->assertSame('UTC', $this->getFieldValue($account, 'timezone', 'value')); } /** * Tests getFieldValue() with delta parameter. */ public function testGetFieldValueWithDelta(): void { $account = User::create(['name' => 'user1']); $account->save(); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value', 0)); $this->assertNull($this->getFieldValue($account, 'name', 'value', 1)); } /** * Tests getFieldValue() consistency with traditional access. */ public function testGetFieldValueConsistencyWithTraditionalAccess(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'consistency@example.com', 'status' => 1, ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->assertSame($account->get('name')->value, $this->getFieldValue($account, 'name', 'value')); $this->assertSame($account->get('mail')->value, $this->getFieldValue($account, 'mail', 'value')); $this->assertSame($account->get('status')->value, $this->getFieldValue($account, 'status', 'value')); } /** * Tests getFieldValue() with scalar values. */ public function testGetFieldValueWithScalarValues(): void { $account = User::create([ 'name' => 'user1', 'status' => 0, ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); // Zero can be stored as '0' or 0 - use loose comparison. $this->assertEquals(0, $this->getFieldValue($account, 'status', 'value')); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value')); } /** * Tests getFieldValue() with non-existent field. */ public function testGetFieldValueWithNonExistentField(): void { $account = User::create(['name' => 'user1']); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Field nonexistent_field is unknown.'); $this->getFieldValue($account, 'nonexistent_field', 'value'); } /** * Tests memory reclamation after field access. * * Verifies that loading entities one at a time with proper cache reset does * not leak memory. Uses a warmup phase to stabilize one-time allocations, * then measures growth over a second batch of entities. * * Regression test for memory leaks described in: * - https://www.drupal.org/project/drupal/issues/3572625 * - https://www.drupal.org/project/drupal/issues/3573982 */ public function testMemoryReclamationAfterFieldAccess(): void { $total = 200; for ($i = 0; $i < $total; $i++) { User::create([ 'name' => "user$i", 'mail' => "user$i@example.com", 'status' => 1, ])->save(); } $storage = \Drupal::entityTypeManager()->getStorage('user'); $uids = array_values($storage->getQuery()->accessCheck(FALSE)->execute()); // Warmup: process first batch to stabilize one-time allocations // (autoloader, internal caches, buffers). $warmup_count = 50; for ($i = 0; $i < $warmup_count && $i < count($uids); $i++) { $account = $storage->load($uids[$i]); $this->getFieldValue($account, 'name', 'value'); $storage->resetCache([$uids[$i]]); } gc_collect_cycles(); $memory_after_warmup = memory_get_usage(); // Process remaining entities and measure growth. for ($i = $warmup_count; $i < count($uids); $i++) { $account = $storage->load($uids[$i]); $this->getFieldValue($account, 'name', 'value'); $storage->resetCache([$uids[$i]]); } gc_collect_cycles(); $memory_after_all = memory_get_usage(); // With proper cleanup, processing 150 additional entities should not // grow memory significantly. A leak (e.g. retained FieldItemList // references) would show ~2-5KB per entity, easily exceeding 100KB. $this->assertLessThan(100 * 1024, $memory_after_all - $memory_after_warmup); } } Loading
core/lib/Drupal/Core/Entity/ContentEntityDeleteForm.php +3 −1 Original line number Diff line number Diff line Loading @@ -62,7 +62,9 @@ public function submitForm(array &$form, FormStateInterface $form_state) { // Make sure that deleting a translation does not delete the whole entity. if (!$entity->isDefaultTranslation()) { $untranslated_entity = $entity->getUntranslated(); // Clone the entity before deleting, as other methods will need to access // the label and other data of the deleted translation. $untranslated_entity = clone $entity->getUntranslated(); $untranslated_entity->removeTranslation($entity->language()->getId()); $untranslated_entity->save(); $form_state->setRedirectUrl($untranslated_entity->toUrl('canonical')); Loading
core/lib/Drupal/Core/Entity/EntityFieldValueTrait.php 0 → 100644 +74 −0 Original line number Diff line number Diff line <?php declare(strict_types=1); namespace Drupal\Core\Entity; use Drupal\Core\Language\LanguageInterface; /** * Adds a ::getFieldValue() method suitable for use with content entities. * * @ingroup entity_api */ trait EntityFieldValueTrait { /** * Gets the value of a field property directly, bypassing the typed data API. * * For certain use cases, it can be desirable to avoid the overhead of * creating FieldItemList and ItemList objects in order to access certain * properties of entities. This is particularly true where the access would be * the only interaction with the entity system for an entire response, or * where a very large number of entities are being dealt with at once. This * method can be used in those cases, but it is marked protected and @internal * to discourage use, since it is not robust for dealing with the full * lifecycle of entity creation or updates, or for computed fields and * properties. * * @param string $field_name * The field name. * @param string $property * The field property, usually 'value' for single property field types. * @param int $delta * The field delta. * * @return mixed * The value of the field property, or NULL. * * @internal */ protected function getFieldValue(string $field_name, string $property, int $delta = 0): mixed { // Attempt to get the value from the values directly if the field is not // initialized yet. if (!isset($this->fields[$field_name]) && isset($this->values[$field_name])) { $langcode = match(TRUE) { \array_key_exists($this->activeLangcode, $this->values[$field_name]) => $this->activeLangcode, \array_key_exists(LanguageInterface::LANGCODE_DEFAULT, $this->values[$field_name]) => LanguageInterface::LANGCODE_DEFAULT, default => NULL, }; if ($langcode !== NULL) { // If there are field values, try to get the property value. return match (TRUE) { // Configurable/Multi-value fields are stored differently, try // accessing with delta and property first, then without delta. isset($this->values[$field_name][$langcode][$delta][$property]) => $this->values[$field_name][$langcode][$delta][$property], isset($this->values[$field_name][$langcode][$property]) => $this->values[$field_name][$langcode][$property], // If the values are scalar, just return that. !is_array($this->values[$field_name][$langcode]) => $this->values[$field_name][$langcode], default => NULL, }; } } // Fall back to access the property through the field object. $field_value = $this->get($field_name)->get($delta); if ($field_value !== NULL) { return $field_value->$property; } // $delta does not exist in value list. return NULL; } }
core/modules/user/src/Entity/User.php +6 −5 Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityChangedTrait; use Drupal\Core\Entity\EntityFieldValueTrait; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; Loading Loading @@ -78,7 +79,7 @@ )] class User extends ContentEntityBase implements UserInterface { use EntityChangedTrait; use EntityChangedTrait, EntityFieldValueTrait; /** * Stores a reference for a reusable anonymous user entity. Loading Loading @@ -288,7 +289,7 @@ public function getCreatedTime() { * {@inheritdoc} */ public function getLastAccessedTime() { return $this->get('access')->value; return $this->getFieldValue('access', 'value'); } /** Loading Loading @@ -318,7 +319,7 @@ public function setLastLoginTime($timestamp) { * {@inheritdoc} */ public function isActive() { return $this->get('status')->value == 1; return $this->getFieldValue('status', 'value') == 1; } /** Loading Loading @@ -351,7 +352,7 @@ public function block() { * {@inheritdoc} */ public function getTimeZone() { return $this->get('timezone')->value; return $this->getFieldValue('timezone', 'value'); } /** Loading Loading @@ -407,7 +408,7 @@ public function isAnonymous() { * {@inheritdoc} */ public function getAccountName() { return $this->get('name')->value ?: ''; return $this->getFieldValue('name', 'value') ?: ''; } /** Loading
core/modules/user/tests/src/Kernel/UserFieldValueTest.php 0 → 100644 +196 −0 Original line number Diff line number Diff line <?php declare(strict_types=1); namespace Drupal\Tests\user\Kernel; use Drupal\KernelTests\KernelTestBase; use Drupal\user\Entity\User; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** * Tests fast scalar field access on the user entity. * * @see \Drupal\Core\Entity\EntityFieldValueTrait */ #[Group('user')] #[RunTestsInSeparateProcesses] class UserFieldValueTest extends KernelTestBase { /** * {@inheritdoc} */ protected static $modules = [ 'user', ]; /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); $this->installEntitySchema('user'); } /** * Tests that fast scalar access matches typed data without field init. */ public function testFastPathMatchesTypedDataOnLoadedEntity(): void { $saved = $this->createUserEntity(); /** @var \Drupal\user\Entity\User $fast_user */ $fast_user = User::load($saved->id()); /** @var \Drupal\user\Entity\User $typed_user */ $typed_user = User::load($saved->id()); $this->assertSame($typed_user->get('name')->value, $fast_user->getAccountName()); $this->assertSame($typed_user->get('timezone')->value, $fast_user->getTimeZone()); $this->assertSame($typed_user->get('status')->value == 1, $fast_user->isActive()); $this->assertEquals($typed_user->get('access')->value, $fast_user->getLastAccessedTime()); } /** * Tests that the fast path falls back to initialized field objects. */ public function testFastPathFallsBackToInitializedFieldObjects(): void { $user = User::create([ 'name' => 'initial-name', 'mail' => 'initial@example.com', ]); $user->get('name')->value = 'updated-name'; $user->get('status')->value = 0; $user->get('access')->value = 987654321; $user->get('timezone')->value = 'UTC'; $this->assertSame('updated-name', $user->getAccountName()); $this->assertFalse($user->isActive()); $this->assertSame('UTC', $user->getTimeZone()); $this->assertEquals($user->get('access')->value, $user->getLastAccessedTime()); } /** * Tests that missing raw values still match the regular field API. */ public function testFastPathHandlesMissingRawValues(): void { $fast_user = User::create([ 'name' => 'fresh-user', 'mail' => 'fresh-user@example.com', ]); $typed_user = User::create([ 'name' => 'fresh-user', 'mail' => 'fresh-user@example.com', ]); $this->assertSame($typed_user->get('timezone')->value, $fast_user->getTimeZone()); $this->assertSame($typed_user->get('status')->value == 1, $fast_user->isActive()); $this->assertEquals($typed_user->get('access')->value, $fast_user->getLastAccessedTime()); } /** * Profiles memory usage of getFieldValue() vs typed data field access. * * Not a pass/fail test — reports measurements to help developers understand * the memory characteristics of each access path. Run with --display-notices * or check test output for the report. * * Example output (PHP 8.5, SQLite): * Entity load: ~23KB, getFieldValue: ~280 bytes, get()->value: ~4KB * Batch with resetCache: 0 bytes growth for both paths. */ public function testMemoryProfile(): void { $storage = \Drupal::entityTypeManager()->getStorage('user'); for ($i = 0; $i < 10; $i++) { $this->createUserEntity(['name' => "profiler$i", 'mail' => "profiler$i@example.com"]); } $storage->resetCache(); gc_collect_cycles(); $uids = array_values($storage->getQuery()->accessCheck(FALSE)->execute()); $uid = $uids[1]; // Measure single entity. gc_collect_cycles(); $before = memory_get_usage(); $account = $storage->load($uid); $after_load = memory_get_usage(); $account->getAccountName(); $after_trait = memory_get_usage(); $account->get('mail')->value; $after_typed = memory_get_usage(); $storage->resetCache([$uid]); unset($account); gc_collect_cycles(); $after_cleanup = memory_get_usage(); // Measure batch — trait path. gc_collect_cycles(); $b1 = memory_get_usage(); foreach ($uids as $u) { $a = $storage->load($u); $a->getAccountName(); $a->isActive(); $a->getTimeZone(); $storage->resetCache([$u]); } unset($a); gc_collect_cycles(); $a1 = memory_get_usage(); // Measure batch — typed data path. gc_collect_cycles(); $b2 = memory_get_usage(); foreach ($uids as $u) { $a = $storage->load($u); $a->get('name')->value; $a->get('status')->value; $a->get('timezone')->value; $storage->resetCache([$u]); } unset($a); gc_collect_cycles(); $a2 = memory_get_usage(); $report = sprintf( "Memory profile (PHP %s):\n" . " Entity load: %d bytes\n" . " getFieldValue: %d bytes\n" . " get()->value: %d bytes\n" . " After cleanup: %d bytes residual\n" . " Batch trait (%d): %d bytes growth\n" . " Batch typed (%d): %d bytes growth", PHP_VERSION, $after_load - $before, $after_trait - $after_load, $after_typed - $after_trait, $after_cleanup - $before, count($uids), $a1 - $b1, count($uids), $a2 - $b2, ); // Report via notice so it shows with --display-notices. trigger_error($report, E_USER_NOTICE); $this->addToAssertionCount(1); } /** * Creates and saves a user entity for field-value tests. */ private function createUserEntity(array $values = []): User { $name = $values['name'] ?? $this->randomMachineName(); $mail = $values['mail'] ?? $name . '@example.com'; $user = User::create($values + [ 'name' => $name, 'mail' => $mail, 'status' => 1, 'access' => 123456789, 'timezone' => 'Europe/Berlin', ]); $user->save(); return $user; } }
core/tests/Drupal/KernelTests/Core/Entity/EntityFieldValueTraitTest.php 0 → 100644 +218 −0 Original line number Diff line number Diff line <?php declare(strict_types=1); namespace Drupal\KernelTests\Core\Entity; use Drupal\KernelTests\KernelTestBase; use Drupal\user\Entity\User; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** * Tests the EntityFieldValueTrait. */ #[Group('entity_api')] #[RunTestsInSeparateProcesses] class EntityFieldValueTraitTest extends KernelTestBase { /** * {@inheritdoc} */ protected static $modules = ['user', 'field']; /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); $this->installEntitySchema('user'); } /** * Calls protected getFieldValue() method via reflection. */ private function getFieldValue(User $account, string $field_name, string $property, int $delta = 0): mixed { $reflection = new \ReflectionClass($account); return $reflection->getMethod('getFieldValue')->invoke($account, $field_name, $property, $delta); } /** * Tests getFieldValue() via User entity methods. */ public function testGetFieldValueViaUserMethods(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'test@example.com', 'status' => 1, 'timezone' => 'UTC', ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->assertTrue($account->isActive()); $this->assertSame('user1', $account->getAccountName()); $this->assertSame('UTC', $account->getTimeZone()); $this->assertIsNumeric($account->getLastAccessedTime()); } /** * Tests getFieldValue() returns correct values. */ public function testGetFieldValueCorrectness(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'test@example.com', 'status' => 0, ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value')); $this->assertSame('test@example.com', $this->getFieldValue($account, 'mail', 'value')); $this->assertEquals(0, $this->getFieldValue($account, 'status', 'value')); } /** * Tests getFieldValue() without typed data initialization. */ public function testGetFieldValueWithoutTypedDataInitialization(): void { $account = User::create(['name' => 'user1']); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value')); $this->assertSame('', $this->getFieldValue($account, 'mail', 'value')); } /** * Tests getFieldValue() falls back to initialized field objects. */ public function testGetFieldValueFallsBackToInitializedFieldObjects(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'before@example.com', ]); $account->get('name')->value = 'user2'; $account->get('mail')->value = 'after@example.com'; $account->get('status')->value = 0; $account->get('timezone')->value = 'UTC'; $this->assertSame('user2', $this->getFieldValue($account, 'name', 'value')); $this->assertSame('after@example.com', $this->getFieldValue($account, 'mail', 'value')); $this->assertEquals(0, $this->getFieldValue($account, 'status', 'value')); $this->assertSame('UTC', $this->getFieldValue($account, 'timezone', 'value')); } /** * Tests getFieldValue() with delta parameter. */ public function testGetFieldValueWithDelta(): void { $account = User::create(['name' => 'user1']); $account->save(); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value', 0)); $this->assertNull($this->getFieldValue($account, 'name', 'value', 1)); } /** * Tests getFieldValue() consistency with traditional access. */ public function testGetFieldValueConsistencyWithTraditionalAccess(): void { $account = User::create([ 'name' => 'user1', 'mail' => 'consistency@example.com', 'status' => 1, ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->assertSame($account->get('name')->value, $this->getFieldValue($account, 'name', 'value')); $this->assertSame($account->get('mail')->value, $this->getFieldValue($account, 'mail', 'value')); $this->assertSame($account->get('status')->value, $this->getFieldValue($account, 'status', 'value')); } /** * Tests getFieldValue() with scalar values. */ public function testGetFieldValueWithScalarValues(): void { $account = User::create([ 'name' => 'user1', 'status' => 0, ]); $account->save(); $account = User::load($account->id()); assert($account instanceof User); // Zero can be stored as '0' or 0 - use loose comparison. $this->assertEquals(0, $this->getFieldValue($account, 'status', 'value')); $this->assertSame('user1', $this->getFieldValue($account, 'name', 'value')); } /** * Tests getFieldValue() with non-existent field. */ public function testGetFieldValueWithNonExistentField(): void { $account = User::create(['name' => 'user1']); $account->save(); $account = User::load($account->id()); assert($account instanceof User); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Field nonexistent_field is unknown.'); $this->getFieldValue($account, 'nonexistent_field', 'value'); } /** * Tests memory reclamation after field access. * * Verifies that loading entities one at a time with proper cache reset does * not leak memory. Uses a warmup phase to stabilize one-time allocations, * then measures growth over a second batch of entities. * * Regression test for memory leaks described in: * - https://www.drupal.org/project/drupal/issues/3572625 * - https://www.drupal.org/project/drupal/issues/3573982 */ public function testMemoryReclamationAfterFieldAccess(): void { $total = 200; for ($i = 0; $i < $total; $i++) { User::create([ 'name' => "user$i", 'mail' => "user$i@example.com", 'status' => 1, ])->save(); } $storage = \Drupal::entityTypeManager()->getStorage('user'); $uids = array_values($storage->getQuery()->accessCheck(FALSE)->execute()); // Warmup: process first batch to stabilize one-time allocations // (autoloader, internal caches, buffers). $warmup_count = 50; for ($i = 0; $i < $warmup_count && $i < count($uids); $i++) { $account = $storage->load($uids[$i]); $this->getFieldValue($account, 'name', 'value'); $storage->resetCache([$uids[$i]]); } gc_collect_cycles(); $memory_after_warmup = memory_get_usage(); // Process remaining entities and measure growth. for ($i = $warmup_count; $i < count($uids); $i++) { $account = $storage->load($uids[$i]); $this->getFieldValue($account, 'name', 'value'); $storage->resetCache([$uids[$i]]); } gc_collect_cycles(); $memory_after_all = memory_get_usage(); // With proper cleanup, processing 150 additional entities should not // grow memory significantly. A leak (e.g. retained FieldItemList // references) would show ~2-5KB per entity, easily exceeding 100KB. $this->assertLessThan(100 * 1024, $memory_after_all - $memory_after_warmup); } }