Skip to content

Commit

Permalink
[10.x] Add a Number utility class (#48845)
Browse files Browse the repository at this point in the history
* Create a new Number utility class

* Add a `Number::bytesToHuman()` helper

Ports #48827 into the new `Number` utility class

* Add a `Number::toHuman()` helper

This is unfortunately dependent on the `ext-intl`, as it wraps the NumberFormatter class.

* Use lowercase `k` for kilobytes

See #48845 (comment)

* Update Support package to suggest `ext-intl`

* Throw if extension is not installed when using NumberFormatter wrapper

As discussed in #internals, this seems to be a good compromise when the extension is not used. Instead of testing against the exception in the tests, I just skipped the test if the extension is missing, as that is what we do in the InteractsWithRedis testing trait.

* Add a `Number::toCurrency()` helper

* Make Number helper locale parameters null and default to App locale

This makes so that if no locale parameter is specified, the configured App locale is used.

* Add a `Number::format()` helper

Adds a locale-aware number formatting helper

* Fix number tests

Could not get Mockery to work, so this is my fix.

* Add a `Number::toPercent()` helper

I'm dividing the supplied value by 100 so that 50 = 50% as that's how Rails does it, and that's what Taylor linked to in his suggestion for this class. The default number formatter would consider 0.5 to be 50% and 50 to be 5000%. I'm not sure which option is best, so I went with the Rails format. https://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_to_percentage

* Rename Number toHuman helper to spellout

We may want to remove it, as per #48845 (comment). But renaming it for now.

* Create new `Number::toHuman()` helper

Based on the Rails implementation, as requested by Taylor in #48845 (comment)

See https://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_to_human

Uses the short scale system, see https://en.wikipedia.org/wiki/Long_and_short_scales

* Change toHuman implementation to better match Rails version

Based more on the logic of Rails, but with added support for massive numbers.

* Update toHuman helper to better handle extreme numbers

Inverts negative numbers, and removes unreachable cases, and handles very large numbers

* Clean up toHuman helper

* formatting

* formatting

* formatting

* formatting

* formatting

---------

Co-authored-by: Taylor Otwell <[email protected]>
  • Loading branch information
caendesilva and taylorotwell authored Nov 15, 2023
1 parent 3a3a9cc commit 6e4ecc7
Show file tree
Hide file tree
Showing 2 changed files with 339 additions and 0 deletions.
161 changes: 161 additions & 0 deletions src/Illuminate/Support/Number.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

namespace Illuminate\Support;

use Illuminate\Support\Traits\Macroable;
use NumberFormatter;
use RuntimeException;

class Number
{
use Macroable;

/**
* The current default locale.
*
* @var string
*/
protected static $locale = 'en';

/**
* Format the given number according to the current locale.
*
* @param int|float $number
* @param ?string $locale
* @return string|false
*/
public static function format(int|float $number, ?string $locale = null)
{
static::ensureIntlExtensionIsInstalled();

$formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::DECIMAL);

return $formatter->format($number);
}

/**
* Convert the given number to its percentage equivalent.
*
* @param int|float $number
* @param int $precision
* @param ?string $locale
* @return string|false
*/
public static function toPercentage(int|float $number, int $precision = 0, ?string $locale = null)
{
static::ensureIntlExtensionIsInstalled();

$formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::PERCENT);

$formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $precision);

return $formatter->format($number / 100);
}

/**
* Convert the given number to its currency equivalent.
*
* @param int|float $number
* @param string $currency
* @param ?string $locale
* @return string|false
*/
public static function toCurrency(int|float $number, string $currency = 'USD', ?string $locale = null)
{
static::ensureIntlExtensionIsInstalled();

$formatter = new NumberFormatter($locale ?? static::$locale, NumberFormatter::CURRENCY);

return $formatter->formatCurrency($number, $currency);
}

/**
* Convert the given number to its file size equivalent.
*
* @param int|float $bytes
* @param int $precision
* @return string
*/
public static function toFileSize(int|float $bytes, int $precision = 0)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

for ($i = 0; ($bytes / 1024) > 0.9 && ($i < count($units) - 1); $i++) {
$bytes /= 1024;
}

return sprintf('%s %s', number_format($bytes, $precision), $units[$i]);
}

/**
* Convert the number to its human readable equivalent.
*
* @param int $number

This comment has been minimized.

Copy link
@realodix

realodix Nov 15, 2023

@taylorotwell I think this typo. It's should int|float $number

* @param int $precision
* @return string
*/
public static function forHumans(int|float $number, int $precision = 0)
{
$units = [
3 => 'thousand',
6 => 'million',
9 => 'billion',
12 => 'trillion',
15 => 'quadrillion',
];

switch (true) {
case $number === 0:
return '0';
case $number < 0:
return sprintf('-%s', static::forHumans(abs($number), $precision));
case $number >= 1e15:
return sprintf('%s quadrillion', static::forHumans($number / 1e15, $precision));
}

$numberExponent = floor(log10($number));
$displayExponent = $numberExponent - ($numberExponent % 3);
$number /= pow(10, $displayExponent);

return trim(sprintf('%s %s', number_format($number, $precision), $units[$displayExponent]));
}

