Skip to content

Add whereType subtype narrowing (alternative to #41) - #43

Closed
danReynolds wants to merge 2 commits into
mainfrom
claude/where-type
Closed

danReynolds wants to merge 2 commits into
mainfrom
claude/where-type

Conversation

@danReynolds

@danReynolds danReynolds commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

An alternative to #41 for the same goal: reading a Collection<A> that holds subtypes B and C as one subtype, by ID and through filters. Opened to compare implementations; #41's test suite is ported to this API.

final Collection<Dog> dogs = animals.whereType<Dog>();

final Dog? rex = dogs.doc('rex').get()?.data;                 // null if missing or not a dog
dogs.where((snap) => snap.data.age >= 2).stream();            // Query<Dog>
dogs.doc('rex').modify((snap) => snap.data.copyWith(age: 4)); // written with Animal's codec
animals.where(isRecent).whereType<Dog>();                     // queries narrow too

whereType mirrors Iterable.whereType and returns the existing Collection, Document and Query types, so snapshots, FilterFns, the stream builders and TransactionWriter take narrowed handles unchanged. There are no new public types and no breaking changes, and S extends T is checked at compile time.

How it works

The narrowing lives in lib/src/where_type.dart as private subclasses: _NarrowedCollection, _NarrowedDocument and _NarrowedObservableDocument. Each holds the source handle it was narrowed from, which owns the stored snapshots and their serialization. Document is unchanged, and Collection and Query only gain the whereType entry points.

  • Reads go through the source and keep a snapshot only if its data is an S, returning a DocumentSnapshot<S> whose doc is a narrowed handle. Hydrated documents are always parsed with the source's fromJson. A narrowed document creates its source handle on first use, so narrowed reads, filters and sorts create no source handles.
  • Writes delegate to the source, so persistence, dependencies and toJson use its codec, and stored snapshots keep the source's type.
  • ObservableDocument gains two hooks that the narrowed observer overrides: the store read, and the reported change event. The narrowed observer reports changes relative to the type: a document that becomes an S is added, one that stops being an S is removed, and changes to other types emit nothing.
  • ObservableQuery treats a document that isn't an S like one that fails the filter, and resolves a narrowed collection's documents through the collection so result handles keep the source's codec.

The source handle is what makes this possible. The store uses the reader's type to decide whether a snapshot is parsed: getSnapshot<T> re-parses anything that isn't a DocumentSnapshot<T> with the reader's codec. So a second typed handle on the same path throws a type-mismatch exception instead of narrowing.

Semantics to decide

  • Narrowed writes only affect their type. update, modify and delete on a Document<Dog> treat a cat as a missing dog. create never overwrites an existing ID of any type. A narrowed collection's delete and replace affect only its documents, and replace checks every ID before writing. Changing a document's type is a write through the source collection. The alternative is source semantics, where a narrowed update turns a cat into a dog.
  • Equality stays path-based, since dependency sets rely on it. The stream builders therefore also compare the handle's runtimeType; without that, switching a builder from dogs.doc(id) to cats.doc(id) kept the old observer. The alternative is type-aware ==.
  • Transactions record and restore narrowed writes through the source. A transaction that writes one document through both a narrowed and a source handle rolls back correctly.
  • Narrowed handles have a null fromJson, since they parse through their source.

Compared with #41

#41 this PR
New public types 7 0
Breaking changes builder callbacks receive read-only DocumentSnapshotView; Queryable.toQuery() returns QueryView none
Writes through the subtype none; write through the parent by ID typed create/update/modify/delete
bool Function(DocumentSnapshot<Dog>) filters rejected by a view's where accepted
Unrelated type argument CollectionView<String>(animals) compiles and is empty compile error
Existing library files +112 / −27 in 11 files +61 / −14 in 8 files; Document untouched
New library files 4 files, 314 lines where_type.dart, 219 lines

Median timings, JIT, 2,000 dogs and 2,000 cats, two alternating rounds, measured on the first commit:

#41 this PR plain query
where + sortBy get() 2.2 ms 0.9 ms 0.57 ms
Synchronous re-read after a write 3.0 ms 0.93 ms
One write to stream emission 1.6 ms 0.28 ms

#41 projects snapshots inside filter and sort callbacks through Expando caches. Here each snapshot is projected once where it is read, and the engine filters and sorts the projected snapshots directly.

The second commit moves the same code into where_type.dart. With Document's debug-only path asserts stripped from both layouts, the two measured the same within noise. Under flutter test, the first version of that layout validated two handles per projected snapshot; lazy source handles leave one. Later interleaved runs against #41 kept the same ordering (#41 2–5× slower on get() and re-reads), but the machine was too loaded (load average ~43 on 10 cores) to quote exact figures.

Other differences found while reviewing #41:

  • Add live read-only subtype collection views #41's builder dispose() overrides dispose observers that the caller passed in. Unmounting a QueryStreamBuilder given collection.observe(multicast: true) closes that observer for its other listeners. Three probes for this fail at Add live read-only subtype collection views #41's head and pass here, where the builders keep main's lifecycle.
  • CollectionView compares by identity, so a view built inline in build makes QueryStreamBuilder re-observe and rescan the collection on every rebuild (11 scans in 11 builds). A new narrowed Collection compares equal by path and type and is observed once; a test covers this.
  • Add live read-only subtype collection views #41's document views cache and emit like collection observers: isDirty turns true after a sibling write, and replace with the same dog emits removed then added. A narrowed ObservableDocument matches a plain one: isDirty stays false, and it emits a single added.

Validation

  • test/core/where_type_test.dart ports all 18 tests of Add live read-only subtype collection views #41's collection_view_test.dart and adds 5: typed writes, writable builder snapshots, codec-safe observed results, transaction rollback, and single observation across inline rebuilds.
  • Port adjustments: Add live read-only subtype collection views #41's constructor test compares collection and query narrowing instead. Stream identity is checked with ==, which is what StreamBuilder compares, instead of same. Read-only type assertions become ordinary type assertions, and the check that views of different types are unequal is dropped because handles compare by path. Add live read-only subtype collection views #41's unmodifiable-cache and single-handle-after-write assertions are dropped: observed queries return the engine's list as on main, and re-project on a synchronous re-read.
  • Locally: 185 core and 90 native tests pass, the ported suite (23) and the web suite (42) pass in Chrome, both CI benchmark workload checks pass, and flutter analyze reports no issues.
  • Mutating each of nine new code paths, including both hooks and the transaction source lookup, fails the suite.
  • A randomized comparison against plain observers of the source collection found no divergence over 400 seeds × 40 batches. It covers deletes, type changes, collection delete and replace, rebroadcasts, dependency changes, clearAll and subcollection parents, and checks synchronous reads, streams and folded change streams. Under two of the mutations it fails 60/60 and 37/60 seeds. The probe is kept locally, not committed.

🤖 Generated with Claude Code

danReynolds and others added 2 commits September 29, 2026 14:57
`collection.whereType<S>()` and `query.whereType<S>()` return ordinary
Collection, Document and Query handles that see only the documents whose
data is an S. A narrowed handle keeps a private source handle. Reads go
through the source and keep the snapshots whose data is an S; writes
delegate to it, so serialization, persistence and dependencies always use
the source's codec. Observers report a document that changes type as
added or removed, and narrowed writes only affect documents of the
narrowed type.

Stream builders also compare their handle's type, since handles compare
by path, and transactions roll back narrowed writes through the source.

An alternative to #41 for comparison. Its test suite is ported to this API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The narrowed collection, document and observer are now private subclasses
in where_type.dart, so Document is unchanged and Collection only gains the
whereType entry point. ObservableDocument gets two small hooks, the store
read and the reported change event, which the narrowed observer overrides.

A narrowed document creates its source handle on first use, so narrowed
reads, filters and sorts create no source handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danReynolds

Copy link
Copy Markdown
Owner Author

Closing in favor of #41's read-only view design. The fixes that came out of this comparison are in #44.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant