BTMash

Blob of contradictions

Extending dynamic migrations with destination records

Thu, 06/07/2012 - 17:27 -- btmash

Disclaimer: This posting was written with Migrate V2.3 and less. 2.4+ has a different handler for images. This post should hopefully still be useful if you just omit the parts about the images and handle those in the way Migrate 2.4 does. For more information on this, look at the excellent post by Mike Ryan. Also, I am diving right into the code with little explanation. If you are new to migrate, take a look at some of my older blog posts (again, mind the disclaimer about 2.3 until I get time to update those posts) to get a better understanding on how to use Migrate.
Updated August 29, 2012: I wrote out a blog post on the new image changes from Migrate 2.4+ a few weeks ago which you can read about here. So take a bit of what you learn from the previous blog posts, and add in all the things from the new one for anything file related (or just look at the new one since I link out to the new reference codebase from there as well).

A few months back, I blogged about creating dynamic migrations. With a small amount of code, you can do something very powerful. You can bring in large amounts of data that need to fit into different places with one simple class. And when all of these containers are holding close to the same kind of data, it makes it an obvious choice. Commerce Migrate approaches migrating data from Ubercart to Commerce in such a way and does a great job of bringing over the core fields of an Ubercart product. But what do you do when you need to add additional sets of data for a particular type of entity bundle? The client that needed my help had various kinds of information attached to their products - taxonomy terms for various vocabularies, additional image fields, text fields, stock, etc. Fields that do not get associated with Commerce products / product displays in the initial migration. When I initially saw this, I was completely stumped - it meant rewriting all the dynamic migrations that were being done by commerce migrate as actual migrations (not a task I was looking forward to given that I would essentially copying/pasting code to get the desired effect without actually using Commerce Migrate).

