src/Persistence/Ui.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

namespace Atk4\Ui\Persistence;

use Atk4\Data\Field;
use Atk4\Data\Field\PasswordField;
use Atk4\Data\Model;
use Atk4\Data\Persistence;
use Atk4\Data\Persistence\Sql\Expression;
use Atk4\Ui\Exception;

/**
 * This class is used for typecasting model types to the values that will be presented to the user. App will
 * always initialize this persistence in $app->uiPersistence and this object will be used by various
 * UI elements to output data to the user.
 *
 * Overriding and extending this class is a great place where you can tweak how various data-types are displayed
 * to the user in the way so it would affect UI globally.
 *
 * You may want to localize some of the output.
 */
class Ui extends Persistence
{
    /** @var string */
    public $locale = 'en';

    /** @var '.'|',' Decimal point separator for numeric (non-integer) types. */
    public $decimalSeparator = '.';
    /** @var ''|' '|','|'.' Thousands separator for numeric types. */
    public $thousandsSeparator = ' ';

    /** @var string Currency symbol for 'atk4_money' type. */
    public $currency = '€';
    /** @var int Number of decimal digits for 'atk4_money' type. */
    public $currencyDecimals = 2;

    /** @var string */
    public $timezone;
    /** @var string */
    public $dateFormat = 'M j, Y';
    /** @var string */
    public $timeFormat = 'H:i';
    /** @var string */
    public $datetimeFormat = 'M j, Y H:i';
    /** @var int Calendar input first day of week, 0 = Sunday, 1 = Monday. */
    public $firstDayOfWeek = 0;

    /** @var string */
    public $yes = 'Yes';
    /** @var string */
    public $no = 'No';

    private Persistence $attributePersistence;

    public function __construct()
    {
        if ($this->timezone === null) {
            $this->timezone = date_default_timezone_get();
        }

        $attributePersistence = clone $this;
        $this->initAttributePersistence($attributePersistence);
        $this->attributePersistence = $attributePersistence;
    }

    protected function initAttributePersistence(self $attributePersistence): void
    {
        $attributePersistence->thousandsSeparator = '';
        $attributePersistence->currency = '';
        $attributePersistence->currencyDecimals = 1;
        $attributePersistence->timezone = 'UTC';
        $attributePersistence->dateFormat = 'Y-m-d';
        $attributePersistence->datetimeFormat = $attributePersistence->dateFormat . ' ' . $attributePersistence->timeFormat;
        $attributePersistence->yes = '1';
        $attributePersistence->no = '0';
    }

    #[\Override]
    public function typecastSaveField(Field $field, $value): ?string
    {
        // relax empty checks for UI render for not yet set values
        $fieldNullableOrig = $field->nullable;
        $fieldRequiredOrig = $field->required;
        try {
            $field->nullable = true;
            $field->required = false;

            return parent::typecastSaveField($field, $value);
        } finally {
            $field->nullable = $fieldNullableOrig;
            $field->required = $fieldRequiredOrig;
        }
    }

    #[\Override]
    protected function _typecastSaveField(Field $field, $value): ?string
    {
        // always normalize string EOL
        if (is_string($value)) {
            $value = preg_replace('~\r?\n|\r~', "\n", $value);
        }

        // typecast using DBAL types
        $value = parent::_typecastSaveField($field, $value);

        switch ($field->type) {
            case 'boolean':
                $value = parent::_typecastLoadField($field, $value);
                $value = $value ? $this->yes : $this->no;

                break;
            case 'smallint':
            case 'integer':
            case 'bigint':
            case 'float':
                $value = parent::_typecastLoadField($field, $value);
                $value = is_int($value)
                    ? (string) $value
                    : Expression::castFloatToString($value);
                $value = preg_replace_callback('~\.?\d+~', function ($matches) {
                    return substr($matches[0], 0, 1) === '.'
                        ? $this->decimalSeparator . preg_replace('~\d{3}\K(?!$)~', '', substr($matches[0], 1))
                        : preg_replace('~(?<!^)(?=(?:\d{3})+$)~', $this->thousandsSeparator, $matches[0]);
                }, $value);
                $value = str_replace(' ', "\u{00a0}" /* Unicode NBSP */, $value);

                break;
            case 'atk4_money':
                $value = parent::_typecastLoadField($field, $value);
                $valueDecimals = strlen(preg_replace('~^[^.]$|^.+\.|0+$~s', '', number_format($value, max(0, 11 - (int) log10($value)), '.', '')));
                $value = ($this->currency ? $this->currency . ' ' : '')
                    . number_format($value, max($this->currencyDecimals, $valueDecimals), $this->decimalSeparator, $this->thousandsSeparator);
                $value = str_replace(' ', "\u{00a0}" /* Unicode NBSP */, $value);

                break;
            case 'date':
            case 'datetime':
            case 'time':
                /** @var \DateTimeInterface|null */
                $value = parent::_typecastLoadField($field, $value);
                if ($value !== null) {
                    $format = [
                        'date' => $this->dateFormat,
                        'datetime' => $this->datetimeFormat,
                        'time' => $this->timeFormat,
                    ][$field->type];

                    $valueHasSeconds = (int) $value->format('s') !== 0;
                    $valueHasMicroseconds = (int) $value->format('u') !== 0;
                    $formatHasMicroseconds = str_contains($format, '.u');
                    if ($valueHasSeconds || $valueHasMicroseconds) {
                        $format = preg_replace('~(?<=:i)(?!:s)~', ':s', $format);
                    }
                    if ($valueHasMicroseconds) {
                        $format = preg_replace('~(?<=:s)(?!\.u)~', '.u', $format);
                    }

                    if ($field->type === 'datetime') {
                        $value = new \DateTime($value->format('Y-m-d H:i:s.u'), $value->getTimezone());
                        $value->setTimezone(new \DateTimeZone($this->timezone));
                    }
                    $value = $value->format($format);

                    if (!$formatHasMicroseconds) {
                        $value = preg_replace('~(?<!\d|:)\d{1,2}:\d{1,2}(?::\d{1,2})?\.\d*?\K0+(?!\d)~', '', $value);
                    }
                }

                break;
        }

        if (is_int($value)) {
            $value = $this->_typecastSaveField(new Field(['type' => 'bigint']), $value);
        }

        if (is_float($value)) {
            $value = $this->_typecastSaveField(new Field(['type' => 'float']), $value);
        }

        return $value;
    }

