jordanbrauer/unit-converter

View on GitHub
src/UnitConverter.php

Summary

Maintainability
A
3 hrs
Test Coverage
F
42%
<?php

declare(strict_types = 1);

/**
 * This file is part of the jordanbrauer/unit-converter PHP package.
 *
 * @copyright 2018 Jordan Brauer <18744334+jordanbrauer@users.noreply.github.com>
 * @license MIT
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace UnitConverter;

use NumberFormatter;
use RuntimeException;
use UnitConverter\Calculator\BinaryCalculator;
use UnitConverter\Calculator\CalculatorInterface;
use UnitConverter\Calculator\Formula\UnitConversionFormula;
use UnitConverter\Exception\BadConverter;
use UnitConverter\Exception\BadUnit;
use UnitConverter\Registry\UnitRegistryInterface;
use UnitConverter\Unit\UnitInterface;

/**
 * The actual unit converter object. Extend this object
 * if you would like to implement your own custom converter.
 *
 * @version 1.0.0
 * @since 0.0.1
 * @author Jordan Brauer <18744334+jordanbrauer@users.noreply.github.com>
 */
class UnitConverter implements UnitConverterInterface
{
    const CONVERSION_HASH_LENGTH = [0, 7];

    /**
     * A static array of supported scalar types for a unit's value.
     *
     * @var array
     */
    private static $types = ["int", "float", "string"];

    /**
     * The converters internal calculator used to handle mathematical operations
     *
     * @var CalculatorInterface $calculator
     */
    protected $calculator;

    /**
     * The value being converted.
     *
     * @var float $convert
     */
    protected $convert;

    /**
     * The unit of measure being converted **from**.
     *
     * @var UnitInterface $from
     */
    protected $from;

    /**
     * The log of events for the current conversion calculations
     *
     * @var array $log
     */
    protected $log = [];

    /**
     * Are conversions going to be logged?
     *
     * @var boolean
     */
    protected $loggingEnabled;

    /**
     * The decimal precision to be calculated
     *
     * @var int $precision
     */
    protected $precision;

    /**
     * The registry that the unit converter accesses available units from
     *
     * @var UnitRegistryInterface $registry
     */
    protected $registry;

    /**
     * The unit of measure being converted **to**.
     *
     * @var UnitInterface $to
     */
    protected $to;

    /**
     * The current conversions unique hash.
     *
     * @var string
     */
    private $hash;

    /**
     * Public constructor function for the UnitConverter class.
     *
     * @param UnitInterface[] $registry A two-dimensional array of UnitInterface objects.
     * @param CalculatorInterface $calculator The calculator that the converter will use to perform mathematical operations.
     */
    public function __construct(UnitRegistryInterface $registry, CalculatorInterface $calculator)
    {
        $this->setRegistry($registry);
        $this->setCalculator($calculator);
        $this->enableConversionLog();
    }

    /**
     * Returns a unit converter set up for binary use cases
     *
     * @api
     * @return UnitConverterInterface
     */
    public static function binary(): UnitConverterInterface
    {
        return self::createBuilder()
            ->addBinaryCalculator()
            ->addDefaultRegistry()
            ->build();
    }

    /**
     * Returns a builder object for quickly scaffolding out a new converter.
     *
     * @api
     * @return ConverterBuilder
     */
    public static function createBuilder()
    {
        return new ConverterBuilder();
    }

    /**
     * Returns a unit converter set up for common use cases
     *
     * @api
     * @return UnitConverterInterface
     */
    public static function default(): UnitConverterInterface
    {
        return self::createBuilder()
            ->addSimpleCalculator()
            ->addDefaultRegistry()
            ->build();
    }

