Commit 13e1e9a3 authored by Zoltan Attila Horvath's avatar Zoltan Attila Horvath Committed by Zoltan Attila Horvath
Browse files

Issue #3254688 by huzooka: Move BeanCompare process plugin into Migrate Magician

parent ba919857
Loading
Loading
Loading
Loading
+259 −0
Original line number Diff line number Diff line
<?php

declare(strict_types = 1);

namespace Drupal\migmag_process\Plugin\migrate\process;

use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;

/**
 * Migrate process plugin for comparing two values.
 *
 * Using this plugin in combination of the 'skip_on_empty' process plugin may
 * allow you to skip evaluation of process pipelines if a specific condition is
 * or is not met.
 *
 * Configuration options:
 * - operator: The comparison operator to use. Defaults to '===' (identical).
 *   For available options check PHP documentation.
 * - return_if: The array of the return values. By default, the process plugin
 *   returns boolean FALSE if the comparison fails, or TRUE if it passes. Except
 *   in case of the spaceship operator ('<=>'), which returns integer 0,
 *   a negative integer, or a positive integer. If we need a NULL return
 *   value if the comparison evaluates to FALSE, then we should specify this
 *   configuration as ['false' => 'foobarbaz']. Optional.
 * - multiple: whether the plugin should handle multiple values or not.
 *   Optional, defaults to FALSE.
 *
 * Examples:
 *
 * @code
 * destination_property:
 *   plugin: migmag_compare
 *   source:
 *     - property_1
 *     - property_2
 * @endcode
 *
 * This configuration sets 'destination_property' to boolean TRUE if
 * 'property_1' is identical to 'property_2', and will set it to
 * boolean FALSE if 'property_1' and 'property_2' have different values.
 *
 * @code
 * destination_property:
 *   plugin: migmag_compare
 *   source:
 *     - property_1
 *     - property_2
 *   operator: '>='
 *   return_if:
 *     false: 'false'
 *     true: 'true'
 * @endcode
 *
 * This configuration sets 'destination_property' to string 'true' if
 * 'property_1' is greater than or equals to 'property_2'; or it sets it to
 * string 'false' if 'property_1' is less than 'property_2'.
 *
 * @code
 * destination_property:
 *   plugin: migmag_compare
 *   source:
 *     - property_1
 *     - property_2
 *   operator: '<=>'
 *   return_if:
 *     '-1': '1st less than 2nd'
 *     '0': 'equal'
 *     '1': '1st greater than 2nd'
 * @endcode
 *
 * With this configuration, 'destination_property' will be set to:
 * - '1st less than 2nd' if 'property_1' is less than 'property_2',
 * - 'equal' if 'property_1' equals to 'property_2',
 * - '1st greater than 2nd' if 'property_1' is greater than 'property_2'.
 *
 * @code
 * destination_property:
 *   -
 *     plugin: migmag_compare
 *     source:
 *       - property_1
 *       - property_2
 *   -
 *     plugin: skip_on_empty
 *     method: process
 *   -
 *     plugin: get
 *     source: property_3
 *   [...]
 * @endcode
 *
 * - If the value of 'property_1' isn't identical to 'property_2', then
 *   'migmag_compare' returns boolean FALSE. Since this is an empty value,
 *   'skip_on_empty' will stop the execution of the process plugin pipeline.
 * - If the value of 'property_1' and 'property_2' are identical,
 *   'migmag_compare' returns boolean TRUE. 'skip_on_empty' won't do anything,
 *   and the pipeline continues with the next process plugin (which returns
 *   the value of 'property_3').
 *
 * @see https://www.php.net/manual/en/language.operators.comparison.php
 *
 * @MigrateProcessPlugin(
 *   id = "migmag_compare"
 * )
 */
