Skip to content

Commit 18b0136

Browse files
cscottGirgias
authored andcommitted
Fix #76093: Format strings w/o loss of precision w/ FORMAT_TYPE_DECIMAL
Passing the argument to NumberFormat::format() as a number loses precision if the value can not be represented precisely as a double or long integer. The icu library provides a "decimal number" type that avoids the loss of prevision when the value is passed as a string. Add a new FORMAT_TYPE_DECIMAL to explicitly request the argument be converted to a string and then passed to icu that way. Co-authored-by: Gina Peter Banyard <[email protected]>
1 parent 85225a8 commit 18b0136

8 files changed

+185
-15
lines changed

ext/intl/formatter/formatter.stub.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ class NumberFormatter
171171
public const int TYPE_INT64 = UNKNOWN;
172172
/** @cvalue FORMAT_TYPE_DOUBLE */
173173
public const int TYPE_DOUBLE = UNKNOWN;
174+
/** @cvalue FORMAT_TYPE_DECIMAL */
175+
public const int TYPE_DECIMAL = UNKNOWN;
174176
/**
175177
* @deprecated
176178
* @cvalue FORMAT_TYPE_CURRENCY
@@ -189,7 +191,7 @@ public static function create(string $locale, int $style, ?string $pattern = nul
189191
* @tentative-return-type
190192
* @alias numfmt_format
191193
*/
192-
public function format(int|float $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
194+
public function format(int|float|string $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
193195

194196
/**
195197
* @param int $offset

ext/intl/formatter/formatter_arginfo.h

Lines changed: 8 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ext/intl/formatter/formatter_format.c

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ PHP_FUNCTION( numfmt_format )
3535
FORMATTER_METHOD_INIT_VARS;
3636

3737
/* Parse parameters. */
38-
if( zend_parse_method_parameters( ZEND_NUM_ARGS(), getThis(), "On|l",
38+
if( zend_parse_method_parameters( ZEND_NUM_ARGS(), getThis(), "Oz|l",
3939
&object, NumberFormatter_ce_ptr, &number, &type ) == FAILURE )
4040
{
4141
RETURN_THROWS();
@@ -53,17 +53,38 @@ PHP_FUNCTION( numfmt_format )
5353
case IS_DOUBLE:
5454
type = FORMAT_TYPE_DOUBLE;
5555
break;
56-
EMPTY_SWITCH_DEFAULT_CASE();
56+
case IS_STRING:
57+
type = FORMAT_TYPE_DECIMAL;
58+
break;
59+
case IS_OBJECT:
60+
type = FORMAT_TYPE_DECIMAL;
61+
break;
62+
default:
63+
zend_argument_type_error(1, "must be of type int|float|string, %s given", zend_zval_type_name(number));
5764
}
5865
}
5966

67+
// Avoid losing precision on 32-bit platforms where PHP's "long" isn't
68+
// as long as the FORMAT_TYPE_INT64 which is requested.
69+
#if SIZEOF_ZEND_LONG < 8
70+
if (Z_TYPE_P(number) == IS_STRING && type == FORMAT_TYPE_INT64) {
71+
type = FORMAT_TYPE_DECIMAL;
72+
}
73+
#endif
74+
6075
switch(type) {
6176
case FORMAT_TYPE_INT32:
6277
{
6378
bool failed = true;
6479
int64_t value_64 = zval_try_get_long(number, &failed);
65-
if (failed || value_64 < -2147483648 || value_64 > 2147483647) {
66-
zend_argument_value_error(object ? 1 : 2, "must be numeric and fit in 32 bits");
80+
if (failed) {
81+
zend_argument_type_error(getThis() ? 1 : 2,
82+
"must be of type int when argument #%d ($format) is NumberFormatter::TYPE_INT32", getThis() ? 2 : 3);
83+
RETURN_THROWS();
84+
}
85+
if (value_64 < -2147483648 || value_64 > 2147483647) {
86+
zend_argument_value_error(getThis() ? 1 : 2,
87+
"must fit in 32 bits when argument #%d ($format) is NumberFormatter::TYPE_INT32", getThis() ? 2 : 3);
6788
RETURN_THROWS();
6889
}
6990
convert_to_long(number);
@@ -87,7 +108,8 @@ PHP_FUNCTION( numfmt_format )
87108
bool failed = true;
88109
int64_t value = zval_try_get_long(number, &failed);
89110
if (failed) {
90-
zend_argument_value_error(object ? 1 : 2, "must be numeric");
111+
zend_argument_type_error(getThis() ? 1 : 2,
112+
"must be of type int when argument #%d ($format) is NumberFormatter::TYPE_INT64", getThis() ? 2 : 3);
91113
RETURN_THROWS();
92114
}
93115
formatted_len = unum_formatInt64(FORMATTER_OBJECT(nfo), value, formatted, formatted_len, NULL, &INTL_DATA_ERROR_CODE(nfo));
@@ -116,6 +138,24 @@ PHP_FUNCTION( numfmt_format )
116138
}
117139
INTL_METHOD_CHECK_STATUS( nfo, "Number formatting failed" );
118140
break;
141+
142+
case FORMAT_TYPE_DECIMAL:
143+
if (!try_convert_to_string(number)) {
144+
RETURN_THROWS();
145+
}
146+
// Convert string as a DecimalNumber, so we don't lose precision
147+
formatted_len = unum_formatDecimal(FORMATTER_OBJECT(nfo), Z_STRVAL_P(number), Z_STRLEN_P(number), formatted, formatted_len, NULL, &INTL_DATA_ERROR_CODE(nfo));
148+
if (INTL_DATA_ERROR_CODE(nfo) == U_BUFFER_OVERFLOW_ERROR) {
149+
intl_error_reset(INTL_DATA_ERROR_P(nfo));
150+
formatted = eumalloc(formatted_len);
151+
unum_formatDecimal(FORMATTER_OBJECT(nfo), Z_STRVAL_P(number), Z_STRLEN_P(number), formatted, formatted_len, NULL, &INTL_DATA_ERROR_CODE(nfo));
152+
if (U_FAILURE( INTL_DATA_ERROR_CODE(nfo) ) ) {
153+
efree(formatted);
154+
}
155+
}
156+
INTL_METHOD_CHECK_STATUS( nfo, "Number formatting failed" );
157+
break;
158+
119159
case FORMAT_TYPE_CURRENCY:
120160
if (getThis()) {
121161
const char *space;

ext/intl/formatter/formatter_format.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@
2222
#define FORMAT_TYPE_INT64 2
2323
#define FORMAT_TYPE_DOUBLE 3
2424
#define FORMAT_TYPE_CURRENCY 4
25+
#define FORMAT_TYPE_DECIMAL 5
2526

2627
#endif // FORMATTER_FORMAT_H

ext/intl/php_intl.stub.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ function datefmt_get_error_message(IntlDateFormatter $formatter): string {}
390390

391391
function numfmt_create(string $locale, int $style, ?string $pattern = null): ?NumberFormatter {}
392392

393-
function numfmt_format(NumberFormatter $formatter, int|float $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
393+
function numfmt_format(NumberFormatter $formatter, int|float|string $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
394394

395395
/** @param int $offset */
396396
function numfmt_parse(NumberFormatter $formatter, string $string, int $type = NumberFormatter::TYPE_DOUBLE, &$offset = null): int|float|false {}

ext/intl/php_intl_arginfo.h

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ext/intl/tests/bug48227.phpt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,50 @@ intl
55
--FILE--
66
<?php
77

8+
$testcases = ['', 1, NULL, true, false, [], (object)[]];
9+
10+
echo "OOP\n";
811
$x = new NumberFormatter('en_US', NumberFormatter::DECIMAL);
9-
foreach (['', 1, NULL, $x] as $value) {
12+
foreach (array_merge($testcases, [$x]) as $value) {
1013
try {
1114
var_dump($x->format($value));
1215
} catch (TypeError $ex) {
1316
echo $ex->getMessage(), PHP_EOL;
1417
}
1518
}
1619

20+
echo "\nProcedural\n";
21+
$x = numfmt_create('en_US', NumberFormatter::DECIMAL);
22+
foreach (array_merge($testcases, [$x]) as $value) {
23+
try {
24+
var_dump(numfmt_format($x, $value));
25+
} catch (TypeError $ex) {
26+
echo $ex->getMessage(), PHP_EOL;
27+
}
28+
}
29+
1730
?>
1831
--EXPECTF--
19-
NumberFormatter::format(): Argument #1 ($num) must be of type int|float, string given
32+
OOP
33+
bool(false)
2034
string(1) "1"
2135

22-
Deprecated: NumberFormatter::format(): Passing null to parameter #1 ($num) of type int|float is deprecated in %s on line %d
36+
Deprecated: NumberFormatter::format(): Passing null to parameter #1 ($num) of type string|int|float is deprecated in %s on line %d
37+
string(1) "0"
38+
string(1) "1"
39+
string(1) "0"
40+
NumberFormatter::format(): Argument #1 ($num) must be of type string|int|float, array given
41+
NumberFormatter::format(): Argument #1 ($num) must be of type string|int|float, stdClass given
42+
NumberFormatter::format(): Argument #1 ($num) must be of type string|int|float, NumberFormatter given
43+
44+
Non-OOP
45+
bool(false)
46+
string(1) "1"
47+
48+
Deprecated: numfmt_format(): Passing null to parameter #2 ($num) of type string|int|float is deprecated in %s on line %d
49+
string(1) "0"
50+
string(1) "1"
2351
string(1) "0"
24-
NumberFormatter::format(): Argument #1 ($num) must be of type int|float, NumberFormatter given
52+
numfmt_format(): Argument #2 ($num) must be of type string|int|float, array given
53+
numfmt_format(): Argument #2 ($num) must be of type string|int|float, stdClass given
54+
numfmt_format(): Argument #2 ($num) must be of type string|int|float, NumberFormatter given

ext/intl/tests/bug76093.phpt

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
--TEST--
2+
Bug #76093 (NumberFormatter::format loses precision)
3+
--EXTENSIONS--
4+
intl
5+
--FILE--
6+
<?php
7+
8+
class Bug76093Stringable implements Stringable {
9+
public function __construct( private readonly string $string ) {}
10+
11+
public function __toString(): string {
12+
return $this->string;
13+
}
14+
}
15+
16+
# See also https://phabricator.wikimedia.org/T268456
17+
$x = new NumberFormatter('en_US', NumberFormatter::DECIMAL);
18+
foreach ([
19+
'999999999999999999', # Fits in signed 64-bit integer
20+
'9999999999999999999', # Does not fit in signed 64-bit integer
21+
9999999999999999999, # Precision loss seen when passing as number
22+
] as $value) {
23+
try {
24+
var_dump([
25+
'input' => $value,
26+
'default' => $x->format($value),
27+
# Note that TYPE_INT64 isn't actually guaranteed to have an
28+
# 64-bit integer as input, because PHP on 32-bit platforms only
29+
# has 32-bit integers. If you pass the value as a string, PHP
30+
# will use the TYPE_DECIMAL type in order to extend the range.
31+
# Also, casting from double to int64 when the int64 range
32+
# is exceeded results in an implementation-defined value.
33+
'int64' => $x->format($value, NumberFormatter::TYPE_INT64),
34+
'double' => $x->format($value, NumberFormatter::TYPE_DOUBLE),
35+
'decimal' => $x->format($value, NumberFormatter::TYPE_DECIMAL),
36+
]);
37+
} catch (TypeError $ex) {
38+
echo $ex->getMessage(), PHP_EOL;
39+
}
40+
}
41+
42+
# Stringable object also supported
43+
try {
44+
echo $x->format(new Bug76093Stringable('9999999999999999999')), PHP_EOL;
45+
} catch (TypeError $ex) {
46+
echo $ex->getMessage(), PHP_EOL;
47+
}
48+
49+
?>
50+
--EXPECTF--
51+
array(5) {
52+
["input"]=>
53+
string(18) "999999999999999999"
54+
["default"]=>
55+
string(23) "999,999,999,999,999,999"
56+
["int64"]=>
57+
string(23) "999,999,999,999,999,999"
58+
["double"]=>
59+
string(25) "1,000,000,000,000,000,000"
60+
["decimal"]=>
61+
string(23) "999,999,999,999,999,999"
62+
}
63+
64+
Deprecated: Implicit conversion from float-string "9999999999999999999" to int loses precision in %s on line %d
65+
array(5) {
66+
["input"]=>
67+
string(19) "9999999999999999999"
68+
["default"]=>
69+
string(25) "9,999,999,999,999,999,999"
70+
["int64"]=>
71+
string(%d) "%r9,223,372,036,854,775,807|9,999,999,999,999,999,999%r"
72+
["double"]=>
73+
string(26) "10,000,000,000,000,000,000"
74+
["decimal"]=>
75+
string(25) "9,999,999,999,999,999,999"
76+
}
77+
78+
Deprecated: Implicit conversion from float 1.0E+19 to int loses precision in %s on line %d
79+
array(5) {
80+
["input"]=>
81+
float(1.0E+19)
82+
["default"]=>
83+
string(26) "10,000,000,000,000,000,000"
84+
["int64"]=>
85+
string(%d) "%r[+-]?[0-9,]+%r"
86+
["double"]=>
87+
string(26) "10,000,000,000,000,000,000"
88+
["decimal"]=>
89+
string(26) "10,000,000,000,000,000,000"
90+
}
91+
9,999,999,999,999,999,999

0 commit comments

Comments
 (0)