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
71 changes: 71 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,77 @@ else {
}
```

### Creation aliases are now first-class

Ergonomic stub properties that are not real Drupal fields - `author` on a
node, `vocabulary_machine_name` and `parent` on a term, `roles` on a user -
are no longer scattered as inline `if ($stub->hasValue('...'))` blocks in
the create methods. They are declared, discoverable artefacts:

- `Drupal\Driver\Alias\CreationAliasInterface` - base contract.
- `Drupal\Driver\Alias\PreCreateAliasInterface` - mutates the stub before
`Entity::create()`.
- `Drupal\Driver\Alias\PostCreateAliasInterface` - acts on the entity
after save (e.g. role assignment).
- `Drupal\Driver\Capability\CreationAliasCapabilityInterface` -
`getCreationAliases(string $entity_type): array<string, CreationAliasInterface>`.

The new capability interface is **opt-in**. It is NOT extended by the
composite driver interfaces (`DrupalDriverInterface`, `DrushDriverInterface`,
`BlackboxDriverInterface`) or by `CoreInterface`. Hand-rolled implementations
of those composite contracts (and PHPUnit-style test doubles built against
them) are NOT required to add `getCreationAliases()`. The concrete classes
`Core`, `DrupalDriver`, and `DrushDriver` implement the capability directly;
`BlackboxDriver` does not.

Consumers (typically DrupalExtension) check for the capability before
calling:

```php
if ($driver instanceof CreationAliasCapabilityInterface) {
$ignored = array_keys($driver->getCreationAliases('node'));
// pass $ignored into the stub parser as the allow-list
}
```

#### Behavioural change: unresolvable aliases now throw

The alias resolvers raise
`Drupal\Driver\Exception\CreationAliasResolutionException` (a subclass of
`\InvalidArgumentException`) when a referenced value cannot be resolved.
Previously the `author` path silently coerced an unknown username into
`uid = 0`, leaving typos invisible. There is no deprecation period.

| Trigger | v2 behaviour | v3 behaviour |
|---|---|---|
| `author` → unknown username on node stub | Silent; `uid` left unset or `0` | Throws `CreationAliasResolutionException` |
| `vocabulary_machine_name` → unknown vocab | Throws `\InvalidArgumentException` | Throws `\InvalidArgumentException` (unchanged - validated by `termCreate()`) |
| `parent` → unknown term name | Throws `\InvalidArgumentException` | Throws `CreationAliasResolutionException` (subclass of `\InvalidArgumentException`; existing catches still match) |
| `roles` on Core `userCreate` | Silently ignored - roles never assigned | Roles assigned via `RolesAlias` (post-create); unknown role throws `\RuntimeException` from `userAddRole()` |

Test suites that relied on silent failures should fix the underlying typo
or stop sending the alias. Test suites that already caught
`\InvalidArgumentException` (for `parent` or `vocabulary_machine_name`) keep
working unchanged.

#### `roles` symmetry on Core

Before v3, `'roles' => [...]` on a user stub only had effect when the driver
was `DrushDriver` - Core silently ignored it. Both drivers now honour the
key via a shared `RolesAlias`, eliminating the asymmetry. Consumers that
fell back to a follow-up `userAddRole()` call after `userCreate()` on Core
can simplify, but they don't have to - calling `userAddRole()` after a
roles-bearing stub is harmless (the role is already assigned).

#### Overriding or adding aliases

Subclasses of `Core` can override `registerDefaultCreationAliases()` (called
once from the constructor) to add, replace, or remove aliases. Re-registering
the same name on the same entity type replaces the inherited entry. Drivers
that compose `Drupal\Driver\Alias\CreationAliasRegistryTrait` get
`registerCreationAlias()`, `getCreationAliases()`, and two protected
dispatchers (`applyPreCreateAliases()`, `applyPostCreateAliases()`) for free.

### What stays the same

- The three driver class names (`BlackboxDriver`, `DrupalDriver`,
Expand Down
46 changes: 46 additions & 0 deletions src/Drupal/Driver/Alias/CreationAliasInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Alias;

/**
* Base contract for a creation alias.
*
* An alias represents one ergonomic stub property that is not a real
* Drupal field, together with the resolution behaviour the driver
* applies during entity creation. Concrete aliases implement either
* 'PreCreateAliasInterface' (to mutate the stub before save) or
* 'PostCreateAliasInterface' (to act on the entity after save).
*/
interface CreationAliasInterface {

/**
* Returns the alias name as it appears on entity stubs.
*
* @return string
* The stub property name this alias resolves (e.g. 'author', 'roles').
*/
public function getName(): string;

/**
* Returns the Drupal entity type id this alias applies to.
*
* @return string
* A Drupal entity type id (e.g. 'node', 'user', 'taxonomy_term').
*/
public function getEntityType(): string;

/**
* Returns a human-readable description of what this alias does.
*
* Used by documentation tools and error messages. Should describe the
* input shape, the resolution behaviour, and the resulting effect on
* the created entity in a single sentence.
*
* @return string
* A single-sentence description of the alias's behaviour.
*/
public function getDescription(): string;

}
102 changes: 102 additions & 0 deletions src/Drupal/Driver/Alias/CreationAliasRegistryTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Alias;

use Drupal\Driver\Entity\EntityStubInterface;

/**
* Implements the 'CreationAliasCapabilityInterface' registry on a driver.
*
* Hosts an entity-type-indexed map of registered aliases, exposes the
* 'getCreationAliases()' lookup, and provides two protected dispatcher
* helpers for create methods to invoke: 'applyPreCreateAliases()' before
* 'Entity::create()' and 'applyPostCreateAliases()' after save.
*
* Both dispatchers gate on 'EntityStubInterface::hasValue()' so an alias
* only fires when the stub actually carries the key it owns.
*/
trait CreationAliasRegistryTrait {

/**
* Registered aliases indexed by entity type and alias name.
*
* @var array<string, array<string, \Drupal\Driver\Alias\CreationAliasInterface>>
*/
protected array $creationAliases = [];

/**
* Adds an alias to the registry.
*
* Re-registering the same name on the same entity type replaces the
* previous entry so subclasses can override aliases by simply
* registering a new instance after 'parent::' setup.
*
* @param \Drupal\Driver\Alias\CreationAliasInterface $alias
* The alias to register.
*/
public function registerCreationAlias(CreationAliasInterface $alias): void {
$this->creationAliases[$alias->getEntityType()][$alias->getName()] = $alias;
}

/**
* Returns creation aliases that apply to the given entity type.
*
* @param string $entity_type
* The Drupal entity type id.
*
* @return array<string, \Drupal\Driver\Alias\CreationAliasInterface>
* Map of alias name to alias instance. Empty array when no aliases apply.
*/
public function getCreationAliases(string $entity_type): array {
return $this->creationAliases[$entity_type] ?? [];
}

/**
* Runs every pre-create alias whose key is present on the stub.
*
* @param \Drupal\Driver\Entity\EntityStubInterface $stub
* The stub being prepared for creation.
* @param string $entity_type
* The Drupal entity type id whose aliases should run.
*/
protected function applyPreCreateAliases(EntityStubInterface $stub, string $entity_type): void {
foreach ($this->getCreationAliases($entity_type) as $alias) {
if (!$alias instanceof PreCreateAliasInterface) {
continue;
}

if (!$stub->hasValue($alias->getName())) {
continue;
}

$alias->applyToStub($stub);
}
}

/**
* Runs every post-create alias whose key is present on the stub.
*
* @param \Drupal\Driver\Entity\EntityStubInterface $stub
* The stub used to create the entity.
* @param object $entity
* The entity that was just saved.
* @param string $entity_type
* The Drupal entity type id whose aliases should run.
*/
protected function applyPostCreateAliases(EntityStubInterface $stub, object $entity, string $entity_type): void {
foreach ($this->getCreationAliases($entity_type) as $alias) {
if (!$alias instanceof PostCreateAliasInterface) {
continue;
}

if (!$stub->hasValue($alias->getName())) {
continue;
}

$alias->applyAfterCreate($stub, $entity);
}
}

}
33 changes: 33 additions & 0 deletions src/Drupal/Driver/Alias/PostCreateAliasInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Alias;

use Drupal\Driver\Entity\EntityStubInterface;

/**
* A creation alias that acts on the entity after it has been saved.
*
* Use this lifecycle for side-effects that require the entity to exist
* first - for example, assigning roles to a user after the user record
* has been written, or attaching references to an entity that needs an
* id before it can be linked.
*/
interface PostCreateAliasInterface extends CreationAliasInterface {

/**
* Reads the alias value from the stub and applies it to the entity.
*
* @param \Drupal\Driver\Entity\EntityStubInterface $stub
* The stub used to create the entity. The dispatcher checks that
* 'getName()' is present on the stub before calling this method.
* @param object $entity
* The Drupal entity that was just persisted.
*
* @throws \Drupal\Driver\Exception\CreationAliasResolutionException
* When the value cannot be applied.
*/
public function applyAfterCreate(EntityStubInterface $stub, object $entity): void;

}
36 changes: 36 additions & 0 deletions src/Drupal/Driver/Alias/PreCreateAliasInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Alias;

use Drupal\Driver\Entity\EntityStubInterface;

/**
* A creation alias that mutates the stub before the entity is created.
*
* Implementations resolve the alias value, write any derived real-field
* values back onto the stub, and remove the alias's own key from the
* stub so the values bag passed to Drupal's entity factory contains
* only real fields.
*/
interface PreCreateAliasInterface extends CreationAliasInterface {

/**
* Resolves the alias value and mutates the stub in place.
*
* Implementations MUST remove the alias's own value from the stub
* (via 'EntityStubInterface::removeValue()') once resolution succeeds,
* unless they intentionally overwrite the same key with the resolved
* representation (e.g. swapping a term name for a tid).
*
* @param \Drupal\Driver\Entity\EntityStubInterface $stub
* The stub being prepared for creation. Must already carry a value
* under 'getName()' - the dispatcher checks for presence first.
*
* @throws \Drupal\Driver\Exception\CreationAliasResolutionException
* When the value cannot be resolved into a Drupal storage value.
*/
public function applyToStub(EntityStubInterface $stub): void;

}
80 changes: 80 additions & 0 deletions src/Drupal/Driver/Alias/RolesAlias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Drupal\Driver\Alias;

use Drupal\Driver\Capability\UserCapabilityInterface;
use Drupal\Driver\Entity\EntityStubInterface;
use Drupal\Driver\Exception\CreationAliasResolutionException;

/**
* Assigns roles to a user after the user has been created.
*
* Reads the 'roles' value (expected to be an array of role machine
* names or labels) and calls 'userAddRole()' for each entry on the
* driver supplied at construction. No-ops when the value is missing or
* not an array.
*
* Shared across drivers - 'Core', 'DrupalDriver', and 'DrushDriver'
* all register an instance that targets their own 'userAddRole()'
* implementation.
*/
class RolesAlias implements PostCreateAliasInterface {

/**
* Constructs the alias with the driver that receives role calls.
*
* @param \Drupal\Driver\Capability\UserCapabilityInterface $driver
* The driver whose 'userAddRole()' will be called per role.
*/
public function __construct(protected readonly UserCapabilityInterface $driver) {
}

/**
* {@inheritdoc}
*/
public function getName(): string {
return 'roles';
}

/**
* {@inheritdoc}
*/
public function getEntityType(): string {
return 'user';
}

/**
* {@inheritdoc}
*/
public function getDescription(): string {
return "Assigns roles to a user after creation. Accepts an array of role machine names or labels; ignores non-array values.";
}

/**
* {@inheritdoc}
*/
public function applyAfterCreate(EntityStubInterface $stub, object $entity): void {
$roles = $stub->getValue('roles');

if (!is_array($roles)) {
return;
}

foreach ($roles as $role) {
if (!is_scalar($role) && !$role instanceof \Stringable) {
throw new CreationAliasResolutionException("Cannot assign role because one of the 'roles' entries is not a scalar or stringable value.");
}

$name = trim((string) $role);

if ($name === '') {
throw new CreationAliasResolutionException("Cannot assign role because one of the 'roles' entries is empty after trimming.");
}

$this->driver->userAddRole($stub, $name);
}
}

}
Loading
Loading