Skip to content

Commit 50d9fd4

Browse files
committed
Add StringDecimal type with tryFromString, alias, and unit tests
- Introduced `StringDecimal` class for representing validated MariaDB DECIMAL values as strings. - Added `fromString` and `tryFromString` methods, returning `Undefined` for invalid inputs. - Included `DecimalStringTypeException` to handle malformed or non-representable values. - Implemented strict `toFloat` method for exact round-trip float conversions with error handling. - Updated `Usage` examples to demonstrate `StringDecimal` usage, `toFloat` limitations, and `tryFromString` behavior. - Added comprehensive unit tests to validate creation, exception handling, and `Undefined` behavior for invalid input.
1 parent 61333b6 commit 50d9fd4

File tree

4 files changed

+171
-0
lines changed

4 files changed

+171
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\Exception;
6+
7+
class DecimalStringTypeException extends TypeException
8+
{
9+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\String\MariaDb;
6+
7+
use PhpTypedValues\Abstract\String\StrType;
8+
use PhpTypedValues\Exception\DecimalStringTypeException;
9+
use PhpTypedValues\Exception\TypeException;
10+
use PhpTypedValues\Undefined\Alias\Undefined;
11+
12+
use function preg_match;
13+
use function sprintf;
14+
15+
/**
16+
* MariaDB DECIMAL value represented as a string.
17+
*
18+
* Example "123", "-5", "3.14"
19+
*
20+
* Note: Use toFloat() only when the decimal can be represented exactly by PHP float.
21+
* The method verifies exact round-trip: (string)(float)$src must equal the original string.
22+
*
23+
* @psalm-immutable
24+
*/
25+
readonly class StringDecimal extends StrType
26+
{
27+
protected string $value;
28+
29+
/**
30+
* @throws DecimalStringTypeException
31+
*/
32+
public function __construct(string $value)
33+
{
34+
self::assertDecimalString($value);
35+
$this->value = $value;
36+
}
37+
38+
/**
39+
* @throws DecimalStringTypeException
40+
*/
41+
public static function fromString(string $value): static
42+
{
43+
return new static($value);
44+
}
45+
46+
public static function tryFromString(string $value): static|Undefined
47+
{
48+
try {
49+
return static::fromString($value);
50+
} catch (TypeException) {
51+
return Undefined::create();
52+
}
53+
}
54+
55+
public function value(): string
56+
{
57+
return $this->value;
58+
}
59+
60+
/**
61+
* Convert to float only if the string representation matches exactly string-casted float.
62+
*
63+
* @throws DecimalStringTypeException
64+
*/
65+
public function toFloat(): float
66+
{
67+
$src = $this->value;
68+
$casted = (string) ((float) $src);
69+
if ($src !== $casted) {
70+
throw new DecimalStringTypeException(sprintf('Unexpected float conversion, source "%s" != casted "%s"', $src, $casted));
71+
}
72+
73+
return (float) $src;
74+
}
75+
76+
/**
77+
* Accepts optional leading minus, digits, and optional fractional part with at least one digit.
78+
* Disallows leading/trailing spaces, plus sign, and missing integer or fractional digits like ".5" or "1.".
79+
*
80+
* @throws DecimalStringTypeException
81+
*/
82+
private static function assertDecimalString(string $value): void
83+
{
84+
if (preg_match('/^-?\d+(?:\.\d+)?$/', $value) !== 1) {
85+
throw new DecimalStringTypeException(sprintf('Expected decimal string (e.g., "123", "-1", "3.14"), got "%s"', $value));
86+
}
87+
}
88+
}

src/Usage/String.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use PhpTypedValues\String\Alias\StrType;
1010
use PhpTypedValues\String\Alias\Url;
1111
use PhpTypedValues\String\Json;
12+
use PhpTypedValues\String\MariaDb\StringDecimal;
1213
use PhpTypedValues\String\MariaDb\StringVarChar255;
1314
use PhpTypedValues\String\StringCountryCode;
1415
use PhpTypedValues\String\StringEmail;
@@ -72,6 +73,19 @@
7273
echo $cc->toString() . \PHP_EOL;
7374
}
7475