    /**
     * Convert a unit to all other possible units of measurement. The results will
     * be an associative array in the form of `symbol => conversion`.
     *
     * @return array
     */
    public function all(): array
    {
        $results = [];
        $symbol = $this->from->getSymbol();

        array_map(function ($unit) use (&$results, $symbol) {
            if ($symbol != $unit) {
                $results[$unit] = $this->to($unit);
            }
        }, $this->registry->listUnits($this->from->getUnitOf()));

        return $results;
    }

    /**
     * Determine whether or not the converter has an active calculator.
     *
     * @api
     * @return bool
     */
    public function calculatorExists(): bool
    {
        return $this->calculator instanceof CalculatorInterface;
    }

    /**
     * @return static
     */
    public function convert($value, int $precision = null): UnitConverterInterface
    {
        $this->precision = $precision;
        $this->convert = $value;

        return $this;
    }

    /**
     * Disables the logging of conversions & their order of operations.
     *
     * @api
     * @return UnitConverterInterface
     */
    public function disableConversionLog(): UnitConverterInterface
    {
        $this->loggingEnabled = false;

        return $this;
    }

    /**
     * Enables the logging of conversions & their order of operations.
     *
     * @api
     * @return UnitConverterInterface
     */
    public function enableConversionLog(): UnitConverterInterface
    {
        $this->loggingEnabled = true;

        return $this;
    }

    /**
     * @return static
     */
    public function from(string $unit): UnitConverterInterface
    {
        $this->from = $this->loadUnit($unit);

        return $this;
    }

    /**
     * Return the current calculator instance for the unit converter.
     *
     * @return CalculatorInterface
     */
    public function getCalculator(): CalculatorInterface
    {
        return $this->calculator;
    }

    /**
     * Return an array, containing a list of events in the order they occured for
     * the current calculation.
     *
     * @api
     * @return array
     */
    public function getConversionLog(): array
    {
        return $this->log;
    }

    /**
     * Return the current registry instance for the unit converter.
     *
     * @return UnitRegistryInterface
     */
    public function getRegistry(): UnitRegistryInterface
    {
        return $this->registry;
    }

    /**
     * Determine whether or not the converter has an active registry.
     *
     * @api
     * @return bool
     */
    public function registryExists(): bool
    {
        return $this->registry instanceof UnitRegistryInterface;
    }

    /**
     * Set the unit converter calculator to perform mathematical operations with.
     *
     * @api
     * @param CalculatorInterface $calculator An instance of a CalculatorInterface
     * @return UnitConverterInterface
     */
    public function setCalculator(CalculatorInterface $calculator): UnitConverterInterface
    {
        $this->calculator = $calculator;

        return $this;
    }

    /**
     * Set the unit converter registry for storing units of measure to convert values with.
     *
     * @api
     * @param UnitRegistryInterface $registry An instance of UnitRegistry.
     * @return UnitConverterInterface
     */
    public function setRegistry(UnitRegistryInterface $registry): UnitConverterInterface
    {
        $this->registry = $registry;

        return $this;
    }

    /**
     * Like `to`, but will present the conversion result as words instead of a numeric value.
     *
     * @param string $unit The unit being converted **to**. The unit must first be registered to the UnitRegistry.
     * @param string $locale The locale to translate the number with. Defaults to Canadian English
     * @return string
     * @see to
     */
    public function spellout(string $unit, string $locale = 'en_CA'): string
    {
        if (!extension_loaded('intl')) {
            throw new RuntimeException('Unable to spellout a conversion due to missing intl library. Please check your PHP extensions.');
        }

        return (new NumberFormatter($locale, NumberFormatter::SPELLOUT))
            ->format($this->to($unit));
    }

    /**
     * {@inheritDoc}
     */
    public function to(string $unit)
    {
        $this->to = $this->loadUnit($unit);

        return $this->calculate();
    }

    /**
     * Determine which calculator is currently being used
     *
     * @api
     * @return null|string
     */
    public function whichCalculator(): ?string
    {
        return ($this->calculatorExists())
            ? get_class($this->calculator)
            : null;
    }

