@@ -8,60 +8,63 @@ 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 all other mutators rewrote
1212` $this->definitions ` and returned ` $this ` . ` Definition::addMetadata() ` and ` Definition::setMetadata() ` did the same
1313with ` $this->metadata ` .
1414
15- File extractors extend the schema they were given so the hydrator can cast auto-added columns
16- (` _input_file_uri ` , partition columns, sheet metadata): ` Hydrator::cast() ` iterates schema definitions and drops
17- undeclared row keys, so a column that should materialize in rows must be present in the schema. An extractor
18- storing the caller's ` Schema ` therefore wrote every internal extension into the caller's object, producing three
19- observable defects (#2536 regression):
15+ File extractors extend the schema that they get, so that the hydrator can cast auto-added columns
16+ (` _input_file_uri ` , partition columns, sheet metadata). ` Hydrator::cast() ` iterates the schema definitions and drops
17+ undeclared row keys. A column that must appear in rows must therefore be in the schema. An extractor that stored the
18+ ` Schema ` of the caller therefore wrote each internal extension into the object of the caller. This caused three
19+ visible 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 ` that the user holds for other purposes gets non-nullable columns that 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 stay in the next runs.
24+ 3 . Cross-stream pollution: partition columns of one stream go into the next stream , and
2525 ` Hydrator::cast(fillMissing: true) ` injects ` null ` into a non-nullable definition.
2626
27- The first fix cloned: ` withSchema() ` stored ` clone $schema ` , and extractors cloned again per run and per stream.
28- That fix was incomplete. ` clone ` is shallow and ` Schema ` 's only state is ` array<string, Definition> ` , so a cloned
29- ` 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 .
27+ The first fix cloned. ` withSchema() ` stored ` clone $schema ` , and extractors cloned again for each run and each
28+ stream. That fix was incomplete. ` clone ` is shallow, and the only state of ` Schema ` is ` array<string, Definition> ` . A
29+ cloned ` Schema ` therefore shares its ` Definition ` instances. ` Schema::addMetadata() ` / ` setMetadata() ` changed a
30+ shared ` Definition ` in place, so each metadata write went through to all copies, also the copy of the caller.
3131
32- The same mutable contract left latent aliasing traps elsewhere: ` Rows::schema() ` seeded its merge loop with row 0's
33- * memoized* ` Schema ` and corrupted it, ` FloeStreamWriter ` retained a caller-owned ` Schema ` for the lifetime of a
34- write session, and ` merge() ` 's fast paths returned ` $this ` or the argument.
32+ The same mutable contract left hidden aliasing traps in other places:
33+
34+ - ` Rows::schema() ` started its merge loop with the memoized ` Schema ` of row 0 and corrupted it.
35+ - ` FloeStreamWriter ` kept a ` Schema ` of the caller for the lifetime of a write session.
36+ - The fast paths of ` merge() ` returned ` $this ` or the argument.
3537
3638## Decision
3739---
3840
39- ** ` Schema ` and its whole state chain are immutable. Every mutator returns a new instance; nothing is ever written
40- in place.**
41+ ` Schema ` and its full state chain are immutable. Each mutator returns a new instance. Nothing is ever written in
42+ place.
4143
42- - ` Schema ` - a ` final readonly class ` . All 19 mutators return ` new self(...) ` ; ` setDefinitions() ` is the
43- 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
45- per-class ` new self(...) ` , matching the idiom ` makeNullable() ` and ` rename() ` already used.
46- - ` Metadata ` - already a ` final readonly class ` .
44+ - ` Schema ` : a ` final readonly class ` . All 19 mutators return ` new self(...) ` . ` setDefinitions() ` is the validation
45+ helper of the constructor, and only the constructor calls it .
46+ - ` Definition ` (19 implementations): each one is a ` final readonly class ` . ` addMetadata() ` and ` setMetadata() ` return
47+ a ` new self(...) ` of their own class. ` makeNullable() ` and ` rename() ` already used this idiom .
48+ - ` Metadata ` : already a ` final readonly class ` .
4749
48- Immutability is declared at the class level, not per property: a ` readonly class ` cannot gain a writable property
49- later, so the guarantee survives future edits instead of depending on whoever adds property number 20 remembering
50- the 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.
50+ Immutability is declared at the class level, not for each property. A ` readonly class ` cannot get a writable
51+ property later. The guarantee therefore survives future edits. It does not depend on the person who adds property
52+ number 20 and remembers the rule. The compiler enforces it, so it is not a convention. Extractors and the DSL hold the
53+ ` Schema ` instances of the caller directly. There is nothing to clone, because there is nothing to mutate. Shared
54+ instances are safe by construction. Examples are the fast paths of ` merge() ` , a kept base ` Definition ` in the
55+ hydrator, and the memoized schema of ` Rows ` .
5356
5457DSL ` from_*() ` functions stay pure delegation.
5558
5659### Out of scope
5760
58- ` UnresolvedReference ` remains mutable - ` as() ` , ` asc() ` and ` desc() ` write ` $alias ` / ` $sort ` on ` $this ` . It is shared
59- with the entire expression DSL, so making it immutable is a separate project and is not attempted here .
61+ ` UnresolvedReference ` stays mutable. ` as() ` , ` asc() ` and ` desc() ` write ` $alias ` / ` $sort ` on ` $this ` . The full
62+ expression DSL shares it , so its immutability is a separate project. This decision does not try it .
6063
6164### Breaking change
6265
63- Calling a mutator and discarding the result is now a ** silent no-op** . There is no ` #[\NoDiscard] ` ; the change is
64- communicated through [ upgrading.md] ( /documentation/upgrading.md ) .
66+ A call to a mutator that discards the result is now a silent no-op. There is no ` #[\NoDiscard] ` .
67+ [ upgrading.md] ( /documentation/upgrading.md ) tells users about the change .
6568
6669``` php
6770$schema->add(str_schema('x')); // before: mutates $schema. now: no-op.
@@ -71,46 +74,46 @@ $schema = $schema->add(str_schema('x')); // correct
7174## Pros & Cons
7275---
7376
74- ** Advantages:**
77+ Advantages:
7578
76- - ** The bug class is gone** , not patched - aliasing is impossible because there is no writable state to alias.
77- - ** Compiler-enforced** : a ` readonly ` violation is a fatal error, not a convention a new extractor can forget.
78- - ** Sharing becomes free** : no defensive clones in extractors, ` PhpRowHydrator ` , or the native hydrator.
79+ - The bug class is gone, not patched. Aliasing is impossible, because there is no writable state to alias.
80+ - Compiler-enforced: a ` readonly ` violation is a fatal error, not a convention that a new extractor can forget.
81+ - Sharing is free: no defensive clones in extractors, ` PhpRowHydrator ` , or the native hydrator.
7982- Fixes the ` Rows::schema() ` and ` FloeStreamWriter ` aliasing traps without touching either.
8083
81- ** Disadvantages:**
84+ Disadvantages:
8285
83- - Breaking change for downstream code that discards a mutator's return value, and it breaks ** silently** .
84- - A long mutator chain allocates one ` Schema ` per link. Schemas are small and built once per pipeline, not per
85- row, so this does not show up in profiles.
86+ - Breaking change for downstream code that discards the return value of a mutator , and it breaks silently.
87+ - A long mutator chain allocates one ` Schema ` for each link. Schemas are small and built once for each pipeline, not
88+ for each row, so this does not show in profiles.
8689
8790## Alternatives Considered
8891---
8992
9093### 1. Clone at reception, clone before extending
9194
92- Every ` withSchema() ` stores ` clone $schema ` ; extractors clone again per run and per stream.
95+ Each ` withSchema() ` stores ` clone $schema ` . Extractors clone again for each run and each stream.
9396
94- ** 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.
97+ Rejected because: the clone is shallow, so ` Definition ` instances stay shared, and metadata mutations still go
98+ through to all copies . It is also a convention and not a rule that the compiler enforces. A new extractor can forget
99+ to clone. It also leaves invisible dead clones in extractors that never extend the schema.
97100
98101### 2. Deep ` Schema::__clone() ` + ` Definition::__clone() `
99102
100- Give ` Schema ` and every ` Definition ` a ` __clone() ` that copies the definition array and its objects.
103+ Give ` Schema ` and each ` Definition ` a ` __clone() ` that copies the definition array and its objects.
101104
102- ** Rejected because:** it patches the symptom while keeping the mutable contract, so every future aliasing trap
103- (` Rows::schema() ` , ` FloeStreamWriter ` , ` merge() ` 's fast paths) still has to be found and cloned around by hand. It
104- also makes every clone more expensive without removing the need to remember to clone.
105+ Rejected because: it patches the symptom and keeps the mutable contract. Each future aliasing trap
106+ (` Rows::schema() ` , ` FloeStreamWriter ` , the fast paths of ` merge() ` ) must still be found and cloned around by hand. It
107+ also makes each clone more expensive, and developers must still remember to clone.
105108
106109### 3. Materialize auto-added columns post-hydration via ` Row::add() ` (Floe style)
107110
108- ** Rejected because:** the extended schema * is * the hydrator's instruction set - ` Hydrator::cast() ` drops undeclared
109- row keys, and the ` findDefinition() ` guard lets a user-declared partition column keep its user-defined type.
110- Post-hydration adds would bypass both.
111+ Rejected because: the extended schema is the instruction set of the hydrator. ` Hydrator::cast() ` drops undeclared
112+ row keys. The ` findDefinition() ` guard lets a partition column that the user declared keep its user-defined type.
113+ Adds after hydration go around both.
111114
112115## Links and References
113116---
114117
115- - [ PR #2536 ] ( https://github.com/flow-php/flow/pull/2536 ) - encoder/hydrator row contract that introduced the
118+ - [ PR #2536 ] ( https://github.com/flow-php/flow/pull/2536 ) : the encoder/hydrator row contract that added the
116119 schema extension in extractors
0 commit comments