Skip to content

v0.5.0

  • v0.5.0
  • c295535
  • Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
  • Choose a tag to compare

  • v0.5.0
  • c295535
  • Choose a tag to compare

  • Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
@robotgryphon robotgryphon tagged this 15 Sep 19:33
* Say which part of a chain could not be compiled, and point at it

A decline used to be a sentence about a whole method, reported against the
method's name: "its reply could not be modelled — a field the element does not
have, a type with no certain read, or a collection that is not an array". Three
faults wearing one message, fixed three different ways, and the author left to
work out which of them it was and where. That was affordable while a decline
cost an optimisation and the chain still ran the slow way. It is not affordable
now that a decline costs the query.

Every walk that can refuse now carries a Refusals and records the expression it
stopped at with a reason naming what is wrong. FGQL015 is reported against that
expression rather than against the method, so the squiggle lands under the
member, the call or the value that could not be translated — the same place EF
Core puts one when a call has no translation.

  'Permissions' is declared as 'List<PermissionEdge>', and a selected
  collection has to be an array — declare it 'PermissionEdge[]', since the
  generated reader fills an array rather than converting into a collection type

The first refusal recorded wins: a walk unwinds through the frames that called
it, and the innermost knows what is actually wrong while the ones above know
only which expression it was part of. Where there is no syntax to point at — a
declared query's document is a string in an attribute — the reason is still
carried and the method's name is where it lands, which is FGQL016's share of
this.

Nothing about which chains compile changes. The selection-set walk, the reply
model and the projection copy make the same decisions they made before; they
now say what they decided and where.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1DREHgvjY9qnVhBTBUdfA

* Let a projection mix the rows it reads with everything else (MINOR)

The walk that derives a selection set carried one lambda parameter: the
projection's own, replaced by the inner one whenever it stepped into a nested
lambda. That holds until a projection nests and reads outwards, which is an
ordinary thing to write:

    .Select(c => new Perms(c.Name, c.Permissions.Select(p => p.Code + c.Code)))

`c.Code` there named nothing the innermost parameter had, so it was skipped as
an expression that reads no row. The document went out without `code`, and the
shaping that came back read `c.Code` off a row struct that had no such member —
a wrong document and generated code that does not compile, from a projection
that is ordinary C#. The same single parameter is why a projection could not
name a constant or a static of somebody else's: with nowhere to record "this
reads no row at all", a member named on its own that was not the row's was
refused outright.

So the walk carries a scope — one entry per lambda parameter between the
projection and the expression being read, each with the node its members are
collected onto. A read belongs to whichever row it starts at, found from the
innermost out, which gets shadowing right for the same reason C# does. A name
in none of them is not a row: it asks the server for nothing and is copied into
the shaping, where it goes on meaning what it meant.

Four things that were silently wrong are now decided rather than guessed:

  - a nested lambda reading an outer row asks for that field too
  - a sequence of the caller's own, filtered by a row's field, asks only for
    the field
  - a lambda over what a projection produced is refused, where its members used
    to be placed under a node they are not fields of
  - a path ends at a field that needs no selection set, where `c.Name.Length`
    used to ask for `name { length }`

And three that were refused now compile: a path through an index, a path
through a client-side call that lands back on a row, and a member read off what
a call produced when what it produced has no fields to miss.

Every compiled-query test now adds what the generator emitted back to the
compilation and asserts it compiles. Reading the generated text cannot catch a
document missing a field the shaping goes on to read — the text looks exactly
like the projection that was written — and that is the failure this file can
produce that costs the most to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1DREHgvjY9qnVhBTBUdfA

* Fill a collection member however it is declared (MINOR)

A generated reader accumulates a list as it reads one, because that is the only
shape that can be filled without knowing the count in advance. For a long time
the only member it would then fill was `T[]`, and every other way of spelling
"many of these" was a decline — which is a strange thing to be told about a
model the serializer this replaced read without complaining, and about the
member a schema's `[PermissionEdge!]` most naturally maps onto.

So the accumulated list is converted, and the set of conversions is the set that
can be recognised with certainty rather than the set that could be guessed:

  List<T> and the interfaces a list already is    the list itself, no copy
  T[]                                             ToArray()
  a public constructor taking a collection         new Target(rows)
  a type a collection expression builds            [.. rows]
  anything else                                    FGQL015, naming the member

A conversion guessed wrong here is a reply read into the wrong shape rather than
a build that fails, so the last row refuses and says both things that would
work: declare IReadOnlyCollection<T>, or give the type a constructor taking one.

