From 0ac2321368f63dab97fe1d46286977e1d38759a9 Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Fri, 10 Jul 2026 21:43:43 +0400 Subject: [PATCH 1/3] feat: add scalar math (times, dividedBy, pow, sqrt, abs, negate) and named dimensions --- src/Unit/Dimension.php | 58 +++++++++++++++++++++- src/Unit/Quantity.php | 79 ++++++++++++++++++++++++++++++ tests/DimensionTest.php | 61 +++++++++++++++++++++++ tests/ScalarMathTest.php | 102 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 tests/ScalarMathTest.php diff --git a/src/Unit/Dimension.php b/src/Unit/Dimension.php index 2de11f9..43531bd 100644 --- a/src/Unit/Dimension.php +++ b/src/Unit/Dimension.php @@ -41,13 +41,69 @@ public function divide(Dimension $other): Dimension return new Dimension($result); } + public function power(int $exponent): Dimension + { + $result = []; + foreach ($this->exponents as $key => $val) { + $result[$key] = $val * $exponent; + } + return new Dimension($result); + } + + /** + * @throws \InvalidArgumentException when an exponent is not divisible by $degree. + */ + public function root(int $degree): Dimension + { + if ($degree < 1) { + throw new \InvalidArgumentException('Root degree must be >= 1.'); + } + + $result = []; + foreach ($this->exponents as $key => $val) { + if ($val % $degree !== 0) { + throw new \InvalidArgumentException( + "Cannot take root {$degree} of dimension: exponent for '{$key}' is not divisible." + ); + } + $result[$key] = intdiv($val, $degree); + } + return new Dimension($result); + } + public function isDimensionless(): bool { return $this->exponents === []; } + /** + * Human-readable name of a common physical dimension, or null if unrecognized. + */ + public function name(): ?string + { + return match ((string) $this) { + '[]' => 'dimensionless', + '{"L":1}' => 'length', + '{"M":1}' => 'mass', + '{"T":1}' => 'time', + '{"Θ":1}' => 'temperature', + '{"L":2}' => 'area', + '{"L":3}' => 'volume', + '{"L":1,"T":-1}' => 'velocity', + '{"L":1,"T":-2}' => 'acceleration', + '{"L":1,"M":1,"T":-2}' => 'force', + '{"L":2,"M":1,"T":-2}' => 'energy', + '{"L":2,"M":1,"T":-3}' => 'power', + '{"L":-1,"M":1,"T":-2}' => 'pressure', + '{"T":-1}' => 'frequency', + '{"B":1}' => 'data', + '{"A":1}' => 'angle', + default => null, + }; + } + public function __toString(): string { - return json_encode($this->exponents) ?: '{}'; + return json_encode($this->exponents, JSON_UNESCAPED_UNICODE) ?: '{}'; } } diff --git a/src/Unit/Quantity.php b/src/Unit/Quantity.php index 9df5a05..972dcc9 100644 --- a/src/Unit/Quantity.php +++ b/src/Unit/Quantity.php @@ -123,6 +123,65 @@ public function divide(Quantity $other): self return new self($value, $newUnit, $this->precision); } + /** Scale the value by a plain number, keeping the same unit. */ + public function times(float $factor): self + { + return new self($this->value * $factor, $this->unit, $this->precision); + } + + /** Divide the value by a plain number, keeping the same unit. */ + public function dividedBy(float $divisor): self + { + if ($divisor === 0.0) { + throw new \InvalidArgumentException('Cannot divide by zero.'); + } + + return new self($this->value / $divisor, $this->unit, $this->precision); + } + + public function abs(): self + { + return new self(abs($this->value), $this->unit, $this->precision); + } + + public function negate(): self + { + return new self(-$this->value, $this->unit, $this->precision); + } + + /** Raise to an integer power, scaling the dimension accordingly (e.g. (2 m)² = 4 m²). */ + public function pow(int $exponent): self + { + $value = $this->value ** $exponent; + $dimension = $this->unit->dimension->power($exponent); + $symbol = self::powerSymbol($this->unit->symbolString(), $exponent); + + return new self($value, new Unit('derived', $symbol, 1.0, $dimension), $this->precision); + } + + /** + * Square root, halving the dimension (e.g. sqrt(4 m²) = 2 m). + * + * @throws \InvalidArgumentException on a negative value or an odd dimension. + */ + public function sqrt(): self + { + if ($this->value < 0.0) { + throw new \InvalidArgumentException('Cannot take the square root of a negative quantity.'); + } + + $dimension = $this->unit->dimension->root(2); + $symbol = self::rootSymbol($this->unit->symbolString()); + + return new self(sqrt($this->value), new Unit('derived', $symbol, 1.0, $dimension), $this->precision); + } + + /** Name of this quantity's physical dimension (e.g. "velocity"), or null if unrecognized. */ + public function dimensionName(): ?string + { + return $this->unit->dimension->name(); + } + /** * Convert to the most readable unit in the same family (e.g. 1500 m -> 1.5 km). * @@ -245,6 +304,26 @@ private static function combineSymbols(string $a, string $b, string $op): string return $a === $b ? '' : $a . '/' . $b; } + private static function powerSymbol(string $symbol, int $exponent): string + { + return match ($exponent) { + 0 => '', + 1 => $symbol, + 2 => $symbol === '' ? '' : $symbol . '²', + 3 => $symbol === '' ? '' : $symbol . '³', + default => $symbol === '' ? '' : $symbol . '^' . $exponent, + }; + } + + private static function rootSymbol(string $symbol): string + { + if (str_ends_with($symbol, '²')) { + return substr($symbol, 0, -strlen('²')); + } + + return $symbol === '' ? '' : '√' . $symbol; + } + /** @return array{value: float, unit: string} */ public function jsonSerialize(): array { diff --git a/tests/DimensionTest.php b/tests/DimensionTest.php index 10f63a7..b989213 100644 --- a/tests/DimensionTest.php +++ b/tests/DimensionTest.php @@ -3,6 +3,7 @@ namespace KhaledAlam\Unit\Tests; use KhaledAlam\Unit\Dimension; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class DimensionTest extends TestCase @@ -45,4 +46,64 @@ public function test_equals_and_to_string(): void $this->assertSame('{"L":1,"T":-1}', (string) $a); $this->assertSame('[]', (string) new Dimension()); } + + public function test_power(): void + { + $this->assertSame(['L' => 2], (new Dimension(['L' => 1]))->power(2)->exponents); + $this->assertSame(['L' => 2, 'T' => -2], (new Dimension(['L' => 1, 'T' => -1]))->power(2)->exponents); + $this->assertTrue((new Dimension(['L' => 1]))->power(0)->isDimensionless()); + } + + public function test_root(): void + { + $this->assertSame(['L' => 1], (new Dimension(['L' => 2]))->root(2)->exponents); + } + + public function test_root_with_invalid_degree_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Root degree must be >= 1.'); + (new Dimension(['L' => 2]))->root(0); + } + + public function test_root_of_non_divisible_exponent_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + (new Dimension(['L' => 3]))->root(2); + } + + /** + * @return array, ?string}> + */ + public static function dimensionNames(): array + { + return [ + 'dimensionless' => [[], 'dimensionless'], + 'length' => [['L' => 1], 'length'], + 'mass' => [['M' => 1], 'mass'], + 'time' => [['T' => 1], 'time'], + 'temperature' => [['Θ' => 1], 'temperature'], + 'area' => [['L' => 2], 'area'], + 'volume' => [['L' => 3], 'volume'], + 'velocity' => [['L' => 1, 'T' => -1], 'velocity'], + 'acceleration' => [['L' => 1, 'T' => -2], 'acceleration'], + 'force' => [['M' => 1, 'L' => 1, 'T' => -2], 'force'], + 'energy' => [['M' => 1, 'L' => 2, 'T' => -2], 'energy'], + 'power' => [['M' => 1, 'L' => 2, 'T' => -3], 'power'], + 'pressure' => [['M' => 1, 'L' => -1, 'T' => -2], 'pressure'], + 'frequency' => [['T' => -1], 'frequency'], + 'data' => [['B' => 1], 'data'], + 'angle' => [['A' => 1], 'angle'], + 'unknown' => [['X' => 5], null], + ]; + } + + /** + * @param array $exponents + */ + #[DataProvider('dimensionNames')] + public function test_name(array $exponents, ?string $expected): void + { + $this->assertSame($expected, (new Dimension($exponents))->name()); + } } diff --git a/tests/ScalarMathTest.php b/tests/ScalarMathTest.php new file mode 100644 index 0000000..c5837ff --- /dev/null +++ b/tests/ScalarMathTest.php @@ -0,0 +1,102 @@ +assertSame('15 m', (string) Quantity::of(5, 'm')->times(3)); + $this->assertSame('2.5 m', (string) Quantity::of(10, 'm')->dividedBy(4)); + } + + public function test_divided_by_zero_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot divide by zero.'); + Quantity::of(10, 'm')->dividedBy(0); + } + + public function test_abs_and_negate(): void + { + $this->assertSame('3 m', (string) Quantity::of(-3, 'm')->abs()); + $this->assertSame('-3 m', (string) Quantity::of(3, 'm')->negate()); + } + + public function test_pow_scales_value_and_dimension(): void + { + $this->assertSame('m', (string) Quantity::of(2, 'm')->pow(1)->getUnit()); + $this->assertSame('4 m²', (string) Quantity::of(2, 'm')->pow(2)); + $this->assertSame('8 m³', (string) Quantity::of(2, 'm')->pow(3)); + $this->assertSame('16 m^4', (string) Quantity::of(2, 'm')->pow(4)); + $this->assertSame('0.5 s^-1', (string) Quantity::of(2, 's')->pow(-1)); + } + + public function test_pow_zero_is_dimensionless(): void + { + $result = Quantity::of(5, 'm')->pow(0); + $this->assertSame(1.0, $result->getValue()); + $this->assertSame('', $result->getUnit()->symbolString()); + $this->assertTrue($result->getUnit()->dimension->isDimensionless()); + } + + public function test_pow_on_dimensionless_keeps_empty_symbol(): void + { + $ratio = Quantity::of(4, 'm')->divide(Quantity::of(2, 'm')); // "" symbol + $this->assertSame('', $ratio->pow(2)->getUnit()->symbolString()); + $this->assertSame('', $ratio->pow(3)->getUnit()->symbolString()); + $this->assertSame('', $ratio->pow(5)->getUnit()->symbolString()); + } + + public function test_pow_result_is_convertible_for_base_units(): void + { + $this->assertSame('40000 cm²', (string) Quantity::of(2, 'm')->pow(2)->convertTo('cm²')); + } + + public function test_sqrt_halves_the_dimension(): void + { + $this->assertSame('2 m', (string) Quantity::of(4, 'm²')->sqrt()); + } + + public function test_sqrt_of_dimensionless(): void + { + $ratio = Quantity::of(4, 'm')->divide(Quantity::of(1, 'm')); // 4, symbol "" + $this->assertSame(2.0, $ratio->sqrt()->getValue()); + $this->assertSame('', $ratio->sqrt()->getUnit()->symbolString()); + } + + public function test_sqrt_falls_back_to_radical_symbol(): void + { + // (2 m)^4 has symbol "m^4"; its square root can't strip a trailing ². + $root = Quantity::of(2, 'm')->pow(4)->sqrt(); + $this->assertSame('√m^4', $root->getUnit()->symbolString()); + $this->assertSame(4.0, $root->getValue()); + } + + public function test_sqrt_of_negative_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + Quantity::of(-4, 'm²')->sqrt(); + } + + public function test_sqrt_of_odd_dimension_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('not divisible'); + Quantity::of(4, 'm')->sqrt(); + } + + public function test_dimension_name(): void + { + $this->assertSame('velocity', Quantity::of(1, 'm/s')->dimensionName()); + $this->assertSame('force', Quantity::of(1, 'N')->dimensionName()); + $this->assertSame('length', Quantity::of(1, 'm')->dimensionName()); + + // m·s is not a named dimension. + $unnamed = Quantity::of(1, 'm')->multiply(Quantity::of(1, 's')); + $this->assertNull($unnamed->dimensionName()); + } +} From 845d59672aab3badc6bc2002c52490ed9a310151 Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Fri, 10 Jul 2026 21:45:38 +0400 Subject: [PATCH 2/3] feat: add Doctrine DBAL QuantityType for Symfony/Doctrine persistence --- composer.json | 11 +++--- src/Doctrine/QuantityType.php | 63 ++++++++++++++++++++++++++++++++++ tests/DoctrineTypeTest.php | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/Doctrine/QuantityType.php create mode 100644 tests/DoctrineTypeTest.php diff --git a/composer.json b/composer.json index dfedf87..61468cb 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ }, "autoload": { "psr-4": { + "KhaledAlam\\Unit\\Doctrine\\": "src/Doctrine", "KhaledAlam\\Unit\\Laravel\\": "src/Laravel", "KhaledAlam\\Unit\\": "src/Unit" }, @@ -45,13 +46,15 @@ "php": "^8.2" }, "require-dev": { - "phpunit/phpunit": "^11.4", - "phpstan/phpstan": "^2.0", + "doctrine/dbal": "^4.0", "friendsofphp/php-cs-fixer": "^3.64", - "illuminate/database": "^11.0 || ^12.0" + "illuminate/database": "^11.0 || ^12.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.4" }, "suggest": { - "illuminate/database": "For the Laravel service provider and Eloquent 'AsQuantity' cast." + "illuminate/database": "For the Laravel service provider and Eloquent 'AsQuantity' cast.", + "doctrine/dbal": "For the Doctrine 'QuantityType' (usable in Symfony and any Doctrine project)." }, "scripts": { "test": "phpunit --configuration phpunit.xml.dist --testsuite=unit", diff --git a/src/Doctrine/QuantityType.php b/src/Doctrine/QuantityType.php new file mode 100644 index 0000000..9e082dc --- /dev/null +++ b/src/Doctrine/QuantityType.php @@ -0,0 +1,63 @@ + $column + */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return $platform->getStringTypeDeclarationSQL($column); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Quantity + { + if ($value instanceof Quantity) { + return $value; + } + + return is_string($value) && $value !== '' ? Quantity::parse($value) : null; + } + + public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof Quantity) { + return (string) $value; + } + + if (is_string($value)) { + return (string) Quantity::parse($value); + } + + throw new \InvalidArgumentException( + 'QuantityType expects a Quantity or string, got ' . get_debug_type($value) . '.' + ); + } +} diff --git a/tests/DoctrineTypeTest.php b/tests/DoctrineTypeTest.php new file mode 100644 index 0000000..a2e13cc --- /dev/null +++ b/tests/DoctrineTypeTest.php @@ -0,0 +1,64 @@ +markTestSkipped('doctrine/dbal is not installed.'); + } + + if (!Type::hasType(QuantityType::NAME)) { + Type::addType(QuantityType::NAME, QuantityType::class); + } + + $type = Type::getType(QuantityType::NAME); + $this->assertInstanceOf(QuantityType::class, $type); + $this->type = $type; + $this->platform = new SQLitePlatform(); + } + + public function test_to_database_value(): void + { + $this->assertSame('2.5 kg', $this->type->convertToDatabaseValue(Quantity::of(2.5, 'kg'), $this->platform)); + $this->assertSame('5.25 ft', $this->type->convertToDatabaseValue('5 ft 3 in', $this->platform)); + $this->assertNull($this->type->convertToDatabaseValue(null, $this->platform)); + } + + public function test_to_database_value_rejects_invalid_type(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->type->convertToDatabaseValue(123, $this->platform); + } + + public function test_to_php_value(): void + { + $quantity = $this->type->convertToPHPValue('2.5 kg', $this->platform); + $this->assertInstanceOf(Quantity::class, $quantity); + $this->assertSame('2500 g', (string) $quantity->to('g')); + + $this->assertNull($this->type->convertToPHPValue(null, $this->platform)); + $this->assertNull($this->type->convertToPHPValue('', $this->platform)); + + $existing = Quantity::of(1, 'm'); + $this->assertSame($existing, $this->type->convertToPHPValue($existing, $this->platform)); + } + + public function test_sql_declaration_is_a_string_column(): void + { + $sql = $this->type->getSQLDeclaration([], $this->platform); + $this->assertStringContainsStringIgnoringCase('CHAR', $sql); + } +} From 76c6d9830185a3422016f1c33794f3c6258420ea Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Fri, 10 Jul 2026 21:46:56 +0400 Subject: [PATCH 3/3] docs: document scalar math, dimension names, and Doctrine integration --- CHANGELOG.md | 14 ++++++++++++ README.md | 49 +++++++++++++++++++++++++++++++++++++++- examples/scalar-math.php | 23 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 examples/scalar-math.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa6266..e84022f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Scalar math** on `Quantity`: `times()`, `dividedBy()`, `pow()`, `sqrt()`, `abs()`, `negate()`. +- **Named dimensions** — `Quantity::dimensionName()` and `Dimension::name()` return + `"velocity"`, `"force"`, `"pressure"`, etc. (`null` when unrecognized). +- **`Dimension::power()` / `Dimension::root()`** helpers. +- **Doctrine integration** — `KhaledAlam\Unit\Doctrine\QuantityType` (DBAL ^4) for + persisting quantities in Symfony and any Doctrine project. + +### Changed +- `Dimension::__toString()` now emits unescaped Unicode, so temperature reads as + `{"Θ":1}` rather than the escaped `Θ` form. + ## [1.2.1] - 2026-07-10 ### Added diff --git a/README.md b/README.md index f87c09d..ee76b85 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ echo $sum->to('m'); // "3 m" - ✅ Automatic conversion between compatible units (e.g. cm → m) - ✅ **Correct** affine temperature conversion (°C ↔ °F ↔ K) - ✅ Derived/compound units rendered as `m/s`, `m²`, `m·s` +- ✅ Scalar math (`times`, `dividedBy`, `pow`, `sqrt`, `abs`, `negate`) +- ✅ Named dimensions (`dimensionName()` → `"velocity"`) - ✅ Comparisons (`equals`, `isGreaterThan`, `isLessThan`) -- ✅ `JsonSerializable` output +- ✅ `JsonSerializable` output, Laravel cast & Doctrine type - ✅ Enum-powered unit naming and a custom unit registry - ✅ Zero runtime dependencies @@ -101,6 +103,24 @@ $area = Quantity::of(2, 'm')->multiply(Quantity::of(3, 'm')); // "6 m²" All operations return **new** `Quantity` objects with the proper dimension and unit. +### Scalar math + +```php +echo Quantity::of(5, 'm')->times(3); // "15 m" +echo Quantity::of(10, 'm')->dividedBy(4); // "2.5 m" +echo Quantity::of(2, 'm')->pow(2); // "4 m²" +echo Quantity::of(4, 'm²')->sqrt(); // "2 m" +echo Quantity::of(-3, 'm')->abs(); // "3 m" +``` + +### Dimension names + +```php +Quantity::of(1, 'm/s')->dimensionName(); // "velocity" +Quantity::of(1, 'N')->dimensionName(); // "force" +Quantity::of(1, 'Pa')->dimensionName(); // "pressure" +``` + ### Temperature (affine scales) ```php @@ -223,6 +243,32 @@ return [ --- +## Symfony / Doctrine integration + +A Doctrine DBAL type persists a `Quantity` as a string column. Register it once, then +map any column with it: + +```php +use Doctrine\DBAL\Types\Type; +use KhaledAlam\Unit\Doctrine\QuantityType; + +Type::addType(QuantityType::NAME, QuantityType::class); // e.g. in a bundle boot / bootstrap +``` + +```php +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Column(type: 'quantity', nullable: true)] +public ?Quantity $weight = null; +``` + +Strings assigned to the property are parsed on write, and the column hydrates back into a +`Quantity` on read. + +> Requires `doctrine/dbal` ^4. + +--- + ## Examples Runnable scripts live in [`examples/`](examples): @@ -231,6 +277,7 @@ Runnable scripts live in [`examples/`](examples): php examples/basic.php php examples/temperature.php php examples/parse-and-humanize.php +php examples/scalar-math.php php examples/shipping.php php examples/physics.php ``` diff --git a/examples/scalar-math.php b/examples/scalar-math.php new file mode 100644 index 0000000..25465fe --- /dev/null +++ b/examples/scalar-math.php @@ -0,0 +1,23 @@ +times(3), "\n"; // 15 m +echo Quantity::of(10, 'kg')->dividedBy(4), "\n"; // 2.5 kg + +// Powers and roots track the dimension +echo Quantity::of(3, 'm')->pow(2), "\n"; // 9 m² +echo Quantity::of(9, 'm²')->sqrt(), "\n"; // 3 m + +// Ask a quantity what it measures +echo Quantity::of(1, 'm/s')->dimensionName(), "\n"; // velocity +echo Quantity::of(1, 'Pa')->dimensionName(), "\n"; // pressure