    #[\Override]
    protected function _typecastLoadField(Field $field, $value)
    {
        switch ($field->type) {
            case 'boolean':
                if (is_string($value)) {
                    $value = trim($value);
                    if (mb_strtolower($value) === mb_strtolower($this->yes)) {
                        $value = '1';
                    } elseif (mb_strtolower($value) === mb_strtolower($this->no)) {
                        $value = '0';
                    }
                }

                break;
            case 'smallint':
            case 'integer':
            case 'bigint':
            case 'float':
            case 'atk4_money':
                if (is_string($value)) {
                    $dSep = $this->decimalSeparator;
                    $tSep = $this->thousandsSeparator;
                    if ($tSep !== '.' && $tSep !== ',' && !str_contains($value, $dSep)) {
                        if (str_contains($value, '.')) {
                            $dSep = '.';
                        } elseif (str_contains($value, ',')) {
                            $dSep = ',';
                        }
                    }

                    $value = str_replace([' ', "\u{00a0}" /* Unicode NBSP */, '_', $tSep], '', $value);
                    $value = str_replace($dSep, '.', $value);

                    if ($field->type === 'atk4_money' && $this->currency !== '' && substr_count($value, $this->currency) === 1) {
                        $currencyPos = strpos($value, $this->currency);
                        $beforeStr = substr($value, 0, $currencyPos);
                        $afterStr = substr($value, $currencyPos + strlen($this->currency));

                        $value = $beforeStr
                            . (ctype_digit(substr($beforeStr, -1)) && ctype_digit(substr($afterStr, 0, 1)) ? '.' : '')
                            . $afterStr;
                    }
                }

                break;
            case 'date':
            case 'datetime':
            case 'time':
                if ($value === '') {
                    return null;
                }

                $dtClass = \DateTime::class;
                $tzClass = \DateTimeZone::class;
                $format = [
                    'date' => $this->dateFormat,
                    'datetime' => $this->datetimeFormat,
                    'time' => $this->timeFormat,
                ][$field->type];

                if (preg_match('~(?<!\d|:)\d{1,2}:\d{1,2}:\d{1,2}(?!\d)~', $value)) {
                    $format = preg_replace('~(?<=:i)(?!:s)~', ':s', $format);
                }
                if (preg_match('~(?<!\d|:)\d{1,2}:\d{1,2}(?::\d{1,2})?\.\d{1,9}(?!\d)~', $value)) {
                    $format = preg_replace('~(?<=:s)(?!\.u)~', '.u', $format);
                }

                $valueOrig = $value;
                $value = $dtClass::createFromFormat('!' . $format, $value, $field->type === 'datetime' ? new $tzClass($this->timezone) : null);
                if ($value === false) {
                    throw (new Exception('Incorrectly formatted datetime'))
                        ->addMoreInfo('format', $format)
                        ->addMoreInfo('value', $valueOrig)
                        ->addMoreInfo('field', $field);
                }

                if ($field->type === 'datetime') {
                    $value->setTimezone(new $tzClass(date_default_timezone_get()));
                }

                $value = parent::_typecastSaveField($field, $value);

                break;
                // <-- reindent once https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/6490 is merged
                // SECURITY: do not unserialize any user input
                // https://github.com/search?q=unserialize+repo%3Adoctrine%2Fdbal+path%3A%2Fsrc%2FTypes
            case 'object':
            case 'array':
                throw new Exception('Object serialization is not supported');
        }

        // typecast using DBAL type and normalize
        $value = parent::_typecastLoadField($field, $value);
        $value = (new Field(['type' => $field->type]))->normalize($value);

        if ($field->hasReference() && $value === '') {
            return null;
        }

        if ($value !== null && $field instanceof PasswordField && !$field->hashPasswordIsHashed($value)) {
            $value = $field->hashPassword($value);
        }

        return $value;
    }

    /**
     * Override parent method to ignore key change by Field::actual property.
     */
    #[\Override]
    public function typecastSaveRow(Model $model, array $row): array
    {
        $result = [];
        foreach ($row as $key => $value) {
            $result[$key] = $this->typecastSaveField($model->getField($key), $value);
        }

        return $result;
    }

    /**
     * @param mixed $value
     */
    public function typecastAttributeSaveField(Field $field, $value): ?string
    {
        return $this->attributePersistence->typecastSaveField($field, $value);
    }

    /**
     * @return mixed
     */
    public function typecastAttributeLoadField(Field $field, ?string $value)
    {
        return $this->attributePersistence->typecastLoadField($field, $value);
    }
}