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().

У цьому блозі викладено основні принципи роботи з імпортом вмісту при використанні бази даних, а також динамічної побудови карти відповідностей полів із їх додатковою обробкою. Сподіваємося, що тут ви змогли знайти для себе щось нове та корисне!

2 votes, Рейтинг: 5

Також по темі

1

Бути членом Drupal спільноти дуже відповідально. Саме тому компанія InternetDevels організувала 3-місячний код-спринт присвячений вдосконаленню CMF. Результати багатообіцяючі....

2

Іноді власники сайтів стикаються з різними проблемами, пов'язаними з їх Drupal-проектами. Тому ми запустили сервіс, який пропонує швидку технічну підтримку вашого сайту.

3

На даний час сервіси з готовими html-темами набирають стають дедалі популярнішими. Однак, чи так вже легко верстати на їх основі Drupal-сайти?...

4

Часто на адресу Drupal можна побачити заяву в стилі: «На цьому фреймворці не створити high-load веб-сайту». Ми спростуємо цей міф не...

5

При прийнятті рішення запустити власний вебсайт одним із найважливіших питань є вибір реалізації - CMS чи самопис? Дізнайтеся більше про переваги фреймворка Drupal.

Subscribe to our blog updates