Migrate - модуль для импорта данных в Drupal

04.07.2014
Migrate module
Автор:

Иногда при разработке сайтов возникает необходимость переноса данных из одной базы в другую. Зачастую это связано либо с переходом на новую версию Drupal (с 6.x на 7.x), либо при необходимости переноса контента на Drupal с другой платформы. Модуль Migrate является очень удобным инструментом для импорта содержимого в таких случаях. Но бывает и так, что его стандартных настроек из коробки просто не хватает для переноса (например, при миграции данных из разных полей в одно, или при необходимости учитывать смещение во времени между серверами для полей типа даты). В таких случаях веб разработчику нужно расширять возможности модуля под себя. Ниже мы опишем методы, которые помогут вам решить подобные задачи.

Представим, что название текущей базы у нас ‘drupal_7’, а название дампа, с которого нужно произвести импорт данных - ‘drupal_6’ соответственно. Тогда для удобства переключения между ними нужно прописать оба подключения к базам данных в settings.php:

  $databases = array (
    'default' => array (
      'default' => array (
        'database' => 'drupal_7',
        'username' => 'root',
        'password' => '*****',
        'host' => 'localhost',
        'port' => '',
        'driver' => 'mysql',
         'prefix' => '',
      ),
    ),
    'old_database' => array (
      'default' => array (
        'database' => 'drupal_6',
        'username' => 'root',
        'password' => '*****',
        'host' => 'localhost',
        'port' => '',
        'driver' => 'mysql',
        'prefix' => '',
      ),
    ),
  );

Как видим, в нашем случае для старой базы будет использоваться ключ подключения 'old_database'. Для удобства его определения в процессе импорта желательно создать админ-страницу с соответствующей настройкой. Также сюда можно добавить настройку доменного имени старого сайта (если он еще функционирует) и значение смещения времени на сервере (если оно отличается для старого и нового сайтов). Доменное имя старого сайта потребуется для получения файлов по имеющемуся пути в базе.

Давайте создадим модуль, который будет содержать необходимые настройки и классы для нашего импорта. Назовем модуль ‘mymodule_migration’ и настроим в нем страницу администрирования с необходимыми параметрами. Тогда наш mymodule_migration.module будет выглядеть так:

/**
 * Implements hook_menu().
 */
function mymodule_migration_menu() {
  $items['admin/content/migrate/mymodule_migration'] = array(
    'title'            => 'Additional Migration settings',
    'page callback'    => 'drupal_get_form',
    'page arguments'   => array('mymodule_migration_settings'),
    'access arguments' => array(MIGRATE_ACCESS_BASIC),
    'file'             => 'mymodule_migration.admin.inc',
    'type'             => MENU_LOCAL_TASK,
    'weight'           => 10,
  );

  return $items;
}

А mymodule_migration.admin.inc будет содержать непосредственно саму форму администрирования:

/**
 * Migration settings form.
 */
function mymodule_migration_settings($form, &$form_state) {
  $form['mymodule_migration_database_key'] = array(
    '#type'     => 'textfield',
    '#title'    => t('D6 database key'),
    '#required' => TRUE,
    '#default_value' => variable_get('mymodule_migration_database_key', ''),
  );
  $form['mymodule_migration_old_site_domain'] = array(
    '#type'     => 'textfield',
    '#title'    => t('Old site domain name'),
    '#required' => TRUE,
    '#default_value' => variable_get('mymodule_migration_old_site_domain', ''),
 );
  $form['mymodule_migration_time_shift'] = array(
    '#type'     => 'textfield',
    '#title'    => t('Time shift between sites'),
    '#default_value' => variable_get('mymodule_migration_time_shift', ''),
    '#description'   => t('A date/time string. e.g. "-1 hour".'),
  );

  system_settings_form($form);
}

