diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2764120..ad86dc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,15 +6,17 @@ 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 - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -24,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') }} @@ -36,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 @@ -46,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..824fa2e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# 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] + +## [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 +- PHPStan level 9 compliance for `src/` +- GitHub Actions workflows updated to latest action versions + +### 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 +- `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 + +- `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'` + +### Removed + +- `MathImmutable` — no longer needed, `Math` itself is now immutable + +## [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/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 65a942a..a1c094f 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,458 @@ # 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) +[![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) -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 + +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 +``` + +> `bcmath` supports numbers of any size and precision up to 2,147,483,647 decimals, represented as strings. ## Installation -Math can be installed using composer : +```bash +composer require fab2s/math +``` + +### Requirements + +- PHP 8.1+ +- ext-bcmath (required) +- ext-gmp (optional, faster base conversions, mod, pow and powMod) + +## Features +### Fluent API + +Chain operations naturally with variadic argument support: + +```php +use fab2s\Math\Math; + +$result = Math::number('100') + ->add('10', '20', '30') // 160 + ->mul('2') // 320 + ->div('4') // 80 + ->sub('38'); // 42 + +echo $result; // '42' ``` -composer require "fab2s/math" + +`Math` is **immutable** — every operation returns a new instance, leaving the original unchanged: + +```php +$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' ``` -`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 +The overhead is a single `clone` per operation (two properties: a string and an int). -## Prerequisites +### Mutable Variant -`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). +For performance-sensitive hot loops, `MathMutable` modifies the instance in place: -## In practice +```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 +} +``` -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()`. +`MathMutable` extends `Math`, so it is accepted anywhere `Math` is type-hinted. -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. +### Strict Validation -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. +Math rejects ambiguous inputs that bcmath would silently convert to `0`: ```php -// instance way -$number = new Math('42'); +// 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 +``` + +### Full Arithmetic Operations -// fluent grammar -$result = (string) $number->add('1')->sub(2)->div(1)->add(1)->mul(-1); // '-42' +```php +$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 +$n->negate(); // Flip sign + +// Division +$n->quotientAndRemainder('7'); // [$quotient, $remainder] + +// 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 +$n->clamp('10', '90'); // Clip between bounds +``` -// factory way: number -$result = (string) Math::number('42')->add('1')->sub(2)->div('1')->add(1)->mul(-1); // '-42' +### Comparisons & Inspection -// factory way: fromBase -$result = (string) Math::fromBase('LZ', 62); // '1337' -$result = (string) Math::fromBase('LZ', 62)->sub(1295); // '42' +```php +$n = Math::number('42'); + +$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 + +$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' +``` -// 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'); +### Base Conversion (2-62) -// 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' +Uses GMP when available for faster conversions: -// toBase does not mutate base 10 internal representation -$result = (string) $number; // '1337'; +```php +// From base X to base 10 +Math::fromBase('LZ', 62); // '1337' +Math::fromBase('101010', 2); // '42' +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' + +// Negative numbers preserve their sign +Math::number('-42')->toBase(16); // '-2a' +Math::fromBase('-LZ', 62); // '-1337' ``` -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: +### Formatting + +Formatting does not mutate the internal number: ```php -$result = (string) Math::number('0000042.000000'); // '42' +$n = Math::number('1234567.891'); -// raw form -$result = Math::number('0000042.000000')->getNumber(); // '0000042.000000' +echo $n->format(2); // '1234567.89' +echo $n->format(2, ',', ' '); // '1 234 567,89' +echo $n; // '1234567.891' (unchanged) +``` -// with some tolerance -$result = Math::number(' 42.0000 ')->getNumber(); // '42.0000' +### Precision Control -// at all time -if ((string) $number1 === (string) $number2) { - // both instance numbers are equals -} +Default precision is 9 decimal places. Control it globally or per-instance: -// same as (internally using bccomp) -if ($number1->eq($number2)) { - // both instance numbers are equals -} +```php +// Global (affects new instances) +Math::setGlobalPrecision(18); + +// Per-instance +$n = Math::number('100')->setPrecision(4); +echo $n->div('3'); // '33.3333' ``` -You can transparently re-use partial $calculus directly as instance when calculating: +> Precision is not handled via `bcscale()` to avoid global state issues in long-running processes. -```php -$number = new Math('42'); -// same as -$number = Math::number('42'); +### Normalized Output -// in constructor -$result = (string) (new Math($number))->div('2'); // '21' -// same as -$result = (string) Math::number($number)->div('2'); // '21' +Results are automatically normalized for accurate comparisons: + +```php +echo Math::number('0000042.000'); // '42' +echo Math::number('-0'); // '0' +echo Math::number('+.500'); // '0.5' -// in calc method -$result = (string) Math::number('42')->add($number)->sub('42')->div('2'); // '21' +// Raw access when needed +Math::number('0042.00')->getNumber(); // '0042.00' ``` -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. +### Instance Reuse -Arguments should be string or `Math`, but it is _ok_ to use integers up to `INT_(32|64)`. +Pass Math instances directly to avoid re-validation: -**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. +```php +$tax = Math::number('0.20'); +$price = Math::number('99.99'); -## Internal precision +$total = $price->add($price->mul($tax)); +``` -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). +## Laravel Integration -`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) +Cast Eloquent model attributes to Math instances: ```php -// set global precision -Math::setGlobalPrecision(18); +use fab2s\Math\Laravel\MathCast; -$number = (new Math('100'))->div('3'); // uses precision 18 -$number->setPrecision(14); // will use precision 14 for any further calculations +class Order extends Model +{ + protected $casts = [ + 'total' => MathCast::class, + 'discount' => MathCast::class . ':nullable', + ]; +} + +$order = new Order; +$order->total = '99.99'; +$order->total->mul('1.2')->format(2); // '119.99' + +$order->discount = null; // OK (nullable) +$order->total = null; // Throws NotNullableException ``` -## Laravel +### Mutable Cast -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. +Use `MathMutableCast` to get `MathMutable` instances instead of immutable `Math`: -````php +```php use fab2s\Math\Laravel\MathCast; +use fab2s\Math\Laravel\MathMutableCast; -class MyModel extends Model +class Order extends Model { - protected $casts = [ - 'not_nullable' => MathCast::class, - 'nullable' => MathCast::class . ':nullable', + protected $casts = [ + 'total' => MathMutableCast::class, + 'discount' => MathMutableCast::class . ':nullable', + 'tax' => MathCast::class, // immutable (default) ]; } -$model = new MyModel; +$order = new Order; +$order->total = '99.99'; +$order->total->add('10'); // modifies in place +``` -$model->not_nullable = 41; -$model->not_nullable->add(1)->eq(42); // true +Using separate cast classes enables proper static type resolution — Larastan/PHPStan will resolve `MathCast` properties to `Math` and `MathMutableCast` properties to `MathMutable`. -$model->not_nullable = null; // throw a NotNullableException +### Upgrading from v2 -$model->nullabe = null; // is ok +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 + +| Method | Description | +|--------|-------------| +| `Math::number($n)` | Create immutable instance | +| `Math::make($n)` | Alias for `number()` | +| `Math::fromBase($n, $base)` | Create 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 + +| Method | Description | +|--------|-------------| +| `add(...$n)` | Addition | +| `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 + +| Method | Description | +|--------|-------------| +| `round($precision)` | Round to precision | +| `floor()` | Round down | +| `ceil()` | Round up | + +### Comparison & Inspection + +| 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 | +| `isZero()` | Check if zero | +| `isPositive()` | Check if positive | +| `isNegative()` | Check if negative | +| `isEven()` | Check if even integer | +| `isOdd()` | Check if odd integer | + +### Conversion & Output + +| Method | Description | +|--------|-------------| +| `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 + +| Method | Description | +|--------|-------------| +| `setPrecision($p)` | Set instance precision | +| `getPrecision()` | Get instance precision | +| `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.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: + +```bash +composer bench # ASCII table +composer bench-md # Markdown table +composer bench-md -- --group=integer # Filter by group +``` -## Requirements +## Compatibility -`Math` is tested against php 8.1 and 8.2. Additionally, MathCast is tested against Laravel 10 and 11. +| 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. + +```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-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/benchmarks/MathBench.php b/benchmarks/MathBench.php new file mode 100644 index 0000000..866dfba --- /dev/null +++ b/benchmarks/MathBench.php @@ -0,0 +1,601 @@ +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() + ; + } + + // ─── 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] + #[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 = $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/MutableBench.php b/benchmarks/MutableBench.php new file mode 100644 index 0000000..9765189 --- /dev/null +++ b/benchmarks/MutableBench.php @@ -0,0 +1,138 @@ +mul('1.21') + ->add('50.00') + ->sub('100.00') + ->div('3') + ->round(2) + ; + } + + #[Bench\Subject] + #[Bench\Groups(['mutability'])] + public function fab2s_immutable_chain(): void + { + Math::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 ─────────────────────────────────────────── + + #[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(['accumulation'])] + #[Bench\Revs(100)] + public function fab2s_immutable_accumulate_100(): void + { + $sum = Math::number('0'); + for ($i = 0; $i < 100; $i++) { + $sum = $sum->add($i . '.99'); + } + } + + #[Bench\Subject] + #[Bench\Groups(['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_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 = Math::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/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 dbc5cb2..40d6fc5 100644 --- a/composer.json +++ b/composer.json @@ -24,9 +24,12 @@ "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", + "phpstan/phpstan": "^2.1", + "brick/math": "^0.14.7", + "phpbench/phpbench": "^1.4" }, "autoload": { "psr-4": { @@ -35,7 +38,8 @@ }, "autoload-dev": { "psr-4": { - "fab2s\\Math\\Tests\\": "tests" + "fab2s\\Math\\Tests\\": "tests", + "fab2s\\Math\\Benchmarks\\": "benchmarks" } }, "scripts": { @@ -45,9 +49,36 @@ "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 --" + ], + "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/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) + /** @var class-string */ + protected string $mathFqn = Math::class; + + public function __construct(string ...$options) { $this->isNullable = in_array('nullable', $options); } @@ -26,31 +36,40 @@ public function __construct(...$options) /** * Cast the given value. * - * @param Model $model + * @param Model $model + * @param array $attributes + * + * @return TMath|null * * @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 $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 Model $model + * @param TMath|string|int|float|null $value + * @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 $this->mathFqn::isNumber($value) ? (string) $this->mathFqn::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/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 9c9696c..56f3a5d 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 @@ -48,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 @@ -103,23 +117,32 @@ public function toBase(string|int $base): string static::validateBase($base = (int) static::validatePositiveInteger($base)); - // do not mutate, only support positive integers - $number = ltrim((string) $this, '-'); + // do not mutate + $normalized = (string) $this; + $sign = ''; + if (isset($normalized[0]) && $normalized[0] === '-') { + $sign = '-'; + $normalized = substr($normalized, 1); + } + + /** @var numeric-string $number */ + $number = $normalized; if (static::$gmpSupport) { - return static::baseConvert($number, 10, $base); + return $sign . 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 ($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; } - $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 @@ -127,10 +150,13 @@ 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 = $number->abs(); + if ($number->hasDecimals()) { [$number, $dec] = explode('.', (string) $number); + } else { + $number = (string) $number; } if ($decimals) { diff --git a/src/MathBaseAbstract.php b/src/MathBaseAbstract.php index 44dac53..c25d021 100644 --- a/src/MathBaseAbstract.php +++ b/src/MathBaseAbstract.php @@ -1,5 +1,7 @@ number; @@ -73,24 +78,80 @@ 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 { - $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; + } - return $this; + protected function mutate(): static + { + return clone $this; } public static function setGlobalPrecision(string|int $precision): void @@ -127,7 +188,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 +218,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 +240,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 +256,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 +268,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/MathMutable.php b/src/MathMutable.php new file mode 100644 index 0000000..c32e774 --- /dev/null +++ b/src/MathMutable.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(); + $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 $this; + return $result; } public function mod(string|int $modulus): static { - $this->number = bcmod($this->number, static::validatePositiveInteger($modulus)); + $result = $this->mutate(); + $modulus = static::validatePositiveInteger($modulus); - return $this; + 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 { - $this->number = bcpowmod($this->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 $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 $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 $this; + return [$quotient, $remainder]; } /** @@ -136,13 +204,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 +219,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 05b9b2c..2e9848c 100644 --- a/tests/Laravel/MathCastTest.php +++ b/tests/Laravel/MathCastTest.php @@ -11,14 +11,16 @@ 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; class MathCastTest extends TestCase { - public function setUp(): void + protected function setUp(): void { // Turn on error reporting error_reporting(E_ALL); @@ -33,18 +35,21 @@ 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); $cast->get(new CastModel, 'key', $value, []); break; - case $expected === null: + default: $this->assertNull($cast->get(new CastModel, 'key', $value, [])); break; } @@ -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): @@ -69,7 +75,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; } @@ -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/MathMutableTest.php b/tests/MathMutableTest.php new file mode 100644 index 0000000..b5b1548 --- /dev/null +++ b/tests/MathMutableTest.php @@ -0,0 +1,349 @@ +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')); + } + + 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() + { + $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); + } + + 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 c5d1635..8199537 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', + ], ]; } @@ -998,69 +1015,94 @@ 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, + ], + // 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, ], ]; } #[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), @@ -1096,9 +1138,285 @@ public function test_base_convert(string|int|Math $number, string $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) + { + 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')); $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); + } }