diff --git a/CHANGELOG.md b/CHANGELOG.md index 346fce4..37d0b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/docs/attributes/infer-rules.md b/docs/attributes/infer-rules.md index 9a13359..b5bed92 100644 --- a/docs/attributes/infer-rules.md +++ b/docs/attributes/infer-rules.md @@ -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: diff --git a/docs/casts/numeric.md b/docs/casts/numeric.md index 9e4e5bc..97beb7d 100644 --- a/docs/casts/numeric.md +++ b/docs/casts/numeric.md @@ -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. diff --git a/docs/features/comparison.md b/docs/features/comparison.md index 7e5a875..69b70b9 100644 --- a/docs/features/comparison.md +++ b/docs/features/comparison.md @@ -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 diff --git a/docs/features/xml.md b/docs/features/xml.md index e85eb92..375629d 100644 --- a/docs/features/xml.md +++ b/docs/features/xml.md @@ -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 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ee8e5e8..b78023d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - level: 5 + level: 6 paths: - src - tests/Fixtures @@ -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/* diff --git a/src/Attributes/Rules.php b/src/Attributes/Rules.php index fc77fb9..844e224 100644 --- a/src/Attributes/Rules.php +++ b/src/Attributes/Rules.php @@ -9,6 +9,7 @@ #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] final class Rules { + /** @param array $rules */ public function __construct( public readonly array $rules, public readonly bool $merge = false, diff --git a/src/BaseData.php b/src/BaseData.php index 8333e21..2b2af0c 100644 --- a/src/BaseData.php +++ b/src/BaseData.php @@ -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; @@ -24,6 +25,7 @@ use StdOut\SimpleDataObjects\Support\XmlStream; use Stringable; +/** @implements Arrayable */ abstract class BaseData implements Arrayable, DataObject, JsonSerializable, Stringable { private static ?ValidatorFactory $validatorFactory = null; @@ -144,6 +146,7 @@ public static function fromResult(#[\SensitiveParameter] mixed $data): Hydration } /** + * @param iterable $items * @return TypedDataCollection */ public static function collection(#[\SensitiveParameter] iterable $items): TypedDataCollection @@ -151,6 +154,7 @@ public static function collection(#[\SensitiveParameter] iterable $items): Typed return TypedDataCollection::of(static::class, $items); } + /** @return array */ public static function jsonSchema(): array { return SchemaGenerator::generate(static::class); @@ -162,6 +166,7 @@ public static function jsonSchema(): array * 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 $items * @return LazyCollection */ public static function lazyCollection(#[\SensitiveParameter] iterable $items): LazyCollection @@ -354,6 +359,7 @@ public static function validate(#[\SensitiveParameter] mixed $data): void } /** + * @param array $data * @return class-string * * @throws DataHydrationException @@ -401,14 +407,27 @@ private static function validatorFactory(): ValidatorFactory 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 */ 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) { @@ -423,6 +442,26 @@ public function diff(self $other): array return $result; } + /** + * @param array $a + * @param array $b + * @param array $plainA + * @param array $plainB + * @return array + */ + 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) { @@ -437,21 +476,25 @@ public function toJson(int $flags = 0, ?string $context = null): string return json_encode($this->toArray($context), $flags | JSON_THROW_ON_ERROR); } + /** @return array */ public function only(string ...$keys): array { return array_intersect_key($this->toArray(), array_flip($keys)); } + /** @return array */ public function except(string ...$keys): array { return array_diff_key($this->toArray(), array_flip($keys)); } + /** @return array */ public function definedOnly(?string $context = null): array { return $this->toArray($context); } + /** @return array */ public function jsonSerialize(): array { return $this->toArray(); diff --git a/src/Casts/BooleanCast.php b/src/Casts/BooleanCast.php index 752cdb1..90a2609 100644 --- a/src/Casts/BooleanCast.php +++ b/src/Casts/BooleanCast.php @@ -11,6 +11,7 @@ final class BooleanCast implements CastsValue, ProvidesJsonSchema { private const array TRUTHY = ['true', '1', 'yes', 'on']; + /** @param array $state */ public static function __set_state(array $state): self { return new self; @@ -34,6 +35,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?bool return $value === null ? null : (bool) $value; } + /** @return array */ public function jsonSchema(): array { return ['type' => 'boolean']; diff --git a/src/Casts/CommaSeparatedCast.php b/src/Casts/CommaSeparatedCast.php index 0e6a1c2..51a49f9 100644 --- a/src/Casts/CommaSeparatedCast.php +++ b/src/Casts/CommaSeparatedCast.php @@ -20,11 +20,13 @@ public function __construct( } } + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['separator'], $state['trim'], $state['maxItems'] ?? null); } + /** @return list|null */ public function get(#[\SensitiveParameter] mixed $value): ?array { if ($value === null) { @@ -64,6 +66,7 @@ private function tooMany(): never throw new InvalidArgumentException("CommaSeparatedCast accepts at most {$this->maxItems} items."); } + /** @return array */ public function jsonSchema(): array { return ['type' => 'string']; diff --git a/src/Casts/DateTimeCast.php b/src/Casts/DateTimeCast.php index 0be55ad..01aceb7 100644 --- a/src/Casts/DateTimeCast.php +++ b/src/Casts/DateTimeCast.php @@ -26,6 +26,7 @@ public function __construct( $this->timezone = $timezone instanceof DateTimeZone ? $timezone->getName() : $timezone; } + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']); @@ -77,6 +78,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string return $dt?->format($this->outputFormat); } + /** @return array */ public function jsonSchema(): array { return $this->outputFormat === DateTimeInterface::ATOM diff --git a/src/Casts/DateTimeImmutableCast.php b/src/Casts/DateTimeImmutableCast.php index cc23717..add8a5b 100644 --- a/src/Casts/DateTimeImmutableCast.php +++ b/src/Casts/DateTimeImmutableCast.php @@ -26,6 +26,7 @@ public function __construct( $this->timezone = $timezone instanceof DateTimeZone ? $timezone->getName() : $timezone; } + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['outputFormat'], $state['inputFormat'], $state['timezone']); @@ -77,6 +78,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string return $dt?->format($this->outputFormat); } + /** @return array */ public function jsonSchema(): array { return $this->outputFormat === DateTimeInterface::ATOM diff --git a/src/Casts/EncryptedCast.php b/src/Casts/EncryptedCast.php index 22202dd..79f19a5 100644 --- a/src/Casts/EncryptedCast.php +++ b/src/Casts/EncryptedCast.php @@ -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 @@ -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); } @@ -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.':'); @@ -171,6 +214,7 @@ public function __debugInfo(): array return ['secretKey' => Redactor::REDACTED]; } + /** @return array */ public function jsonSchema(): array { return ['type' => 'string']; diff --git a/src/Casts/EnumCast.php b/src/Casts/EnumCast.php index 559420d..1c594f5 100644 --- a/src/Casts/EnumCast.php +++ b/src/Casts/EnumCast.php @@ -15,6 +15,7 @@ public function __construct( private readonly ?UnitEnum $default = null, ) {} + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['enumClass'], $state['default']); diff --git a/src/Casts/FloatCast.php b/src/Casts/FloatCast.php index 1b5a699..697d649 100644 --- a/src/Casts/FloatCast.php +++ b/src/Casts/FloatCast.php @@ -4,6 +4,7 @@ namespace StdOut\SimpleDataObjects\Casts; +use InvalidArgumentException; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; @@ -11,11 +12,13 @@ final class FloatCast implements CastsValue, ProvidesJsonSchema { public function __construct( private readonly int $decimals = -1, + private readonly bool $finite = false, ) {} + /** @param array $state */ public static function __set_state(array $state): self { - return new self($state['decimals']); + return new self($state['decimals'], $state['finite'] ?? false); } public function get(#[\SensitiveParameter] mixed $value): ?float @@ -26,6 +29,10 @@ public function get(#[\SensitiveParameter] mixed $value): ?float $float = (float) $value; + if ($this->finite && ! is_finite($float)) { + throw new InvalidArgumentException('FloatCast(finite: true) rejects INF and NAN.'); + } + return $this->decimals >= 0 ? round($float, $this->decimals) : $float; } @@ -34,6 +41,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?float return $this->get($value); } + /** @return array */ public function jsonSchema(): array { return ['type' => 'number']; diff --git a/src/Casts/IntegerCast.php b/src/Casts/IntegerCast.php index c2d08cf..a90d5db 100644 --- a/src/Casts/IntegerCast.php +++ b/src/Casts/IntegerCast.php @@ -9,6 +9,7 @@ final class IntegerCast implements CastsValue, ProvidesJsonSchema { + /** @param array $state */ public static function __set_state(array $state): self { return new self; @@ -24,6 +25,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?int return $value === null ? null : (int) $value; } + /** @return array */ public function jsonSchema(): array { return ['type' => 'integer']; diff --git a/src/Casts/JsonCast.php b/src/Casts/JsonCast.php index 207a35f..7724af2 100644 --- a/src/Casts/JsonCast.php +++ b/src/Casts/JsonCast.php @@ -14,6 +14,7 @@ public function __construct( private readonly int $encodeFlags = 0, ) {} + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['assoc'], $state['encodeFlags']); @@ -41,6 +42,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string return json_encode($value, $this->encodeFlags | JSON_THROW_ON_ERROR); } + /** @return array */ public function jsonSchema(): array { return []; diff --git a/src/Casts/MoneyCast.php b/src/Casts/MoneyCast.php index b91fa51..34e9b6a 100644 --- a/src/Casts/MoneyCast.php +++ b/src/Casts/MoneyCast.php @@ -28,6 +28,7 @@ public function __construct( private readonly int $decimals = 2, ) {} + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['currency'], $state['decimals']); @@ -134,6 +135,7 @@ private function assertCurrency(string $currency): void } } + /** @return array */ public function jsonSchema(): array { return ['type' => 'integer', 'description' => 'Amount in minor units (cents).']; diff --git a/src/Casts/TrimCast.php b/src/Casts/TrimCast.php index 1d4b403..daead99 100644 --- a/src/Casts/TrimCast.php +++ b/src/Casts/TrimCast.php @@ -17,6 +17,7 @@ public function __construct( private readonly ?string $transform = null, ) {} + /** @param array $state */ public static function __set_state(array $state): self { return new self($state['transform']); @@ -42,6 +43,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string return $this->get($value); } + /** @return array */ public function jsonSchema(): array { return ['type' => 'string']; diff --git a/src/Casts/UuidCast.php b/src/Casts/UuidCast.php index 05b8810..f012dfc 100644 --- a/src/Casts/UuidCast.php +++ b/src/Casts/UuidCast.php @@ -13,6 +13,7 @@ final class UuidCast implements CastsValue, ProvidesJsonSchema { private const string PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; + /** @param array $state */ public static function __set_state(array $state): self { return new self; @@ -38,6 +39,7 @@ public function set(#[\SensitiveParameter] mixed $value): ?string return $this->get($value); } + /** @return array */ public function jsonSchema(): array { return ['type' => 'string', 'format' => 'uuid']; diff --git a/src/Concerns/HasLaravelIntegration.php b/src/Concerns/HasLaravelIntegration.php index 0b4c4e3..06bef69 100644 --- a/src/Concerns/HasLaravelIntegration.php +++ b/src/Concerns/HasLaravelIntegration.php @@ -41,6 +41,10 @@ public static function fromModel(Model $model): static return static::from($data); } + /** + * @param Request $request + * @param array $headers + */ public function toResponse($request, int $status = 200, array $headers = [], ?string $context = null): JsonResponse { $wrapIn = MetadataRegistry::get(static::class)->wrapIn; @@ -50,6 +54,7 @@ public function toResponse($request, int $status = 200, array $headers = [], ?st } /** + * @param LengthAwarePaginator $paginator * @return PaginatedDataCollection */ public static function paginatedCollection(LengthAwarePaginator $paginator): PaginatedDataCollection diff --git a/src/Concerns/WireableData.php b/src/Concerns/WireableData.php index 7c438fb..c091d29 100644 --- a/src/Concerns/WireableData.php +++ b/src/Concerns/WireableData.php @@ -20,6 +20,7 @@ abstract public static function from(#[\SensitiveParameter] mixed $data): static abstract public function toArray(): array; + /** @return array */ public function toLivewire(): array { return $this->toArray(); diff --git a/src/Contracts/DataPipe.php b/src/Contracts/DataPipe.php index 2a536db..e38e396 100644 --- a/src/Contracts/DataPipe.php +++ b/src/Contracts/DataPipe.php @@ -6,6 +6,10 @@ interface DataPipe { - /** @param class-string $dataClass */ + /** + * @param class-string $dataClass + * @param array $data + * @return array + */ public function handle(#[\SensitiveParameter] array $data, string $dataClass, callable $next): array; } diff --git a/src/Contracts/ProvidesJsonSchema.php b/src/Contracts/ProvidesJsonSchema.php index 92e657a..cfd3f0a 100644 --- a/src/Contracts/ProvidesJsonSchema.php +++ b/src/Contracts/ProvidesJsonSchema.php @@ -6,5 +6,6 @@ interface ProvidesJsonSchema { + /** @return array */ public function jsonSchema(): array; } diff --git a/src/Laravel/Console/MakeDataCommand.php b/src/Laravel/Console/MakeDataCommand.php index 5b81ce1..38d4968 100644 --- a/src/Laravel/Console/MakeDataCommand.php +++ b/src/Laravel/Console/MakeDataCommand.php @@ -178,7 +178,7 @@ private function propertiesBlock(): string } /** - * @return list|null + * @return list|null}>|null */ private function columnsForModel(string $model): ?array { diff --git a/src/Laravel/DataCollectionCast.php b/src/Laravel/DataCollectionCast.php index 5455b5b..fe4d72a 100644 --- a/src/Laravel/DataCollectionCast.php +++ b/src/Laravel/DataCollectionCast.php @@ -7,17 +7,19 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use StdOut\SimpleDataObjects\BaseData; +use StdOut\SimpleDataObjects\Casts\EncryptedCast; use StdOut\SimpleDataObjects\Exceptions\DataHydrationException; use StdOut\SimpleDataObjects\TypedDataCollection; /** - * @implements CastsAttributes + * @implements CastsAttributes, TypedDataCollection|iterable|string> */ final class DataCollectionCast implements CastsAttributes { /** @param class-string $dataClass */ public function __construct(private readonly string $dataClass) {} + /** @return TypedDataCollection|null */ public function get(Model $model, string $key, #[\SensitiveParameter] mixed $value, #[\SensitiveParameter] array $attributes): ?TypedDataCollection { if ($value === null) { @@ -65,7 +67,9 @@ public function compare(Model $model, string $key, #[\SensitiveParameter] mixed ->map(static fn (BaseData $item): array => $item->toArray()) ->all(); - return $toArrays($this->get($model, $key, $firstValue, [])) - === $toArrays($this->get($model, $key, $secondValue, [])); + $first = $this->get($model, $key, $firstValue, []); + $second = $this->get($model, $key, $secondValue, []); + + return EncryptedCast::sameIgnoringNonces(static fn (): bool => $toArrays($first) === $toArrays($second)); } } diff --git a/src/Laravel/DataObjectCast.php b/src/Laravel/DataObjectCast.php index c11da85..888742a 100644 --- a/src/Laravel/DataObjectCast.php +++ b/src/Laravel/DataObjectCast.php @@ -7,9 +7,10 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use StdOut\SimpleDataObjects\BaseData; +use StdOut\SimpleDataObjects\Casts\EncryptedCast; /** - * @implements CastsAttributes + * @implements CastsAttributes|string> */ final class DataObjectCast implements CastsAttributes { @@ -47,6 +48,7 @@ public function compare(Model $model, string $key, #[\SensitiveParameter] mixed $first = $this->dataClass::from($firstValue); $second = $this->dataClass::from($secondValue); - return $first::class === $second::class && $first->toArray($this->context) === $second->toArray($this->context); + return $first::class === $second::class + && EncryptedCast::sameIgnoringNonces(fn (): bool => $first->toArray($this->context) === $second->toArray($this->context)); } } diff --git a/src/Laravel/PaginatedDataCollection.php b/src/Laravel/PaginatedDataCollection.php index 82b86ff..637547a 100644 --- a/src/Laravel/PaginatedDataCollection.php +++ b/src/Laravel/PaginatedDataCollection.php @@ -14,6 +14,7 @@ /** @template T of BaseData */ final class PaginatedDataCollection implements JsonSerializable, Responsable { + /** @param LengthAwarePaginator $paginator */ private function __construct( /** @var TypedDataCollection */ public readonly TypedDataCollection $data, @@ -22,6 +23,7 @@ private function __construct( /** * @param class-string $dataClass + * @param LengthAwarePaginator $paginator * @return self */ public static function of(string $dataClass, LengthAwarePaginator $paginator): self @@ -45,11 +47,13 @@ public function toArray(?string $context = null): array ]; } + /** @return array */ public function jsonSerialize(): array { return $this->toArray(); } + /** @param array $headers */ public function toResponse($request, int $status = 200, array $headers = [], ?string $context = null): JsonResponse { return new JsonResponse($this->toArray($context), $status, $headers); diff --git a/src/Pipes/NullifyEmptyStringsPipe.php b/src/Pipes/NullifyEmptyStringsPipe.php index 29e6155..2956266 100644 --- a/src/Pipes/NullifyEmptyStringsPipe.php +++ b/src/Pipes/NullifyEmptyStringsPipe.php @@ -8,6 +8,10 @@ final class NullifyEmptyStringsPipe implements DataPipe { + /** + * @param array $data + * @return array + */ public function handle(#[\SensitiveParameter] array $data, string $dataClass, callable $next): array { return $next(array_map( diff --git a/src/Pipes/TrimStringsPipe.php b/src/Pipes/TrimStringsPipe.php index 3e52836..190a331 100644 --- a/src/Pipes/TrimStringsPipe.php +++ b/src/Pipes/TrimStringsPipe.php @@ -8,6 +8,10 @@ final class TrimStringsPipe implements DataPipe { + /** + * @param array $data + * @return array + */ public function handle(#[\SensitiveParameter] array $data, string $dataClass, callable $next): array { return $next(array_map( diff --git a/src/Support/ClassMeta.php b/src/Support/ClassMeta.php index b255474..c20c9bd 100644 --- a/src/Support/ClassMeta.php +++ b/src/Support/ClassMeta.php @@ -79,8 +79,10 @@ public function __construct( $cascadesNestedRules = true; } + $nestedPrefix = $meta->flatten && ! $meta->allowsNull && ! $meta->hasDefault ? '' : "{$inputName}."; + foreach ($meta->nestedRules as $suffix => $rules) { - $validationRules["{$inputName}.{$suffix}"] = $rules; + $validationRules[$nestedPrefix.$suffix] = $rules; } } @@ -98,6 +100,7 @@ public function __construct( * Missing and unmapped discriminator values resolve to the configured * fallback, or null when there is none. * + * @param array $data * @return class-string|null */ public function resolveDiscriminatedClass(#[\SensitiveParameter] array $data): ?string @@ -242,6 +245,7 @@ private static function prepareNestedValidation(string $class, #[\SensitiveParam return $meta->prepareValidation($data, $prefix, $rules); } + /** @param array $state */ public static function __set_state(array $state): self { return new self( diff --git a/src/Support/ClassMetaFactory.php b/src/Support/ClassMetaFactory.php index 8c7145b..5e62071 100644 --- a/src/Support/ClassMetaFactory.php +++ b/src/Support/ClassMetaFactory.php @@ -136,7 +136,10 @@ private static function doBuild(string $class): ClassMeta ); } - /** @param list $params */ + /** + * @param list $params + * @param ReflectionClass $reflection + */ private static function assertSensitiveFieldsProtected(ReflectionClass $reflection, array $params): void { if (! array_any($params, static fn (ParameterMeta $p): bool => $p->isSensitive)) { @@ -154,7 +157,10 @@ private static function assertSensitiveFieldsProtected(ReflectionClass $reflecti ); } - /** @return array method name => output key */ + /** + * @param ReflectionClass $reflection + * @return array method name => output key + */ private static function computedMethods(ReflectionClass $reflection, ?string $strategy): array { $computed = []; @@ -256,6 +262,7 @@ private static function validateDiscriminatorTarget(string $class, mixed $target /** * @param list $excludeNames + * @param ReflectionClass $reflection * @return list */ private static function publicTypedProperties(ReflectionClass $reflection, array $excludeNames): array @@ -349,6 +356,7 @@ private static function buildParam(ReflectionParameter|ReflectionProperty $param $rules = match (true) { $explicitRules !== null && ! $explicitRules->merge => $explicitRules->rules, + $inferRules && $flattenAttrs !== [] && ! $allowsNull && ! $hasDefault => $explicitRules->rules ?? [], $inferRules => [ ...RuleInferrer::forParameter($parameter, $allowsNull, $nestedDataClass, $enumClass, $dataCollectionClass, $isOptional), ...$explicitRules->rules ?? [], diff --git a/src/Support/HydratorCompiler.php b/src/Support/HydratorCompiler.php index 0e05a8d..d651e5b 100644 --- a/src/Support/HydratorCompiler.php +++ b/src/Support/HydratorCompiler.php @@ -24,7 +24,7 @@ final class HydratorCompiler /** * @internal Read directly by BaseData::from() for speed — do not mutate. * - * @var array + * @var array): object> */ public static array $hydrators = []; @@ -35,7 +35,7 @@ final class HydratorCompiler * * @internal Read directly by BaseData::fromLazy() — do not mutate. * - * @var array + * @var array): array> */ public static array $argResolvers = []; @@ -47,7 +47,7 @@ final class HydratorCompiler * * @internal Read directly by BaseData::fromLazy() and BaseData::with() — do not mutate. * - * @var array + * @var array, object): void> */ public static array $populators = []; @@ -57,7 +57,7 @@ final class HydratorCompiler * * @internal Read directly by BaseData::fromResult() for speed — do not mutate. * - * @var array + * @var array): HydrationResult> */ public static array $collectingHydrators = []; @@ -108,7 +108,7 @@ public static function compileArgs(string $class): Closure /** * @param class-string $class - * @return Closure(array, object): void + * @return Closure(array, object): void */ public static function compilePopulate(string $class): Closure { diff --git a/src/Support/InputNormalizer.php b/src/Support/InputNormalizer.php index a6a75fa..7932bce 100644 --- a/src/Support/InputNormalizer.php +++ b/src/Support/InputNormalizer.php @@ -18,6 +18,7 @@ */ final class InputNormalizer { + /** @return array */ public static function normalize(string $class, #[\SensitiveParameter] mixed $data): array { if ($data instanceof Arrayable) { diff --git a/src/Support/ParameterMeta.php b/src/Support/ParameterMeta.php index 8d6138b..d4d93e1 100644 --- a/src/Support/ParameterMeta.php +++ b/src/Support/ParameterMeta.php @@ -20,6 +20,7 @@ final class ParameterMeta * @param class-string|null $nestedDataClass * @param class-string|null $enumClass * @param class-string|null $dataCollectionClass + * @param array $rules */ public function __construct( public readonly string $phpName, @@ -57,6 +58,7 @@ public function __construct( && $pipes === []; } + /** @param array $state */ public static function __set_state(array $state): self { // Older caches only have a single 'inputName' — reuse it as both. diff --git a/src/Support/PipelineRunner.php b/src/Support/PipelineRunner.php index f8fe9c7..4f822ab 100644 --- a/src/Support/PipelineRunner.php +++ b/src/Support/PipelineRunner.php @@ -22,6 +22,8 @@ final class PipelineRunner * * @param list> $pipes * @param class-string $dataClass + * @param array $data + * @return array */ public static function run(#[\SensitiveParameter] array $data, string $dataClass, array $pipes): array { diff --git a/src/Support/SchemaGenerator.php b/src/Support/SchemaGenerator.php index df99e25..ae301f1 100644 --- a/src/Support/SchemaGenerator.php +++ b/src/Support/SchemaGenerator.php @@ -13,7 +13,10 @@ */ final class SchemaGenerator { - /** @param class-string $class */ + /** + * @param class-string $class + * @return array + */ public static function generate(string $class): array { $defs = []; @@ -22,7 +25,10 @@ public static function generate(string $class): array return $defs === [] ? $schema : ['$defs' => $defs, ...$schema]; } - /** @param array $defs */ + /** + * @param array> $defs + * @return array + */ private static function classSchema(string $class, array &$defs): array { $meta = MetadataRegistry::get($class); @@ -47,8 +53,8 @@ private static function classSchema(string $class, array &$defs): array } /** - * @param array $defs - * @return array{0: array, 1: list} + * @param array> $defs + * @return array{0: array>, 1: list} */ private static function properties(ClassMeta $meta, array &$defs): array { @@ -82,7 +88,8 @@ private static function properties(ClassMeta $meta, array &$defs): array * Per-field schema, shared with TypeScriptGenerator so type resolution * (casts, enums, nested refs, collections, rules) exists in one place. * - * @param array $defs + * @param array> $defs + * @return array */ public static function fieldSchema(ParameterMeta $param, array &$defs): array { @@ -106,6 +113,7 @@ public static function fieldSchema(ParameterMeta $param, array &$defs): array return $schema; } + /** @return array */ private static function scalarSchema(string $phpType): array { return match ($phpType) { @@ -118,7 +126,10 @@ private static function scalarSchema(string $phpType): array }; } - /** @param class-string $enumClass */ + /** + * @param class-string $enumClass + * @return array + */ private static function enumSchema(string $enumClass): array { $isBacked = is_subclass_of($enumClass, BackedEnum::class); @@ -130,7 +141,10 @@ private static function enumSchema(string $enumClass): array return ['type' => is_int($values[0] ?? null) ? 'integer' : 'string', 'enum' => $values]; } - /** @param array $defs */ + /** + * @param array> $defs + * @return array + */ private static function refSchema(string $class, array &$defs): array { $key = self::defKey($class); @@ -151,7 +165,10 @@ public static function defKey(string $class): string return $pos === false ? $class : substr($class, $pos + 1); } - /** @param array $defs */ + /** + * @param array> $defs + * @return array + */ private static function discriminatorSchema(ClassMeta $meta, array &$defs): array { $classes = $meta->discriminatorMap; @@ -169,6 +186,11 @@ private static function discriminatorSchema(ClassMeta $meta, array &$defs): arra return ['oneOf' => $refs]; } + /** + * @param array $schema + * @param array $rules + * @return array + */ private static function applyRules(array $schema, array $rules): array { $type = $schema['type'] ?? null; @@ -195,6 +217,10 @@ private static function applyRules(array $schema, array $rules): array return $schema; } + /** + * @param array $schema + * @return array + */ private static function applyBound(array $schema, ?string $type, string $bound, float $value): array { $key = match (true) { diff --git a/src/Support/SerializerCompiler.php b/src/Support/SerializerCompiler.php index 72be014..79b5a8b 100644 --- a/src/Support/SerializerCompiler.php +++ b/src/Support/SerializerCompiler.php @@ -21,7 +21,7 @@ final class SerializerCompiler /** * @internal Read directly by BaseData::toArray() for speed — do not mutate. * - * @var array + * @var array> */ public static array $serializers = []; @@ -32,13 +32,13 @@ final class SerializerCompiler * * @internal Read directly by BaseData::toArray() for speed — do not mutate. * - * @var array> + * @var array>> */ public static array $contextualSerializers = []; private const int MAX_CACHED_CONTEXTS = 64; - /** @var array */ + /** @var array> */ private static array $undeclaredContextSerializers = []; /** @param class-string $class */ @@ -54,7 +54,7 @@ public static function compile(string $class, ?string $context = null): Closure $p = $meta->parameters; - /** @var Closure(object): array $fn */ + /** @var Closure(object): array $fn */ $fn = Closure::bind(eval('return '.self::generate($class, $meta).';'), null, $class); return self::$serializers[$class] = $fn; @@ -63,7 +63,7 @@ public static function compile(string $class, ?string $context = null): Closure $p = $meta->parameters; $declared = self::declaresContext($meta, $context); - /** @var Closure(object, string): array $fn */ + /** @var Closure(object, string): array $fn */ $fn = $declared ? Closure::bind(eval('return '.self::generate($class, $meta, $context, true).';'), null, $class) : self::$undeclaredContextSerializers[$class] ??= Closure::bind(eval('return '.self::generate($class, $meta, null, true).';'), null, $class); diff --git a/src/Support/TypeResolver.php b/src/Support/TypeResolver.php index ff50904..cad4667 100644 --- a/src/Support/TypeResolver.php +++ b/src/Support/TypeResolver.php @@ -49,6 +49,7 @@ public static function resolve(ReflectionParameter|ReflectionProperty $parameter return [null, null, false]; } + /** @return array{0: class-string|null, 1: class-string|null} */ private static function classEntry(string $typeName): array { if (is_subclass_of($typeName, DataObject::class)) { diff --git a/src/Support/TypeScriptGenerator.php b/src/Support/TypeScriptGenerator.php index ad99e46..5e508a7 100644 --- a/src/Support/TypeScriptGenerator.php +++ b/src/Support/TypeScriptGenerator.php @@ -49,8 +49,8 @@ private static function interfaceFor(string $class, ClassMeta $meta): string } /** - * @param array $defs - * @return array output name => [schema, isOptional] + * @param array> $defs + * @return array, 1: bool}> output name => [schema, isOptional] */ private static function fields(ClassMeta $meta, array &$defs): array { @@ -86,6 +86,7 @@ private static function typeAlias(string $class, ClassMeta $meta): string return 'export type '.SchemaGenerator::defKey($class).' = '.implode(' | ', $names).";\n\n"; } + /** @param array $schema */ private static function tsType(array $schema): string { $type = $schema['type'] ?? null; diff --git a/src/Support/XmlStream.php b/src/Support/XmlStream.php index 4475cc0..d4050cf 100644 --- a/src/Support/XmlStream.php +++ b/src/Support/XmlStream.php @@ -12,6 +12,8 @@ * element is skipped without being read into memory. * * @internal + * + * @phpstan-type XmlShape array{0: array, 1: array{0: string, 1: int}|null, 2: array, 3: array} */ final class XmlStream { @@ -38,7 +40,7 @@ final class XmlStream * text entry is [input key, scalar type]; a child is [kind, input key, * scalar type or class-string]. * - * @var array, 1: array|null, 2: array, 3: array}> + * @var array */ private static array $shapes = []; @@ -118,6 +120,9 @@ private static function fatalError(): ?\LibXMLError /** * Expects the reader on an element; leaves it either on that same element * or on its end tag, so the caller always advances with next(). + * + * @param XmlShape $shape + * @param array $row */ private static function fill(XMLReader $xml, array $shape, #[\SensitiveParameter] array &$row): void { @@ -192,7 +197,10 @@ private static function coerce(#[\SensitiveParameter] string $value, int $type): }; } - /** @param class-string $class */ + /** + * @param class-string $class + * @return XmlShape + */ private static function shape(string $class): array { $shape = [[], null, [], []]; @@ -201,7 +209,10 @@ private static function shape(string $class): array return self::$shapes[$class] = $shape; } - /** @param class-string $class */ + /** + * @param class-string $class + * @param XmlShape $shape + */ private static function describe(string $class, array &$shape): void { $meta = MetadataRegistry::get($class); diff --git a/src/TypedDataCollection.php b/src/TypedDataCollection.php index 30fbf2e..a915071 100644 --- a/src/TypedDataCollection.php +++ b/src/TypedDataCollection.php @@ -17,6 +17,7 @@ final class TypedDataCollection extends Collection { /** * @param class-string $dataClass + * @param iterable $items * @return static */ public static function of(string $dataClass, #[\SensitiveParameter] iterable $items = []): static diff --git a/tests/DataCollectionCastTest.php b/tests/DataCollectionCastTest.php index 72560e0..413af84 100644 --- a/tests/DataCollectionCastTest.php +++ b/tests/DataCollectionCastTest.php @@ -10,6 +10,7 @@ use StdOut\SimpleDataObjects\Laravel\AsDataCollection; use StdOut\SimpleDataObjects\Laravel\DataCollectionCast; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableItemData; +use StdOut\SimpleDataObjects\Tests\Fixtures\CastableSecretData; use StdOut\SimpleDataObjects\TypedDataCollection; /** @@ -120,4 +121,13 @@ public function test_as_data_collection_cast_using_resolves_to_a_data_collection $this->assertInstanceOf(DataCollectionCast::class, $cast); $this->assertInstanceOf(TypedDataCollection::class, $cast->get($this->model(), 'items', [], [])); } + + public function test_compare_matches_encrypted_fields_by_plaintext(): void + { + $cast = new DataCollectionCast(CastableSecretData::class); + $raw = fn (string $pan): string => $cast->set($this->model(), 'secrets', [new CastableSecretData('visa', $pan)], []); + + $this->assertTrue($cast->compare($this->model(), 'secrets', $raw('4111111111111111'), $raw('4111111111111111'))); + $this->assertFalse($cast->compare($this->model(), 'secrets', $raw('4111111111111111'), $raw('4000000000000002'))); + } } diff --git a/tests/DataObjectCastTest.php b/tests/DataObjectCastTest.php index ebcbb20..c767174 100644 --- a/tests/DataObjectCastTest.php +++ b/tests/DataObjectCastTest.php @@ -10,6 +10,7 @@ use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAccountData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableAddressData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastablePaymentData; +use StdOut\SimpleDataObjects\Tests\Fixtures\CastableSecretData; /** * Direct, isolated tests of the cast class itself — $model is unused by @@ -128,4 +129,13 @@ public function test_compare_rejects_instances_of_different_classes(): void $this->assertFalse($cast->compare($this->model(), 'payment', '{"type":"card","last4":"4242"}', '{"type":"bank","iban":"UA00"}')); } + + public function test_compare_matches_encrypted_fields_by_plaintext(): void + { + $cast = new DataObjectCast(CastableSecretData::class); + $raw = fn (string $pan): string => $cast->set($this->model(), 'secret', new CastableSecretData('visa', $pan), []); + + $this->assertTrue($cast->compare($this->model(), 'secret', $raw('4111111111111111'), $raw('4111111111111111'))); + $this->assertFalse($cast->compare($this->model(), 'secret', $raw('4111111111111111'), $raw('4000000000000002'))); + } } diff --git a/tests/EloquentCastTest.php b/tests/EloquentCastTest.php index 78bc5d5..1295640 100644 --- a/tests/EloquentCastTest.php +++ b/tests/EloquentCastTest.php @@ -15,6 +15,7 @@ use StdOut\SimpleDataObjects\Tests\Fixtures\CastableCardPaymentData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableItemData; use StdOut\SimpleDataObjects\Tests\Fixtures\CastableOrderModel; +use StdOut\SimpleDataObjects\Tests\Fixtures\CastableSecretData; use StdOut\SimpleDataObjects\TypedDataCollection; /** @@ -39,6 +40,8 @@ public static function setUpBeforeClass(): void $table->string('items')->nullable(); $table->string('payment')->nullable(); $table->string('account')->nullable(); + $table->text('secret')->nullable(); + $table->text('secrets')->nullable(); }); self::$capsule = $capsule; @@ -201,6 +204,41 @@ public function test_dirty_check_detects_a_genuine_change(): void $this->assertTrue($model->isDirty('address')); } + public function test_dirty_check_compares_encrypted_fields_by_plaintext(): void + { + $this->skipUnlessDirtyCheckDispatchesToCompare(); + + $model = CastableOrderModel::create([ + 'secret' => new CastableSecretData('visa', '4111111111111111'), + 'secrets' => [new CastableSecretData('visa', '4111111111111111')], + ])->fresh(); + $stored = $model->getRawOriginal('secret'); + + $model->secret = new CastableSecretData('visa', '4111111111111111'); + $model->secrets = CastableSecretData::collection([new CastableSecretData('visa', '4111111111111111')]); + + $this->assertNotSame($stored, $model->getAttributes()['secret']); + $this->assertFalse($model->isDirty(['secret', 'secrets'])); + + $model->save(); + + $this->assertSame($stored, CastableOrderModel::find($model->id)->getRawOriginal('secret')); + } + + public function test_dirty_check_detects_a_changed_encrypted_field(): void + { + $model = CastableOrderModel::create([ + 'secret' => new CastableSecretData('visa', '4111111111111111'), + 'secrets' => [new CastableSecretData('visa', '4111111111111111')], + ])->fresh(); + + $model->secret = new CastableSecretData('visa', '4000000000000002'); + $model->secrets = CastableSecretData::collection([new CastableSecretData('visa', '4000000000000002')]); + + $this->assertTrue($model->isDirty('secret')); + $this->assertTrue($model->isDirty('secrets')); + } + public function test_collection_dirty_check_compares_decoded_values(): void { $this->skipUnlessDirtyCheckDispatchesToCompare(); diff --git a/tests/EqualsDiffTest.php b/tests/EqualsDiffTest.php index b59dd00..6edc728 100644 --- a/tests/EqualsDiffTest.php +++ b/tests/EqualsDiffTest.php @@ -5,8 +5,12 @@ namespace StdOut\SimpleDataObjects\Tests; use PHPUnit\Framework\TestCase; +use StdOut\SimpleDataObjects\Casts\EncryptedCast; use StdOut\SimpleDataObjects\Tests\Fixtures\EventData; +use StdOut\SimpleDataObjects\Tests\Fixtures\KeyringCardData; +use StdOut\SimpleDataObjects\Tests\Fixtures\SecretData; use StdOut\SimpleDataObjects\Tests\Fixtures\UserData; +use StdOut\SimpleDataObjects\Tests\Fixtures\UserDataSubclass; class EqualsDiffTest extends TestCase { @@ -114,4 +118,56 @@ public function test_equals_returns_false_for_different_classes(): void $this->assertFalse($user->equals($event)); } + + public function test_equals_compares_encrypted_fields_by_plaintext(): void + { + $a = new SecretData('abc'); + + $this->assertNotSame($a->toArray(), new SecretData('abc')->toArray()); + $this->assertTrue($a->equals(new SecretData('abc'))); + $this->assertFalse($a->equals(new SecretData('abd'))); + } + + public function test_diff_ignores_reencryption_and_reports_ciphertext_of_changed_fields(): void + { + $a = new KeyringCardData('4111111111111111', 'ALICE'); + $b = new KeyringCardData('4111111111111111', 'BOB'); + + $diff = $a->diff($b); + + $this->assertSame(['holder'], array_keys($diff)); + $this->assertStringStartsWith('v2:', $diff['holder'][0]); + $this->assertStringStartsWith('v2:', $diff['holder'][1]); + $this->assertSame([], $a->diff(new KeyringCardData('4111111111111111', 'ALICE'))); + } + + public function test_plaintext_comparison_mode_is_scoped_and_restored(): void + { + $cast = new EncryptedCast('test-encryption-key'); + + $inner = EncryptedCast::comparingPlaintext(static function () use ($cast): array { + $nested = EncryptedCast::comparingPlaintext(static fn (): ?string => $cast->set('x')); + + return [$nested, $cast->set('y')]; + }); + + $this->assertSame(['x', 'y'], $inner); + $this->assertNotSame('z', $cast->set('z')); + + try { + EncryptedCast::comparingPlaintext(static fn () => throw new \RuntimeException('boom')); + } catch (\RuntimeException) { + } + + $this->assertNotSame('z', $cast->set('z')); + } + + public function test_equals_returns_false_for_different_classes_with_identical_output(): void + { + $user = UserData::from(['name' => 'Alice', 'email' => 'alice@example.com']); + $admin = new UserDataSubclass('Alice', 'alice@example.com'); + + $this->assertSame($user->toArray(), $admin->toArray()); + $this->assertFalse($user->equals($admin)); + } } diff --git a/tests/Fixtures/CastableOrderModel.php b/tests/Fixtures/CastableOrderModel.php index 83ea32f..6bf9254 100644 --- a/tests/Fixtures/CastableOrderModel.php +++ b/tests/Fixtures/CastableOrderModel.php @@ -18,5 +18,7 @@ class CastableOrderModel extends Model 'items' => AsDataCollection::class.':'.CastableItemData::class, 'payment' => CastablePaymentData::class, 'account' => CastableAccountData::class.':storage', + 'secret' => CastableSecretData::class, + 'secrets' => AsDataCollection::class.':'.CastableSecretData::class, ]; } diff --git a/tests/Fixtures/CastableSecretData.php b/tests/Fixtures/CastableSecretData.php new file mode 100644 index 0000000..669aff9 --- /dev/null +++ b/tests/Fixtures/CastableSecretData.php @@ -0,0 +1,22 @@ +assertFalse($result->ok()); } + + public function test_required_flatten_validates_the_parents_own_keys(): void + { + $rules = MetadataRegistry::get(InferredFlattenData::class)->validationRules; + + $this->assertArrayNotHasKey('address', $rules); + $this->assertSame(['required', 'string'], $rules['street']); + $this->assertSame(['required', 'string', 'max:5'], $rules['zip']); + + $data = InferredFlattenData::fromValidated(['name' => 'Alice', 'street' => 'Main', 'zip' => '01001']); + + $this->assertSame('01001', $data->address->zip); + } + + public function test_required_flatten_rejects_invalid_nested_keys(): void + { + try { + InferredFlattenData::fromValidated(['name' => 'Alice', 'zip' => '0100100']); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(['street', 'zip'], array_keys($e->errors())); + } + } + + public function test_nullable_flatten_keeps_prefixed_rules(): void + { + $rules = MetadataRegistry::get(InferredNullableFlattenData::class)->validationRules; + + $this->assertSame(['nullable', 'array'], $rules['address']); + $this->assertSame(['required', 'string', 'max:5'], $rules['address.zip']); + $this->assertArrayNotHasKey('zip', $rules); + } + + public function test_required_flatten_keeps_explicitly_merged_rules_for_itself(): void + { + $rules = MetadataRegistry::get(InferredFlattenMergeData::class)->validationRules; + + $this->assertSame(['array'], $rules['address']); + $this->assertSame(['required', 'string'], $rules['street']); + } } diff --git a/tests/PrimitiveCastsTest.php b/tests/PrimitiveCastsTest.php index 450b5c9..a55f926 100644 --- a/tests/PrimitiveCastsTest.php +++ b/tests/PrimitiveCastsTest.php @@ -581,4 +581,33 @@ public function test_comma_separated_cast_rejects_a_non_positive_max_items(): vo new CommaSeparatedCast(maxItems: 0); } + + public function test_float_cast_accepts_non_finite_values_by_default(): void + { + $this->assertSame(INF, (new FloatCast)->get(INF)); + $this->assertNan((new FloatCast)->get(NAN)); + } + + public function test_float_cast_finite_rejects_inf_and_nan(): void + { + $cast = new FloatCast(finite: true); + + $this->assertSame(1.5, $cast->get('1.5')); + $this->assertNull($cast->get(null)); + + foreach ([INF, -INF, NAN, '1e400'] as $value) { + try { + $cast->get($value); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + $this->assertSame('FloatCast(finite: true) rejects INF and NAN.', $e->getMessage()); + } + } + } + + public function test_float_cast_set_state_keeps_finite_and_defaults_it_for_old_caches(): void + { + $this->assertEquals(new FloatCast(2, true), FloatCast::__set_state(['decimals' => 2, 'finite' => true])); + $this->assertEquals(new FloatCast(2), FloatCast::__set_state(['decimals' => 2])); + } }