@@ -8,7 +8,7 @@ Date: 2026-07-27
88## Context
99---
1010
11- ` Schema ` was mutable — ` add() ` , ` remove() ` , ` rename() ` , ` merge() ` and every other mutator rewrote
11+ ` Schema ` was mutable - ` add() ` , ` remove() ` , ` rename() ` , ` merge() ` and every other mutator rewrote
1212` $this->definitions ` and returned ` $this ` . ` Definition::addMetadata() ` and ` Definition::setMetadata() ` did the same
1313with ` $this->metadata ` .
1414
@@ -18,16 +18,16 @@ undeclared row keys, so a column that should materialize in rows must be present
1818storing the caller's ` Schema ` therefore wrote every internal extension into the caller's object, producing three
1919observable defects (#2536 regression):
2020
21- 1 . ** Caller schema pollution** — a ` Schema ` the user holds for other purposes gains non-nullable columns it never
21+ 1 . ** Caller schema pollution** - a ` Schema ` the user holds for other purposes gains non-nullable columns it never
2222 declared.
23- 2 . ** Extractor-lifetime pollution** — columns added during one ` extract() ` run persist into subsequent runs.
24- 3 . ** Cross-stream pollution** — partition columns of one stream leak into the next, and
23+ 2 . ** Extractor-lifetime pollution** - columns added during one ` extract() ` run persist into subsequent runs.
24+ 3 . ** Cross-stream pollution** - partition columns of one stream leak into the next, and
2525 ` Hydrator::cast(fillMissing: true) ` injects ` null ` into a non-nullable definition.
2626
2727The first fix cloned: ` withSchema() ` stored ` clone $schema ` , and extractors cloned again per run and per stream.
2828That fix was incomplete. ` clone ` is shallow and ` Schema ` 's only state is ` array<string, Definition> ` , so a cloned
2929` Schema ` ** shares its ` Definition ` instances** . ` Schema::addMetadata() ` / ` setMetadata() ` reached into a shared
30- ` Definition ` and mutated it in place, so metadata writes aliased through every copy — including the caller's.
30+ ` Definition ` and mutated it in place, so metadata writes aliased through every copy - including the caller's.
3131
3232The same mutable contract left latent aliasing traps elsewhere: ` Rows::schema() ` seeded its merge loop with row 0's
3333* memoized* ` Schema ` and corrupted it, ` FloeStreamWriter ` retained a caller-owned ` Schema ` for the lifetime of a
@@ -39,23 +39,23 @@ write session, and `merge()`'s fast paths returned `$this` or the argument.
3939** ` Schema ` and its whole state chain are immutable. Every mutator returns a new instance; nothing is ever written
4040in place.**
4141
42- - ` Schema ` — a ` final readonly class ` . All 19 mutators return ` new self(...) ` ; ` setDefinitions() ` is the
42+ - ` Schema ` - a ` final readonly class ` . All 19 mutators return ` new self(...) ` ; ` setDefinitions() ` is the
4343 constructor's validation helper and is called from the constructor only.
44- - ` Definition ` (19 implementations) — each a ` final readonly class ` . ` addMetadata() ` and ` setMetadata() ` return a
44+ - ` Definition ` (19 implementations) - each a ` final readonly class ` . ` addMetadata() ` and ` setMetadata() ` return a
4545 per-class ` new self(...) ` , matching the idiom ` makeNullable() ` and ` rename() ` already used.
46- - ` Metadata ` — already a ` final readonly class ` .
46+ - ` Metadata ` - already a ` final readonly class ` .
4747
4848Immutability is declared at the class level, not per property: a ` readonly class ` cannot gain a writable property
4949later, so the guarantee survives future edits instead of depending on whoever adds property number 20 remembering
5050the rule. It is compiler-enforced, not convention. Extractors and the DSL hold caller-provided ` Schema ` instances
51- directly: there is nothing to clone because there is nothing to mutate. Sharing an instance — ` merge() ` 's fast
52- paths, a retained base ` Definition ` in the hydrator, ` Rows ` ' memoized schema — is safe by construction.
51+ directly: there is nothing to clone because there is nothing to mutate. Sharing an instance - ` merge() ` 's fast
52+ paths, a retained base ` Definition ` in the hydrator, ` Rows ` ' memoized schema - is safe by construction.
5353
5454DSL ` from_*() ` functions stay pure delegation.
5555
5656### Out of scope
5757
58- ` EntryReference ` remains mutable — ` as() ` , ` asc() ` and ` desc() ` write ` $alias ` / ` $sort ` on ` $this ` . It is shared
58+ ` EntryReference ` remains mutable - ` as() ` , ` asc() ` and ` desc() ` write ` $alias ` / ` $sort ` on ` $this ` . It is shared
5959with the entire expression DSL, so making it immutable is a separate project and is not attempted here.
6060
6161### Breaking change
@@ -73,7 +73,7 @@ $schema = $schema->add(str_schema('x')); // correct
7373
7474** Advantages:**
7575
76- - ** The bug class is gone** , not patched — aliasing is impossible because there is no writable state to alias.
76+ - ** The bug class is gone** , not patched - aliasing is impossible because there is no writable state to alias.
7777- ** Compiler-enforced** : a ` readonly ` violation is a fatal error, not a convention a new extractor can forget.
7878- ** Sharing becomes free** : no defensive clones in extractors, ` PhpRowHydrator ` , or the native hydrator.
7979- Fixes the ` Rows::schema() ` and ` FloeStreamWriter ` aliasing traps without touching either.
@@ -92,8 +92,8 @@ $schema = $schema->add(str_schema('x')); // correct
9292Every ` withSchema() ` stores ` clone $schema ` ; extractors clone again per run and per stream.
9393
9494** Rejected because:** the clone is shallow, so ` Definition ` instances stay shared and metadata mutations alias
95- through every copy anyway. It is also convention rather than a compiler-enforced rule — a new extractor can forget
96- to clone — and it leaves unobservable dead clones in extractors that never extend the schema.
95+ through every copy anyway. It is also convention rather than a compiler-enforced rule - a new extractor can forget
96+ to clone - and it leaves unobservable dead clones in extractors that never extend the schema.
9797
9898### 2. Deep ` Schema::__clone() ` + ` Definition::__clone() `
9999
@@ -105,7 +105,7 @@ also makes every clone more expensive without removing the need to remember to c
105105
106106### 3. Materialize auto-added columns post-hydration via ` Row::add() ` (Floe style)
107107
108- ** Rejected because:** the extended schema * is* the hydrator's instruction set — ` Hydrator::cast() ` drops undeclared
108+ ** Rejected because:** the extended schema * is* the hydrator's instruction set - ` Hydrator::cast() ` drops undeclared
109109row keys, and the ` findDefinition() ` guard lets a user-declared partition column keep its user-defined type.
110110Post-hydration adds would bypass both.
111111
0 commit comments