custom_field-1.0.x-dev/src/Commands/UpdaterCommands.php

src/Commands/UpdaterCommands.php
<?php

declare(strict_types=1);

namespace Drupal\custom_field\Commands;

use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\custom_field\CustomFieldUpdateManagerInterface;
use Drupal\custom_field\Plugin\CustomField\FieldType\DateRangeType;
use Drupal\custom_field\Plugin\CustomField\FieldType\DateTimeType;
use Drupal\custom_field\Plugin\CustomFieldTypeManagerInterface;
use Drupal\field\Entity\FieldStorageConfig;
use Drush\Commands\DrushCommands;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
 * Drush commands for managing custom field column updates.
 */
final class UpdaterCommands extends DrushCommands {

  use StringTranslationTrait;

  /**
   * The custom field update manager service.
   *
   * @var \Drupal\custom_field\CustomFieldUpdateManagerInterface
   */
  private readonly CustomFieldUpdateManagerInterface $updateManager;

  /**
   * The custom field type manager service.
   *
   * @var \Drupal\custom_field\Plugin\CustomFieldTypeManagerInterface
   */
  private readonly CustomFieldTypeManagerInterface $customFieldTypeManager;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  private readonly EntityTypeManagerInterface $entityTypeManager;

  /**
   * Constructs a new UpdaterCommands object.
   *
   * @param \Drupal\custom_field\CustomFieldUpdateManagerInterface $update_manager
   *   The custom field update manager service.
   * @param \Drupal\custom_field\Plugin\CustomFieldTypeManagerInterface $custom_field_type_manager
   *   The custom field type manager service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager service.
   */
  public function __construct(CustomFieldUpdateManagerInterface $update_manager, CustomFieldTypeManagerInterface $custom_field_type_manager, EntityTypeManagerInterface $entity_type_manager) {
    parent::__construct();
    $this->updateManager = $update_manager;
    $this->customFieldTypeManager = $custom_field_type_manager;
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * Add a new column to a custom field.
   *
   * @command custom_field:add-column
   * @usage drush custom_field:add-column
   *   Interactively adds a new column to a custom field.
   * @aliases cf-add-column
   *
   * @throws \Exception
   */
  public function addColumn(): void {
    // Prompt for field storage instance to get the FieldStorageConfig object.
    $field_storage = $this->promptFieldStorage();
    $entity_type_id = $field_storage->getTargetEntityTypeId();
    $field_name = $field_storage->getName();

    // Prompt for the new column name, validating it doesn't already exist.
    $new_column = $this->promptNewProperty($field_storage);
    $data_type = $this->promptDataType();

    // Prompt for column options based on the data type.
    $column_options = $this->promptColumnOptions($data_type);

    // Confirmation.
    $io = new SymfonyStyle($this->input, $this->output);
    $io->section('Summary');

    // Format options as a readable list.
    $options_display = 'None';
    if (!empty($column_options)) {
      $options_list = [];
      foreach ($column_options as $key => $value) {
        $value_display = is_bool($value) ? ($value ? 'TRUE' : 'FALSE') : $value;
        $options_list[] = "$key: $value_display";
      }
      $options_display = "\n  - " . implode("\n  - ", $options_list);
    }

    $io->listing([
      "entity_type_id: $entity_type_id",
      "field_name: $field_name",
      "column: $new_column",
      "data_type: $data_type",
      "options: $options_display",
    ]);

    if (!$io->confirm('Proceed with adding this column?')) {
      $io->warning('Operation cancelled.');
      return;
    }

    // Execute the addColumn method.
    $this->updateManager->addColumn($entity_type_id, $field_name, $new_column, $data_type, $column_options);

    // Process any pending batch operations (for data restoration).
    $this->processBatch();

    $this->io()->success((string) $this->t('Column @column added to @field_name on @entity_type.', [
      '@column' => $new_column,
      '@field_name' => $field_name,
      '@entity_type' => $entity_type_id,
    ]));

    // Output example update hook.
    $this->outputUpdateHookExample('add', [
      'entity_type_id' => $entity_type_id,
      'field_name' => $field_name,
      'column' => $new_column,
      'data_type' => $data_type,
      'column_options' => $column_options,
    ]);
  }

  /**
   * Remove a column from a custom field.
   *
   * @command custom_field:remove-column
   * @usage drush custom_field:remove-column
   *   Interactively removes a column from a custom field.
   * @aliases cf-remove-column
   *
   * @throws \Exception
   */
  public function removeColumn(): void {
    // Prompt for field storage instance to get the FieldStorageConfig object.
    $field_storage = $this->promptFieldStorage();
    $entity_type_id = $field_storage->getTargetEntityTypeId();
    $field_name = $field_storage->getName();

    // Prompt for the column to remove.
    $column = $this->promptColumn($field_storage);

    // Confirmation.
    $io = new SymfonyStyle($this->input, $this->output);
    $io->section('Summary');
    $io->listing([
      "entity_type_id: $entity_type_id",
      "field_name: $field_name",
      "column: $column",
    ]);

    if (!$io->confirm('Proceed with removing this column?')) {
      $io->warning('Operation cancelled.');
      return;
    }

    // Execute the removeColumn method.
    $this->updateManager->removeColumn($entity_type_id, $field_name, $column);

    // Process any pending batch operations (for data restoration).
    $this->processBatch();

    $this->io()->success((string) $this->t('Column @column removed from @field_name on @entity_type.', [
      '@column' => $column,
      '@field_name' => $field_name,
      '@entity_type' => $entity_type_id,
    ]));

    // Output example update hook.
    $this->outputUpdateHookExample('remove', [
      'entity_type_id' => $entity_type_id,
      'field_name' => $field_name,
      'column' => $column,
    ]);
  }

  /**
   * Prompt for the new property name, ensuring it doesn't already exist.
   *
   * @param \Drupal\field\Entity\FieldStorageConfig $field_storage
   *   The field storage configuration object.
   *
   * @return string
   *   The new property name.
   *
   * @throws \RuntimeException
   */
  protected function promptNewProperty(FieldStorageConfig $field_storage): string {
    $columns = $field_storage->getSetting('columns') ?? [];
    $existing_column_names = array_keys($columns);
    $field_name = $field_storage->getName();
    // Calculate a safe max column length to coincide with SQL column limit.
    $max_name_length = 64 - strlen($field_name) - 12;

    // phpcs:ignore Drupal.Semantics.FunctionT.WhiteSpace
    $question = new Question((string) $this->t('Enter new column name (lowercase, max @max_length chars)', ['@max_length' => $max_name_length]));
    $question->setValidator(function ($answer) use ($existing_column_names, $max_name_length) {
      $answer = strtolower(trim($answer));
      if (empty($answer)) {
        throw new \RuntimeException('The new column name is required.');
      }
      if (strlen($answer) > $max_name_length) {
        throw new \RuntimeException(sprintf('The column name cannot exceed %d characters.', $max_name_length));
      }
      if (in_array($answer, $existing_column_names, TRUE)) {
        throw new \RuntimeException("The column name '$answer' already exists in the field storage.");
      }
      // Validate machine name format (alphanumeric and underscores).
      if (!preg_match('/^(?!.*__)[a-zA-Z0-9_]+$/', $answer)) {
        throw new \RuntimeException('The column name must contain only alphanumeric characters and single underscores, no double underscores.');
      }
      return $answer;
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for the field storage instance of type 'custom'.
   *
   * @return \Drupal\field\Entity\FieldStorageConfig
   *   The selected field storage configuration object.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function promptFieldStorage(): FieldStorageConfig {
    // Load all field storage instances of type 'custom'.
    /** @var \Drupal\field\FieldStorageConfigInterface[] $field_storage_configs */
    $field_storage_configs = $this->entityTypeManager
      ->getStorage('field_storage_config')
      ->loadByProperties(['type' => 'custom']);

    if (empty($field_storage_configs)) {
      throw new \RuntimeException('No custom field storage instances found.');
    }

    // Prepare table rows: ID, Field Name, Entity Type, Bundles.
    $rows = [];
    $choices = [];
    $index = 0;
    foreach ($field_storage_configs as $field_storage) {
      $entity_type_id = $field_storage->getTargetEntityTypeId();
      $field_name = $field_storage->getName();

      // Get bundles where this field is used.
      /** @var \Drupal\field\FieldConfigInterface[] $field_configs */
      $field_configs = $this->entityTypeManager
        ->getStorage('field_config')
        ->loadByProperties([
          'entity_type' => $entity_type_id,
          'field_name' => $field_name,
        ]);
      $bundles = array_map(function ($field_config) {
        return $field_config->getTargetBundle();
      }, $field_configs);
      $bundles = !empty($bundles) ? implode(', ', $bundles) : 'None';

      $rows[] = [
        $index,
        $field_name,
        $entity_type_id,
        $bundles,
      ];
      $choices[$index] = $field_storage;
      $index++;
    }

    // Display the table.
    $table = new Table($this->output);
    $table
      ->setHeaders(['#', 'Field Name', 'Entity Type', 'Bundles'])
      ->setRows($rows);
    $table->render();

    $prompt = $this->t('Enter the number (#) of the field storage instance');
    $question = new Question((string) $prompt);
    $question->setValidator(function ($answer) use ($choices) {
      if (!isset($choices[$answer])) {
        throw new \RuntimeException('Invalid selection: ' . $answer);
      }
      return $choices[$answer];
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for the column to remove from the field storage.
   *
   * @param \Drupal\field\Entity\FieldStorageConfig $field_storage
   *   The field storage configuration object.
   *
   * @return string
   *   The selected column name.
   *
   * @throws \RuntimeException
   */
  protected function promptColumn(FieldStorageConfig $field_storage): string {
    // Get the columns from the field storage settings.
    $columns = $field_storage->getSetting('columns') ?? [];

    if (empty($columns)) {
      throw new \RuntimeException('No columns found for field ' . $field_storage->getName() . '.');
    }
    // Removing the only column left is not allowed.
    elseif (count($columns) === 1) {
      throw new \RuntimeException('The field ' . $field_storage->getName() . ' has only 1 column.');
    }

    // Prepare table rows: ID, Name, Type.
    $rows = [];
    $choices = [];
    $index = 0;
    foreach ($columns as $column_name => $column_info) {
      $rows[] = [
        $index,
        $column_name,
        $column_info['type'] ?? 'Unknown',
      ];
      $choices[$index] = $column_name;
      $index++;
    }

    // Display the table.
    $table = new Table($this->output);
    $table
      ->setHeaders(['#', 'Column Name', 'Type'])
      ->setRows($rows);
    $table->render();

    // phpcs:ignore Drupal.Semantics.FunctionT.WhiteSpace
    $question = new Question((string) $this->t('Enter the number (#) of the column to remove'));
    $question->setValidator(function ($answer) use ($choices) {
      if (!isset($choices[$answer])) {
        throw new \RuntimeException('Invalid selection: ' . $answer);
      }
      return $choices[$answer];
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for the data type.
   *
   * @param string|null $data_type
   *   The provided data type, if any.
   *
   * @return string
   *   The prompted data type.
   *
   * @throws \RuntimeException
   */
  protected function promptDataType(?string $data_type = NULL): string {
    if ($data_type) {
      return $data_type;
    }

    $definitions = $this->customFieldTypeManager->getDefinitions();

    if (empty($definitions)) {
      throw new \RuntimeException('No field type plugins found.');
    }

    // Sort definitions alphabetically by ID.
    ksort($definitions);

    // Prepare table rows: ID, Label.
    $rows = [];
    $choices = [];
    $index = 0;
    foreach ($definitions as $id => $definition) {
      $rows[] = [
        $index,
        $id,
        (string) $definition['label'],
      ];
      $choices[$index] = $id;
      $index++;
    }

    // Display the table.
    $table = new Table($this->output);
    $table
      ->setHeaders(['#', 'ID', 'Label'])
      ->setRows($rows);
    $table->render();

    $prompt = $this->t('Enter the number (#) of the field type for the new column');
    $question = new Question((string) $prompt);
    $question->setValidator(function ($answer) use ($choices) {
      if (!isset($choices[$answer])) {
        throw new \RuntimeException('Invalid selection: ' . $answer);
      }
      return $choices[$answer];
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for column options based on the data type.
   *
   * Supported options:
   *  - string: max_length (1-255, optional, default 255)
   *  - telephone: max_length (1-256, optional, default 256)
   *  - integer/float: unsigned (boolean, default FALSE), size (tiny, small,
   *    medium, big, normal, optional)
   *  - decimal: unsigned (boolean, default FALSE), precision (1-65, optional,
   *    default 10), scale (0-30, optional, default 2)
   *  - entity_reference: target_type (required)
   *  - datetime: datetime_type (date or datetime, required)
   *
   * @param string $data_type
   *   The selected data type.
   *
   * @return array
   *   The column options array, with NULL values filtered out.
   *
   * @throws \RuntimeException
   */
  protected function promptColumnOptions(string $data_type): array {
    $options = [];

    if (in_array($data_type, ['integer', 'float', 'decimal'])) {
      $prompt = $this->t('Is the field unsigned? (yes/no)');
      $options['unsigned'] = $this->promptBoolean(
        (string) $prompt,
        FALSE,
      );
    }
    switch ($data_type) {
      case 'string':
        $prompt = $this->t('Enter max length for string (1-255, optional, default 255)');
        $max_length = $this->promptNumeric(
          (string) $prompt,
          1,
          255,
          FALSE,
          255
        );
        $options['length'] = $max_length;
        break;

      case 'telephone':
        $prompt = $this->t('Enter max length for telephone (1-256, optional, default 256)');
        $max_length = $this->promptNumeric(
          (string) $prompt,
          1,
          256,
          FALSE,
          256
        );
        $options['length'] = $max_length;
        break;

      case 'integer':
      case 'float':
        $prompt = $this->t('Select size (optional, press Enter to skip)');
        $options['size'] = $this->promptChoice(
          (string) $prompt,
          ['tiny', 'small', 'medium', 'big', 'normal'],
          'normal'
        );
        break;

      case 'decimal':
        $prompt_precision = $this->t('Enter precision for decimal (10-32, optional, default 10)');
        $options['precision'] = $this->promptNumeric(
          (string) $prompt_precision,
          10,
          32,
          FALSE,
          10
        );
        $prompt_scale = $this->t('Enter scale for decimal (0-10, optional, default 2)');
        $options['scale'] = $this->promptNumeric(
          (string) $prompt_scale,
          0,
          10,
          FALSE,
          2
        );
        break;

      case 'entity_reference':
        $options['target_type'] = $this->promptTargetEntityType();
        break;

      case 'datetime':
      case 'daterange':
        $prompt = $this->t('Select datetime type');
        $date_time_types = [
          DateTimeType::DATETIME_TYPE_DATE,
          DateTimeType::DATETIME_TYPE_DATETIME,
        ];
        if ($data_type === 'daterange') {
          $date_time_types[] = DateRangeType::DATETIME_TYPE_ALLDAY;
        }
        $options['datetime_type'] = $this->promptChoice(
          (string) $prompt,
          $date_time_types,
          'datetime'
        );
        break;
    }

    // Filter out NULL values for optional fields.
    return array_filter($options, function ($value) {
      return $value !== NULL;
    });
  }

  /**
   * Prompt for a boolean value using arrow keys.
   *
   * @param string $prompt
   *   The prompt message.
   * @param bool $default
   *   The default value.
   *
   * @return bool|null
   *   The boolean value, or NULL if optional and skipped.
   *
   * @throws \RuntimeException
   */
  protected function promptBoolean(string $prompt, bool $default): ?bool {
    $choices = ['no', 'yes'];
    $default_index = $default ? 1 : 0;

    $question = new ChoiceQuestion($prompt, $choices, $default_index);
    $question->setValidator(function ($answer) {
      return $answer === 'yes';
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for a numeric value within a range.
   *
   * @param string $prompt
   *   The prompt message.
   * @param int $min
   *   The minimum value.
   * @param int $max
   *   The maximum value.
   * @param bool $required
   *   Whether the value is required.
   * @param int|null $default
   *   The default value if provided.
   *
   * @return int|null
   *   The numeric value, or NULL if optional and skipped.
   *
   * @throws \RuntimeException
   */
  protected function promptNumeric(string $prompt, int $min, int $max, bool $required, ?int $default = NULL): ?int {
    $question = new Question($prompt);
    $question->setValidator(function ($answer) use ($min, $max, $required, $default) {
      $answer = trim((string) $answer);
      if (!$required && $answer === '') {
        return $default;
      }
      if (!is_numeric($answer) || $answer < $min || $answer > $max) {
        throw new \RuntimeException(sprintf('Please enter a number between %d and %d.', $min, $max));
      }
      return (int) $answer;
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for a choice from a list of options using arrow keys.
   *
   * @param string $prompt
   *   The prompt message.
   * @param array $options
   *   The list of valid options.
   * @param string|null $default
   *   The default option, or NULL if optional.
   *
   * @return string|null
   *   The selected option, or NULL if optional and skipped.
   *
   * @throws \RuntimeException
   */
  protected function promptChoice(string $prompt, array $options, ?string $default): ?string {
    // If optional and no default, add a "Skip" option.
    $choices = $options;
    if ($default === NULL) {
      $choices[] = 'Skip';
    }

    $question = new ChoiceQuestion($prompt, $choices, $default);
    $question->setValidator(function ($answer) use ($options, $default) {
      if ($default === NULL && $answer === 'Skip') {
        return NULL;
      }
      if (!in_array($answer, $options, TRUE)) {
        throw new \RuntimeException('Invalid selection.');
      }
      return $answer;
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Prompt for the target entity type.
   *
   * @return string
   *   The selected entity type ID.
   *
   * @throws \RuntimeException
   */
  protected function promptTargetEntityType(): string {
    // Get all entity type definitions.
    $entity_type_definitions = $this->entityTypeManager->getDefinitions();

    if (empty($entity_type_definitions)) {
      throw new \RuntimeException('No entity types found.');
    }

    // Separate content and configuration entity types.
    $content_entities = [];
    $config_entities = [];
    foreach ($entity_type_definitions as $id => $definition) {
      if ($definition->entityClassImplements(ContentEntityInterface::class)) {
        $content_entities[$id] = $definition;
      }
      elseif ($definition->entityClassImplements(ConfigEntityInterface::class)) {
        $config_entities[$id] = $definition;
      }
    }

    // Sort each group alphabetically by label.
    uasort($content_entities, function ($a, $b) {
      return strcmp((string) $a->getLabel(), (string) $b->getLabel());
    });
    uasort($config_entities, function ($a, $b) {
      return strcmp((string) $a->getLabel(), (string) $b->getLabel());
    });

    // Prepare table rows: ID, Label.
    $rows = [];
    $choices = [];
    $index = 0;

    // Add content entities first.
    if (!empty($content_entities)) {
      $rows[] = ['Content Entities', '', ''];
      foreach ($content_entities as $id => $definition) {
        $rows[] = [
          $index,
          $id,
          (string) $definition->getLabel(),
        ];
        $choices[$index] = $id;
        $index++;
      }
    }

    // Add configuration entities.
    if (!empty($config_entities)) {
      $rows[] = ['Configuration Entities', '', ''];
      foreach ($config_entities as $id => $definition) {
        $rows[] = [
          $index,
          $id,
          (string) $definition->getLabel(),
        ];
        $choices[$index] = $id;
        $index++;
      }
    }

    // Display the table.
    $io = new SymfonyStyle($this->input, $this->output);
    $io->title((string) $this->t('Available Target Entity Types'));
    $table = new Table($this->output);
    $table
      ->setHeaders(['#', 'ID', 'Label'])
      ->setRows($rows);
    $table->render();

    // phpcs:ignore Drupal.Semantics.FunctionT.WhiteSpace
    $question = new Question((string) $this->t('Enter the number (#) of the target entity type: '));
    $question->setValidator(function ($answer) use ($choices) {
      if (!isset($choices[$answer])) {
        throw new \RuntimeException('Invalid selection: ' . $answer);
      }
      return $choices[$answer];
    });

    return $this->io()->askQuestion($question);
  }

  /**
   * Process any pending batch operations.
   *
   * @throws \Exception
   *   If the batch process fails.
   */
  protected function processBatch(): void {
    // Check if a batch is set.
    $batch = batch_get();
    if ($batch) {
      // Process the batch using Drush.
      $batch_result = drush_backend_batch_process();
      if ($batch_result === FALSE) {
        throw new \Exception('Failed to process batch for data restoration. Please check the logs for errors.');
      }
      // Clear the batch after processing.
      batch_set([]);
    }
  }

  /**
   * Output an example update hook for the executed command.
   *
   * @param string $operation
   *   The operation ('add' or 'remove').
   * @param array $params
   *   The parameters used in the command.
   */
  protected function outputUpdateHookExample(string $operation, array $params): void {
    $io = $this->io();
    $io->section('For deploying this update to other environments, copy the example update hook to your custom module .install file. Replace the module name (MY_MODULE) and sequence (N) accordingly.');

    $hook_name = 'MY_MODULE_update_N';
    $description = $operation === 'add' ?
      "Add column '{$params['column']}' to '{$params['field_name']}' on '{$params['entity_type_id']}'." :
      "Remove column '{$params['column']}' from '{$params['field_name']}' on '{$params['entity_type_id']}'.";
    $action = $operation === 'add' ? 'addColumn' : 'removeColumn';

    // Start the code block.
    $code[] = "/**";
    $code[] = " * $description";
    $code[] = " */";
    $code[] = "function $hook_name() {";
    $code[] = "  \\Drupal::service('custom_field.update_manager')->$action(";
    $code[] = "    '{$params['entity_type_id']}',";
    $code[] = "    '{$params['field_name']}',";
    $code[] = "    '{$params['column']}',";
    if ($operation === 'add') {
      $code[] = "    '{$params['data_type']}',";
      // Format column_options as a PHP array.
      $options_str = !empty($params['column_options']) ? $this->formatArray($params['column_options']) : '';
      if (!empty($options_str)) {
        $code[] = "    $options_str";
      }
    }
    $code[] = "  );";
    $code[] = "}";
    $code[] = "";

    // Output the formatted code block.
    $io->block(implode("\n", $code), NULL, 'fg=green;bg=black', ' ', TRUE);
  }

  /**
   * Format an array as a PHP array string with proper indentation.
   *
   * @param array $array
   *   The array to format.
   * @param int $indent
   *   The number of spaces to indent each level.
   *
   * @return string
   *   The formatted array string.
   */
  protected function formatArray(array $array, int $indent = 4): string {
    if (empty($array)) {
      return '[]';
    }

    $lines = ['['];
    $spaces = str_repeat(' ', $indent);
    foreach ($array as $key => $value) {
      if (is_string($value)) {
        $lines[] = "$spaces'$key' => '$value',";
      }
      elseif (is_bool($value)) {
        $lines[] = "$spaces'$key' => " . ($value ? 'TRUE' : 'FALSE') . ",";
      }
      else {
        $lines[] = "$spaces'$key' => " . var_export($value, TRUE) . ",";
      }
    }
    $lines[] = str_repeat(' ', $indent) . ']';

    return implode("\n", $lines);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc