From eb363caf7121f37384daa63fdeb4690ed96c02e1 Mon Sep 17 00:00:00 2001 From: fab2s Date: Thu, 22 Jan 2026 02:44:31 +0100 Subject: [PATCH 1/8] Bump php --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2764120..56f53a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-versions: [ '8.2', '8.1' ] - orchestra-versions: [ '8.0', '9.0' ] + php-versions: [ '8.4', '8.3', '8.2', '8.1' ] + orchestra-versions: [ '8.0', '9.0', '10.0' ] exclude: - php-versions: 8.1 orchestra-versions: 9.0 + - php-versions: 8.1 + orchestra-versions: 10.0 steps: - name: Checkout From a13dace428573642468913d3042d035585261ca3 Mon Sep 17 00:00:00 2001 From: fab2s Date: Thu, 22 Jan 2026 03:17:57 +0100 Subject: [PATCH 2/8] doc --- README.md | 341 +++++++++++++++++++++++---------- composer.json | 4 +- tests/Laravel/MathCastTest.php | 2 +- 3 files changed, 238 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 65a942a..e02ed6d 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,294 @@ # 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) [![codecov](https://codecov.io/gh/fab2s/Math/graph/badge.svg?token=6JD33CQLE3)](https://codecov.io/gh/fab2s/Math) [![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) +[![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) +[![codecov](https://codecov.io/gh/fab2s/Math/graph/badge.svg?token=6JD33CQLE3)](https://codecov.io/gh/fab2s/Math) +[![Latest Stable Version](https://poser.pugx.org/fab2s/math/v/stable)](https://packagist.org/packages/fab2s/math) +[![Total Downloads](https://poser.pugx.org/fab2s/math/downloads)](https://packagist.org/packages/fab2s/math) +[![Monthly Downloads](https://poser.pugx.org/fab2s/math/d/monthly)](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. +A fluent, high-precision arithmetic library for PHP built on [bcmath](https://php.net/bcmath). Designed for financial calculations, scientific computing, and anywhere floating-point errors are unacceptable. -> `Bcmath` supports numbers of any size and precision up to 2 147 483 647 (or 0x7FFFFFFF) decimals, if there is sufficient memory, represented as strings. +## The Problem -## Installation - -Math can be installed using composer : +Floating-point arithmetic has well-known precision limitations: +```php +var_dump((0.1 + 0.7) == 0.8); // false +echo (1.4 - 1) * 100; // 40.000000000000006 +echo 0.7 + 0.1 - 0.8; // -1.1102230246252E-16 ``` -composer require "fab2s/math" + +> `bcmath` supports numbers of any size and precision up to 2,147,483,647 decimals, represented as strings. + +## Installation + +```bash +composer require fab2s/math ``` -`Math` is also included in [OpinHelper](https://github.com/fab2s/OpinHelpers) which packages several bellow "Swiss Army Knife" level Helpers covering some of the most annoying aspects of php programing, such as UTF8 string manipulation, high precision Mathematics or properly locking a file +### Requirements -## Prerequisites +- PHP 8.1+ +- ext-bcmath (required) +- ext-gmp (optional, enables ~20x faster base conversions) -`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). +## Features -## In practice +### Fluent API -As `Math` is meant to be used where precision matters, it is pretty strict with input numbers : it will throw an exception whenever an input number does not match `^[+-]?([0-9]+(\.[0-9]+)?|\.[0-9]+)$` after passing though `trim()`. +Chain operations naturally with variadic argument support: -In practice this means that "-.0051" and "00028.34" are ok, but "1E12", "3,14" or "1.1.1" will throw an exception. This is done so because in `bcmath` world, "1E12", "1.1.1" and "abc" are all "0", which could result in some disaster if you where to do nothing. +```php +use fab2s\Math\Math; -A `Math` instance is just initialized with a valid base 10 number. From there you can do the math and just cast the instance as string to get the current result at any stage. +$result = Math::number('100') + ->add('10', '20', '30') // 160 + ->mul('2') // 320 + ->div('4') // 80 + ->sub('38'); // 42 + +echo $result; // '42' +``` + +> **Important:** Math instances are **mutable**. Operations modify the instance in place and return `$this` for chaining. To preserve the original value, wrap it in a new instance: +> +> ```php +> $original = Math::number('100'); +> $modified = $original->add('50'); // $original is now also '150' +> +> // To keep $original unchanged: +> $original = Math::number('100'); +> $modified = Math::number($original)->add('50'); // $original stays '100' +> ``` + +### Strict Validation + +Math rejects ambiguous inputs that bcmath would silently convert to `0`: ```php -// instance way -$number = new Math('42'); - -// fluent grammar -$result = (string) $number->add('1')->sub(2)->div(1)->add(1)->mul(-1); // '-42' - -// factory way: number -$result = (string) Math::number('42')->add('1')->sub(2)->div('1')->add(1)->mul(-1); // '-42' - -// factory way: fromBase -$result = (string) Math::fromBase('LZ', 62); // '1337' -$result = (string) Math::fromBase('LZ', 62)->sub(1295); // '42' - -// combos -$number = Math::number('42') - ->add(Math::fromBase('LZ', 62), '-42') - ->sub('1337', '42') - ->mul(3, 4, 1) - ->div(4, 3) - ->sub('.1') - ->abs() - ->round(0) - ->floor() - ->ceil() - ->min('512', '256') - ->max('8', '16', '32'); - -// formatting does not mutate internal number -$result = (string) $number->format(2); // '42.00' -$result = (string) $number; // '42'; -// and you can continue calculating after string cast -$result = (string) $number->add('1295')->toBase(62); // 'LZ' - -// toBase does not mutate base 10 internal representation -$result = (string) $number; // '1337'; +// Valid +Math::number('42'); +Math::number('-0.005'); +Math::number('.5'); + +// Throws exception +Math::number('1E12'); // Exponential notation +Math::number('3,14'); // Comma separator +Math::number('$100'); // Currency symbols ``` -The string form of any such calculus is normalized (things like '-0', '+.0' or '0.00' to '0'), which means that you can accurately compare `Math` instances results: +### Full Arithmetic Operations ```php -$result = (string) Math::number('0000042.000000'); // '42' +$n = Math::number('100'); + +// Basic +$n->add(...$nums); // Addition +$n->sub(...$nums); // Subtraction +$n->mul(...$nums); // Multiplication +$n->div(...$nums); // Division + +// Advanced +$n->sqrt(); // Square root +$n->pow('2'); // Power +$n->mod('7'); // Modulo +$n->powMod($e, $m); // Modular exponentiation +$n->abs(); // Absolute value + +// Rounding +$n->round(2); // Round to 2 decimals +$n->floor(); // Round down +$n->ceil(); // Round up + +// Limits +$n->min('50', '200'); // 50 +$n->max('50', '200'); // 200 +``` -// raw form -$result = Math::number('0000042.000000')->getNumber(); // '0000042.000000' +### Comparisons -// with some tolerance -$result = Math::number(' 42.0000 ')->getNumber(); // '42.0000' +```php +$n = Math::number('42'); -// at all time -if ((string) $number1 === (string) $number2) { - // both instance numbers are equals -} +$n->eq('42'); // true — equal +$n->gt('40'); // true — greater than +$n->gte('42'); // true — greater than or equal +$n->lt('50'); // true — less than +$n->lte('42'); // true — less than or equal +``` -// same as (internally using bccomp) -if ($number1->eq($number2)) { - // both instance numbers are equals -} +### Base Conversion (2-62) + +Uses GMP when available for faster conversions: + +```php +// From base X to base 10 +Math::fromBase('LZ', 62); // '1337' +Math::fromBase('101010', 2); // '42' +Math::fromBase('FF', 16); // '255' + +// From base 10 to base X +Math::number('1337')->toBase(62); // 'LZ' +Math::number('42')->toBase(2); // '101010' +Math::number('255')->toBase(16); // 'FF' ``` -You can transparently re-use partial $calculus directly as instance when calculating: +### Formatting + +Formatting does not mutate the internal number: ```php -$number = new Math('42'); -// same as -$number = Math::number('42'); +$n = Math::number('1234567.891'); + +echo $n->format(2); // '1234567.89' +echo $n->format(2, ',', ' '); // '1 234 567,89' +echo $n; // '1234567.891' (unchanged) +``` -// in constructor -$result = (string) (new Math($number))->div('2'); // '21' -// same as -$result = (string) Math::number($number)->div('2'); // '21' +### Precision Control -// in calc method -$result = (string) Math::number('42')->add($number)->sub('42')->div('2'); // '21' +Default precision is 9 decimal places. Control it globally or per-instance: + +```php +// Global (affects new instances) +Math::setGlobalPrecision(18); + +// Per-instance +$n = Math::number('100')->setPrecision(4); +echo $n->div('3'); // '33.3333' ``` -Doing so is actually faster than casting a pre-existing instance to string because it does not trigger a normalization (internal number state is only normalized when exporting result) nor a number validation, as internal $number is already valid at all times. +> Precision is not handled via `bcscale()` to avoid global state issues in long-running processes. -Arguments should be string or `Math`, but it is _ok_ to use integers up to `INT_(32|64)`. +### Normalized Output -**YOU SHOULD 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 reason why `bcmath` exists, so even if you trust your locale settings, using floats still kinda defeats the purpose of using such lib. +Results are automatically normalized for accurate comparisons: -## Internal precision +```php +echo Math::number('0000042.000'); // '42' +echo Math::number('-0'); // '0' +echo Math::number('+.500'); // '0.5' -Precision handling does not rely on [bcscale](https://php.net/bcscale) as it is not so reliable IRL. As it is a global setup, it may affect or be affected by far away/unrelated code (with fpm it can actually spread to all PHP processes). +// Raw access when needed +Math::number('0042.00')->getNumber(); // '0042.00' +``` + +### Instance Reuse -`Math` handle precisions at both instance and global (limited to the current PHP process) precision. The global precision is stored in a static variable. When set, each new instance will start with this global precision as its own precision (you can still set the instance precision after instantiation). When no global precision is set, initial instance precision defaults to `Math::PRECISION` (currently 9, or 9 digits after the dot) +Pass Math instances directly to avoid re-validation: ```php -// set global precision -Math::setGlobalPrecision(18); +$tax = Math::number('0.20'); +$price = Math::number('99.99'); -$number = (new Math('100'))->div('3'); // uses precision 18 -$number->setPrecision(14); // will use precision 14 for any further calculations +$total = Math::number($price)->add($price->mul($tax)); ``` -## Laravel +## Laravel Integration -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. +Cast Eloquent model attributes to Math instances: -````php +```php use fab2s\Math\Laravel\MathCast; -class MyModel extends Model +class Order extends Model { - protected $casts = [ - 'not_nullable' => MathCast::class, - 'nullable' => MathCast::class . ':nullable', + protected $casts = [ + 'total' => MathCast::class, + 'discount' => MathCast::class . ':nullable', ]; } -$model = new MyModel; - -$model->not_nullable = 41; -$model->not_nullable->add(1)->eq(42); // true +$order = new Order; +$order->total = '99.99'; +$order->total->mul('1.2')->format(2); // '119.99' -$model->not_nullable = null; // throw a NotNullableException - -$model->nullabe = null; // is ok - -```` - -## Requirements +$order->discount = null; // OK (nullable) +$order->total = null; // Throws NotNullableException +``` -`Math` is tested against php 8.1 and 8.2. Additionally, MathCast is tested against Laravel 10 and 11. +## API Reference + +### Factory Methods + +| Method | Description | +|--------|-------------| +| `Math::number($n)` | Create instance from number | +| `Math::fromBase($n, $base)` | Create from base 2-62 | +| `new Math($n, $precision)` | Constructor with optional precision | + +### Arithmetic + +| Method | Description | +|--------|-------------| +| `add(...$n)` | Addition | +| `sub(...$n)` | Subtraction | +| `mul(...$n)` | Multiplication | +| `div(...$n)` | Division | +| `mod($n)` | Modulo | +| `pow($n)` | Power | +| `powMod($exp, $mod)` | Modular exponentiation | +| `sqrt()` | Square root | +| `abs()` | Absolute value | + +### Rounding + +| Method | Description | +|--------|-------------| +| `round($precision)` | Round to precision | +| `floor()` | Round down | +| `ceil()` | Round up | + +### Comparison + +| Method | Description | +|--------|-------------| +| `eq($n)` | Equal | +| `gt($n)` | Greater than | +| `gte($n)` | Greater than or equal | +| `lt($n)` | Less than | +| `lte($n)` | Less than or equal | +| `min(...$n)` | Minimum value | +| `max(...$n)` | Maximum value | + +### Conversion & Output + +| Method | Description | +|--------|-------------| +| `toBase($base)` | Convert to base 2-62 | +| `format($dec, $point, $sep)` | Format with separators | +| `getNumber()` | Get raw (non-normalized) number | +| `(string)` | Get normalized number | + +### Precision + +| Method | Description | +|--------|-------------| +| `setPrecision($p)` | Set instance precision | +| `getPrecision()` | Get instance precision | +| `Math::setGlobalPrecision($p)` | Set default for new instances | +| `Math::getGlobalPrecision()` | Get global precision | + +## Compatibility + +| PHP | Laravel | +|-----|---------| +| 8.1 | 10 | +| 8.2 | 10, 11, 12 | +| 8.3 | 10, 11, 12 | +| 8.4 | 10, 11, 12 | + +## Related + +`Math` is also included in [OpinHelpers](https://github.com/fab2s/OpinHelpers), a collection of utilities for common PHP challenges. ## Contributing -Contributions are welcome, do not hesitate to open issues and submit pull requests. +Contributions are welcome. Please open issues and submit pull requests. ## License -`Math` is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). \ No newline at end of file +Math is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/composer.json b/composer.json index dbc5cb2..7b8cd86 100644 --- a/composer.json +++ b/composer.json @@ -24,9 +24,9 @@ "fab2s/context-exception": "^2.0|^3.0" }, "require-dev": { - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^10.0|^11.0", "laravel/pint": "^1.11", - "orchestra/testbench": "^8.0|^9.0" + "orchestra/testbench": "^8.0|^9.0|^10.0" }, "autoload": { "psr-4": { diff --git a/tests/Laravel/MathCastTest.php b/tests/Laravel/MathCastTest.php index 05b9b2c..300f83f 100644 --- a/tests/Laravel/MathCastTest.php +++ b/tests/Laravel/MathCastTest.php @@ -18,7 +18,7 @@ class MathCastTest extends TestCase { - public function setUp(): void + protected function setUp(): void { // Turn on error reporting error_reporting(E_ALL); From 40a81f3274904056d6bb4977ec42fc7c63c71f72 Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 8 Feb 2026 00:17:11 +0100 Subject: [PATCH 3/8] strict immutable stan --- .github/workflows/ci.yml | 8 +- .github/workflows/qa.yml | 24 +- .gitignore | 2 + CHANGELOG.md | 58 +++++ README.md | 28 ++- composer.json | 24 +- phpstan-tests.neon | 13 + phpstan.neon | 13 + .../Exception/NotNullableException.php | 2 + src/Laravel/MathCast.php | 25 +- src/Math.php | 18 +- src/MathBaseAbstract.php | 41 ++- src/MathImmutable.php | 20 ++ src/MathOpsAbstract.php | 104 ++++---- tests/Laravel/MathCastTest.php | 4 +- tests/MathImmutableTest.php | 237 ++++++++++++++++++ tests/MathTest.php | 32 +-- 17 files changed, 549 insertions(+), 104 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 phpstan-tests.neon create mode 100644 phpstan.neon create mode 100644 src/MathImmutable.php create mode 100644 tests/MathImmutableTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56f53a9..85957b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -26,10 +26,10 @@ jobs: - name: Get composer cache directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -48,4 +48,4 @@ jobs: run: composer install --no-progress --prefer-dist --optimize-autoloader - name: Test with phpunit - run: vendor/bin/phpunit + run: composer test diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 6aca2b9..a5d2d31 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -12,21 +12,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.4 extensions: mbstring, dom, fileinfo, gmp, bcmath coverage: xdebug - name: Get composer cache directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -39,16 +39,18 @@ jobs: run: composer install --no-progress --prefer-dist --optimize-autoloader - name: Check code style - run: vendor/bin/pint --config pint.json --test + run: composer fix + + - name: PHPStan src + run: composer stan + + - name: PHPStan tests + run: composer stan-tests - name: Compute Coverage run: vendor/bin/phpunit --coverage-clover ./coverage.xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v4 with: - files: ./coverage.xml - flags: unittests - name: codecov-math + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index fbebd00..2dde6dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor .*.cache +.phpstan composer.lock +/cov diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dd94c88 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [2.1.0] - 2025-02-08 + +### Added + +- `MathImmutable` — immutable variant of `Math` where every operation returns a new instance, leaving the original unchanged. Extends `Math` and is accepted anywhere `Math` is type-hinted. +- `declare(strict_types=1)` in all source files +- `phpstan-tests.neon` — dedicated PHPStan configuration for tests at level 5 + +### Changed + +- `__toString()` bypasses redundant regex validation for better performance +- Cross-type argument passing between `Math` and `MathImmutable` instances +- PHPStan level 9 compliance for `src/` +- GitHub Actions workflows updated to latest action versions + +### Fixed + +- `format()` now works correctly with `Math` subclasses that override mutating methods +- `bcDec2Base()` properly initializes result as `'0'` + +## [2.0.0] - 2024-04-24 + +### Added + +- `MathCast` — Laravel Eloquent cast for `Math` instances with nullable support + +### Changed + +- Minimum PHP version raised to 8.1 + +### Removed + +- PHP < 8.1 support + +## [1.0.1] - 2021-06-16 + +### Added + +- PHP 8.0 support + +## [1.0.0] - 2019-07-31 + +Initial release. + +[Unreleased]: https://github.com/fab2s/Math/compare/2.1.0...HEAD +[2.1.0]: https://github.com/fab2s/Math/compare/2.0.0...2.1.0 +[2.0.0]: https://github.com/fab2s/Math/compare/1.0.1...2.0.0 +[1.0.1]: https://github.com/fab2s/Math/compare/1.0.0...1.0.1 +[1.0.0]: https://github.com/fab2s/Math/releases/tag/1.0.0 diff --git a/README.md b/README.md index e02ed6d..5386c5e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Latest Stable Version](https://poser.pugx.org/fab2s/math/v/stable)](https://packagist.org/packages/fab2s/math) [![Total Downloads](https://poser.pugx.org/fab2s/math/downloads)](https://packagist.org/packages/fab2s/math) [![Monthly Downloads](https://poser.pugx.org/fab2s/math/d/monthly)](https://packagist.org/packages/fab2s/math) +[![PHPStan](https://img.shields.io/badge/PHPStan-level%209-brightgreen.svg?style=flat)](https://phpstan.org) [![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) @@ -53,7 +54,7 @@ $result = Math::number('100') echo $result; // '42' ``` -> **Important:** Math instances are **mutable**. Operations modify the instance in place and return `$this` for chaining. To preserve the original value, wrap it in a new instance: +> **Important:** `Math` instances are **mutable**. Operations modify the instance in place and return `$this` for chaining. To preserve the original value, use `MathImmutable` or wrap it in a new instance: > > ```php > $original = Math::number('100'); @@ -64,6 +65,24 @@ echo $result; // '42' > $modified = Math::number($original)->add('50'); // $original stays '100' > ``` +### Immutable Variant + +`MathImmutable` provides the same API but every operation returns a new instance, leaving the original unchanged: + +```php +use fab2s\Math\MathImmutable; + +$a = MathImmutable::number('100'); +$b = $a->add('50'); // $a is still '100', $b is '150' +$c = $b->mul('2'); // $b is still '150', $c is '300' + +// Works everywhere Math is accepted +function calculateTax(Math $price): Math { /* ... */ } +calculateTax($a); // MathImmutable extends Math +``` + +`MathImmutable` extends `Math`, so it inherits all factory methods (`number()`, `make()`, `fromBase()`) and is accepted anywhere `Math` is type-hinted. The overhead is a single `clone` per operation (two properties: a string and an int). + ### Strict Validation Math rejects ambiguous inputs that bcmath would silently convert to `0`: @@ -216,9 +235,12 @@ $order->total = null; // Throws NotNullableException | Method | Description | |--------|-------------| -| `Math::number($n)` | Create instance from number | +| `Math::number($n)` | Create mutable instance | +| `Math::make($n)` | Alias for `number()` | | `Math::fromBase($n, $base)` | Create from base 2-62 | -| `new Math($n, $precision)` | Constructor with optional precision | +| `MathImmutable::number($n)` | Create immutable instance | +| `MathImmutable::make($n)` | Alias for `number()` | +| `MathImmutable::fromBase($n, $base)` | Create immutable from base 2-62 | ### Arithmetic diff --git a/composer.json b/composer.json index 7b8cd86..e6a74ee 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "require-dev": { "phpunit/phpunit": "^10.0|^11.0", "laravel/pint": "^1.11", - "orchestra/testbench": "^8.0|^9.0|^10.0" + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpstan/phpstan": "^2.1" }, "autoload": { "psr-4": { @@ -45,7 +46,26 @@ "post-install-cmd": [ "rm -rf .*.cache" ], - "fix": "@php vendor/bin/pint --config pint.json" + "fix": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/pint --ansi --config pint.json --" + ], + "test": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/phpunit --colors --" + ], + "cov": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/phpunit --colors --coverage-html ./cov --" + ], + "stan": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/phpstan analyse -c ./phpstan.neon --ansi --" + ], + "stan-tests": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/phpstan analyse -c ./phpstan-tests.neon --ansi --" + ] }, "suggest": { "ext-gmp": "For faster Math::baseConvert up to base62" diff --git a/phpstan-tests.neon b/phpstan-tests.neon new file mode 100644 index 0000000..7ce2cc2 --- /dev/null +++ b/phpstan-tests.neon @@ -0,0 +1,13 @@ + +parameters: + tmpDir: .phpstan + level: 5 + + paths: + - tests + + parallel: + maximumNumberOfProcesses: 4 + + ignoreErrors: + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d4ec7b9 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,13 @@ + +parameters: + tmpDir: .phpstan + level: 9 + + paths: + - src + parallel: + maximumNumberOfProcesses: 4 + + ignoreErrors: + + diff --git a/src/Laravel/Exception/NotNullableException.php b/src/Laravel/Exception/NotNullableException.php index d245a84..d9db9af 100644 --- a/src/Laravel/Exception/NotNullableException.php +++ b/src/Laravel/Exception/NotNullableException.php @@ -1,5 +1,7 @@ */ class MathCast implements CastsAttributes { protected bool $isNullable = false; - public function __construct(...$options) + public function __construct(string ...$options) { $this->isNullable = in_array('nullable', $options); } @@ -26,31 +29,37 @@ public function __construct(...$options) /** * Cast the given value. * - * @param Model $model + * @param Model $model + * @param array $attributes * * @throws NotNullableException */ - public function get($model, string $key, $value, array $attributes): ?Math + public function get($model, string $key, mixed $value, array $attributes): ?Math { - return Math::isNumber($value) ? Math::number($value) : $this->handleNullable($model, $key); + /** @var string|int|float|null $value */ + return Math::isNumber($value) ? Math::number((string) $value) : $this->handleNullable($model, $key); } /** * Prepare the given value for storage. * - * @param Model $model + * @param Model $model + * @param array $attributes * * @throws NotNullableException */ - public function set($model, string $key, $value, array $attributes): ?string + public function set($model, string $key, mixed $value, array $attributes): ?string { - return Math::isNumber($value) ? (string) Math::number($value) : $this->handleNullable($model, $key); + /** @var string|int|float|null $value */ + return Math::isNumber($value) ? (string) Math::number((string) $value) : $this->handleNullable($model, $key); } /** + * @return null + * * @throws NotNullableException */ - protected function handleNullable(Model $model, string $key) + protected function handleNullable(Model $model, string $key): mixed { return $this->isNullable ? null : throw NotNullableException::make($key, $model); } diff --git a/src/Math.php b/src/Math.php index 9c9696c..88f2212 100644 --- a/src/Math.php +++ b/src/Math.php @@ -1,5 +1,7 @@ number); + return static::normalizeReal($this->number); } public static function number(string|int|float|Math $number): static @@ -104,16 +108,18 @@ public function toBase(string|int $base): string static::validateBase($base = (int) static::validatePositiveInteger($base)); // do not mutate, only support positive integers + /** @var numeric-string $number */ $number = ltrim((string) $this, '-'); if (static::$gmpSupport) { return static::baseConvert($number, 10, $base); } $result = ''; + $strBase = (string) $base; $baseChar = static::getBaseChar($base); - while (bccomp($number, 0) != 0) { // still data to process - $rem = bcmod($number, $base); // calc the remainder - $number = bcdiv(bcsub($number, $rem), $base); + while (bccomp($number, '0') != 0) { // still data to process + $rem = (int) bcmod($number, $strBase); // calc the remainder + $number = bcdiv(bcsub($number, (string) $rem), $strBase); $result = $baseChar[$rem] . $result; } @@ -127,10 +133,12 @@ public function format(string|int $decimals = 0, string $decPoint = '.', string $decimals = max(0, (int) $decimals); $dec = ''; // do not mutate - $number = (new static($this))->round($decimals)->normalize(); + $number = (new self($this))->round($decimals)->normalize(); $sign = $number->isPositive() ? '' : '-'; if ($number->abs()->hasDecimals()) { [$number, $dec] = explode('.', (string) $number); + } else { + $number = (string) $number; } if ($decimals) { diff --git a/src/MathBaseAbstract.php b/src/MathBaseAbstract.php index 44dac53..db43706 100644 --- a/src/MathBaseAbstract.php +++ b/src/MathBaseAbstract.php @@ -1,5 +1,7 @@ number; @@ -80,16 +85,23 @@ public function hasDecimals(): bool public function normalize(): static { - $this->number = static::normalizeReal($this->number); + $result = $this->mutate(); + $result->number = static::normalizeReal($result->number); - return $this; + return $result; } public function setPrecision(string|int $precision): static { // even INT_32 should be enough precision - $this->precision = max(0, (int) $precision); + $result = $this->mutate(); + $result->precision = max(0, (int) $precision); + + return $result; + } + protected function mutate(): static + { return $this; } @@ -127,7 +139,7 @@ public static function isNumber(string|int|float|Math|null $number): bool public static function normalizeNumber(string|int|float|Math|null $number, Math|string|int|float|null $default = null): ?string { if (! static::isNumber($number)) { - return $default; + return $default !== null ? (string) $default : null; } return static::normalizeReal((string) $number); @@ -157,15 +169,18 @@ public static function getBaseChar(string|int $base): string */ public static function baseConvert(string|int $number, string|int $fromBase = 10, string|int $toBase = 62): string { - return gmp_strval(gmp_init($number, $fromBase), $toBase); + return gmp_strval(gmp_init($number, (int) $fromBase), (int) $toBase); } /** * Normalize a valid real number * removes preceding / trailing 0 and + + * + * @return numeric-string */ protected static function normalizeReal(string|int $number): string { + $number = (string) $number; $sign = $number[0] === '-' ? '-' : ''; $number = ltrim($number, '0+-'); @@ -176,6 +191,7 @@ protected static function normalizeReal(string|int $number): string $number = ($number ?: '0') . $dec; } + /** @var numeric-string */ // @phpstan-ignore varTag.nativeType return $number ? $sign . $number : '0'; } @@ -191,8 +207,9 @@ protected static function validateBase(int $base): void protected static function bcDec2Base(string $number, int $base, string $baseChar): string { - $result = ''; + $result = '0'; $numberLen = strlen($number); + $base = (string) $base; // Now loop through each digit in the number for ($i = $numberLen - 1; $i >= 0; $i--) { $char = $number[$i]; // extract the last char from the number @@ -202,29 +219,33 @@ protected static function bcDec2Base(string $number, int $base, string $baseChar } // Now convert the value+position to decimal - $result = bcadd($result, bcmul($ord, bcpow($base, ($numberLen - $i - 1)))); + $result = bcadd($result, bcmul((string) $ord, bcpow($base, (string) ($numberLen - $i - 1)))); } - return $result ? $result : '0'; + return $result; } + /** @return numeric-string */ protected static function validateInputNumber(string|int|float|Math $number): string { - if ($number instanceof static) { + if ($number instanceof self) { return $number->getNumber(); } - $number = trim($number); + $number = trim((string) $number); if (! static::isNumber($number)) { throw new InvalidArgumentException('Argument number is not valid'); } + /** @var numeric-string */ return $number; } /** * @param string|int $integer up to INT_32|64 since it's only used for things * like exponents, it should be enough + * + * @return numeric-string */ protected static function validatePositiveInteger(string|int $integer): string { diff --git a/src/MathImmutable.php b/src/MathImmutable.php new file mode 100644 index 0000000..2c75b87 --- /dev/null +++ b/src/MathImmutable.php @@ -0,0 +1,20 @@ +mutate(); foreach ($numbers as $number) { - $this->number = bcadd($this->number, static::validateInputNumber($number), $this->precision); + $result->number = bcadd($result->number, static::validateInputNumber($number), $result->precision); } - return $this; + return $result; } public function sub(string|int|float|Math ...$numbers): static { + $result = $this->mutate(); foreach ($numbers as $number) { - $this->number = bcsub($this->number, static::validateInputNumber($number), $this->precision); + $result->number = bcsub($result->number, static::validateInputNumber($number), $result->precision); } - return $this; + return $result; } public function mul(string|int|float|Math ...$numbers): static { + $result = $this->mutate(); foreach ($numbers as $number) { - $this->number = bcmul($this->number, static::validateInputNumber($number), $this->precision); + $result->number = bcmul($result->number, static::validateInputNumber($number), $result->precision); } - return $this; + return $result; } public function div(string|int|float|Math ...$numbers): static { + $result = $this->mutate(); foreach ($numbers as $number) { - $this->number = bcdiv($this->number, static::validateInputNumber($number), $this->precision); + $result->number = bcdiv($result->number, static::validateInputNumber($number), $result->precision); } - return $this; + return $result; } public function sqrt(): static { - $this->number = bcsqrt($this->number, $this->precision); + $result = $this->mutate(); + $result->number = bcsqrt($result->number, $result->precision); - return $this; + return $result; } public function pow(string|int $exponent): static { - $this->number = bcpow($this->number, static::validatePositiveInteger($exponent), $this->precision); + $result = $this->mutate(); + $result->number = bcpow($result->number, static::validatePositiveInteger($exponent), $result->precision); - return $this; + return $result; } public function mod(string|int $modulus): static { - $this->number = bcmod($this->number, static::validatePositiveInteger($modulus)); + $result = $this->mutate(); + $result->number = bcmod($result->number, static::validatePositiveInteger($modulus)); - return $this; + return $result; } public function powMod(string|int $exponent, string|int $modulus): static { - $this->number = bcpowmod($this->number, static::validatePositiveInteger($exponent), static::validatePositiveInteger($modulus)); + $result = $this->mutate(); + $result->number = bcpowmod($result->number, static::validatePositiveInteger($exponent), static::validatePositiveInteger($modulus)); - return $this; + return $result; } public function round(string|int $precision = 0): static { $precision = max(0, (int) $precision); - if ($this->hasDecimals()) { - if ($this->isPositive()) { - $this->number = bcadd($this->number, '0.' . str_repeat('0', $precision) . '5', $precision); - - return $this; + $result = $this->mutate(); + if ($result->hasDecimals()) { + /** @var numeric-string $offset */ // @phpstan-ignore varTag.nativeType + $offset = '0.' . str_repeat('0', $precision) . '5'; + if ($result->isPositive()) { + $result->number = bcadd($result->number, $offset, $precision); + + return $result; } - $this->number = bcsub($this->number, '0.' . str_repeat('0', $precision) . '5', $precision); + $result->number = bcsub($result->number, $offset, $precision); } - return $this; + return $result; } public function ceil(): static { - if ($this->hasDecimals()) { - if ($this->isPositive()) { - $this->number = bcadd($this->number, (preg_match('`\.[0]*$`', $this->number) ? '0' : '1'), 0); + $result = $this->mutate(); + if ($result->hasDecimals()) { + if ($result->isPositive()) { + $result->number = bcadd($result->number, (preg_match('`\.[0]*$`', $result->number) ? '0' : '1'), 0); - return $this; + return $result; } - $this->number = bcsub($this->number, '0', 0); + $result->number = bcsub($result->number, '0', 0); } - return $this; + return $result; } public function floor(): static { - if ($this->hasDecimals()) { - if ($this->isPositive()) { - $this->number = bcadd($this->number, 0, 0); + $result = $this->mutate(); + if ($result->hasDecimals()) { + if ($result->isPositive()) { + $result->number = bcadd($result->number, '0', 0); - return $this; + return $result; } - $this->number = bcsub($this->number, (preg_match('`\.[0]*$`', $this->number) ? '0' : '1'), 0); + $result->number = bcsub($result->number, (preg_match('`\.[0]*$`', $result->number) ? '0' : '1'), 0); } - return $this; + return $result; } public function abs(): static { - $this->number = ltrim($this->number, '-'); + $result = $this->mutate(); + $result->number = ltrim($result->number, '-'); - return $this; + return $result; } /** @@ -136,13 +152,14 @@ public function abs(): static */ public function max(string|int|float|Math ...$numbers): static { + $result = $this->mutate(); foreach ($numbers as $number) { - if (bccomp($number = static::validateInputNumber($number), $this->number, $this->precision) === 1) { - $this->number = $number; + if (bccomp($number = static::validateInputNumber($number), $result->number, $result->precision) === 1) { + $result->number = $number; } } - return $this; + return $result; } /** @@ -150,12 +167,13 @@ public function max(string|int|float|Math ...$numbers): static */ public function min(string|int|float|Math ...$numbers): static { + $result = $this->mutate(); foreach ($numbers as $number) { - if (bccomp($number = static::validateInputNumber($number), $this->number, $this->precision) === -1) { - $this->number = $number; + if (bccomp($number = static::validateInputNumber($number), $result->number, $result->precision) === -1) { + $result->number = $number; } } - return $this; + return $result; } } diff --git a/tests/Laravel/MathCastTest.php b/tests/Laravel/MathCastTest.php index 300f83f..307d73f 100644 --- a/tests/Laravel/MathCastTest.php +++ b/tests/Laravel/MathCastTest.php @@ -44,7 +44,7 @@ public function test_math_cast_get( $this->expectException(NotNullableException::class); $cast->get(new CastModel, 'key', $value, []); break; - case $expected === null: + default: $this->assertNull($cast->get(new CastModel, 'key', $value, [])); break; } @@ -69,7 +69,7 @@ public function test_math_cast_set( $this->expectException(NotNullableException::class); $cast->set(new CastModel, 'key', $value, []); break; - case $expected === null: + default: $this->assertSame(null, $cast->set(new CastModel, 'key', $value, [])); break; } diff --git a/tests/MathImmutableTest.php b/tests/MathImmutableTest.php new file mode 100644 index 0000000..ee63fae --- /dev/null +++ b/tests/MathImmutableTest.php @@ -0,0 +1,237 @@ +assertInstanceOf(Math::class, MathImmutable::number('42')); + } + + public function test_add_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->add('5'); + + $this->assertSame('10', (string) $a); + $this->assertSame('15', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_sub_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->sub('3'); + + $this->assertSame('10', (string) $a); + $this->assertSame('7', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_mul_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->mul('3'); + + $this->assertSame('10', (string) $a); + $this->assertSame('30', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_div_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->div('2'); + + $this->assertSame('10', (string) $a); + $this->assertSame('5', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_sqrt_is_immutable() + { + $a = MathImmutable::number('9'); + $b = $a->sqrt(); + + $this->assertSame('9', (string) $a); + $this->assertSame('3', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_pow_is_immutable() + { + $a = MathImmutable::number('2'); + $b = $a->pow(3); + + $this->assertSame('2', (string) $a); + $this->assertSame('8', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_mod_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->mod(3); + + $this->assertSame('10', (string) $a); + $this->assertSame('1', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_round_is_immutable() + { + $a = MathImmutable::number('10.555'); + $b = $a->round(2); + + $this->assertSame('10.555', (string) $a); + $this->assertSame('10.56', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_ceil_is_immutable() + { + $a = MathImmutable::number('10.1'); + $b = $a->ceil(); + + $this->assertSame('10.1', (string) $a); + $this->assertSame('11', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_floor_is_immutable() + { + $a = MathImmutable::number('10.9'); + $b = $a->floor(); + + $this->assertSame('10.9', (string) $a); + $this->assertSame('10', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_abs_is_immutable() + { + $a = MathImmutable::number('-10'); + $b = $a->abs(); + + $this->assertSame('-10', (string) $a); + $this->assertSame('10', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_max_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->max('20'); + + $this->assertSame('10', (string) $a); + $this->assertSame('20', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_min_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->min('5'); + + $this->assertSame('10', (string) $a); + $this->assertSame('5', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_normalize_is_immutable() + { + $a = MathImmutable::number('010.100'); + $b = $a->normalize(); + + $this->assertSame('010.100', $a->getNumber()); + $this->assertSame('10.1', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_set_precision_is_immutable() + { + $a = MathImmutable::number('10'); + $b = $a->setPrecision(2); + + $this->assertNotSame($a, $b); + $this->assertSame('3.33', (string) $b->div(3)); + // original precision unchanged + $this->assertSame('3.333333333', (string) $a->div(3)); + } + + public function test_chaining() + { + $a = MathImmutable::number('10'); + $b = $a->add('5')->mul('2'); + + $this->assertSame('10', (string) $a); + $this->assertSame('30', (string) $b); + } + + public function test_factory_methods() + { + $a = MathImmutable::number('42'); + $this->assertInstanceOf(MathImmutable::class, $a); + + $b = MathImmutable::make('42'); + $this->assertInstanceOf(MathImmutable::class, $b); + } + + public function test_operations_return_immutable_math() + { + $a = MathImmutable::number('10'); + + $this->assertInstanceOf(MathImmutable::class, $a->add('1')); + $this->assertInstanceOf(MathImmutable::class, $a->sub('1')); + $this->assertInstanceOf(MathImmutable::class, $a->mul('2')); + $this->assertInstanceOf(MathImmutable::class, $a->div('2')); + $this->assertInstanceOf(MathImmutable::class, $a->pow(2)); + $this->assertInstanceOf(MathImmutable::class, $a->mod(3)); + $this->assertInstanceOf(MathImmutable::class, $a->abs()); + $this->assertInstanceOf(MathImmutable::class, $a->round(2)); + $this->assertInstanceOf(MathImmutable::class, $a->ceil()); + $this->assertInstanceOf(MathImmutable::class, $a->floor()); + $this->assertInstanceOf(MathImmutable::class, $a->normalize()); + $this->assertInstanceOf(MathImmutable::class, $a->setPrecision(4)); + $this->assertInstanceOf(MathImmutable::class, $a->max('20')); + $this->assertInstanceOf(MathImmutable::class, $a->min('5')); + } + + public function test_cross_type_operations() + { + $immutable = MathImmutable::number('10'); + $mutable = Math::number('5'); + + // MathImmutable accepting Math argument + $result = $immutable->add($mutable); + $this->assertSame('15', (string) $result); + $this->assertSame('10', (string) $immutable); + $this->assertInstanceOf(MathImmutable::class, $result); + + // Math accepting MathImmutable argument + $result2 = $mutable->add($immutable); + $this->assertSame('15', (string) $result2); + $this->assertInstanceOf(Math::class, $result2); + } + + public function test_variadic_immutability() + { + $a = MathImmutable::number('10'); + $b = $a->add('1', '2', '3'); + + $this->assertSame('10', (string) $a); + $this->assertSame('16', (string) $b); + } +} diff --git a/tests/MathTest.php b/tests/MathTest.php index c5d1635..32d881f 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -998,69 +998,69 @@ public static function baseConvertData(): array return [ [ 'number' => '0', - 'base' => '62', + 'base' => 62, ], [ 'number' => '0', - 'base' => '36', + 'base' => 36, ], [ 'number' => '10', - 'base' => '62', + 'base' => 62, ], [ 'number' => '10', - 'base' => '36', + 'base' => 36, ], [ 'number' => '62', - 'base' => '62', + 'base' => 62, ], [ 'number' => '36', - 'base' => '36', + 'base' => 36, ], [ 'number' => '000255173029255255255', - 'base' => '16', + 'base' => 16, ], [ 'number' => '00025517302925525525', - 'base' => '28', + 'base' => 28, ], [ 'number' => '000255173029255255255', - 'base' => '8', + 'base' => 8, ], [ 'number' => '000255173029255255255', - 'base' => '36', + 'base' => 36, ], [ 'number' => '255173029255255255', - 'base' => '2', + 'base' => 2, ], [ 'number' => '25517993029255255255', - 'base' => '37', + 'base' => 37, ], [ 'number' => '25517993029255255255', - 'base' => '35', + 'base' => 35, ], [ 'number' => '0', - 'base' => '48', + 'base' => 48, ], [ 'number' => '9856565', - 'base' => '61', + 'base' => 61, ], ]; } #[DataProvider('baseConvertData')] - public function test_base_convert(string|int|Math $number, string $base) + public function test_base_convert(string|int|Math $number, int $base) { $this->assertSame( (string) Math::number($number), From 3dc44f68b45ec9e3f7e818f4a90e1ae8b8aaac35 Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 8 Feb 2026 01:20:46 +0100 Subject: [PATCH 4/8] benchmarks --- README.md | 44 ++- benchmarks/ImmutableBench.php | 117 ++++++++ benchmarks/MathBench.php | 511 ++++++++++++++++++++++++++++++++++ benchmarks/report.php | 110 ++++++++ composer.json | 17 +- phpbench.json | 4 + src/MathOpsAbstract.php | 31 ++- tests/MathTest.php | 92 ++++++ 8 files changed, 916 insertions(+), 10 deletions(-) create mode 100644 benchmarks/ImmutableBench.php create mode 100644 benchmarks/MathBench.php create mode 100644 benchmarks/report.php create mode 100644 phpbench.json diff --git a/README.md b/README.md index 5386c5e..6317d7f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ composer require fab2s/math - PHP 8.1+ - ext-bcmath (required) -- ext-gmp (optional, enables ~20x faster base conversions) +- ext-gmp (optional, faster base conversions, mod, pow and powMod) ## Features @@ -294,6 +294,48 @@ $order->total = null; // Throws NotNullableException | `Math::setGlobalPrecision($p)` | Set default for new instances | | `Math::getGlobalPrecision()` | Get global precision | +## Benchmarks + +Compared against [brick/math](https://github.com/brick/math) (PHP 8.4, opcache off, GMP enabled). The **bold** value is the faster one in each row, and _Factor_ shows how many times faster it is. + +| Operation | fab2s/math | brick/math | Factor | +|---|---:|---:|---:| +| instantiate int | **0.244μs (±6.9%)** | 0.274μs (±2.0%) | 1.12x | +| instantiate string | **0.224μs (±8.8%)** | 0.566μs (±3.4%) | 2.52x | +| add | **0.560μs (±16.8%)** | 2.083μs (±3.3%) | 3.72x | +| add variadic | **1.244μs (±2.7%)** | 6.172μs (±2.7%) | 4.96x | +| sub | **0.570μs (±4.6%)** | 2.134μs (±4.1%) | 3.75x | +| mul | **0.659μs (±6.5%)** | 2.073μs (±6.2%) | 3.15x | +| div | **0.646μs (±12.1%)** | 4.374μs (±2.3%) | 6.78x | +| pow | **0.896μs (±4.9%)** | 1.248μs (±12.7%) | 1.39x | +| mod | **0.800μs (±8.0%)** | 2.554μs (±5.1%) | 3.19x | +| sqrt | **2.414μs (±7.2%)** | 4.893μs (±38.1%) | 2.03x | +| abs | **0.594μs (±18.9%)** | 1.000μs (±8.4%) | 1.68x | +| round | **0.556μs (±21.3%)** | 3.474μs (±5.2%) | 6.25x | +| ceil | **0.505μs (±8.9%)** | 3.203μs (±37.2%) | 6.34x | +| floor | **0.444μs (±3.3%)** | 2.448μs (±3.1%) | 5.52x | +| comparisons | **1.471μs (±61.7%)** | 6.027μs (±4.7%) | 4.10x | +| to string | **0.585μs (±19.4%)** | 0.842μs (±12.0%) | 1.44x | +| chained workflow | **1.618μs (±3.3%)** | 8.461μs (±10.7%) | 5.23x | +| large number ops | **1.679μs (±3.3%)** | 8.392μs (±3.4%) | 5.00x | +| accumulate 100 additions | **37.691μs (±0.9%)** | 150.010μs (±1.7%) | 3.98x | +| base convert to 62 | **1.079μs (±1.6%)** | 6.328μs (±16.6%) | 5.87x | +| base convert to 16 | 0.966μs (±8.2%) | **0.863μs (±13.0%)** | 0.89x | +| integer mul | **0.691μs (±33.0%)** | 1.859μs (±16.5%) | 2.69x | +| integer powmod | **1.124μs (±8.3%)** | 2.551μs (±3.6%) | 2.27x | +| create 1000 instances | **281.288μs (±3.2%)** | 669.150μs (±0.7%) | 2.38x | +| immutable chain | **2.086μs (±5.9%)** | 13.237μs (±2.8%) | 6.34x | + +fab2s/math wins every operation except base-16 conversion, where brick/math delegates to GMP's native hex output. The speed advantage comes from keeping bcmath's C-level string arithmetic as the hot path for decimal operations, while brick/math pays for an extra object-wrapping layer on top of GMP. Integer-only operations (`mod`, `pow`, `powMod`, base conversion) use GMP directly when the extension is available, combining the best of both backends. Realistic workflows like chained calculations or 100-iteration accumulations show a consistent 4-6x advantage, and the immutable variant stays over 6x faster thanks to a single lightweight `clone` per operation versus brick/math's heavier object allocation. + +Run benchmarks yourself: + +```bash +composer bench # ASCII table +composer bench-md # Markdown table +composer bench-md -- --group=integer # Filter by group +``` + ## Compatibility | PHP | Laravel | diff --git a/benchmarks/ImmutableBench.php b/benchmarks/ImmutableBench.php new file mode 100644 index 0000000..a883dcc --- /dev/null +++ b/benchmarks/ImmutableBench.php @@ -0,0 +1,117 @@ +mul('1.21') + ->add('50.00') + ->sub('100.00') + ->div('3') + ->round(2) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['mutability'])] + public function fab2s_immutable_chain(): void + { + MathImmutable::number('1000.00') + ->mul('1.21') + ->add('50.00') + ->sub('100.00') + ->div('3') + ->round(2) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['mutability'])] + public function brick_immutable_chain(): void + { + BigDecimal::of('1000.00') + ->multipliedBy('1.21') + ->plus('50.00') + ->minus('100.00') + ->dividedBy('3', 9, RoundingMode::HALF_UP) + ->toScale(2, RoundingMode::HALF_UP) + ; + } + + // ─── Accumulation with immutable objects ───────────────────── + + #[Bench\Subject] + #[Bench\Groups(['immutable_accumulation'])] + #[Bench\Revs(100)] + public function fab2s_immutable_accumulate_100(): void + { + $sum = MathImmutable::number('0'); + for ($i = 0; $i < 100; $i++) { + $sum = $sum->add($i . '.99'); + } + } + + #[Bench\Subject] + #[Bench\Groups(['immutable_accumulation'])] + #[Bench\Revs(100)] + public function brick_accumulate_100(): void + { + $sum = BigDecimal::of('0'); + for ($i = 0; $i < 100; $i++) { + $sum = $sum->plus($i . '.99'); + } + } + + // ─── Repeated operations on same base value ────────────────── + + #[Bench\Subject] + #[Bench\Groups(['branch'])] + public function fab2s_immutable_branch(): void + { + $price = MathImmutable::number('99.99'); + $withTax10 = $price->mul('1.10'); + $withTax20 = $price->mul('1.20'); + $withTax10->add('5.00')->round(2); + $withTax20->add('5.00')->round(2); + } + + #[Bench\Subject] + #[Bench\Groups(['branch'])] + public function brick_branch(): void + { + $price = BigDecimal::of('99.99'); + $withTax10 = $price->multipliedBy('1.10'); + $withTax20 = $price->multipliedBy('1.20'); + $withTax10->plus('5.00')->toScale(2, RoundingMode::HALF_UP); + $withTax20->plus('5.00')->toScale(2, RoundingMode::HALF_UP); + } +} diff --git a/benchmarks/MathBench.php b/benchmarks/MathBench.php new file mode 100644 index 0000000..6bcd119 --- /dev/null +++ b/benchmarks/MathBench.php @@ -0,0 +1,511 @@ +add('987654321.987654321') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['addition'])] + public function brick_add(): void + { + BigDecimal::of('123456789.123456789') + ->plus(BigDecimal::of('987654321.987654321')) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['addition'])] + public function fab2s_add_variadic(): void + { + Math::number('100') + ->add('200', '300', '400', '500') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['addition'])] + public function brick_add_variadic(): void + { + BigDecimal::of('100') + ->plus('200') + ->plus('300') + ->plus('400') + ->plus('500') + ; + } + + // ─── Subtraction ───────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['subtraction'])] + public function fab2s_sub(): void + { + Math::number('987654321.987654321') + ->sub('123456789.123456789') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['subtraction'])] + public function brick_sub(): void + { + BigDecimal::of('987654321.987654321') + ->minus(BigDecimal::of('123456789.123456789')) + ; + } + + // ─── Multiplication ────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['multiplication'])] + public function fab2s_mul(): void + { + Math::number('123456789.123456789') + ->mul('9.87654321') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['multiplication'])] + public function brick_mul(): void + { + BigDecimal::of('123456789.123456789') + ->multipliedBy(BigDecimal::of('9.87654321')) + ; + } + + // ─── Division ──────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['division'])] + public function fab2s_div(): void + { + Math::number('987654321.987654321') + ->div('123.456789') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['division'])] + public function brick_div(): void + { + BigDecimal::of('987654321.987654321') + ->dividedBy(BigDecimal::of('123.456789'), 9, RoundingMode::HALF_UP) + ; + } + + // ─── Power ─────────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['power'])] + public function fab2s_pow(): void + { + Math::number('12345.6789') + ->pow(10) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['power'])] + public function brick_pow(): void + { + BigDecimal::of('12345.6789') + ->power(10) + ; + } + + // ─── Modulo ────────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['modulo'])] + public function fab2s_mod(): void + { + Math::number('987654321') + ->mod('12345') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['modulo'])] + public function brick_mod(): void + { + BigDecimal::of('987654321') + ->remainder(BigDecimal::of('12345')) + ; + } + + // ─── Square root ───────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['sqrt'])] + public function fab2s_sqrt(): void + { + Math::number('987654321.123456789') + ->sqrt() + ; + } + + #[Bench\Subject] + #[Bench\Groups(['sqrt'])] + public function brick_sqrt(): void + { + BigDecimal::of('987654321123456789') + ->toBigInteger() + ->sqrt() + ; + } + + // ─── Abs ───────────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['abs'])] + public function fab2s_abs(): void + { + Math::number('-987654321.123456789') + ->abs() + ; + } + + #[Bench\Subject] + #[Bench\Groups(['abs'])] + public function brick_abs(): void + { + BigDecimal::of('-987654321.123456789') + ->abs() + ; + } + + // ─── Rounding ──────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['rounding'])] + public function fab2s_round(): void + { + Math::number('123456.789012345') + ->round(4) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['rounding'])] + public function brick_round(): void + { + BigDecimal::of('123456.789012345') + ->toScale(4, RoundingMode::HALF_UP) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['rounding'])] + public function fab2s_ceil(): void + { + Math::number('123456.789012345') + ->ceil() + ; + } + + #[Bench\Subject] + #[Bench\Groups(['rounding'])] + public function brick_ceil(): void + { + BigDecimal::of('123456.789012345') + ->toScale(0, RoundingMode::CEILING) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['rounding'])] + public function fab2s_floor(): void + { + Math::number('123456.789012345') + ->floor() + ; + } + + #[Bench\Subject] + #[Bench\Groups(['rounding'])] + public function brick_floor(): void + { + BigDecimal::of('123456.789012345') + ->toScale(0, RoundingMode::FLOOR) + ; + } + + // ─── Comparison ────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['comparison'])] + public function fab2s_comparisons(): void + { + $a = Math::number('123456789.123456789'); + $a->gt('123456789.123456788'); + $a->gte('123456789.123456789'); + $a->lt('123456789.123456790'); + $a->lte('123456789.123456789'); + $a->eq('123456789.123456789'); + } + + #[Bench\Subject] + #[Bench\Groups(['comparison'])] + public function brick_comparisons(): void + { + $a = BigDecimal::of('123456789.123456789'); + $b1 = BigDecimal::of('123456789.123456788'); + $b2 = BigDecimal::of('123456789.123456789'); + $b3 = BigDecimal::of('123456789.123456790'); + $a->isGreaterThan($b1); + $a->isGreaterThanOrEqualTo($b2); + $a->isLessThan($b3); + $a->isLessThanOrEqualTo($b2); + $a->isEqualTo($b2); + } + + // ─── String conversion ─────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['conversion'])] + public function fab2s_to_string(): void + { + (string) Math::number('123456789.123456789'); + } + + #[Bench\Subject] + #[Bench\Groups(['conversion'])] + public function brick_to_string(): void + { + (string) BigDecimal::of('123456789.123456789'); + } + + // ─── Chained operations (realistic workflow) ───────────────── + + #[Bench\Subject] + #[Bench\Groups(['chained'])] + public function fab2s_chained_workflow(): void + { + Math::number('1000.00') + ->mul('1.21') // apply tax + ->add('50.00') // add shipping + ->sub('100.00') // discount + ->round(2) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['chained'])] + public function brick_chained_workflow(): void + { + BigDecimal::of('1000.00') + ->multipliedBy('1.21') + ->plus('50.00') + ->minus('100.00') + ->toScale(2, RoundingMode::HALF_UP) + ; + } + + // ─── Large number arithmetic ───────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['large'])] + public function fab2s_large_number_ops(): void + { + Math::number('999999999999999999999999999999.999999999') + ->add('999999999999999999999999999999.999999999') + ->mul('2.5') + ->div('3') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['large'])] + public function brick_large_number_ops(): void + { + BigDecimal::of('999999999999999999999999999999.999999999') + ->plus('999999999999999999999999999999.999999999') + ->multipliedBy('2.5') + ->dividedBy('3', 9, RoundingMode::HALF_UP) + ; + } + + // ─── Many small operations (accumulation) ──────────────────── + + #[Bench\Subject] + #[Bench\Groups(['accumulation'])] + #[Bench\Revs(100)] + public function fab2s_accumulate_100_additions(): void + { + $sum = Math::number('0'); + for ($i = 0; $i < 100; $i++) { + $sum->add((string) $i . '.99'); + } + } + + #[Bench\Subject] + #[Bench\Groups(['accumulation'])] + #[Bench\Revs(100)] + public function brick_accumulate_100_additions(): void + { + $sum = BigDecimal::of('0'); + for ($i = 0; $i < 100; $i++) { + $sum = $sum->plus($i . '.99'); + } + } + + // ─── Base conversion (fab2s specialty) ──────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['base_conversion'])] + public function fab2s_base_convert_to_62(): void + { + Math::number('9999999999999999') + ->toBase(62) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['base_conversion'])] + public function brick_base_convert_to_62(): void + { + BigInteger::of('9999999999999999') + ->toArbitraryBase('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['base_conversion'])] + public function fab2s_base_convert_to_16(): void + { + Math::number('9999999999999999') + ->toBase(16) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['base_conversion'])] + public function brick_base_convert_to_16(): void + { + BigInteger::of('9999999999999999') + ->toBase(16) + ; + } + + // ─── Integer operations (BigInteger comparison) ────────────── + + #[Bench\Subject] + #[Bench\Groups(['integer'])] + public function fab2s_integer_mul(): void + { + Math::number('123456789012345678901234567890') + ->mul('987654321098765432109876543210') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['integer'])] + public function brick_integer_mul(): void + { + BigInteger::of('123456789012345678901234567890') + ->multipliedBy('987654321098765432109876543210') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['integer'])] + public function fab2s_integer_powmod(): void + { + Math::number('123456789') + ->powMod(100, '9999999999') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['integer'])] + public function brick_integer_powmod(): void + { + BigInteger::of('123456789') + ->modPow('100', '9999999999') + ; + } + + // ─── Memory: object creation overhead ──────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['memory'])] + #[Bench\Revs(100)] + public function fab2s_create_1000_instances(): void + { + $objects = []; + for ($i = 0; $i < 1000; $i++) { + $objects[] = Math::number((string) $i . '.123456789'); + } + } + + #[Bench\Subject] + #[Bench\Groups(['memory'])] + #[Bench\Revs(100)] + public function brick_create_1000_instances(): void + { + $objects = []; + for ($i = 0; $i < 1000; $i++) { + $objects[] = BigDecimal::of($i . '.123456789'); + } + } +} diff --git a/benchmarks/report.php b/benchmarks/report.php new file mode 100644 index 0000000..41d6629 --- /dev/null +++ b/benchmarks/report.php @@ -0,0 +1,110 @@ +#!/usr/bin/env php +&1', + PHP_BINARY, + escapeshellarg($dumpFile), + implode(' ', array_map('escapeshellarg', $args)), +); + +// Run phpbench, show progress on stderr +$proc = popen($cmd, 'r'); +while (($line = fgets($proc)) !== false) { + fwrite(STDERR, $line); +} +pclose($proc); + +if (! file_exists($dumpFile)) { + fwrite(STDERR, "No dump file generated\n"); + exit(1); +} + +$xml = simplexml_load_file($dumpFile); +unlink($dumpFile); +if (! $xml) { + fwrite(STDERR, "Failed to parse benchmark XML\n"); + exit(1); +} + +$subjects = []; +foreach ($xml->suite->benchmark as $benchmark) { + foreach ($benchmark->subject as $subject) { + $name = (string) $subject['name']; + $stats = $subject->variant->stats; + $subjects[$name] = [ + 'mode' => (float) $stats['mode'], + 'rstdev' => (float) $stats['rstdev'], + ]; + } +} + +// Pair fab2s_* with brick_* by operation name +$pairs = []; +foreach ($subjects as $name => $data) { + if (! str_starts_with($name, 'fab2s_')) { + continue; + } + + $op = substr($name, 6); + $brick = 'brick_' . $op; + if (! isset($subjects[$brick])) { + continue; + } + + $pairs[$op] = [ + 'fab2s' => $data, + 'brick' => $subjects[$brick], + ]; +} + +if (empty($pairs)) { + fwrite(STDERR, "No paired benchmarks found\n"); + exit(1); +} + +function formatTime(float $us): string +{ + if ($us >= 1000) { + return sprintf('%.2fms', $us / 1000); + } + + return sprintf('%.3fμs', $us); +} + +// Output markdown +echo "\n| Operation | fab2s/math | brick/math | Factor |\n"; +echo "|---|---:|---:|---:|\n"; + +foreach ($pairs as $op => $pair) { + $fab2s = $pair['fab2s']['mode']; + $brick = $pair['brick']['mode']; + $factor = $brick / max($fab2s, 0.001); + $winner = $fab2s <= $brick ? '**' : ''; + $loser = $fab2s > $brick ? '**' : ''; + + echo sprintf( + "| %s | %s%s (±%.1f%%)%s | %s%s (±%.1f%%)%s | %.2fx |\n", + str_replace('_', ' ', $op), + $winner, + formatTime($fab2s), + $pair['fab2s']['rstdev'], + $winner, + $loser, + formatTime($brick), + $pair['brick']['rstdev'], + $loser, + $factor, + ); +} diff --git a/composer.json b/composer.json index e6a74ee..40d6fc5 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,9 @@ "phpunit/phpunit": "^10.0|^11.0", "laravel/pint": "^1.11", "orchestra/testbench": "^8.0|^9.0|^10.0", - "phpstan/phpstan": "^2.1" + "phpstan/phpstan": "^2.1", + "brick/math": "^0.14.7", + "phpbench/phpbench": "^1.4" }, "autoload": { "psr-4": { @@ -36,7 +38,8 @@ }, "autoload-dev": { "psr-4": { - "fab2s\\Math\\Tests\\": "tests" + "fab2s\\Math\\Tests\\": "tests", + "fab2s\\Math\\Benchmarks\\": "benchmarks" } }, "scripts": { @@ -65,9 +68,17 @@ "stan-tests": [ "Composer\\Config::disableProcessTimeout", "@php vendor/bin/phpstan analyse -c ./phpstan-tests.neon --ansi --" + ], + "bench": [ + "Composer\\Config::disableProcessTimeout", + "XDEBUG_MODE=off @php vendor/bin/phpbench run --report=aggregate --" + ], + "bench-md": [ + "Composer\\Config::disableProcessTimeout", + "@php benchmarks/report.php --" ] }, "suggest": { - "ext-gmp": "For faster Math::baseConvert up to base62" + "ext-gmp": "For faster base conversion, mod, pow and powMod" } } diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..f4e0ca5 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,4 @@ +{ + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks" +} diff --git a/src/MathOpsAbstract.php b/src/MathOpsAbstract.php index aa90a4b..ac5ccf8 100644 --- a/src/MathOpsAbstract.php +++ b/src/MathOpsAbstract.php @@ -66,24 +66,43 @@ public function sqrt(): static public function pow(string|int $exponent): static { - $result = $this->mutate(); - $result->number = bcpow($result->number, static::validatePositiveInteger($exponent), $result->precision); + $result = $this->mutate(); + $exponent = static::validatePositiveInteger($exponent); + + if (static::$gmpSupport && ! $result->hasDecimals()) { + $result->number = gmp_strval(gmp_pow(gmp_init($result->number), (int) $exponent)); // @phpstan-ignore assign.propertyType + } else { + $result->number = bcpow($result->number, $exponent, $result->precision); + } return $result; } public function mod(string|int $modulus): static { - $result = $this->mutate(); - $result->number = bcmod($result->number, static::validatePositiveInteger($modulus)); + $result = $this->mutate(); + $modulus = static::validatePositiveInteger($modulus); + + if (static::$gmpSupport) { + $result->number = gmp_strval(gmp_mod(gmp_init($result->number), gmp_init($modulus))); // @phpstan-ignore assign.propertyType + } else { + $result->number = bcmod($result->number, $modulus); + } return $result; } public function powMod(string|int $exponent, string|int $modulus): static { - $result = $this->mutate(); - $result->number = bcpowmod($result->number, static::validatePositiveInteger($exponent), static::validatePositiveInteger($modulus)); + $result = $this->mutate(); + $exponent = static::validatePositiveInteger($exponent); + $modulus = static::validatePositiveInteger($modulus); + + if (static::$gmpSupport) { + $result->number = gmp_strval(gmp_powm(gmp_init($result->number), gmp_init($exponent), gmp_init($modulus))); // @phpstan-ignore assign.propertyType + } else { + $result->number = bcpowmod($result->number, $exponent, $modulus); + } return $result; } diff --git a/tests/MathTest.php b/tests/MathTest.php index 32d881f..30ef64e 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -1096,6 +1096,98 @@ public function test_base_convert(string|int|Math $number, int $base) Math::gmpSupport(null); } + #[DataProvider('modData')] + public function test_mod_gmp_toggle(string|int|Math $number, string $mod, string $expected) + { + if (! Math::gmpSupport()) { + $this->markTestSkipped('GMP not available'); + } + + $gmpResult = (string) Math::number($number)->mod($mod); + + Math::gmpSupport(true); + $bcResult = (string) Math::number($number)->mod($mod); + Math::gmpSupport(null); + + $this->assertSame($gmpResult, $bcResult); + } + + #[DataProvider('powModData')] + public function test_pow_mod_gmp_toggle(string|int|Math $number, string $pow, string $mod) + { + if (! Math::gmpSupport()) { + $this->markTestSkipped('GMP not available'); + } + + $gmpResult = (string) Math::number($number)->powMod($pow, $mod); + + Math::gmpSupport(true); + $bcResult = (string) Math::number($number)->powMod($pow, $mod); + Math::gmpSupport(null); + + $this->assertSame($gmpResult, $bcResult); + } + + public static function powGmpData(): array + { + return [ + [ + 'number' => '2', + 'exponent' => '10', + 'expected' => '1024', + ], + [ + 'number' => '7', + 'exponent' => '5', + 'expected' => '16807', + ], + [ + 'number' => '123456789', + 'exponent' => '3', + 'expected' => '1881676371789154860897069', + ], + [ + 'number' => '-3', + 'exponent' => '3', + 'expected' => '-27', + ], + [ + 'number' => '1', + 'exponent' => '100', + 'expected' => '1', + ], + [ + 'number' => '0', + 'exponent' => '5', + 'expected' => '0', + ], + ]; + } + + #[DataProvider('powGmpData')] + public function test_pow_gmp_toggle(string|int|Math $number, string $exponent, string $expected) + { + if (! Math::gmpSupport()) { + $this->markTestSkipped('GMP not available'); + } + + $gmpResult = (string) Math::number($number)->pow($exponent); + $this->assertSame($expected, $gmpResult); + + Math::gmpSupport(true); + $bcResult = (string) Math::number($number)->pow($exponent); + Math::gmpSupport(null); + + $this->assertSame($gmpResult, $bcResult); + } + + public function test_pow_decimal_uses_bcmath() + { + // Decimal base should use bcmath path even with GMP available + $result = (string) Math::number('2.5')->pow('3'); + $this->assertSame('15.625', $result); + } + public function test_to_string() { $this->assertSame('33.33', (string) Math::number('33.33')); From 71a8627bd69c6e90e4309718ff867b14a8426a70 Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 8 Feb 2026 02:49:58 +0100 Subject: [PATCH 5/8] polishing --- CHANGELOG.md | 36 +- README.md | 148 +++++--- benchmarks/MathBench.php | 92 ++++- .../{ImmutableBench.php => MutableBench.php} | 43 ++- src/Math.php | 5 +- src/MathBaseAbstract.php | 51 ++- src/{MathImmutable.php => MathMutable.php} | 4 +- src/MathOpsAbstract.php | 33 ++ tests/MathImmutableTest.php | 237 ------------ tests/MathMutableTest.php | 355 ++++++++++++++++++ tests/MathTest.php | 180 +++++++++ 11 files changed, 862 insertions(+), 322 deletions(-) rename benchmarks/{ImmutableBench.php => MutableBench.php} (70%) rename src/{MathImmutable.php => MathMutable.php} (85%) delete mode 100644 tests/MathImmutableTest.php create mode 100644 tests/MathMutableTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index dd94c88..67f5861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] -## [2.1.0] - 2025-02-08 - -### Added - -- `MathImmutable` — immutable variant of `Math` where every operation returns a new instance, leaving the original unchanged. Extends `Math` and is accepted anywhere `Math` is type-hinted. -- `declare(strict_types=1)` in all source files -- `phpstan-tests.neon` — dedicated PHPStan configuration for tests at level 5 +## [3.0.0] - 2026-02-08 ### Changed +- **BREAKING:** `Math` is now immutable by default — every operation returns a new instance, leaving the original unchanged +- `MathImmutable` replaced by `MathMutable` — mutability is now the explicit opt-in for performance-sensitive hot loops - `__toString()` bypasses redundant regex validation for better performance -- Cross-type argument passing between `Math` and `MathImmutable` instances - PHPStan level 9 compliance for `src/` - GitHub Actions workflows updated to latest action versions +### Added + +- `MathMutable` — mutable variant of `Math` where operations modify the instance in place. Extends `Math` and is accepted anywhere `Math` is type-hinted. +- `negate()` — flip sign, zero stays zero +- `clamp($min, $max)` — clip value between bounds +- `quotientAndRemainder($divisor)` — returns `[$quotient, $remainder]` in one call +- `isZero()` — precision-aware zero check +- `isNegative()` — complement to `isPositive()` +- `isEven()` / `isOdd()` — integer parity checks, return `false` for non-integers +- `getScale()` — number of meaningful decimal places (normalized) +- `getIntegralPart()` — part before the decimal point (normalized, `-0` becomes `0`) +- `getFractionalPart()` — part after the decimal point (normalized, trailing zeros stripped) +- `declare(strict_types=1)` in all source files +- `phpstan-tests.neon` — dedicated PHPStan configuration for tests at level 5 + ### Fixed -- `format()` now works correctly with `Math` subclasses that override mutating methods +- `format()` now works correctly with immutable default (captures `abs()` return value) - `bcDec2Base()` properly initializes result as `'0'` +### Removed + +- `MathImmutable` — no longer needed, `Math` itself is now immutable + ## [2.0.0] - 2024-04-24 ### Added @@ -51,8 +65,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). Initial release. -[Unreleased]: https://github.com/fab2s/Math/compare/2.1.0...HEAD -[2.1.0]: https://github.com/fab2s/Math/compare/2.0.0...2.1.0 +[Unreleased]: https://github.com/fab2s/Math/compare/3.0.0...HEAD +[3.0.0]: https://github.com/fab2s/Math/compare/2.0.0...3.0.0 [2.0.0]: https://github.com/fab2s/Math/compare/1.0.1...2.0.0 [1.0.1]: https://github.com/fab2s/Math/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/fab2s/Math/releases/tag/1.0.0 diff --git a/README.md b/README.md index 6317d7f..1f2b7e2 100644 --- a/README.md +++ b/README.md @@ -54,34 +54,30 @@ $result = Math::number('100') echo $result; // '42' ``` -> **Important:** `Math` instances are **mutable**. Operations modify the instance in place and return `$this` for chaining. To preserve the original value, use `MathImmutable` or wrap it in a new instance: -> -> ```php -> $original = Math::number('100'); -> $modified = $original->add('50'); // $original is now also '150' -> -> // To keep $original unchanged: -> $original = Math::number('100'); -> $modified = Math::number($original)->add('50'); // $original stays '100' -> ``` - -### Immutable Variant - -`MathImmutable` provides the same API but every operation returns a new instance, leaving the original unchanged: +`Math` is **immutable** — every operation returns a new instance, leaving the original unchanged: ```php -use fab2s\Math\MathImmutable; - -$a = MathImmutable::number('100'); +$a = Math::number('100'); $b = $a->add('50'); // $a is still '100', $b is '150' $c = $b->mul('2'); // $b is still '150', $c is '300' +``` + +The overhead is a single `clone` per operation (two properties: a string and an int). + +### Mutable Variant + +For performance-sensitive hot loops, `MathMutable` modifies the instance in place: -// Works everywhere Math is accepted -function calculateTax(Math $price): Math { /* ... */ } -calculateTax($a); // MathImmutable extends Math +```php +use fab2s\Math\MathMutable; + +$sum = MathMutable::number('0'); +for ($i = 0; $i < 1000; $i++) { + $sum->add($i . '.99'); // modifies $sum in place, no clone +} ``` -`MathImmutable` extends `Math`, so it inherits all factory methods (`number()`, `make()`, `fromBase()`) and is accepted anywhere `Math` is type-hinted. The overhead is a single `clone` per operation (two properties: a string and an int). +`MathMutable` extends `Math`, so it is accepted anywhere `Math` is type-hinted. ### Strict Validation @@ -116,6 +112,10 @@ $n->pow('2'); // Power $n->mod('7'); // Modulo $n->powMod($e, $m); // Modular exponentiation $n->abs(); // Absolute value +$n->negate(); // Flip sign + +// Division +$n->quotientAndRemainder('7'); // [$quotient, $remainder] // Rounding $n->round(2); // Round to 2 decimals @@ -123,11 +123,12 @@ $n->floor(); // Round down $n->ceil(); // Round up // Limits -$n->min('50', '200'); // 50 -$n->max('50', '200'); // 200 +$n->min('50', '200'); // 50 +$n->max('50', '200'); // 200 +$n->clamp('10', '90'); // Clip between bounds ``` -### Comparisons +### Comparisons & Inspection ```php $n = Math::number('42'); @@ -137,6 +138,17 @@ $n->gt('40'); // true — greater than $n->gte('42'); // true — greater than or equal $n->lt('50'); // true — less than $n->lte('42'); // true — less than or equal + +$n->isZero(); // false +$n->isPositive(); // true +$n->isNegative(); // false +$n->isEven(); // true +$n->isOdd(); // false + +$n = Math::number('42.99'); +$n->getScale(); // 2 +$n->getIntegralPart(); // '42' +$n->getFractionalPart(); // '99' ``` ### Base Conversion (2-62) @@ -203,7 +215,7 @@ Pass Math instances directly to avoid re-validation: $tax = Math::number('0.20'); $price = Math::number('99.99'); -$total = Math::number($price)->add($price->mul($tax)); +$total = $price->add($price->mul($tax)); ``` ## Laravel Integration @@ -235,12 +247,12 @@ $order->total = null; // Throws NotNullableException | Method | Description | |--------|-------------| -| `Math::number($n)` | Create mutable instance | +| `Math::number($n)` | Create immutable instance | | `Math::make($n)` | Alias for `number()` | | `Math::fromBase($n, $base)` | Create from base 2-62 | -| `MathImmutable::number($n)` | Create immutable instance | -| `MathImmutable::make($n)` | Alias for `number()` | -| `MathImmutable::fromBase($n, $base)` | Create immutable from base 2-62 | +| `MathMutable::number($n)` | Create mutable instance | +| `MathMutable::make($n)` | Alias for `number()` | +| `MathMutable::fromBase($n, $base)` | Create mutable from base 2-62 | ### Arithmetic @@ -250,11 +262,14 @@ $order->total = null; // Throws NotNullableException | `sub(...$n)` | Subtraction | | `mul(...$n)` | Multiplication | | `div(...$n)` | Division | +| `quotientAndRemainder($n)` | Returns `[$quotient, $remainder]` | | `mod($n)` | Modulo | | `pow($n)` | Power | | `powMod($exp, $mod)` | Modular exponentiation | | `sqrt()` | Square root | | `abs()` | Absolute value | +| `negate()` | Flip sign | +| `clamp($min, $max)` | Clip between bounds | ### Rounding @@ -264,7 +279,7 @@ $order->total = null; // Throws NotNullableException | `floor()` | Round down | | `ceil()` | Round up | -### Comparison +### Comparison & Inspection | Method | Description | |--------|-------------| @@ -275,6 +290,11 @@ $order->total = null; // Throws NotNullableException | `lte($n)` | Less than or equal | | `min(...$n)` | Minimum value | | `max(...$n)` | Maximum value | +| `isZero()` | Check if zero | +| `isPositive()` | Check if positive | +| `isNegative()` | Check if negative | +| `isEven()` | Check if even integer | +| `isOdd()` | Check if odd integer | ### Conversion & Output @@ -283,6 +303,9 @@ $order->total = null; // Throws NotNullableException | `toBase($base)` | Convert to base 2-62 | | `format($dec, $point, $sep)` | Format with separators | | `getNumber()` | Get raw (non-normalized) number | +| `getScale()` | Number of decimal places | +| `getIntegralPart()` | Part before the decimal point | +| `getFractionalPart()` | Part after the decimal point | | `(string)` | Get normalized number | ### Precision @@ -300,33 +323,44 @@ Compared against [brick/math](https://github.com/brick/math) (PHP 8.4, opcache o | Operation | fab2s/math | brick/math | Factor | |---|---:|---:|---:| -| instantiate int | **0.244μs (±6.9%)** | 0.274μs (±2.0%) | 1.12x | -| instantiate string | **0.224μs (±8.8%)** | 0.566μs (±3.4%) | 2.52x | -| add | **0.560μs (±16.8%)** | 2.083μs (±3.3%) | 3.72x | -| add variadic | **1.244μs (±2.7%)** | 6.172μs (±2.7%) | 4.96x | -| sub | **0.570μs (±4.6%)** | 2.134μs (±4.1%) | 3.75x | -| mul | **0.659μs (±6.5%)** | 2.073μs (±6.2%) | 3.15x | -| div | **0.646μs (±12.1%)** | 4.374μs (±2.3%) | 6.78x | -| pow | **0.896μs (±4.9%)** | 1.248μs (±12.7%) | 1.39x | -| mod | **0.800μs (±8.0%)** | 2.554μs (±5.1%) | 3.19x | -| sqrt | **2.414μs (±7.2%)** | 4.893μs (±38.1%) | 2.03x | -| abs | **0.594μs (±18.9%)** | 1.000μs (±8.4%) | 1.68x | -| round | **0.556μs (±21.3%)** | 3.474μs (±5.2%) | 6.25x | -| ceil | **0.505μs (±8.9%)** | 3.203μs (±37.2%) | 6.34x | -| floor | **0.444μs (±3.3%)** | 2.448μs (±3.1%) | 5.52x | -| comparisons | **1.471μs (±61.7%)** | 6.027μs (±4.7%) | 4.10x | -| to string | **0.585μs (±19.4%)** | 0.842μs (±12.0%) | 1.44x | -| chained workflow | **1.618μs (±3.3%)** | 8.461μs (±10.7%) | 5.23x | -| large number ops | **1.679μs (±3.3%)** | 8.392μs (±3.4%) | 5.00x | -| accumulate 100 additions | **37.691μs (±0.9%)** | 150.010μs (±1.7%) | 3.98x | -| base convert to 62 | **1.079μs (±1.6%)** | 6.328μs (±16.6%) | 5.87x | -| base convert to 16 | 0.966μs (±8.2%) | **0.863μs (±13.0%)** | 0.89x | -| integer mul | **0.691μs (±33.0%)** | 1.859μs (±16.5%) | 2.69x | -| integer powmod | **1.124μs (±8.3%)** | 2.551μs (±3.6%) | 2.27x | -| create 1000 instances | **281.288μs (±3.2%)** | 669.150μs (±0.7%) | 2.38x | -| immutable chain | **2.086μs (±5.9%)** | 13.237μs (±2.8%) | 6.34x | - -fab2s/math wins every operation except base-16 conversion, where brick/math delegates to GMP's native hex output. The speed advantage comes from keeping bcmath's C-level string arithmetic as the hot path for decimal operations, while brick/math pays for an extra object-wrapping layer on top of GMP. Integer-only operations (`mod`, `pow`, `powMod`, base conversion) use GMP directly when the extension is available, combining the best of both backends. Realistic workflows like chained calculations or 100-iteration accumulations show a consistent 4-6x advantage, and the immutable variant stays over 6x faster thanks to a single lightweight `clone` per operation versus brick/math's heavier object allocation. +| instantiate int | **0.261μs (±4.0%)** | 0.301μs (±8.9%) | 1.15x | +| instantiate string | **0.244μs (±36.6%)** | 0.678μs (±4.5%) | 2.78x | +| add | **0.632μs (±9.7%)** | 2.278μs (±3.0%) | 3.60x | +| add variadic | **1.406μs (±5.6%)** | 6.653μs (±1.3%) | 4.73x | +| sub | **0.612μs (±1.7%)** | 2.325μs (±4.8%) | 3.80x | +| mul | **0.665μs (±2.5%)** | 2.183μs (±4.9%) | 3.28x | +| div | **0.762μs (±4.1%)** | 4.664μs (±3.6%) | 6.12x | +| pow | **0.986μs (±47.0%)** | 1.416μs (±45.5%) | 1.44x | +| mod | **0.851μs (±2.3%)** | 2.851μs (±42.2%) | 3.35x | +| sqrt | **2.160μs (±3.6%)** | 4.536μs (±11.3%) | 2.10x | +| abs | **0.344μs (±4.0%)** | 0.919μs (±35.8%) | 2.67x | +| negate | **0.379μs (±10.5%)** | 1.036μs (±93.7%) | 2.73x | +| clamp | **0.956μs (±64.5%)** | 4.325μs (±32.5%) | 4.53x | +| quotient & remainder | **0.894μs (±10.7%)** | 2.878μs (±4.6%) | 3.22x | +| inspection | **1.943μs (±5.7%)** | 4.672μs (±3.7%) | 2.40x | +| round | **0.596μs (±31.8%)** | 3.495μs (±10.2%) | 5.86x | +| ceil | **0.528μs (±8.1%)** | 2.965μs (±39.6%) | 5.62x | +| floor | **0.469μs (±6.2%)** | 2.504μs (±4.0%) | 5.34x | +| comparisons | **1.400μs (±7.5%)** | 6.171μs (±4.7%) | 4.41x | +| to string | **0.529μs (±6.6%)** | 0.789μs (±3.4%) | 1.49x | +| chained workflow | **1.810μs (±3.2%)** | 8.519μs (±2.0%) | 4.71x | +| large number ops | **1.792μs (±5.9%)** | 8.273μs (±1.8%) | 4.62x | +| accumulate 100 additions | **41.182μs (±3.0%)** | 147.875μs (±11.8%) | 3.59x | +| base convert to 62 | **1.162μs (±23.8%)** | 6.888μs (±5.6%) | 5.93x | +| base convert to 16 | 1.081μs (±15.3%) | **0.965μs (±7.6%)** | 0.89x | +| integer mul | **0.937μs (±11.6%)** | 1.873μs (±5.3%) | 2.00x | +| integer powmod | **1.263μs (±10.0%)** | 2.810μs (±7.9%) | 2.22x | +| create 1000 instances | **301.794μs (±4.2%)** | 731.200μs (±2.6%) | 2.42x | + +All operations above use immutable `Math` (the default). fab2s/math wins every operation except base-16 conversion, where brick/math delegates to GMP's native hex output. The speed advantage comes from keeping bcmath's C-level string arithmetic as the hot path for decimal operations, while brick/math pays for an extra object-wrapping layer on top of GMP. Integer-only operations (`mod`, `pow`, `powMod`, base conversion) use GMP directly when the extension is available, combining the best of both backends. Realistic workflows like chained calculations or 100-iteration accumulations show a consistent 3-5x advantage, with immutability costing only a lightweight `clone` per operation (two properties: a string and an int). + +`MathMutable` eliminates the clone overhead entirely for hot loops: + +| Operation | MathMutable | Math (immutable) | brick/math | +|---|---:|---:|---:| +| chained workflow | **1.964μs (±8.5%)** | 2.294μs (±5.0%) | 14.149μs (±3.9%) | +| accumulate 100 | **38.086μs (±2.0%)** | 41.339μs (±2.5%) | 147.063μs (±0.4%) | +| branch | 2.795μs (±4.0%) | **2.580μs (±3.3%)** | 12.885μs (±20.7%) | Run benchmarks yourself: diff --git a/benchmarks/MathBench.php b/benchmarks/MathBench.php index 6bcd119..866dfba 100644 --- a/benchmarks/MathBench.php +++ b/benchmarks/MathBench.php @@ -234,6 +234,96 @@ public function brick_abs(): void ; } + // ─── Negate ───────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['negate'])] + public function fab2s_negate(): void + { + Math::number('987654321.123456789') + ->negate() + ; + } + + #[Bench\Subject] + #[Bench\Groups(['negate'])] + public function brick_negate(): void + { + BigDecimal::of('987654321.123456789') + ->negated() + ; + } + + // ─── Clamp ────────────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['clamp'])] + public function fab2s_clamp(): void + { + Math::number('987654321.123456789') + ->clamp('100', '999999999') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['clamp'])] + public function brick_clamp(): void + { + BigDecimal::of('987654321.123456789') + ->clamp('100', '999999999') + ; + } + + // ─── Quotient and Remainder ───────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['quotient_remainder'])] + public function fab2s_quotient_and_remainder(): void + { + Math::number('987654321') + ->quotientAndRemainder('12345') + ; + } + + #[Bench\Subject] + #[Bench\Groups(['quotient_remainder'])] + public function brick_quotient_and_remainder(): void + { + BigDecimal::of('987654321') + ->quotientAndRemainder('12345') + ; + } + + // ─── Inspection ───────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['inspection'])] + public function fab2s_inspection(): void + { + $a = Math::number('123456789.123456789'); + $a->isZero(); + $a->isPositive(); + $a->isNegative(); + $a->isEven(); + $a->isOdd(); + $a->getScale(); + $a->getIntegralPart(); + $a->getFractionalPart(); + } + + #[Bench\Subject] + #[Bench\Groups(['inspection'])] + public function brick_inspection(): void + { + $a = BigDecimal::of('123456789.123456789'); + $a->isZero(); + $a->isPositive(); + $a->isNegative(); + $a->getScale(); + $a->getIntegralPart(); + $a->getFractionalPart(); + } + // ─── Rounding ──────────────────────────────────────────────── #[Bench\Subject] @@ -394,7 +484,7 @@ public function fab2s_accumulate_100_additions(): void { $sum = Math::number('0'); for ($i = 0; $i < 100; $i++) { - $sum->add((string) $i . '.99'); + $sum = $sum->add((string) $i . '.99'); } } diff --git a/benchmarks/ImmutableBench.php b/benchmarks/MutableBench.php similarity index 70% rename from benchmarks/ImmutableBench.php rename to benchmarks/MutableBench.php index a883dcc..9765189 100644 --- a/benchmarks/ImmutableBench.php +++ b/benchmarks/MutableBench.php @@ -14,17 +14,16 @@ use Brick\Math\BigDecimal; use Brick\Math\RoundingMode; use fab2s\Math\Math; -use fab2s\Math\MathImmutable; +use fab2s\Math\MathMutable; use PhpBench\Attributes as Bench; /** - * Compares MathImmutable with brick/math BigDecimal (both immutable) - * and measures the overhead of immutability in fab2s/math. + * Compares Math (immutable) with MathMutable and brick/math BigDecimal. */ #[Bench\Warmup(3)] #[Bench\Iterations(5)] #[Bench\Revs(1000)] -class ImmutableBench +class MutableBench { // ─── Mutable vs Immutable (fab2s internal comparison) ──────── @@ -32,7 +31,7 @@ class ImmutableBench #[Bench\Groups(['mutability'])] public function fab2s_mutable_chain(): void { - Math::number('1000.00') + MathMutable::number('1000.00') ->mul('1.21') ->add('50.00') ->sub('100.00') @@ -45,7 +44,7 @@ public function fab2s_mutable_chain(): void #[Bench\Groups(['mutability'])] public function fab2s_immutable_chain(): void { - MathImmutable::number('1000.00') + Math::number('1000.00') ->mul('1.21') ->add('50.00') ->sub('100.00') @@ -67,21 +66,32 @@ public function brick_immutable_chain(): void ; } - // ─── Accumulation with immutable objects ───────────────────── + // ─── Accumulation ─────────────────────────────────────────── + + #[Bench\Subject] + #[Bench\Groups(['accumulation'])] + #[Bench\Revs(100)] + public function fab2s_mutable_accumulate_100(): void + { + $sum = MathMutable::number('0'); + for ($i = 0; $i < 100; $i++) { + $sum->add($i . '.99'); + } + } #[Bench\Subject] - #[Bench\Groups(['immutable_accumulation'])] + #[Bench\Groups(['accumulation'])] #[Bench\Revs(100)] public function fab2s_immutable_accumulate_100(): void { - $sum = MathImmutable::number('0'); + $sum = Math::number('0'); for ($i = 0; $i < 100; $i++) { $sum = $sum->add($i . '.99'); } } #[Bench\Subject] - #[Bench\Groups(['immutable_accumulation'])] + #[Bench\Groups(['accumulation'])] #[Bench\Revs(100)] public function brick_accumulate_100(): void { @@ -93,11 +103,22 @@ public function brick_accumulate_100(): void // ─── Repeated operations on same base value ────────────────── + #[Bench\Subject] + #[Bench\Groups(['branch'])] + public function fab2s_mutable_branch(): void + { + $price = MathMutable::number('99.99'); + $withTax10 = MathMutable::number($price)->mul('1.10'); + $withTax20 = MathMutable::number($price)->mul('1.20'); + $withTax10->add('5.00')->round(2); + $withTax20->add('5.00')->round(2); + } + #[Bench\Subject] #[Bench\Groups(['branch'])] public function fab2s_immutable_branch(): void { - $price = MathImmutable::number('99.99'); + $price = Math::number('99.99'); $withTax10 = $price->mul('1.10'); $withTax20 = $price->mul('1.20'); $withTax10->add('5.00')->round(2); diff --git a/src/Math.php b/src/Math.php index 88f2212..dbb3f89 100644 --- a/src/Math.php +++ b/src/Math.php @@ -117,7 +117,7 @@ public function toBase(string|int $base): string $result = ''; $strBase = (string) $base; $baseChar = static::getBaseChar($base); - while (bccomp($number, '0') != 0) { // still data to process + while ($number !== '0') { // still data to process $rem = (int) bcmod($number, $strBase); // calc the remainder $number = bcdiv(bcsub($number, (string) $rem), $strBase); $result = $baseChar[$rem] . $result; @@ -135,7 +135,8 @@ public function format(string|int $decimals = 0, string $decPoint = '.', string // do not mutate $number = (new self($this))->round($decimals)->normalize(); $sign = $number->isPositive() ? '' : '-'; - if ($number->abs()->hasDecimals()) { + $number = $number->abs(); + if ($number->hasDecimals()) { [$number, $dec] = explode('.', (string) $number); } else { $number = (string) $number; diff --git a/src/MathBaseAbstract.php b/src/MathBaseAbstract.php index db43706..c25d021 100644 --- a/src/MathBaseAbstract.php +++ b/src/MathBaseAbstract.php @@ -78,11 +78,60 @@ public function isPositive(): bool return $this->number[0] !== '-'; } + public function isNegative(): bool + { + return $this->number[0] === '-' && trim($this->number, '-0.') !== ''; + } + + public function isZero(): bool + { + return trim($this->number, '+-0.') === ''; + } + + public function isEven(): bool + { + $number = static::normalizeReal($this->number); + + return ! str_contains($number, '.') && bcmod($number, '2') === '0'; + } + + public function isOdd(): bool + { + $number = static::normalizeReal($this->number); + + return ! str_contains($number, '.') && bcmod($number, '2') !== '0'; + } + public function hasDecimals(): bool { return str_contains($this->number, '.'); } + public function getScale(): int + { + $number = static::normalizeReal($this->number); + $pos = strpos($number, '.'); + + return $pos === false ? 0 : strlen($number) - $pos - 1; + } + + public function getIntegralPart(): string + { + $number = static::normalizeReal($this->number); + $pos = strpos($number, '.'); + $result = $pos === false ? $number : substr($number, 0, $pos); + + return $result === '-0' ? '0' : $result; + } + + public function getFractionalPart(): string + { + $number = static::normalizeReal($this->number); + $pos = strpos($number, '.'); + + return $pos === false ? '' : substr($number, $pos + 1); + } + public function normalize(): static { $result = $this->mutate(); @@ -102,7 +151,7 @@ public function setPrecision(string|int $precision): static protected function mutate(): static { - return $this; + return clone $this; } public static function setGlobalPrecision(string|int $precision): void diff --git a/src/MathImmutable.php b/src/MathMutable.php similarity index 85% rename from src/MathImmutable.php rename to src/MathMutable.php index 2c75b87..c32e774 100644 --- a/src/MathImmutable.php +++ b/src/MathMutable.php @@ -11,10 +11,10 @@ namespace fab2s\Math; -class MathImmutable extends Math +class MathMutable extends Math { protected function mutate(): static { - return clone $this; + return $this; } } diff --git a/src/MathOpsAbstract.php b/src/MathOpsAbstract.php index ac5ccf8..15988b1 100644 --- a/src/MathOpsAbstract.php +++ b/src/MathOpsAbstract.php @@ -166,6 +166,39 @@ public function abs(): static return $result; } + public function negate(): static + { + $result = $this->mutate(); + if ($result->number[0] === '-') { + $result->number = substr($result->number, 1); // @phpstan-ignore assign.propertyType + } elseif (trim($result->number, '+-0.') !== '') { + $result->number = '-' . $result->number; // @phpstan-ignore assign.propertyType + } + + return $result; + } + + public function clamp(string|int|float|Math $min, string|int|float|Math $max): static + { + return $this->max($min)->min($max); + } + + /** + * @return array{static, static} + */ + public function quotientAndRemainder(string|int|float|Math $divisor): array + { + $divisor = static::validateInputNumber($divisor); + $number = $this->number; + $quotient = $this->mutate(); + $remainder = clone $this; + + $quotient->number = bcdiv($number, $divisor, 0); + $remainder->number = bcmod($number, $divisor, $this->precision); + + return [$quotient, $remainder]; + } + /** * returns the highest number among all arguments */ diff --git a/tests/MathImmutableTest.php b/tests/MathImmutableTest.php deleted file mode 100644 index ee63fae..0000000 --- a/tests/MathImmutableTest.php +++ /dev/null @@ -1,237 +0,0 @@ -assertInstanceOf(Math::class, MathImmutable::number('42')); - } - - public function test_add_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->add('5'); - - $this->assertSame('10', (string) $a); - $this->assertSame('15', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_sub_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->sub('3'); - - $this->assertSame('10', (string) $a); - $this->assertSame('7', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_mul_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->mul('3'); - - $this->assertSame('10', (string) $a); - $this->assertSame('30', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_div_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->div('2'); - - $this->assertSame('10', (string) $a); - $this->assertSame('5', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_sqrt_is_immutable() - { - $a = MathImmutable::number('9'); - $b = $a->sqrt(); - - $this->assertSame('9', (string) $a); - $this->assertSame('3', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_pow_is_immutable() - { - $a = MathImmutable::number('2'); - $b = $a->pow(3); - - $this->assertSame('2', (string) $a); - $this->assertSame('8', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_mod_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->mod(3); - - $this->assertSame('10', (string) $a); - $this->assertSame('1', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_round_is_immutable() - { - $a = MathImmutable::number('10.555'); - $b = $a->round(2); - - $this->assertSame('10.555', (string) $a); - $this->assertSame('10.56', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_ceil_is_immutable() - { - $a = MathImmutable::number('10.1'); - $b = $a->ceil(); - - $this->assertSame('10.1', (string) $a); - $this->assertSame('11', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_floor_is_immutable() - { - $a = MathImmutable::number('10.9'); - $b = $a->floor(); - - $this->assertSame('10.9', (string) $a); - $this->assertSame('10', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_abs_is_immutable() - { - $a = MathImmutable::number('-10'); - $b = $a->abs(); - - $this->assertSame('-10', (string) $a); - $this->assertSame('10', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_max_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->max('20'); - - $this->assertSame('10', (string) $a); - $this->assertSame('20', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_min_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->min('5'); - - $this->assertSame('10', (string) $a); - $this->assertSame('5', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_normalize_is_immutable() - { - $a = MathImmutable::number('010.100'); - $b = $a->normalize(); - - $this->assertSame('010.100', $a->getNumber()); - $this->assertSame('10.1', (string) $b); - $this->assertNotSame($a, $b); - } - - public function test_set_precision_is_immutable() - { - $a = MathImmutable::number('10'); - $b = $a->setPrecision(2); - - $this->assertNotSame($a, $b); - $this->assertSame('3.33', (string) $b->div(3)); - // original precision unchanged - $this->assertSame('3.333333333', (string) $a->div(3)); - } - - public function test_chaining() - { - $a = MathImmutable::number('10'); - $b = $a->add('5')->mul('2'); - - $this->assertSame('10', (string) $a); - $this->assertSame('30', (string) $b); - } - - public function test_factory_methods() - { - $a = MathImmutable::number('42'); - $this->assertInstanceOf(MathImmutable::class, $a); - - $b = MathImmutable::make('42'); - $this->assertInstanceOf(MathImmutable::class, $b); - } - - public function test_operations_return_immutable_math() - { - $a = MathImmutable::number('10'); - - $this->assertInstanceOf(MathImmutable::class, $a->add('1')); - $this->assertInstanceOf(MathImmutable::class, $a->sub('1')); - $this->assertInstanceOf(MathImmutable::class, $a->mul('2')); - $this->assertInstanceOf(MathImmutable::class, $a->div('2')); - $this->assertInstanceOf(MathImmutable::class, $a->pow(2)); - $this->assertInstanceOf(MathImmutable::class, $a->mod(3)); - $this->assertInstanceOf(MathImmutable::class, $a->abs()); - $this->assertInstanceOf(MathImmutable::class, $a->round(2)); - $this->assertInstanceOf(MathImmutable::class, $a->ceil()); - $this->assertInstanceOf(MathImmutable::class, $a->floor()); - $this->assertInstanceOf(MathImmutable::class, $a->normalize()); - $this->assertInstanceOf(MathImmutable::class, $a->setPrecision(4)); - $this->assertInstanceOf(MathImmutable::class, $a->max('20')); - $this->assertInstanceOf(MathImmutable::class, $a->min('5')); - } - - public function test_cross_type_operations() - { - $immutable = MathImmutable::number('10'); - $mutable = Math::number('5'); - - // MathImmutable accepting Math argument - $result = $immutable->add($mutable); - $this->assertSame('15', (string) $result); - $this->assertSame('10', (string) $immutable); - $this->assertInstanceOf(MathImmutable::class, $result); - - // Math accepting MathImmutable argument - $result2 = $mutable->add($immutable); - $this->assertSame('15', (string) $result2); - $this->assertInstanceOf(Math::class, $result2); - } - - public function test_variadic_immutability() - { - $a = MathImmutable::number('10'); - $b = $a->add('1', '2', '3'); - - $this->assertSame('10', (string) $a); - $this->assertSame('16', (string) $b); - } -} diff --git a/tests/MathMutableTest.php b/tests/MathMutableTest.php new file mode 100644 index 0000000..e5f75cb --- /dev/null +++ b/tests/MathMutableTest.php @@ -0,0 +1,355 @@ +add('5'); + + $this->assertSame('10', (string) $a); + $this->assertSame('15', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_sub_is_immutable() + { + $a = Math::number('10'); + $b = $a->sub('3'); + + $this->assertSame('10', (string) $a); + $this->assertSame('7', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_mul_is_immutable() + { + $a = Math::number('10'); + $b = $a->mul('3'); + + $this->assertSame('10', (string) $a); + $this->assertSame('30', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_div_is_immutable() + { + $a = Math::number('10'); + $b = $a->div('2'); + + $this->assertSame('10', (string) $a); + $this->assertSame('5', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_sqrt_is_immutable() + { + $a = Math::number('9'); + $b = $a->sqrt(); + + $this->assertSame('9', (string) $a); + $this->assertSame('3', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_pow_is_immutable() + { + $a = Math::number('2'); + $b = $a->pow(3); + + $this->assertSame('2', (string) $a); + $this->assertSame('8', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_mod_is_immutable() + { + $a = Math::number('10'); + $b = $a->mod(3); + + $this->assertSame('10', (string) $a); + $this->assertSame('1', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_round_is_immutable() + { + $a = Math::number('10.555'); + $b = $a->round(2); + + $this->assertSame('10.555', (string) $a); + $this->assertSame('10.56', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_ceil_is_immutable() + { + $a = Math::number('10.1'); + $b = $a->ceil(); + + $this->assertSame('10.1', (string) $a); + $this->assertSame('11', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_floor_is_immutable() + { + $a = Math::number('10.9'); + $b = $a->floor(); + + $this->assertSame('10.9', (string) $a); + $this->assertSame('10', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_abs_is_immutable() + { + $a = Math::number('-10'); + $b = $a->abs(); + + $this->assertSame('-10', (string) $a); + $this->assertSame('10', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_max_is_immutable() + { + $a = Math::number('10'); + $b = $a->max('20'); + + $this->assertSame('10', (string) $a); + $this->assertSame('20', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_min_is_immutable() + { + $a = Math::number('10'); + $b = $a->min('5'); + + $this->assertSame('10', (string) $a); + $this->assertSame('5', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_normalize_is_immutable() + { + $a = Math::number('010.100'); + $b = $a->normalize(); + + $this->assertSame('010.100', $a->getNumber()); + $this->assertSame('10.1', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_set_precision_is_immutable() + { + $a = Math::number('10'); + $b = $a->setPrecision(2); + + $this->assertNotSame($a, $b); + $this->assertSame('3.33', (string) $b->div(3)); + // original precision unchanged + $this->assertSame('3.333333333', (string) $a->div(3)); + } + + public function test_chaining_is_immutable() + { + $a = Math::number('10'); + $b = $a->add('5')->mul('2'); + + $this->assertSame('10', (string) $a); + $this->assertSame('30', (string) $b); + } + + public function test_variadic_immutability() + { + $a = Math::number('10'); + $b = $a->add('1', '2', '3'); + + $this->assertSame('10', (string) $a); + $this->assertSame('16', (string) $b); + } + + public function test_operations_return_math() + { + $a = Math::number('10'); + + $this->assertInstanceOf(Math::class, $a->add('1')); + $this->assertInstanceOf(Math::class, $a->sub('1')); + $this->assertInstanceOf(Math::class, $a->mul('2')); + $this->assertInstanceOf(Math::class, $a->div('2')); + $this->assertInstanceOf(Math::class, $a->pow(2)); + $this->assertInstanceOf(Math::class, $a->mod(3)); + $this->assertInstanceOf(Math::class, $a->abs()); + $this->assertInstanceOf(Math::class, $a->round(2)); + $this->assertInstanceOf(Math::class, $a->ceil()); + $this->assertInstanceOf(Math::class, $a->floor()); + $this->assertInstanceOf(Math::class, $a->normalize()); + $this->assertInstanceOf(Math::class, $a->setPrecision(4)); + $this->assertInstanceOf(Math::class, $a->max('20')); + $this->assertInstanceOf(Math::class, $a->min('5')); + } + + // ─── MathMutable is mutable ─────────────────────────────────── + + public function test_mutable_instanceof_math() + { + $this->assertInstanceOf(Math::class, MathMutable::number('42')); + } + + public function test_mutable_add_is_mutable() + { + $a = MathMutable::number('10'); + $b = $a->add('5'); + + $this->assertSame('15', (string) $a); + $this->assertSame($a, $b); + } + + public function test_mutable_sub_is_mutable() + { + $a = MathMutable::number('10'); + $b = $a->sub('3'); + + $this->assertSame('7', (string) $a); + $this->assertSame($a, $b); + } + + public function test_mutable_mul_is_mutable() + { + $a = MathMutable::number('10'); + $b = $a->mul('3'); + + $this->assertSame('30', (string) $a); + $this->assertSame($a, $b); + } + + public function test_mutable_div_is_mutable() + { + $a = MathMutable::number('10'); + $b = $a->div('2'); + + $this->assertSame('5', (string) $a); + $this->assertSame($a, $b); + } + + public function test_mutable_normalize_is_mutable() + { + $a = MathMutable::number('010.100'); + $b = $a->normalize(); + + $this->assertSame($a, $b); + } + + public function test_mutable_set_precision_is_mutable() + { + $a = MathMutable::number('10'); + $b = $a->setPrecision(2); + + $this->assertSame($a, $b); + } + + public function test_mutable_factory_methods() + { + $a = MathMutable::number('42'); + $this->assertInstanceOf(MathMutable::class, $a); + + $b = MathMutable::make('42'); + $this->assertInstanceOf(MathMutable::class, $b); + } + + public function test_mutable_operations_return_mutable() + { + $a = MathMutable::number('10'); + + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->add('1')); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->sub('1')); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->mul('2')); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->div('2')); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->pow(2)); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->mod(3)); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('-10')->abs()); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10.5')->round(0)); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10.5')->ceil()); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10.5')->floor()); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->normalize()); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->setPrecision(4)); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->max('20')); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->min('5')); + } + + public function test_cross_type_operations() + { + $immutable = Math::number('10'); + $mutable = MathMutable::number('5'); + + // Math accepting MathMutable argument + $result = $immutable->add($mutable); + $this->assertSame('15', (string) $result); + $this->assertSame('10', (string) $immutable); + $this->assertInstanceOf(Math::class, $result); + + // MathMutable accepting Math argument + $result2 = $mutable->add($immutable); + $this->assertSame('15', (string) $result2); + $this->assertInstanceOf(MathMutable::class, $result2); + } + + // ─── MathMutable edge cases for new operations ─────────────── + + public function test_mutable_negate_is_mutable() + { + $a = MathMutable::number('42'); + $b = $a->negate(); + + $this->assertSame('-42', (string) $a); + $this->assertSame($a, $b); + } + + public function test_mutable_clamp_is_mutable() + { + $a = MathMutable::number('15'); + $b = $a->clamp('0', '10'); + + $this->assertSame('10', (string) $a); + $this->assertSame($a, $b); + } + + public function test_mutable_quotient_and_remainder() + { + $a = MathMutable::number('17'); + [$q, $r] = $a->quotientAndRemainder('5'); + + // quotient is $this (mutated in place) + $this->assertSame($a, $q); + $this->assertSame('3', (string) $q); + // remainder is a separate clone + $this->assertNotSame($a, $r); + $this->assertSame('2', (string) $r); + $this->assertInstanceOf(MathMutable::class, $r); + } + + public function test_mutable_negate_operations_return_mutable() + { + $this->assertInstanceOf(MathMutable::class, MathMutable::number('42')->negate()); + $this->assertInstanceOf(MathMutable::class, MathMutable::number('15')->clamp('0', '10')); + + [$q, $r] = MathMutable::number('17')->quotientAndRemainder('5'); + $this->assertInstanceOf(MathMutable::class, $q); + $this->assertInstanceOf(MathMutable::class, $r); + } +} diff --git a/tests/MathTest.php b/tests/MathTest.php index 30ef64e..ea73b96 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -81,6 +81,10 @@ public static function number_formatData(): array ['-0', '0.00000000', 8], ['+0', '0.00000000', 8], ['0', '0'], + // negative values that round to zero must not produce -0 + ['-0.001', '0.00', 2], + ['-0.004', '0.00', 2], + ['-0.000000001', '0.00000000', 8], ]; } @@ -522,6 +526,19 @@ public static function normalizeData(): array 'number' => Math::number(' 000100.0001 '), 'expected' => '100.0001', ], + // negative zero variants with decimal padding + [ + 'number' => '-0.00', + 'expected' => '0', + ], + [ + 'number' => '-0.000000000', + 'expected' => '0', + ], + [ + 'number' => '-00.00', + 'expected' => '0', + ], ]; } @@ -1193,4 +1210,167 @@ public function test_to_string() $this->assertSame('33.33', (string) Math::number('33.33')); $this->assertSame('33.33', Math::number('33.33')->jsonSerialize()); } + + public function test_is_zero() + { + $this->assertTrue(Math::number('0')->isZero()); + $this->assertTrue(Math::number('-0')->isZero()); + $this->assertTrue(Math::number('+0')->isZero()); + $this->assertTrue(Math::number('0.000000000')->isZero()); + $this->assertFalse(Math::number('1')->isZero()); + $this->assertFalse(Math::number('-1')->isZero()); + $this->assertFalse(Math::number('0.000000001')->isZero()); + } + + public function test_is_negative() + { + $this->assertTrue(Math::number('-1')->isNegative()); + $this->assertTrue(Math::number('-0.001')->isNegative()); + $this->assertFalse(Math::number('0')->isNegative()); + $this->assertFalse(Math::number('-0')->isNegative()); + $this->assertFalse(Math::number('+0')->isNegative()); + $this->assertFalse(Math::number('0.000000000')->isNegative()); + $this->assertFalse(Math::number('1')->isNegative()); + $this->assertFalse(Math::number('+42')->isNegative()); + } + + public function test_is_even() + { + $this->assertTrue(Math::number('0')->isEven()); + $this->assertTrue(Math::number('2')->isEven()); + $this->assertTrue(Math::number('42')->isEven()); + $this->assertTrue(Math::number('-8')->isEven()); + $this->assertFalse(Math::number('1')->isEven()); + $this->assertFalse(Math::number('3')->isEven()); + $this->assertFalse(Math::number('-7')->isEven()); + // non-integers are neither even nor odd + $this->assertFalse(Math::number('42.5')->isEven()); + $this->assertFalse(Math::number('2.0001')->isEven()); + $this->assertFalse(Math::number('-3.14')->isEven()); + } + + public function test_is_odd() + { + $this->assertTrue(Math::number('1')->isOdd()); + $this->assertTrue(Math::number('3')->isOdd()); + $this->assertTrue(Math::number('-7')->isOdd()); + $this->assertFalse(Math::number('0')->isOdd()); + $this->assertFalse(Math::number('2')->isOdd()); + $this->assertFalse(Math::number('42')->isOdd()); + // non-integers are neither even nor odd + $this->assertFalse(Math::number('42.5')->isOdd()); + $this->assertFalse(Math::number('1.001')->isOdd()); + $this->assertFalse(Math::number('-3.14')->isOdd()); + } + + public function test_is_even_odd_after_operations() + { + // after operations, bcmath pads with zeros — should still work + $this->assertTrue(Math::number('1')->add('1')->isEven()); + $this->assertTrue(Math::number('2')->add('1')->isOdd()); + $this->assertFalse(Math::number('1')->div('3')->isEven()); + $this->assertFalse(Math::number('1')->div('3')->isOdd()); + } + + public function test_get_scale() + { + $this->assertSame(0, Math::number('42')->getScale()); + $this->assertSame(2, Math::number('42.99')->getScale()); + $this->assertSame(9, Math::number('1.123456789')->getScale()); + $this->assertSame(3, Math::number('-0.001')->getScale()); + // after operations, bcmath padding should be stripped + $this->assertSame(0, Math::number('1')->add('1')->getScale()); + $this->assertSame(0, Math::number('1.5')->add('0.5')->getScale()); + $this->assertSame(1, Math::number('1.5')->add('0.3')->getScale()); + } + + public function test_get_integral_part() + { + $this->assertSame('42', Math::number('42')->getIntegralPart()); + $this->assertSame('42', Math::number('42.99')->getIntegralPart()); + $this->assertSame('0', Math::number('-0.001')->getIntegralPart()); + $this->assertSame('0', Math::number('0')->getIntegralPart()); + $this->assertSame('0', Math::number('-0')->getIntegralPart()); + // after operations + $this->assertSame('2', Math::number('1')->add('1')->getIntegralPart()); + } + + public function test_get_fractional_part() + { + $this->assertSame('', Math::number('42')->getFractionalPart()); + $this->assertSame('99', Math::number('42.99')->getFractionalPart()); + $this->assertSame('001', Math::number('-0.001')->getFractionalPart()); + $this->assertSame('123456789', Math::number('1.123456789')->getFractionalPart()); + // after operations, padding should be stripped + $this->assertSame('', Math::number('1')->add('1')->getFractionalPart()); + } + + public function test_negate() + { + $this->assertSame('-42', (string) Math::number('42')->negate()); + $this->assertSame('42', (string) Math::number('-42')->negate()); + $this->assertSame('0', (string) Math::number('0')->negate()); + $this->assertSame('0', (string) Math::number('-0')->negate()); + $this->assertSame('0', (string) Math::number('+0')->negate()); + $this->assertSame('0', (string) Math::number('0.000000000')->negate()); + $this->assertSame('-0.5', (string) Math::number('0.5')->negate()); + $this->assertSame('0.5', (string) Math::number('-0.5')->negate()); + // beyond precision — should still negate + $this->assertSame('-0.0000000001', (string) Math::number('0.0000000001')->negate()); + } + + public function test_negate_is_immutable() + { + $a = Math::number('42'); + $b = $a->negate(); + + $this->assertSame('42', (string) $a); + $this->assertSame('-42', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_clamp() + { + $this->assertSame('5', (string) Math::number('5')->clamp('0', '10')); + $this->assertSame('0', (string) Math::number('-5')->clamp('0', '10')); + $this->assertSame('10', (string) Math::number('15')->clamp('0', '10')); + $this->assertSame('0', (string) Math::number('0')->clamp('0', '10')); + $this->assertSame('10', (string) Math::number('10')->clamp('0', '10')); + $this->assertSame('-5', (string) Math::number('-5')->clamp('-10', '-1')); + } + + public function test_clamp_is_immutable() + { + $a = Math::number('15'); + $b = $a->clamp('0', '10'); + + $this->assertSame('15', (string) $a); + $this->assertSame('10', (string) $b); + $this->assertNotSame($a, $b); + } + + public function test_quotient_and_remainder() + { + [$q, $r] = Math::number('17')->quotientAndRemainder('5'); + $this->assertSame('3', (string) $q); + $this->assertSame('2', (string) $r); + + [$q, $r] = Math::number('100')->quotientAndRemainder('10'); + $this->assertSame('10', (string) $q); + $this->assertSame('0', (string) $r); + + [$q, $r] = Math::number('-17')->quotientAndRemainder('5'); + $this->assertSame('-3', (string) $q); + $this->assertSame('-2', (string) $r); + } + + public function test_quotient_and_remainder_is_immutable() + { + $a = Math::number('17'); + [$q, $r] = $a->quotientAndRemainder('5'); + + $this->assertSame('17', (string) $a); + $this->assertNotSame($a, $q); + $this->assertNotSame($a, $r); + } } From 2cfe09f32181c85ebb343f6693ea0c54057de322 Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 8 Feb 2026 12:01:10 +0100 Subject: [PATCH 6/8] fix ci --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85957b6..ad86dc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,8 @@ jobs: - name: Remove composer.lock run: rm -f composer.lock - - name: Remove Pint - run: composer remove "laravel/pint" --dev --no-update + - name: Remove Qa / bench libs + run: composer remove "laravel/pint" "brick/math" --dev --no-update - name: Install Orchestra ${{ matrix.orchestra-versions }} run: composer require "orchestra/testbench:^${{ matrix.orchestra-versions }}" --dev --no-update From 99f10fec991c4efdc53156f555d0a492964cf19a Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 8 Feb 2026 14:06:30 +0100 Subject: [PATCH 7/8] MathCast --- CHANGELOG.md | 3 ++ README.md | 70 ++++++++++++++++++++++++++++++++- src/Laravel/MathCast.php | 20 +++++++--- src/Laravel/MathMutableCast.php | 33 ++++++++++++++++ src/Math.php | 35 ++++++++++++----- tests/Laravel/MathCastTest.php | 55 ++++++++++++++++++++++++-- tests/MathTest.php | 46 ++++++++++++++++++++++ 7 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 src/Laravel/MathMutableCast.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f5861..824fa2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `MathMutableCast` — Laravel Eloquent cast that returns `MathMutable` instances. Use `MathMutableCast::class` (with optional `:nullable`) instead of `MathCast::class` for mutable cast attributes. Separate cast classes enable proper static type resolution. **Upgrading from v2:** since `Math` is now immutable, existing Laravel models that rely on in-place mutation of cast attributes should switch from `MathCast::class` to `MathMutableCast::class` to restore the previous behavior. - `MathMutable` — mutable variant of `Math` where operations modify the instance in place. Extends `Math` and is accepted anywhere `Math` is type-hinted. - `negate()` — flip sign, zero stays zero - `clamp($min, $max)` — clip value between bounds @@ -34,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- `toBase()` / `fromBase()` now preserve the sign of negative numbers instead of silently stripping it — `Math::number('-42')->toBase(16)` returns `'-2a'` and `Math::fromBase('-2a', 16)` returns `'-42'` +- `fromBase()` now normalizes input case for bases <= 36 — `Math::fromBase('FF', 16)` works consistently with and without GMP - `format()` now works correctly with immutable default (captures `abs()` return value) - `bcDec2Base()` properly initializes result as `'0'` diff --git a/README.md b/README.md index 1f2b7e2..a1c094f 100644 --- a/README.md +++ b/README.md @@ -159,12 +159,16 @@ Uses GMP when available for faster conversions: // From base X to base 10 Math::fromBase('LZ', 62); // '1337' Math::fromBase('101010', 2); // '42' -Math::fromBase('FF', 16); // '255' +Math::fromBase('ff', 16); // '255' (case-insensitive for bases <= 36) // From base 10 to base X Math::number('1337')->toBase(62); // 'LZ' Math::number('42')->toBase(2); // '101010' -Math::number('255')->toBase(16); // 'FF' +Math::number('255')->toBase(16); // 'ff' + +// Negative numbers preserve their sign +Math::number('-42')->toBase(16); // '-2a' +Math::fromBase('-LZ', 62); // '-1337' ``` ### Formatting @@ -241,6 +245,51 @@ $order->discount = null; // OK (nullable) $order->total = null; // Throws NotNullableException ``` +### Mutable Cast + +Use `MathMutableCast` to get `MathMutable` instances instead of immutable `Math`: + +```php +use fab2s\Math\Laravel\MathCast; +use fab2s\Math\Laravel\MathMutableCast; + +class Order extends Model +{ + protected $casts = [ + 'total' => MathMutableCast::class, + 'discount' => MathMutableCast::class . ':nullable', + 'tax' => MathCast::class, // immutable (default) + ]; +} + +$order = new Order; +$order->total = '99.99'; +$order->total->add('10'); // modifies in place +``` + +Using separate cast classes enables proper static type resolution — Larastan/PHPStan will resolve `MathCast` properties to `Math` and `MathMutableCast` properties to `MathMutable`. + +### Upgrading from v2 + +In v2, `Math` was mutable, so `MathCast` attributes behaved as mutable values. In v3, `Math` is immutable by default — existing code that mutates cast attributes in place will silently lose changes: + +```php +// v2: works — Math was mutable +// v3: $order->total is unchanged — Math is now immutable +$order->total->add('10'); +``` + +To restore the previous behavior, switch to `MathMutableCast`: + +```php +use fab2s\Math\Laravel\MathMutableCast; + +protected $casts = [ + 'total' => MathMutableCast::class, + 'discount' => MathMutableCast::class . ':nullable', +]; +``` + ## API Reference ### Factory Methods @@ -387,6 +436,23 @@ composer bench-md -- --group=integer # Filter by group Contributions are welcome. Please open issues and submit pull requests. +```shell +# fix code style +composer fix + +# run tests +composer test + +# run tests with coverage +composer cov + +# static analysis (src, level 9) +composer stan + +# static analysis (tests, level 5) +composer stan-tests +``` + ## License Math is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/src/Laravel/MathCast.php b/src/Laravel/MathCast.php index 86a0234..c99b6a7 100644 --- a/src/Laravel/MathCast.php +++ b/src/Laravel/MathCast.php @@ -16,11 +16,18 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; -/** @implements CastsAttributes */ +/** + * @template TMath of Math + * + * @implements CastsAttributes + */ class MathCast implements CastsAttributes { protected bool $isNullable = false; + /** @var class-string */ + protected string $mathFqn = Math::class; + public function __construct(string ...$options) { $this->isNullable = in_array('nullable', $options); @@ -32,26 +39,29 @@ public function __construct(string ...$options) * @param Model $model * @param array $attributes * + * @return TMath|null + * * @throws NotNullableException */ public function get($model, string $key, mixed $value, array $attributes): ?Math { /** @var string|int|float|null $value */ - return Math::isNumber($value) ? Math::number((string) $value) : $this->handleNullable($model, $key); + return $this->mathFqn::isNumber($value) ? $this->mathFqn::number((string) $value) : $this->handleNullable($model, $key); // @phpstan-ignore return.type } /** * Prepare the given value for storage. * - * @param Model $model - * @param array $attributes + * @param Model $model + * @param TMath|string|int|float|null $value + * @param array $attributes * * @throws NotNullableException */ public function set($model, string $key, mixed $value, array $attributes): ?string { /** @var string|int|float|null $value */ - return Math::isNumber($value) ? (string) Math::number((string) $value) : $this->handleNullable($model, $key); + return $this->mathFqn::isNumber($value) ? (string) $this->mathFqn::number((string) $value) : $this->handleNullable($model, $key); } /** diff --git a/src/Laravel/MathMutableCast.php b/src/Laravel/MathMutableCast.php new file mode 100644 index 0000000..af38b93 --- /dev/null +++ b/src/Laravel/MathMutableCast.php @@ -0,0 +1,33 @@ + */ +class MathMutableCast extends MathCast +{ + /** @var class-string */ + protected string $mathFqn = MathMutable::class; + + /** + * @param array $attributes + * + * @throws NotNullableException + */ + public function get($model, string $key, mixed $value, array $attributes): ?MathMutable + { + /** @var MathMutable|null */ + return parent::get($model, $key, $value, $attributes); + } +} diff --git a/src/Math.php b/src/Math.php index dbb3f89..56f3a5d 100644 --- a/src/Math.php +++ b/src/Math.php @@ -52,23 +52,33 @@ public static function make(string|int|float|Math $number): static */ public static function fromBase(string $number, int $base): static { - // only positive - $number = trim($number, ' -'); + $number = trim($number); + $sign = ''; + if (isset($number[0]) && $number[0] === '-') { + $sign = '-'; + $number = substr($number, 1); + } + if ($number === '' || str_contains($number, '.')) { throw new InvalidArgumentException('Argument number is not an integer'); } $baseChar = static::getBaseChar($base); + + // normalize case for bases <= 36 where case is not significant + if ($base <= 36) { + $number = strtolower($number); + } // By now we know we have a correct base and number if (trim($number, $baseChar[0]) === '') { return new static('0'); } if (static::$gmpSupport) { - return new static(static::baseConvert($number, $base, 10)); + return new static($sign . static::baseConvert($number, $base, 10)); } - return new static(static::bcDec2Base($number, $base, $baseChar)); + return new static($sign . static::bcDec2Base($number, $base, $baseChar)); } public function gte(string|int|float|Math $number): bool @@ -107,11 +117,18 @@ public function toBase(string|int $base): string static::validateBase($base = (int) static::validatePositiveInteger($base)); - // do not mutate, only support positive integers + // do not mutate + $normalized = (string) $this; + $sign = ''; + if (isset($normalized[0]) && $normalized[0] === '-') { + $sign = '-'; + $normalized = substr($normalized, 1); + } + /** @var numeric-string $number */ - $number = ltrim((string) $this, '-'); + $number = $normalized; if (static::$gmpSupport) { - return static::baseConvert($number, 10, $base); + return $sign . static::baseConvert($number, 10, $base); } $result = ''; @@ -123,9 +140,9 @@ public function toBase(string|int $base): string $result = $baseChar[$rem] . $result; } - $result = $result ? $result : $baseChar[0]; + $result = $result ?: $baseChar[0]; - return (string) $result; + return $sign . $result; } public function format(string|int $decimals = 0, string $decPoint = '.', string $thousandsSep = ' '): string diff --git a/tests/Laravel/MathCastTest.php b/tests/Laravel/MathCastTest.php index 307d73f..2e9848c 100644 --- a/tests/Laravel/MathCastTest.php +++ b/tests/Laravel/MathCastTest.php @@ -11,7 +11,9 @@ use fab2s\Math\Laravel\Exception\NotNullableException; use fab2s\Math\Laravel\MathCast; +use fab2s\Math\Laravel\MathMutableCast; use fab2s\Math\Math; +use fab2s\Math\MathMutable; use fab2s\Math\Tests\Laravel\Artifacts\CastModel; use Orchestra\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -33,12 +35,15 @@ public function test_math_cast_get( Math|string|int|float|null $value, Math|string|null $expected, array $options = [], + string $castClass = MathCast::class, ): void { - $cast = new MathCast(...$options); + $cast = new $castClass(...$options); switch (true) { case is_object($expected): - $this->assertTrue($expected->eq($cast->get(new CastModel, 'key', $value, []))); + $result = $cast->get(new CastModel, 'key', $value, []); + $this->assertTrue($expected->eq($result)); + $this->assertSame(get_class($expected), get_class($result)); break; case is_string($expected): $this->expectException(NotNullableException::class); @@ -58,8 +63,9 @@ public function test_math_cast_set( Math|string|int|float|null $value, Math|string|null $expected, array $options = [], + string $castClass = MathCast::class, ): void { - $cast = new MathCast(...$options); + $cast = new $castClass(...$options); switch (true) { case is_object($expected): @@ -111,6 +117,49 @@ public static function castProvider(): array 'expected' => Math::number(42), 'options' => ['nullable'], ], + // MathMutableCast cases + [ + 'value' => null, + 'expected' => null, + 'options' => ['nullable'], + 'castClass' => MathMutableCast::class, + ], + [ + 'value' => MathMutable::number(42.42), + 'expected' => MathMutable::number(42.42), + 'options' => ['nullable'], + 'castClass' => MathMutableCast::class, + ], + [ + 'value' => MathMutable::number(42.42), + 'expected' => MathMutable::number(42.42), + 'options' => [], + 'castClass' => MathMutableCast::class, + ], + [ + 'value' => null, + 'expected' => NotNullableException::class, + 'options' => [], + 'castClass' => MathMutableCast::class, + ], + [ + 'value' => '42.4200000', + 'expected' => MathMutable::number(42.42), + 'options' => ['nullable'], + 'castClass' => MathMutableCast::class, + ], + [ + 'value' => 42.42, + 'expected' => MathMutable::number(42.42), + 'options' => ['nullable'], + 'castClass' => MathMutableCast::class, + ], + [ + 'value' => 42, + 'expected' => MathMutable::number(42), + 'options' => ['nullable'], + 'castClass' => MathMutableCast::class, + ], ]; } } diff --git a/tests/MathTest.php b/tests/MathTest.php index ea73b96..8199537 100644 --- a/tests/MathTest.php +++ b/tests/MathTest.php @@ -1073,6 +1073,31 @@ public static function baseConvertData(): array 'number' => '9856565', 'base' => 61, ], + // negative base conversion + [ + 'number' => '-42', + 'base' => 16, + ], + [ + 'number' => '-42', + 'base' => 2, + ], + [ + 'number' => '-1337', + 'base' => 62, + ], + [ + 'number' => '-255', + 'base' => 36, + ], + [ + 'number' => '-255173029255255255', + 'base' => 16, + ], + [ + 'number' => '-25517993029255255255', + 'base' => 37, + ], ]; } @@ -1113,6 +1138,27 @@ public function test_base_convert(string|int|Math $number, int $base) Math::gmpSupport(null); } + public function test_from_base_case_insensitive() + { + // bases <= 36: case should not matter + $this->assertSame('255', (string) Math::fromBase('FF', 16)); + $this->assertSame('255', (string) Math::fromBase('ff', 16)); + $this->assertSame('1337', (string) Math::fromBase('115', 36)); + $this->assertSame('-255', (string) Math::fromBase('-FF', 16)); + $this->assertSame('-255', (string) Math::fromBase('-ff', 16)); + + if (! Math::gmpSupport()) { + return; + } + + // verify bcmath path produces the same results + Math::gmpSupport(true); + $this->assertSame('255', (string) Math::fromBase('FF', 16)); + $this->assertSame('255', (string) Math::fromBase('ff', 16)); + $this->assertSame('-255', (string) Math::fromBase('-FF', 16)); + Math::gmpSupport(null); + } + #[DataProvider('modData')] public function test_mod_gmp_toggle(string|int|Math $number, string $mod, string $expected) { From e123c2ed16223fe40bcfa2f154c80f34a32d4f2f Mon Sep 17 00:00:00 2001 From: fab2s Date: Sun, 8 Feb 2026 23:47:03 +0100 Subject: [PATCH 8/8] polish --- tests/MathMutableTest.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/MathMutableTest.php b/tests/MathMutableTest.php index e5f75cb..b5b1548 100644 --- a/tests/MathMutableTest.php +++ b/tests/MathMutableTest.php @@ -204,8 +204,6 @@ public function test_operations_return_math() $this->assertInstanceOf(Math::class, $a->min('5')); } - // ─── MathMutable is mutable ─────────────────────────────────── - public function test_mutable_instanceof_math() { $this->assertInstanceOf(Math::class, MathMutable::number('42')); @@ -274,8 +272,6 @@ public function test_mutable_factory_methods() public function test_mutable_operations_return_mutable() { - $a = MathMutable::number('10'); - $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->add('1')); $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->sub('1')); $this->assertInstanceOf(MathMutable::class, MathMutable::number('10')->mul('2')); @@ -309,8 +305,6 @@ public function test_cross_type_operations() $this->assertInstanceOf(MathMutable::class, $result2); } - // ─── MathMutable edge cases for new operations ─────────────── - public function test_mutable_negate_is_mutable() { $a = MathMutable::number('42');