gdbots/pbj-php

View on GitHub
src/AbstractMessage.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
declare(strict_types=1);

namespace Gdbots\Pbj;

use Gdbots\Pbj\Exception\FrozenMessageIsImmutable;
use Gdbots\Pbj\Exception\LogicException;
use Gdbots\Pbj\Exception\RequiredFieldNotSet;
use Gdbots\Pbj\Serializer\PhpArraySerializer;
use Gdbots\Pbj\WellKnown\MessageRef;
use Gdbots\Pbj\WellKnown\NodeRef;

abstract class AbstractMessage implements Message, \JsonSerializable
{
    /** @var Schema[] */
    private static array $schemas = [];
    private static ?PhpArraySerializer $serializer = null;
    protected array $data = [];
    protected array $decoded = [];

    /** @see Message::freeze */
    private bool $isFrozen = false;

    /** @see Message::isReplay */
    private ?bool $isReplay = null;

    /**
     * Nothing fancy on new messages... we let the serializers or application code get fancy.
     */
    final public function __construct()
    {
    }

    final public static function schema(): Schema
    {
        $class = static::class;
        if (!isset(self::$schemas[$class])) {
            self::$schemas[$class] = static::defineSchema();
        }

        return self::$schemas[$class];
    }

    abstract protected static function defineSchema(): Schema;

    final public static function create(): static
    {
        return (new static())->populateDefaults();
    }

    final public static function fromArray(array $data = []): static
    {
        if (!isset($data[Schema::PBJ_FIELD_NAME])) {
            $data[Schema::PBJ_FIELD_NAME] = static::schema()->getId()->toString();
        }

        return self::getSerializer()->deserialize($data);
    }

    final public function toArray(): array
    {
        return self::getSerializer()->serialize($this);
    }

    final public function __toString()
    {
        return json_encode($this, JSON_PRETTY_PRINT);
    }

    final public function jsonSerialize(): array
    {
        return $this->toArray();
    }

    final public function __sleep()
    {
        return ['data'];
    }

    final public function __wakeup()
    {
        $this->decoded = [];
        $this->isFrozen = false;
        $this->isReplay = null;
    }

    final public function __clone()
    {
        $this->data = unserialize(serialize($this->data));
        $this->decoded = [];
        $this->isFrozen = false;
        $this->isReplay = null;
    }

    final public function generateEtag(array $ignoredFields = []): string
    {
        $array = $this->toArray();

        if (empty($ignoredFields)) {
            return md5(json_encode($array));
        }

        foreach ($ignoredFields as $field) {
            unset($array[$field]);
        }

        return md5(json_encode($array));
    }

    public function generateMessageRef(?string $tag = null): MessageRef
    {
        return new MessageRef(static::schema()->getCurie(), 'null', $tag);
    }

    public function generateNodeRef(): NodeRef
    {
        return NodeRef::fromNode($this);
    }

    public function getUriTemplateVars(): array
    {
        return [];
    }

    final public function validate(bool $strict = false, bool $recursive = false): static
    {
        if (!$strict && $this->isFrozen()) {
            return $this;
        }

        if (!$strict) {
            foreach (static::schema()->getRequiredFields() as $field) {
                if (!$this->has($field->getName())) {
                    throw new RequiredFieldNotSet($this, $field);
                }
            }
        } else {
            foreach (static::schema()->getFields() as $field) {
                if ($field->isRequired() && !$this->has($field->getName())) {
                    throw new RequiredFieldNotSet($this, $field);
                }

                // just getting the field will decode/guard the values
                $this->get($field->getName());
            }
        }

        if (!$recursive) {
            return $this;
        }

        foreach ($this->getNestedMessages() as $message) {
            $message->validate($strict, $recursive);
        }

        return $this;
    }

    final public function freeze(bool $withStrictValidation = true): static
    {
        if ($this->isFrozen()) {
            return $this;
        }

        $this->validate($withStrictValidation);
        $this->isFrozen = true;

        foreach ($this->getNestedMessages() as $message) {
            $message->freeze($withStrictValidation);
        }

        return $this;
    }

    final public function isFrozen(): bool
    {
        return $this->isFrozen;
    }

    /**
     * Ensures a frozen message can't be modified.
     *
     * @throws FrozenMessageIsImmutable
     */
    private function guardFrozenMessage(): void
    {
        if ($this->isFrozen) {
            throw new FrozenMessageIsImmutable($this);
        }
    }

    /**
     * {@inheritdoc}
     * This could probably use some work.  :)  low level serialization string match.
     */
    public function equals(Message $other): bool
    {
        return json_encode($this) === json_encode($other);
    }

