Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
```
Expand Down
11 changes: 7 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"autoload": {
"psr-4": {
"KhaledAlam\\Unit\\Doctrine\\": "src/Doctrine",
"KhaledAlam\\Unit\\Laravel\\": "src/Laravel",
"KhaledAlam\\Unit\\": "src/Unit"
},
Expand All @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions examples/scalar-math.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/**
* Scalar arithmetic, powers/roots, and dimension names.
*
* php examples/scalar-math.php
*/

require __DIR__ . '/../vendor/autoload.php';

use KhaledAlam\Unit\Quantity;

// Scale by plain numbers
echo Quantity::of(5, 'm')->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
63 changes: 63 additions & 0 deletions src/Doctrine/QuantityType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace KhaledAlam\Unit\Doctrine;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use KhaledAlam\Unit\Quantity;

/**
* Doctrine DBAL type that persists a {@see Quantity} as a human-readable string
* (e.g. "2.5 kg") and hydrates it back into a Quantity.
*
* Register it once (e.g. in a Symfony bundle boot or bootstrap):
*
* use Doctrine\DBAL\Types\Type;
* use KhaledAlam\Unit\Doctrine\QuantityType;
*
* Type::addType(QuantityType::NAME, QuantityType::class);
*
* Then map a column with `#[ORM\Column(type: 'quantity')]`.
*
* Requires doctrine/dbal ^4.
*/
final class QuantityType extends Type
{
public const NAME = 'quantity';

/**
* @param array<string, mixed> $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) . '.'
);
}
}
58 changes: 57 additions & 1 deletion src/Unit/Dimension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?: '{}';
}
}
79 changes: 79 additions & 0 deletions src/Unit/Quantity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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
{
Expand Down
Loading
Loading