Skip to content

Commit b2cb3da

Browse files
cscottlucaswerkmeister
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.
1 parent f4efb88 commit b2cb3da

9 files changed

+200
-15
lines changed

Zend/zend_API.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,8 @@ static zend_always_inline zval *zend_try_array_init(zval *zv)
15261526
_(Z_EXPECTED_ARRAY_OR_STRING_OR_NULL, "of type array|string|null") \
15271527
_(Z_EXPECTED_STRING_OR_LONG, "of type string|int") \
15281528
_(Z_EXPECTED_STRING_OR_LONG_OR_NULL, "of type string|int|null") \
1529+
_(Z_EXPECTED_STRING_OR_NUMBER, "of type string|int|float") \
1530+
_(Z_EXPECTED_STRING_OR_NUMBER_OR_NULL, "of type string|int|float|null") \
15291531
_(Z_EXPECTED_OBJECT_OR_CLASS_NAME, "an object or a valid class name") \
15301532
_(Z_EXPECTED_OBJECT_OR_CLASS_NAME_OR_NULL, "an object, a valid class name, or null") \
15311533
_(Z_EXPECTED_OBJECT_OR_STRING, "of type object|string") \
@@ -2155,6 +2157,17 @@ ZEND_API ZEND_COLD void zend_argument_value_error(uint32_t arg_num, const char *
21552157
#define Z_PARAM_STR_OR_LONG_OR_NULL(dest_str, dest_long, is_null) \
21562158
Z_PARAM_STR_OR_LONG_EX(dest_str, dest_long, is_null, 1);
21572159

2160+
#define Z_PARAM_STR_OR_NUMBER_EX(dest, check_null) \
2161+
Z_PARAM_PROLOGUE(0, 0); \
2162+
if (UNEXPECTED(!zend_parse_arg_str_or_number(_arg, &dest, check_null, _i))) { \
2163+
_expected_type = check_null ? Z_EXPECTED_STRING_OR_NUMBER_OR_NULL : Z_EXPECTED_STRING_OR_NUMBER; \
2164+
_error_code = ZPP_ERROR_WRONG_ARG; \
2165+
break; \
2166+
}
2167+
2168+
#define Z_PARAM_STR_OR_NUMBER(dest) \
2169+
Z_PARAM_STR_OR_NUMBER_EX(dest, 0)
2170+
21582171
/* End of new parameter parsing API */
21592172

21602173
/* Inlined implementations shared by new and old parameter parsing APIs */
@@ -2506,6 +2519,18 @@ static zend_always_inline bool zend_parse_arg_str_or_long(zval *arg, zend_string
25062519
return 1;
25072520
}
25082521

2522+
static zend_always_inline bool zend_parse_arg_str_or_number(zval *arg, zval **dest, bool check_null, uint32_t arg_num)
2523+
{
2524+
if (EXPECTED(Z_TYPE_P(arg) == IS_LONG || Z_TYPE_P(arg) == IS_DOUBLE || Z_TYPE_P(arg) == IS_STRING)) {
2525+
*dest = arg;
2526+
} else if (check_null && EXPECTED(Z_TYPE_P(arg) == IS_NULL)) {
2527+
*dest = NULL;
2528+
} else {
2529+
return zend_parse_arg_number_slow(arg, dest, arg_num);
2530+
}
2531+
return 1;
2532+
}
2533+
25092534
static zend_always_inline bool zend_parse_arg_obj_or_class_name(
25102535
zval *arg, zend_class_entry **destination, bool allow_null
25112536
) {

ext/intl/formatter/formatter.stub.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,11 @@ class NumberFormatter
390390
* @cvalue FORMAT_TYPE_DOUBLE
391391
*/
392392
public const TYPE_DOUBLE = UNKNOWN;
393+
/**
394+
* @var int
395+
* @cvalue FORMAT_TYPE_DECIMAL
396+
*/
397+
public const TYPE_DECIMAL = UNKNOWN;
393398
/**
394399
* @var int
395400
* @deprecated
@@ -409,7 +414,7 @@ public static function create(string $locale, int $style, ?string $pattern = nul
409414
* @tentative-return-type
410415
* @alias numfmt_format
411416
*/
412-
public function format(int|float $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
417+
public function format(int|float|string $num, int $type = NumberFormatter::TYPE_DEFAULT): string|false {}
413418

414419
/**
415420
* @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: 52 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+
#endif
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));
@@ -103,6 +131,24 @@ PHP_FUNCTION( numfmt_format )
103131
}
104132
INTL_METHOD_CHECK_STATUS( nfo, "Number formatting failed" );
105133
break;
134+
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+
106152
case FORMAT_TYPE_CURRENCY:
107153
if (getThis()) {
108154
const char *space;
@@ -113,6 +159,7 @@ PHP_FUNCTION( numfmt_format )
113159
zend_argument_value_error(3, "cannot be NumberFormatter::TYPE_CURRENCY constant, use numfmt_format_currency() function instead");
114160
}
115161
RETURN_THROWS();
162+
116163
default:
117164
zend_argument_value_error(getThis() ? 2 : 3, "must be a NumberFormatter::TYPE_* constant");
118165
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
@@ -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("\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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
# Also, casting from double to int64 when the int64 range
24+
# is exceeded results in an implementation-defined value.
25+
'int64' => $x->format($value, NumberFormatter::TYPE_INT64),
26+
'double' => $x->format($value, NumberFormatter::TYPE_DOUBLE),
27+
'decimal' => $x->format($value, NumberFormatter::TYPE_DECIMAL),
28+
]);
29+
} catch (TypeError $ex) {
30+
echo $ex->getMessage(), PHP_EOL;
31+
}
32+
}
33+
34+
?>
35+
--EXPECTF--
36+
array(5) {
37+
["input"]=>
38+
string(18) "999999999999999999"
39+
["default"]=>
40+
string(23) "999,999,999,999,999,999"
41+
["int64"]=>
42+
string(23) "999,999,999,999,999,999"
43+
["double"]=>
44+
string(25) "1,000,000,000,000,000,000"
45+
["decimal"]=>
46+
string(23) "999,999,999,999,999,999"
47+
}
48+
array(5) {
49+
["input"]=>
50+
string(19) "9999999999999999999"
51+
["default"]=>
52+
string(25) "9,999,999,999,999,999,999"
53+
["int64"]=>
54+
string(%d) "%r9,223,372,036,854,775,807|9,999,999,999,999,999,999%r"
55+
["double"]=>
56+
string(26) "10,000,000,000,000,000,000"
57+
["decimal"]=>
58+
string(25) "9,999,999,999,999,999,999"
59+
}
60+
array(5) {
61+
["input"]=>
62+
float(1.0E+19)
63+
["default"]=>
64+
string(26) "10,000,000,000,000,000,000"
65+
["int64"]=>
66+
string(%d) "%r[+-]?[0-9,]+%r"
67+
["double"]=>
68+
string(26) "10,000,000,000,000,000,000"
69+
["decimal"]=>
70+
string(26) "10,000,000,000,000,000,000"
71+
}

0 commit comments

Comments
 (0)