Skip to content

Commit

Permalink
A new content authoring command: entity:create (#5815)
Browse files Browse the repository at this point in the history
  • Loading branch information
weitzman committed Feb 24, 2024
1 parent 13a65b6 commit 841bf44
Showing 1 changed file with 236 additions and 0 deletions.
236 changes: 236 additions & 0 deletions src/Commands/core/EntityCreateCommands.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<?php

declare(strict_types=1);

namespace Drush\Commands\core;

use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\SiteProcess\Util\Escape;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountSwitcherInterface;
use Drupal\field\Entity\FieldStorageConfig;
use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;
use Drush\Utils\StringUtils;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Yaml\Yaml;

final class EntityCreateCommands extends DrushCommands
{
const CREATE = 'entity:create';

public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
protected EntityFieldManagerInterface $entityFieldManager,
protected AccountSwitcherInterface $accountSwitcher
) {
parent::__construct();
}

public static function create(ContainerInterface $container): self
{
$commandHandler = new static(
$container->get('entity_type.manager'),
$container->get('entity_field.manager'),
$container->get('account_switcher')
);

return $commandHandler;
}

/**
* Create a content entity after prompting for field values.
*
* When entering field values, one may submit an incomplete document and any entity violations
* will be helpfully reported at the top of the document. enter <info>skip</info> as
* a value in order to skip validation for that field. Timestamp values may be expressed via any string
* recognized by strtotime()
*/
#[CLI\Command(name: self::CREATE, aliases: ['econ', 'entity-create'])]
#[CLI\Argument(name: 'entity_type', description: 'An entity type name.')]
#[CLI\Argument(name: 'bundle', description: 'A bundle name')]
#[CLI\Option(name: 'uid', description: 'The entity author ID. Also used by permission checks (e.g. content moderation)')]
#[CLI\Option(name: 'skip-fields', description: 'A list of field names that skip both data entry and validation. Delimit fields by comma')]
#[CLI\Option(name: 'validate', description: 'Validate the entity before saving.')]
#[CLI\OptionsetGetEditor]
#[CLI\Usage(name: 'drush entity:create node article --validate=0', description: 'Create an article entity and skip validation entirely.')]
#[CLI\Usage(name: 'drush entity:create node article --skip-fields=field_media_image,field_tags', description: 'Create an article omitting two fields.')]
#[CLI\Usage(name: 'drush entity:create user user --editor=nano', description: 'Create a user using the Nano text editor.')]
#[CLI\Version(version: '12.5')]
public function createEntity(string $entity_type, $bundle, array $options = ['validate' => true, 'uid' => self::REQ, 'skip-fields' => self::REQ]): string
{
$bundleKey = $this->entityTypeManager->getDefinition($entity_type)->getKey('bundle');
$entity = $this->entityTypeManager->getStorage($entity_type)->create([$bundleKey => $bundle]);
$instances = $this->entityFieldManager->getFieldDefinitions($entity_type, $bundle);
$skip_fields = StringUtils::csvToArray($options['skip-fields']);
if ($skip_fields) {
$instances = array_diff_key($instances, array_flip($skip_fields));
}
ksort($instances);
$yaml = $this->getInitialYaml($instances, $entity, $options);
// Write tmp YAML file for editing
$path = drush_save_data_to_temp_file($yaml, '.yml');
do {
$messages = [];
$yaml = $this->edit($options['editor'], $path);
try {
$values = Yaml::parse($yaml);
} catch (\Exception $e) {
$yaml = "# Error: {$e->getMessage()}\n" . $yaml;
file_put_contents($path, $yaml);
continue;
}
foreach (array_filter($values) as $name => $value) {
if ($value !== 'skip') {
$this->setValue($entity, $name, $value);
} else {
$skip_fields[] = $name;
}
}

if (!$options['validate']) {
break;
}
$violations = $entity->validate();
$this->filterViolations($violations, $skip_fields);
if (!$violations->count()) {
break;
}
$this->removePreamble($yaml, $lines);

// Switch needed to overcome Content Moderation constraint.
if ($options['uid']) {
$this->accountSwitcher->switchTo($this->entityTypeManager->getStorage('user')->load($options['uid']));
}
foreach ($violations as $violation) {
$messages[] = "# {$violation->getPropertyPath()}: {$violation->getMessage()}";
}
file_put_contents($path, "# Violations:\n" . implode("\n", $messages) . "\n" . implode("\n", $lines));
} while (true);
$entity->save();
return $entity->toUrl('canonical', ['absolute' => true])->toString();
}

private function edit($editor, string $path): string
{
$exec = self::getEditor($editor);
$cmd = sprintf($exec, Escape::shellArg($path));
$process = $this->processManager()->shell($cmd);
$process->setTty(true);
$process->mustRun();
return file_get_contents($path);
}


/**
* Build initial YAML including comments with authoring hints.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface[] $instances
*/
private function getInitialYaml(array $instances, ContentEntityInterface $entity, array $options): string
{
$lines = $comments = [];
// Build field names and default value.
foreach ($instances as $field_name => $instance) {
$comment = [];
if (!$this->showField($instance)) {
continue;
}
$cardinality = $instance->getFieldStorageDefinition()->getCardinality();
$multiple = $instance->getFieldStorageDefinition()->isMultiple();
$suffix = $multiple ? '[]' : '';
if ($instance->isRequired()) {
$comment[] = 'required';
}
if (!in_array($cardinality, [1, FieldStorageConfig::CARDINALITY_UNLIMITED])) {
$comment[] = "max: $cardinality";
}
// Get a simple default value if defined.
$default_value = '';
if ($field_name == 'uid') {
$default_value = $options['uid'];
} elseif ($default = $instance->getDefaultValue($entity)) {
if (count($default) == 1 && (count($default[0]) == 1)) {
// var_export converts boolean to "true".
$default_value = var_export(reset($default[0]), true);
;
}
}
$comments[] = $comment;
$lines[] = "$field_name: $default_value" . $suffix;
}
// Append comment as needed, using padding.
$max_len = max(array_map('strlen', $lines));
foreach ($lines as $key => $line) {
if ($comments[$key]) {
$lines[$key] = str_pad($line, $max_len + 1) . '# ' . implode(', ', $comments[$key]);
}
}
return implode("\n", $lines);
}

/**
* Show/hide a field when building initial YAML.
*/
private function showField(FieldDefinitionInterface $instance): bool
{
if ($instance->isReadOnly()) {
return false;
}
if (in_array($instance->getType(), ['image', 'layout_section', 'changed', 'created'])) {
return false;
}
foreach (['revision', 'content_translation', 'default_langcode', 'revision', 'content_translation'] as $deny) {
if (str_starts_with($instance->getName(), $deny)) {
return false;
}
}
return true;
}

#[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR)]
private function validate(CommandData $commandData): void
{
if (!$this->input()->isInteractive()) {
throw new \RuntimeException('entity:create is designed for an interactive terminal.');
}
}

private function removePreamble(string $yaml, &$lines): void
{
$lines = explode("\n", $yaml);
foreach ($lines as $index => $line) {
if (str_starts_with($line, '#')) {
unset($lines[$index]);
}
}
}

private function filterViolations(EntityConstraintViolationListInterface &$violations, array $skip_fields): void
{
foreach ($violations as $key => $violation) {
if (in_array($violation->getPropertyPath(), $skip_fields)) {
$violations->remove($key);
}
}
}

protected function setValue(\Drupal\Core\Entity\EntityInterface $entity, int|string $name, mixed $value): void
{
switch ($entity->get($name)->getFieldDefinition()->getType()) {
case 'timestamp':
if (!is_numeric($value)) {
$value = strtotime($value);
}
// Keep going.
default:
$entity->set($name, $value);
break;
}
}
}

0 comments on commit 841bf44

Please sign in to comment.