Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.97% covered (success)
96.97%
128 / 132
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SnowflakeIdentifier
96.97% covered (success)
96.97%
128 / 132
62.50% covered (warning)
62.50%
5 / 8
43
0.00% covered (danger)
0.00%
0 / 1
 getSeparator
n/a
0 / 0
n/a
0 / 0
0
 allowsSeparatorInLastSegment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMinimumNumberOfSegments
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 toNative
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromNative
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
10.05
 getRegularExpression
98.33% covered (success)
98.33%
59 / 60
0.00% covered (danger)
0.00%
0 / 1
15
 createRegex
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2namespace Apie\Core\ValueObjects;
3
4use Apie\Core\Exceptions\InvalidTypeException;
5use Apie\Core\RegexUtils;
6use Apie\Core\Utils\ConverterUtils;
7use Apie\Core\ValueObjects\Exceptions\InvalidStringForValueObjectException;
8use Apie\Core\ValueObjects\Interfaces\HasRegexValueObjectInterface;
9use Apie\Core\ValueObjects\Interfaces\ValueObjectInterface;
10use Apie\RegexTools\CompiledRegularExpression;
11use ReflectionClass;
12use ReflectionNamedType;
13
14abstract class SnowflakeIdentifier implements ValueObjectInterface, HasRegexValueObjectInterface
15{
16    private string $calculated;
17
18    abstract protected static function getSeparator(): string;
19
20    /**
21     * Lets the last segment contain the separator. fromNative() already
22     * splits with a limit, so such values can be parsed back.
23     */
24    protected static function allowsSeparatorInLastSegment(): bool
25    {
26        return false;
27    }
28
29    final protected static function getMinimumNumberOfSegments(): int
30    {
31        $refl = new ReflectionClass(static::class);
32        $parameters = $refl->getConstructor()->getParameters();
33        $parameters = array_reverse($parameters);
34        $count = count($parameters) - 1;
35        foreach ($parameters as $parameter) {
36            if (!$parameter->isOptional() || !$parameter->getType()?->allowsNull()) {
37                return $count;
38            }
39            $count--;
40        }
41
42        return 0;
43    }
44
45    final public function toNative(): string
46    {
47        if (!isset($this->calculated)) {
48            $prefix = '';
49            if (is_callable([static::class, 'getPrefix'])) {
50                $prefix = static::getPrefix() . static::getSeparator();
51            }
52            $refl = new ReflectionClass($this);
53            $separator = static::getSeparator();
54            $result = [];
55            $minCount = static::getMinimumNumberOfSegments();
56            $count = 0;
57            $parameters = $refl->getConstructor()->getParameters();
58            $lastIndex = count($parameters) - 1;
59            foreach ($parameters as $index => $parameter) {
60                $propertyName = $parameter->getName();
61                $propertyValue = $refl->getProperty($propertyName)->getValue($this);
62                $stringPropertyValue = get_debug_type($propertyValue) === 'float'
63                    ? number_format($propertyValue, 6, '.', '')
64                    : Utils::toString($propertyValue);
65                $isFreeSegment = $index === $lastIndex && static::allowsSeparatorInLastSegment();
66                if (!$isFreeSegment && strpos($stringPropertyValue, $separator) !== false) {
67                    throw new InvalidStringForValueObjectException($stringPropertyValue, $propertyValue);
68                }
69                if ($propertyValue !== null || $count < $minCount) {
70                    $result[] = $stringPropertyValue;
71                }
72                $count++;
73            }
74
75            $this->calculated = $prefix . implode($separator, $result);
76        }
77        return $this->calculated;
78    }
79
80    final public function __toString(): string
81    {
82        return $this->toNative();
83    }
84
85    final public function jsonSerialize(): string
86    {
87        return $this->toNative();
88    }
89
90    public static function fromNative(mixed $input): self
91    {
92        $input = Utils::toString($input);
93        $prefix = '';
94        if (is_callable([static::class, 'getPrefix'])) {
95            $prefix = static::getPrefix() . static::getSeparator();
96        }
97        if (strpos($input, $prefix) === 0) {
98            $input = substr($input, strlen($prefix));
99        } else {
100            throw new InvalidStringForValueObjectException($input, new ReflectionClass(static::class));
101        }
102        $refl = new ReflectionClass(static::class);
103        $parameters = $refl->getConstructor()->getParameters();
104        $separator = static::getSeparator();
105        $split = explode($separator, $input, count($parameters));
106        $minCount = static::getMinimumNumberOfSegments();
107        $maxCount = count($parameters);
108        if (count($split) < $minCount || count($split) > $maxCount) {
109            throw new InvalidStringForValueObjectException($input, new ReflectionClass(static::class));
110        }
111        $constructorArguments = [];
112    
113        foreach ($parameters as $key => $parameter) {
114            $parameterType = $parameter->getType();
115            if (!($parameterType instanceof ReflectionNamedType)) {
116                throw new InvalidTypeException($parameterType, 'ReflectionNamedType');
117            }
118            $splitValue = $split[$key] ?? null;
119            if ($parameterType->allowsNull() && ($splitValue === '' || $splitValue === null)) {
120                $constructorArguments[] = null;
121            } else {
122                $constructorArguments[] = Utils::toTypehint($parameterType, $splitValue ?? '');
123            }
124        }
125        return $refl->newInstanceArgs($constructorArguments);
126    }
127
128    final public static function getRegularExpression(): string
129    {
130        $refl = new ReflectionClass(static::class);
131        $parameters = $refl->getConstructor()->getParameters();
132        $separator = preg_quote(static::getSeparator());
133
134        $expressions = [];
135        $optionalExpressions = [];
136        $prefix = '';
137        if (is_callable([static::class, 'getPrefix'])) {
138            $prefix = static::getPrefix() . static::getSeparator();
139        }
140        if ($prefix !== '') {
141            $expressions[] = preg_quote($prefix, static::getSeparator());
142        }
143        $addedOptionalMarker = false;
144        $addedSeparator = false;
145        $addedOptionalSeparator = false;
146        foreach ($parameters as $parameter) {
147            $parameterType = $parameter->getType();
148            if (!($parameterType instanceof ReflectionNamedType)) {
149                throw new InvalidTypeException($parameterType, 'ReflectionNamedType');
150            }
151            if ($parameter->isOptional()) {
152                $addedOptionalMarker = true;
153            }
154            $regex = '[^' . $separator . ']+';
155            $class = ConverterUtils::toReflectionClass($parameterType);
156            if (in_array(HasRegexValueObjectInterface::class, $class?->getInterfaceNames() ?? [])) {
157                $foundRegex = '(' . RegexUtils::removeDelimiters($class->getMethod('getRegularExpression')->invoke(null)) . ')';
158                if (strpos($foundRegex, '?=') === false) {
159                    $regex = $foundRegex;
160                }
161            } else {
162                switch ($parameterType->getName()) {
163                    case 'int':
164                        $regex = '-?(0|[1-9]\d*)';
165                        break;
166                    case 'float':
167                        $regex = '-?(0|[1-9]\d*)(\.\d+)?';
168                        break;
169                }
170            }
171            if ($addedOptionalMarker) {
172                $optionalExpressions[] = $regex;
173                $optionalExpressions[] = $separator;
174                $addedOptionalSeparator = true;
175            } else {
176                $expressions[] = $regex;
177                $expressions[] = $separator;
178                $addedSeparator = true;
179            }
180        }
181        if ($addedSeparator) {
182            $last = array_pop($expressions);
183            if (!empty($optionalExpressions)) {
184                array_unshift($optionalExpressions, $last);
185            }
186        }
187        if ($addedOptionalSeparator) {
188            array_pop($optionalExpressions);
189        }
190
191        $requiredExpression = self::createRegex($expressions);
192        $optionalExpression = self::createRegex($optionalExpressions);
193        if ($optionalExpressions) {
194            $optionalExpression = '(' . $optionalExpression . ')?';
195        }
196
197        $tmp = CompiledRegularExpression::createFromRegexWithoutDelimiters('');
198        $expressions = array_map(
199            function (string $expression) {
200                return CompiledRegularExpression::createFromRegexWithoutDelimiters($expression)
201                            ->removeStartAndEndMarkers();
202            },
203            [$requiredExpression, $optionalExpression]
204        );
205        array_unshift($expressions, CompiledRegularExpression::createFromRegexWithoutDelimiters('^'));
206        array_push($expressions, CompiledRegularExpression::createFromRegexWithoutDelimiters('$'));
207
208        return $tmp->merge(...$expressions)->__toString();
209    }
210
211    /**
212     * @param array<int, string> $expressions
213     */
214    private static function createRegex(array $expressions): string
215    {
216        $expressions = array_map(
217            function (string $expression) {
218                return CompiledRegularExpression::createFromRegexWithoutDelimiters($expression)
219                            ->removeStartAndEndMarkers();
220            },
221            $expressions
222        );
223        array_unshift($expressions, CompiledRegularExpression::createFromRegexWithoutDelimiters('^'));
224        array_push($expressions, CompiledRegularExpression::createFromRegexWithoutDelimiters('$'));
225
226        return implode('', $expressions);
227    }
228}