A row of the generator's own is a payload rather than a model, and nothing
should be able to add to it — so where the member it mirrors is declared as
something a read-only list already is, the row holds IReadOnlyList<T> and the
rows reach it uncopied. Where the caller declared a List, it holds a List. Not
for want of tidiness: the projection is the caller's own code, copied verbatim
and written against the type they declared, so `new Perms(c.Name, c.Permissions)`
compiles where they wrote it and has to go on compiling where it lands.
Narrowing the member to be tidy would be a compile error in code nobody wrote.
Declaring IReadOnlyCollection<T> is what buys the read-only row, and costs
nothing else.

The AOT smoke sample still publishes with no trim warnings; the harness that
compiles what the generator emitted covers the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1DREHgvjY9qnVhBTBUdfA

* Bind every row a lambda ranges over, by its type (MINOR)

Asking whether the last few rounds hold up under SelectMany turned up a silent
one and two loud ones.

A projection's inner lambdas were bound one at a time and by position: the
parameter of the lambda, over whatever the receiver held. `Zip` and
`SelectMany`'s result selector take two, and a lambda with two parameters had
no parameter this could name — so both were names in no scope, and a name in no
scope had just been taught to mean "a value of the caller's own, which asks the
server for nothing". `(a, b) => a.Name` asked for nothing: the document went out
without `name`, the generated code compiled because the row was the caller's own
type, and `Name` came back null. Silent wrong data, which is worse than a wrong
document, and it was this session's doing.

So every parameter is bound, and which rows each ranges over is decided by its
type rather than its position — the rows the receiver holds, the rows a
flattening selector reached, or something whose members cannot be placed. One
rule serves Zip, SelectMany, GroupBy and the indexed overloads without knowing
any of them by name. A lambda whose parameters cannot be read one for one
against the delegate it binds to joins the scope unnamed, and an unnamed
parameter anywhere makes every name in that projection something to understand
outright — the conservatism that was there before constants were allowed in, put
back where it belongs.

Two more, both found by asking the question:

SelectMany now hands back what its selector reached, so the operators after it
name fields where those rows actually live. `c.Countries.SelectMany(n =>
n.Continent.Countries).Select(m => m.Name)` asked for `name` one level too high
and for every scalar of the flattened rows besides; it now asks for exactly
`countries { continent { countries { name } } }`, and for a flattened list of
scalars it asks for the member alone.

A grouping's `Key` is not a field. It is a property, which is all the walk used
to ask, so `g.Key` was written into the document and the reply then could not be
modelled because the element has no such member. The question is now whether the
type a node stands for actually has the property, as its own or as something it
is — which IGrouping fails and every real field passes.

And the chain's own SelectMany, GroupBy or Join is refused by name rather than
as "its chain could not be read as one query ending in this method", with the
message saying where they do work: inside the Select, or after the await.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1DREHgvjY9qnVhBTBUdfA

* Test a flatten that flattens, not a round trip dressed as one

The SelectMany case added last round read `c.Countries.SelectMany(n =>
n.Continent.Countries)` over a queryable of continents, which walks back up to
the continent it started from and asks for its countries again. The document
said so — `continents { countries { continent { countries { name } } } }` — and
a document that walks back up to a field it already had is a second trip
through the resolvers for rows the first trip fetched.

That is the expression's doing rather than the translation's, and the proof is
that the plain nested Select spelling of the same walk prints the same document.
Collapsing it would mean deciding that a country's continent is the continent
the country came from, which a schema may happen to do and GraphQL does not
promise; and the projection is copied verbatim, so the row has to carry the path
the projection reads through. But it makes a poor demonstration of an operator
whose whole job is to shorten a path, and a worse one of what an optimal
document looks like.

So the case is now `l.Related.SelectMany(r => r.Related)` — the related listings
of the related listings, two hops down a member holding its own type, which is
the shape the placement bug went wrong on quietly and asks for `listings {
related { related { name } } }`. Beside it, two documents worth pinning: a
genuine flatten one level down, `hemispheres { continents { countries { name }
} }`, and the shape all of this is measured against — the names of the countries
in a continent, `continents { countries { name } }`, one path down and no more.

The README and the design note carried the round trip as their example too, and
now carry the flatten, with the round trip kept as what it is: a worked example
of the document being the path the projection walks, and of querying the field
you actually want instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1DREHgvjY9qnVhBTBUdfA

---------

Co-authored-by: Claude <noreply@anthropic.com>
Assets 2
Loading