From cca83b7fc02225f5565e7aa4a696dcfee80ffe4e Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Sat, 31 Jan 2026 15:34:51 -0300 Subject: [PATCH] Introduce DateFormatter and NumberFormatter Introduce formatters for the typical use cases of formatting numbers and dates. Internally, those formatters use `number_format` and `date_format` respectively. If a date cannot be parsed in runtime, the formatter will return the input unmodified. Similarly, if some input is not numeric, the numeric formatter will return it unchanged. --- README.md | 3 + docs/DateFormatter.md | 229 +++++++++++++++++++++++++++++ docs/NumberFormatter.md | 174 ++++++++++++++++++++++ src/DateFormatter.php | 36 +++++ src/NumberFormatter.php | 38 +++++ tests/Unit/DateFormatterTest.php | 206 ++++++++++++++++++++++++++ tests/Unit/NumberFormatterTest.php | 174 ++++++++++++++++++++++ 7 files changed, 860 insertions(+) create mode 100644 docs/DateFormatter.md create mode 100644 docs/NumberFormatter.md create mode 100644 src/DateFormatter.php create mode 100644 src/NumberFormatter.php create mode 100644 tests/Unit/DateFormatterTest.php create mode 100644 tests/Unit/NumberFormatterTest.php diff --git a/README.md b/README.md index 83ee8f3..8a6828b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ SPDX-FileCopyrightText: (c) Respect Project Contributors SPDX-License-Identifier: ISC SPDX-FileContributor: Henrique Moody +SPDX-FileContributor: Alexandre Gomes Gaigalas --> # Respect\StringFormatter @@ -38,7 +39,9 @@ echo f::create() | Formatter | Description | | ---------------------------------------------------- | --------------------------------------------------- | +| [DateFormatter](docs/DateFormatter.md) | Date and time formatting with flexible parsing | | [MaskFormatter](docs/MaskFormatter.md) | Range-based string masking with Unicode support | +| [NumberFormatter](docs/NumberFormatter.md) | Number formatting with thousands and decimal separators | | [PatternFormatter](docs/PatternFormatter.md) | Pattern-based string filtering with placeholders | | [PlaceholderFormatter](docs/PlaceholderFormatter.md) | Template interpolation with placeholder replacement | diff --git a/docs/DateFormatter.md b/docs/DateFormatter.md new file mode 100644 index 0000000..3223e68 --- /dev/null +++ b/docs/DateFormatter.md @@ -0,0 +1,229 @@ + + +# DateFormatter + +The `DateFormatter` parses and reformats date and time strings using flexible input parsing and configurable output formats. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\DateFormatter; + +$formatter = new DateFormatter(); + +echo $formatter->format('2024-01-15 10:30:45'); +// Outputs: 2024-01-15 10:30:45 +``` + +### Custom Format + +```php +use Respect\StringFormatter\DateFormatter; + +$formatter = new DateFormatter('m/d/Y'); + +echo $formatter->format('2024-01-15'); +// Outputs: 01/15/2024 +``` + +### European Format + +```php +use Respect\StringFormatter\DateFormatter; + +$formatter = new DateFormatter('d.m.Y'); + +echo $formatter->format('2024-01-15'); +// Outputs: 15.01.2024 +``` + +### With Month Names + +```php +use Respect\StringFormatter\DateFormatter; + +$formatter = new DateFormatter('l, F j, Y'); + +echo $formatter->format('2024-01-15'); +// Outputs: Monday, January 15, 2024 +``` + +### ISO 8601 Format + +```php +use Respect\StringFormatter\DateFormatter; + +$formatter = new DateFormatter('c'); + +echo $formatter->format('2024-01-15T10:30:45+00:00'); +// Outputs: 2024-01-15T10:30:45+00:00 +``` + +## API + +### `DateFormatter::__construct` + +- `__construct(string $format = 'Y-m-d H:i:s')` + +Creates a new formatter instance with the specified date format. + +**Parameters:** + +- `$format`: PHP date format string (default: `'Y-m-d H:i:s'`) + +### `format` + +- `format(string $input): string` + +Parses the input date string and formats it according to the formatter's configuration. + +If the input cannot be parsed as a date, it is returned unchanged without modification. + +**Parameters:** + +- `$input`: A date string in any format parseable by `DateTime` + +**Returns:** The formatted date string if the input can be parsed; the input unchanged otherwise + +## Behavior + +### Strict Date Validation + +The formatter uses a two-level validation approach: + +1. **Exception handling**: Catches `DateMalformedStringException` thrown by invalid format strings +2. **Error checking**: Uses `DateTime::getLastErrors()` to detect parsing errors and warnings + +This ensures even dates that appear parseable but have parsing issues are rejected. + +### Valid Input + +The formatter uses PHP's `DateTime` constructor which supports various date formats including ISO 8601, MySQL format, relative formats (like "now", "tomorrow"), and other flexible formats. Valid input must parse without errors or warnings. + +```php +$formatter = new DateFormatter('Y-m-d'); + +// Valid date input +echo $formatter->format('2024-01-15'); // Outputs: 2024-01-15 +echo $formatter->format('2024-01-15 10:30:45'); // Outputs: 2024-01-15 +echo $formatter->format('January 15, 2024'); // Outputs: 2024-01-15 +``` + +### Invalid Input + +When input cannot be parsed as a date or has parsing errors/warnings, the formatter returns it unchanged: + +```php +$formatter = new DateFormatter('Y-m-d'); + +// Invalid input is returned unchanged +echo $formatter->format('invalid date'); // Outputs: invalid date +echo $formatter->format('this-is-not-a-date'); // Outputs: this-is-not-a-date +echo $formatter->format('N/A'); // Outputs: N/A +``` + +## Input Formats + +The formatter uses PHP's `DateTime` constructor which supports various input formats: + +### Standard Date Formats + +| Format | Example | +|---------------|-----------------------| +| ISO 8601 | `2024-01-15T10:30:45` | +| MySQL | `2024-01-15 10:30:45` | +| US Format | `01/15/2024` | +| European | `15.01.2024` | +| Unix Timestamp| `@1705331445` | + +## Output Format Strings + +### Year + +| Format | Description | Example | +|--------|--------------------------|---------| +| `Y` | 4-digit year | 2024 | +| `y` | 2-digit year | 24 | + +### Month + +| Format | Description | Example | +|--------|--------------------------|---------| +| `m` | 2-digit month | 01 | +| `n` | Month without leading 0 | 1 | +| `F` | Full month name | January | +| `M` | 3-letter month | Jan | + +### Day + +| Format | Description | Example | +|--------|--------------------------|---------| +| `d` | 2-digit day | 15 | +| `j` | Day without leading 0 | 15 | +| `D` | 3-letter weekday | Mon | +| `l` | Full weekday name | Monday | + +### Time + +| Format | Description | Example | +|--------|--------------------------|---------| +| `H` | 24-hour format (00-23) | 10 | +| `h` | 12-hour format (01-12) | 10 | +| `i` | Minutes (00-59) | 30 | +| `s` | Seconds (00-59) | 45 | +| `A` | AM/PM uppercase | AM | +| `a` | am/pm lowercase | am | + +### Other + +| Format | Description | Example | +|--------|--------------------------|---------------------------------| +| `c` | ISO 8601 | 2024-01-15T10:30:45+00:00 | +| `r` | RFC 2822 | Mon, 15 Jan 2024 10:30:45 +0000 | +| `U` | Unix timestamp | 1705331445 | +| `z` | Day of year (0-365) | 014 | +| `W` | Week number (ISO-8601) | 02 | + +## Examples + +### Common Formats + +| Description | Format | Input | Output | +|-------------------|------------------|-------------------------|-----------------------------------| +| US Date | `m/d/Y` | `2024-01-15` | `01/15/2024` | +| European Date | `d.m.Y` | `2024-01-15` | `15.01.2024` | +| Time Only | `H:i:s` | `2024-01-15 10:30:45` | `10:30:45` | +| Long Format | `l, F j, Y` | `2024-01-15` | `Monday, January 15, 2024` | +| Short Format | `M d, Y` | `2024-01-15` | `Jan 15, 2024` | +| ISO 8601 | `c` | `2024-01-15T10:30:45` | `2024-01-15T10:30:45+00:00` | +| RFC 2822 | `r` | `2024-01-15 10:30:45` | `Mon, 15 Jan 2024 10:30:45 +0000` | + +### Parsing Flexibility + +The formatter intelligently parses various input formats: + +```php +$formatter = new DateFormatter('Y-m-d'); + +// All produce the same output: 2024-01-15 +echo $formatter->format('2024-01-15'); // ISO format +echo $formatter->format('01/15/2024'); // US format +echo $formatter->format('15.01.2024'); // European format +echo $formatter->format('January 15, 2024'); // Long format +``` + +### Relative Date Processing + +```php +$formatter = new DateFormatter('l, F j, Y'); + +echo $formatter->format('now'); // Today's date +echo $formatter->format('tomorrow'); // Tomorrow's date +echo $formatter->format('yesterday'); // Yesterday's date +``` diff --git a/docs/NumberFormatter.md b/docs/NumberFormatter.md new file mode 100644 index 0000000..ee2b128 --- /dev/null +++ b/docs/NumberFormatter.md @@ -0,0 +1,174 @@ + + +# NumberFormatter + +The `NumberFormatter` formats numeric strings with configurable thousands and decimal separators. + +## Usage + +### Basic Usage + +```php +use Respect\StringFormatter\NumberFormatter; + +$formatter = new NumberFormatter(); + +echo $formatter->format('1234567'); +// Outputs: 1,234,567 +``` + +### With Decimals + +```php +use Respect\StringFormatter\NumberFormatter; + +$formatter = new NumberFormatter(2); + +echo $formatter->format('1234567.89'); +// Outputs: 1,234,567.89 +``` + +### European Format + +```php +use Respect\StringFormatter\NumberFormatter; + +$formatter = new NumberFormatter( + decimals: 2, + decimalSeparator: ',', + thousandsSeparator: '.', +); + +echo $formatter->format('1234567.89'); +// Outputs: 1.234.567,89 +``` + +### Custom Separators + +```php +use Respect\StringFormatter\NumberFormatter; + +$formatter = new NumberFormatter( + decimals: 2, + decimalSeparator: ',', + thousandsSeparator: ' ', +); + +echo $formatter->format('1234567.89'); +// Outputs: 1 234 567,89 +``` + +## API + +### `NumberFormatter::__construct` + +- `__construct(int $decimals = 0, string $decimalSeparator = '.', string $thousandsSeparator = ',')` + +Creates a new formatter instance with the specified formatting options. + +**Parameters:** + +- `$decimals`: Number of decimal places to display (default: `0`) +- `$decimalSeparator`: Character to use as decimal separator (default: `.`) +- `$thousandsSeparator`: Character to use as thousands separator (default: `,`) + +### `format` + +- `format(string $input): string` + +Formats the input numeric string according to the formatter's configuration. + +If the input is not numeric, it is returned unchanged without modification. + +**Parameters:** + +- `$input`: A numeric string to format + +**Returns:** The formatted number string if the input is numeric; the input unchanged otherwise + +## Behavior + +### Numeric Input + +Valid numeric input includes integers, floats, negative numbers, and scientific notation. The formatter uses PHP's `number_format()` function for formatting. + +### Non-Numeric Input + +When input is not numeric, the formatter returns it unchanged: + +```php +$formatter = new NumberFormatter(2); + +// Valid numeric input +echo $formatter->format('1234.56'); // Outputs: 1,234.56 + +// Non-numeric input is returned unchanged +echo $formatter->format('N/A'); // Outputs: N/A +echo $formatter->format(''); // Outputs: (empty string) +echo $formatter->format('abc'); // Outputs: abc +``` + +## Formatting Options + +### Decimal Separator + +The decimal separator is applied based on the number of decimals: + +| Decimals | Input | Separator | Output | +|----------|--------|-----------|----------| +| 0 | 1000 | (none) | 1,000 | +| 2 | 1000 | `.` | 1,000.00 | +| 2 | 1000 | `,` | 1,000,00 | + +### Thousands Separator + +The thousands separator is applied for values of 1,000 or greater: + +| Thousands | Input | Output | +|-----------|-----------|-------------| +| `,` | 1234567 | 1,234,567 | +| `.` | 1234567 | 1.234.567 | +| `' '` | 1234567 | 1 234 567 | +| `''` | 1234567 | 1234567 | + +### Rounding Behavior + +The formatter rounds to the specified number of decimal places: + +| Input | Decimals | Output | +|-----------|----------|----------| +| 1234.5678 | 0 | 1,235 | +| 1234.5478 | 1 | 1,234.5 | +| 1234.5678 | 2 | 1,234.57 | + +## Examples + +### International Formats + +| Format | Decimals | Decimal Sep | Thousands Sep | Input | Output | +|--------|----------|-------------|---------------|-------------|---------------| +| US | 2 | `.` | `,` | 1234567.89 | 1,234,567.89 | +| EU | 2 | `,` | `.` | 1234567.89 | 1.234.567,89 | +| Swiss | 2 | `.` | `'` | 1234567.89 | 1'234'567.89 | + +### Scientific Notation + +The formatter accepts scientific notation: + +| Input | Output | +|---------|-----------| +| `1e6` | 1,000,000 | +| `1.5e3` | 1,500 | + +### Negative Numbers + +Negative numbers are properly formatted: + +| Input | Output | +|------------|--------------| +| `-1234567` | -1,234,567 | +| `-1234.56` | -1,234.56 | diff --git a/src/DateFormatter.php b/src/DateFormatter.php new file mode 100644 index 0000000..90a17fe --- /dev/null +++ b/src/DateFormatter.php @@ -0,0 +1,36 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter; + +use DateTime; +use Throwable; + +final readonly class DateFormatter implements Formatter +{ + public function __construct(private string $format = 'Y-m-d H:i:s') + { + } + + public function format(string $input): string + { + try { + $dateTime = new DateTime($input); + $errors = DateTime::getLastErrors(); + if ($errors !== false && (!empty($errors['warning_count']) || !empty($errors['error_count']))) { + return $input; + } + + return $dateTime->format($this->format); + } catch (Throwable) { + return $input; + } + } +} diff --git a/src/NumberFormatter.php b/src/NumberFormatter.php new file mode 100644 index 0000000..0fc1f93 --- /dev/null +++ b/src/NumberFormatter.php @@ -0,0 +1,38 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter; + +use function is_numeric; +use function number_format; + +final readonly class NumberFormatter implements Formatter +{ + public function __construct( + private int $decimals = 0, + private string $decimalSeparator = '.', + private string $thousandsSeparator = ',', + ) { + } + + public function format(string $input): string + { + if (!is_numeric($input)) { + return $input; + } + + return number_format( + (float) $input, + $this->decimals, + $this->decimalSeparator, + $this->thousandsSeparator, + ); + } +} diff --git a/tests/Unit/DateFormatterTest.php b/tests/Unit/DateFormatterTest.php new file mode 100644 index 0000000..71bbd96 --- /dev/null +++ b/tests/Unit/DateFormatterTest.php @@ -0,0 +1,206 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter\Test\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Respect\StringFormatter\DateFormatter; + +#[CoversClass(DateFormatter::class)] +final class DateFormatterTest extends TestCase +{ + #[Test] + #[DataProvider('providerForBasicFormatting')] + public function itShouldFormatBasicDates( + string $format, + string $input, + string $expected, + ): void { + $formatter = new DateFormatter($format); + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForBasicFormatting(): array + { + return [ + 'default format with datetime string' => [ + 'Y-m-d H:i:s', + '2024-01-15 10:30:45', + '2024-01-15 10:30:45', + ], + 'date only format' => [ + 'Y-m-d', + '2024-01-15 10:30:45', + '2024-01-15', + ], + 'time only format' => [ + 'H:i:s', + '2024-01-15 10:30:45', + '10:30:45', + ], + 'iso 8601 format' => [ + 'c', + '2024-01-15T10:30:45+00:00', + '2024-01-15T10:30:45+00:00', + ], + 'american format' => [ + 'm/d/Y', + '2024-01-15', + '01/15/2024', + ], + 'european format' => [ + 'd.m.Y', + '2024-01-15', + '15.01.2024', + ], + 'month name format' => [ + 'l, F j, Y', + '2024-01-15 10:30:45', + 'Monday, January 15, 2024', + ], + 'short month format' => [ + 'M d, Y', + '2024-01-15', + 'Jan 15, 2024', + ], + 'abbreviated weekday' => [ + 'D', + '2024-01-15', + 'Mon', + ], + 'unix timestamp' => [ + 'U', + '2024-01-15T00:00:00Z', + '1705276800', + ], + 'with time zone' => [ + 'Y-m-d H:i:s T', + '2024-01-15T10:30:45Z', + '2024-01-15 10:30:45 Z', + ], + 'ordinal date format' => [ + 'Y-z', + '2024-01-15', + '2024-14', + ], + 'week number' => [ + 'Y-W', + '2024-01-15', + '2024-03', + ], + 'day of week numeric' => [ + 'N', + '2024-01-15', + '1', + ], + 'parse date string without time' => [ + 'Y-m-d', + '2024-01-15', + '2024-01-15', + ], + 'parse string with mixed format' => [ + 'd-m-Y', + '15-01-2024', + '15-01-2024', + ], + ]; + } + + #[Test] + #[DataProvider('providerForUnparsableInput')] + public function itShouldReturnInputUnchangedForUnparsableInput(string $input): void + { + $formatter = new DateFormatter(); + $actual = $formatter->format($input); + + self::assertSame($input, $actual); + } + + /** @return array */ + public static function providerForUnparsableInput(): array + { + return [ + 'invalid date' => ['2024-13-45'], + 'random text with invalid chars' => ['@#$%^&*()'], + 'invalid format numeric only' => ['9999999999999999999'], + ]; + } + + #[Test] + #[DataProvider('providerForInvalidDateStrings')] + public function itShouldReturnInputUnchangedForInvalidDateStrings(string $input): void + { + $formatter = new DateFormatter('Y-m-d'); + $actual = $formatter->format($input); + + self::assertSame($input, $actual); + } + + /** @return array */ + public static function providerForInvalidDateStrings(): array + { + return [ + // These throw DateMalformedStringException in PHP 8.3+ + 'completely invalid format' => ['not-a-date-at-all'], + 'text with date' => ['hello 2024-01-15'], + 'date at end after text' => ['text then 2024-01-15'], + 'random symbols' => ['@#$%^&*()'], + 'only symbols' => ['------'], + ]; + } + + #[Test] + #[DataProvider('providerForValidDateStrings')] + public function itShouldFormatValidDateStringsWithoutErrors(string $input, string $expectedFormatted): void + { + $formatter = new DateFormatter('Y-m-d'); + + // Verify that when DateTime can construct with no errors, formatting succeeds + $actual = $formatter->format($input); + + self::assertSame($expectedFormatted, $actual); + } + + /** @return array */ + public static function providerForValidDateStrings(): array + { + return [ + 'valid iso date' => ['2024-01-15', '2024-01-15'], + 'valid with time' => ['2024-01-15 10:30:45', '2024-01-15'], + 'valid with timezone' => ['2024-01-15T10:30:45Z', '2024-01-15'], + 'valid relative format' => ['January 15, 2024', '2024-01-15'], + 'valid slash format' => ['01/15/2024', '2024-01-15'], + 'valid dot format' => ['15.01.2024', '2024-01-15'], + ]; + } + + #[Test] + public function itShouldCheckDateTimeLastErrorsForStrictValidation(): void + { + // This test documents that the formatter uses DateTime::getLastErrors() + // to perform strict validation beyond exception handling + + $formatter = new DateFormatter('Y-m-d'); + + // Valid dates should format successfully + self::assertSame('2024-01-15', $formatter->format('2024-01-15')); + self::assertSame('2024-01-15', $formatter->format('2024-01-15 10:30:45')); + + // Invalid dates should return input unchanged + self::assertSame('not a date', $formatter->format('not a date')); + self::assertSame('2024-02-30', $formatter->format('2024-02-30')); + } +} diff --git a/tests/Unit/NumberFormatterTest.php b/tests/Unit/NumberFormatterTest.php new file mode 100644 index 0000000..4eb9f62 --- /dev/null +++ b/tests/Unit/NumberFormatterTest.php @@ -0,0 +1,174 @@ + + */ + +declare(strict_types=1); + +namespace Respect\StringFormatter\Test\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Respect\StringFormatter\NumberFormatter; + +#[CoversClass(NumberFormatter::class)] +final class NumberFormatterTest extends TestCase +{ + #[Test] + #[DataProvider('providerForBasicFormatting')] + public function itShouldFormatBasicNumbers( + int $decimals, + string $decimalSeparator, + string $thousandsSeparator, + string $input, + string $expected, + ): void { + $formatter = new NumberFormatter($decimals, $decimalSeparator, $thousandsSeparator); + $actual = $formatter->format($input); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForBasicFormatting(): array + { + return [ + 'integer with default separators' => [ + 0, + '.', + ',', + '1234567', + '1,234,567', + ], + 'float with default separators' => [ + 2, + '.', + ',', + '1234567.89', + '1,234,567.89', + ], + 'small number with default' => [ + 0, + '.', + ',', + '123', + '123', + ], + 'zero with decimals' => [ + 2, + '.', + ',', + '0', + '0.00', + ], + 'negative number' => [ + 0, + '.', + ',', + '-1234567', + '-1,234,567', + ], + 'negative with decimals' => [ + 2, + '.', + ',', + '-1234.56', + '-1,234.56', + ], + 'european format' => [ + 2, + ',', + '.', + '1234567.89', + '1.234.567,89', + ], + 'no thousands separator' => [ + 2, + '.', + '', + '1234567.89', + '1234567.89', + ], + 'space thousands separator' => [ + 2, + ',', + ' ', + '1234567.89', + '1 234 567,89', + ], + 'three decimals' => [ + 3, + '.', + ',', + '1234.5678', + '1,234.568', + ], + 'no decimals input' => [ + 2, + '.', + ',', + '1000', + '1,000.00', + ], + 'string integer' => [ + 0, + '.', + ',', + '999', + '999', + ], + 'string float' => [ + 1, + '.', + ',', + '99.9', + '99.9', + ], + 'leading zeros stripped' => [ + 0, + '.', + ',', + '00123', + '123', + ], + 'scientific notation' => [ + 0, + '.', + ',', + '1e6', + '1,000,000', + ], + ]; + } + + #[Test] + #[DataProvider('providerForNonNumericInput')] + public function itShouldReturnInputUnchangedForNonNumericInput(string $input): void + { + $formatter = new NumberFormatter(); + $actual = $formatter->format($input); + + self::assertSame($input, $actual); + } + + /** @return array */ + public static function providerForNonNumericInput(): array + { + return [ + 'empty string' => [''], + 'text' => ['abc'], + 'mixed text and numbers' => ['123abc'], + 'only comma' => [','], + 'only decimal' => ['.'], + 'multiple decimals' => ['1.2.3'], + 'letter e with non-numeric' => ['1e2e3'], + 'control character' => ["1\0234"], + 'special characters' => ['$1234'], + ]; + } +}