Skip to content

Commit 9cf0d36

Browse files
committed
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.
1 parent 78ef25b commit 9cf0d36

10 files changed

+185
-14
lines changed

Zend/zend_API.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,8 @@ static zend_always_inline zval *zend_try_array_init(zval *zv)
13131313
_(Z_EXPECTED_ARRAY_OR_STRING_OR_NULL, "of type array|string|null") \
13141314
_(Z_EXPECTED_STRING_OR_LONG, "of type string|int") \
13151315
_(Z_EXPECTED_STRING_OR_LONG_OR_NULL, "of type string|int|null") \
1316+
_(Z_EXPECTED_STRING_OR_NUMBER, "of type string|int|float") \
1317+
_(Z_EXPECTED_STRING_OR_NUMBER_OR_NULL, "of type string|int|float|null") \
13161318
_(Z_EXPECTED_OBJECT_OR_CLASS_NAME, "an object or a valid class name") \
13171319
_(Z_EXPECTED_OBJECT_OR_CLASS_NAME_OR_NULL, "an object, a valid class name, or null") \
13181320
_(Z_EXPECTED_OBJECT_OR_STRING, "of type object|string") \
@@ -1911,6 +1913,17 @@ ZEND_API ZEND_COLD void zend_argument_value_error(uint32_t arg_num, const char *
19111913
#define Z_PARAM_STR_OR_LONG_OR_NULL(dest_str, dest_long, is_null) \
19121914
Z_PARAM_STR_OR_LONG_EX(dest_str, dest_long, is_null, 1);
19131915

1916+
#define Z_PARAM_STR_OR_NUMBER_EX(dest, check_null) \
1917+
Z_PARAM_PROLOGUE(0, 0); \
1918+
if (UNEXPECTED(!zend_parse_arg_str_or_number(_arg, &dest, check_null, _i))) { \
1919+
_expected_type = check_null ? Z_EXPECTED_STRING_OR_NUMBER_OR_NULL : Z_EXPECTED_STRING_OR_NUMBER; \
1920+
_error_code = ZPP_ERROR_WRONG_ARG; \
1921+
break; \
1922+
}
1923+
1924+
#define Z_PARAM_STR_OR_NUMBER(dest) \
1925+
Z_PARAM_STR_OR_NUMBER_EX(dest, 0)
1926+
19141927
/* End of new parameter parsing API */
19151928

19161929
/* Inlined implementations shared by new and old parameter parsing APIs */
@@ -2247,6 +2260,18 @@ static zend_always_inline bool zend_parse_arg_str_or_long(zval *arg, zend_string
22472260
return 1;
22482261
}
22492262

2263+
static zend_always_inline bool zend_parse_arg_str_or_number(zval *arg, zval **dest, bool check_null, uint32_t arg_num)
2264+
{
2265+
if (EXPECTED(Z_TYPE_P(arg) == IS_LONG || Z_TYPE_P(arg) == IS_DOUBLE || Z_TYPE_P(arg) == IS_STRING)) {
2266+
*dest = arg;
2267+
} else if (check_null && EXPECTED(Z_TYPE_P(arg) == IS_NULL)) {
2268+
*dest = NULL;
2269+
} else {
2270+
return zend_parse_arg_number_slow(arg, dest, arg_num);
2271+
}
2272+
return 1;
2273+
}
2274+
22502275
static zend_always_inline bool zend_parse_arg_obj_or_class_name(
22512276
zval *arg, zend_class_entry **destination, bool allow_null
22522277
) {

ext/intl/formatter/formatter.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ void formatter_register_constants( INIT_FUNC_ARGS )
125125
FORMATTER_EXPOSE_CUSTOM_CLASS_CONST( "TYPE_INT32", FORMAT_TYPE_INT32 );
126126
FORMATTER_EXPOSE_CUSTOM_CLASS_CONST( "TYPE_INT64", FORMAT_TYPE_INT64 );
127127
FORMATTER_EXPOSE_CUSTOM_CLASS_CONST( "TYPE_DOUBLE", FORMAT_TYPE_DOUBLE );
128+
FORMATTER_EXPOSE_CUSTOM_CLASS_CONST( "TYPE_DECIMAL", FORMAT_TYPE_DECIMAL );
128129
FORMATTER_EXPOSE_CUSTOM_CLASS_CONST( "TYPE_CURRENCY", FORMAT_TYPE_CURRENCY );
129130

130131
#undef FORMATTER_EXPOSE_CUSTOM_CLASS_CONST

ext/intl/formatter/formatter.stub.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public static function create(string $locale, int $style, ?string $pattern = nul
1717
* @tentative-return-type
1818
* @alias numfmt_format
1919
*/
20-
public function format(int|float $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
20+
public function format(int|float|string $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
2121

2222
/**
2323
* @param int $offset

ext/intl/formatter/formatter_arginfo.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* This is a generated file, edit the .stub.php file instead.
2-
* Stub hash: f76ad76b08b7ca47883659fabfcc0882a2820c43 */
2+
* Stub hash: a3dc0e6258a22b5e27dcdd550715741b0c9b33d0 */
33

44
ZEND_BEGIN_ARG_INFO_EX(arginfo_class_NumberFormatter___construct, 0, 0, 2)
55
ZEND_ARG_TYPE_INFO(0, locale, IS_STRING, 0)
@@ -14,7 +14,7 @@ ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_OBJ_INFO_EX(arginfo_class_NumberFormatter_c
1414
ZEND_END_ARG_INFO()
1515

1616
ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_MASK_EX(arginfo_class_NumberFormatter_format, 0, 1, MAY_BE_STRING|MAY_BE_FALSE)
17-
ZEND_ARG_TYPE_MASK(0, num, MAY_BE_LONG|MAY_BE_DOUBLE, NULL)
17+
ZEND_ARG_TYPE_MASK(0, num, MAY_BE_LONG|MAY_BE_DOUBLE|MAY_BE_STRING, NULL)
1818
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, type, IS_LONG, 0, "NumberFormatter::TYPE_DEFAULT")
1919
ZEND_END_ARG_INFO()
2020

ext/intl/formatter/formatter_format.c

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,22 @@ PHP_FUNCTION( numfmt_format )
3434
int32_t formatted_len = USIZE(format_buf);
3535
FORMATTER_METHOD_INIT_VARS;
3636

37+
object = getThis();
38+
3739
/* Parse parameters. */
38-
if( zend_parse_method_parameters( ZEND_NUM_ARGS(), getThis(), "On|l",
39-
&object, NumberFormatter_ce_ptr, &number, &type ) == FAILURE )
40-
{
41-
RETURN_THROWS();
40+
if (object) {
41+
ZEND_PARSE_PARAMETERS_START(1, 2)
42+
Z_PARAM_STR_OR_NUMBER(number)
43+
Z_PARAM_OPTIONAL
44+
Z_PARAM_LONG(type)
45+
ZEND_PARSE_PARAMETERS_END();
46+
} else {
47+
ZEND_PARSE_PARAMETERS_START(2, 3)
48+
Z_PARAM_OBJECT_OF_CLASS(object, NumberFormatter_ce_ptr)
49+
Z_PARAM_STR_OR_NUMBER(number)
50+
Z_PARAM_OPTIONAL
51+
Z_PARAM_LONG(type)
52+
ZEND_PARSE_PARAMETERS_END();
4253
}
4354

4455
/* Fetch the object. */
@@ -53,10 +64,21 @@ PHP_FUNCTION( numfmt_format )
5364
case IS_DOUBLE:
5465
type = FORMAT_TYPE_DOUBLE;
5566
break;
67+
case IS_STRING:
68+
type = FORMAT_TYPE_DECIMAL;
69+
break;
5670
EMPTY_SWITCH_DEFAULT_CASE();
5771
}
5872
}
5973

74+
// Avoid losing precision on 32-bit platforms where PHP's "long" isn't
75+
// as long as the FORMAT_TYPE_INT64 which is requested.
76+
if(sizeof(zend_long) < 8) {
77+
if (Z_TYPE_P(number) == IS_STRING && type == FORMAT_TYPE_INT64) {
78+
type = FORMAT_TYPE_DECIMAL;
79+
}
80+
}
81+
6082
switch(type) {
6183
case FORMAT_TYPE_INT32:
6284
convert_to_long(number);
@@ -76,7 +98,13 @@ PHP_FUNCTION( numfmt_format )
7698

7799
case FORMAT_TYPE_INT64:
78100
{
79-
int64_t value = (Z_TYPE_P(number) == IS_DOUBLE)?(int64_t)Z_DVAL_P(number):Z_LVAL_P(number);
101+
int64_t value;
102+
if (Z_TYPE_P(number) == IS_DOUBLE) {
103+
value = (int64_t)Z_DVAL_P(number);
104+
} else {
105+
convert_to_long(number);
106+
value = Z_LVAL_P(number);
107+
}
80108
formatted_len = unum_formatInt64(FORMATTER_OBJECT(nfo), value, formatted, formatted_len, NULL, &INTL_DATA_ERROR_CODE(nfo));
81109
if (INTL_DATA_ERROR_CODE(nfo) == U_BUFFER_OVERFLOW_ERROR) {
82110
intl_error_reset(INTL_DATA_ERROR_P(nfo));
@@ -104,6 +132,23 @@ PHP_FUNCTION( numfmt_format )
104132
INTL_METHOD_CHECK_STATUS( nfo, "Number formatting failed" );
105133
break;
106134

135+
case FORMAT_TYPE_DECIMAL:
136+
if (!try_convert_to_string(number)) {
137+
RETURN_THROWS();
138+
}
139+
// Convert string as a DecimalNumber, so we don't lose precision
140+
formatted_len = unum_formatDecimal(FORMATTER_OBJECT(nfo), Z_STRVAL_P(number), Z_STRLEN_P(number), formatted, formatted_len, NULL, &INTL_DATA_ERROR_CODE(nfo));
141+
if (INTL_DATA_ERROR_CODE(nfo) == U_BUFFER_OVERFLOW_ERROR) {
142+
intl_error_reset(INTL_DATA_ERROR_P(nfo));
143+
formatted = eumalloc(formatted_len);
144+
unum_formatDecimal(FORMATTER_OBJECT(nfo), Z_STRVAL_P(number), Z_STRLEN_P(number), formatted, formatted_len, NULL, &INTL_DATA_ERROR_CODE(nfo));
145+
if (U_FAILURE( INTL_DATA_ERROR_CODE(nfo) ) ) {
146+
efree(formatted);
147+
}
148+
}
149+
INTL_METHOD_CHECK_STATUS( nfo, "Number formatting failed" );
150+
break;
151+
107152
default:
108153
zend_argument_value_error(3, "must be a NumberFormatter::TYPE_* constant");
109154
RETURN_THROWS();

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
@@ -225,7 +225,7 @@ function datefmt_get_error_message(IntlDateFormatter $formatter): string {}
225225

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

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

230230
/** @param int $offset */
231231
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ ZEND_END_ARG_INFO()
375375

376376
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_numfmt_format, 0, 2, MAY_BE_STRING|MAY_BE_FALSE)
377377
ZEND_ARG_OBJ_INFO(0, formatter, NumberFormatter, 0)
378-
ZEND_ARG_TYPE_MASK(0, num, MAY_BE_LONG|MAY_BE_DOUBLE, NULL)
378+
ZEND_ARG_TYPE_MASK(0, num, MAY_BE_LONG|MAY_BE_DOUBLE|MAY_BE_STRING, NULL)
379379
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, type, IS_LONG, 0, "NumberFormatter::TYPE_DEFAULT")
380380
ZEND_END_ARG_INFO()
381381

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("\nNon-OOP\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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
--TEST--
2+
Bug #76093 (NumberFormatter::format loses precision)
3+
--SKIPIF--
4+
<?php if( !extension_loaded( 'intl' ) ) print 'skip'; ?>
5+
--FILE--
6+
<?php
7+
8+
# See also https://phabricator.wikimedia.org/T268456
9+
$x = new NumberFormatter('en_US', NumberFormatter::DECIMAL);
10+
foreach ([
11+
'999999999999999999', # Fits in signed 64-bit integer
12+
'9999999999999999999', # Does not fit in signed 64-bit integer
13+
9999999999999999999, # Precision loss seen when passing as number
14+
] as $value) {
15+
try {
16+
var_dump([
17+
'input' => $value,
18+
'default' => $x->format($value),
19+
# Note that TYPE_INT64 isn't actually guaranteed to have an
20+
# 64-bit integer as input, because PHP on 32-bit platforms only
21+
# has 32-bit integers. If you pass the value as a string, PHP
22+
# will use the TYPE_DECIMAL type in order to extend the range.
23+
'int64' => $x->format($value, NumberFormatter::TYPE_INT64),
24+
'double' => $x->format($value, NumberFormatter::TYPE_DOUBLE),
25+
'decimal' => $x->format($value, NumberFormatter::TYPE_DECIMAL),
26+
]);
27+
} catch (TypeError $ex) {
28+
echo $ex->getMessage(), PHP_EOL;
29+
}
30+
}
31+
32+
?>
33+
--EXPECTF--
34+
array(5) {
35+
["input"]=>
36+
string(18) "999999999999999999"
37+
["default"]=>
38+
string(23) "999,999,999,999,999,999"
39+
["int64"]=>
40+
string(23) "999,999,999,999,999,999"
41+
["double"]=>
42+
string(25) "1,000,000,000,000,000,000"
43+
["decimal"]=>
44+
string(23) "999,999,999,999,999,999"
45+
}
46+
array(5) {
47+
["input"]=>
48+
string(19) "9999999999999999999"
49+
["default"]=>
50+
string(25) "9,999,999,999,999,999,999"
51+
["int64"]=>
52+
string(25) "%r9,223,372,036,854,775,807|9,999,999,999,999,999,999%r"
53+
["double"]=>
54+
string(26) "10,000,000,000,000,000,000"
55+
["decimal"]=>
56+
string(25) "9,999,999,999,999,999,999"
57+
}
58+
array(5) {
59+
["input"]=>
60+
float(1.0E+19)
61+
["default"]=>
62+
string(26) "10,000,000,000,000,000,000"
63+
["int64"]=>
64+
string(26) "-9,223,372,036,854,775,808"
65+
["double"]=>
66+
string(26) "10,000,000,000,000,000,000"
67+
["decimal"]=>
68+
string(26) "10,000,000,000,000,000,000"
69+
}

0 commit comments

Comments
 (0)