Теперь перейдем к рассмотрению собственных классов модуля. Они будут описывать процесс построения запроса для получения необходимых данных из базы, а также карты соответствий между полями. Для этого используется hook_migrate_api() который необходимо описать в файле MODULENAME.migrate.inc (назовем его mymodule_migration.migrate.inc). Создадим папку ‘includes’ в директории модуля, где будут находиться все необходимые .inc-файлы для переноса, в том числе и файлы классов. Опишем класс для миграции типа содержимого Page, и тогда мы будем иметь наш mymodule_migration.migrate.inc в таком виде:

/**
 * Implements hook_migrate_api().
 */
function mymodule_migration_migrate_api() {
  $api = array(
    'api' => 2,
    'groups' => array(
      'mymodule' => array(
        'title' => t(‘Mymodule Imports'),
      ),
    ),
    'migrations' => array(
      'MigratePages' => array(
        'class_name' => 'MymodulePagesMigration',
        'title'      => 'Pages',
        'group_name' => 'mymodule',
      ),
    ),
  );
  return $api;
}

Если нужно импортировать только один тип содержимого (что встречается довольно редко), то наш новый класс должен наследовать базовый класс Migration. При импорте нескольких типов содержимого лучше создать базовый класс импорта, который будет наследовать базовый класс Migration, а все остальные классы по миграции конкретного типа содержимого будут уже наследовать наш базовый класс.

Приступим к созданию базового класса - создадим файл node.inc в папке includes, и опишем там наш класс:

/**
 * @file
 * Basic class to handle the nodes migration.
 */
class MymoduleNodeMigration extends Migration {
  /**
   * General initialization of a Migration object.
   */
  public function __construct($args, $type) {
    parent::__construct($args);
  }
}

  Здесь нужно добавить несколько защищенных (protected) свойств класса, которые нам понадобятся позже:

  • $migrateUid - идентификатор автора (можно указать 1 по умолчанию),

  • $databaseKey - ключ базы данных старой версии сайта,

  • $oldSiteDomain - доменное имя старой версии сайта,

  • $timeShift - смещение времени между серверами,

  • $sourceType - тип содержимого, который импортируется,

  • $destinationType - название типа содержимого, в который осуществляется импорт.

Теперь стоит изменить конструктор класса и заполнить описанные нами свойства. Конструктор должен выйти примерно таким:

/**
 * General initialization of a Migration object.
 */
public function __construct($args, $type) {
  parent::__construct($args);

  $this->databaseKey     = variable_get('mymodule_migration_database_key');
  $this->oldSiteDomain   = variable_get('mymodule_migration_old_site_domain');
  $this->timeShift       = variable_get('mymodule_migration_time_shift');
  $this->description     = t('Migrate Nodes');
  $this->destinationType = $type;
}

Создадим метод, который будет отвечать за построение соответствий между полями типа материала и полями запроса - buildSimpleFieldsMapping(). Это важный момент, который определяет, какими значениями будут наполняться поля нового типа содержимого.

/**
 * Build fields mapping.
 */
public function buildSimpleFieldsMapping() {
  $source_fields = array(
    'nid'               => t('The node ID of the page'),
    'linked_files'      => t('The set of linked files'),
    'right_side_images' => t('The set of images that previously appeared on the side'),
  );

  // Setup common mappings.
  $this->addSimpleMappings(array('title', 'status', 'created', 'changed', 'comment', 'promote', 'sticky'));

  // Make the mappings.
  $this->addFieldMapping('is_new')->defaultValue(TRUE);
  $this->addFieldMapping('uid')->defaultValue($this->migrateUid);
  $this->addFieldMapping('revision')->defaultValue(TRUE);
  $this->addFieldMapping('revision_uid')->defaultValue($this->migrateUid);
  $this->addFieldMapping('language', 'language')->defaultValue('en');

  $this->addFieldMapping('body', 'body');
  $this->addFieldMapping('body:summary', 'teaser');
  $this->addFieldMapping('body:format', 'format');

  // Build base query and fields mapping.
  $query = $this->query();

  $this->highwaterField = array(
    'name'  => 'changed',
    'alias' => 'n',
    'type'  => 'int',
  );

  // Generate source from the base query.
  $this->source = new MigrateSourceSQL($query, $source_fields, NULL,
    array(
      'map_joinable' => FALSE,
      'cache_counts' => TRUE,
      'cache_key'    => 'migrate_' . $this->sourceType
    )
  );
  $this->destination = new MigrateDestinationNode($this->destinationType);

  // Build map.
  $this->map = new MigrateSQLMap($this->machineName,
    array(
      'nid' => array(
        'type'        => 'int',
        'unsigned'    => TRUE,
        'not null'    => TRUE,
        'description' => t('D6 Unique Node ID'),
        'alias'       => 'n',
      )
    ),
    MigrateDestinationTerm::getKeySchema()
  );
 }

Мы описали соответствие только между базовыми полями, а наш тип содержимого может содержать также и дополнительные поля, которые тоже нужно добавить в карту соответствий. Каждое добавленное поле должно иметь либо значение по умолчанию, либо соответствующее поле в запросе, с которого будет браться значение. Для построения запроса выборки данный используется метод query(). Здесь стоит разделить процесс на две части:

  • информация о дополнительных полях,
  • добавления полей в карту соответствий.

Для получения информации о дополнительных полях можно воспользоваться CSV файлом, в который нужно внести необходимую информации в формате "Поле источник","Поле назначение","Описание поля","Тип поля". Это позволит динамически дополнять карту соответствий полей. Такой CSV файл должен быть для каждого типа содержимого. Для порядка можно создать отдельную папку для таких файлов, например ‘mappings’. Называть файлы лучше по названию источника типа содержимого.

Вот пример как должен выглядеть такой файл:

"Source field","Destination field","Description","Field type"
"field_attached_file","field_file","File","file"

Теперь возвращаемся к методу query(). У нас уже есть необходимая информация о дополнительных полях, и мы можем закончить построение карты соответствий полей.

/**
 * Query for basic node fields from Drupal 6.
 *
 * @return QueryConditionInterface
 */
protected function query() {
  // Basic query for node.
  $query = Database::getConnection('default', $this->databaseKey)
    ->select('node', 'n')
    ->fields('n')
    ->condition('n.type', $this->sourceType)
    ->groupBy('n.nid')
    ->orderBy('n.changed');
  $query->join('node_revisions', 'nr', 'n.vid = nr.vid');
  $query->fields('nr', array('body', 'teaser', 'format'));
  $query->join('content_type_' . $this->sourceType, 'ct', 'n.vid = ct.vid');

  // Get fields mapping.
  $field_mappings = DRUPAL_ROOT . '/' . drupal_get_path('module', 'mymodule_migration') . '/mappings/' . $this->sourceType . '.csv';
  $result = fopen($field_mappings, 'r');

  // Make sure there actually map is exists.
  if (!$result) {
    Migration::displayMessage(t('Empty mapping for !type', array('!type' => $this->sourceType)));
    return array();
  }

  // Skip the header.
  fgets($result);
  $fields = array();

  // Run through the mappings - 0 is a source field name, 1 is a destination
  // field name, 2 is a description of the mapping, 3 is a field type.
  while ($row = fgetcsv($result)) {
    $cck_field         = $row[0];
    $destination_field = $row[1];
    $field_description = $row[2];
    $field_type        = $row[3];

    // Getting field columns.
    $field_columns = Database::getConnection('default', $this->databaseKey)
      ->select('content_node_field', 'nf')
      ->fields('nf', array('db_columns'))
      ->condition('field_name', $cck_field)
      ->execute()
      ->fetchField();
    $field_columns = unserialize($field_columns);

    switch ($field_type) {
      case 'term':
        // For the term field the $cck_field should be a vocabulary id.
        $column_field = "{$destination_field}_{$cck_field}";
        $query->leftJoin('term_node', 'tn', 'n.vid = tn.vid');
        $query->leftJoin('term_data', 'td', "tn.tid = td.tid AND td.vid = '{$cck_field}'");
        $query->addField('td', 'name', $column_field);
        $this->addFieldMapping($destination_field, $column_field);
        $this->term_fields[$column_field] = $cck_field;
        break;

      default:
        // Checking which table should be used for getting field values.
        $argument = 0;
        if (Database::getConnection('default', $this->databaseKey)->schema()->tableExists('content_' . $cck_field)) {
          $query->leftJoin('content_' . $cck_field, $cck_field, "n.vid = {$cck_field}.vid");
          foreach (array_keys($field_columns) as $column_name) {
            $column_field = "{$cck_field}_{$column_name}";
            $this->buildFieldMapping($destination_field, $cck_field, $column_name, $column_field, $field_type, $argument);
            if (in_array($field_type, array('file', 'image', 'multiple'))) {
              $query->addExpression("GROUP_CONCAT(DISTINCT {$cck_field}.{$column_field})", $column_field);
            }
            else {
              $query->addField($cck_field, $column_field, $column_field);
            }
            $argument++;
          }
        }
        elseif (!empty($field_columns)) {
          foreach (array_keys($field_columns) as $column_name) {
            $column_field = "{$cck_field}_{$column_name}";
            $this->buildFieldMapping($destination_field, $cck_field, $column_name, $column_field, $field_type, $argument);
            $query->addField('ct', $column_field, $column_field);
            $argument++;
          }
        }
    }
   // Build a specific field mapping by the field instance.
   if (!empty($field_columns)) {
     $this->buildFieldInstanceMapping($destination_field, $cck_field, $field_type, $field_columns);
   }
  }
  return $query;
}

Для добавления дополнительных полей в карту соответствий используется созданный метод buildFieldMapping(). Вот как выглядит этот метод для нашего случая:

/**
 * Add field mapping.
 */
protected function buildFieldMapping($destination_field, $cck_field, $column_name, $column_field, $field_type, $argument) {
  $column_field = "{$cck_field}_{$column_name}";
  if (empty($argument)) {
    if (!isset($this->codedFieldMappings[$destination_field])) {
      if (in_array($field_type, array('file', 'image', 'multiple'))) {
        $this->addFieldMapping($destination_field, $column_field)
          ->separator(',');
      }
      else {
        $this->addFieldMapping($destination_field, $column_field);
      }
    }
    else {
      $this->duplicate_destination[$column_field] = $destination_field;
    }
  }
  else {
    $this->addFieldMapping("{$destination_field}:{$column_name}", $column_field);
  }
  if ($column_name == 'format' && $field_type == 'text') {
    $this->text_format_fields[] = $column_field;
  }
}

Также в query() используется вызов метода buildFieldInstanceMapping(), который был разработан для дополнительных действий над полями  типа 'file' и 'image'.

/**
 * Add a specific field mapping according to the field instance.
 */
protected function buildFieldInstanceMapping($destination_field, $cck_field, $field_type, $field_columns) {
  switch ($field_type) {
    case 'file':
    case 'image':
      // Get field instance settings.
      $instance_settings = field_info_instance('node', $destination_field, $this->destinationType);
      // The file_class determines how the 'image' value is interpreted, and what
      // other options are available. In this case, MigrateFileUri indicates that
      // the 'image' value is a URI.
      $this->addFieldMapping("{$destination_field}:file_class")
        ->defaultValue('MigrateFileUri');
      // Here we specify the directory containing the source files.
      $this->addFieldMapping("{$destination_field}:source_dir")
        ->defaultValue($this->oldSiteDomain);
      // Directory that source images will be copied to.
      $this->addFieldMapping("{$destination_field}:destination_dir")
        ->defaultValue(file_default_scheme() . '://' . $instance_settings['settings']['file_directory']);
      // And we map the alt and title values in the database to those on the image.
      $this->addFieldMapping("{$destination_field}:alt", 'title');
      $this->addFieldMapping("{$destination_field}:title", 'title');

      $this->addUnmigratedDestinations(array(
        "{$destination_field}:destination_file",
        "{$destination_field}:file_replace",
        "{$destination_field}:language",
        "{$destination_field}:preserve_files",
        "{$destination_field}:urlencode",
      ));
      $this->file_fields[] = $cck_field . '_' . key($field_columns);
      break;

    case 'date':
      $this->date_fields[] = $cck_field . '_' . key($field_columns);
      break;
  }
}

Как уже упоминалось, специфические действия по конкретным полям или типам полей можно произвести в методах prepareRow() и prepare(). Покажем вариант использования prepareRow() на примере полей даты, информацию о которых мы сохранили в массиве 'date_fields' нашего экземпляра класса миграции:

/**
  * Implemens prepareRow().
  *
  * @param Object $row
  *   Object containing raw source data.
  *
  * @return bool
  *   TRUE to process this row, FALSE to have the source skip it.
  */
public function prepareRow($row) {
  // Make a time shift for date fields.
  if (!empty($this->date_fields) && !empty($this->timeShift)) {
    foreach ($this->date_fields as $date_field) {
      if (!empty($row->$date_field)) {
        $row->$date_field = date('Y-m-d\TH:i:s', strtotime($this->timeShift, strtotime($row->$date_field)));
      }
    }
  }
}

Что касается метода prepare(), то он принимает уже два параметра - prepare ($node, stdClass $row). Это позволяет производить манипуляции уже непосредственно с объектом типа содержимого, который будет сохранен, при этом имея доступ и к $row.

Теперь вернемся к созданию класса миграции для конкретного типа содержимого - Page, который мы уже описали в hook_migrate_api. Создадим файл page.inc в папке includes с классом миграции этого типа содержимого:

/**
 * @file
 * Handle the migration Pages.
 */

class MymodulePagesMigration extends MymoduleNodeMigration {
  /**
  * General initialization of a Migration object.
  */
  public function __construct($args) {
     parent::__construct($args, 'page');

     $this->sourceType  = 'page';
     $this->description = t('Migrate Pages');

     if (!empty($this->databaseKey)) {
       $this->buildSimpleFieldsMapping();
     }
  }
}

Если необходимо произвести специфические операции над полем для конкретного типа содержимого, то это можно сделать непосредственно в его классе импорта, используя все те же методы prepareRow() и prepare().

В этом блоге были изложены основные принципы работы с импортом содержимого при использовании базы данных, а также динамического построения карты соответствий полей с их дополнительной обработкой. Надеемся, что здесь вы смогли найти для себя что-то новое и полезное!

3 votes, Rating: 5

Также по теме

1

В этой статье речь пойдёт о том, как установить ядро Drupal 8 как сабмодуль. Мы не будем рассматривать само понятие сабмодулей, их преимущества/недостатки, а лишь покажем в деталях, как подобную...

2

Не так давно мы рассматривали, как создать ctools тип контента для модуля Panels. На этот раз пришел черед другого...

3

Apps -  это модуль, который можно позиционировать как следующий шаг в развитии...

4

Задача импорта контента часто бывает нетривиальной. Писать импорт "с нуля" для каждого случая далеко не оптимальный вариант, поэтому мы рекомендуем использовать уже существующие решения, например...

5

В даной статье описан процесс развёртывания CMS Drupal с использованием Oracle DB на Debian сервере.

Need a quote? Let's discuss the project

Are you looking for someone to help you with your Drupal Web Development needs? Let’s get in touch and discuss the requirements of your project. We would love to hear from you.

Join the people who have already subscribed!

Want to be aware of important and interesting things happening? We will inform you about new blog posts on Drupal development, design, QA testing and more, as well news about Drupal events.

No charge. Unsubscribe anytime