class MigMagCompare extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
    if (!is_array($value)) {
      throw new MigrateException(
        sprintf(
          "'%s' migrate process plugin's processed value must be an array, got '%s'.",
          $this->pluginId,
          gettype($value)
        )
      );
    }
    if (count($value) < 2) {
      throw new MigrateException(
        sprintf(
          "'%s' migrate process plugin's processed array value must have at least two values.",
          $this->pluginId
        )
      );
    }
    [$variable_1, $variable_2] = array_values($value);
    $operator = $this->configuration['operator'] ?? '===';
    if (!is_string($operator)) {
      throw new MigrateException(
        sprintf(
          "'%s' migrate process plugin's operator must be a string, got '%s'.",
          $this->pluginId,
          gettype($operator)
        )
      );
    }

    try {
      $comparison_result = $this->doCompare($variable_1, $variable_2, $operator);
    }
    catch (\Throwable $t) {
      throw new MigrateException(
        sprintf(
          "Comparison failed in '%s' migrate process plugin with message: %s.",
          $this->pluginId,
          $t->getMessage()
        )
      );
    }

    if (!isset($comparison_result)) {
      throw new MigrateException(
        sprintf(
          "'%s' migrate process plugin does not support operator '%s'.",
          $this->pluginId,
          $operator
        )
      );
    }

    return $this->deliverReturnValue($comparison_result);
  }

  /**
   * Evaluated the configured comparison.
   *
   * @param mixed $value_1
   *   The first value of the comparison.
   * @param mixed $value2
   *   The second value of the comparison.
   * @param string $operator
   *   The operator to use.
   *
   * @return bool|int|null
   *   The return value of the comparison, ot NULL if the operator is
   *   unsupported.
   */
  protected function doCompare($value_1, $value2, string $operator) {
    switch ($operator) {
      case '==':
        return $value_1 == $value2;

      case '===':
        return $value_1 === $value2;

      case '!=':
      case '<>':
        return $value_1 <> $value2;

      case '!==':
        return $value_1 !== $value2;

      case '<':
        return $value_1 < $value2;

      case '<=':
        return $value_1 <= $value2;

      case '>':
        return $value_1 > $value2;

      case '>=':
        return $value_1 >= $value2;

      case '<=>':
        return $value_1 <=> $value2;
    }

    return NULL;
  }

  /**
   * Returns the appropriate configured value.
   *
   * @param bool|int $comparison_result
   *   The result of an evaluated comparison.
   *
   * @return bool|int|mixed
   *   The returned value.
   */
  protected function deliverReturnValue($comparison_result) {
    // Spaceship returns integer.
    if (is_bool($comparison_result)) {
      return $comparison_result === FALSE
        ? $this->configuration['return_if']['false'] ?? $comparison_result
        : $this->configuration['return_if']['true'] ?? $comparison_result;
    }

    if (!is_int($comparison_result)) {
      throw new \BadMethodCallException(
        sprintf(__METHOD__ . ": argument must be a boolean or an integer")
      );
    }

    // This must be an integer since only spaceship returns non-boolean values.
    if ($comparison_result > 0) {
      return $this->configuration['return_if']['1'] ?? $comparison_result;
    }
    if ($comparison_result < 0) {
      return $this->configuration['return_if']['-1'] ?? $comparison_result;
    }

    return $this->configuration['return_if']['0'] ?? $comparison_result;
  }

  /**
   * {@inheritdoc}
   */
  public function multiple() {
    return $this->configuration['multiple'] ?? FALSE;
  }

}
+341 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\migmag_process\Unit\Plugin;

use Drupal\migmag_process\Plugin\migrate\process\MigMagCompare;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;
use Drupal\Tests\migrate\Unit\MigrateTestCase;

/**
 * Tests the migmag_compare process plugin.
 *
 * @coversDefaultClass \Drupal\migmag_process\Plugin\migrate\process\MigMagCompare
 *
 * @group migmag_process
 */
class MigMagCompareTest extends MigrateTestCase {

  /**
   * Tests the transformation of the provided values.
   *
   * @param array $plugin_config
   *   The configuration of the tested plugin instance.
   * @param mixed $value
   *   The incoming value to test the transformation with.
   * @param mixed $expected_result
   *   The expected result of the transformation.
   * @param string|null $expected_exception_message
   *   The expected message of the MigrateException if the test case should end
   *   in a MigrateException. If this is NULL, then the test does not expects a
   *   MigrateException to be thrown. Defaults to NULL.
   *
   * @covers ::transform
   * @covers ::doCompare
   * @covers ::deliverReturnValue
   *
   * @dataProvider providerTestTransform
   */
  public function testTransform(array $plugin_config, $value, $expected_result, string $expected_exception_message = NULL): void {
    $migrate_executable = $this->prophesize(MigrateExecutableInterface::class);
    $row = $this->prophesize(Row::class);
    $plugin_config += ['plugin' => 'migmag_compare'];
    $plugin = new MigMagCompare(
      $plugin_config,
      $plugin_config['plugin'],
      []
    );

    if ($expected_exception_message) {
      $this->expectException(MigrateException::class);
      $this->expectExceptionMessage($expected_exception_message);
    }
    $actual_result = $plugin->transform(
      $value,
      $migrate_executable->reveal(),
      $row->reveal(),
      'destination_property'
    );

    $this->assertSame($expected_result, $actual_result);
  }