    final public function isReplay(?bool $replay = null): bool
    {
        if (null === $replay) {
            if (null === $this->isReplay) {
                $this->isReplay = false;
            }
            return $this->isReplay;
        }

        if (null === $this->isReplay) {
            $this->isReplay = (bool)$replay;
            if ($this->isReplay) {
                $this->freeze();
            }
            return $this->isReplay;
        }

        throw new LogicException('You can only set the replay mode on one time.');
    }

    final public function populateDefaults(?string $fieldName = null): static
    {
        $this->guardFrozenMessage();

        if ($fieldName) {
            $this->populateDefault(static::schema()->getField($fieldName));
            return $this;
        }

        foreach (static::schema()->getFields() as $field) {
            $this->populateDefault($field);
        }

        return $this;
    }

    /**
     * Populates the default on a single field if it's not already set
     * and the default generated is not a null value or empty array.
     *
     * @param Field $field
     *
     * @return bool Returns true if a non null/empty default was applied or already present.
     */
    private function populateDefault(Field $field): bool
    {
        $fieldName = $field->getName();

        if ($this->has($fieldName)) {
            return true;
        }

        $default = $field->getDefault($this);
        if (null === $default) {
            return false;
        }

        if ($field->isASingleValue()) {
            $this->decoded[$fieldName] = $default;
            $this->data[$fieldName] = $this->encodeValue($default, $field);
            return true;
        }

        if (empty($default)) {
            return false;
        }

        /*
         * sets have a special handling to deal with unique values
         */
        if ($field->isASet()) {
            $this->addToSet($fieldName, $default);
            return true;
        }

        $this->decoded[$fieldName] = $default;
        $this->data[$fieldName] = $this->encodeValue($default, $field);
        return true;
    }

