custom_field-1.0.x-dev/src/CustomFieldUpdateManager.php

src/CustomFieldUpdateManager.php
<?php

namespace Drupal\custom_field;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface;
use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\FieldConfigInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
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\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\FieldStorageConfigInterface;

/**
 * Provides the CustomFieldUpdateManager service.
 */
class CustomFieldUpdateManager implements CustomFieldUpdateManagerInterface {

  use DependencySerializationTrait;
  use StringTranslationTrait;

  /**
   * The key value store service.
   *
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   */
  protected KeyValueStoreInterface $keyValue;

  /**
   * Creates a CustomFieldUpdateManager object.
   */
  public function __construct(
    protected EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager,
    protected EntityTypeManagerInterface $entityTypeManager,
    protected EntityTypeBundleInfoInterface $entityTypeBundleInfo,
    protected Connection $database,
    protected CustomFieldTypeManagerInterface $customFieldTypeManager,
    protected EntityLastInstalledSchemaRepositoryInterface $lastInstalledSchemaRepository,
    KeyValueFactoryInterface $key_value,
    protected ConfigFactoryInterface $configFactory,
    protected ModuleHandlerInterface $moduleHandler,
    protected LoggerChannelInterface $logger,
    protected MessengerInterface $messenger,
  ) {
    $this->keyValue = $key_value->get('entity.storage_schema.sql');
  }

