From 202a13454d01376125b3f2fe5ae375162cb615a5 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 27 May 2026 15:43:01 +0200 Subject: [PATCH] Migrate Iso/Lens to STAB form (Iso) Ports Iso and Lens from the two-template form to the four-template profunctor STAB form (Iso / Lens): - Concrete Iso/Lens invariant on all four slots (compose boundary detection via standard Psalm inference; AdjacentTemplateValidator removed). - IsoInterface/LensInterface covariant on all four slots for storage flexibility. - Type-changing API: Lens::set(S,B):T, Iso::from(B):T, Lens::update(S, callable(A):B):T. - DynamicFunctionStorage compose() providers emit 2N+2 templates with STAB pairing, guarded against zero-arg calls. - compose() $lenses/$isos param is array (not non-empty-array): compose is a monoid, so empty -> identity and single -> passthrough are valid results. - New docs/stab-optics.md walkthrough; unit + SA tests including type-changing composition and single/empty composition. --- README.md | 4 + docs/isomorphisms.md | 4 + docs/lens.md | 4 + docs/stab-optics.md | 433 ++++++++++++++++++ src/Iso/Iso.php | 53 +-- src/Iso/IsoInterface.php | 24 +- src/Iso/compose.php | 8 +- src/Iso/object_data.php | 6 +- src/Lens/Lens.php | 61 +-- src/Lens/LensInterface.php | 30 +- src/Lens/compose.php | 8 +- src/Lens/index.php | 4 +- src/Lens/optional.php | 14 +- src/Lens/properties.php | 11 +- src/Lens/property.php | 2 +- src/Lens/read_only.php | 6 +- .../Compose/AdjacentTemplateValidator.php | 114 ----- src/Psalm/Iso/Provider/ComposeProvider.php | 97 ++-- src/Psalm/Lens/Provider/ComposeProvider.php | 97 ++-- tests/static-analyzer/Iso/compose.php | 44 +- .../Iso/compose_type_changing.php | 28 ++ tests/static-analyzer/Lens/compose.php | 44 +- .../Lens/compose_type_changing.php | 28 ++ tests/unit/Iso/ComposeTest.php | 23 + tests/unit/Iso/IsoTest.php | 39 ++ tests/unit/Lens/ComposeTest.php | 20 + tests/unit/Lens/LensTest.php | 40 ++ 27 files changed, 902 insertions(+), 344 deletions(-) create mode 100644 docs/stab-optics.md delete mode 100644 src/Psalm/Compose/AdjacentTemplateValidator.php create mode 100644 tests/static-analyzer/Iso/compose_type_changing.php create mode 100644 tests/static-analyzer/Lens/compose_type_changing.php diff --git a/README.md b/README.md index 46bd64a..a5121f3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ This package provides following components: * [Lens](/docs/lens.md): Separate your data from it's structure * [Reflect](/docs/reflect.md): Helps you read from and write to objects in a runtime-safe context. +> Finding your way around optics? The +> [🧬 STAB optics walkthrough](/docs/stab-optics.md) maps out the +> `S`, `T`, `A`, `B` type parameters with worked examples. + ## Inspiration diff --git a/docs/isomorphisms.md b/docs/isomorphisms.md index c1bdb00..92cc9ec 100644 --- a/docs/isomorphisms.md +++ b/docs/isomorphisms.md @@ -1,5 +1,9 @@ # 🔄 Isomorphic Magic +> **Finding your way around?** Get to grips with the dynamics of `Iso`'s +> `S`, `T`, `A`, `B` type parameters in the short walkthrough at +> [**🧬 STAB optics — the four-letter dance**](./stab-optics.md). + Seamlessly navigate between different data representations with our Isomorphisms. Experience the enchantment of transforming your data effortlessly, as if conducting a symphony of bits and bytes. diff --git a/docs/lens.md b/docs/lens.md index 8963162..cb27f2b 100644 --- a/docs/lens.md +++ b/docs/lens.md @@ -1,5 +1,9 @@ # 🔍 Lenses of Clarity +> **Finding your way around?** Get to grips with the dynamics of `Lens`'s +> `S`, `T`, `A`, `B` type parameters in the short walkthrough at +> [**🧬 STAB optics — the four-letter dance**](./stab-optics.md). + Focus on what matters most. Our lenses provide a crystal-clear view, allowing you to zero in on specific data points without the distraction of unnecessary details. Precision meets simplicity in every line of code. diff --git a/docs/stab-optics.md b/docs/stab-optics.md new file mode 100644 index 0000000..9247d49 --- /dev/null +++ b/docs/stab-optics.md @@ -0,0 +1,433 @@ +# 🧬 STAB optics — the four-letter dance + +If you've poked at the type signatures of `Iso` or `Lens` and thought +*"why on earth are there four type parameters?"*, this page is for you. + +You don't need any prior optics or category-theory background. +We'll start from a concrete problem, see why two type parameters aren't +enough, and arrive at the four-letter shape `Iso` / +`Lens` (called **STAB**, pronounced like the word). + +By the end you'll know: + +* what each of `S`, `T`, `A`, `B` means +* when you actually need all four (and when you can just repeat two) +* how `compose` lines up the four slots +* a mnemonic to keep it all straight + +--- + +## The problem + +Suppose you have a `Person`: + +```php +class Person { + public string $name; +} +``` + +You want a lens that lets you read and write the person's `name`. +That's the bread-and-butter Lens, and you write it like this: + +```php +use VeeWee\Reflecta\Lens\Lens; + +$nameLens = new Lens( + get: fn (Person $p): string => $p->name, + set: function (Person $p, string $newName): Person { + $new = clone $p; + $new->name = $newName; + return $new; + }, +); + +$alice = new Person(); $alice->name = 'Alice'; +$alice2 = $nameLens->set($alice, 'Alice the Great'); +// > Person { name: "Alice the Great" } +``` + +So far so good. In terms of *types*, this lens lives between +**Person** (the whole thing) and **string** (the focused detail). +You might naturally write that as `Lens`: two type +parameters, "the whole" and "the focus". That's how plenty of optics +tutorials introduce it, and for this example it works fine. + +So why does Reflecta's `Lens` take **four** type parameters? + +--- + +## A slightly trickier example + +Let's anonymize people. The rule: take a `Person`, replace their name +with a `HashedName`, and produce an `AnonymizedPerson`. + +```php +final class HashedName { + public function __construct(public string $hash) {} +} + +final class AnonymizedPerson { + public function __construct(public HashedName $name) {} +} +``` + +We want a lens that, when you `set` a `HashedName` on a `Person`, +hands you back an `AnonymizedPerson` instead. + +In other words, *writing through the lens changes what type the +whole thing is*. + +```php +$anonymizingLens = new Lens( + get: fn (Person $p): string => $p->name, + set: fn (Person $p, HashedName $hashed): AnonymizedPerson + => new AnonymizedPerson($hashed), +); + +$alice = new Person(); $alice->name = 'Alice'; +$hashed = new HashedName(sha1('Alice')); +$anonymousAlice = $anonymizingLens->set($alice, $hashed); +// > AnonymizedPerson { name: HashedName { hash: "..." } } +``` + +Now look at the four types floating around: + +| Role | Type | +|---|---| +| The thing we read **from** | `Person` | +| The thing we get **out** when reading | `string` (the original name) | +| The thing we supply when writing | `HashedName` | +| The thing we produce when writing | `AnonymizedPerson` | + +Four roles. Two type parameters can't capture this; they can only +express "the whole" and "the focus", treating both directions the same. + +So: four parameters it is. + +--- + +## The four letters + +Reflecta names them `S`, `T`, `A`, `B`, following the convention from +Haskell's `lens` library. Pretty much every other optics library you'll +find uses the same names. + +| Slot | Stands for | Role | +|---|---|---| +| `S` | **S**ource (input) | The type you read **from** | +| `T` | **T**arget (output) | The type you produce when you write **back** | +| `A` | **A**tom (output) | The focused value you extract from `S` | +| `B` | **B**uild (input) | The focused value you supply to produce `T` | + +So the anonymizing lens above has type: + +``` +Lens +// ─ S ─ ──── T ──── ─ A ─ ─── B ─── +``` + +Read it as: *"a lens between (Person, AnonymizedPerson) and (string, HashedName)"*. + +The method signatures fall out naturally: + +```php +// Lens +$lens->get(S $s): A // read from S, get an A out +$lens->set(S $s, B $b): T // read from S, write a B, get T back +``` + +The `Iso` shape is the same idea, just bidirectional: + +```php +// Iso +$iso->to(S $s): A // forward +$iso->from(B $b): T // backward +``` + +--- + +## The 95% case: when S = T and A = B + +For the boring "I just want to read and write the name on a Person" +lens from the very first example, there's no type change. The whole +is always a `Person`. The focus is always a `string`. So: + +* `S = Person`, `T = Person` (you read a Person, you write back a Person) +* `A = string`, `B = string` (the focus is a string both ways) + +You write it like this: + +```php +/** @var Lens $nameLens */ +$nameLens = new Lens( + get: fn (Person $p): string => $p->name, + set: function (Person $p, string $newName): Person { + $new = clone $p; + $new->name = $newName; + return $new; + }, +); +``` + +Yes, you're repeating yourself. That's fine. Other optics libraries +(Monocle in Scala, Arrow in Kotlin, monocle-ts in TypeScript, +LanguageExt in C#) all do it the same way: one `Lens` shape with four +slots, and you repeat the same type twice when you don't need to change +types. + +The mental shortcut: **"when in doubt, repeat the two types."** That +covers the 95% case with no head-scratching. + +> **Heads up:** Psalm doesn't yet support template default values, so +> you can't write `Lens` and have it expand to +> `Lens` automatically. If/when Psalm +> adds that, we'll surface it here. + +--- + +## When you actually want all four + +Whenever writing through the optic *changes the type of the whole*, +you need the full STAB shape. A few real-world cases: + +* **Anonymization / pseudonymization.** Replace a `string $name` with + a `HashedName` and end up with an `AnonymizedPerson`. +* **Unit conversions where the wrapper changes.** Replace a `Meters` + inside a `MeasurementInMeters` and produce a `MeasurementInFeet`. +* **Phase changes in a builder.** Replace a `Draft` payload inside an + `Order` and produce an `Order`. + +```php +// Lens that pseudonymizes the focus and the wrapper at the same time +/** @var Lens $anonymize */ +$anonymize = new Lens( + get: fn (Person $p): string => $p->name, + set: fn (Person $p, HashedName $hashed): AnonymizedPerson + => new AnonymizedPerson($hashed), +); + +$alice = new Person(); $alice->name = 'Alice'; +$hashed = new HashedName('…'); +$anonymousAlice = $anonymize->set($alice, $hashed); +// > AnonymizedPerson { name: HashedName { hash: "…" } } +``` + +If you only ever use the lens for `get`, the type-changing slots +(`T` and `B`) are dormant. They sit there equal to whatever makes the +writer happy. + +--- + +## Compose: the four slots line up + +The `compose` operation chains two optics together. With STAB it +expresses what people actually mean: the **focus** of the first optic +must match the **whole** of the second. + +``` + Lens ∘ Lens + ───────────────── ───────────────── + reads S, focuses A reads A, focuses C + writes B back, produces T writes D back, produces B + + ⇩ compose + + Lens + ───────────────── + reads S, focuses C + writes D back, produces T +``` + +The inner pair `(A, B)` of the first lens is exactly the outer pair +`(S, T)` of the second. The composed lens keeps the outer pair of the +first and the inner pair of the second. + +Concrete monomorphic example: + +```php +use function VeeWee\Reflecta\Lens\compose; +use function VeeWee\Reflecta\Lens\property; + +class Person { public Hat $hat; } +class Hat { public string $color; } + +/** @var Lens $hatLens */ +$hatLens = property('hat'); + +/** @var Lens $colorLens */ +$colorLens = property('color'); + +/** @var Lens $hatColorLens */ +$hatColorLens = compose($hatLens, $colorLens); + +$person = new Person(); +$person->hat = new Hat(); +$person->hat->color = 'green'; + +$hatColorLens->get($person); // > "green" +$hatColorLens->set($person, 'red'); // > Person { hat: { color: "red" } } +``` + +Because every slot is invariant, Psalm will flag a real mistake at the +compose boundary. For example, if `colorLens` focused on `int` but you +tried to compose it after a `hatLens` that focuses on `Hat`, the +`(A, B)` of one and `(S, T)` of the other wouldn't line up and you'd +get an `InvalidArgument` from Psalm. No plugin involvement, no runtime +check, just standard type inference doing its job. + +--- + +## A few derived shapes worth knowing + +A handful of helpers in the library collapse or swap the STAB slots in +specific ways. You don't have to memorize these; they make sense once +the four-slot model has clicked. + +#### `Iso::inverse()` + +Flipping an iso swaps the forward and backward directions. The +`(S, T)` pair and the `(A, B)` pair trade places, and inside each pair +the two slots also swap: + +``` +Iso::inverse(): Iso +``` + +If `$base64` is an `Iso`, +then `$base64->inverse()` is an +`Iso`. Same data, going +the other way. + +#### `Lens::readonly(…)` / `read_only(…)` + +A read-only lens can't be written through, so it can't change types +either. Both `T = S` and `B = A` are forced: + +``` +read_only(LensInterface): Lens +``` + +#### `optional(…)` / `Lens::optional()` + +Wrapping a lens with `optional` says "anything in the pipeline might be +missing". Every slot becomes nullable, so the wrapper can short-circuit +to `null` on either direction: + +``` +optional(LensInterface): Lens +``` + +--- + +## Cheat sheet + +Stick this on a sticky note: + +``` +S = Source — what you read FROM +T = Target — what you write OUT to +A = Atom — what you read OUT as the focus +B = Build — what you write IN as the focus +``` + +Decision tree: + +``` +Need to write a lens / iso, and … + +Q: does writing through it change the outer type? + ├── no → just repeat both types: Lens + └── yes → use all four: Lens + with T ≠ S and/or B ≠ A +``` + +--- + +## Footnote: a note on variance + +> You can skip this section. It only matters if you already know what +> "covariance" / "contravariance" mean from other type systems (Scala, +> TypeScript, C#) and were wondering how Reflecta declares its slots. + +Reflecta splits the choice across two layers. + +* **Concrete `Iso` and `Lens` are invariant on + all four slots.** That's where the type safety lives: `new Iso(...)`, + `$iso->compose($other)` between concrete optics, and the SA tests + for compose boundary checking all rely on invariant unification. If + you build optics and chain them, you get the full STAB type check. + +* **`IsoInterface` and `LensInterface` are + covariant on all four slots.** That's where the flexibility lives: + an `Iso` fits in a slot typed + `IsoInterface`, so you can keep + heterogeneous optics in a single collection without losing the leaf + types. + +C# does the same split: `List` is invariant; `IEnumerable` +is covariant. You pick which side of that trade by which type you +write in your signatures. + +### When you need the interface covariance + +Concrete example: you want to keep different-shaped optics in a single +map, looked up by name. + +```php +/** @var array> $isos */ +$isos = []; + +/** @var Iso $nameIso */ +$nameIso = new Iso(/* … */); + +/** @var Iso $countIso */ +$countIso = new Iso(/* … */); + +$isos['name'] = $nameIso; // OK: covariance widens to IsoInterface +$isos['count'] = $countIso; // OK +``` + +Without the interface covariance, those storage lines wouldn't +type-check: the invariant slots refuse to widen `Iso` to +`Iso`. You'd be pushed to type everything as `mixed` from +the leaves up, and the precise types on `$nameIso` and `$countIso` +would be unusable in a shared collection. + +### The cost: opt-in unsoundness + +Interface covariance is unsound on input slots (`S` and `B`). What +breaks at runtime: + +```php +/** @var IsoInterface $animalIso */ +$animalIso = $someDogIso; // widens via covariance, Psalm allows it + +$animalIso->to(new Cat()); // BOOM at runtime: the underlying iso wanted a Dog +``` + +Psalm won't catch that. The runtime will. + +So: code that wants the strict guarantee writes concrete +`Iso` / `Lens` in its signatures. Code that +needs storage flexibility writes the interface. **You opt in to the +unsoundness by typing against the interface.** + +In practice, the registries this covariance exists for don't exercise +the unsoundness anyway. They store concrete optics, look them up by +key, and deal in `mixed` at the retrieval boundary. The widening is +real; the runtime explosion only happens if a caller claims a +narrower type than the underlying optic actually supports. + +--- + +## Where to go next + +* [🔍 Lenses of Clarity](./lens.md): practical Lens guide, with + constructors like `property`, `index`, `optional`, etc. +* [🔄 Isomorphic Magic](./isomorphisms.md): practical Iso guide, + including `object_data` and `compose`. + +If anything on this page is unclear, please [open an issue](https://github.com/veewee/reflecta/issues). +The goal here is "explained well enough that the next reader doesn't +need to read it twice". diff --git a/src/Iso/Iso.php b/src/Iso/Iso.php index 4b68401..d8ef338 100644 --- a/src/Iso/Iso.php +++ b/src/Iso/Iso.php @@ -9,24 +9,26 @@ use function Psl\Result\wrap; /** - * @template-covariant S - * @template-covariant A + * @template S + * @template T + * @template A + * @template B * * @psalm-immutable * @psalm-suppress ImpureFunctionCall - * @implements IsoInterface + * @implements IsoInterface */ final class Iso implements IsoInterface { /** @var callable(S): A */ private $to; - /** @var callable(A): S */ + /** @var callable(B): T */ private $from; /** * @param callable(S): A $to - * @param callable(A): S $from + * @param callable(B): T $from */ public function __construct(callable $to, callable $from) { @@ -37,7 +39,7 @@ public function __construct(callable $to, callable $from) /** * @pure * @template I - * @return Iso + * @return Iso */ public static function identity(): self { @@ -74,25 +76,25 @@ public function tryTo($s): ResultInterface } /** - * @param A $a - * @return S + * @param B $b + * @return T */ - public function from($a) + public function from($b) { - return ($this->from)($a); + return ($this->from)($b); } /** - * @param A $a - * @return ResultInterface + * @param B $b + * @return ResultInterface */ - public function tryFrom($a): ResultInterface + public function tryFrom($b): ResultInterface { - return wrap(fn () => ($this->from)($a)); + return wrap(fn () => ($this->from)($b)); } /** - * @return Lens + * @return Lens */ public function asLens(): LensInterface { @@ -100,15 +102,15 @@ public function asLens(): LensInterface $this->to, /** * @param S $_ - * @param A $a - * @return S + * @param B $b + * @return T */ - fn ($_, $a) => $this->from($a) + fn ($_, $b) => $this->from($b) ); } /** - * @return Iso + * @return Iso */ public function inverse(): self { @@ -116,14 +118,13 @@ public function inverse(): self } /** - * @template S2 * @template A2 - * @param IsoInterface $that - * @return Iso + * @template B2 + * @param IsoInterface $that + * @return Iso */ public function compose(IsoInterface $that): IsoInterface { - /** @psalm-suppress InvalidArgument */ return new self( /** * @param S $s @@ -131,10 +132,10 @@ public function compose(IsoInterface $that): IsoInterface */ fn ($s) => $that->to($this->to($s)), /** - * @param A2 $a2 - * @return S + * @param B2 $b2 + * @return T */ - fn ($a2) => $this->from($that->from($a2)) + fn ($b2) => $this->from($that->from($b2)) ); } } diff --git a/src/Iso/IsoInterface.php b/src/Iso/IsoInterface.php index 0a9c5ae..e040ece 100644 --- a/src/Iso/IsoInterface.php +++ b/src/Iso/IsoInterface.php @@ -7,7 +7,9 @@ /** * @template-covariant S + * @template-covariant T * @template-covariant A + * @template-covariant B * * @psalm-immutable */ @@ -26,32 +28,32 @@ public function to($s); public function tryTo($s): ResultInterface; /** - * @param A $a - * @return S + * @param B $b + * @return T */ - public function from($a); + public function from($b); /** - * @param A $a - * @return ResultInterface + * @param B $b + * @return ResultInterface */ - public function tryFrom($a): ResultInterface; + public function tryFrom($b): ResultInterface; /** - * @return LensInterface + * @return LensInterface */ public function asLens(): LensInterface; /** - * @return IsoInterface + * @return IsoInterface */ public function inverse(): self; /** - * @template S2 * @template A2 - * @param IsoInterface $that - * @return IsoInterface + * @template B2 + * @param IsoInterface $that + * @return IsoInterface */ public function compose(self $that): self; } diff --git a/src/Iso/compose.php b/src/Iso/compose.php index c0811bf..b8b00e4 100644 --- a/src/Iso/compose.php +++ b/src/Iso/compose.php @@ -8,18 +8,20 @@ * @no-named-arguments * * @template S + * @template T * @template A + * @template B * - * @param non-empty-array> $isos + * @param array> $isos * - * @return IsoInterface + * @return IsoInterface * * @psalm-pure * @psalm-suppress ImpureFunctionCall */ function compose(IsoInterface ... $isos): IsoInterface { - /** @var IsoInterface */ + /** @var IsoInterface */ return reduce( $isos, static fn (IsoInterface $current, IsoInterface $next) => $current->compose($next), diff --git a/src/Iso/object_data.php b/src/Iso/object_data.php index e0a951a..a7c3323 100644 --- a/src/Iso/object_data.php +++ b/src/Iso/object_data.php @@ -11,15 +11,15 @@ * @template A of array * * @param class-string $className - * @param null|Lens $accessor + * @param null|Lens $accessor * - * @return Iso + * @return Iso * * @psalm-pure */ function object_data(string $className, ?Lens $accessor = null): Iso { - /** @var Lens $typedAccessor */ + /** @var Lens $typedAccessor */ $typedAccessor = $accessor ?? properties(); return new Iso( diff --git a/src/Lens/Lens.php b/src/Lens/Lens.php index 4e41ee0..b070c5e 100644 --- a/src/Lens/Lens.php +++ b/src/Lens/Lens.php @@ -8,12 +8,14 @@ use function Psl\Result\wrap; /** - * @template-covariant S - * @template-covariant A + * @template S + * @template T + * @template A + * @template B * * @psalm-immutable * @psalm-suppress ImpureFunctionCall - * @implements LensInterface + * @implements LensInterface */ final class Lens implements LensInterface { @@ -23,13 +25,13 @@ final class Lens implements LensInterface private $get; /** - * @var callable(S, A): S + * @var callable(S, B): T */ private $set; /** * @param callable(S): A $get - * @param callable(S, A): S $set + * @param callable(S, B): T $set */ public function __construct(callable $get, callable $set) { @@ -39,10 +41,10 @@ public function __construct(callable $get, callable $set) /** * @pure - * @template RS - * @template RA - * @param callable(RS): RA $get - * @return Lens + * @template S2 + * @template A2 + * @param callable(S2): A2 $get + * @return Lens */ public static function readonly(callable $get): self { @@ -52,7 +54,7 @@ public static function readonly(callable $get): self /** * @pure * @template I - * @return Lens + * @return Lens */ public static function identity(): self { @@ -91,28 +93,28 @@ public function tryGet($s): ResultInterface /** * @param S $s - * @param A $a - * @return S + * @param B $b + * @return T */ - public function set($s, $a) + public function set($s, $b) { - return ($this->set)($s, $a); + return ($this->set)($s, $b); } /** * @param S $s - * @param A $a - * @return ResultInterface + * @param B $b + * @return ResultInterface */ - public function trySet($s, $a): ResultInterface + public function trySet($s, $b): ResultInterface { - return wrap(fn () => ($this->set)($s, $a)); + return wrap(fn () => ($this->set)($s, $b)); } /** * @param S $s - * @param callable(A): A $f - * @return S + * @param callable(A): B $f + * @return T */ public function update($s, callable $f) { @@ -121,8 +123,8 @@ public function update($s, callable $f) /** * @param S $s - * @param callable(A): A $f - * @return ResultInterface + * @param callable(A): B $f + * @return ResultInterface */ public function tryUpdate($s, callable $f): ResultInterface { @@ -130,7 +132,7 @@ public function tryUpdate($s, callable $f): ResultInterface } /** - * @return LensInterface + * @return LensInterface */ public function optional(): LensInterface { @@ -138,14 +140,13 @@ public function optional(): LensInterface } /** - * @template S2 * @template A2 - * @param LensInterface $that - * @return LensInterface + * @template B2 + * @param LensInterface $that + * @return LensInterface */ public function compose(LensInterface $that): LensInterface { - /** @psalm-suppress InvalidArgument */ return new self( /** * @param S $s @@ -154,10 +155,10 @@ public function compose(LensInterface $that): LensInterface fn ($s) => $that->get(($this->get)($s)), /** * @param S $s - * @param A2 $a2 - * @return S + * @param B2 $b2 + * @return T */ - fn ($s, $a2) => $this->set($s, $that->set($this->get($s), $a2)) + fn ($s, $b2) => $this->set($s, $that->set($this->get($s), $b2)) ); } } diff --git a/src/Lens/LensInterface.php b/src/Lens/LensInterface.php index 1610694..7a84776 100644 --- a/src/Lens/LensInterface.php +++ b/src/Lens/LensInterface.php @@ -6,7 +6,9 @@ /** * @template-covariant S + * @template-covariant T * @template-covariant A + * @template-covariant B * * @psalm-immutable */ @@ -26,42 +28,42 @@ public function tryGet($s): ResultInterface; /** * @param S $s - * @param A $a - * @return S + * @param B $b + * @return T */ - public function set($s, $a); + public function set($s, $b); /** * @param S $s - * @param A $a - * @return ResultInterface + * @param B $b + * @return ResultInterface */ - public function trySet($s, $a): ResultInterface; + public function trySet($s, $b): ResultInterface; /** * @param S $s - * @param callable(A): A $f - * @return S + * @param callable(A): B $f + * @return T */ public function update($s, callable $f); /** * @param S $s - * @param callable(A): A $f - * @return ResultInterface + * @param callable(A): B $f + * @return ResultInterface */ public function tryUpdate($s, callable $f): ResultInterface; /** - * @return LensInterface + * @return LensInterface */ public function optional(): LensInterface; /** - * @template S2 * @template A2 - * @param LensInterface $that - * @return LensInterface + * @template B2 + * @param LensInterface $that + * @return LensInterface */ public function compose(LensInterface $that): LensInterface; } diff --git a/src/Lens/compose.php b/src/Lens/compose.php index ae43c0b..553bec6 100644 --- a/src/Lens/compose.php +++ b/src/Lens/compose.php @@ -8,18 +8,20 @@ * @no-named-arguments * * @template S + * @template T * @template A + * @template B * - * @param non-empty-array> $lenses + * @param array> $lenses * - * @return LensInterface + * @return LensInterface * * @psalm-pure * @psalm-suppress ImpureFunctionCall */ function compose(LensInterface ... $lenses): LensInterface { - /** @var LensInterface */ + /** @var LensInterface */ return reduce( $lenses, static fn (LensInterface $current, LensInterface $next) => $current->compose($next), diff --git a/src/Lens/index.php b/src/Lens/index.php index 3a22a53..b3b5ba1 100644 --- a/src/Lens/index.php +++ b/src/Lens/index.php @@ -7,12 +7,12 @@ /** * @param array-key $index - * @return Lens + * @return Lens * @psalm-pure */ function index($index): Lens { - /** @return Lens */ + /** @return Lens */ return new Lens( static fn (array $subject): mixed => index_get($subject, $index), static fn (array $subject, mixed $value): array => index_set($subject, $index, $value), diff --git a/src/Lens/optional.php b/src/Lens/optional.php index e004336..45f5a77 100644 --- a/src/Lens/optional.php +++ b/src/Lens/optional.php @@ -4,11 +4,13 @@ /** * @template S + * @template T * @template A + * @template B * - * @param LensInterface $that + * @param LensInterface $that * - * @return Lens + * @return Lens * * @psalm-pure */ @@ -32,13 +34,13 @@ function optional(LensInterface $that): Lens ), /** * @param S|null $subject - * @param A $value - * @return S|null + * @param B|null $value + * @return T|null */ static fn ($subject, $value) => $that->trySet($subject, $value)->proceed( /** - * @param S $s - * @return S + * @param T $s + * @return T */ static fn ($s) => $s, /** diff --git a/src/Lens/properties.php b/src/Lens/properties.php index 37f6f0b..d1182ad 100644 --- a/src/Lens/properties.php +++ b/src/Lens/properties.php @@ -13,20 +13,21 @@ * * @param null|Closure(ReflectedProperty): bool $predicate * - * @return Lens + * @return Lens * @psalm-pure */ function properties(Closure|null $predicate = null): Lens { - /** @var Lens */ + /** @var Lens */ return new Lens( /** * @param S $subject * @return A - * - * @psalm-suppress InvalidReturnType, InvalidReturnStatement */ - static fn (object $subject): array => properties_get($subject, $predicate), + static function (object $subject) use ($predicate): array { + /** @var A */ + return properties_get($subject, $predicate); + }, /** * @param S $subject * @param A $value diff --git a/src/Lens/property.php b/src/Lens/property.php index 74693b4..ef43736 100644 --- a/src/Lens/property.php +++ b/src/Lens/property.php @@ -7,7 +7,7 @@ /** * @template S of object - * @return Lens + * @return Lens * @psalm-pure */ function property(string $propertyName): Lens diff --git a/src/Lens/read_only.php b/src/Lens/read_only.php index 5227b80..0cc5a8e 100644 --- a/src/Lens/read_only.php +++ b/src/Lens/read_only.php @@ -4,11 +4,13 @@ /** * @template S + * @template T * @template A + * @template B * - * @param LensInterface $that + * @param LensInterface $that * - * @return Lens + * @return Lens * * @psalm-pure */ diff --git a/src/Psalm/Compose/AdjacentTemplateValidator.php b/src/Psalm/Compose/AdjacentTemplateValidator.php deleted file mode 100644 index c2a57fd..0000000 --- a/src/Psalm/Compose/AdjacentTemplateValidator.php +++ /dev/null @@ -1,114 +0,0 @@ -, Iso, Iso). - * - * This validator walks the adjacent arg pairs and emits InvalidArgument - * when the right template of arg[i] is not equivalent to the left template - * of arg[i+1]. - */ -final class AdjacentTemplateValidator -{ - /** - * @param class-string $genericClass Iso::class or Lens::class - * @param non-empty-string $functionId - */ - public static function validate( - FunctionReturnTypeProviderEvent $event, - string $genericClass, - string $functionId, - ): void { - $args = $event->getCallArgs(); - if (count($args) < 2) { - return; - } - - $source = $event->getStatementsSource(); - $nodeTypes = $source->getNodeTypeProvider(); - $codebase = $source->getCodebase(); - $suppressed = $source->getSuppressedIssues(); - - $genericClassLc = strtolower($genericClass); - - $previousRight = null; - $previousIndex = 0; - foreach ($args as $index => $arg) { - $argType = $nodeTypes->getType($arg->value); - if ($argType === null) { - $previousRight = null; - continue; - } - - $generic = self::extractGeneric($argType, $genericClassLc); - if ($generic === null) { - $previousRight = null; - continue; - } - - [$left, $right] = $generic; - - if ($previousRight !== null) { - $forward = UnionTypeComparator::isContainedBy($codebase, $previousRight, $left); - $backward = UnionTypeComparator::isContainedBy($codebase, $left, $previousRight); - - if (!$forward || !$backward) { - IssueBuffer::maybeAdd( - new InvalidArgument( - 'Argument ' . ($index + 1) . ' of ' . $functionId - . ' expects ' . $genericClass . '<' . $previousRight->getId() . ', ...>,' - . ' ' . $genericClass . '<' . $left->getId() . ', ...> provided' - . ' (compose boundary mismatch with argument ' . ($previousIndex + 1) . ')', - new CodeLocation($source, $arg->value), - $functionId, - ), - $suppressed, - ); - } - } - - $previousRight = $right; - $previousIndex = $index; - } - } - - /** - * @param lowercase-string $genericClassLc - * @return array{0: Union, 1: Union}|null - */ - private static function extractGeneric(Union $argType, string $genericClassLc): ?array - { - foreach ($argType->getAtomicTypes() as $atomic) { - if (!$atomic instanceof TGenericObject) { - continue; - } - if (strtolower($atomic->value) !== $genericClassLc) { - continue; - } - if (count($atomic->type_params) < 2) { - continue; - } - - return [$atomic->type_params[0], $atomic->type_params[1]]; - } - - return null; - } -} diff --git a/src/Psalm/Iso/Provider/ComposeProvider.php b/src/Psalm/Iso/Provider/ComposeProvider.php index 83ecd5f..6f71198 100644 --- a/src/Psalm/Iso/Provider/ComposeProvider.php +++ b/src/Psalm/Iso/Provider/ComposeProvider.php @@ -7,20 +7,17 @@ use Psalm\Plugin\DynamicTemplateProvider; use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent; -use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; -use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Storage\FunctionLikeParameter; use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; use VeeWee\Reflecta\Iso\Iso; -use VeeWee\Reflecta\Psalm\Compose\AdjacentTemplateValidator; use function array_map; use function count; use function range; -final class ComposeProvider implements DynamicFunctionStorageProviderInterface, FunctionReturnTypeProviderInterface +final class ComposeProvider implements DynamicFunctionStorageProviderInterface { private const FUNCTION_ID = 'veewee\reflecta\iso\compose'; @@ -32,52 +29,56 @@ public static function getFunctionIds(): array return [self::FUNCTION_ID]; } - public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union - { - AdjacentTemplateValidator::validate($event, Iso::class, self::FUNCTION_ID); - - // Defer return type to the DynamicFunctionStorage provider. - return null; - } - public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage { $templateProvider = $event->getTemplateProvider(); $argsCount = count($event->getArgs()); - // Create S->A iso pairs - $composedIsos = array_map( - static fn (int $callable_offset) => self::createABIso( - self::createTemplateFromOffset($templateProvider, $callable_offset), - self::createTemplateFromOffset($templateProvider, $callable_offset + 1), - ), - range(1, $argsCount) + // No args: fall back to the declared signature (its non-empty-array param + // already rejects empty calls). Building storage here would compute a + // negative base offset and read undefined $templates entries. + if ($argsCount === 0) { + return null; + } + + // STAB chain: for N args, we need 2N+2 templates. + // Arg i (1..N) takes Iso. + // Adjacent args share their (slot-3, slot-4) with the next arg's (slot-1, slot-2), + // which under invariant templates forces the boundary types to unify. + $templateCount = ($argsCount * 2) + 2; + $templates = array_map( + static fn (int $offset) => self::createTemplateFromOffset($templateProvider, $offset), + range(1, $templateCount) ); $composeStorage = new DynamicFunctionStorage(); - $composeStorage->params = [ - ...array_map( - static fn (TGenericObject $iso, int $offset) => self::createParam( - "iso_{$offset}", - new Union([$iso]), - ), - $composedIsos, - array_keys($composedIsos) - ) - ]; - - // Add compose template list for each intermediate Iso - $composeStorage->templates = array_map( - static fn ($offset) => self::createTemplateFromOffset($templateProvider, $offset), - range(1, $argsCount + 1), - ); - - // Compose return type from templates T1 -> TLast (Where TLast could also be T1 when no arguments are provided.) + $composeStorage->templates = $templates; + + $params = []; + foreach (range(1, $argsCount) as $argIndex) { + $base = ($argIndex - 1) * 2; + $params[] = self::createParam( + "iso_{$argIndex}", + new Union([ + self::createStabIso( + $templates[$base], + $templates[$base + 1], + $templates[$base + 2], + $templates[$base + 3], + ), + ]), + ); + } + $composeStorage->params = $params; + + // Return type: outer (S, T) of the first arg + inner (A, B) of the last arg. $composeStorage->return_type = new Union([ - self::createABIso( - current($composeStorage->templates), - end($composeStorage->templates) - ) + self::createStabIso( + $templates[0], + $templates[1], + $templates[$templateCount - 2], + $templates[$templateCount - 1], + ), ]); return $composeStorage; @@ -90,15 +91,19 @@ private static function createTemplateFromOffset( return $template_provider->createTemplate("T{$offset}"); } - private static function createABIso( - TTemplateParam $aType, - TTemplateParam $bType + private static function createStabIso( + TTemplateParam $s, + TTemplateParam $t, + TTemplateParam $a, + TTemplateParam $b, ): TGenericObject { return new TGenericObject( Iso::class, [ - new Union([$aType]), - new Union([$bType]), + new Union([$s]), + new Union([$t]), + new Union([$a]), + new Union([$b]), ] ); } diff --git a/src/Psalm/Lens/Provider/ComposeProvider.php b/src/Psalm/Lens/Provider/ComposeProvider.php index 6a080e1..b85578f 100644 --- a/src/Psalm/Lens/Provider/ComposeProvider.php +++ b/src/Psalm/Lens/Provider/ComposeProvider.php @@ -7,20 +7,17 @@ use Psalm\Plugin\DynamicTemplateProvider; use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent; -use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; -use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Storage\FunctionLikeParameter; use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; use VeeWee\Reflecta\Lens\Lens; -use VeeWee\Reflecta\Psalm\Compose\AdjacentTemplateValidator; use function array_map; use function count; use function range; -final class ComposeProvider implements DynamicFunctionStorageProviderInterface, FunctionReturnTypeProviderInterface +final class ComposeProvider implements DynamicFunctionStorageProviderInterface { private const FUNCTION_ID = 'veewee\reflecta\lens\compose'; @@ -32,52 +29,56 @@ public static function getFunctionIds(): array return [self::FUNCTION_ID]; } - public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Union - { - AdjacentTemplateValidator::validate($event, Lens::class, self::FUNCTION_ID); - - // Defer return type to the DynamicFunctionStorage provider. - return null; - } - public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage { $templateProvider = $event->getTemplateProvider(); $argsCount = count($event->getArgs()); - // Create S->A lens pairs - $composedLenses = array_map( - static fn (int $callable_offset) => self::createABLens( - self::createTemplateFromOffset($templateProvider, $callable_offset), - self::createTemplateFromOffset($templateProvider, $callable_offset + 1), - ), - range(1, $argsCount) + // No args: fall back to the declared signature (its non-empty-array param + // already rejects empty calls). Building storage here would compute a + // negative base offset and read undefined $templates entries. + if ($argsCount === 0) { + return null; + } + + // STAB chain: for N args, we need 2N+2 templates. + // Arg i (1..N) takes Lens. + // Adjacent args share their (slot-3, slot-4) with the next arg's (slot-1, slot-2), + // which under invariant templates forces the boundary types to unify. + $templateCount = ($argsCount * 2) + 2; + $templates = array_map( + static fn (int $offset) => self::createTemplateFromOffset($templateProvider, $offset), + range(1, $templateCount) ); $composeStorage = new DynamicFunctionStorage(); - $composeStorage->params = [ - ...array_map( - static fn (TGenericObject $lens, int $offset) => self::createParam( - "lens_{$offset}", - new Union([$lens]), - ), - $composedLenses, - array_keys($composedLenses) - ) - ]; - - // Add compose template list for each intermediate Lens - $composeStorage->templates = array_map( - static fn ($offset) => self::createTemplateFromOffset($templateProvider, $offset), - range(1, $argsCount + 1), - ); - - // Compose return type from templates T1 -> TLast (Where TLast could also be T1 when no arguments are provided.) + $composeStorage->templates = $templates; + + $params = []; + foreach (range(1, $argsCount) as $argIndex) { + $base = ($argIndex - 1) * 2; + $params[] = self::createParam( + "lens_{$argIndex}", + new Union([ + self::createStabLens( + $templates[$base], + $templates[$base + 1], + $templates[$base + 2], + $templates[$base + 3], + ), + ]), + ); + } + $composeStorage->params = $params; + + // Return type: outer (S, T) of the first arg + inner (A, B) of the last arg. $composeStorage->return_type = new Union([ - self::createABLens( - current($composeStorage->templates), - end($composeStorage->templates) - ) + self::createStabLens( + $templates[0], + $templates[1], + $templates[$templateCount - 2], + $templates[$templateCount - 1], + ), ]); return $composeStorage; @@ -90,15 +91,19 @@ private static function createTemplateFromOffset( return $template_provider->createTemplate("T{$offset}"); } - private static function createABLens( - TTemplateParam $aType, - TTemplateParam $bType + private static function createStabLens( + TTemplateParam $s, + TTemplateParam $t, + TTemplateParam $a, + TTemplateParam $b, ): TGenericObject { return new TGenericObject( Lens::class, [ - new Union([$aType]), - new Union([$bType]), + new Union([$s]), + new Union([$t]), + new Union([$a]), + new Union([$b]), ] ); } diff --git a/tests/static-analyzer/Iso/compose.php b/tests/static-analyzer/Iso/compose.php index b424421..ba6de19 100644 --- a/tests/static-analyzer/Iso/compose.php +++ b/tests/static-analyzer/Iso/compose.php @@ -6,15 +6,19 @@ use function VeeWee\Reflecta\Iso\compose; /** - * @template A - * @template B - * @template C - * @template D + * @template A1 + * @template A2 + * @template B1 + * @template B2 + * @template C1 + * @template C2 + * @template D1 + * @template D2 * - * @param Iso $iso1 - * @param Iso $iso2 - * @param Iso $iso3 - * @return Iso + * @param Iso $iso1 + * @param Iso $iso2 + * @param Iso $iso3 + * @return Iso */ function it_knows_composed_result(Iso $iso1, Iso $iso2, Iso $iso3): Iso { @@ -22,15 +26,23 @@ function it_knows_composed_result(Iso $iso1, Iso $iso2, Iso $iso3): Iso } /** - * @template A - * @template B - * @template C - * @template D + * Boundary between iso1 and iso2 doesn't line up: + * iso1 outputs (B1, B2) on its (A, B) side, but iso2's (S, T) side is (C1, C2). + * With invariant templates Psalm catches the mismatch via standard inference. * - * @param Iso $iso1 - * @param Iso $iso2 - * @param Iso $iso3 - * @return Iso + * @template A1 + * @template A2 + * @template B1 + * @template B2 + * @template C1 + * @template C2 + * @template D1 + * @template D2 + * + * @param Iso $iso1 + * @param Iso $iso2 + * @param Iso $iso3 + * @return Iso * * @psalm-suppress InvalidArgument */ diff --git a/tests/static-analyzer/Iso/compose_type_changing.php b/tests/static-analyzer/Iso/compose_type_changing.php new file mode 100644 index 0000000..d1f0368 --- /dev/null +++ b/tests/static-analyzer/Iso/compose_type_changing.php @@ -0,0 +1,28 @@ + $iso1 + * @param Iso $iso2 + * @return Iso + */ +function it_composes_type_changing_isos(Iso $iso1, Iso $iso2): Iso +{ + return compose($iso1, $iso2); +} diff --git a/tests/static-analyzer/Lens/compose.php b/tests/static-analyzer/Lens/compose.php index d0e6062..8893831 100644 --- a/tests/static-analyzer/Lens/compose.php +++ b/tests/static-analyzer/Lens/compose.php @@ -6,15 +6,19 @@ use function VeeWee\Reflecta\Lens\compose; /** - * @template A - * @template B - * @template C - * @template D + * @template A1 + * @template A2 + * @template B1 + * @template B2 + * @template C1 + * @template C2 + * @template D1 + * @template D2 * - * @param Lens $lens1 - * @param Lens $lens2 - * @param Lens $lens3 - * @return Lens + * @param Lens $lens1 + * @param Lens $lens2 + * @param Lens $lens3 + * @return Lens */ function it_knows_composed_result(Lens $lens1, Lens $lens2, Lens $lens3): Lens { @@ -22,15 +26,23 @@ function it_knows_composed_result(Lens $lens1, Lens $lens2, Lens $lens3): Lens } /** - * @template A - * @template B - * @template C - * @template D + * Boundary between lens1 and lens2 doesn't line up: + * lens1 outputs (B1, B2) on its (A, B) side, but lens2's (S, T) side is (C1, C2). + * With invariant templates Psalm catches the mismatch via standard inference. * - * @param Lens $lens1 - * @param Lens $lens2 - * @param Lens $lens3 - * @return Lens + * @template A1 + * @template A2 + * @template B1 + * @template B2 + * @template C1 + * @template C2 + * @template D1 + * @template D2 + * + * @param Lens $lens1 + * @param Lens $lens2 + * @param Lens $lens3 + * @return Lens * * @psalm-suppress InvalidArgument */ diff --git a/tests/static-analyzer/Lens/compose_type_changing.php b/tests/static-analyzer/Lens/compose_type_changing.php new file mode 100644 index 0000000..c3fc417 --- /dev/null +++ b/tests/static-analyzer/Lens/compose_type_changing.php @@ -0,0 +1,28 @@ + $lens1 + * @param Lens $lens2 + * @return Lens + */ +function it_composes_type_changing_lenses(Lens $lens1, Lens $lens2): Lens +{ + return compose($lens1, $lens2); +} diff --git a/tests/unit/Iso/ComposeTest.php b/tests/unit/Iso/ComposeTest.php index f1940e6..7b14a28 100644 --- a/tests/unit/Iso/ComposeTest.php +++ b/tests/unit/Iso/ComposeTest.php @@ -31,4 +31,27 @@ public function test_it_can_be_composed(): void static::assertSame(base64_encode('hello,world'), $joined); static::assertSame($data, $exploded); } + + public function test_it_composes_a_single_iso_as_passthrough(): void + { + $base64 = new Iso( + base64_encode(...), + base64_decode(...), + ); + + $composed = compose($base64); + + static::assertSame(base64_encode('hello'), $composed->to('hello')); + static::assertSame('hello', $composed->from(base64_encode('hello'))); + } + + public function test_it_composes_an_empty_list_as_identity(): void + { + /** @var list<\VeeWee\Reflecta\Iso\IsoInterface> $isos */ + $isos = []; + $composed = compose(...$isos); + + static::assertSame('hello', $composed->to('hello')); + static::assertSame('hello', $composed->from('hello')); + } } diff --git a/tests/unit/Iso/IsoTest.php b/tests/unit/Iso/IsoTest.php index 42501af..5036a97 100644 --- a/tests/unit/Iso/IsoTest.php +++ b/tests/unit/Iso/IsoTest.php @@ -125,4 +125,43 @@ public function test_it_can_be_composed(): void static::assertSame(base64_encode('hello,world'), $joined); static::assertSame($data, $exploded); } + + public function test_it_supports_type_changing_from(): void + { + $person = new IsoTestPerson('Alice'); + $hashed = new IsoTestHashedName('hashed:Alice'); + + /** @var Iso $anonymize */ + $anonymize = new Iso( + static fn (IsoTestPerson $p): string => $p->name, + static fn (IsoTestHashedName $h): IsoTestAnonymizedPerson => new IsoTestAnonymizedPerson($h), + ); + + $back = $anonymize->from($hashed); + + static::assertInstanceOf(IsoTestAnonymizedPerson::class, $back); + static::assertSame($hashed, $back->name); + static::assertSame('Alice', $anonymize->to($person)); + } +} + +final class IsoTestPerson +{ + public function __construct(public string $name) + { + } +} + +final class IsoTestHashedName +{ + public function __construct(public string $hash) + { + } +} + +final class IsoTestAnonymizedPerson +{ + public function __construct(public IsoTestHashedName $name) + { + } } diff --git a/tests/unit/Lens/ComposeTest.php b/tests/unit/Lens/ComposeTest.php index 0822f14..152572b 100644 --- a/tests/unit/Lens/ComposeTest.php +++ b/tests/unit/Lens/ComposeTest.php @@ -20,4 +20,24 @@ public function test_it_can_compose_lenses(): void static::assertSame('hello', $composed->get($data)); static::assertSame(['greet' => ['message' => 'goodbye']], $composed->set($data, 'goodbye')); } + + public function test_it_composes_a_single_lens_as_passthrough(): void + { + $composed = compose(index('greet')); + $data = ['greet' => 'hello']; + + static::assertSame('hello', $composed->get($data)); + static::assertSame(['greet' => 'goodbye'], $composed->set($data, 'goodbye')); + } + + public function test_it_composes_an_empty_list_as_identity(): void + { + /** @var list<\VeeWee\Reflecta\Lens\LensInterface> $lenses */ + $lenses = []; + $composed = compose(...$lenses); + $data = ['greet' => 'hello']; + + static::assertSame($data, $composed->get($data)); + static::assertSame(['other' => 'value'], $composed->set($data, ['other' => 'value'])); + } } diff --git a/tests/unit/Lens/LensTest.php b/tests/unit/Lens/LensTest.php index c11353d..0f2747d 100644 --- a/tests/unit/Lens/LensTest.php +++ b/tests/unit/Lens/LensTest.php @@ -123,4 +123,44 @@ public function test_it_can_not_write_to_readonly_lens(): void $this->expectExceptionObject(ReadonlyException::couldNotWrite()); $lens->set('result', 'impossible'); } + + public function test_it_supports_type_changing_set(): void + { + $person = new LensTestPerson('Alice'); + $hashed = new LensTestHashedName('hashed:Alice'); + + /** @var Lens $anonymize */ + $anonymize = new Lens( + static fn (LensTestPerson $p): string => $p->name, + static fn (LensTestPerson $_, LensTestHashedName $h): LensTestAnonymizedPerson + => new LensTestAnonymizedPerson($h), + ); + + $back = $anonymize->set($person, $hashed); + + static::assertInstanceOf(LensTestAnonymizedPerson::class, $back); + static::assertSame($hashed, $back->name); + static::assertSame('Alice', $anonymize->get($person)); + } +} + +final class LensTestPerson +{ + public function __construct(public string $name) + { + } +} + +final class LensTestHashedName +{ + public function __construct(public string $hash) + { + } +} + +final class LensTestAnonymizedPerson +{ + public function __construct(public LensTestHashedName $name) + { + } }