    final public function setWithoutValidation(string $fieldName, $value): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);

        if (null === $value) {
            return $this->clear($fieldName);
        }

        unset($this->decoded[$fieldName]);

        if ($field->isASet()) {
            $this->data[$fieldName] = [];
            foreach ($value as $v) {
                $this->data[$fieldName][strtolower(trim((string)$v))] = $v;
            }
            return $this;
        }

        $this->data[$fieldName] = $value;
        return $this;
    }

    final public function has(string $fieldName): bool
    {
        if (!isset($this->data[$fieldName])) {
            return false;
        }

        if (is_array($this->data[$fieldName])) {
            return !empty($this->data[$fieldName]);
        }

        return true;
    }

    final public function get(string $fieldName, mixed $default = null): mixed
    {
        if (!$this->has($fieldName)) {
            return $default;
        }

        $field = static::schema()->getField($fieldName);
        if ($this->hasDecoded($fieldName)) {
            return $field->isASet() ? array_values($this->decoded[$fieldName]) : $this->decoded[$fieldName];
        }

        if ($field->isASingleValue()) {
            $decoded = $this->decodeValue($this->data[$fieldName], $field);
        } else {
            $decoded = array_map(function ($value) use ($fieldName, $field) {
                return $this->decodeValue($value, $field);
            }, $this->data[$fieldName]);
        }

        $this->decoded[$fieldName] = $decoded;
        return $field->isASet() ? array_values($this->decoded[$fieldName]) : $this->decoded[$fieldName];
    }

    final public function fget(string $fieldName, mixed $default = null): mixed
    {
        if (!$this->has($fieldName)) {
            return $default;
        }

        $field = static::schema()->getField($fieldName);
        if ($field->isASet()) {
            return array_values($this->data[$fieldName]);
        }

        return $this->data[$fieldName];
    }

    final public function clear(string $fieldName): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        unset($this->decoded[$fieldName]);
        unset($this->data[$fieldName]);
        $this->populateDefault($field);
        return $this;
    }

    final public function set(string $fieldName, mixed $value): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isASingleValue(), 'Field must be a single value.', $fieldName);

        if (null === $value) {
            return $this->clear($fieldName);
        }

        $field->guardValue($value);
        $this->decoded[$fieldName] = $value;
        $this->data[$fieldName] = $this->encodeValue($value, $field);
        return $this;
    }

    final public function isInSet(string $fieldName, mixed $value): bool
    {
        if (!$this->has($fieldName)) {
            return false;
        }

        $v = $value instanceof \BackedEnum ? $value->value : $value;
        return isset($this->data[$fieldName][strtolower(trim((string)$v))]);
    }

    final public function addToSet(string $fieldName, array $values): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isASet(), 'Field must be a set.', $fieldName);

        unset($this->decoded[$fieldName]);

        foreach ($values as $value) {
            $v = $value instanceof \BackedEnum ? $value->value : $value;
            $key = strtolower(trim((string)$v));
            if (0 === strlen($key)) {
                continue;
            }

            $field->guardValue($value);
            $this->data[$fieldName][$key] = $this->encodeValue($value, $field);
        }

        return $this;
    }

    final public function removeFromSet(string $fieldName, array $values): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isASet(), 'Field must be a set.', $fieldName);

        unset($this->decoded[$fieldName]);

        foreach ($values as $value) {
            $v = $value instanceof \BackedEnum ? $value->value : $value;
            $key = strtolower(trim((string)$v));
            if (0 === strlen($key)) {
                continue;
            }

            unset($this->data[$fieldName][$key]);
        }

        return $this;
    }

    final public function isInList(string $fieldName, mixed $value): bool
    {
        if (!$this->has($fieldName)) {
            return false;
        }

        return in_array($value, $this->get($fieldName));
    }

    final public function getFromListAt(string $fieldName, int $index, mixed $default = null): mixed
    {
        if (!$this->has($fieldName)) {
            return $default;
        }

        $values = $this->get($fieldName);
        return $values[$index] ?? $default;
    }

    final public function addToList(string $fieldName, array $values): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isAList(), 'Field must be a list.', $fieldName);

        unset($this->decoded[$fieldName]);

        foreach ($values as $value) {
            $field->guardValue($value);
            $this->data[$fieldName][] = $this->encodeValue($value, $field);
        }

        return $this;
    }

    final public function removeFromListAt(string $fieldName, int $index): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isAList(), 'Field must be a list.', $fieldName);

        if (empty($this->data[$fieldName])) {
            return $this;
        }

        unset($this->decoded[$fieldName]);
        array_splice($this->data[$fieldName], $index, 1);
        if (empty($this->data[$fieldName])) {
            return $this;
        }

        // reset the numerical indexes
        $this->data[$fieldName] = array_values($this->data[$fieldName]);
        return $this;
    }

    final public function isInMap(string $fieldName, string $key): bool
    {
        if (!$this->has($fieldName)) {
            return false;
        }

        return isset($this->data[$fieldName][$key]);
    }

    final public function getFromMap(string $fieldName, string $key, mixed $default = null): mixed
    {
        if (!$this->isInMap($fieldName, $key)) {
            return $default;
        }

        $values = $this->get($fieldName);
        return $values[$key] ?? $default;
    }

    final public function addToMap(string $fieldName, string $key, mixed $value): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isAMap(), 'Field must be a map.', $fieldName);

        if (null === $value) {
            return $this->removeFromMap($fieldName, $key);
        }

        unset($this->decoded[$fieldName]);
        $field->guardValue($value);
        $this->data[$fieldName][$key] = $this->encodeValue($value, $field);

        return $this;
    }

    final public function removeFromMap(string $fieldName, string $key): static
    {
        $this->guardFrozenMessage();
        $field = static::schema()->getField($fieldName);
        Assertion::true($field->isAMap(), 'Field must be a map.', $fieldName);

        unset($this->decoded[$fieldName]);
        unset($this->data[$fieldName][$key]);
        return $this;
    }

    private static function getSerializer(): PhpArraySerializer
    {
        if (null === self::$serializer) {
            self::$serializer = new PhpArraySerializer();
        }

        return self::$serializer;
    }

    private function hasDecoded(string $fieldName): bool
    {
        return isset($this->decoded[$fieldName]);
    }

    private function encodeValue(mixed $value, Field $field): mixed
    {
        $type = $field->getType();
        if ($type->isMessage()) {
            return $value;
        }

        return $type->encode($value, $field, self::getSerializer());
    }

    private function decodeValue(mixed $value, Field $field): mixed
    {
        $decoded = $field->getType()->decode($value, $field, self::getSerializer());
        $field->guardValue($decoded);
        return $decoded;
    }

    /**
     * @return self[]
     */
    private function getNestedMessages(): array
    {
        $messages = [];
        foreach (static::schema()->getFields() as $field) {
            if ($field->getType()->isMessage()) {
                /** @var self $value */
                $value = $this->fget($field->getName());
                if (empty($value)) {
                    continue;
                }

                if ($value instanceof self) {
                    $messages[] = $value;
                    continue;
                }

                /** @var self $v */
                foreach ($value as $v) {
                    $messages[] = $v;
                }
            }
        }

        return $messages;
    }
}