  /**
   * {@inheritdoc}
   */
  public function addColumn(string $entity_type_id, string $field_name, string $new_property, string $data_type, array $options = []): void {
    /** @var \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage_definition */
    $field_storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($field_name, $entity_type_id);

    // Return early if no storage definition.
    if (!$field_storage_definition) {
      $message = 'There is no field storage definition for field ' . $field_name . ' and entity type ' . $entity_type_id . '.';
      throw new \Exception($message);
    }

    // Calculate a safe max column length to coincide with SQL column limit.
    $max_name_length = 64 - strlen($field_name) - 12;

    // Validate machine name format (alphanumeric, underscores).
    if (!preg_match('/^(?!.*__)[a-zA-Z0-9_]+$/', $new_property)) {
      throw new \Exception('The new column name must contain only alphanumeric characters and single underscores, no double underscores.');
    }

    if (strlen($new_property) > $max_name_length) {
      throw new \Exception(sprintf('The new column name cannot exceed %d characters.', $max_name_length));
    }

    $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
    $storage = $this->entityTypeManager->getStorage($entity_type_id);
    $definitions = $this->customFieldTypeManager->getDefinitions();
    $column = $definitions[$data_type] ?? NULL;

    // If we don't have a matching data type, return early.
    if (!$column) {
      $allowed_data_types = array_keys($definitions);
      sort($allowed_data_types);
      $valid_types_string = "\n" . implode("\n", $allowed_data_types);
      throw new \InvalidArgumentException(sprintf("Field '%s' requires a valid data type. Valid data types are: %s",
        $new_property,
        $valid_types_string
      ));
    }

    // Validate options.
    $target_type = NULL;
    $date_time_type = NULL;
    $uri_scheme = NULL;
    switch ($data_type) {
      case 'string':
      case 'telephone':
        $max = $data_type === 'telephone' ? 256 : 255;
        if (isset($options['length']) && (!is_numeric($options['length']) || $options['length'] > $max)) {
          throw new \InvalidArgumentException(sprintf("Field '%s' requires a numeric 'length' <= %s characters.",
            $new_property,
            $max,
          ));
        }
        break;

      case 'integer':
      case 'float':
      case 'decimal':
        if (isset($options['unsigned']) && !is_bool($options['unsigned'])) {
          throw new \InvalidArgumentException(sprintf("Field '%s' requires a boolean 'unsigned' value.",
            $new_property,
          ));
        }
        if (in_array($data_type, ['integer', 'float']) && isset($options['size'])) {
          $valid_sizes = [
            'tiny',
            'small',
            'medium',
            'big',
            'normal',
          ];
          if (!in_array($options['size'], $valid_sizes)) {
            $valid_size_string = "\n" . implode("\n", $valid_sizes);
            throw new \InvalidArgumentException(sprintf("Field '%s' requires a valid 'size' value. Valid sizes are:%s",
              $new_property,
              $valid_size_string,
            ));
          }
        }
        if ($data_type === 'decimal') {
          if (isset($options['precision'])) {
            $precision = (int) $options['precision'];
            if ($precision < 10 || $precision > 32) {
              throw new \InvalidArgumentException(sprintf("Field '%s' requires a numeric 'precision' value between 10 and 32.",
                $new_property,
              ));
            }
            $options['precision'] = $precision;
          }
          if (isset($options['scale'])) {
            if (!is_numeric($options['scale']) || $options['scale'] > 10) {
              throw new \InvalidArgumentException(sprintf("Field '%s' requires a numeric 'scale' value <= 10.",
                $new_property,
              ));
            }
            // Cast to integer.
            $options['scale'] = (int) $options['scale'];
          }
        }
        break;

      case 'entity_reference':
        if (!isset($options['target_type'])) {
          $entity_types = $this->entityTypeManager->getDefinitions();
          $valid_entity_types = array_keys($entity_types);
          sort($valid_entity_types);
          $valid_types_string = "\n" . implode("\n", $valid_entity_types);
          throw new \InvalidArgumentException(sprintf("Field '%s' requires a 'target_type'. Valid target types are:%s",
            $new_property,
            $valid_types_string,
          ));
        }
        $target_type = $options['target_type'];
        break;

      case 'datetime':
      case 'daterange':
        $date_time_types = [
          DateTimeType::DATETIME_TYPE_DATE,
          DateTimeType::DATETIME_TYPE_DATETIME,
        ];
        if ($data_type === 'daterange') {
          $date_time_types[] = DateRangeType::DATETIME_TYPE_ALLDAY;
        }
        if (!isset($options['datetime_type']) || !in_array($options['datetime_type'], $date_time_types)) {
          $valid_types_string = "\n" . implode("\n", $date_time_types);
          throw new \InvalidArgumentException(sprintf("Field '%s' requires a 'datetime_type'. Valid datetime types are:%s",
            $new_property,
            $valid_types_string,
          ));
        }
        $date_time_type = $options['datetime_type'];
        break;

      case 'image':
      case 'file':
        $target_type = 'file';
        $options['target_type'] = 'file';
        $uri_scheme = $this->configFactory->get('system.file')->get('default_scheme');
        break;

      case 'viewfield':
        $target_type = 'view';
        $options['target_type'] = $target_type;
        if (!$this->moduleHandler->moduleExists('custom_field_viewfield')) {
          throw new \InvalidArgumentException(sprintf("Field '%s' requires the 'custom_field_viewfield' module to be enabled.",
            $new_property,
          ));
        }
        break;
    }
    /** @var \Drupal\custom_field\Plugin\CustomFieldTypeInterface $plugin */
    $plugin = $this->customFieldTypeManager->createInstance($data_type);
    $options['name'] = $new_property;
    $custom_field_schema = $plugin->schema($options);
    $spec = current($custom_field_schema);
    $spec['not null'] = FALSE;
    $spec['default'] = NULL;

    // If the storage is SqlContentEntityStorage, update the database schema.
    if (!$storage instanceof SqlContentEntityStorage) {
      return;
    }
    /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
    $table_mapping = $storage->getTableMapping([
      $field_name => $field_storage_definition,
    ]);

    $table_names = $table_mapping->getDedicatedTableNames();
    $column_name = "{$field_storage_definition->getName()}_{$new_property}";
    $schema = $this->database->schema();

    $existing_data = [];
    $is_revisionable = $entity_type->isRevisionable() && $field_storage_definition->isRevisionable();
    foreach ($table_names as $table_name) {
      $field_exists = $schema->fieldExists($table_name, $column_name);
      $table_exists = $schema->tableExists($table_name);

      // Skip revision tables for non-revisionable entity types.
      if ($table_name === $entity_type_id . '_revision__' . $field_name && !$is_revisionable) {
        continue;
      }

      // Add the new column.
      if (!$field_exists && $table_exists) {
        $schema->addField($table_name, $column_name, $spec);
        // Get the old data.
        $rows = $this->database->select($table_name)
          ->fields($table_name)
          ->execute()
          ->fetchAll(\PDO::FETCH_ASSOC);
        if (!empty($rows)) {
          // Truncate the table.
          $existing_data[$table_name] = $rows;
          $this->database->truncate($table_name)->execute();
        }
      }
      else {
        // Show message that field already exists.
        $message = 'The column ' . $column_name . ' already exists in table ' . $table_name . '.';
        throw new \Exception($message);
      }
    }

    // Load the installed field schema so that it can be updated.
    $schema_key = "$entity_type_id.field_schema_data.$field_name";
    $field_schema_data = $this->keyValue->get($schema_key);

    // Add the new column to the installed field schema.
    foreach ($field_schema_data as $table_name => $fieldSchema) {
      $field_schema_data[$table_name]['fields'][$column_name] = $spec;
    }

    // Save changes to the installed field schema.
    $this->keyValue->set($schema_key, $field_schema_data);

    // Tell Drupal we have handled column changes.
    $new_field_storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($field_name, $entity_type_id);
    if ($new_field_storage_definition instanceof FieldStorageConfigInterface) {
      $new_field_storage_definition->setSetting('column_changes_handled', TRUE);
      $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($new_field_storage_definition);

      // Update cached entity definitions for entity types.
      if ($table_mapping->allowsSharedTableStorage($new_field_storage_definition)) {
        $definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($entity_type_id);
        $definitions[$field_name] = $new_field_storage_definition;
        $this->lastInstalledSchemaRepository->setLastInstalledFieldStorageDefinitions($entity_type_id, $definitions);
      }
    }

    // Update config.
    $field_storage_config = FieldStorageConfig::loadByName($entity_type_id, $field_name);
    $columns = $field_storage_config->getSetting('columns');

    // These settings exist across all data types.
    $column_config = [
      'type' => $data_type,
      'name' => $new_property,
    ];

    // Add the conditional settings based on schema match from data type.
    $optional_config = array_filter([
      'length' => $spec['length'] ?? NULL,
      'unsigned' => $spec['unsigned'] ?? NULL,
      'precision' => $spec['precision'] ?? NULL,
      'scale' => $spec['scale'] ?? NULL,
      'size' => $spec['size'] ?? NULL,
      'datetime_type' => $date_time_type ?: NULL,
      'target_type' => $target_type ?: NULL,
      'uri_scheme' => $uri_scheme ?: NULL,
    ], fn($value) => $value !== NULL);

    $column_config = array_merge($column_config, $optional_config);
    $columns[$new_property] = $column_config;

    $field_storage_config->setSetting('columns', $columns);
    $field_storage_config->save();

    if (!empty($existing_data)) {
      $this->restoreData($table_names, $existing_data);
    }

  }

