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
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

## [2.7.0] — 2026-10-16

### Added
- `FloatCast(finite: true)` rejects `INF` and `NAN` (e.g. `1e400` in JSON)
at hydration instead of letting `toJson()` fail later. Opt-in.

### Fixed
- `equals()`, `diff()` and the Eloquent casts compare `EncryptedCast` fields
by their decrypted value. Equal objects no longer differ only because each
`toArray()` draws a new nonce, and saving an unchanged model no longer
rewrites the encrypted column. `diff()` still reports ciphertexts, never
plaintext.
- `#[InferRules]` with a required `#[Flatten]` property rejected valid flat
input with "address is required"; the nested rules now apply to the
parent's own keys.

### Changed
- PHPStan runs at level 6: array, iterable and generic types are documented
across `src`.
- Documentation: XML entity handling, comparison of encrypted and hidden
fields.

## [2.6.0] — 2026-10-16

### Added
Expand Down Expand Up @@ -633,7 +655,8 @@ Initial release.
`toResponse()`).
- CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`.

[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.6.0...HEAD
[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.7.0...HEAD
[2.7.0]: https://github.com/std-out/simple-data-objects/compare/v2.6.0...v2.7.0
[2.6.0]: https://github.com/std-out/simple-data-objects/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...v2.5.0
[2.4.0]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...v2.4.0
Expand Down
2 changes: 2 additions & 0 deletions docs/attributes/infer-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ public readonly TypedDataCollection $items;

The cascade only happens if the **nested** class itself has rules to contribute — a nested class without `#[InferRules]` and without any `#[Rules]` attributes contributes nothing beyond the parent's own `'field' => ['required'|'nullable', 'array']` entry. Nesting depth is unbounded (a nested class's own cascade recurses into its own nested classes), and self-referential or mutually cyclic `BaseData` graphs are detected and stop cascading at the cycle instead of recursing forever.

A required [`#[Flatten]`](./flatten.md) property reads its fields from the parent's own keys, so it gets no rule for itself and the nested class's rules apply to the parent's keys without a prefix (`'zip'`, not `'address.zip'`). A nullable or defaulted flattened property keeps the prefixed form.

## Overriding or merging with #[Rules]

By default, an explicit [`#[Rules]`](./rules.md) on a property **replaces** the inferred rules for that property entirely — predictable, no surprises about what's actually being merged:
Expand Down
11 changes: 11 additions & 0 deletions docs/casts/numeric.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ class PriceData extends BaseData
PriceData::from(['amount' => '9.99'])->amount; // 9.99 (float)
```

### Rejecting INF and NAN

`json_decode('1e400')` yields `INF`, and a float field accepts it — then `toJson()` fails with `JsonException`. Pass `finite: true` to reject non-finite values at hydration:

```php
#[Cast(new FloatCast(finite: true))]
public readonly float $amount,
```

`INF`, `-INF` and `NAN` then throw `InvalidArgumentException`, and `tryFrom()` returns `null`. Without `finite` they pass through unchanged.

## Null Handling

Both casts return `null` for `null` input.
Expand Down
6 changes: 6 additions & 0 deletions docs/features/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ $a->diff($b);
// ['startsAt' => ['2024-01-01', '2025-06-15']]
```

## Encrypted and Hidden Fields

`EncryptedCast` produces a fresh ciphertext on every `toArray()`, so `equals()` and `diff()` compare those fields by their decrypted value: two objects holding the same card number are equal. `diff()` still reports a changed encrypted field with its ciphertexts, never the plaintext. The Eloquent cast (`IsEloquentCastable`) uses the same comparison, so saving a model whose data object didn't change doesn't rewrite the column.

`#[Hidden]` fields are not part of `toArray()` and are ignored by both methods.

## Use Cases

```php
Expand Down
1 change: 1 addition & 0 deletions docs/features/xml.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class VendorData extends BaseData
- **Undeclared elements are skipped** without being read into memory, so a DTO that needs three fields of a fifty-field element only pays for three. This also means `#[RejectUnknownKeys]` and class-level pipes only ever see declared fields.
- **Namespaced elements** are matched by their qualified name as written in the file: `#[XmlElement('g:price')]`.
- **`#[MapInputName]` and `#[TransformKeys]`** still apply to element names, so a class already mapped for array input needs nothing extra.
- **Entities.** The predefined entities (`&`, `<`, …) and character references (`A`) are decoded. Entities declared in a DTD are never expanded — neither internal nor external — so the file can't trigger entity-expansion attacks or read local files; a reference to one is dropped from the text (`A&x;B` reads as `AB`). Validate or reject DTD-bearing input upstream if that matters for your data.

## Errors