  /**
   * Data provider for ::testTransform.
   *
   * @return array[]
   *   The test cases.
   */
  public function providerTestTransform(): array {
    $return_if_conf = [
      'true' => 'true',
      'false' => 'false',
      0 => 'equal',
      -1 => '1st less than 2nd',
      1 => '1st greater than 2nd',
    ];
    return [
      "No operator ('==='), expected FALSE" => [
        'config' => [],
        'value' => ['0', 0],
        'expected' => FALSE,
      ],
      "No operator ('==='), expected TRUE" => [
        'config' => [],
        'value' => ['foo', 'foo'],
        'expected' => TRUE,
      ],

      "'==' operator, expected FALSE" => [
        'config' => ['operator' => '=='],
        'value' => ['ab', 1],
        'expected' => FALSE,
      ],
      "'==' operator, expected TRUE" => [
        'config' => ['operator' => '=='],
        'value' => ['0', 0],
        'expected' => TRUE,
      ],
      "Why weak comparison ('==') is dangerous (with PHP < 8.0)" => [
        'config' => ['operator' => '=='],
        'value' => ['ab', 0],
        'expected' => PHP_MAJOR_VERSION < 8,
      ],

      "'===' operator, expected FALSE" => [
        'config' => ['operator' => '==='],
        'value' => ['1', 1],
        'expected' => FALSE,
      ],
      "'===' operator, expected TRUE" => [
        'config' => ['operator' => '==='],
        'value' => [1.234, 1.234],
        'expected' => TRUE,
      ],

      "'!=' operator, expected FALSE" => [
        'config' => ['operator' => '!='],
        'value' => ['0', 0],
        'expected' => FALSE,
      ],
      "'!=' operator, expected TRUE" => [
        'config' => ['operator' => '!='],
        'value' => ['ab', 1],
        'expected' => TRUE,
      ],
      "Why weak comparison ('!=') is dangerous (with PHP < 8.0)" => [
        'config' => ['operator' => '!='],
        'value' => ['ab', 0],
        'expected' => PHP_MAJOR_VERSION >= 8,
      ],
      "Mixed values #1, '<>' operator, expected FALSE" => [
        'config' => ['operator' => '<>'],
        'value' => ['1.2345', 1.2345],
        'expected' => FALSE,
      ],
      "Mixed values #2, '<>' operator" => [
        'config' => ['operator' => '<>'],
        'value' => ['0abc', 0],
        'expected' => PHP_MAJOR_VERSION >= 8,
      ],
      "Mixed values #3, '!=' operator, expected FALSE" => [
        'config' => ['operator' => '<>'],
        'value' => [NULL, ''],
        'expected' => FALSE,
      ],

      "'!==' operator, expected FALSE" => [
        'config' => ['operator' => '!=='],
        'value' => [1.2345, 1.2345],
        'expected' => FALSE,
      ],
      "'!==' operator, expected TRUE" => [
        'config' => ['operator' => '!=='],
        'value' => ['1.2345', 1.2345],
        'expected' => TRUE,
      ],

      "'<' operator #1, expected FALSE" => [
        'config' => ['operator' => '<'],
        'value' => [1.23456, 1.2345],
        'expected' => FALSE,
      ],

      "'<' operator #2, expected TRUE" => [
        'config' => ['operator' => '<'],
        'value' => [1.2345, 1.23456],
        'expected' => TRUE,
      ],
      "'<' operator #3, expected TRUE" => [
        'config' => ['operator' => '<'],
        'value' => ['1.2345', 1.23456],
        'expected' => TRUE,
      ],
      "'<' operator #4, expected FALSE" => [
        'config' => ['operator' => '<'],
        'value' => ['1.23456', 1.2345],
        'expected' => FALSE,
      ],

      "'<=' operator #1, expected FALSE" => [
        'config' => ['operator' => '<='],
        'value' => [1.2345, 1],
        'expected' => FALSE,
      ],
      "'<=' operator #2, expected TRUE" => [
        'config' => ['operator' => '<='],
        'value' => [1, 1.2345],
        'expected' => TRUE,
      ],
      "'<=' operator #3, expected TRUE" => [
        'config' => ['operator' => '<='],
        'value' => ['1.23456', 1.2345678],
        'expected' => TRUE,
      ],

      "'>' operator #1, expected FALSE" => [
        'config' => ['operator' => '>'],
        'value' => [1.23456, 1.2345678],
        'expected' => FALSE,
      ],
      "'>' operator #2, expected TRUE" => [
        'config' => ['operator' => '>'],
        'value' => [1.23456, 1.2345],
        'expected' => TRUE,
      ],

      "'>=' operator #1, expected FALSE" => [
        'config' => ['operator' => '>='],
        'value' => [1, 1.2345],
        'expected' => FALSE,
      ],
      "'>=' operator #2, expected TRUE" => [
        'config' => ['operator' => '>='],
        'value' => [1.2345, 1],
        'expected' => TRUE,
      ],
      "'>=' operator #3, expected TRUE" => [
        'config' => ['operator' => '>='],
        'value' => [1.2345678, '1.23456'],
        'expected' => TRUE,
      ],

      "Second object is always bigger" => [
        'config' => ['operator' => '>'],
        'value' => [(object) ['a' => 1], (object) []],
        'expected' => TRUE,
      ],

      "Second array is always bigger" => [
        'config' => ['operator' => '>'],
        'value' => [[1], []],
        'expected' => TRUE,
      ],

      "'<=>' operator #1" => [
        'config' => ['operator' => '<=>'],
        'value' => [1, 2],
        'expected' => -1,
      ],
      "'<=>' operator #2" => [
        'config' => ['operator' => '<=>'],
        'value' => [2, 2],
        'expected' => 0,
      ],
      "'<=>' operator #3" => [
        'config' => ['operator' => '<=>'],
        'value' => [3, 2],
        'expected' => 1,
      ],
      "'<=>' operator #4: object" => [
        'config' => ['operator' => '<=>'],
        'value' => [(object) [1 => 1], (object) [1 => 0]],
        'expected' => 1,
      ],
      "'<=>' operator #5: object" => [
        'config' => ['operator' => '<=>'],
        'value' => [(object) [1 => 1], (object) [1 => 1]],
        'expected' => 0,
      ],
      "'<=>' operator #6: object" => [
        'config' => ['operator' => '<=>'],
        'value' => [(object) [1 => 1], (object) [1 => 2]],
        'expected' => -1,
      ],

      'return_if #1: true' => [
        'config' => ['return_if' => $return_if_conf],
        'value' => [1, 1],
        'expected' => 'true',
      ],
      'return_if #2: false' => [
        'config' => ['return_if' => $return_if_conf],
        'value' => [0, 1],
        'expected' => 'false',
      ],
      'return_if #3: less' => [
        'config' => [
          'operator' => '<=>',
          'return_if' => $return_if_conf,
        ],
        'value' => [0, 1],
        'expected' => '1st less than 2nd',
      ],
      'return_if #4: equal' => [
        'config' => [
          'operator' => '<=>',
          'return_if' => $return_if_conf,
        ],
        'value' => [1, 1],
        'expected' => 'equal',
      ],
      'return_if #5: greater' => [
        'config' => [
          'operator' => '<=>',
          'return_if' => $return_if_conf,
        ],
        'value' => [2, 1],
        'expected' => '1st greater than 2nd',
      ],

      "Exception: comparison fails" => [
        'config' => ['operator' => '<=>'],
        'value' => [(object) [1 => 1], 1],
        'expected' => NULL,
        'exception' => "Comparison failed in 'migmag_compare' migrate process plugin with message: Object of class stdClass could not be converted to int.",
      ],
      "Exception: object value" => [
        'config' => [],
        'value' => (object) ['foo' => 'bar'],
        'expected' => NULL,
        'exception' => "'migmag_compare' migrate process plugin's processed value must be an array, got 'object'.",
      ],
      "Exception: integer value" => [
        'config' => [],
        'value' => 1,
        'expected' => NULL,
        'exception' => "'migmag_compare' migrate process plugin's processed value must be an array, got 'integer'.",
      ],
      "Exception: only one array value" => [
        'config' => [],
        'value' => [1],
        'expected' => NULL,
        'exception' => "'migmag_compare' migrate process plugin's processed array value must have at least two values.",
      ],
      "Exception: unsupported operator" => [
        'config' => ['operator' => 'foo'],
        'value' => [1, 2],
        'expected' => NULL,
        'exception' => "'migmag_compare' migrate process plugin does not support operator 'foo'.",
      ],
      "Exception: non-string operator" => [
        'config' => ['operator' => (object) ['foo']],
        'value' => [1, 2],
        'expected' => NULL,
        'exception' => "'migmag_compare' migrate process plugin's operator must be a string, got 'object'.",
      ],
    ];
  }

}