  /**
   * {@inheritdoc}
   */
  public function removeColumn(string $entity_type_id, string $field_name, string $property): void {
    $field_storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($field_name, $entity_type_id);

    // Return early if no storage definition.
    if (!$field_storage_definition) {
      $message = 'There is no field storage definition for field ' . $field_name . ' and entity type ' . $entity_type_id . '.';
      throw new \Exception($message);
    }

    $schema = $this->database->schema();
    $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
    $storage = $this->entityTypeManager->getStorage($entity_type_id);
    assert($storage instanceof SqlContentEntityStorage);
    /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
    $table_mapping = $storage->getTableMapping([
      $field_name => $field_storage_definition,
    ]);
    $table_names = $table_mapping->getDedicatedTableNames();
    $table = $table_mapping->getDedicatedDataTableName($field_storage_definition);
    $column_name = $table_mapping->getFieldColumnName($field_storage_definition, $property);
    $table_columns = $field_storage_definition->getColumns();

    // Return early if there's only one column or if $property doesn't exist.
    if (!isset($table_columns[$property])) {
      $message = $column_name . ' cannot be removed because it does not exist for ' . $field_name;
      throw new \Exception($message);
    }
    elseif (count($field_storage_definition->getSetting('columns')) <= 1) {
      $message = 'Removing column ' . $column_name . ' would leave no remaining columns. The custom field requires at least 1 column.';
      throw new \Exception($message);
    }

    // Load the installed field schema so that it can be updated.
    $schema_key = "$entity_type_id.field_schema_data.$field_name";
    $field_schema_data = $this->keyValue->get($schema_key);

    // Save changes to the installed field schema.
    $existing_data = [];
    $removed_columns = [];
    $is_revisionable = $entity_type->isRevisionable() && $field_storage_definition->isRevisionable();
    if ($field_schema_data) {
      foreach ($table_names as $table_name) {
        $field_exists = $schema->fieldExists($table_name, $column_name);
        $table_exists = $schema->tableExists($table_name);

        // Skip revision tables for non-revisionable entity types.
        if ($table_name === $entity_type_id . '_revision__' . $field_name && !$is_revisionable) {
          continue;
        }

        // Remove the new column.
        if ($field_exists && $table_exists) {
          $all_columns = $table_mapping->getAllColumns($table_name);

          // Filter out the column to be removed and any related columns, and
          // collect removed columns for logging.
          $removed_columns[$table_name] = [];
          $columns_to_fetch = array_filter($all_columns, function ($col) use ($table_name, $column_name, &$removed_columns) {
            if ($col === $column_name || str_starts_with($col, $column_name . '__')) {
              $removed_columns[$table_name][] = $col;
              return FALSE;
            }
            return TRUE;
          });

          // Get the existing data, excluding the removed column.
          $query = $this->database->select($table_name);
          foreach ($columns_to_fetch as $col) {
            $query->addField($table_name, $col);
          }
          $rows = $query->execute()->fetchAll(\PDO::FETCH_ASSOC);
          if (!empty($rows)) {
            // Truncate the table.
            $existing_data[$table_name] = $rows;
            $this->database->truncate($table_name)->execute();
          }
          // Remove the column from the schema data.
          unset($field_schema_data[$table_name]['fields'][$column_name]);
        }
      }
      // Update schema definition in database.
      $this->keyValue->set($schema_key, $field_schema_data);

      // Tell Drupal we have handled column changes.
      if ($field_storage_definition instanceof FieldStorageConfigInterface) {
        $field_storage_definition->setSetting('column_changes_handled', TRUE);
      }
      $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($field_storage_definition);
    }

    // Update the field storage config.
    $field_storage_config = FieldStorageConfig::loadByName($entity_type_id, $field_name);
    // Remove the column from the field storage configuration.
    $columns = $field_storage_config->getSetting('columns');
    if (isset($columns[$property])) {
      unset($columns[$property]);
      $field_storage_config->setSetting('columns', $columns);
      $field_storage_config->save();
    }

    $bundles = array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
    foreach ($bundles as $bundle) {
      // Update the field config for each bundle.
      if ($field_config = FieldConfig::loadByName($entity_type_id, $bundle, $field_name)) {
        assert($field_config instanceof FieldConfigInterface);
        $settings = $field_config->getSettings();
        foreach ($settings as $setting_type => $setting) {
          if (is_array($setting) && isset($setting[$property])) {
            unset($settings[$setting_type][$property]);
            $field_config->setSettings($settings);
            $field_config->save();
          }
        }

        // Update entity form display configs.
        if ($displays = $this->entityTypeManager->getStorage('entity_form_display')->loadByProperties([
          'targetEntityType' => $field_config->getTargetEntityTypeId(),
          'bundle' => $field_config->getTargetBundle(),
        ])) {
          /** @var \Drupal\Core\Entity\Display\EntityFormDisplayInterface $display */
          foreach ($displays as $display) {
            if ($component = $display->getComponent($field_name)) {
              $changed = FALSE;
              // Check for settings to remove in the custom_flex plugin.
              if (isset($component['settings']['columns'][$property])) {
                unset($component['settings']['columns'][$property]);
                $changed = TRUE;
              }
              // Check for field settings.
              if (isset($component['settings']['fields'][$property])) {
                unset($component['settings']['fields'][$property]);
                $changed = TRUE;
              }
              if ($changed) {
                $display->setComponent($field_name, $component)->save();
              }
            }
          }
        }

        // Update entity view display configs.
        if ($displays = $this->entityTypeManager->getStorage('entity_view_display')->loadByProperties([
          'targetEntityType' => $field_config->getTargetEntityTypeId(),
          'bundle' => $field_config->getTargetBundle(),
        ])) {
          /** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display */
          foreach ($displays as $display) {
            if ($component = $display->getComponent($field_name)) {
              if (isset($component['settings']['fields'][$property])) {
                unset($component['settings']['fields'][$property]);
                $display->setComponent($field_name, $component)->save();
              }
            }
          }
        }
      }
    }

    // Try to drop field data.
    $this->database->schema()->dropField($table, $column_name);

    // Restore the data after removing the column.
    if (!empty($existing_data)) {
      // Log the removed items as a list for each table.
      $log_message = 'Removed columns from the following tables:';
      foreach ($removed_columns as $table => $columns) {
        if (!empty($columns)) {
          $log_message .= "<br><br><strong>$table:</strong><br>- " . implode("<br>- ", $columns);
        }
      }
      $this->logger->info($log_message);
      $this->restoreData($table_names, $existing_data);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function addExtraColumns(string $entity_type_id, string $field_name, string $property, array $extra_columns = []): void {
    /** @var \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage_definition */
    $field_storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($field_name, $entity_type_id);

    // Return early if no storage definition.
    if (!$field_storage_definition) {
      $message = 'There is no field storage definition for field ' . $field_name . ' and entity type ' . $entity_type_id . '.';
      throw new \Exception($message);
    }

    // Check if the main property exists in the field storage.
    $table_columns = $field_storage_definition->getColumns();
    if (!isset($table_columns[$property])) {
      $message = 'The property ' . $property . ' does not exist for field ' . $field_name . '.';
      throw new \Exception($message);
    }

    $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
    $storage = $this->entityTypeManager->getStorage($entity_type_id);
    if (!$storage instanceof SqlContentEntityStorage) {
      return;
    }

    /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
    $table_mapping = $storage->getTableMapping([
      $field_name => $field_storage_definition,
    ]);
    $table_names = $table_mapping->getDedicatedTableNames();
    $schema = $this->database->schema();
    $schema_key = "$entity_type_id.field_schema_data.$field_name";
    $field_schema_data = $this->keyValue->get($schema_key);
    $is_revisionable = $entity_type->isRevisionable() && $field_storage_definition->isRevisionable();

    // Validate all extra columns and prepare schema definitions.
    $new_columns = [];
    foreach ($extra_columns as $suffix => $column_definition) {
      // Validate machine name format (alphanumeric, underscores).
      if (!preg_match('/^(?!.*__)[a-zA-Z0-9_]+$/', $suffix)) {
        throw new \Exception('The column suffix ' . $suffix . ' must contain only alphanumeric characters and single underscores, no double underscores.');
      }
      // Construct the full column name with double underscore.
      $new_column_name = $property . '__' . $suffix;

      // Calculate a safe max column length to coincide with SQL column limit.
      $max_name_length = 64 - strlen($field_name) - 12;
      if (strlen($new_column_name) > $max_name_length) {
        throw new \Exception(sprintf('The new column name %s cannot exceed %d characters.', $new_column_name, $max_name_length));
      }

      // Validate the column definition.
      if (!isset($column_definition['type'])) {
        throw new \InvalidArgumentException(sprintf("The column '%s' requires a 'type' definition.", $new_column_name));
      }

      // Use the provided schema definition directly.
      $spec = $column_definition;
      $spec['not null'] = FALSE;
      $spec['default'] = NULL;

      // Store the schema spec for this column.
      $db_column_name = "{$field_storage_definition->getName()}_{$new_column_name}";
      $new_columns[$suffix] = [
        'db_column_name' => $db_column_name,
        'spec' => $spec,
      ];
    }

    // Process tables: save existing data, truncate, and add new columns.
    $existing_data = [];
    foreach ($table_names as $table_name) {
      $table_exists = $schema->tableExists($table_name);
      // Skip revision tables for non-revisionable entity types.
      if ($table_name === $entity_type_id . '_revision__' . $field_name && !$is_revisionable) {
        continue;
      }

      if ($table_exists) {
        // Add all new columns.
        foreach ($new_columns as $column) {
          $db_column_name = $column['db_column_name'];
          $spec = $column['spec'];

          if (!$schema->fieldExists($table_name, $db_column_name)) {
            $schema->addField($table_name, $db_column_name, $spec);
          }
          else {
            $message = 'The column ' . $db_column_name . ' already exists in table ' . $table_name . '.';
            throw new \Exception($message);
          }
        }
        // Save existing data.
        $rows = $this->database->select($table_name)
          ->fields($table_name)
          ->execute()
          ->fetchAll(\PDO::FETCH_ASSOC);
        if (!empty($rows)) {
          // Truncate the table.
          $existing_data[$table_name] = $rows;
          $this->database->truncate($table_name)->execute();
        }
      }
    }

    // Update the field schema data.
    foreach ($field_schema_data as $table_name => $fieldSchema) {
      foreach ($new_columns as $column) {
        $db_column_name = $column['db_column_name'];
        $field_schema_data[$table_name]['fields'][$db_column_name] = $column['spec'];
      }
    }
    $this->keyValue->set($schema_key, $field_schema_data);

    // Tell Drupal we have handled column changes.
    $new_field_storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($field_name, $entity_type_id);
    if ($new_field_storage_definition instanceof FieldStorageConfigInterface) {
      $new_field_storage_definition->setSetting('column_changes_handled', TRUE);
      $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($new_field_storage_definition);
      // Update cached entity definitions for entity types.
      if ($table_mapping->allowsSharedTableStorage($new_field_storage_definition)) {
        $definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($entity_type_id);
        $definitions[$field_name] = $new_field_storage_definition;
        $this->lastInstalledSchemaRepository->setLastInstalledFieldStorageDefinitions($entity_type_id, $definitions);
      }
    }

    // Restore existing data if any.
    if (!empty($existing_data)) {
      $this->restoreData($table_names, $existing_data);
    }
  }

  /**
   * A batch wrapper function for restoring data.
   *
   * @param array $tables
   *   The array of table names to restore data for.
   * @param array $existing_data
   *   The existing data to be restored for each table.
   */
  private function restoreData(array $tables, array $existing_data): void {
    $batch_size = 50;
    $tables_count = count($tables);

    // Initialize the batch.
    $batch = [
      'title' => $this->t('Restoring data...'),
      'operations' => [],
      'init_message' => $this->formatPlural($tables_count,
        'Starting data restoration for 1 table...',
        'Starting data restoration for @count tables...',
        ['@count' => count($tables)]),
      'error_message' => $this->t('An error occurred during data restoration. Please check the logs for errors.'),
      'finished' => [$this, 'restoreDataBatchFinished'],
    ];

    // Add table names to the batch context.
    $batch['context']['tables'] = $tables;

    // Process each table separately and create a batch for each one.
    foreach ($tables as $table_name) {
      if (!empty($existing_data[$table_name])) {
        // Populate the 'operations' array with data processing tasks.
        $total_rows = count($existing_data[$table_name]);
        $chunks = array_chunk($existing_data[$table_name], $batch_size);
        $chunk_total = count($chunks);

        foreach ($chunks as $chunk_index => $chunk) {
          $batch['operations'][] = [
            [$this, 'restoreDataBatchCallback'],
            [$table_name, $chunk, $total_rows, $chunk_total, $chunk_index + 1],
          ];
        }
      }
    }
    if (!empty($batch['operations'])) {
      // Queue the batch for processing.
      batch_set($batch);
    }
  }

  /**
   * The batch processing callback function for restoring data.
   *
   * @param string $table_name
   *   The table to batch process.
   * @param array $data
   *   The array of data to insert into the table.
   * @param int $total_rows
   *   The total number of rows in the table being processed.
   * @param int $chunk_total
   *   The total number of chunks for the table being processed.
   * @param int $chunk_index
   *   The index of the current chunk being processed (1-based).
   * @param array|\ArrayAccess $context
   *   The context array.
   *
   * @throws \Exception
   */
  public function restoreDataBatchCallback(string $table_name, array $data, int $total_rows, int $chunk_total, int $chunk_index, mixed &$context): void {
    // Initialize 'progress' key if it does not exist.
    if (!isset($context['sandbox']['progress'])) {
      $context['sandbox']['progress'] = 0;
    }

    if (!isset($context['sandbox']['table'])) {
      $context['sandbox']['table'] = $table_name;
    }

    // Initialize 'processed_rows' key for the table if it does not exist.
    if (!isset($context['results'][$table_name]['processed_rows'])) {
      $context['results'][$table_name]['processed_rows'] = 0;
    }

    // Get the current schema to ensure we only insert valid columns.
    $schema = $this->database->schema();
    $valid_columns = [];
    if (!empty($data[0])) {
      foreach (array_keys($data[0]) as $column) {
        if ($schema->fieldExists($table_name, $column)) {
          $valid_columns[] = $column;
        }
      }
    }

    // Filter the data to include only columns that exist in the current schema.
    $filtered_data = [];
    foreach ($data as $row) {
      $filtered_row = array_intersect_key($row, array_flip($valid_columns));
      if (!empty($filtered_row)) {
        $filtered_data[] = $filtered_row;
      }
    }

    // If no valid data remains after filtering, skip the insert.
    if (empty($filtered_data)) {
      $this->logger->warning('No valid data to restore for table @table: All columns were filtered out.', [
        '@table' => $table_name,
      ]);
      $context['message'] = t('No valid data to restore for @table (Chunk: @chunk/@total_chunks)', [
        '@table' => $table_name,
        '@chunk' => $chunk_index,
        '@total_chunks' => $chunk_total,
      ]);
      $context['finished'] = 1;
      return;
    }

    // Get the fields for the insert query based on the filtered data.
    $fields = array_keys($filtered_data[0]);
    $insert_query = $this->database->insert($table_name)->fields($fields);

    // Process a batch of rows for the current chunk.
    $batch_size = 50;
    $start = $context['sandbox']['progress'];
    $total_rows_chunk = count($filtered_data);
    $rows_to_process = array_slice($filtered_data, $start, $batch_size);

    // Use batch insert to optimize insertion.
    foreach ($rows_to_process as $row) {
      $insert_query->values(array_values($row));
      $context['sandbox']['progress']++;
      $context['results'][$table_name]['processed_rows']++;
    }

    // Insert multiple rows in a single query using batch insert.
    $insert_query->execute();

    // Update the progress message to include the table information.
    $context['message'] = t('Processed @current out of @total. (Table: @table, Chunk: @chunk/@total_chunks)', [
      '@current' => $context['sandbox']['progress'],
      '@total' => $total_rows,
      '@table' => $table_name,
      '@chunk' => $chunk_index,
      '@total_chunks' => $chunk_total,
    ]);

    // Calculate the overall progress for the batch process.
    if ($total_rows_chunk > 0) {
      $context['finished'] = $context['sandbox']['progress'] / $total_rows_chunk;
    }
    else {
      unset($context['sandbox']['table']);
      $context['finished'] = 1;
    }
  }

  /**
   * The batch processing finished callback function.
   *
   * @param bool $success
   *   The end result status of the batching.
   * @param array $results
   *   The results array of the batching.
   */
  public function restoreDataBatchFinished(bool $success, array $results): void {
    if ($success) {
      foreach ($results as $table_name => $result) {
        if (isset($result['processed_rows'])) {
          $total_rows = $result['processed_rows'];
          $message = t('Restored @total_rows rows in @table', [
            '@table' => $table_name,
            '@total_rows' => $total_rows,
          ]);
          $this->messenger->addMessage($message, 'status');
        }
        $this->logger->info('Restored @count rows for table @table.', [
          '@count' => $result['processed_rows'] ?? 0,
          '@table' => $table_name,
        ]);
      }
    }
    else {
      $this->logger->error('Data restoration failed.');
    }
  }

}

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

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