Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 73 additions & 10 deletions src/Drupal/Driver/Core/Field/FileHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,8 @@ public function expand($values): array {
foreach ((array) $values as $value) {
$is_array = is_array($value);
$file_path = (string) ($is_array ? $value['target_id'] ?? $value[0] : $value);
$file_extension = pathinfo($file_path, PATHINFO_EXTENSION);
$data = file_get_contents($file_path);

if ($data === FALSE) {
throw new \Exception(sprintf('Error reading file %s.', $file_path));
}

/** @var \Drupal\file\FileInterface $file */
$file = \Drupal::service('file.repository')
->writeData($data, 'public://' . uniqid() . '.' . $file_extension);
$file->save();
$file = $this->resolveExistingFile($file_path) ?? $this->uploadAndSave($file_path);

$files[] = [
'target_id' => $file->id(),
Expand All @@ -40,4 +31,76 @@ public function expand($values): array {
return $files;
}

/**
* Returns a managed File addressed by URI or bare basename, or NULL.
*
* Restores the 2.x behaviour where tests could pre-create a managed file
* and reference it by URI ('public://foo.txt') or bare basename
* ('foo.txt') without triggering a re-upload. Paths containing '/' but no
* scheme (e.g. '/tmp/foo.txt') are treated as disk paths and fall through
* to the upload path unchanged.
*
* The native return type is 'object' (not FileInterface) so unit-test
* doubles that satisfy the small 'id()' surface this method's callers
* actually need can also pass without implementing the full File entity
* contract. In production the storage returns File entities.
*
* @param string $value
* The raw field value: URI, bare basename, or absolute filesystem path.
*
* @return object|null
* A File entity (or File-compatible stub in tests), or NULL on no match.
*/
protected function resolveExistingFile(string $value): ?object {
$storage = \Drupal::entityTypeManager()->getStorage('file');

if (str_contains($value, '://')) {
$matches = $storage->loadByProperties(['uri' => $value]);

return $matches ? reset($matches) : NULL;
}

if (!str_contains($value, '/')) {
foreach (['public', 'private'] as $scheme) {
$matches = $storage->loadByProperties(['uri' => $scheme . '://' . $value]);

if ($matches) {
return reset($matches);
}
}
}

return NULL;
}

/**
* Reads a file from disk, writes it to public://, and returns the new File.
*
* Uses 'object' as the native return type for the same reason as
* 'resolveExistingFile()': unit-test doubles can satisfy it without
* implementing FileInterface. In production the repository service
* returns a saved File entity.
*
* @param string $file_path
* A filesystem path readable from the current working directory.
*
* @return object
* A File entity (or File-compatible stub in tests).
*/
protected function uploadAndSave(string $file_path): object {
$data = file_get_contents($file_path);

if ($data === FALSE) {
throw new \Exception(sprintf('Error reading file %s.', $file_path));
}

$file_extension = pathinfo($file_path, PATHINFO_EXTENSION);

$file = \Drupal::service('file.repository')
->writeData($data, 'public://' . uniqid() . '.' . $file_extension);
$file->save();

return $file;
}

}
16 changes: 6 additions & 10 deletions src/Drupal/Driver/Core/Field/ImageHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,20 @@

/**
* Image field handler for Drupal 8.
*
* Extends FileHandler to inherit the resolve-existing-managed-file lookup
* (by URI or bare basename) and the upload-and-save fallback. Overrides
* expand() to return the image-specific shape ('target_id', 'alt', 'title').
*/
class ImageHandler extends AbstractHandler {
class ImageHandler extends FileHandler {

/**
* {@inheritdoc}
*/
public function expand($values): array {
$file_path = $values[0];
$file_contents = file_get_contents($file_path);

if ($file_contents === FALSE) {
throw new \Exception(sprintf('Error reading file %s.', $file_path));
}

/** @var \Drupal\file\FileInterface $file */
$file = \Drupal::service('file.repository')
->writeData($file_contents, 'public://' . uniqid() . '.jpg');
$file->save();
$file = $this->resolveExistingFile($file_path) ?? $this->uploadAndSave($file_path);

return [
'target_id' => $file->id(),
Expand Down
15 changes: 13 additions & 2 deletions src/Drupal/Driver/Core/Field/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ field-type string returned by `FieldDefinitionInterface::getType()`.
| ID | Field-type shape | Representative types | Handler | Rationale |
|-----|------------------------------------|--------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|
| H1 | Single-column scalar | `string`, `integer`, `boolean`, `float`, `decimal`, `email`, `telephone`, `uri`, `timestamp`, `created`, `changed` | `DefaultHandler` | `(array) $value` produces the correct `['value' => ...]` 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. |
| H2 | Multi-column compound | `text`, `text_long`, `text_with_summary`, `link`, `address`, `daterange` | Typed handler required (`TextHandler`, `TextLongHandler`, `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. |
| H4 | Entity reference (single target) | `entity_reference`, `file`, `image` | `EntityReferenceHandler`, `FileHandler`, `ImageHandler` | Resolve human-readable label/path/filename to `target_id`. `FileHandler`/`ImageHandler` first try to reuse an existing managed file at the given URI or bare basename (searching `public://` and `private://`) before falling back to uploading a new file under `public://<uniqid>.<ext>`. |
| 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). |
Expand Down Expand Up @@ -78,6 +78,17 @@ 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.

## Handler-coverage safety net

`FieldTypeCoverageKernelTest` enumerates every field-type plugin the loaded
Drupal install exposes and asserts that each one is either (a) backed by a
registered handler, (b) schema-compatible with `DefaultHandler` (single
`value` column), or (c) listed in the test's `SKIP` map with a documented
reason (computed, write-only, composite-lifecycle, etc.). Adding a new core
field type without a handler or a SKIP entry fails that test, preventing
the type from silently falling through to `DefaultHandler` and blowing up
the first time a scenario references it.

## What each resolution actually means in code

- **Expand via handler**: field enters `getEntityFieldTypes()`, survives all
Expand Down
23 changes: 23 additions & 0 deletions src/Drupal/Driver/Core/Field/TextHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Core\Field;

/**
* Pass-through handler for 'text' fields.
*
* Stores (value, format) per delta; 'text' is the one-line counterpart to
* 'text_long'. Both share the multi-column shape and therefore need a
* dedicated pass-through so DefaultHandler does not reject the payload.
*/
class TextHandler implements FieldHandlerInterface {

/**
* {@inheritdoc}
*/
public function expand(mixed $values): array {
return (array) $values;
}

}
23 changes: 23 additions & 0 deletions src/Drupal/Driver/Core/Field/TextLongHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Core\Field;

/**
* Pass-through handler for 'text_long' fields.
*
* Stores (value, format) per delta; used for taxonomy term description,
* paragraph body, custom block body. DefaultHandler cannot marshal it
* because it is multi-column, so a dedicated pass-through is required.
*/
class TextLongHandler implements FieldHandlerInterface {

/**
* {@inheritdoc}
*/
public function expand(mixed $values): array {
return (array) $values;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

declare(strict_types=1);

namespace Drupal\Tests\Driver\Kernel\Core\Field;

use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Driver\Core\Core;
use Drupal\Driver\Core\Field\DefaultHandler;
use PHPUnit\Framework\Attributes\Group;

/**
* Coverage test: every known field type is handled or explicitly skipped.
*
* Enumerates every field-type plugin the loaded modules expose and asserts
* that each one is either:
* (a) backed by a dedicated handler registered with Core (the registry map
* has an entry for this type), or
* (b) safe for DefaultHandler - its storage schema declares exactly one
* column named 'value', which is the only shape DefaultHandler can
* marshal, or
* (c) documented in the SKIP map with a reason (computed, write-only, or
* otherwise not stub-expansion-compatible).
*
* If a type falls into none of these buckets the test fails with the type
* name, forcing the contributor to add a handler, confirm DefaultHandler is
* safe, or record a SKIP entry with a reason. This is the safety net that
* catches missing-handler regressions before they ship to consumers.
*
* @group fields
*/
#[Group('fields')]
class FieldTypeCoverageKernelTest extends FieldHandlerKernelTestBase {

/**
* {@inheritdoc}
*
* @var array<string>
*/
protected static $modules = [
...self::BASE_MODULES,
'text',
'filter',
'link',
'datetime',
'datetime_range',
'file',
'image',
'options',
'telephone',
'comment',
'path',
'taxonomy',
];

/**
* Field types that are not eligible for driver expand().
*
* Each entry documents the reason so the skip list does not become a
* silent hiding place for regressions.
*
* @var array<string, string>
*/
private const SKIP = [
'password' => 'Write-only field; hashed by the user storage layer on save.',
'comment' => 'Composite field driven by the comment module lifecycle; not stub-expandable.',
'path' => 'Computed from the path_alias table; stubbing the value has no storage effect.',
'uuid' => 'Base computed field generated by entity storage; never a stub target.',
'map' => 'Stores a serialized PHP array in a single value column; scenario authors pass the array directly, so DefaultHandler is bypassed at expand time.',
'language' => 'Set via the language manager, not via expand(); stub property flows untouched.',
'shape' => 'Test-only fixture field type shipped by entity_test; not present in production Drupal installs.',
'shape_required' => 'Test-only fixture field type shipped by entity_test; not present in production Drupal installs.',
];

/**
* Tests that every known field type has a coverage strategy.
*/
public function testEveryKnownFieldTypeIsHandledOrSkipped(): void {
$definitions = \Drupal::service('plugin.manager.field.field_type')->getDefinitions();

$missing = [];

foreach (array_keys($definitions) as $type) {
if (array_key_exists($type, self::SKIP)) {
continue;
}

if ($this->isHandlerRegistered($type)) {
continue;
}

if ($this->isDefaultHandlerSafe($type)) {
continue;
}

$missing[] = $type;
}

$this->assertSame(
[],
$missing,
sprintf(
"Field types with no handler and no SKIP entry: %s.\n"
. 'Add a dedicated handler under src/Drupal/Driver/Core/Field/ (named <Type>Handler.php), '
. 'or add a SKIP entry with a documented reason.',
implode(', ', $missing),
),
);
}

/**
* Returns TRUE when Core has a handler class registered for this type.
*/
private function isHandlerRegistered(string $type): bool {
$property = new \ReflectionProperty(Core::class, 'fieldHandlers');
$handlers = $property->getValue($this->core);

return isset($handlers[$type]);
}

/**
* Returns TRUE when the field type's schema matches DefaultHandler's shape.
*
* DefaultHandler only marshals fields whose storage schema declares exactly
* one column named 'value'. Any other shape triggers a loud throw at
* expand() time, meaning the type needs a dedicated handler.
*/
private function isDefaultHandlerSafe(string $type): bool {
try {
$storage = BaseFieldDefinition::create($type);
$plugin_class = \Drupal::service('plugin.manager.field.field_type')->getPluginClass($type);
$schema = $plugin_class::schema($storage);
}
catch (\Throwable) {
// Schema construction fails for types that require settings we haven't
// supplied (e.g. entity_reference without target_type). Treat those as
// unsafe: if DefaultHandler cannot reason about the schema, neither
// can this coverage test, and a dedicated handler is the right answer.
return FALSE;
}

$columns = $schema['columns'] ?? [];

return count($columns) === 1 && array_key_exists('value', $columns);
}

/**
* Guards against DefaultHandler being mistakenly matched as a "handler".
*
* The handler registry never lists DefaultHandler explicitly (it is the
* fallback). If this assumption ever changes, isHandlerRegistered() above
* would falsely mark the DefaultHandler-safe types as "handled", hiding
* real gaps.
*/
public function testDefaultHandlerIsNotRegistered(): void {
$property = new \ReflectionProperty(Core::class, 'fieldHandlers');
$handlers = $property->getValue($this->core);

$this->assertNotContains(
DefaultHandler::class,
$handlers,
'DefaultHandler must remain the unregistered fallback.',
);
}

}
Loading
Loading