diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46ca0f..2764120 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,15 @@ name: CI on: [pull_request] jobs: tests: - name: Math CI PHP ${{ matrix.php-versions }} + name: Math (PHP ${{ matrix.php-versions }} / Orchestra ${{ matrix.orchestra-versions }}) runs-on: ubuntu-latest strategy: matrix: php-versions: [ '8.2', '8.1' ] + orchestra-versions: [ '8.0', '9.0' ] + exclude: + - php-versions: 8.1 + orchestra-versions: 9.0 steps: - name: Checkout @@ -26,8 +30,8 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ matrix.php-versions }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ matrix.php-versions }}-composer- + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- - name: Remove composer.lock run: rm -f composer.lock @@ -35,6 +39,9 @@ jobs: - name: Remove Pint run: composer remove "laravel/pint" --dev --no-update + - name: Install Orchestra ${{ matrix.orchestra-versions }} + run: composer require "orchestra/testbench:^${{ matrix.orchestra-versions }}" --dev --no-update + - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index a1eba36..6aca2b9 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -29,8 +29,8 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} - key: 8.2-composer-${{ hashFiles('**/composer.json') }} - restore-keys: 8.2-composer- + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- - name: Remove composer.lock run: rm -f composer.lock @@ -46,6 +46,8 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: files: ./coverage.xml flags: unittests diff --git a/README.md b/README.md index c16bc32..1ec397a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Math -[![CI](https://github.com/fab2s/Math/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/Math/actions/workflows/ci.yml) [![QA](https://github.com/fab2s/Math/actions/workflows/qa.yml/badge.svg)](https://github.com/fab2s/Math/actions/workflows/qa.yml) [![Total Downloads](https://poser.pugx.org/fab2s/math/downloads)](//packagist.org/packages/fab2s/math) [![Monthly Downloads](https://poser.pugx.org/fab2s/math/d/monthly)](//packagist.org/packages/fab2s/math) [![Latest Stable Version](https://poser.pugx.org/fab2s/math/v/stable)](https://packagist.org/packages/fab2s/math) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fab2s/Math/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/fab2s/Math/?branch=master) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) [![License](https://poser.pugx.org/fab2s/math/license)](https://packagist.org/packages/fab2s/math) +[![CI](https://github.com/fab2s/Math/actions/workflows/ci.yml/badge.svg)](https://github.com/fab2s/Math/actions/workflows/ci.yml) [![QA](https://github.com/fab2s/Math/actions/workflows/qa.yml/badge.svg)](https://github.com/fab2s/Math/actions/workflows/qa.yml) [![Total Downloads](https://poser.pugx.org/fab2s/math/downloads)](//packagist.org/packages/fab2s/math) [![Monthly Downloads](https://poser.pugx.org/fab2s/math/d/monthly)](//packagist.org/packages/fab2s/math) [![Latest Stable Version](https://poser.pugx.org/fab2s/math/v/stable)](https://packagist.org/packages/fab2s/math) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) [![License](https://poser.pugx.org/fab2s/math/license)](https://packagist.org/packages/fab2s/math) A fluent [bcmath](https://php.net/bcmath) based _Helper_ to handle high precision calculus in base 10 with a rather strict approach (want precision for something right?). It does not try to be smart and just fails without `bcmath`, but it does auto detect [GMP](https://php.net/GMP) for faster base conversions. @@ -19,7 +19,7 @@ composer require "fab2s/math" ## Prerequisites -`Math` requires [bcmath](https://php.net/bcmath), [GMP](https://php.net/GMP) is auto detected and used when available for faster base conversions (up to 62). +`Math` requires [bcmath](https://php.net/bcmath), [GMP](https://php.net/GMP) is auto-detected and used when available for faster base conversions (up to 62). ## In practice @@ -110,7 +110,7 @@ Doing so is actually faster than casting a pre-existing instance to string becau Arguments should be string or `Math`, but it is _ok_ to use integers up to `INT_(32|64)`. **DO NOT** use `floats` as casting them to `string` may result in local dependent format, such as using a coma instead of a dot for decimals or just turn them exponential notation which is not supported by bcmath. -The way floats are handled in general and by PHP in particular is the very the reason why `bcmath` exists, so even if you trust your locale settings, using floats still kinda defeats the purpose of using such lib. +The way floats are handled in general and by PHP in particular is the very reason why `bcmath` exists, so even if you trust your locale settings, using floats still kinda defeats the purpose of using such lib. ## Internal precision @@ -126,9 +126,35 @@ $number = (new Math('100'))->div('3'); // uses precision 18 $number->setPrecision(14); // will use precision 14 for any further calculations ``` +## Laravel + +For those using [Laravel](https://laravel.com/), `Math` comes with a Laravel caster: [MathCaster](./src/Laravel/MathCast.php) which you can use to directly cast your model properties. + +````php +use fab2s\Math\Laravel\MathCast; + +class MyModel extends Model +{ + protected $casts = [ + 'not_nullable' => MathCast::class, + 'nullable' => MathCast::class . ':nullable', + ]; +} + +$model = new MyModel; + +$model->not_nullable = 41; +$model->not_nullable->add(1)->eq(42); // true + +$model->not_nullable = null; // throw a NotNullableException + +$model->nullabe = null; // is ok + +```` + ## Requirements -`Math` is tested against php 8.1 and 8.2 +`Math` is tested against php 8.1 and 8.2. Additionally, MathCast is tested against Laravel 10 and 11. ## Contributing diff --git a/composer.json b/composer.json index c1633cb..74a09e9 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,13 @@ ], "require" : { "php": "^8.1", - "ext-bcmath": "*" + "ext-bcmath": "*", + "fab2s/context-exception": "^2.0|^3.0" }, "require-dev": { "phpunit/phpunit": "^10.0", "laravel/pint": "^1.11", - "orchestra/testbench": "^7.0|^8.0" + "orchestra/testbench": "^8.0|^9.0" }, "autoload": { "classmap": [ diff --git a/src/Laravel/Exception/NotNullableException.php b/src/Laravel/Exception/NotNullableException.php new file mode 100644 index 0000000..d245a84 --- /dev/null +++ b/src/Laravel/Exception/NotNullableException.php @@ -0,0 +1,28 @@ +setContext([ + 'model' => $modelClass, + 'data' => $model->toArray(), + ]) + ; + } +} diff --git a/src/Laravel/MathCast.php b/src/Laravel/MathCast.php new file mode 100644 index 0000000..3480604 --- /dev/null +++ b/src/Laravel/MathCast.php @@ -0,0 +1,57 @@ +isNullable = in_array('nullable', $options); + } + + /** + * Cast the given value. + * + * @param Model $model + * + * @throws NotNullableException + */ + public function get($model, string $key, $value, array $attributes): ?Math + { + return Math::isNumber($value) ? Math::number($value) : $this->handleNullable($model, $key); + } + + /** + * Prepare the given value for storage. + * + * @param Model $model + * + * @throws NotNullableException + */ + public function set($model, string $key, $value, array $attributes): ?string + { + return Math::isNumber($value) ? (string) Math::number($value) : $this->handleNullable($model, $key); + } + + /** + * @throws NotNullableException + */ + protected function handleNullable(Model $model, string $key) + { + return $this->isNullable ? null : throw NotNullableException::make($key, $model); + } +} diff --git a/src/Math.php b/src/Math.php index b1e5c18..9c9696c 100644 --- a/src/Math.php +++ b/src/Math.php @@ -21,6 +21,7 @@ class Math extends MathOpsAbstract implements JsonSerializable, Stringable public function __construct(string|int|float|Math $number) { if (isset(static::$globalPrecision)) { + /* @codeCoverageIgnore */ $this->precision = static::$globalPrecision; } @@ -43,26 +44,26 @@ public static function make(string|int|float|Math $number): static } /** - * convert any based value bellow or equals to 64 to its decimal value + * convert any based value bellow or equals to 62 to its decimal value */ - public static function fromBase(string|int $number, int $base): static + public static function fromBase(string $number, int $base): static { - // trim base 64 padding char, only positive - $number = trim($number, ' =-'); + // only positive + $number = trim($number, ' -'); if ($number === '' || str_contains($number, '.')) { throw new InvalidArgumentException('Argument number is not an integer'); } $baseChar = static::getBaseChar($base); + // By now we know we have a correct base and number if (trim($number, $baseChar[0]) === '') { return new static('0'); } - if (static::$gmpSupport && $base <= 62) { + if (static::$gmpSupport) { return new static(static::baseConvert($number, $base, 10)); } - // By now we know we have a correct base and number return new static(static::bcDec2Base($number, $base, $baseChar)); } @@ -92,7 +93,7 @@ public function eq(string|int|float|Math $number): bool } /** - * convert decimal value to any other base bellow or equals to 64 + * convert decimal value to any other base bellow or equals to 62 */ public function toBase(string|int $base): string { @@ -100,9 +101,11 @@ public function toBase(string|int $base): string throw new InvalidArgumentException('Argument number is not an integer'); } + static::validateBase($base = (int) static::validatePositiveInteger($base)); + // do not mutate, only support positive integers $number = ltrim((string) $this, '-'); - if (static::$gmpSupport && $base <= 62) { + if (static::$gmpSupport) { return static::baseConvert($number, 10, $base); } diff --git a/src/MathBaseAbstract.php b/src/MathBaseAbstract.php index 70f6e0b..44dac53 100644 --- a/src/MathBaseAbstract.php +++ b/src/MathBaseAbstract.php @@ -21,11 +21,6 @@ abstract class MathBaseAbstract */ const PRECISION = 9; - /** - * base <= 64 charlist - */ - const BASECHAR_64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - /** * base <= 62 char list */ @@ -39,17 +34,16 @@ abstract class MathBaseAbstract /** * highest base supported */ - const BASE_MAX = 64; + const BASE_MAX = 62; /** - * base char cache for all supported bases (bellow 64) + * base char cache for all supported bases (up to 62) * * @var array */ protected static array $baseChars = [ 36 => self::BASECHAR_36, 62 => self::BASECHAR_62, - 64 => self::BASECHAR_64, ]; /** @@ -130,7 +124,7 @@ public static function isNumber(string|int|float|Math|null $number): bool /** * Validation flavour of normalization logic */ - public static function normalizeNumber(string|int|float|Math $number, string|int|null $default = null): ?string + public static function normalizeNumber(string|int|float|Math|null $number, Math|string|int|float|null $default = null): ?string { if (! static::isNumber($number)) { return $default; @@ -147,10 +141,6 @@ public static function getBaseChar(string|int $base): string static::validateBase($base = (int) static::validatePositiveInteger($base)); - if ($base > 62) { - return static::$baseChars[$base] = substr(static::BASECHAR_64, 0, $base); - } - if ($base > 36) { return static::$baseChars[$base] = substr(static::BASECHAR_62, 0, $base); } @@ -194,12 +184,12 @@ protected static function normalizeReal(string|int $number): string */ protected static function validateBase(int $base): void { - if ($base < 2 || $base > self::BASE_MAX || ! static::gmpSupport() && $base > 62) { - throw new InvalidArgumentException('Argument base is not valid, base 2 to ' . (static::gmpSupport() ? 64 : 62) . ' are supported'); + if ($base < 2 || $base > self::BASE_MAX) { + throw new InvalidArgumentException('Argument base is not valid, base 2 to ' . self::BASE_MAX . ' are supported'); } } - protected static function bcDec2Base(string|int $number, string|int $base, string $baseChar): string + protected static function bcDec2Base(string $number, int $base, string $baseChar): string { $result = ''; $numberLen = strlen($number); diff --git a/tests/Laravel/Artifacts/CastModel.php b/tests/Laravel/Artifacts/CastModel.php new file mode 100644 index 0000000..dde612d --- /dev/null +++ b/tests/Laravel/Artifacts/CastModel.php @@ -0,0 +1,23 @@ + MathCast::class, + 'nullable' => MathCast::class . ':nullable', + ]; +} diff --git a/tests/Laravel/MathCastTest.php b/tests/Laravel/MathCastTest.php new file mode 100644 index 0000000..05b9b2c --- /dev/null +++ b/tests/Laravel/MathCastTest.php @@ -0,0 +1,116 @@ +assertTrue($expected->eq($cast->get(new CastModel, 'key', $value, []))); + break; + case is_string($expected): + $this->expectException(NotNullableException::class); + $cast->get(new CastModel, 'key', $value, []); + break; + case $expected === null: + $this->assertNull($cast->get(new CastModel, 'key', $value, [])); + break; + } + } + + /** + * @throws NotNullableException + */ + #[DataProvider('castProvider')] + public function test_math_cast_set( + Math|string|int|float|null $value, + Math|string|null $expected, + array $options = [], + ): void { + $cast = new MathCast(...$options); + + switch (true) { + case is_object($expected): + $this->assertSame((string) $expected, $cast->set(new CastModel, 'key', $value, [])); + break; + case is_string($expected): + $this->expectException(NotNullableException::class); + $cast->set(new CastModel, 'key', $value, []); + break; + case $expected === null: + $this->assertSame(null, $cast->set(new CastModel, 'key', $value, [])); + break; + } + } + + public static function castProvider(): array + { + return [ + [ + 'value' => null, + 'expected' => null, + 'options' => ['nullable'], + ], + [ + 'value' => Math::number(42.42), + 'expected' => Math::number(42.42), + 'options' => ['nullable'], + ], + [ + 'value' => Math::number(42.42), + 'expected' => Math::number(42.42), + ], + [ + 'value' => null, + 'expected' => NotNullableException::class, + ], + [ + 'value' => '42.4200000', + 'expected' => Math::number(42.42), + 'options' => ['nullable'], + ], + [ + 'value' => 42.42, + 'expected' => Math::number(42.42), + 'options' => ['nullable'], + ], + [ + 'value' => 42, + 'expected' => Math::number(42), + 'options' => ['nullable'], + ], + ]; + } +} diff --git a/tests/MathExceptionTest.php b/tests/MathExceptionTest.php new file mode 100644 index 0000000..15c47e5 --- /dev/null +++ b/tests/MathExceptionTest.php @@ -0,0 +1,88 @@ +expectException(InvalidArgumentException::class); + $testClass::validateBaseTest(128); + } + + public function test_bc_dec_2_base() + { + $testClass = new class(0) extends Math + { + public static function bcDec2BaseTest(string $number, int $base, string $baseChar): void + { + self::bcDec2Base($number, $base, $baseChar); + } + }; + + $this->expectException(InvalidArgumentException::class); + $testClass::bcDec2BaseTest('$', 42, Math::getBaseChar(62)); + } + + public function test_validate_input_number() + { + $testClass = new class(0) extends Math + { + public static function validateInputNumberTest(string|int|float|Math $number): void + { + self::validateInputNumber($number); + } + }; + + $this->expectException(InvalidArgumentException::class); + $testClass::validateInputNumberTest('NaN'); + } + + public function test_positive_integer() + { + $testClass = new class(0) extends Math + { + public static function validatePositiveIntegerTest(string|int $number): void + { + self::validatePositiveInteger($number); + } + }; + + $this->expectException(InvalidArgumentException::class); + $testClass::validatePositiveIntegerTest(0); + } + + public function test_from_base() + { + $this->expectException(InvalidArgumentException::class); + Math::fromBase('LZ.LZ', 62); + } + + public function test_to_base() + { + $this->expectException(InvalidArgumentException::class); + Math::make(42.42)->toBase(62); + } +} diff --git a/tests/MathTest.php b/tests/MathTest.php index 5ac9124..c5d1635 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -11,12 +11,59 @@ use fab2s\Math\Math; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; /** * Class MathTest */ -class MathTest extends \PHPUnit\Framework\TestCase +class MathTest extends TestCase { + public function test_precision() + { + $this->assertSame( + '42.42', + (string) Math::number(42.42) + ->setPrecision(2) + ->add('0.0042'), + ); + + $this->assertSame( + '42.4242', + (string) Math::number(42.42) + ->add('0.0042'), + ); + + Math::setGlobalPrecision(2); + + $this->assertSame( + '42.42', + (string) Math::number(42.42) + ->add('0.0042'), + ); + + Math::setGlobalPrecision(Math::PRECISION); + + $this->assertSame( + '42.4242', + (string) Math::number(42.42) + ->add('0.0042'), + ); + } + + public function test_json_serialize() + { + $number = Math::number(42); + $this->assertSame((string) $number, $number->jsonSerialize()); + + $this->assertSame(json_encode((string) $number), json_encode(Math::number(42))); + } + + public function test_normalize_number() + { + $this->assertSame('42', Math::normalizeNumber('000042.0000')); + $this->assertSame('42', Math::normalizeNumber(null, 42)); + } + public static function number_formatData(): array { return [ @@ -1041,6 +1088,11 @@ public function test_base_convert(string|int|Math $number, string $base) Math::number($number)->toBase($base), ); + $this->assertSame( + (string) Math::number($number), + (string) Math::fromBase(Math::number($number)->toBase($base), $base), + ); + Math::gmpSupport(null); }