diff --git a/UPGRADING.md b/UPGRADING.md index fbd39266..2b8619aa 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -32,6 +32,26 @@ DrupalExtension integrations) may need updating. - `Drupal\Driver\Core\AbstractCore` - merged into `Core`. Custom cores should extend `Core` directly and override the methods they need. +### Field classification moved to `FieldClassifier` + +The predicates `fieldExists()` and `fieldIsBase()` are gone from `DrupalDriver` +and `Core`. The empty `FieldCapabilityInterface` is removed entirely. Field +classification into the nine F-row categories (F1-F9) now lives on +`Drupal\Driver\Core\Field\FieldClassifierInterface`, implemented by +`Drupal\Driver\Core\Field\FieldClassifier`. See +`src/Drupal/Driver/Core/Field/README.md` for the full truth table. + +If consumer code called `$driver->fieldExists(...)` or `$driver->fieldIsBase(...)`, +replace with: + +- `fieldExists($type, $name)` → `$core->classifier()->fieldIsConfigurable($type, $name)` (if you were checking for a configurable field) +- `fieldIsBase($type, $name)` → `$core->classifier()->fieldIsBaseStandard($type, $name)` (or one of the more specific F-row predicates, depending on intent) + +The classifier is the single source of truth for field classification; the +old two-predicate API was insufficient to distinguish F1-F9 correctly and +caused downstream bugs (notably with computed writable base fields like +`moderation_state`). + ### DrushDriver no longer supports Content or Field capabilities `DrushDriver` used to rely on a companion module installed on the @@ -39,19 +59,17 @@ site-under-test to provide entity CRUD and field introspection over Drush. That indirection has been removed: `DrushDriver` now exposes only operations that Drush services natively. -`DrushDriverInterface` no longer extends `ContentCapabilityInterface` or -`FieldCapabilityInterface`. The following methods are gone from `DrushDriver`: +`DrushDriverInterface` no longer extends `ContentCapabilityInterface`. The +following methods are gone from `DrushDriver`: - `nodeCreate`, `nodeDelete` - `termCreate`, `termDelete` - `entityCreate`, `entityDelete` -- `fieldExists`, `fieldIsBase` -Consumers that need entity CRUD or field introspection should use -`DrupalDriver` (which bootstraps Drupal and delegates to `Core`) or -implement the missing behaviour themselves. Test the capability with -`instanceof ContentCapabilityInterface` / `instanceof FieldCapabilityInterface` -before calling. +Consumers that need entity CRUD should use `DrupalDriver` (which bootstraps +Drupal and delegates to `Core`) or implement the missing behaviour themselves. +Test the capability with `instanceof ContentCapabilityInterface` before +calling. ### CoreInterface expanded @@ -73,8 +91,6 @@ that capability): | `createNode` | `nodeCreate` | Content | | `createTerm` | `termCreate` | Content | | `createEntity` | `entityCreate` | Content | -| `isField` | `fieldExists` | Field | -| `isBaseField` | `fieldIsBase` | Field | | `clearCache` | `cacheClear` | Cache | | `clearStaticCaches` | `cacheClearStatic` | Cache | | `runCron` | `cronRun` | Cron | diff --git a/src/Drupal/Driver/Capability/FieldCapabilityInterface.php b/src/Drupal/Driver/Capability/FieldCapabilityInterface.php deleted file mode 100644 index c10fe29a..00000000 --- a/src/Drupal/Driver/Capability/FieldCapabilityInterface.php +++ /dev/null @@ -1,38 +0,0 @@ -getEntityFieldManager()); + } + + /** + * {@inheritdoc} + */ + public function classifier(): FieldClassifierInterface { + if (!$this->fieldClassifier instanceof FieldClassifierInterface) { + $this->fieldClassifier = $this->createFieldClassifier(); + } + + return $this->fieldClassifier; + } + /** * {@inheritdoc} */ public function getFieldHandler(object $entity, string $entity_type, string $field_name): FieldHandlerInterface { - $field_types = $this->getEntityFieldTypes($entity_type, [$field_name]); + $bundle = $this->resolveBundleFromEntity($entity_type, $entity); + $field_types = $this->getEntityFieldTypes($entity_type, $bundle); if (!isset($field_types[$field_name])) { throw new \RuntimeException(sprintf('Field "%s" not found on entity type "%s".', $field_name, $entity_type)); @@ -200,21 +229,26 @@ public function getFieldHandler(object $entity, string $entity_type, string $fie * The entity type ID. * @param \stdClass $entity * Entity object. - * @param array $base_fields - * Optional. Define base fields that will be expanded in addition to user - * defined fields. */ - protected function expandEntityFields(string $entity_type, \stdClass $entity, array $base_fields = []): void { - // Include any base fields present as properties on the entity object so - // their values travel through the field-handler pipeline alongside - // configured fields. Without this, base entity-reference fields such as - // 'commerce_product.variations' or 'user.roles' would never reach - // EntityReferenceHandler and could not be populated from a stub. - $base_fields = array_values(array_unique(array_merge($base_fields, $this->detectBaseFieldsOnEntity($entity_type, $entity)))); + protected function expandEntityFields(string $entity_type, \stdClass $entity): void { + $definition = $this->loadEntityTypeDefinition($entity_type); - $field_types = $this->getEntityFieldTypes($entity_type, $base_fields); + // The id key and bundle key identify the record itself and must not pass + // through the handler pipeline. For example, on 'commerce_product' the + // bundle key 'type' is also a base entity_reference field; expanding it + // would resolve the bundle machine name through EntityReferenceHandler + // and overwrite the scalar with ['target_id' => ...], corrupting every + // subsequent bundle lookup for the same stub. + $skip = array_filter([$definition->getKey('id'), $definition->getKey('bundle')]); + + $bundle = $this->resolveBundleFromEntity($entity_type, $entity); + $field_types = $this->getEntityFieldTypes($entity_type, $bundle); foreach (array_keys($field_types) as $field_name) { + if (in_array($field_name, $skip, TRUE)) { + continue; + } + if (isset($entity->$field_name)) { $entity->$field_name = $this->getFieldHandler($entity, $entity_type, $field_name) ->expand($entity->$field_name); @@ -223,39 +257,33 @@ protected function expandEntityFields(string $entity_type, \stdClass $entity, ar } /** - * Returns the names of base fields set as properties on the entity. + * Resolves the bundle for an entity stub. * - * The entity type's id key and bundle key are excluded: they identify the - * record itself and must not pass through the field-handler pipeline, where - * they would be treated as look-up values (e.g. expanding node's 'type' - * through EntityReferenceHandler would try to resolve the bundle string as - * a NodeType config entity reference and discard the plain string form). + * Consults the entity type's bundle key first, then 'step_bundle', then + * falls back to the entity type id (single-bundle entities like 'user' + * use the type id as their bundle). * * @param string $entity_type * The entity type ID. - * @param \stdClass $entity - * Entity stub whose properties are inspected. + * @param object $entity + * Entity stub. * - * @return array - * Base field names whose corresponding property is set on the stub. + * @return string + * Bundle name. Never empty. */ - protected function detectBaseFieldsOnEntity(string $entity_type, \stdClass $entity): array { + protected function resolveBundleFromEntity(string $entity_type, object $entity): string { $definition = $this->loadEntityTypeDefinition($entity_type); - $skip = array_filter([$definition->getKey('id'), $definition->getKey('bundle')]); - - $detected = []; + $bundle_key = $definition->getKey('bundle'); - foreach (array_keys($this->getEntityFieldManager()->getBaseFieldDefinitions($entity_type)) as $field_name) { - if (in_array($field_name, $skip, TRUE)) { - continue; - } + if ($bundle_key && !empty($entity->$bundle_key)) { + return (string) $entity->$bundle_key; + } - if (property_exists($entity, $field_name)) { - $detected[] = $field_name; - } + if (isset($entity->step_bundle) && $entity->step_bundle !== '') { + return (string) $entity->step_bundle; } - return $detected; + return $entity_type; } /** @@ -798,38 +826,35 @@ public function getExtensionPathList(): array { return $paths; } - /** - * Expands specified base fields on the entity object. - * - * @param string $entity_type - * The entity type for which to return the field types. - * @param \StdClass $entity - * Entity object. - * @param array $base_fields - * Base fields to be expanded in addition to user defined fields. - */ - public function expandEntityBaseFields(string $entity_type, \StdClass $entity, array $base_fields): void { - $this->expandEntityFields($entity_type, $entity, $base_fields); - } - /** * {@inheritdoc} */ - public function getEntityFieldTypes(string $entity_type, array $base_fields = []): array { + public function getEntityFieldTypes(string $entity_type, ?string $bundle = NULL): array { $entity_field_manager = $this->getEntityFieldManager(); - $fields = $entity_field_manager->getFieldStorageDefinitions($entity_type); + $bundle_fields = []; + $fields = $entity_field_manager->getFieldStorageDefinitions($entity_type) + + $entity_field_manager->getBaseFieldDefinitions($entity_type); - if ($base_fields !== []) { - $fields += $entity_field_manager->getBaseFieldDefinitions($entity_type); + if ($bundle !== NULL) { + $bundle_fields = $entity_field_manager->getFieldDefinitions($entity_type, $bundle); + $fields += $bundle_fields; } $types = []; foreach ($fields as $field_name => $field) { - $is_configured = $this->fieldExists($entity_type, $field_name); - $is_requested_base = in_array($field_name, $base_fields) && $this->fieldIsBase($entity_type, $field_name); - - if (!$is_configured && !$is_requested_base) { + // See src/Drupal/Driver/Core/Field/README.md. Only F1, F5, F9 enter the + // expansion pipeline; the OR below names those rows explicitly. + // F5 is additionally scoped to the bundle when known - otherwise a + // configurable field storage attached only to other bundles would slip + // into the type map and blow up in AbstractHandler::__construct(). + $is_base_standard = $this->classifier()->fieldIsBaseStandard($entity_type, $field_name); + $is_configurable = $this->classifier()->fieldIsConfigurable($entity_type, $field_name) + && ($bundle === NULL || isset($bundle_fields[$field_name])); + $is_bundle_storage_backed = $bundle !== NULL + && $this->classifier()->fieldIsBundleStorageBacked($entity_type, $field_name, $bundle); + + if (!$is_base_standard && !$is_configurable && !$is_bundle_storage_backed) { continue; } @@ -839,22 +864,6 @@ public function getEntityFieldTypes(string $entity_type, array $base_fields = [] return $types; } - /** - * {@inheritdoc} - */ - public function fieldExists(string $entity_type, string $field_name): bool { - $fields = $this->getEntityFieldManager()->getFieldStorageDefinitions($entity_type); - return (isset($fields[$field_name]) && $fields[$field_name] instanceof FieldStorageConfig); - } - - /** - * {@inheritdoc} - */ - public function fieldIsBase(string $entity_type, string $field_name): bool { - $base_fields = $this->getEntityFieldManager()->getBaseFieldDefinitions($entity_type); - return isset($base_fields[$field_name]); - } - /** * Returns the entity field manager service. * diff --git a/src/Drupal/Driver/Core/CoreInterface.php b/src/Drupal/Driver/Core/CoreInterface.php index 76e76156..9fc50cf2 100644 --- a/src/Drupal/Driver/Core/CoreInterface.php +++ b/src/Drupal/Driver/Core/CoreInterface.php @@ -11,13 +11,13 @@ use Drupal\Driver\Capability\ConfigCapabilityInterface; use Drupal\Driver\Capability\ContentCapabilityInterface; use Drupal\Driver\Capability\CronCapabilityInterface; -use Drupal\Driver\Capability\FieldCapabilityInterface; use Drupal\Driver\Capability\LanguageCapabilityInterface; use Drupal\Driver\Capability\MailCapabilityInterface; use Drupal\Driver\Capability\ModuleCapabilityInterface; use Drupal\Driver\Capability\RoleCapabilityInterface; use Drupal\Driver\Capability\UserCapabilityInterface; use Drupal\Driver\Capability\WatchdogCapabilityInterface; +use Drupal\Driver\Core\Field\FieldClassifierInterface; use Drupal\Driver\Core\Field\FieldHandlerInterface; /** @@ -34,7 +34,6 @@ interface CoreInterface extends ConfigCapabilityInterface, ContentCapabilityInterface, CronCapabilityInterface, - FieldCapabilityInterface, LanguageCapabilityInterface, MailCapabilityInterface, ModuleCapabilityInterface, @@ -124,14 +123,30 @@ public function registerFieldHandler(string $field_type, string $class): void; /** * Returns the field types for the given entity type. * + * Returns the map of every F1, F5, and F9 field that should be routed + * through the handler pipeline for this entity type. See + * 'src/Drupal/Driver/Core/Field/README.md' for the classification rules. + * * @param string $entity_type * The entity type ID. - * @param array $base_fields - * Optional. Base fields to include alongside user-defined fields. + * @param string|null $bundle + * Optional. Bundle to consult for F9 (storage-backed bundle-attached) + * fields. Without a bundle only F1 and F5 fields surface. * * @return array * Map of field name to field type. */ - public function getEntityFieldTypes(string $entity_type, array $base_fields = []): array; + public function getEntityFieldTypes(string $entity_type, ?string $bundle = NULL): array; + + /** + * Returns the field classifier, lazily instantiating on first access. + * + * Consumers call into the classifier to ask which F-row a field belongs to + * (F1, F2, ..., F9). See 'src/Drupal/Driver/Core/Field/README.md'. + * + * @return \Drupal\Driver\Core\Field\FieldClassifierInterface + * The classifier instance. + */ + public function classifier(): FieldClassifierInterface; } diff --git a/src/Drupal/Driver/Core/Field/DefaultHandler.php b/src/Drupal/Driver/Core/Field/DefaultHandler.php index 36927432..b6bcbcc6 100644 --- a/src/Drupal/Driver/Core/Field/DefaultHandler.php +++ b/src/Drupal/Driver/Core/Field/DefaultHandler.php @@ -5,7 +5,11 @@ namespace Drupal\Driver\Core\Field; /** - * Default field handler for Drupal 8. + * Fallback handler for field types that have no dedicated handler. + * + * Only correct for H1 (single-column scalar) fields. See + * 'src/Drupal/Driver/Core/Field/README.md' for the full handler-selection + * table and the loud-failure policy this class enforces. */ class DefaultHandler extends AbstractHandler { @@ -13,6 +17,20 @@ class DefaultHandler extends AbstractHandler { * {@inheritdoc} */ public function expand(mixed $values): array { + $columns = $this->fieldInfo->getColumns(); + + if (count($columns) !== 1 || !array_key_exists('value', $columns)) { + throw new \RuntimeException(sprintf( + 'No dedicated handler is registered for field "%s" (type "%s") on entity type "%s" bundle "%s", and DefaultHandler cannot marshal it: the field has %d column(s) (%s) and DefaultHandler only supports single-column scalar fields keyed by "value". Implement a dedicated handler for this field type and register it via Core::registerFieldHandler().', + $this->fieldInfo->getName(), + $this->fieldInfo->getType(), + $this->fieldInfo->getTargetEntityTypeId(), + $this->fieldConfig->getTargetBundle() ?? '(none)', + count($columns), + implode(', ', array_keys($columns)), + )); + } + return (array) $values; } diff --git a/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php b/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php new file mode 100644 index 00000000..6da80f6c --- /dev/null +++ b/src/Drupal/Driver/Core/Field/EntityReferenceRevisionsHandler.php @@ -0,0 +1,107 @@ + X, 'target_revision_id' => Y]'. + */ +class EntityReferenceRevisionsHandler extends AbstractHandler { + + /** + * {@inheritdoc} + */ + public function expand($values): array { + $entity_type_id = $this->fieldInfo->getSetting('target_type'); + $entity_type_manager = \Drupal::entityTypeManager(); + $entity_definition = $entity_type_manager->getDefinition($entity_type_id); + $id_key = $entity_definition->getKey('id'); + $label_key = $entity_type_id !== 'user' ? $entity_definition->getKey('label') : 'name'; + $main_property = $this->fieldInfo->getMainPropertyName(); + + $target_bundles = $this->getTargetBundles(); + $target_bundle_key = $target_bundles ? $entity_definition->getKey('bundle') : NULL; + + $storage = $entity_type_manager->getStorage($entity_type_id); + $resolved = []; + + foreach ((array) $values as $value) { + $has_extras = is_string($main_property) && is_array($value) && array_key_exists($main_property, $value); + $lookup = $has_extras ? $value[$main_property] : $value; + + $query = \Drupal::entityQuery($entity_type_id); + $query->accessCheck(FALSE); + + if ($label_key) { + $is_numeric_id = is_int($lookup) || (is_string($lookup) && ctype_digit($lookup)); + $or = $query->orConditionGroup(); + + if ($is_numeric_id) { + $or->condition($id_key, (int) $lookup); + } + + $or->condition($label_key, $lookup); + $query->condition($or); + } + else { + $query->condition($id_key, $lookup); + } + + if ($target_bundles && $target_bundle_key) { + $query->condition($target_bundle_key, $target_bundles, 'IN'); + } + + $entities = $query->execute(); + + if (!$entities) { + throw new \Exception(sprintf("No entity '%s' of type '%s' exists.", $lookup, $entity_type_id)); + } + + $resolved_id = array_shift($entities); + $target = $storage->load($resolved_id); + $revision_id = $target instanceof RevisionableInterface ? $target->getRevisionId() : NULL; + + if ($has_extras) { + $value[$main_property] = $resolved_id; + if ($revision_id !== NULL && !array_key_exists('target_revision_id', $value)) { + $value['target_revision_id'] = $revision_id; + } + $resolved[] = $value; + } + else { + $resolved[] = [ + 'target_id' => $resolved_id, + 'target_revision_id' => $revision_id, + ]; + } + } + + return $resolved; + } + + /** + * Retrieves bundles for which the field is configured to reference. + * + * @return mixed + * Array of bundle names, or NULL if not able to determine bundles. + */ + protected function getTargetBundles(): mixed { + $settings = $this->fieldConfig->getSettings(); + + if (!empty($settings['handler_settings']['target_bundles'])) { + return $settings['handler_settings']['target_bundles']; + } + + return NULL; + } + +} diff --git a/src/Drupal/Driver/Core/Field/FieldClassifier.php b/src/Drupal/Driver/Core/Field/FieldClassifier.php new file mode 100644 index 00000000..13e6f72d --- /dev/null +++ b/src/Drupal/Driver/Core/Field/FieldClassifier.php @@ -0,0 +1,188 @@ +entityFieldManager->getBaseFieldDefinitions($entity_type); + + if (!isset($base[$field_name])) { + return FALSE; + } + + $definition = $base[$field_name]; + + return !$definition->isComputed() && !$definition->getFieldStorageDefinition()->hasCustomStorage(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsBaseComputedReadOnly(string $entity_type, string $field_name): bool { + $base = $this->entityFieldManager->getBaseFieldDefinitions($entity_type); + + if (!isset($base[$field_name])) { + return FALSE; + } + + $definition = $base[$field_name]; + + return $definition->isComputed() && $definition->isReadOnly(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsBaseComputedWritable(string $entity_type, string $field_name): bool { + $base = $this->entityFieldManager->getBaseFieldDefinitions($entity_type); + + if (!isset($base[$field_name])) { + return FALSE; + } + + $definition = $base[$field_name]; + + return $definition->isComputed() && !$definition->isReadOnly(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsBaseCustomStorage(string $entity_type, string $field_name): bool { + $base = $this->entityFieldManager->getBaseFieldDefinitions($entity_type); + + if (!isset($base[$field_name])) { + return FALSE; + } + + $definition = $base[$field_name]; + + return !$definition->isComputed() && $definition->getFieldStorageDefinition()->hasCustomStorage(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsConfigurable(string $entity_type, string $field_name): bool { + $storage = $this->entityFieldManager->getFieldStorageDefinitions($entity_type); + + return isset($storage[$field_name]) && $storage[$field_name] instanceof FieldStorageConfig; + } + + /** + * {@inheritdoc} + */ + public function fieldIsBundleComputedReadOnly(string $entity_type, string $field_name, string $bundle): bool { + if ($this->isBaseField($entity_type, $field_name)) { + return FALSE; + } + + $definition = $this->bundleFieldDefinition($entity_type, $field_name, $bundle); + + if (!$definition instanceof FieldDefinitionInterface) { + return FALSE; + } + + return $definition->isComputed() && $definition->isReadOnly(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsBundleComputedWritable(string $entity_type, string $field_name, string $bundle): bool { + if ($this->isBaseField($entity_type, $field_name)) { + return FALSE; + } + + $definition = $this->bundleFieldDefinition($entity_type, $field_name, $bundle); + + if (!$definition instanceof FieldDefinitionInterface) { + return FALSE; + } + + return $definition->isComputed() && !$definition->isReadOnly(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsBundleCustomStorage(string $entity_type, string $field_name, string $bundle): bool { + if ($this->isBaseField($entity_type, $field_name)) { + return FALSE; + } + + $definition = $this->bundleFieldDefinition($entity_type, $field_name, $bundle); + + if (!$definition instanceof FieldDefinitionInterface) { + return FALSE; + } + + return !$definition->isComputed() && $definition->getFieldStorageDefinition()->hasCustomStorage(); + } + + /** + * {@inheritdoc} + */ + public function fieldIsBundleStorageBacked(string $entity_type, string $field_name, string $bundle): bool { + $storage = $this->entityFieldManager->getFieldStorageDefinitions($entity_type); + + if (!isset($storage[$field_name])) { + return FALSE; + } + + if ($storage[$field_name] instanceof FieldStorageConfig) { + return FALSE; + } + + if ($this->isBaseField($entity_type, $field_name)) { + return FALSE; + } + + $bundle_fields = $this->entityFieldManager->getFieldDefinitions($entity_type, $bundle); + + return isset($bundle_fields[$field_name]); + } + + /** + * Checks whether a field name is in the entity-type-wide base definitions. + */ + protected function isBaseField(string $entity_type, string $field_name): bool { + $base = $this->entityFieldManager->getBaseFieldDefinitions($entity_type); + + return isset($base[$field_name]); + } + + /** + * Returns the bundle-scoped definition for a field, or NULL if absent. + */ + protected function bundleFieldDefinition(string $entity_type, string $field_name, string $bundle): ?FieldDefinitionInterface { + $definitions = $this->entityFieldManager->getFieldDefinitions($entity_type, $bundle); + + return $definitions[$field_name] ?? NULL; + } + +} diff --git a/src/Drupal/Driver/Core/Field/FieldClassifierInterface.php b/src/Drupal/Driver/Core/Field/FieldClassifierInterface.php new file mode 100644 index 00000000..a7fb22e7 --- /dev/null +++ b/src/Drupal/Driver/Core/Field/FieldClassifierInterface.php @@ -0,0 +1,157 @@ + ...]` shape for all single-column scalars. | +| H2 | Multi-column compound | `text_with_summary`, `link`, `address`, `daterange` | Typed handler required (`TextWithSummaryHandler`, `LinkHandler`, `AddressHandler`, `DaterangeHandler`) | `DefaultHandler` must throw if it is invoked on a field type outside H1. See "DefaultHandler loud-failure policy" below. | +| H3 | Simple datetime | `datetime` | `DatetimeHandler` | Parses human date strings to ISO 8601 storage shape. | +| H4 | Entity reference (single target) | `entity_reference`, `file`, `image` | `EntityReferenceHandler`, `FileHandler`, `ImageHandler` | Resolve human-readable label/path/filename to `target_id`. Specialized subclasses for file/image handle alt/title columns. | +| H5 | Entity reference with revision | `entity_reference_revisions` (paragraphs) | `EntityReferenceRevisionsHandler` | Composite `target_id` + `target_revision_id`. Resolves target and auto-populates the current revision id. | +| H6 | Typed list (allowed values) | `list_string`, `list_integer`, `list_float` | `ListStringHandler`, `ListIntegerHandler`, `ListFloatHandler` | Allow matching on label or key; store the key. | +| H7 | Typed list (boolean as list) | `boolean` on list widget | `BooleanHandler` | Accept truthy aliases ("yes", "on", "true", field label). | +| H8 | Name field (contrib) | `name` | `NameHandler` | Composite name components. | +| H9 | Organic Groups reference (contrib) | `og_standard_reference` | `OgStandardReferenceHandler` | OG-specific lookup. | +| H10 | Embedded asset reference (contrib) | `embridge_asset_item` | `EmbridgeAssetItemHandler` | Embridge-specific shape. | + +## Cardinality + +Independent of resolution. Handlers must accept either a scalar or an array. +Internally, they normalize a scalar to `[$scalar]` before returning the storage +shape. No category in the primary table changes behavior based on cardinality. + +## DefaultHandler loud-failure policy + +`DefaultHandler` is the fallback when no typed handler matches a field's type +string. It runs `(array) $value`, which is only correct for H1 (single-column +scalars). For H2-H10 it would silently produce a malformed storage shape that +the entity layer then persists as broken data (entity reference by string +instead of id, datetime stored as raw user input, address fields left null, +etc.). + +**`DefaultHandler` loudly fails when invoked on a field type outside H1.** + +Detection criterion: the field's storage definition has exactly one column (the +canonical `value` column). If the field has multiple columns or its single +column is not named `value`, `DefaultHandler::expand()` throws a clearly-worded +exception identifying the field name, entity type, bundle, and field-type +string, and stating that a dedicated handler must be implemented for this +field type. The error is a direct call to action: implement the handler (or +register one), then re-run. + +In typical scenarios (node title, boolean status, integer counters, etc.) +nothing changes - `DefaultHandler` works as today. In edge cases where a user +stubs a compound field that has no registered handler, they get an immediate, +actionable error instead of silently corrupted data downstream. + +## What each resolution actually means in code + +- **Expand via handler**: field enters `getEntityFieldTypes()`, survives all + predicates, `getFieldHandler()` resolves a handler by field type, + `$entity->$field_name = $handler->expand($entity->$field_name)` runs. +- **Skip entirely**: field never enters `getEntityFieldTypes()`. Stub property + is left exactly as the caller set it. Value flows untouched into the entity + via `Node::create((array) $stub)` or equivalent. The field class, Drupal + storage layer, or module hooks take over from there. + +The intentional consequence of "Skip entirely" for F2-F4 and F6-F8: the driver +treats computed and custom-storage fields as *transparent* - it neither fights +the field's own persistence mechanism nor tries to normalize a value it has no +schema for. The scenario author's raw value arrives at the entity; whatever +happens after is Drupal's and the declaring module's responsibility. + +## Predicate design + +One predicate per F-row, all on `FieldClassifierInterface`, implemented by +`Core\Field\FieldClassifier`. + +| F-row | Predicate | Returns TRUE when | +|-------|--------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| F1 | `fieldIsBaseStandard($type, $name)` | field is in `getBaseFieldDefinitions($type)`, not computed, not custom-storage | +| F2 | `fieldIsBaseComputedReadOnly($type, $name)` | in `getBaseFieldDefinitions($type)`, computed, `isReadOnly()` returns TRUE | +| F3 | `fieldIsBaseComputedWritable($type, $name)` | in `getBaseFieldDefinitions($type)`, computed, `isReadOnly()` returns FALSE | +| F4 | `fieldIsBaseCustomStorage($type, $name)` | in `getBaseFieldDefinitions($type)`, `hasCustomStorage()` returns TRUE | +| F5 | `fieldIsConfigurable($type, $name)` | present in `getFieldStorageDefinitions($type)` as `FieldStorageConfig` instance | +| F6 | `fieldIsBundleComputedReadOnly($type, $name, $bundle)` | in `getFieldDefinitions($type, $bundle)`, computed, read-only, not in base definitions | +| F7 | `fieldIsBundleComputedWritable($type, $name, $bundle)` | in `getFieldDefinitions($type, $bundle)`, computed, writable, not in base definitions | +| F8 | `fieldIsBundleCustomStorage($type, $name, $bundle)` | in `getFieldDefinitions($type, $bundle)`, custom storage, not in base definitions | +| F9 | `fieldIsBundleStorageBacked($type, $name, $bundle)` | present in `getFieldStorageDefinitions($type)` (via `hook_entity_field_storage_info()`), not a `FieldStorageConfig`, not base | + +No aggregate predicate. Code that needs to decide whether a field enters the +expansion pipeline OR's the three expand-row predicates inline: + +```php +if ($this->classifier()->fieldIsBaseStandard($entity_type, $field_name) + || $this->classifier()->fieldIsConfigurable($entity_type, $field_name) + || $this->classifier()->fieldIsBundleStorageBacked($entity_type, $field_name, $bundle)) { + // expand +} +``` + +The verbosity is the point: each call site documents exactly which F-rows it +cares about. If a future F-row needs inclusion, it is added explicitly at each +call site rather than slipped into an aggregate that callers no longer see. + +## Classifier discovery + +The classifier follows the same version-directory pattern the repo uses for +`Core` itself and for field handlers. Future Drupal versions ship their own +classifier in `Core{N}\Field\FieldClassifier`; the parent's discovery logic +picks it up automatically when instantiated from a `Core{N}\Core` subclass. + +`Core` exposes `createFieldClassifier()` as a factory method. Subclasses +override it only when they need a version-specific classifier class: + +```php +// Core11\Core (hypothetical future) +protected function createFieldClassifier(): FieldClassifierInterface { + return new \Drupal\Driver\Core11\Field\FieldClassifier(...); +} +``` + +The default implementation returns the base `FieldClassifier`. The pattern +mirrors `registerDefaultFieldHandlers()`, which likewise allows subclasses to +extend handler registration per version. + +## Pipeline walk-through + +For reference, here is the complete flow when `entityCreate()` is called with +a stub: + +1. `entityCreate($entity_type, $entity)` calls `expandEntityFields($entity_type, $entity)`. +2. `expandEntityFields()` resolves the bundle from the stub and calls + `getEntityFieldTypes($entity_type, $bundle)`. +3. `getEntityFieldTypes()` iterates `getFieldStorageDefinitions()`, + `getBaseFieldDefinitions()`, and per-bundle definitions (when a bundle is + known). Each field is kept iff `fieldIsBaseStandard()`, + `fieldIsConfigurable()`, or `fieldIsBundleStorageBacked()` is TRUE. +4. `expandEntityFields()` iterates the returned map and, for each field whose + name is also set as a property on the stub, calls `getFieldHandler()` to + resolve a typed handler by field-type string (falling back to + `DefaultHandler`). The handler's `expand()` transforms the stub value + into Drupal's storage shape. +5. Fields not in the returned map (F2/F3/F4/F6/F7/F8) keep their original + stub values. The entity constructor receives the full stub as an array; + Drupal's field classes and storage layer take over from there. diff --git a/src/Drupal/Driver/DrupalDriver.php b/src/Drupal/Driver/DrupalDriver.php index 6ed0325d..f9c4f76d 100644 --- a/src/Drupal/Driver/DrupalDriver.php +++ b/src/Drupal/Driver/DrupalDriver.php @@ -23,7 +23,7 @@ class DrupalDriver implements DrupalDriverInterface { /** * Drupal core object. */ - public CoreInterface $core; + protected CoreInterface $core; /** * System path to the Drupal installation. @@ -38,7 +38,7 @@ class DrupalDriver implements DrupalDriverInterface { /** * Drupal core version. */ - public int $version; + protected int $version; /** * Set Drupal root and URI. @@ -93,36 +93,12 @@ public function processBatch(): void { } /** - * Determine major Drupal version. - * - * @return int - * The major Drupal version. - * - * @throws \Drupal\Driver\Exception\BootstrapException - * Thrown when the Drupal version could not be determined. - * - * @see drush_drupal_version() + * {@inheritdoc} */ public function getDrupalVersion(): int { return $this->version; } - /** - * Injects the active Core implementation. - * - * Consumers override the driver's default Core lookup by passing any - * class that implements 'CoreInterface' - the class name and namespace - * do not matter. Typically called in a test bootstrap when the project - * ships its own Core subclass (e.g. one that registers additional field - * handlers in its 'registerDefaultFieldHandlers()' override). - * - * @param \Drupal\Driver\Core\CoreInterface $core - * The Core instance the driver should delegate to. - */ - public function setCore(CoreInterface $core): void { - $this->core = $core; - } - /** * Automatically set the core from the current version. * @@ -155,12 +131,19 @@ public function setCoreFromVersion(): void { } /** - * Return current core. + * {@inheritdoc} */ public function getCore(): CoreInterface { return $this->core; } + /** + * {@inheritdoc} + */ + public function setCore(CoreInterface $core): void { + $this->core = $core; + } + /** * {@inheritdoc} */ @@ -307,20 +290,6 @@ public function cronRun(): bool { return $this->getCore()->cronRun(); } - /** - * {@inheritdoc} - */ - public function fieldExists(string $entity_type, string $field_name): bool { - return $this->getCore()->fieldExists($entity_type, $field_name); - } - - /** - * {@inheritdoc} - */ - public function fieldIsBase(string $entity_type, string $field_name): bool { - return $this->getCore()->fieldIsBase($entity_type, $field_name); - } - /** * {@inheritdoc} */ diff --git a/src/Drupal/Driver/DrupalDriverInterface.php b/src/Drupal/Driver/DrupalDriverInterface.php index 7d24cba2..edaa00aa 100644 --- a/src/Drupal/Driver/DrupalDriverInterface.php +++ b/src/Drupal/Driver/DrupalDriverInterface.php @@ -10,13 +10,13 @@ use Drupal\Driver\Capability\ConfigCapabilityInterface; use Drupal\Driver\Capability\ContentCapabilityInterface; use Drupal\Driver\Capability\CronCapabilityInterface; -use Drupal\Driver\Capability\FieldCapabilityInterface; use Drupal\Driver\Capability\LanguageCapabilityInterface; use Drupal\Driver\Capability\MailCapabilityInterface; use Drupal\Driver\Capability\ModuleCapabilityInterface; use Drupal\Driver\Capability\RoleCapabilityInterface; use Drupal\Driver\Capability\UserCapabilityInterface; use Drupal\Driver\Capability\WatchdogCapabilityInterface; +use Drupal\Driver\Core\CoreInterface; /** * Contract for the full-featured Drupal driver. @@ -32,7 +32,6 @@ interface DrupalDriverInterface extends ConfigCapabilityInterface, ContentCapabilityInterface, CronCapabilityInterface, - FieldCapabilityInterface, LanguageCapabilityInterface, MailCapabilityInterface, ModuleCapabilityInterface, @@ -40,4 +39,36 @@ interface DrupalDriverInterface extends UserCapabilityInterface, WatchdogCapabilityInterface { + /** + * Return current core. + */ + public function getCore(): CoreInterface; + + /** + * Injects the active Core implementation. + * + * Consumers override the driver's default Core lookup by passing any + * class that implements 'CoreInterface' - the class name and namespace + * do not matter. Typically called in a test bootstrap when the project + * ships its own Core subclass (e.g. one that registers additional field + * handlers in its 'registerDefaultFieldHandlers()' override). + * + * @param \Drupal\Driver\Core\CoreInterface $core + * The Core instance the driver should delegate to. + */ + public function setCore(CoreInterface $core): void; + + /** + * Returns the major Drupal version detected at construction time. + * + * The version is captured once when the driver is instantiated; the + * detection itself may throw BootstrapException, but this getter does not. + * + * @return int + * The major Drupal version. + * + * @see drush_drupal_version() + */ + public function getDrupalVersion(): int; + } diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityCreateModerationStateKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityCreateModerationStateKernelTest.php new file mode 100644 index 00000000..d9f159cc --- /dev/null +++ b/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityCreateModerationStateKernelTest.php @@ -0,0 +1,123 @@ + + */ + protected static $modules = [ + 'system', + 'user', + 'field', + 'text', + 'filter', + 'node', + 'workflows', + 'content_moderation', + ]; + + /** + * The Core driver under test. + */ + protected Core $core; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('content_moderation_state'); + $this->installEntitySchema('workflow'); + $this->installConfig(['system', 'filter', 'node']); + + NodeType::create(['type' => 'article', 'name' => 'Article'])->save(); + + $workflow = Workflow::create([ + 'id' => 'editorial', + 'label' => 'Editorial', + 'type' => 'content_moderation', + 'type_settings' => [ + 'states' => [ + 'draft' => ['label' => 'Draft', 'weight' => 0, 'published' => FALSE, 'default_revision' => FALSE], + 'published' => ['label' => 'Published', 'weight' => 1, 'published' => TRUE, 'default_revision' => TRUE], + ], + 'transitions' => [ + 'create_new_draft' => ['label' => 'Create draft', 'to' => 'draft', 'from' => ['draft'], 'weight' => 0], + 'publish' => ['label' => 'Publish', 'to' => 'published', 'from' => ['draft'], 'weight' => 1], + ], + ], + ]); + $type_plugin = $workflow->getTypePlugin(); + + if (!$type_plugin instanceof ContentModeration) { + throw new \LogicException('Expected a ContentModeration workflow type plugin.'); + } + + $type_plugin->addEntityTypeAndBundle('node', 'article'); + $workflow->save(); + + $this->core = new Core($this->root); + } + + /** + * Tests that 'moderation_state' on a stub is captured at save. + */ + public function testEntityCreatePassesModerationStateThrough(): void { + $stub = (object) [ + 'type' => 'article', + 'title' => 'Draft article', + 'moderation_state' => 'draft', + ]; + + $this->core->entityCreate('node', $stub); + + $this->assertNotEmpty($stub->nid, 'entityCreate populated node nid on the stub.'); + + $node = Node::load((int) $stub->nid); + $this->assertInstanceOf(Node::class, $node); + + $revision_id = $node->getRevisionId(); + $moderation_state = ContentModerationState::loadFromModeratedEntity($node); + + $this->assertInstanceOf( + ContentModerationState::class, + $moderation_state, + 'content_moderation recorded a ContentModerationState for the node.', + ); + $this->assertSame('draft', $moderation_state->get('moderation_state')->value); + $this->assertSame((int) $revision_id, (int) $moderation_state->get('content_entity_revision_id')->value); + } + +} diff --git a/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php b/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php index 7932616d..03d96384 100644 --- a/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php +++ b/tests/Drupal/Tests/Driver/Kernel/Core/CoreEntityMethodsKernelTest.php @@ -15,8 +15,9 @@ /** * Kernel test for generic entity methods on Core via the driver. * - * Covers 'entityCreate()', 'entityDelete()' (both the stub-object branch and - * the loaded-entity branch), and 'expandEntityBaseFields()'. + * Covers 'entityCreate()' and 'entityDelete()' (both the stub-object branch + * and the loaded-entity branch). Base-field expansion is exercised + * implicitly by any 'entityCreate()' call whose stub sets a base field. * * @group core */ @@ -182,19 +183,6 @@ public function testEntityCreateMapsStepBundle(): void { $this->assertSame('custom_bundle', $created->bundle()); } - /** - * Tests 'expandEntityBaseFields()' invokes the field handler pipeline. - */ - public function testExpandEntityBaseFieldsRewritesBaseField(): void { - $stub = (object) ['name' => 'bobbie']; - - // 'name' is a base field on the user entity type; the driver's - // DefaultHandler wraps scalar values in a value-array. - $this->core->expandEntityBaseFields('user', $stub, ['name']); - - $this->assertSame(['bobbie'], $stub->name); - } - /** * Tests 'entityCreate()' rejects an unknown entity type with a clear message. * diff --git a/tests/Drupal/Tests/Driver/Unit/BlackboxDriverTest.php b/tests/Drupal/Tests/Driver/Unit/BlackboxDriverTest.php index e3e3578d..498a0f1a 100644 --- a/tests/Drupal/Tests/Driver/Unit/BlackboxDriverTest.php +++ b/tests/Drupal/Tests/Driver/Unit/BlackboxDriverTest.php @@ -12,7 +12,6 @@ use Drupal\Driver\Capability\ConfigCapabilityInterface; use Drupal\Driver\Capability\ContentCapabilityInterface; use Drupal\Driver\Capability\CronCapabilityInterface; -use Drupal\Driver\Capability\FieldCapabilityInterface; use Drupal\Driver\Capability\LanguageCapabilityInterface; use Drupal\Driver\Capability\MailCapabilityInterface; use Drupal\Driver\Capability\ModuleCapabilityInterface; @@ -103,7 +102,6 @@ public static function dataProviderDoesNotImplementCapability(): \Iterator { yield 'config' => [ConfigCapabilityInterface::class]; yield 'content' => [ContentCapabilityInterface::class]; yield 'cron' => [CronCapabilityInterface::class]; - yield 'field' => [FieldCapabilityInterface::class]; yield 'language' => [LanguageCapabilityInterface::class]; yield 'mail' => [MailCapabilityInterface::class]; yield 'module' => [ModuleCapabilityInterface::class]; diff --git a/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php b/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php index 46952e46..ae9c1ecc 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldHandlerLookupTest.php @@ -197,7 +197,7 @@ public function __construct(string $drupal_root, string $uri, protected array $f /** * {@inheritdoc} */ - public function getEntityFieldTypes(string $entity_type, array $base_fields = []): array { + public function getEntityFieldTypes(string $entity_type, ?string $bundle = NULL): array { return $this->field_type_map; } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldMethodsTest.php b/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldMethodsTest.php index af6f50b3..d92986c4 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldMethodsTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/CoreFieldMethodsTest.php @@ -6,14 +6,16 @@ use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Driver\Core\Core; +use Drupal\Driver\Core\Field\FieldClassifier; +use Drupal\Driver\Core\Field\FieldClassifierInterface; use Drupal\field\Entity\FieldStorageConfig; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; /** - * Tests 'fieldIsBase()', 'fieldExists()', and 'getEntityFieldTypes()' methods. + * Tests 'getEntityFieldTypes()' against the classifier-backed predicates. * * @group core * @group fields @@ -23,159 +25,64 @@ class CoreFieldMethodsTest extends TestCase { /** - * Tests that 'fieldIsBase()' correctly identifies base fields. - * - * @param string $field_name - * The field name to check. - * @param bool $expected - * The expected result. - * - * @dataProvider dataProviderIsBaseField - */ - #[DataProvider('dataProviderIsBaseField')] - public function testIsBaseField(string $field_name, bool $expected): void { - $core = $this->createTestCore(); - $this->assertSame($expected, $core->fieldIsBase('node', $field_name)); - } - - /** - * Data provider for testIsBaseField(). - */ - public static function dataProviderIsBaseField(): \Iterator { - yield 'non-computed base field' => ['title', TRUE]; - yield 'computed base field' => ['moderation_state', TRUE]; - yield 'configurable field' => ['field_tags', FALSE]; - yield 'unknown field' => ['nonexistent', FALSE]; - } - - /** - * Tests that 'fieldExists()' correctly identifies configurable fields. - * - * @param string $field_name - * The field name to check. - * @param bool $expected - * The expected result. - * - * @dataProvider dataProviderIsField - */ - #[DataProvider('dataProviderIsField')] - public function testIsField(string $field_name, bool $expected): void { - $core = $this->createTestCore(); - $this->assertSame($expected, $core->fieldExists('node', $field_name)); - } - - /** - * Data provider for testIsField(). + * Tests that 'getEntityFieldTypes()' returns configurable and F1 base fields. */ - public static function dataProviderIsField(): \Iterator { - yield 'configurable field' => ['field_tags', TRUE]; - yield 'non-computed base field' => ['title', FALSE]; - yield 'computed base field' => ['moderation_state', FALSE]; - yield 'unknown field' => ['nonexistent', FALSE]; - } - - /** - * Tests that 'getEntityFieldTypes()' includes computed base fields. - * - * @param array $base_fields_arg - * The $base_fields argument to pass. - * @param array $expected_fields - * The expected field names in the result. - * @param array $unexpected_fields - * Field names that should NOT be in the result. - * - * @dataProvider dataProviderGetEntityFieldTypes - */ - #[DataProvider('dataProviderGetEntityFieldTypes')] - public function testGetEntityFieldTypes(array $base_fields_arg, array $expected_fields, array $unexpected_fields): void { + public function testGetEntityFieldTypesIncludesF1AndF5AndExcludesF3(): void { $core = $this->createTestCore(); - $result = $core->getEntityFieldTypes('node', $base_fields_arg); - - foreach ($expected_fields as $field_name) { - $this->assertArrayHasKey($field_name, $result, sprintf("Expected '%s' in result.", $field_name)); - } - foreach ($unexpected_fields as $field_name) { - $this->assertArrayNotHasKey($field_name, $result, sprintf("Did not expect '%s' in result.", $field_name)); - } - } + $result = $core->getEntityFieldTypes('node'); - /** - * Data provider for testGetEntityFieldTypes(). - */ - public static function dataProviderGetEntityFieldTypes(): \Iterator { - yield 'no base fields requested' => [ - [], - ['field_tags'], - ['title', 'moderation_state'], - ]; - yield 'non-computed base field requested' => [ - ['title'], - ['field_tags', 'title'], - ['moderation_state'], - ]; - yield 'computed base field requested' => [ - ['moderation_state'], - ['field_tags', 'moderation_state'], - ['title'], - ]; - yield 'multiple base fields requested' => [ - ['title', 'moderation_state'], - ['field_tags', 'title', 'moderation_state'], - [], - ]; + $this->assertArrayHasKey('title', $result, 'F1 standard base field included.'); + $this->assertArrayHasKey('field_tags', $result, 'F5 configurable field included.'); + $this->assertArrayNotHasKey('moderation_state', $result, 'F3 computed writable base field excluded.'); } /** - * Creates a TestCore with a mocked entity field manager. + * Creates a TestCore wired to a mocked entity field manager and classifier. */ protected function createTestCore(): TestCore { - // Non-computed base field. + $storage_no_custom = $this->createMock(FieldStorageDefinitionInterface::class); + $storage_no_custom->method('hasCustomStorage')->willReturn(FALSE); + $title_field = $this->createMock(BaseFieldDefinition::class); $title_field->method('getType')->willReturn('string'); + $title_field->method('isComputed')->willReturn(FALSE); + $title_field->method('getFieldStorageDefinition')->willReturn($storage_no_custom); - // Computed base field (not in getFieldStorageDefinitions). $moderation_state_field = $this->createMock(BaseFieldDefinition::class); $moderation_state_field->method('getType')->willReturn('string'); + $moderation_state_field->method('isComputed')->willReturn(TRUE); + $moderation_state_field->method('isReadOnly')->willReturn(FALSE); + $moderation_state_field->method('getFieldStorageDefinition')->willReturn($storage_no_custom); - // Configurable field - mock satisfies `instanceof FieldStorageConfig` in - // Core::fieldExists without needing the real constructor's array arg. $field_tags = $this->createMock(FieldStorageConfig::class); $field_tags->method('getType')->willReturn('entity_reference'); $entity_field_manager = $this->createMock(EntityFieldManagerInterface::class); - - // getFieldStorageDefinitions: returns non-computed base fields and - // configurable fields. - $entity_field_manager->method('getFieldStorageDefinitions') - ->with('node') - ->willReturn([ - 'title' => $title_field, - 'field_tags' => $field_tags, - ]); - - // getBaseFieldDefinitions: returns ALL base fields (computed and - // non-computed). - $entity_field_manager->method('getBaseFieldDefinitions') - ->with('node') - ->willReturn([ - 'title' => $title_field, - 'moderation_state' => $moderation_state_field, - ]); + $entity_field_manager->method('getFieldStorageDefinitions')->with('node')->willReturn([ + 'title' => $title_field, + 'field_tags' => $field_tags, + ]); + $entity_field_manager->method('getBaseFieldDefinitions')->with('node')->willReturn([ + 'title' => $title_field, + 'moderation_state' => $moderation_state_field, + ]); $core = new TestCore(__DIR__, 'default'); $core->setEntityFieldManager($entity_field_manager); + $core->setFieldClassifier(new FieldClassifier($entity_field_manager)); + return $core; } } /** - * Testable subclass that overrides 'getEntityFieldManager()'. + * Testable subclass that injects a mocked entity field manager and classifier. */ class TestCore extends Core { /** - * The mock entity field manager. + * The injected mock entity field manager. */ protected EntityFieldManagerInterface $entityFieldManager; @@ -186,6 +93,13 @@ public function setEntityFieldManager(EntityFieldManagerInterface $entity_field_ $this->entityFieldManager = $entity_field_manager; } + /** + * Injects a pre-built classifier so the lazy factory is not consulted. + */ + public function setFieldClassifier(FieldClassifierInterface $classifier): void { + $this->fieldClassifier = $classifier; + } + /** * {@inheritdoc} */ diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php index 9af86bc4..ce12e200 100644 --- a/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/DefaultHandlerTest.php @@ -4,9 +4,11 @@ namespace Drupal\Tests\Driver\Unit\Core\Field; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Driver\Core\Field\DefaultHandler; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; /** * Tests the DefaultHandler field handler. @@ -17,15 +19,64 @@ class DefaultHandlerTest extends TestCase { /** - * Tests that expand() returns the input unchanged. + * Tests that a single 'value' column passes through unchanged. + */ + public function testExpandReturnsValuesForSingleValueColumn(): void { + $handler = $this->handlerWithColumns(['value' => []]); + + $this->assertSame(['one', 'two'], $handler->expand(['one', 'two'])); + } + + /** + * Tests that a multi-column field triggers the loud-failure policy. + */ + public function testExpandThrowsForMultipleColumns(): void { + $handler = $this->handlerWithColumns(['value' => [], 'format' => []]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No dedicated handler is registered'); + $this->expectExceptionMessage('2 column(s) (value, format)'); + + $handler->expand('hello'); + } + + /** + * Tests that a single-column field not keyed by 'value' triggers failure. */ - public function testExpandReturnsValuesUnchanged(): void { + public function testExpandThrowsForSingleColumnNotNamedValue(): void { + $handler = $this->handlerWithColumns(['target_id' => []]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('target_id'); + + $handler->expand(42); + } + + /** + * Builds a DefaultHandler wired to a mocked field storage/config pair. + * + * @param array> $columns + * Column descriptors keyed by column name (the content is irrelevant; + * only the array keys are inspected by DefaultHandler). + */ + protected function handlerWithColumns(array $columns): DefaultHandler { + $storage = $this->createMock(FieldStorageDefinitionInterface::class); + $storage->method('getColumns')->willReturn($columns); + $storage->method('getName')->willReturn('field_example'); + $storage->method('getType')->willReturn('example_type'); + $storage->method('getTargetEntityTypeId')->willReturn('node'); + + $config = $this->createMock(FieldDefinitionInterface::class); + $config->method('getTargetBundle')->willReturn('article'); + $reflection = new \ReflectionClass(DefaultHandler::class); $handler = $reflection->newInstanceWithoutConstructor(); + $info_prop = $reflection->getParentClass()->getProperty('fieldInfo'); + $info_prop->setValue($handler, $storage); + $config_prop = $reflection->getParentClass()->getProperty('fieldConfig'); + $config_prop->setValue($handler, $config); - $values = ['one', 'two', 3]; - - $this->assertSame($values, $handler->expand($values)); + return $handler; } } diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php new file mode 100644 index 00000000..d6511e88 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/EntityReferenceRevisionsHandlerTest.php @@ -0,0 +1,163 @@ +setUpDrupalContainer(resolved_id: 42, revision_id: 7); + + $handler = $this->handlerUnderTest(); + $result = $handler->expand(['Paragraph A']); + + $this->assertSame([['target_id' => 42, 'target_revision_id' => 7]], $result); + } + + /** + * Tests that an extras-array input preserves extras and resolves the target. + */ + public function testExtrasArrayInputPreservesExtras(): void { + $this->setUpDrupalContainer(resolved_id: 42, revision_id: 7); + + $handler = $this->handlerUnderTest(); + $result = $handler->expand([ + ['target_id' => 'Paragraph A', 'extra' => 'keep-me'], + ]); + + $this->assertSame([ + ['target_id' => 42, 'extra' => 'keep-me', 'target_revision_id' => 7], + ], $result); + } + + /** + * Tests that the handler throws when the target label does not resolve. + */ + public function testUnknownTargetThrows(): void { + $this->setUpDrupalContainer(resolved_id: NULL, revision_id: NULL); + + $handler = $this->handlerUnderTest(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("No entity 'Paragraph A' of type 'paragraph' exists."); + + $handler->expand(['Paragraph A']); + } + + /** + * Tests that a non-revisionable target yields a NULL revision id. + */ + public function testNonRevisionableTargetYieldsNullRevisionId(): void { + $this->setUpDrupalContainer(resolved_id: 42, revision_id: NULL, revisionable: FALSE); + + $handler = $this->handlerUnderTest(); + $result = $handler->expand(['Paragraph A']); + + $this->assertSame([['target_id' => 42, 'target_revision_id' => NULL]], $result); + } + + /** + * Instantiates the handler without invoking its parent constructor. + * + * Direct construction would bootstrap the field-storage validation in + * AbstractHandler, which this test replaces with injected fakes via + * reflection. Using reflection keeps the test focused on expand() output. + */ + protected function handlerUnderTest(): EntityReferenceRevisionsHandler { + $storage = $this->createMock(FieldStorageDefinitionInterface::class); + $storage->method('getSetting')->with('target_type')->willReturn('paragraph'); + $storage->method('getMainPropertyName')->willReturn('target_id'); + + $config = $this->createMock(FieldDefinitionInterface::class); + $config->method('getSettings')->willReturn([]); + + $reflection = new \ReflectionClass(EntityReferenceRevisionsHandler::class); + $handler = $reflection->newInstanceWithoutConstructor(); + $info_prop = $reflection->getParentClass()->getProperty('fieldInfo'); + $info_prop->setValue($handler, $storage); + $config_prop = $reflection->getParentClass()->getProperty('fieldConfig'); + $config_prop->setValue($handler, $config); + + return $handler; + } + + /** + * Sets up the Drupal container with stubs the handler consults. + * + * The handler calls '\Drupal::entityTypeManager()' and + * '\Drupal::entityQuery()', both resolved through '\Drupal::getContainer()'. + * Wire the container so the query returns a deterministic id (or an empty + * array to force the "not found" branch) and storage returns an optionally + * revisionable target. + */ + protected function setUpDrupalContainer(?int $resolved_id, ?int $revision_id, bool $revisionable = TRUE): void { + $query = $this->createMock(QueryInterface::class); + $query->method('accessCheck')->willReturnSelf(); + $query->method('condition')->willReturnSelf(); + $query->method('orConditionGroup')->willReturn($query); + $query->method('execute')->willReturn($resolved_id === NULL ? [] : [$resolved_id => $resolved_id]); + + if ($revisionable) { + $target = $this->createMock(RevisionableInterface::class); + $target->method('getRevisionId')->willReturn($revision_id); + } + else { + $target = $this->createMock(EntityTypeInterface::class); + } + + $entity_storage = $this->createMock(EntityStorageInterface::class); + $entity_storage->method('load')->willReturn($target); + $entity_storage->method('getQuery')->willReturn($query); + + $entity_type = $this->createMock(EntityTypeInterface::class); + $entity_type->method('getKey')->willReturnMap([ + ['id', 'id'], + ['label', 'label'], + ['bundle', 'type'], + ]); + + $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); + $entity_type_manager->method('getDefinition')->with('paragraph')->willReturn($entity_type); + $entity_type_manager->method('getStorage')->with('paragraph')->willReturn($entity_storage); + + $container = new ContainerBuilder(); + $container->set('entity_type.manager', $entity_type_manager); + \Drupal::setContainer($container); + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/Core/Field/FieldClassifierTest.php b/tests/Drupal/Tests/Driver/Unit/Core/Field/FieldClassifierTest.php new file mode 100644 index 00000000..7df26b31 --- /dev/null +++ b/tests/Drupal/Tests/Driver/Unit/Core/Field/FieldClassifierTest.php @@ -0,0 +1,221 @@ +entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBaseStandard('node', 'title')); + $this->assertFalse($classifier->fieldIsBaseStandard('node', 'mod_readonly')); + $this->assertFalse($classifier->fieldIsBaseStandard('node', 'mod_writable')); + $this->assertFalse($classifier->fieldIsBaseStandard('node', 'base_custom')); + $this->assertFalse($classifier->fieldIsBaseStandard('node', 'field_tags')); + $this->assertFalse($classifier->fieldIsBaseStandard('node', 'nonexistent')); + } + + /** + * Tests F2 detection: computed read-only base field. + */ + public function testFieldIsBaseComputedReadOnly(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBaseComputedReadOnly('node', 'mod_readonly')); + $this->assertFalse($classifier->fieldIsBaseComputedReadOnly('node', 'title')); + $this->assertFalse($classifier->fieldIsBaseComputedReadOnly('node', 'mod_writable')); + } + + /** + * Tests F3 detection: computed writable base field. + */ + public function testFieldIsBaseComputedWritable(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBaseComputedWritable('node', 'mod_writable')); + $this->assertFalse($classifier->fieldIsBaseComputedWritable('node', 'mod_readonly')); + $this->assertFalse($classifier->fieldIsBaseComputedWritable('node', 'title')); + } + + /** + * Tests F4 detection: custom-storage base field. + */ + public function testFieldIsBaseCustomStorage(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBaseCustomStorage('node', 'base_custom')); + $this->assertFalse($classifier->fieldIsBaseCustomStorage('node', 'title')); + $this->assertFalse($classifier->fieldIsBaseCustomStorage('node', 'mod_writable')); + } + + /** + * Tests F5 detection: FieldStorageConfig configurable field. + */ + public function testFieldIsConfigurable(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsConfigurable('node', 'field_tags')); + $this->assertFalse($classifier->fieldIsConfigurable('node', 'title')); + $this->assertFalse($classifier->fieldIsConfigurable('node', 'nonexistent')); + } + + /** + * Tests F6 detection: bundle-only computed read-only field. + */ + public function testFieldIsBundleComputedReadOnly(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBundleComputedReadOnly('node', 'bundle_computed_ro', 'article')); + $this->assertFalse($classifier->fieldIsBundleComputedReadOnly('node', 'bundle_computed_rw', 'article')); + $this->assertFalse($classifier->fieldIsBundleComputedReadOnly('node', 'title', 'article')); + $this->assertFalse($classifier->fieldIsBundleComputedReadOnly('node', 'nonexistent', 'article')); + } + + /** + * Tests F7 detection: bundle-only computed writable field. + */ + public function testFieldIsBundleComputedWritable(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBundleComputedWritable('node', 'bundle_computed_rw', 'article')); + $this->assertFalse($classifier->fieldIsBundleComputedWritable('node', 'bundle_computed_ro', 'article')); + $this->assertFalse($classifier->fieldIsBundleComputedWritable('node', 'title', 'article')); + $this->assertFalse($classifier->fieldIsBundleComputedWritable('node', 'nonexistent', 'article')); + } + + /** + * Tests F8 detection: bundle-only custom-storage field. + */ + public function testFieldIsBundleCustomStorage(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBundleCustomStorage('node', 'bundle_custom', 'article')); + $this->assertFalse($classifier->fieldIsBundleCustomStorage('node', 'bundle_computed_rw', 'article')); + $this->assertFalse($classifier->fieldIsBundleCustomStorage('node', 'title', 'article')); + $this->assertFalse($classifier->fieldIsBundleCustomStorage('node', 'nonexistent', 'article')); + } + + /** + * Tests F9 detection: storage-info hook + bundle-hook pair. + */ + public function testFieldIsBundleStorageBacked(): void { + $classifier = new FieldClassifier($this->entityFieldManager()); + + $this->assertTrue($classifier->fieldIsBundleStorageBacked('node', 'bundle_storage_backed', 'article')); + $this->assertFalse($classifier->fieldIsBundleStorageBacked('node', 'title', 'article')); + $this->assertFalse($classifier->fieldIsBundleStorageBacked('node', 'field_tags', 'article')); + $this->assertFalse($classifier->fieldIsBundleStorageBacked('node', 'nonexistent', 'article')); + $this->assertFalse($classifier->fieldIsBundleStorageBacked('node', 'bundle_computed_rw', 'article')); + } + + /** + * Builds an entity-field-manager fixture with one field per F-row. + */ + protected function entityFieldManager(): EntityFieldManagerInterface { + // Storage stubs for the hasCustomStorage() chain on base definitions. + $storage_no_custom = $this->createMock(FieldStorageDefinitionInterface::class); + $storage_no_custom->method('hasCustomStorage')->willReturn(FALSE); + $storage_with_custom = $this->createMock(FieldStorageDefinitionInterface::class); + $storage_with_custom->method('hasCustomStorage')->willReturn(TRUE); + + // F1 standard base field. + $title = $this->createMock(BaseFieldDefinition::class); + $title->method('isComputed')->willReturn(FALSE); + $title->method('getFieldStorageDefinition')->willReturn($storage_no_custom); + $title->method('isReadOnly')->willReturn(FALSE); + + // F2 computed read-only base. + $mod_ro = $this->createMock(BaseFieldDefinition::class); + $mod_ro->method('isComputed')->willReturn(TRUE); + $mod_ro->method('isReadOnly')->willReturn(TRUE); + $mod_ro->method('getFieldStorageDefinition')->willReturn($storage_no_custom); + + // F3 computed writable base (moderation_state-like). + $mod_rw = $this->createMock(BaseFieldDefinition::class); + $mod_rw->method('isComputed')->willReturn(TRUE); + $mod_rw->method('isReadOnly')->willReturn(FALSE); + $mod_rw->method('getFieldStorageDefinition')->willReturn($storage_no_custom); + + // F4 custom-storage base. + $base_custom = $this->createMock(BaseFieldDefinition::class); + $base_custom->method('isComputed')->willReturn(FALSE); + $base_custom->method('getFieldStorageDefinition')->willReturn($storage_with_custom); + + // F5 configurable field. + $field_tags = $this->createMock(FieldStorageConfig::class); + + $base = [ + 'title' => $title, + 'mod_readonly' => $mod_ro, + 'mod_writable' => $mod_rw, + 'base_custom' => $base_custom, + ]; + + $storage = [ + 'title' => $title, + 'base_custom' => $base_custom, + 'field_tags' => $field_tags, + // F9 storage entry (not a FieldStorageConfig, not in base). + 'bundle_storage_backed' => $this->createMock(FieldStorageDefinitionInterface::class), + ]; + + // F6 bundle-computed read-only. + $bundle_ro = $this->createMock(FieldDefinitionInterface::class); + $bundle_ro->method('isComputed')->willReturn(TRUE); + $bundle_ro->method('isReadOnly')->willReturn(TRUE); + + // F7 bundle-computed writable. + $bundle_rw = $this->createMock(FieldDefinitionInterface::class); + $bundle_rw->method('isComputed')->willReturn(TRUE); + $bundle_rw->method('isReadOnly')->willReturn(FALSE); + + // F8 bundle custom-storage. + $bundle_custom_storage = $this->createMock(FieldStorageDefinitionInterface::class); + $bundle_custom_storage->method('hasCustomStorage')->willReturn(TRUE); + $bundle_custom = $this->createMock(FieldDefinitionInterface::class); + $bundle_custom->method('isComputed')->willReturn(FALSE); + $bundle_custom->method('getFieldStorageDefinition')->willReturn($bundle_custom_storage); + + // F9 definition in bundle (paired with the storage entry above). + $bundle_f9 = $this->createMock(FieldDefinitionInterface::class); + + $bundle_defs = [ + 'title' => $title, + 'field_tags' => $field_tags, + 'bundle_computed_ro' => $bundle_ro, + 'bundle_computed_rw' => $bundle_rw, + 'bundle_custom' => $bundle_custom, + 'bundle_storage_backed' => $bundle_f9, + ]; + + $manager = $this->createMock(EntityFieldManagerInterface::class); + $manager->method('getBaseFieldDefinitions')->with('node')->willReturn($base); + $manager->method('getFieldStorageDefinitions')->with('node')->willReturn($storage); + $manager->method('getFieldDefinitions')->with('node', 'article')->willReturn($bundle_defs); + + return $manager; + } + +} diff --git a/tests/Drupal/Tests/Driver/Unit/CoreLookupTest.php b/tests/Drupal/Tests/Driver/Unit/CoreLookupTest.php index 2f4b6786..055213ee 100644 --- a/tests/Drupal/Tests/Driver/Unit/CoreLookupTest.php +++ b/tests/Drupal/Tests/Driver/Unit/CoreLookupTest.php @@ -75,7 +75,8 @@ protected function createDriverWithVersion(int $version): DrupalDriver { $uri_prop = $reflection->getProperty('uri'); $uri_prop->setValue($driver, 'default'); - $driver->version = $version; + $version_property = $reflection->getProperty('version'); + $version_property->setValue($driver, $version); return $driver; } diff --git a/tests/Drupal/Tests/Driver/Unit/DrupalDriverDelegationTest.php b/tests/Drupal/Tests/Driver/Unit/DrupalDriverDelegationTest.php index 93c2aa04..2161acf9 100644 --- a/tests/Drupal/Tests/Driver/Unit/DrupalDriverDelegationTest.php +++ b/tests/Drupal/Tests/Driver/Unit/DrupalDriverDelegationTest.php @@ -163,8 +163,6 @@ public static function dataProviderForwardsToCore(): \Iterator { yield 'roleCreate' => ['roleCreate', [['admin']], 'roleCreate']; yield 'roleCreate named' => ['roleCreate', [['admin'], 'editor', 'Editor'], 'roleCreate']; yield 'roleDelete' => ['roleDelete', ['editor'], 'roleDelete']; - yield 'fieldExists' => ['fieldExists', ['node', 'title'], 'fieldExists']; - yield 'fieldIsBase' => ['fieldIsBase', ['node', 'title'], 'fieldIsBase']; yield 'languageCreate' => ['languageCreate', [$language], 'languageCreate']; yield 'languageDelete' => ['languageDelete', [$language], 'languageDelete']; yield 'configGet' => ['configGet', ['system.site', 'name'], 'configGet']; @@ -202,8 +200,10 @@ protected function createDriverWithCore(CoreInterface $core, int $version = 11): $uri = $reflection->getProperty('uri'); $uri->setValue($driver, 'default'); - $driver->version = $version; - $driver->core = $core; + $version_property = $reflection->getProperty('version'); + $version_property->setValue($driver, $version); + + $driver->setCore($core); return $driver; } diff --git a/tests/Drupal/Tests/Driver/Unit/DrupalDriverTest.php b/tests/Drupal/Tests/Driver/Unit/DrupalDriverTest.php index 4f8c881e..c0434390 100644 --- a/tests/Drupal/Tests/Driver/Unit/DrupalDriverTest.php +++ b/tests/Drupal/Tests/Driver/Unit/DrupalDriverTest.php @@ -9,7 +9,6 @@ use Drupal\Driver\Capability\ConfigCapabilityInterface; use Drupal\Driver\Capability\ContentCapabilityInterface; use Drupal\Driver\Capability\CronCapabilityInterface; -use Drupal\Driver\Capability\FieldCapabilityInterface; use Drupal\Driver\Capability\LanguageCapabilityInterface; use Drupal\Driver\Capability\MailCapabilityInterface; use Drupal\Driver\Capability\ModuleCapabilityInterface; @@ -73,7 +72,6 @@ public static function dataProviderImplementsCapability(): \Iterator { yield 'config' => [ConfigCapabilityInterface::class]; yield 'content' => [ContentCapabilityInterface::class]; yield 'cron' => [CronCapabilityInterface::class]; - yield 'field' => [FieldCapabilityInterface::class]; yield 'language' => [LanguageCapabilityInterface::class]; yield 'mail' => [MailCapabilityInterface::class]; yield 'module' => [ModuleCapabilityInterface::class];