/**
* Execute the given callback using the given locale.
*
* @param string $locale
* @param callable $callback
* @return mixed
*/
public static function withLocale(string $locale, callable $callback)
{
$previousLocale = static::$locale;

static::useLocale($locale);

return tap($callback(), fn () => static::useLocale($previousLocale));
}

/**
* Set the default locale.
*
* @param string $locale
* @return void
*/
public static function useLocale(string $locale)
{
static::$locale = $locale;
}

/**
* Ensure the "intl" PHP exntension is installed.
*
* @return void
*/
protected static function ensureIntlExtensionIsInstalled()
{
if (! extension_loaded('intl')) {
throw new RuntimeException('The "intl" PHP extension is required to use this method.');
}
}
}
178 changes: 178 additions & 0 deletions tests/Support/SupportNumberTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

namespace Illuminate\Tests\Support;

use Illuminate\Support\Number;
use PHPUnit\Framework\TestCase;

class SupportNumberTest extends TestCase
{
public function testFormat()
{
$this->needsIntlExtension();

$this->assertSame('0', Number::format(0));
$this->assertSame('1', Number::format(1));
$this->assertSame('10', Number::format(10));
$this->assertSame('25', Number::format(25));
$this->assertSame('100', Number::format(100));
$this->assertSame('100,000', Number::format(100000));
$this->assertSame('123,456,789', Number::format(123456789));

$this->assertSame('-1', Number::format(-1));
$this->assertSame('-10', Number::format(-10));
$this->assertSame('-25', Number::format(-25));

$this->assertSame('0.2', Number::format(0.2));
$this->assertSame('1.23', Number::format(1.23));
$this->assertSame('-1.23', Number::format(-1.23));
$this->assertSame('123.456', Number::format(123.456));

$this->assertSame('', Number::format(INF));
$this->assertSame('NaN', Number::format(NAN));
}

public function testFormatWithDifferentLocale()
{
$this->needsIntlExtension();

$this->assertSame('123,456,789', Number::format(123456789, 'en'));
$this->assertSame('123.456.789', Number::format(123456789, 'de'));
$this->assertSame('123 456 789', Number::format(123456789, 'fr'));
$this->assertSame('123 456 789', Number::format(123456789, 'ru'));
$this->assertSame('123 456 789', Number::format(123456789, 'sv'));
}

public function testFormatWithAppLocale()
{
$this->needsIntlExtension();

$this->assertSame('123,456,789', Number::format(123456789));

Number::useLocale('de');

$this->assertSame('123.456.789', Number::format(123456789));

Number::useLocale('en');
}

public function testToPercent()
{
$this->needsIntlExtension();

$this->assertSame('0%', Number::toPercentage(0, precision: 0));
$this->assertSame('0%', Number::toPercentage(0));
$this->assertSame('1%', Number::toPercentage(1));
$this->assertSame('10.00%', Number::toPercentage(10, precision: 2));
$this->assertSame('100%', Number::toPercentage(100));
$this->assertSame('100.00%', Number::toPercentage(100, precision: 2));

$this->assertSame('300%', Number::toPercentage(300));
$this->assertSame('1,000%', Number::toPercentage(1000));

$this->assertSame('2%', Number::toPercentage(1.75));
$this->assertSame('1.75%', Number::toPercentage(1.75, precision: 2));
$this->assertSame('1.750%', Number::toPercentage(1.75, precision: 3));
$this->assertSame('0%', Number::toPercentage(0.12345));
$this->assertSame('0.12%', Number::toPercentage(0.12345, precision: 2));
$this->assertSame('0.1235%', Number::toPercentage(0.12345, precision: 4));
}

public function testToCurrency()
{
$this->needsIntlExtension();

$this->assertSame('$0.00', Number::toCurrency(0));
$this->assertSame('$1.00', Number::toCurrency(1));
$this->assertSame('$10.00', Number::toCurrency(10));

$this->assertSame('€0.00', Number::toCurrency(0, 'EUR'));
$this->assertSame('€1.00', Number::toCurrency(1, 'EUR'));
$this->assertSame('€10.00', Number::toCurrency(10, 'EUR'));

$this->assertSame('-$5.00', Number::toCurrency(-5));
$this->assertSame('$5.00', Number::toCurrency(5.00));
$this->assertSame('$5.32', Number::toCurrency(5.325));
}

public function testToCurrencyWithDifferentLocale()
{
$this->needsIntlExtension();

$this->assertSame('1,00 €', Number::toCurrency(1, 'EUR', 'de'));
$this->assertSame('1,00 $', Number::toCurrency(1, 'USD', 'de'));
$this->assertSame('1,00 £', Number::toCurrency(1, 'GBP', 'de'));

$this->assertSame('123.456.789,12 $', Number::toCurrency(123456789.12345, 'USD', 'de'));
$this->assertSame('123.456.789,12 €', Number::toCurrency(123456789.12345, 'EUR', 'de'));
$this->assertSame('1 234,56 $US', Number::toCurrency(1234.56, 'USD', 'fr'));
}

public function testBytesToHuman()
{
$this->assertSame('0 B', Number::toFileSize(0));
$this->assertSame('1 B', Number::toFileSize(1));
$this->assertSame('1 KB', Number::toFileSize(1024));
$this->assertSame('2 KB', Number::toFileSize(2048));
$this->assertSame('2.00 KB', Number::toFileSize(2048, precision: 2));
$this->assertSame('1.23 KB', Number::toFileSize(1264, precision: 2));
$this->assertSame('1.234 KB', Number::toFileSize(1264, 3));
$this->assertSame('5 GB', Number::toFileSize(1024 * 1024 * 1024 * 5));
$this->assertSame('10 TB', Number::toFileSize((1024 ** 4) * 10));
$this->assertSame('10 PB', Number::toFileSize((1024 ** 5) * 10));
$this->assertSame('1 ZB', Number::toFileSize(1024 ** 7));
$this->assertSame('1 YB', Number::toFileSize(1024 ** 8));
$this->assertSame('1,024 YB', Number::toFileSize(1024 ** 9));
}

public function testToHuman()
{
$this->assertSame('1', Number::forHumans(1));
$this->assertSame('10', Number::forHumans(10));
$this->assertSame('100', Number::forHumans(100));
$this->assertSame('1 thousand', Number::forHumans(1000));
$this->assertSame('1 million', Number::forHumans(1000000));
$this->assertSame('1 billion', Number::forHumans(1000000000));
$this->assertSame('1 trillion', Number::forHumans(1000000000000));
$this->assertSame('1 quadrillion', Number::forHumans(1000000000000000));
$this->assertSame('1 thousand quadrillion', Number::forHumans(1000000000000000000));

$this->assertSame('123', Number::forHumans(123));
$this->assertSame('1 thousand', Number::forHumans(1234));
$this->assertSame('1.23 thousand', Number::forHumans(1234, precision: 2));
$this->assertSame('12 thousand', Number::forHumans(12345));
$this->assertSame('1 million', Number::forHumans(1234567));
$this->assertSame('1 billion', Number::forHumans(1234567890));
$this->assertSame('1 trillion', Number::forHumans(1234567890123));
$this->assertSame('1.23 trillion', Number::forHumans(1234567890123, precision: 2));
$this->assertSame('1 quadrillion', Number::forHumans(1234567890123456));
$this->assertSame('1.23 thousand quadrillion', Number::forHumans(1234567890123456789, precision: 2));
$this->assertSame('490 thousand', Number::forHumans(489939));
$this->assertSame('489.9390 thousand', Number::forHumans(489939, precision: 4));
$this->assertSame('500.00000 million', Number::forHumans(500000000, precision: 5));

$this->assertSame('1 million quadrillion', Number::forHumans(1000000000000000000000));
$this->assertSame('1 billion quadrillion', Number::forHumans(1000000000000000000000000));
$this->assertSame('1 trillion quadrillion', Number::forHumans(1000000000000000000000000000));
$this->assertSame('1 quadrillion quadrillion', Number::forHumans(1000000000000000000000000000000));
$this->assertSame('1 thousand quadrillion quadrillion', Number::forHumans(1000000000000000000000000000000000));

$this->assertSame('0', Number::forHumans(0));
$this->assertSame('-1', Number::forHumans(-1));
$this->assertSame('-10', Number::forHumans(-10));
$this->assertSame('-100', Number::forHumans(-100));
$this->assertSame('-1 thousand', Number::forHumans(-1000));
$this->assertSame('-1 million', Number::forHumans(-1000000));
$this->assertSame('-1 billion', Number::forHumans(-1000000000));
$this->assertSame('-1 trillion', Number::forHumans(-1000000000000));
$this->assertSame('-1 quadrillion', Number::forHumans(-1000000000000000));
$this->assertSame('-1 thousand quadrillion', Number::forHumans(-1000000000000000000));
}

protected function needsIntlExtension()
{
if (! extension_loaded('intl')) {
$this->markTestSkipped('The intl extension is not installed. Please install the extension to enable '.__CLASS__);
}
}
}

0 comments on commit 6e4ecc7

Please sign in to comment.