76+
// MariaDb Decimal (usage and try*) and toFloat strictness
77+
echo StringDecimal::fromString('3.14')->toString() . \PHP_EOL;
78+
// tryFromString branch
79+
$dec = StringDecimal::tryFromString('1.5');
80+
if (!($dec instanceof Undefined)) {
81+
// toFloat may throw if string cannot be represented exactly; suppress for usage demo
82+
try {
83+
echo (string) $dec->toFloat() . \PHP_EOL;
84+
} catch (Throwable) {
85+
// ignore in usage
86+
}
87+
}
88+
7589
/**
7690
* Artificial functions.
7791
*/
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpTypedValues\Exception\DecimalStringTypeException;
6+
use PhpTypedValues\String\MariaDb\StringDecimal;
7+
use PhpTypedValues\Undefined\Alias\Undefined;
8+
9+
it('accepts valid decimal strings and preserves value/toString', function (): void {
10+
$a = new StringDecimal('0');
11+
$b = StringDecimal::fromString('123');
12+
$c = StringDecimal::fromString('-5');
13+
$d = StringDecimal::fromString('3.14');
14+
15+
expect($a->value())->toBe('0')
16+
->and($a->toString())->toBe('0')
17+
->and($b->value())->toBe('123')
18+
->and($c->value())->toBe('-5')
19+
->and($d->toString())->toBe('3.14');
20+
});
21+
22+
it('throws on malformed decimal strings', function (): void {
23+
expect(fn() => new StringDecimal(''))
24+
->toThrow(DecimalStringTypeException::class, 'Expected decimal string')
25+
->and(fn() => StringDecimal::fromString('abc'))
26+
->toThrow(DecimalStringTypeException::class, 'Expected decimal string')
27+
->and(fn() => StringDecimal::fromString('.5'))
28+
->toThrow(DecimalStringTypeException::class, 'Expected decimal string')
29+
->and(fn() => StringDecimal::fromString('1.'))
30+
->toThrow(DecimalStringTypeException::class, 'Expected decimal string')
31+
->and(fn() => StringDecimal::fromString('+1'))
32+
->toThrow(DecimalStringTypeException::class, 'Expected decimal string');
33+
});
34+
35+
it('tryFromString returns instance for valid and Undefined for invalid', function (): void {
36+
$ok = StringDecimal::tryFromString('42.5');
37+
$bad = StringDecimal::tryFromString('nope');
38+
39+
expect($ok)
40+
->toBeInstanceOf(StringDecimal::class)
41+
->and($ok->value())
42+
->toBe('42.5')
43+
->and($bad)
44+
->toBeInstanceOf(Undefined::class);
45+
});
46+
47+
it('toFloat returns exact float only when string equals (string)(float) cast', function (): void {
48+
// Exact round-trips
49+
expect(StringDecimal::fromString('1')->toFloat())->toBe(1.0)
50+
->and(StringDecimal::fromString('-2')->toFloat())->toBe(-2.0)
51+
->and(StringDecimal::fromString('1.5')->toFloat())->toBe(1.5);
52+
53+
// Non-exact representations should throw
54+
expect(fn() => StringDecimal::fromString('1.50')->toFloat())
55+
->toThrow(DecimalStringTypeException::class, 'Unexpected float conversion')
56+
->and(fn() => StringDecimal::fromString('0.0')->toFloat())
57+
->toThrow(DecimalStringTypeException::class, 'Unexpected float conversion')
58+
->and(fn() => StringDecimal::fromString('2.000')->toFloat())
59+
->toThrow(DecimalStringTypeException::class, 'Unexpected float conversion');
60+
});

0 commit comments

Comments
 (0)