Expand Down
17 changes: 16 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
parameters:
level: 5
level: 6
paths:
- src
- tests/Fixtures
Expand Down Expand Up @@ -28,3 +28,18 @@ parameters:
paths:
- tests/Fixtures/MissingTargetDiscriminatorData.php
- tests/Fixtures/NonStringTargetDiscriminatorData.php
-
identifier: missingType.iterableValue
path: tests/Fixtures/*
-
identifier: missingType.generics
path: tests/Fixtures/*
-
identifier: missingType.parameter
path: tests/Fixtures/*
-
identifier: missingType.property
path: tests/Fixtures/*
-
identifier: missingType.return
path: tests/Fixtures/*
1 change: 1 addition & 0 deletions src/Attributes/Rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
final class Rules
{
/** @param array<mixed> $rules */
public function __construct(
public readonly array $rules,
public readonly bool $merge = false,
Expand Down
45 changes: 44 additions & 1 deletion src/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Illuminate\Validation\Factory as ValidatorFactory;
use Illuminate\Validation\ValidationException;
use JsonSerializable;
use StdOut\SimpleDataObjects\Casts\EncryptedCast;
use StdOut\SimpleDataObjects\Contracts\DataObject;
use StdOut\SimpleDataObjects\Exceptions\DataHydrationException;
use StdOut\SimpleDataObjects\Support\ClassMeta;
Expand All @@ -24,6 +25,7 @@
use StdOut\SimpleDataObjects\Support\XmlStream;
use Stringable;

/** @implements Arrayable<string, mixed> */
abstract class BaseData implements Arrayable, DataObject, JsonSerializable, Stringable
{
private static ?ValidatorFactory $validatorFactory = null;
Expand All @@ -39,7 +41,7 @@
*/
public static function from(#[\SensitiveParameter] mixed $data): static
{
$hydrate = HydratorCompiler::$hydrators[static::class] ?? HydratorCompiler::compile(static::class);

Check warning on line 44 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ */ public static function from(#[\SensitiveParameter] mixed $data): static { - $hydrate = HydratorCompiler::$hydrators[static::class] ?? HydratorCompiler::compile(static::class); + $hydrate = HydratorCompiler::compile(static::class) ?? HydratorCompiler::$hydrators[static::class]; if (is_array($data)) { /** @var static */

if (is_array($data)) {
/** @var static */
Expand All @@ -63,7 +65,7 @@
public static function fromLazy(#[\SensitiveParameter] mixed $data): static
{
$class = static::class;
$reflector = self::$reflectors[$class] ??= new \ReflectionClass($class);

Check warning on line 68 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "AssignCoalesce": @@ @@ public static function fromLazy(#[\SensitiveParameter] mixed $data): static { $class = static::class; - $reflector = self::$reflectors[$class] ??= new \ReflectionClass($class); + $reflector = (self::$reflectors[$class] = new \ReflectionClass($class)); // A ghost's class is fixed at creation, so a #[Discriminator] class // must resolve the concrete class eagerly; hydration stays deferred.

// A ghost's class is fixed at creation, so a #[Discriminator] class
// must resolve the concrete class eagerly; hydration stays deferred.
Expand All @@ -86,19 +88,19 @@
$meta = MetadataRegistry::get($class);

if (! $meta->hasConstructor) {
(HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost);

Check warning on line 91 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $meta = MetadataRegistry::get($class); if (! $meta->hasConstructor) { - (HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost); + (HydratorCompiler::compilePopulate($class) ?? HydratorCompiler::$populators[$class])($normalized, $ghost); return; }

return;
}

$args = (HydratorCompiler::$argResolvers[$class] ?? HydratorCompiler::compileArgs($class))($normalized);

Check warning on line 96 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ return; } - $args = (HydratorCompiler::$argResolvers[$class] ?? HydratorCompiler::compileArgs($class))($normalized); + $args = (HydratorCompiler::compileArgs($class) ?? HydratorCompiler::$argResolvers[$class])($normalized); $ghost->__construct(...$args); // Hybrid: the constructor only covers its own parameters — the
$ghost->__construct(...$args);

// Hybrid: the constructor only covers its own parameters — the
// extra properties still need populating, same mechanism as the
// constructor-less path above.
if ($meta->hasExtraProperties) {
(HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost);

Check warning on line 103 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ // extra properties still need populating, same mechanism as the // constructor-less path above. if ($meta->hasExtraProperties) { - (HydratorCompiler::$populators[$class] ?? HydratorCompiler::compilePopulate($class))($normalized, $ghost); + (HydratorCompiler::compilePopulate($class) ?? HydratorCompiler::$populators[$class])($normalized, $ghost); } }); }
}
});
}
Expand All @@ -120,7 +122,7 @@
*/
public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult
{
$collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class);

Check warning on line 125 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ */ public static function fromResult(#[\SensitiveParameter] mixed $data): HydrationResult { - $collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class); + $collect = HydratorCompiler::compileCollecting(static::class) ?? HydratorCompiler::$collectingHydrators[static::class]; if (is_array($data)) { /** @var HydrationResult<static> */

if (is_array($data)) {
/** @var HydrationResult<static> */
Expand All @@ -131,7 +133,7 @@
return HydrationResult::success($data);
}

try {

Check warning on line 136 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "CatchBlockRemoval": @@ @@ try { $data = InputNormalizer::normalize(static::class, $data); - } catch (DataHydrationException $e) { - return HydrationResult::failure(['$input' => $e->getMessage()]); } catch (\Throwable) { return HydrationResult::failure(['$input' => 'Input could not be normalized.']); }
$data = InputNormalizer::normalize(static::class, $data);
} catch (DataHydrationException $e) {
return HydrationResult::failure(['$input' => $e->getMessage()]);
Expand All @@ -144,13 +146,15 @@
}

/**
* @param iterable<mixed> $items
* @return TypedDataCollection<static>
*/
public static function collection(#[\SensitiveParameter] iterable $items): TypedDataCollection
{
return TypedDataCollection::of(static::class, $items);
}

/** @return array<string, mixed> */
public static function jsonSchema(): array
{
return SchemaGenerator::generate(static::class);
Expand All @@ -162,6 +166,7 @@
* for large iterables (a DB cursor, a generator reading a big CSV) where
* holding every hydrated instance in memory at once would be wasteful.
*
* @param iterable<mixed> $items
* @return LazyCollection<int, static>
*/
public static function lazyCollection(#[\SensitiveParameter] iterable $items): LazyCollection
Expand All @@ -169,7 +174,7 @@
$class = static::class;

return LazyCollection::make(static function () use ($items, $class): \Generator {
$hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class);

Check warning on line 177 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $class = static::class; return LazyCollection::make(static function () use ($items, $class): \Generator { - $hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class); + $hydrate = HydratorCompiler::compile($class) ?? HydratorCompiler::$hydrators[$class]; foreach ($items as $item) { yield $item instanceof $class

foreach ($items as $item) {
yield $item instanceof $class
Expand All @@ -190,7 +195,7 @@
$class = static::class;

return LazyCollection::make(static function () use ($class, $uri, $path): \Generator {
$hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class);

Check warning on line 198 in src/BaseData.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Coalesce": @@ @@ $class = static::class; return LazyCollection::make(static function () use ($class, $uri, $path): \Generator { - $hydrate = HydratorCompiler::$hydrators[$class] ?? HydratorCompiler::compile($class); + $hydrate = HydratorCompiler::compile($class) ?? HydratorCompiler::$hydrators[$class]; foreach (XmlStream::read($class, $uri, $path) as $row) { yield $hydrate($row);

foreach (XmlStream::read($class, $uri, $path) as $row) {
yield $hydrate($row);
Expand Down Expand Up @@ -354,6 +359,7 @@
}

/**
* @param array<mixed> $data
* @return class-string<static>
*
* @throws DataHydrationException
Expand Down Expand Up @@ -401,14 +407,27 @@

public function equals(self $other): bool
{
return $other::class === static::class && $this->toArray() === $other->toArray();
if ($other::class !== static::class) {
return false;
}

$encryptions = EncryptedCast::$encryptions;

return $this->toArray() === $other->toArray()
|| (EncryptedCast::$encryptions !== $encryptions && EncryptedCast::comparingPlaintext(fn (): bool => $this->toArray() === $other->toArray()));
}

/** @return array<string, array{0: mixed, 1: mixed}> */
public function diff(self $other): array
{
$encryptions = EncryptedCast::$encryptions;
$a = $this->toArray();
$b = $other->toArray();

if (EncryptedCast::$encryptions !== $encryptions) {
return self::diffPlaintext($a, $b, ...EncryptedCast::comparingPlaintext(fn (): array => [$this->toArray(), $other->toArray()]));
}

$result = [];

foreach (array_keys($a + $b) as $key) {
Expand All @@ -423,6 +442,26 @@
return $result;
}

/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
* @param array<string, mixed> $plainA
* @param array<string, mixed> $plainB
* @return array<string, array{0: mixed, 1: mixed}>
*/
private static function diffPlaintext(array $a, array $b, array $plainA, array $plainB): array
{
$result = [];

foreach (array_keys($a + $b) as $key) {
if (($plainA[$key] ?? null) !== ($plainB[$key] ?? null)) {
$result[$key] = [$a[$key] ?? null, $b[$key] ?? null];
}
}

return $result;
}

public function toArray(?string $context = null): array
{
if ($context === null) {
Expand All @@ -437,21 +476,25 @@
return json_encode($this->toArray($context), $flags | JSON_THROW_ON_ERROR);
}

/** @return array<string, mixed> */
public function only(string ...$keys): array
{
return array_intersect_key($this->toArray(), array_flip($keys));
}

/** @return array<string, mixed> */
public function except(string ...$keys): array
{
return array_diff_key($this->toArray(), array_flip($keys));
}

/** @return array<string, mixed> */
public function definedOnly(?string $context = null): array
{
return $this->toArray($context);
}

/** @return array<string, mixed> */
public function jsonSerialize(): array
{
return $this->toArray();
Expand Down
2 changes: 2 additions & 0 deletions src/Casts/BooleanCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class BooleanCast implements CastsValue, ProvidesJsonSchema
{
private const array TRUTHY = ['true', '1', 'yes', 'on'];

/** @param array<string, mixed> $state */
public static function __set_state(array $state): self
{
return new self;
Expand All @@ -34,6 +35,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?bool
return $value === null ? null : (bool) $value;
}

/** @return array<string, mixed> */
public function jsonSchema(): array
{
return ['type' => 'boolean'];
Expand Down
3 changes: 3 additions & 0 deletions src/Casts/CommaSeparatedCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ public function __construct(
}
}

/** @param array<string, mixed> $state */
public static function __set_state(array $state): self
{
return new self($state['separator'], $state['trim'], $state['maxItems'] ?? null);
}

/** @return list<string>|null */
public function get(#[\SensitiveParameter] mixed $value): ?array
{
if ($value === null) {
Expand Down Expand Up @@ -64,6 +66,7 @@ private function tooMany(): never
throw new InvalidArgumentException("CommaSeparatedCast accepts at most {$this->maxItems} items.");
}

/** @return array<string, mixed> */
public function jsonSchema(): array
{
return ['type' => 'string'];
Expand Down
2 changes: 2 additions & 0 deletions src/Casts/DateTimeCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function __construct(
$this->timezone = $timezone instanceof DateTimeZone ? $timezone->getName() : $timezone;
}

/** @param array<string, mixed> $state */
public static function __set_state(array $state): self
{
return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']);
Expand Down Expand Up @@ -77,6 +78,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string
return $dt?->format($this->outputFormat);
}

/** @return array<string, mixed> */
public function jsonSchema(): array
{
return $this->outputFormat === DateTimeInterface::ATOM
Expand Down
2 changes: 2 additions & 0 deletions src/Casts/DateTimeImmutableCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function __construct(
$this->timezone = $timezone instanceof DateTimeZone ? $timezone->getName() : $timezone;
}

/** @param array<string, mixed> $state */
public static function __set_state(array $state): self
{
return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']);
Expand Down Expand Up @@ -77,6 +78,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string
return $dt?->format($this->outputFormat);
}

/** @return array<string, mixed> */
public function jsonSchema(): array
{
return $this->outputFormat === DateTimeInterface::ATOM
Expand Down
44 changes: 44 additions & 0 deletions src/Casts/EncryptedCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ final class EncryptedCast implements BindsToField, CastsValue, ProvidesJsonSchem

private string $associatedData;

private static bool $comparingPlaintext = false;

/** @internal */
public static int $encryptions = 0;

/**
* Attribute arguments only allow constant expressions — env() or config()
* calls are a compile error there. Pass the NAME of an environment
Expand Down Expand Up @@ -144,6 +149,12 @@ public function set(#[\SensitiveParameter] mixed $value): ?string
throw new InvalidArgumentException('EncryptedCast can only encrypt scalar or Stringable values, got '.get_debug_type($value).'.');
}

if (self::$comparingPlaintext) {
return (string) $value;
}

self::$encryptions++;

if ($this->keyring !== null) {
return $this->seal((string) $value);
}
Expand All @@ -154,6 +165,38 @@ public function set(#[\SensitiveParameter] mixed $value): ?string
return sodium_bin2base64($nonce.$ciphertext, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
}

/**
* @internal
*
* @template T
*
* @param \Closure(): T $compare
* @return T
*/
public static function comparingPlaintext(\Closure $compare): mixed
{
$previous = self::$comparingPlaintext;
self::$comparingPlaintext = true;

try {
return $compare();
} finally {
self::$comparingPlaintext = $previous;
}
}

/**
* @internal
*
* @param \Closure(): bool $compare
*/
public static function sameIgnoringNonces(\Closure $compare): bool
{
$encryptions = self::$encryptions;

return $compare() || (self::$encryptions !== $encryptions && self::comparingPlaintext($compare));
}

public function needsReencryption(string $value): bool
{
return $this->keyring !== null && ! str_starts_with($value, self::PREFIX.$this->currentKeyId.':');
Expand All @@ -171,6 +214,7 @@ public function __debugInfo(): array
return ['secretKey' => Redactor::REDACTED];
}

/** @return array<string, mixed> */
public function jsonSchema(): array
{
return ['type' => 'string'];
Expand Down
Loading
Loading