Skip to main content

Authored on

Mon, 08/19/2019 - 20:10
Image
london tower bridge

There are moments when you need to update several entities at once reason why you need to avoid a timeout. Perhaps a cron implementation may works, I know, however what If it is something that requires to have an admin page where authorized user can trigger those updates.

Those are good scenarios to implement the powerful Batch API into Drupal. Easy peasy, there’s a good documentation and useful example at official Drupal page: https://api.drupal.org/api/drupal/core%21includes%21form.inc/group/batch/8.7.x

What if you need multiple operations, then you should send results from one operation callback to another? That was something I faced recently, then I would like to share how I managed it.

Firstly I created a custom module to work with, no big deal:

name: Batch Operations
description: 'Multiple tasks sharing results amont operations.'
package: keboca

type: module
core: 8.x

Then I came up with a service which will be have all business logic we need to, then define a service into a YML file:

services:
  batch_operations.manager:
    class: \Drupal\batch_operations\BatchOperationsManager

Now we write our service with a couple public static methods which will handle the operations and finish callback method, it may look like this:

<?php

namespace Drupal\batch_operations;

use Drupal\node\Entity\Node;

/**
 * Class BatchOperationsManager
 *
 * @package Drupal\batch_operations
 */
class BatchOperationsManager {

  /**
   * Init operation task by retrieving all content to be updated.
   *
   * @param array $args
   * @param array $context
   */
  public static function initOperation($args, &$context) {
    // Init variables.
    $limit = $args['limit'];
    $offset = (!empty($context['sandbox']['offset'])) ?
      $context['sandbox']['offset'] : 0;

    // Define total on first call.
    if (!isset($context['sandbox']['total'])) {
      $context['sandbox']['total'] = \Drupal::database()
        ->select('node')
        ->condition('type', 'article')
        ->countQuery()
        ->execute()
        ->fetchField();
    }

    /** @var array $results */
    $results = \Drupal::database()
      ->select('node', 'n')
      ->fields('n', ['nid'])
      ->range($offset, $limit)
      ->condition('type', 'article')
      ->execute()
      ->fetchAll();

    // Setup results based on retrieved objects.
    $context['results'] = array_reduce($results,
      function ($carry, $object) {
        // Map object results extracted from previous query.
        $carry[$object->nid] = $object->nid;
        return $carry;
      }, $context['results']
    );

    // Redefine offset value.
    $context['sandbox']['offset'] = $offset + $limit;

    // Set current step as unfinished until offset is greater than total.
    $context['finished'] = 0;
    if ($context['sandbox']['offset'] >= $context['sandbox']['total']) {
      $context['finished'] = 1;
    }

    // Setup info message to notify about current progress.
    $context['message'] = t(
      'Retrived @consumed nodes of @total from available articles',
      [
        '@consumed' => $context['sandbox']['offset'],
        '@total' => $context['sandbox']['total'],
      ]
    );
  }

  /**
   * Process operation to update content retrieved from init operation.
   *
   * @param array $args
   * @param array $context
   */
  public static function updateProcess($args, &$context) {
    // Define total on first call.
    if (!isset($context['sandbox']['total'])) {
      $context['sandbox']['total'] = count($context['results']);
    }

    // Init limit variable.
    $limit = $args['limit'];

    // Walk-through all results in order to update them.
    $count = 0;
    foreach ($context['results'] as $key => $nid) {
      /** @var \Drupal\node\Entity\Node $entity */
      $entity = Node::load($nid);

      /**
       * @todo Any update what you need to perform on each article.
       */

      // Make it persistent.
      $entity->save();

      // Increment count at one.
      $count++;

      // Remove current result.
      unset($context['results'][$key]);
      if ($count >= $limit) {
        break;
      }
    }

    // Setup message to notify how many remaining articles.
    $context['message'] = t(
      'Updating articles... @total pending...',
      ['@total' => count($context['results'])]
    );

    // Set current step as unfinished until there's not results.
    $context['finished'] = (empty($context['results']));

    // When it is completed, then setup result as total amount updated.
    if ($context['finished']) {
      $context['results'] = $context['sandbox']['total'];
    }
  }

  /**
   * Final operation to define message after executed all batch operations.
   *
   * @param bool $success
   * @param array $results
   * @param array $operations
   */
  public static function finishProcess($success, $results, $operations) {
    // Setup final message after process is done.
    $message = ($success) ?
      t('Update process of @count articles was completed.',
        ['@count' => $results]) :
      t('Finished with an error.');
    \Drupal::messenger()->addMessage($message);
  }
}

Let's summarize what's happening there:

  1. `initOperation` method will retrieve all articles and mapped them by executing a range query on each iteration until it gets all nodes. It will store all retrieved content into `$context['results']` which will be sent over next operation task.
  2. `updateProcess` method will iterate over `$context['results']` which contains all retrieved content from previous operation, then it will be execute any fancy update you may need then remove it from the context until there are no more results.
  3. `finishProcess` method will be trigger once all operations are completed then I will create a message to notify the user about how the process ends up.

The real trick here is to use the "&$context" variable, because it is send by reference among all different operations to be executed by the Drupal batch.

Now you only need to add a new batch, thankfully Drupal 8.6.x introduced a new BatchBuilder class in order to create/prepare new batches: https://www.drupal.org/node/2875389
Based on our example the code to create a new batch may looks as following:


// Define batch process to update articles.
$batch_builder = (new BatchBuilder())
  ->setTitle($this->t('Article masive updates...'))
  ->setFinishCallback([BatchOperationsManager::class, 'finishProcess'])
  ->addOperation([BatchOperationsManager::class, 'initOperation'], [
      ['limit' => 100],
    ]
  )->addOperation([BatchOperationsManager::class, 'updateProcess'], [
    ['limit' => 100],
  ]);
batch_set($batch_builder->toArray());

That’s our Batch implementation is ready to go. You may be wondering where you should actually include that final chunk of code to invoke a batch by Drupal. In that case, I may suggest to create a custom form and finally include it into your submission method. 

If you don’t know how to achieve it, then I would recommend you an official Drupal 8 documentation named “Introduction to Form API”. It will guide you step by step how to successfully create a custom form, then you can include this new batch into your `submitForm` method.

That said, I hope it helps you at some point somehow!

Happy coding!

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.