Thankfully, there is an option to actually set up to migrate pieces of data to an entity that has already been brought into the system. In the Migration class, there is a variable called systemOfRecord which tracks the kind of migration you are going to perform. By default, this is set to Migration::SOURCE. This means that the source data is what will provide the id and its appropriate mapping to your system. But by setting it to Migration::DESTINATION. You need to provide the entity id and migrate takes care of most of the rest for you (there are some hitches I ran into while doing a commerce migration, but I don't yet know if this is an issue within migrate - it is the reason why I have debated writing out this post for so long). So...lets dive into some code!

For this example, I have an ubercart product that was migrated into commerce. It is missing a fair amount of data that was added in by my client (his products had 4 additional text fields, 3 additional image fields, and product stock information which didn't make its way through). He had some other data that was mapped to a product display (which is arguably simpler than this code), but I want to focus on a product migration as I imagine others may be moving into this. As always, we're going to define our class and some of the override functions:

  1. class DEMigrationCommerceProductMigration extends Migration {
  2.  
  3. protected $file_public_path = "";
  4. protected $source_drupal_root = "";
  5.  
  6. public function __construct() {
  7. ...
  8. }
  9.  
  10. public function prepareRow($current_row) {
  11. ...
  12. }
  13.  
  14. public function prepare($entity, stdClass $current_row) {
  15. ...
  16. }
  17.  
  18. private function generateMigrateFile($fid = NULL, $file_data = '') {
  19. ...
  20. }
  21. }

The __construct() function is where we will define our migration information, what kind of data from the source will map to in the destination. prepareRow() will define any data coming in that needs to get refined for the migration. And prepare() is where we are presented with the actual entity prior to being saved and we can manipulate it any way we want. I have one helper functions to map out my image and file field information (since there may be multiple rows of file data for a given entity, we cannot pull it in the first time around and so we need to call on it during either our prepareRow() or prepare() functions. And I have a couple of variables on where the files directory along with the base path to the drupal install (which...I believe can now be externally hosted as well so you just provide the url!)

Let's first walk piece by piece through the __construct() function. We first define our class information and some basic data.

  1. public function __construct() {
  2. parent::__construct();
  3. // This is to let migrate know the migration is going to be sourced from data that was already migrated.
  4. $this->systemOfRecord = Migration::DESTINATION;
  5. $this->description = t('Update fields for commerce products');
  6. $arguments = array(
  7. 'type' => 'product',
  8. );
  9. $this->dependencies = array('CommerceMigrateUbercartProductProduct');
  10.  
  11. $this->file_public_path = variable_get('commerce_migrate_ubercart_public_files_directory', variable_get('file_public_path', 'sites/btmash.com/files'));
  12. $this->source_drupal_root = variable_get('commerce_migrate_ubercart_source_drupal_root', DRUPAL_ROOT);
  13.  
  14. // Create a map object for tracking the relationships between source rows
  15. $this->map = new MigrateSQLMap($this->machineName,
  16. 'nid' => array(
  17. 'type' => 'int',
  18. 'unsigned' => TRUE,
  19. 'not null' => TRUE,
  20. 'description' => 'Ubercart node ID',
  21. 'alias' => 'ucp',
  22. ),
  23. ),
  24. MigrateDestinationEntityAPI::getKeySchema('commerce_product', $arguments['type'])
  25. );
  26. // Code to define the connection and initial mapping coming up below.
  27. }

In the snippet above, I have defined the systemOfRecord = Migration::DESTINATION, which lets migrate get ready to provide me with a somewhat build entity object. I have also made this migration depend on a prior migration (in this case, CommerceMigrateUbercartProductProduct so that anytime I run this migration, it ensures everything from CommerceMigrateUbercartProductProduct has been migrated/updated. And that is really the main gist of it. We can now define our connection and initial mapping.

  1. public function __construct() {
  2. // ... Continued from above
  3. // Create a MigrateSource object, which manages retrieving the input data.
  4. $connection = commerce_migrate_ubercart_get_source_connection();
  5. $query = $connection->select('node', 'n');
  6. $query->innerJoin('content_type_product', 'ctp', 'n.nid = ctp.nid and n.vid = ctp.vid');
  7. $query->innerJoin('uc_products', 'ucp', 'n.nid = ucp.nid AND n.vid = ucp.vid');
  8. $query->fields('n', array('nid', 'vid'))
  9. ->fields('ctp', array('field_award_winner_value', 'field_lp_short_desc_value', 'field_lp_image_fid', 'field_lp_image_data', 'field_lp2_short_desc_value', 'field_lp2_image_fid', 'field_lp2_image_data', 'field_beauty_award_value', 'field_awards_lp_image_fid', 'field_awards_lp_image_data'))
  10. ->fields('ucp', array('model', 'sell_price'))
  11. ->condition('n.type', $arguments['type'])
  12. ->distinct();
  13.  
  14. $this->source = new MigrateSourceSQL($query, array(), NULL, array('map_joinable' => FALSE));
  15.  
  16. $this->destination = new MigrateDestinationEntityAPI('commerce_product', $arguments['type']);
  17. $this->addFieldMapping('product_id', 'nid')->sourceMigration('CommerceMigrateUbercartProductProduct');
  18.  
  19. // Some more code to define the mappings coming up below.
  20. }

Since my prior posts, I have learnt some cool things thanks to the Commerce Migrate project :) Firstly, you can define external database sources. In my case, I have opted to use the database connection object Commerce Migrate provides (since my data is coming from that database) which is really just an extra database you would define in settings.php. Once you have your query built out, you then let migrate know about your source. The key part to this line is the map_joinable value. By default, this value is set to TRUE. When this happens. migrate assumes you are dealing with a local database and will try to join up against it. It is smart (because it is faster and there is less computation work required by your server) but if your db is outside the system, then it won't work. After that, we define what we are migrating content into (in this case, it is a commerce_product for which the migration is being handled by the MigrateDestinationEntityAPI - at the time of implementation, it was a part of commerce_migrate but it has since been moved to the migrate_extras module.). We are also going to let migrate know where to get the product ID for this migration. As I mentioned, this is an update to a migration, so this is the one time we actually provide the id :) In this case:

  1. $this->addFieldMapping('product_id', 'nid')->sourceMigration('CommerceMigrateUbercartProductProduct');

should do the trick. It will check from our prior migration on the product_id that was mapped out and append our new data there. At this stage, the rest of the constructor should look very familiar:

  1. $this->addFieldMapping('commerce_stock', 'stock');
  2.  
  3. // TEXT FIELDS
  4. $generic_textarea_arguments = MigrateTextFieldHandler::arguments(NULL, 'full_html');
  5. $this->addFieldMapping('field_award_winner', 'field_award_winner_value')
  6. ->arguments($generic_textarea_arguments);
  7. $this->addFieldMapping('field_lp_short_desc', 'field_lp_short_desc_value')
  8. ->arguments($generic_textarea_arguments);
  9. $this->addFieldMapping('field_lp2_short_desc', 'field_lp2_short_desc_value')
  10. ->arguments($generic_textarea_arguments);
  11. $this->addFieldMapping('field_beauty_award_desc', 'field_beauty_award_value')
  12. ->arguments($generic_textarea_arguments);
  13.  
  14. // IMAGE FIELDS
  15. $generic_image_arguments = array(
  16. 'file_function' => 'file_copy',
  17. 'file_replace' => FILE_EXISTS_RENAME,
  18. );
  19. $this->addFieldMapping('field_lp_image', 'lp_image')
  20. ->arguments($generic_image_arguments);
  21. $this->addFieldMapping('field_lp2_image', 'lp2_image')
  22. ->arguments($generic_image_arguments);
  23. $this->addFieldMapping('field_awards_lp_image', 'awards_lp_image')
  24. ->arguments($generic_image_arguments);
  25.  
  26. // All the items that do not require mappings
  27. $this->addFieldMapping('sku')->issueGroup(t('DNM'));
  28. $this->addFieldMapping('type')->issueGroup(t('DNM'));
  29. $this->addFieldMapping('title')->issueGroup(t('DNM'));
  30. $this->addFieldMapping('status')->issueGroup(t('DNM'));
  31. $this->addFieldMapping('created')->issueGroup(t('DNM'));
  32. $this->addFieldMapping('changed')->issueGroup(t('DNM'));
  33. $this->addFieldMapping('uid')->issueGroup(t('DNM'));
  34. $this->addFieldMapping('commerce_price')->issueGroup(t('DNM'));
  35. $this->addFieldMapping('field_image')->issueGroup(t('DNM'));
  36. $this->addFieldMapping('path')->issueGroup(t('DNM'));
  37.  
  38. // All the source migration pieces that do not directly correlate to a destination.
  39. $this->addUnmigratedSources(array('vid', 'model', 'sell_price', 'field_lp_image_fid', 'field_lp_image_data', 'field_lp2_image_fid', 'field_lp2_image_data', 'field_awards_lp_image_fid', 'field_awards_lp_image_data'));

From the above, we have provided mappings for our stock, the text fields, and our image fields.

And with that, we have the beginnings of a migration for a migration :D Let's populate the prepareRow() and generateMigrateFile() functions:

  1. public function prepareRow($current_row) {
  2. // There is some crazy stuff here which gets explained below
  3. // ...
  4.  
  5. // Figure out stock.
  6. $connection = commerce_migrate_ubercart_get_source_connection();
  7. $current_row->stock = 0;
  8. $results = $connection->query("SELECT * FROM {uc_product_stock} WHERE nid=:nid", array(':nid' => $current_row->nid));
  9. foreach ($results as $result) {
  10. $current_row->stock = $result->stock;
  11. }
  12.  
  13. // Load images;
  14. $current_row->lp_image = $this->generateMigrateFile($current_row->field_lp_image_fid, $current_row->field_lp_image_data);
  15. $current_row->lp2_image = $this->generateMigrateFile($current_row->field_lp2_image_fid, $current_row->field_lp2_image_data);
  16. $current_row->awards_lp_image = $this->generateMigrateFile($current_row->field_awards_lp_image_fid, $current_row->field_awards_lp_image_data);
  17. }
  18.  
  19. private function generateMigrateFile($fid = NULL, $file_data = '') {
  20. $files = array();
  21.  
  22. if (!empty($fid)) {
  23. $connection = commerce_migrate_ubercart_get_source_connection();
  24. $query = $connection->select('files', 'f');
  25. $query->fields('f', array('fid', 'filepath'))
  26. ->condition('fid', $fid);
  27. $results = $query->execute();
  28. foreach($results as $record) {
  29. $value = unserialize($file_data);
  30. $value['path'] = $this->source_drupal_root .'/'. $record->filepath;
  31. $files[] = drupal_json_encode($value);
  32. }
  33. }
  34. return $files;
  35. }

The above helps me get my mappings for the image files known to Migrate in a way it understands. As a note, this will need to get updated since Migrate 2.4 handles images differently (see the disclaimer at the top). But by doing things this way, I know exactly where to change my code for the future.

With this, we should basically be done. And if there weren't any caveats, you might even be done and ready with your migration (ie: I encountered few issues with the core entities supported by Migrate. However, I ran into issues where when I resaved the entity, I actually lost data that was initially associated from the initial migration. In the case of the products, I lost the price, and I lost the core image that came over.

For some reason, the entity lost part of its data during the process() handling and so the entity got saved without that data. A few speculated that it may be trying to look for a mapping and when it can't find it, it gets set to whatever your default value is. I have brought up the issue here and here - the latter may already be fixed but I'm unsure at this time. Anyways, back to the issue at hand. I had a simple way around this - I knew the fields this was affecting (the pricing and the image) and I knew the original entity had this information. So...why not just load the original entity and store it to a variable during prepareRow()?

  1. public function prepareRow($current_row) {
  2. // Load the current product
  3. $results = db_query("SELECT destid1 FROM {migrate_map_commercemigrateubercartproductproduct} WHERE sourceid1 = :source_id", array(':source_id' => $current_row->nid));
  4. foreach ($results as $result) {
  5. $current_row->pid = $result->destid1;
  6. $product = commerce_product_load($current_row->pid);
  7. $current_row->product = clone $product;
  8. }
  9. // All that code from above ...

We clone the original product because the product object can be manipulated elsewhere - our clone is copied into memory elsewhere so we are free to work with it without any external influence :)

Since we now have the original product, we can go to the prepare() function and make our modifications.

  1. public function prepare($entity, stdClass $current_row) {
  2. $product = $current_row->product;
  3. $entity->commerce_price = $product->commerce_price;
  4. $entity->field_image = $product->field_image;
  5. }

So we have, in effect, "reset" our price and image fields. Alternatively, you could bring them in (again) with the migration so you might not have to deal with this kind of logic. But its certainly an approach that worked at the time.

I'm pasting this entire example on to a github gist so you can go through it as one file as opposed to it being broken down here. And as always, I hope all of this has been helpful. If something doesn't make a lot of sense, leave a comment! I want to keep improving this documentation and hopefully, all of this will be even more useful to other folk looking to migrate content in the Drupal community.