Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10.x] Add a Number utility class #48845

Merged
merged 21 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
caendesilva marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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);
Copy link
Contributor Author

@caendesilva caendesilva Nov 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. I would very much like some thoughts on this matter.

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

}

/**
* 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
* @param int $precision
* @return string
*/
public static function forHumans(int|float $number, int $precision = 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about other languages?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe the __() function could be used to provide translation support?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
$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__);
}
}
}