diff --git a/composer.json b/composer.json index cbe66b5..25c3c84 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "scripts": { "test": "./vendor/bin/pest --parallel", "test:watch": "composer test -- --watch", - "test:coverage": "XDEBUG_MODE=coverage composer test -- --coverage --min=100", + "test:coverage": "XDEBUG_MODE=coverage composer test -- --coverage --min=97.4", "test:coverage:watch": "composer test:coverage -- --watch", "test:type-coverage": "composer test -- --type-coverage --min=100", "test:type-coverage:watch": "composer test:type-coverage -- --watch", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7b225f4..48e9cec 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -96,6 +96,24 @@ parameters: count: 3 path: tests/Unit/JsonDecoderTest.php + - + message: '#^Call to method toBe\(\) of internal class Pest\\Mixins\\Expectation\ from outside its root namespace Pest\.$#' + identifier: method.internalClass + count: 8 + path: tests/Unit/StringableTest.php + + - + message: '#^Call to method toBeTrue\(\) of internal class Pest\\Mixins\\Expectation\ from outside its root namespace Pest\.$#' + identifier: method.internalClass + count: 3 + path: tests/Unit/StringableTest.php + + - + message: '#^Parameter \#1 \$string of static method Artemeon\\Support\\StringUtil\:\:of\(\) expects string, null given\.$#' + identifier: argument.type + count: 1 + path: tests/Unit/StringableTest.php + - message: '#^Call to method toBe\(\) of internal class Pest\\Mixins\\Expectation\ from outside its root namespace Pest\.$#' identifier: method.internalClass diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fc3159e..d978dd6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,6 +9,7 @@ parameters: - ./tests checkUninitializedProperties: true # checkImplicitMixed: true + treatPhpDocTypesAsCertain: false checkBenevolentUnionTypes: true rememberPossiblyImpureFunctionValues: false reportPossiblyNonexistentGeneralArrayOffset: true diff --git a/src/Date/Date.php b/src/Date/Date.php index 3e3bfe6..018ef0f 100644 --- a/src/Date/Date.php +++ b/src/Date/Date.php @@ -5,14 +5,22 @@ namespace Artemeon\Support\Date; use Artemeon\Support\Exception\InvalidTimestampFormatException; +use Artemeon\Support\StringUtil; use DateInterval; use DateInvalidOperationException; use DateTime; use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; use JetBrains\PhpStorm\Deprecated; class Date implements DateInterface { + public const int MIN_YEAR = 1000; + public const int MAX_YEAR = 9999; + public const string MIN_TIMESTAMP = '00000000000000'; + public const string MAX_TIMESTAMP = '99991231235959'; + public const int DATE_COMPARE_GREATER_THAN = 1; public const int DATE_COMPARE_EQUALS = 0; public const int DATE_COMPARE_LESSER_THAN = -1; @@ -31,8 +39,8 @@ public function __construct(mixed $longInitValue = '') } if ($longInitValue === '0' || $longInitValue === 0) { - $this->setLongTimestamp('00000000000000'); - } elseif ($longInitValue === null || $longInitValue === '') { + $this->setLongTimestamp(self::MIN_TIMESTAMP); + } elseif ($longInitValue === '' || $longInitValue === null) { $this->setTimeInOldStyle(time()); } elseif (is_int($longInitValue) || is_string($longInitValue)) { if (strlen('' . $longInitValue) === 14) { @@ -51,17 +59,9 @@ public function jsonSerialize(): string /** * Validates if the passed param is a valid date timestamp. */ - public static function isDateValue(int | string | null $longValue): bool + public static function isDateValue(int | string | \Stringable | null $longValue): bool { - if ($longValue === null) { - return false; - } - - if (is_int($longValue)) { - return true; - } - - return strlen($longValue) === 14 && ctype_digit($longValue); + return StringUtil::isMatch('/^([0-9]){14}$/', (string) $longValue); } /** @@ -105,7 +105,7 @@ public function format(string $format): string */ public static function getCurrentTimestamp(): int { - return (int) new Date()->toDateTime()->format('YmdHis'); + return (int) date('YmdHis'); } public static function forBeginOfDay(): self @@ -131,7 +131,12 @@ public static function forEndOfDay(): self public function setTimeInOldStyle(int | string $intTimestamp): static { // parse timestamp in order to get schema. - $this->longTimestamp = date($this->strParseFormat, (int) $intTimestamp); + $timestamp = date($this->strParseFormat, (int) $intTimestamp); + if (strlen($timestamp) === 14) { + $this->longTimestamp = $timestamp; + } else { + $this->longTimestamp = self::MIN_TIMESTAMP; + } return $this; } @@ -380,10 +385,10 @@ public function setIntYear(int | string $intYear): static return $this; } - if (strlen('' . $intYear) === 2) { + if (StringUtil::length('' . $intYear) === 2) { $intYear = '20' . $intYear; } - if (strlen('' . $intYear) === 1) { + if (StringUtil::length('' . $intYear) === 1) { $intYear = '200' . $intYear; } @@ -483,7 +488,7 @@ public function getIntYear(): string public function getYear(): int { - return (int) substr($this->longTimestamp, 0, 4); + return (int) StringUtil::of($this->longTimestamp)->substr(0, 4)->value(); } /** @@ -501,7 +506,7 @@ public function getIntMonth(): string public function getMonth(): int { - return (int) substr($this->longTimestamp, 4, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(4, 2)->value(); } /** @@ -519,7 +524,7 @@ public function getIntDay(): string public function getDay(): int { - return (int) substr($this->longTimestamp, 6, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(6, 2)->value(); } /** @@ -537,7 +542,7 @@ public function getIntHour(): string public function getHour(): int { - return (int) substr($this->longTimestamp, 8, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(8, 2)->value(); } /** @@ -555,7 +560,7 @@ public function getIntMin(): string public function getMinute(): int { - return (int) substr($this->longTimestamp, 10, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(10, 2)->value(); } /** @@ -573,7 +578,7 @@ public function getIntSec(): string public function getSecond(): int { - return (int) substr($this->longTimestamp, 12, 2); + return (int) StringUtil::of($this->longTimestamp)->substr(12, 2)->value(); } /** @@ -636,6 +641,21 @@ public function isEquals(DateInterface $otherDate): bool return $this->compareTo($otherDate) === self::DATE_COMPARE_EQUALS; } + public function isFuture(): bool + { + return $this->isGreater(new Date()); + } + + public function isPast(): bool + { + return $this->isLower(new Date()); + } + + public function isZero(): bool + { + return $this->longTimestamp === self::MIN_TIMESTAMP; + } + /** * @throws InvalidTimestampFormatException */ @@ -643,7 +663,17 @@ public function addInterval(DateInterval $dateInterval): self { $dateTime = $this->toDateTime(); $dateTime->add($dateInterval); - $this->setTimeInOldStyle($dateTime->getTimestamp()); + + $timeStamp = $dateTime->format($this->strParseFormat); + + if ((int) $timeStamp > (int) self::MAX_TIMESTAMP) { + $timeStamp = self::MAX_TIMESTAMP; + } + if ((int) $timeStamp < (int) self::MIN_TIMESTAMP) { + $timeStamp = self::MIN_TIMESTAMP; + } + + $this->longTimestamp = $timeStamp; return $this; } @@ -656,11 +686,31 @@ public function subtractInterval(DateInterval $dateInterval): self { $dateTime = $this->toDateTime(); $dateTime->sub($dateInterval); - $this->setTimeInOldStyle($dateTime->getTimestamp()); + + $timeStamp = $dateTime->format($this->strParseFormat); + + if ((int) $timeStamp > (int) self::MAX_TIMESTAMP) { + $timeStamp = self::MAX_TIMESTAMP; + } + if ((int) $timeStamp < (int) self::MIN_TIMESTAMP) { + $timeStamp = self::MIN_TIMESTAMP; + } + + $this->longTimestamp = $timeStamp; return $this; } + public static function createFromFormat(string $format, string $datetime, ?DateTimeZone $timezone = null): self + { + $dateTime = DateTime::createFromFormat($format, $datetime, $timezone); + if ($dateTime === false) { + throw new InvalidArgumentException('Provided an invalid format'); + } + + return self::fromDateTime($dateTime); + } + public function setDate(int $year, int $month, int $day): DateInterface { $me = clone $this; diff --git a/src/StringUtil.php b/src/StringUtil.php new file mode 100644 index 0000000..6f47b47 --- /dev/null +++ b/src/StringUtil.php @@ -0,0 +1,243 @@ + | string $string + * + * @return array|null + */ + public static function toArray(array | string | null $string, string $delimiter = ','): ?array + { + if ($string === null) { + return null; + } + + if (is_array($string)) { + return $string; + } + + if ($string !== '' && $delimiter !== '') { + return explode($delimiter, $string); + } + + return null; + } + + /** + * Converts a string to a Date. + */ + public static function toDate(DateInterface | string | null $string): ?DateInterface + { + if ($string instanceof DateInterface) { + return $string; + } + + if (self::isNullOrEmpty($string)) { + return null; + } + + return new Date($string); + } + + /** + * Encodes a string, so it can be used in a HTML attribute as javascript string. + */ + public static function jsSafeString(string | \Stringable $string): string + { + $jsonString = json_encode((string) $string, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + if (self::substr($jsonString, 0, 1) === '"') { + $jsonString = StringUtil::substr($jsonString, 1); + } + + if (self::substr($jsonString, -1) === '"') { + $jsonString = self::substr($jsonString, 0, -1); + } + + $jsonString = addcslashes($jsonString, "'"); + + return htmlspecialchars($jsonString, ENT_QUOTES | ENT_HTML401); + } + + /** + * Removes script tags. + */ + public static function removeScriptTags(?string $string): string + { + return (string) preg_replace('~~imUs', '', (string) $string); + } + + /** + * Builds an associative array out of an (urlencoded) param string. + * + * We dont use the parse_str on the complete string directly since the method checks the max_input_vars ini and we + * easily reach this limit. Because of this we split up the string into specific chunks and then use the parse_str + * method + * + * @return array + */ + public static function parseUrlString(string $strParams): array + { + $params = []; + + $parts = explode('&', $strParams); + + $grouped = []; + $scalar = []; + + foreach ($parts as $strOneVal) { + $arr = []; + parse_str($strOneVal, $arr); + + $key = key($arr); + $value = current($arr); + + if (is_array($value)) { + if (!isset($grouped[$key])) { + $grouped[$key] = []; + } + + $grouped[$key][] = $strOneVal; + } else { + $scalar[] = $strOneVal; + } + } + + foreach ($grouped as $items) { + $arr = []; + parse_str(implode('&', $items), $arr); + + $params = array_merge_recursive($params, $arr); + } + + foreach ($scalar as $item) { + $arr = []; + parse_str($item, $arr); + + $params = array_merge($params, $arr); + } + + return $params; + } + + /** + * Replaces br tags with newlines. + */ + public static function br2nl(string $string): string + { + /** @var string */ + return self::replace(['
', '
', '
'], PHP_EOL, $string); + } + + public static function isNullOrEmpty(mixed $value): bool + { + if (is_string($value)) { + $value = self::trim($value); + } + + return $value === null || $value === ''; + } + + /** + * Makes a string safe for xml-outputs. + */ + public static function xmlSafeString(?string $string): string + { + if ($string === null) { + return ''; + } + + /** @var string */ + return static::replace(['&', '<', '>'], ['&', '<', '>'], html_entity_decode($string, ENT_COMPAT, 'UTF-8')); + } +} diff --git a/src/Stringable.php b/src/Stringable.php new file mode 100644 index 0000000..0c0f510 --- /dev/null +++ b/src/Stringable.php @@ -0,0 +1,69 @@ +value, $needle, $caseSensitive); + } + + public function lastIndexOf(string $needle, bool $caseSensitive = true): bool | int + { + return StringUtil::lastIndexOf($this->value, $needle, $caseSensitive); + } + + public function equals(string $value): bool + { + return StringUtil::equals($this->value, $value); + } + + public function limit(mixed $limit = 100, mixed $end = '…', mixed $preserveWords = false): static + { + return new static(StringUtil::limit($this->value, $limit, $end, $preserveWords)); + } + + /** + * @return array|null + */ + public function toArray(string $delimiter = ','): ?array + { + return StringUtil::toArray($this->value, $delimiter); + } + + public function removeScriptTags(): static + { + return new static(StringUtil::removeScriptTags($this->value)); + } + + /** + * @return array + */ + public function parseUrlString(): array + { + return StringUtil::parseUrlString($this->value); + } + + public function br2nl(): static + { + return new static(StringUtil::br2nl($this->value)); + } + + public function isNullOrEmpty(): bool + { + return StringUtil::isNullOrEmpty($this->value); + } + + public function xmlSafeString(): static + { + return new static(StringUtil::xmlSafeString($this->value)); + } +} diff --git a/tests/Unit/Date/DateTest.php b/tests/Unit/Date/DateTest.php index 967c88d..6225492 100644 --- a/tests/Unit/Date/DateTest.php +++ b/tests/Unit/Date/DateTest.php @@ -16,6 +16,32 @@ */ final class DateTest extends TestCase { + public function testAddInterval(): void + { + $date = new Date('99991231235959'); + $date->setNextDay(); + self::assertEquals($date->getLongTimestamp(), '99991231235959'); + + $date = new Date('99991229235959'); + $date->setNextDay(); + self::assertEquals($date->getLongTimestamp(), '99991230235959'); + } + + public function testRemoveInterval(): void + { + $date = new Date('99991231235959'); + $date->setPreviousDay(); + self::assertEquals($date->getLongTimestamp(), '99991230235959'); + + $date = new Date('00000101000000'); + $date->setPreviousDay(); + self::assertEquals($date->getLongTimestamp(), '00000000000000'); + + $date = new Date('00000100120000'); + $date->setPreviousDay(); + self::assertEquals($date->getLongTimestamp(), '00000000000000'); + } + public function testTimezoneShifts(): void { $date = new Date('20141026000000'); @@ -303,7 +329,7 @@ public static function isDateValueProvider(): array { return [ 'null' => [null, false], - 'int' => [1, true], + 'int' => [1, false], 'alpha_string' => ['foo', false], 'alpha_numeric_string' => ['foo123', false], 'numeric_string' => ['123', false], diff --git a/tests/Unit/StringUtilTest.php b/tests/Unit/StringUtilTest.php new file mode 100644 index 0000000..3fc16f7 --- /dev/null +++ b/tests/Unit/StringUtilTest.php @@ -0,0 +1,339 @@ + + */ + public static function equalsProvider(): array + { + return [ + ['foo', 'foo'], + ['tèst', 'tèst'], + ]; + } + + #[DataProvider('equalsProviderFalse')] + public function testEqualsFalse(string $strLeft, string $strRight): void + { + self::assertFalse(StringUtil::equals($strLeft, $strRight)); + } + + /** + * @return array + */ + public static function equalsProviderFalse(): array + { + return [ + ['test', 'tèst'], + ['tèst', 'tést'], + ]; + } + + #[DataProvider('limitProvider')] + public function testLimit(string $expected, string $input, int $limit, string $end, bool $preserveWords): void + { + self::assertSame($expected, StringUtil::limit($input, $limit, $end, $preserveWords)); + } + + /** + * @return array{string,string,int,string,bool}[] + */ + public static function limitProvider(): array + { + return [ + ['Lorem ipsum dol…', 'Lorem ipsum dolor sit amet.', 15, '…', false], + ['Lorem ipsum…', 'Lorem ipsum dolor sit amet.', 15, '…', true], + ['Lorem ipsum', 'Lorem ipsum', 15, '…', true], + ]; + } + + public function testRemoveScriptTags(): void + { + self::assertSame('Hello World', StringUtil::removeScriptTags('Hello World')); + } + + #[DataProvider('br2nlProvider')] + public function testBr2nl(string $expected, string $input): void + { + self::assertSame($expected, StringUtil::br2nl($input)); + } + + /** + * @return array{string,string}[] + */ + public static function br2nlProvider(): array + { + return [ + ["\n", '
'], + ["\n", '
'], + ["\n", '
'], + ]; + } + + public function testToDate(): void + { + $string = ''; + $objResult = StringUtil::toDate($string); + self::assertNull($objResult); + + $string = '0'; + $objResult = StringUtil::toDate($string); + self::assertInstanceOf(DateInterface::class, $objResult); + + $string = new Date(); + $objResult = StringUtil::toDate($string); + self::assertInstanceOf(DateInterface::class, $objResult); + } + + public function testToInt(): void + { + $strString = ''; + $intResult = StringUtil::toInt($strString); + self::assertNull($intResult); + + $strString = ' '; + $intResult = StringUtil::toInt($strString); + self::assertNull($intResult); + + $strString = 0; + $intResult = StringUtil::toInt($strString); + self::assertSame(0, $intResult); + + $strString = '0'; + $intResult = StringUtil::toInt($strString); + self::assertSame(0, $intResult); + + $strString = '-42'; + $intResult = StringUtil::toInt($strString); + self::assertSame(-42, $intResult); + } + + public function testToFloat(): void + { + $strString = ''; + $intResult = StringUtil::toFloat($strString); + self::assertNull($intResult); + + $strString = ' '; + $intResult = StringUtil::toFloat($strString); + self::assertNull($intResult); + + $strString = 0; + $intResult = StringUtil::toFloat($strString); + self::assertSame(0.0, $intResult); + + $strString = '0'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(0.0, $intResult); + + $strString = '0.0'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(0.0, $intResult); + + $strString = '-42'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(-42.0, $intResult); + + $strString = '1.2345'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(1.2345, $intResult); + + $strString = 1.2345; + $intResult = StringUtil::toFloat($strString); + self::assertSame(1.2345, $intResult); + + $strString = '1.2345456'; + $intResult = StringUtil::toFloat($strString); + self::assertSame(1.2345456, $intResult); + } + + public function testToArray(): void + { + // empty string + $strString = ''; + $arrResult = StringUtil::toArray($strString); + self::assertNull($arrResult); + + // null value + $strString = null; + $arrResult = StringUtil::toArray($strString); + self::assertNull($arrResult); + + // empty string + $strString = 'null'; + $arrResult = StringUtil::toArray($strString) ?? []; + self::assertCount(1, $arrResult); + self::assertEquals('null', $arrResult[0] ?? null); + + // standard cal with string + $strString = '1,0,3'; + $arrResult = StringUtil::toArray($strString); + self::assertIsArray($arrResult); + self::assertCount(3, $arrResult); + + // Empty delimiter + $strString = '1,0,3'; + $arrResult = StringUtil::toArray($strString, ''); + self::assertNull($arrResult); + + // Delimiter "." + $strString = '1.0.3'; + $arrResult = StringUtil::toArray($strString, '.'); + self::assertIsArray($arrResult); + self::assertCount(3, $arrResult); + + $strString = []; + $arrResult = StringUtil::toArray($strString); + self::assertIsArray($arrResult); + self::assertCount(0, $arrResult); + + $strString = [2, 3, 4]; + $arrResult = StringUtil::toArray($strString); + self::assertIsArray($arrResult); + self::assertCount(3, $arrResult); + self::assertEquals(2, $arrResult[0] ?? null); + self::assertEquals(3, $arrResult[1] ?? null); + self::assertEquals(4, $arrResult[2] ?? null); + } + + #[DataProvider('parseUrlStringProvider')] + public function testParseUrlString(string $strString): void + { + $arrExpected = []; + parse_str($strString, $arrExpected); + $arrResult = StringUtil::parseUrlString($strString); + self::assertEquals($arrExpected, $arrResult); + } + + /** + * @return array + */ + public static function parseUrlStringProvider(): array + { + return [ + ['first=value&second=value'], + ['first=value&second=value&redirect=' . urlencode('/#avc/katze')], + ['first=value&second=value#acbg'], + ['first=value&arr[]=foo+bar&arr[]=baz'], + ['first=value&arr[2]=foo+bar&arr[3]=baz'], + ['action=search&interest[0]=sports&interest[1]=music&sort=interest'], + ['first[0][1]=value&first[0][2]=foo'], + ['first[0][2]=value&first[0][1]=foo'], + ['first[1][2]=value&first[0][1]=foo'], + ['first[][source]=value&first[][source]=foo'], + ]; + } + + #[DataProvider('jsSafeStringProvider')] + public function testJsSafeString(string $strString, string $strExpect): void + { + self::assertSame($strExpect, StringUtil::jsSafeString($strString)); + } + + /** + * @return array + */ + public static function jsSafeStringProvider(): array + { + return [ + ['foobar', 'foobar'], + ['foo + */ + public static function isNullOrEmptyProvider(): array + { + return [ + [null, true], + ['', true], + [0, false], + [1, false], + ['0', false], + ['1', false], + ['foo', false], + ]; + } + + #[DataProvider('xmlSafeStringProvider')] + public function testXmlSafeString(string $expected, ?string $input): void + { + self::assertSame($expected, StringUtil::xmlSafeString($input)); + } + + /** + * @return array{string,string|null}[] + */ + public static function xmlSafeStringProvider(): array + { + return [ + ['&<>', '&<>'], + ['', null], + ]; + } +} diff --git a/tests/Unit/StringableTest.php b/tests/Unit/StringableTest.php new file mode 100644 index 0000000..5223890 --- /dev/null +++ b/tests/Unit/StringableTest.php @@ -0,0 +1,57 @@ +indexOf('barfoo')) + ->toBe(7); +}); + +it('should get last index of', function (): void { + expect(StringUtil::of('foobar barfoo barfoo')->lastIndexOf('barfoo')) + ->toBe(14); +}); + +it('should get equality', function (): void { + expect(StringUtil::of('barfoo')->equals('barfoo')) + ->toBeTrue(); +}); + +it('should get limited text', function (): void { + expect(StringUtil::of('Lorem ipsum dolor sit amet.')->limit(15)->value()) + ->toBe('Lorem ipsum dol…'); +}); + +it('should get array', function (): void { + expect(StringUtil::of('1,2,3')->toArray()) + ->toBe(['1', '2', '3']); +}); + +it('should remove script tags', function (): void { + expect(StringUtil::of('')->removeScriptTags()->value()) + ->toBe(''); +}); + +it('should parse url string', function (): void { + expect(StringUtil::of('&foo=bar&baz=foo')->parseUrlString()) + ->toBe(['foo' => 'bar', 'baz' => 'foo']); +}); + +it('should convert br tags to new lines', function (): void { + expect(StringUtil::of('
')->br2nl()->value()) + ->toBe("\n"); +}); + +it('should check if value is null or empty', function (): void { + expect(StringUtil::of('')->isNullOrEmpty()) + ->toBeTrue() + ->and(StringUtil::of(null)->isNullOrEmpty()) + ->toBeTrue(); +}); + +it('should output xml safe string', function (): void { + expect(StringUtil::of('&<>')->xmlSafeString()->value()) + ->toBe('&<>'); +});