-
Notifications
You must be signed in to change notification settings - Fork 11.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[10.x] Add a
Number
utility class (#48845)
* 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
1 parent
3a3a9cc
commit 6e4ecc7
Showing
2 changed files
with
339 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Sorry, something went wrong. |
||
* @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.'); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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__); | ||
} | ||
} | ||
} |
@taylorotwell I think this typo. It's should
int|float $number