    /**
     * Calculate the conversion from one unit to another.
     *
     * @internal
     * @throws BadConverter
     * @return int|float|string
     */
    protected function calculate()
    {
        if (!$this->calculatorExists()) {
            throw BadConverter::missingCalculator();
        }

        if ($this->from->getUnitOf() !== $this->to->getUnitOf()) {
            throw BadUnit::conversion($this->from, $this->to);
        }

        if ($this->conversionExists()) {
            return $this->log[$this->getConversionHash()]['result'];
        }

        $fromUnits = $this->from->getUnits();
        $toUnits = $this->to->getUnits();

        if (BinaryCalculator::class === $this->whichCalculator()) {
            extract($this->castUnitsTo("string", $fromUnits, $toUnits));

            if ($this->precision) {
                $this->calculator->setPrecision($this->precision);
            }
        }

        $formula = $this->from->getFormulaFor($this->to) ?? new UnitConversionFormula();
        $result = $this->calculator->exec($formula, $this->convert, $fromUnits, $toUnits, $this->precision);

        $this->writeLog($result, ($this->calculator->dump(true)[0] ?? null));

        return $result;
    }

    /**
     * Returns an array containing the "from" and "to" unit values casted to the specified type.
     *
     * @internal
     * @throws BadUnit When an unsupported scalar type is specified, throws exception.
     * @param string $type The variable type to be casted. Can be one of, "int", "float", or "string".
     * @param int|float|string $fromUnits
     * @param int|float|string $toUnits
     * @return array
     */
    protected function castUnitsTo(string $type, $fromUnits, $toUnits): array
    {
        if (!in_array($type, self::$types)) {
            throw BadUnit::scalar($type, self::$types);
        }

        return array_combine([
            'fromUnits',
            'toUnits',
        ], array_map(function ($value) use ($type) {
            settype($value, $type);

            return $value;
        }, [
            $fromUnits,
            $toUnits,
        ]));
    }

    /**
     * Load a unit from the unit converter registry.
     *
     * @internal
     * @uses UnitConverter\UnitRegistry::loadUnit
     * @throws BadConverter Thrown if an attempt is made to access a non-existent registry.
     * @param string $symbol The symbol of the unit being loaded.
     * @return UnitInterface
     */
    protected function loadUnit(string $symbol): UnitInterface
    {
        if (!$this->registryExists()) {
            throw BadConverter::missingRegistry();
        }

        return $this->registry->loadUnit($symbol);
    }

    /**
     * Add an entry to the conversion calculation log.
     *
     * @internal
     * @param int|float|string $result
     * @param string $calculation
     * @return void
     */
    protected function writeLog($result, string $calculation = null): void
    {
        if ($this->loggingEnabled and $calculation) {
            $this->log[$this->getConversionHash()] = [
                'calculation' => $calculation,
                'value'       => $this->convert,
                'precision'   => $this->precision,
                'from'        => $this->from->getSymbol(),
                'to'          => $this->to->getSymbol(),
                'result'      => $result,
            ];
        }
    }

    /**
     * Generates a conversion hash for the current set of parameters & checks if
     * it matches any previous conversions. If logging is disabled, a conversion
     * hash **will not** be generated & the check will not be made.
     *
     * @return bool
     */
    private function conversionExists(): bool
    {
        return $this->loggingEnabled
            and array_key_exists($this->generateConversionHash(), $this->log);
    }

    /**
     * Sets & returns a unique md5 hash of the current conversion.
     *
     * @return string
     */
    private function generateConversionHash(): string
    {
        $this->hash = mb_substr(md5(
            $this->convert.
            $this->precision.
            $this->from->getRegistryKey().
            $this->to->getSymbol()
        ), ...self::CONVERSION_HASH_LENGTH);

        return $this->hash;
    }

    /**
     * Return a unique md5 hash of the current conversion.
     *
     * @return null|string
     */
    private function getConversionHash(): ?string
    {
        return $this->hash;
    }
}