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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
- [Finding a timezone by identifier with UTC fallback](#finding-a-timezone-by-identifier-with-utc-fallback)
- [Checking if a timezone exists in the collection](#checking-if-a-timezone-exists-in-the-collection)
- [Getting all identifiers as strings](#getting-all-identifiers-as-strings)
+ [Value equality](#value-equality)
- [Checking equality](#checking-equality)
- [Checking equality across factories](#checking-equality-across-factories)
- [Checking equality of a nested value object](#checking-equality-of-a-nested-value-object)
* [License](#license)
* [Contributing](#contributing)

Expand Down Expand Up @@ -1242,6 +1246,69 @@ $timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London')
$timezones->toStrings(); # ["UTC", "America/Sao_Paulo", "Europe/London"]
```

### Value equality

Every type of this library that implements the value-object contract compares by value, never by instance. A value
object that carries another as a property inherits that, because the comparison walks the properties structurally.

#### Checking equality

Two instances of the same value are equal and share the same hash code, however each one was written.

```php
<?php

declare(strict_types=1);

use TinyBlocks\Time\Instant;

$morning = Instant::fromString(value: '2026-02-17T08:27:21+00:00');
$sameMoment = Instant::fromString(value: '2026-02-17T05:27:21-03:00');

$morning->equals(other: $sameMoment); # true, both normalize to the same UTC moment
$morning->hashCode() === $sameMoment->hashCode(); # true
```

#### Checking equality across factories

The factory that built the instance is not part of the value, so instances from different factories are equal.

```php
<?php

declare(strict_types=1);

use TinyBlocks\Time\LocalDate;

$parsed = LocalDate::fromString(value: '2026-02-17');
$composed = LocalDate::of(year: 2026, month: 2, day: 17);

$parsed->equals(other: $composed); # true
$parsed->hashCode() === $composed->hashCode(); # true
```

#### Checking equality of a nested value object

A value object holding another compares by value, because the comparison recurses into each property.

```php
<?php

declare(strict_types=1);

use TinyBlocks\Time\Instant;
use TinyBlocks\Time\Period;

$to = Instant::fromString(value: '2026-02-17T09:00:00+00:00');
$from = Instant::fromString(value: '2026-02-17T08:00:00+00:00');
$sameFrom = Instant::fromString(value: '2026-02-17T05:00:00-03:00');

$period = Period::from(from: $from, to: $to);
$samePeriod = Period::from(from: $sameFrom, to: $to);

$period->equals(other: $samePeriod); # true, the comparison walks into each Instant
```

## License

Time is licensed under [MIT](LICENSE).
Expand Down
3 changes: 0 additions & 3 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ parameters:
- tests
tmpDir: reports/phpstan
ignoreErrors:
# DateTimeImmutable::createFromFormat returns DateTimeImmutable|false; the UNIX format 'U' always succeeds for a valid integer, so the false branch is unreachable.
- identifier: method.nonObject
path: src/Instant.php
# DateTimeImmutable::createFromFormat returns DateTimeImmutable|false; the recomposed wall-clock string is always well-formed, so the false branch is unreachable.
- identifier: method.nonObject
path: src/Internal/ZonedShift.php
Expand Down
66 changes: 38 additions & 28 deletions src/Instant.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,33 @@
use TinyBlocks\Mapper\ScalarCodec;
use TinyBlocks\Time\Exceptions\InvalidInstant;
use TinyBlocks\Time\Exceptions\InvalidLocalDate;
use TinyBlocks\Time\Internal\IsoMoment;
use TinyBlocks\Time\Internal\TextDecoder;
use TinyBlocks\Time\Internal\ZonedShift;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;

/**
* Represents a single point on the timeline, always normalized to UTC with microsecond precision.
*
* <p>Two instances of this type for the same moment are always equal by value. The state is the
* canonical UTC text at microsecond precision rather than a date-time object, because structural
* equality compares a property that is not itself a value object by identity: an instance holding a
* date-time object would never equal another holding the same moment, and neither would any value
* object that wraps one.</p>
*/
#[ScalarCodec(decode: 'fromString', encode: 'toIso8601')]
final readonly class Instant implements ValueObject
{
use ValueObjectBehavior;

private const string UNIX_FORMAT = 'U';
private const string OFFSET_FORMAT = 'P';
private const string ISO8601_FORMAT = 'Y-m-d\TH:i:sP';
private const string ISO8601_MICRO_FORMAT = 'Y-m-d\TH:i:s.uP';
private const string ISO8601_DATETIME_FORMAT = 'Y-m-d\TH:i:s';
private const string FRACTIONAL_SECONDS_FORMAT = 'u';

private function __construct(private DateTimeImmutable $datetime)
private function __construct(private string $canonical)
{
}

Expand All @@ -40,8 +46,9 @@ private function __construct(private DateTimeImmutable $datetime)
public static function now(): Instant
{
$utc = Timezone::utc()->toDateTimeZone();
$datetime = new DateTimeImmutable(timezone: $utc);

return new Instant(datetime: new DateTimeImmutable(timezone: $utc));
return new Instant(canonical: IsoMoment::canonicalize(datetime: $datetime));
}

/**
Expand All @@ -56,7 +63,7 @@ public static function fromString(string $value): Instant
$decoder = TextDecoder::create();
$datetime = $decoder->decode(value: $value);

return new Instant(datetime: $datetime);
return new Instant(canonical: IsoMoment::canonicalize(datetime: $datetime));
}

/**
Expand All @@ -67,10 +74,10 @@ public static function fromString(string $value): Instant
*/
public static function fromUnixSeconds(int $seconds): Instant
{
$utc = Timezone::utc()->toDateTimeZone();
$datetime = DateTimeImmutable::createFromFormat(self::UNIX_FORMAT, (string)$seconds, $utc);
$template = '@%d';
$datetime = new DateTimeImmutable(datetime: sprintf($template, $seconds));

return new Instant(datetime: $datetime->setTimezone($utc));
return new Instant(canonical: IsoMoment::canonicalize(datetime: $datetime));
}

/**
Expand All @@ -82,9 +89,9 @@ public static function fromUnixSeconds(int $seconds): Instant
public function plus(Duration $duration): Instant
{
$template = '+%d seconds';
$modified = $this->datetime->modify(sprintf($template, $duration->toSeconds()));
$modified = $this->toDateTimeImmutable()->modify(sprintf($template, $duration->toSeconds()));

return new Instant(datetime: $modified);
return new Instant(canonical: IsoMoment::canonicalize(datetime: $modified));
}

/**
Expand All @@ -96,9 +103,9 @@ public function plus(Duration $duration): Instant
public function minus(Duration $duration): Instant
{
$template = '-%d seconds';
$modified = $this->datetime->modify(sprintf($template, $duration->toSeconds()));
$modified = $this->toDateTimeImmutable()->modify(sprintf($template, $duration->toSeconds()));

return new Instant(datetime: $modified);
return new Instant(canonical: IsoMoment::canonicalize(datetime: $modified));
}

/**
Expand All @@ -109,7 +116,7 @@ public function minus(Duration $duration): Instant
*/
public function isAfter(Instant $other): bool
{
return $this->datetime > $other->datetime;
return $this->toDateTimeImmutable() > $other->toDateTimeImmutable();
}

/**
Expand All @@ -120,7 +127,7 @@ public function isAfter(Instant $other): bool
*/
public function isBefore(Instant $other): bool
{
return $this->datetime < $other->datetime;
return $this->toDateTimeImmutable() < $other->toDateTimeImmutable();
}

/**
Expand All @@ -138,10 +145,11 @@ public function isBefore(Instant $other): bool
public function plusYears(int $years, ?Timezone $zone = null): Instant
{
$timezone = ($zone ?? Timezone::utc());
$datetime = $this->toDateTimeImmutable();
$shiftedDate = $this->toLocalDate(zone: $timezone)->plusYears(years: $years);
$recomposed = ZonedShift::recompose(zone: $timezone, original: $this->datetime, shiftedDate: $shiftedDate);
$recomposed = ZonedShift::recompose(zone: $timezone, original: $datetime, shiftedDate: $shiftedDate);

return new Instant(datetime: $recomposed);
return new Instant(canonical: IsoMoment::canonicalize(datetime: $recomposed));
}

/**
Expand All @@ -168,15 +176,16 @@ public function plusYears(int $years, ?Timezone $zone = null): Instant
public function toIso8601(Precision $precision = Precision::Seconds): string
{
$template = '%s.%s%s';
$datetime = $this->toDateTimeImmutable();

return match ($precision) {
Precision::Seconds => $this->datetime->format(self::ISO8601_FORMAT),
Precision::Microseconds => $this->datetime->format(self::ISO8601_MICRO_FORMAT),
Precision::Seconds => $datetime->format(self::ISO8601_FORMAT),
Precision::Microseconds => $datetime->format(self::ISO8601_MICRO_FORMAT),
Precision::Milliseconds => sprintf(
$template,
$this->datetime->format(self::ISO8601_DATETIME_FORMAT),
substr($this->datetime->format(self::FRACTIONAL_SECONDS_FORMAT), 0, 3),
$this->datetime->format(self::OFFSET_FORMAT)
$datetime->format(self::ISO8601_DATETIME_FORMAT),
substr($datetime->format(self::FRACTIONAL_SECONDS_FORMAT), 0, 3),
$datetime->format(self::OFFSET_FORMAT)
)
};
}
Expand Down Expand Up @@ -220,10 +229,11 @@ public function minusYears(int $years, ?Timezone $zone = null): Instant
public function plusMonths(int $months, ?Timezone $zone = null): Instant
{
$timezone = ($zone ?? Timezone::utc());
$datetime = $this->toDateTimeImmutable();
$shiftedDate = $this->toLocalDate(zone: $timezone)->plusMonths(months: $months);
$recomposed = ZonedShift::recompose(zone: $timezone, original: $this->datetime, shiftedDate: $shiftedDate);
$recomposed = ZonedShift::recompose(zone: $timezone, original: $datetime, shiftedDate: $shiftedDate);

return new Instant(datetime: $recomposed);
return new Instant(canonical: IsoMoment::canonicalize(datetime: $recomposed));
}

/**
Expand Down Expand Up @@ -251,7 +261,7 @@ public function minusMonths(int $months, ?Timezone $zone = null): Instant
*/
public function toLocalDate(Timezone $zone): LocalDate
{
$datetime = $this->datetime->setTimezone($zone->toDateTimeZone());
$datetime = $this->toDateTimeImmutable()->setTimezone($zone->toDateTimeZone());

return LocalDate::fromString(value: $datetime->format('Y-m-d'));
}
Expand All @@ -265,7 +275,7 @@ public function toLocalDate(Timezone $zone): LocalDate
*/
public function durationUntil(Instant $other): Duration
{
$difference = abs($this->datetime->getTimestamp() - $other->datetime->getTimestamp());
$difference = abs($this->toDateTimeImmutable()->getTimestamp() - $other->toDateTimeImmutable()->getTimestamp());

return Duration::fromSeconds(seconds: $difference);
}
Expand All @@ -277,7 +287,7 @@ public function durationUntil(Instant $other): Duration
*/
public function toUnixSeconds(): int
{
return $this->datetime->getTimestamp();
return $this->toDateTimeImmutable()->getTimestamp();
}

/**
Expand All @@ -288,7 +298,7 @@ public function toUnixSeconds(): int
*/
public function isAfterOrEqual(Instant $other): bool
{
return $this->datetime >= $other->datetime;
return $this->toDateTimeImmutable() >= $other->toDateTimeImmutable();
}

/**
Expand All @@ -299,7 +309,7 @@ public function isAfterOrEqual(Instant $other): bool
*/
public function isBeforeOrEqual(Instant $other): bool
{
return $this->datetime <= $other->datetime;
return $this->toDateTimeImmutable() <= $other->toDateTimeImmutable();
}

/**
Expand All @@ -309,6 +319,6 @@ public function isBeforeOrEqual(Instant $other): bool
*/
public function toDateTimeImmutable(): DateTimeImmutable
{
return $this->datetime;
return IsoMoment::restore(canonical: $this->canonical);
}
}
27 changes: 27 additions & 0 deletions src/Internal/IsoDate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Time\Internal;

use DateTimeImmutable;
use TinyBlocks\Time\Timezone;

final class IsoDate
{
private const string CANONICAL_FORMAT = 'Y-m-d';

private function __construct()
{
}

public static function restore(string $canonical): DateTimeImmutable
{
return new DateTimeImmutable(datetime: $canonical, timezone: Timezone::utc()->toDateTimeZone());
}

public static function canonicalize(DateTimeImmutable $datetime): string
{
return $datetime->format(self::CANONICAL_FORMAT);
}
}
29 changes: 29 additions & 0 deletions src/Internal/IsoMoment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Time\Internal;

use DateTimeImmutable;
use TinyBlocks\Time\Timezone;

final class IsoMoment
{
private const string CANONICAL_FORMAT = 'Y-m-d\TH:i:s.uP';

private function __construct()
{
}

public static function restore(string $canonical): DateTimeImmutable
{
$datetime = new DateTimeImmutable(datetime: $canonical);

return $datetime->setTimezone(Timezone::utc()->toDateTimeZone());
}

public static function canonicalize(DateTimeImmutable $datetime): string
{
return $datetime->setTimezone(Timezone::utc()->toDateTimeZone())->format(self::CANONICAL_FORMAT);
}
}
6 changes: 5 additions & 1 deletion src/Internal/Seconds.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
namespace TinyBlocks\Time\Internal;

use TinyBlocks\Time\Exceptions\InvalidSeconds;
use TinyBlocks\Vo\ValueObject;
use TinyBlocks\Vo\ValueObjectBehavior;

final readonly class Seconds
final readonly class Seconds implements ValueObject
{
use ValueObjectBehavior;

private const int ZERO = 0;

private function __construct(public int $value)
Expand Down
Loading