Skip to content

feat(quotations): describe how the serializer writes a document - #65

Open
xperiandri wants to merge 10 commits into
mainfrom
feat/quotations-layout-supported
Open

xperiandri wants to merge 10 commits into
mainfrom
feat/quotations-layout-supported

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

Depends on #60, which depends on #59 and #58. The branch is built on the branch of #60, so until those are merged this pull request also shows their eight commits; its own are the last two. It targets main because the build workflow runs only for pull requests into main.

The fourth step of phase 1 of ADR 0001 (#34), in the narrow form that the maintainer chose. It takes the place of #61, #62 and #63, which describe every setting of FSharp.SystemTextJson and are drafts now (see the end).

A query is written against the document as the serializer stored it, not against the F# type:

<@ fun (p : Person) -> p.NickName.IsSome && p.Status = Status.Active && p.Tags |> List.contains "vip" @>

Three things in the text of that query come from the serializer, not from the quotation:

  • the name of the property, nick_name or NickName: a naming policy and an attribute decide it;
  • how a value was written: Status.Active is 1 or "Active", a list is a JSON array, a tuple is an array or an object, a case of a union is an object with a tag;
  • where a union keeps its tag and its fields.

A wrong answer gives a query that runs and returns nothing, with no error to say why. This pull request adds what answers these questions. The ADR calls it the layout: what the documents look like as the serializer left them.

type IJsonLayout =
    abstract MemberName : memberOfDocument : DocumentMember -> string voption
    abstract EnumEncoding : memberOfDocument : DocumentMember voption * enumType : Type -> EnumEncoding
    abstract TupleEncoding : memberOfDocument : DocumentMember voption * tupleType : Type -> TupleEncoding
    abstract CollectionEncoding : memberOfDocument : DocumentMember voption * collectionType : Type -> CollectionEncoding
    abstract MapEncoding : memberOfDocument : DocumentMember voption * mapType : Type -> MapEncoding
    abstract UnionCaseEncoding : memberOfDocument : DocumentMember voption * case : UnionCaseInfo -> UnionCaseEncoding
    abstract Polymorphism : baseType : Type -> PolymorphismEncoding

JsonLayout.ofSerializerOptions : JsonSerializerOptions -> Result<IJsonLayout, ImmutableArray<string>>

ofSerializerOptions takes the options that the Cosmos DB client serializes with: System.Text.Json with the converter of FSharp.SystemTextJson.

Why a layout, and not the attributes of the serializer. An attribute or a converter says how to write a value. A query has to find it again, from the type alone and before any value exists: where is the tag of Circle? what does radius stand for in the document? The serializer cannot answer that, but it can be asked what it writes, and the layout does that. It asks the serializer itself: for a type, JsonSerializerOptions.GetTypeInfo gives the contract that the serializer will use, and the contract names the converter. So the order of the converters, the kinds of types that FSharp.SystemTextJson is set to handle, a converter that a type names in an attribute and the naming policy all count as they do when a document is written.

Narrow on purpose. The layout supports the set-ups that it has been compared with the serializer for, and refuses the others instead of describing them wrongly:

Supported Refused when the layout is made, with the reason
Converters one JsonFSharpConverter, of that class itself, that writes every kind of type of F# (JsonFSharpTypes.All) none; more than one; a class that an application derived from it; one that leaves a kind of type out
Unions an adjacent or an internal tag with UnwrapOption; named fields or not; UnwrapFieldlessTags, UnwrapRecordCases, UnwrapSingleCaseUnions; any names and naming policies for the tag and the fields an external tag or none, no UnwrapOption, UnwrapSingleFieldCases, UnionFieldNamesFromTypes, an adjacent tag that has the name of the fields
Options any naming policy, IncludeFields, IncludeRecordProperties, enumerations by name, maps as objects or as arrays of pairs, numbers that are read from texts references that are handled, numbers that are written as texts, read-only members that are left out

ofSerializerOptions returns Result.Error with one sentence for each such setting. A type that the layout cannot describe, in a set-up that it supports, is answered with "not described" and never with a guess:

  • a type that a converter of the application writes, a member that names a converter of its own, and a type whose contract a resolver of the application made;
  • a record or a union that has options of its own, in the overrides or in an attribute that the common options allow;
  • a record that writes two of its members under one name;
  • a union with a case that is null (UseNullAsTrueValue), a tag that is a number or a boolean, two cases with one tag, a name that is declared for a field, or a name of the tag that is written twice;
  • a type with derived types whose discriminators are numbers.

Twenty-two ways to set the serializer up are tested: the defaults of FSharp.SystemTextJson with one change at a time, and the options that the application of the maintainer writes its documents with (an internal tag named $type, named fields, a record in a case unwrapped, options that are always skipped, enumerations by name, camel case or snake case names).

Optional values are not part of the layout. The full layout said also whether an option without a value is written as null or left out. A query does not need to know: it guards a member that can be without a value with NOT IS_DEFINED(x) OR IS_NULL(x), which is true for both. ADR §9 already has that as the guard that is right whatever the documents hold, and narrows it only on request.

What the serializers really do, found by writing documents and kept as tests:

  • A member is asked about together with the type it is read from. A quotation names an inherited property as the class that declares it has it (fun (talk : Talk) -> talk.Room holds Meeting.Room), and the serializer can write the two classes in different ways. So the questions take a DocumentMember, the member with the type of the receiver.
  • An enumeration is written to see how it is written. System.Text.Json has one converter class for numbers and for names, and what an instance writes is private to it. So the layout has the serializer write every value of the enumeration that has a name, and all of them have to come out as one kind of JSON value. An enumeration whose converter writes one value as a name and another as a number, or refuses a value, is not described. The encoding is a kind of JSON value, not what is written for one value: a naming policy can spell the name and a converter of the application writes what it likes, so the translator has to have the value written by the serializer (a requirement on the next steps, as the ADR says).
  • A tuple is an array only when the library's own converter writes it. The converter classes of tuples are public and open to inheritance, so an application can derive one that writes a tuple as text. Only a class of the assembly of FSharp.SystemTextJson counts.
  • A union is described as a whole. A document of one case can hold the tag of another, as a field can hold a text. So when one case cannot be described, none of the union is. The tag of a case is the first name of its JsonName, then its JsonPropertyName, then the name of the case spelled by the policy of the tags; the naming policy of the serializer does not reach it. The fields have the names that F# gives them, spelled by the policy of the fields. A union that is written as the one value it wraps (UnwrapSingleCaseUnions) has no tag, and its one field stands in place of the union.
  • A record in a case is written by the union with the options that the union has, so the case is described only when the record by itself is written by FSharp.SystemTextJson with the same options. Next to an internal tag the object holds the tag as well as the members of the record, which is UnionFieldPlace.Members; anywhere else it is the record as it is written anywhere.
  • The name of the tag can be written twice. The library writes {"radius":"Circle","radius":3} when a field has the name of an internal tag, and reads it back. A query reads one property of a name and cannot tell which of the two it is, and the second can hold a text, so no case of the union is described. The same holds when a case writes the members of a record that the layout does not describe (a converter of the options takes the record, and the union writes its members all the same): one of them can have the name of the tag, so next to an internal tag such a union is not described either. (Found by the review.)
  • Two things can be written under one name. FSharp.SystemTextJson writes {"title":"a","title":"b"} for a record with the fields Title and title under camel case, {"same":1,"same":2} for two fields that declare one name, and the same for a property that asks to be written under the name of a field. A union has the same problem with its cases: two cases that declare one tag are written with one tag, and so are FooBar and Foo_Bar under snake case. A query cannot tell which of two it reads, so a record that has a name twice among the members it writes is not described, and neither is a union whose cases share a tag. A member that is never written does not count, and two names that differ by the case of a letter are two names, as a document has them. (Found by the review.)
  • A member that is never written and declares a name has that name. [<JsonPropertyName "$type"; JsonIgnore>] is how an application marks a member that stands for a property that something else writes: the type discriminator, a computed property. A member with a plain [<JsonIgnore>] has no name, which the translator will report as an error (ADR §21.2). An override has a name of its own: it does not have the declaration of the base class.
  • The extension data has no name: its entries are properties of the document itself.
  • A record is described only when the serializer made its contract. JsonTypeInfo.OriginatingResolver names the resolver that made a contract. A resolver of the application can make the contract of a record from any converter, also from the one of FSharp.SystemTextJson with options of its own while the options hold another, and nothing shows how it made it.
  • Every question is answered once, also when several threads ask at a time: finding an answer asks the serializer, which can run code of the application, so what is kept is a Lazy for the question.
  • The options of the caller are not touched. A contract can be asked only from options that have a resolver and do not change any more, so the layout asks a copy. The resolver is not copied, because it cannot be: it can be any object. The layout asks the resolver that the serializer asks, so a change of it before the layout is asked the first time reaches both, and from then on the resolver of System.Text.Json refuses a change of its modifiers, as it does after the first document is written. Left with the maintainer.

The records of the application are not described yet. The application writes its records with a converter factory of its own, which stands before the one of FSharp.SystemTextJson in the options, names a member by the naming policy alone and leaves out null and Skip. The layout does not know what that converter writes, so a member of such a record has no name, and the translator, the next step, will refuse a query that reads one. The tests use the options of the application without that factory. Two ways to describe the records, for the maintainer to choose: a converter of an application says by a small interface how it names members, or the application writes its records with the options of the library, AllowNullFields and SkippableOptionFields, in place of its converter. Left with the maintainer.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

The assembly is not packed yet (#58), so the packages are unchanged. FSharp.SystemTextJson 1.4.36 becomes a dependency of FSharp.Azure.Cosmos.Quotations, as the ADR decided.

Checklist

  • Build and tests pass locally
  • I have added tests that prove my fix is effective or that my feature works (if appropriate)
  • I have added necessary documentation (if appropriate)

Further comments

  • Tests: 367 for the layout (FSharp.Azure.Cosmos.Quotations.Tests has 1248 now). The serializer is the oracle: the tests write values and look in the documents; they do not repeat the rules.
    • JsonLayoutTests: the names of members. Every type of a model made for the layout is written with each of the 22 ways to set the serializer up, and the document is searched for each member by a value that no other member holds; the layout has to give the name that the member is found under. The refused ways are tested too, each with the reason that the layout gives, one at a time and all at once.
    • JsonLayoutValueTests: an enumeration, a tuple, a collection and a map, as the value of a member and by itself, with every way to set the serializer up, and the values that the layout does not describe.
    • JsonLayoutUnionTests: a value of every case of the unions of the model is written, and the document has to hold the mark of the case and of no other case, and at the place of every field the JSON that the serializer writes for the value of the field. A union that the serializer refuses, a union with options of its own, a record in a case that is written another way, a case with the name of its tag twice, and the unions that a layout does not describe are tested one by one.
    • JsonLayoutPolymorphismTests: a derived type is written with the discriminator that the layout says, and a type with discriminators that are numbers is not described.
  • Can the tests fail? 155 one-line defects are planted in the layout, one at a time, and reverted: a refusal of the options left out, a rule of the library switched off or swapped, a name read from the wrong attribute, the options of the wrong converter taken, a place of a field moved by one, the answers not kept. Each of them fails at least one test. Where one did not when it was first planted, a test was missing and is added: four more ways to set the serializer up (a tag under the name of the fields, and under the name of a field with each place for the tag; numbers that are read from texts), a union whose case holds a record with a private representation, an override that is never written, a name declared on a member of a type that a converter writes, and the names of members of a union, a list and an option. The review of this pull request added more: records whose members are written under one name, unions whose cases share a tag, and a union whose case writes the members of a record that the layout does not know next to an internal tag. Four lines were not reachable, and are gone: the check of the class of the factory in the options, which the refusal at the start makes, the default name of the discriminator property, which the serializer always gives, a condition on the name of a field in the tag of a case, which a refusal of the union comes first for, and the inheritance of an attribute of a type, which a record cannot have.
  • The model is written by hand. Its values are fixed values in StoredModel.fs, StoredValues.fs and StoredUnions.fs, not the Slim and Fat generator variants of the ADR; those come with the generator step, and their first test will be shown before it is committed.
  • Not in this step:
    • Identity and OptionalEncoding of the ADR, which this form does not need, and the part of the full layout that describes a field of a union case as a member (feat(quotations): describe how a union is written #63 and the branch after it);
    • JsonLayout.ofClientOptions, which needs the Cosmos DB client and comes with the functions that run a query;
    • MemberDefaults, OverrideMembers of FSharp.SystemTextJson, which the library keeps internal, and the encodings that documents were written with in the past.
  • The full layout is put aside, not thrown away. feat(quotations): describe how the serializer writes a member #61, feat(quotations): describe how values that are no objects are written #62 and feat(quotations): describe how a union is written #63 and a branch after them (feat/quotations-layout-union-fields) hold the layout for every setting of FSharp.SystemTextJson with its tests and its record of verified facts; they are drafts, and the branches stay. The maintainer chose to start with the narrow form and to come back to the rest. What the ADR says of the full form is moved under a heading of its own in docs: add ADR 0001 for the FSharp.Azure.Cosmos.Quotations architecture #34.
  • Verification: dotnet build FSharp.Azure.Cosmos.slnx in Debug and Release with the warnings main has and none in the two Quotations projects, also with -p:WarnOn=3390; the tests pass in both configurations; Fantomas clean; the strict documentation build passes; every cref of the changed library file names a member that a documentation file declares, except five that name types of FSharp.SystemTextJson (JsonNameAttribute, JsonUnionEncoding, BaseJsonTupleConverter) that its documentation file does not declare; both commits build, and the second passes the tests.
  • No changelog entry: nothing is packed.

🤖 Generated with Claude Code

xperiandri and others added 6 commits October 11, 2026 16:44
FSharp.Azure.Cosmos.Quotations is going to translate F# quotations into
Azure Cosmos DB queries (ADR 0001). The translator is built stage by
stage, so no package is made of the project until the stages produce a
query end to end.

A stage that cannot translate a quotation says why with a
TranslationError: a code for the calling program, a message and hints
for the person who reads it, and the offending part of the quotation as
text. When the code was compiled with --quotations-debug, the error also
carries the place in the source that the compiler recorded for that
part; a part that a rewrite built has no place of its own, so the place
of the first part inside it is taken.

The error has no equality: its hints are an ImmutableArray, which equals
another one only when both wrap the same array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One condition can be written in many ways: with pipes, composition,
let bindings, functions marked with ReflectedDefinition and spliced
quotations. The stages after this one should not have to know every
spelling, so the normalizer rewrites them away. What is left holds
operators, members of the document and calls whose code cannot be read.

Rewritten away:

- |>, <|, ||>, |||>, <||, <|||, >> and <<, recognised by the methods of
  FSharp.Core and never by name;
- a function that is written and applied in place, and a let whose
  variable is never assigned to. That includes what the compiler binds
  itself: the value a match looks at, the parts of a tuple pattern and
  the copy of a struct, which is a mutable variable when the compiler
  cannot tell that the called member leaves the struct alone;
- a call of a function, method, property getter or extension member
  with a reflected definition, also an inline one that takes its
  operators as witnesses, a generic one and a member of a base class.
  A member that a derived class can override stays a call, and so does
  a module-level value, which is computed once;
- the reading of an item, field or case of a tuple, record, anonymous
  record or union that is built in the same place, and fst, snd and
  Item1..Item7, which become the reading of an item;
- a comparison of a boolean with true or false, a choice between true
  and false, a condition that is a constant and a double negation. The
  branch that a constant condition does not choose is not read at all:
  code that never runs needs no normal form. A captured boolean is not
  a constant: it is a parameter of the query. The default value of a
  boolean, which the compiler writes for bool (), is false;
- the witnesses of a call, the address of a struct anonymous record and
  the compiled value that an auto-quoted argument carries.

Reported as a TranslationError instead:

- a call of a function with a reflected definition that calls itself,
  directly or through others, when the call reads a value of the
  document;
- a quotation whose rewriting does not end, after 10 000 steps or when
  the stack of the thread runs low, whichever comes first;
- a compiled function that is applied to a value of the document,
  however the quotation gets to it: by its name, from a property or a
  field of a captured value, or as what a call returns. A function
  that the document chooses, as the result of a choice does, is
  reported as one that is chosen when the query runs. An application
  to captured values only is left for the stage that computes values
  outside the query;
- a mutable variable that is assigned to, an assignment, the address of
  a value, a sequence of statements, a loop, exception handling and a
  recursive function.

Of the constructs that stay in the normal form, the outermost one is
reported. An assignment, a sequence of statements, a loop, exception
handling and a recursive function are errors whatever is inside them,
so the outermost of these is reported also when the quotation inside
it has no normal form at all. A quotation that is nested too deeply
stays the error whatever stands around it: the error of a construct
shows the construct, and printing something that deep needs the stack
that has run out.

Only what stays is an error. A call of a function that calls itself is
not replaced by its definition, which would never end: it stays a
call, and so does the call that led to it. What becomes of such a call
is decided when the whole quotation is normalized. One that reads a
value of the document is the error. One that reads none is a value to
compute outside the query, as a call of any compiled function is. And
one in code that never runs is gone by then, so the branch that a
constant does not choose may hold one however the constant gets to the
condition: bound by a let, passed to a function written in place or to
a function with a definition.

The compiler keeps the definition of an inline function of a module
for the form of the function that takes the witnesses, and the
definition of an inline member of a type for the member itself; both
take the witnesses first, then the receiver, then the arguments. A
definition is applied only to arguments of the types it takes: a call
that is built through the API can pass an inline member no witnesses,
and such a call stays a call.

Reflected definitions and the argument groups of their methods are kept
for the process: FSharp.Core unpickles a definition on every lookup,
which took 16 microseconds here, and searches the resources of the
assembly for a method that has none, which took one. Substitution is
left to FSharp.Core where nothing inside the body needs care, because
it builds nodes without validating them, while rebuilding one member
access through the public API cost 4 microseconds of reflection.

Two things need care. One variable can be bound at several places, since
a quotation that is spliced twice arrives as the same nodes, so a
binding that hides the variable or would capture a variable of the value
is respected, also when the question is whether a mutable variable is
assigned to. And a value that is bound once and used at several places
has to be one node at all of them: the code computes it once, and the
stage that computes values outside the query tells by the node that the
places are one value. FSharp.Core builds every node of a body anew,
which makes two nodes of such a value as soon as something else is
written into the body that holds it. So a body with such a node is
substituted node by node, and the node is rewritten and normalized once
for all its places. The places are those of one call: the definition of
a function is one tree for the process, so every call after the first
normalizes a copy of it, and a value that the function binds is not one
node across calls, which each compute it for themselves. A node that the
quotation comes with at several places is kept one node in the same way:
a quotation that is spliced into two places is the same nodes at both,
and code can build a tree with one node twice. The quotation is walked
once for such nodes before it is normalized, and not to its end when it
is nested too deeply, which the normalization then reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new test project, FSharp.Azure.Cosmos.Quotations.Tests, with unit
tests that need no emulator.

Most tests are pairs: one way to write a function next to the plain way
to write the same function. For every pair three things are checked:
both ways have one normal form, the normal form is returned as the same
node when it is normalized again, and it computes what the compiled
code computes on sample values. The compiled code and the quotation come
from one lambda, which the compiler passes both ways to a parameter
marked with ReflectedDefinition(true), and Unquote evaluates the
quotation.

The other tests state what a pair cannot: what must stay as it is, what
must be gone, how one variable that is bound at two nested places is
treated, that a value which is bound once stays one node at all its
places, and so does a quotation that is spliced into two places, what
every error says and which of several errors is reported.

The test project is compiled with --quotations-debug, so the errors
carry the place in the source; a quotation built through the API covers
the case without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query has parts that do not depend on the document, such as
DateTime.UtcNow.AddDays -30.0 or limit + 1. Their values are known
before the query runs and become its parameters. The evaluator computes
such a value from a quotation in normal form.

It works by reflection: properties and fields are read, methods and
constructors called, tuples, records, unions and arrays built and taken
apart, types and union cases tested, captured functions applied.
Nothing is compiled, because turning a quotation into a delegate costs
hundreds of microseconds. A choice computes only the branch its
condition chooses, so a && b does not compute b when a is false.

37 operators of FSharp.Core refuse to be called by reflection: -, /, %,
the unary and bitwise operators, the shifts, the conversions between
number types and their checked forms. Their methods raise
NotSupportedException, "Dynamic invocation of op_Subtraction is not
supported", while + and * can be called. For each of them
LanguagePrimitives has a function for operands of any types, which the
compiler passes to the operator as a hidden argument; the evaluator
takes the pairs from there. The checked operators are paired by hand:
the compiler passes them the unchecked functions, with which
Checked.byte 300 would give 44 instead of raising.

Those functions fail for an enumeration type, so they are called for
the number type under it, and a result that is an enumeration value is
made one again. That also covers int over an enumeration value, whose
own method can be called for other types.

What reflection finds out about a type or a method is kept for the
process: the precomputed constructors and readers of FSharp.Reflection
take a record from 8 microseconds to 0.3 and an item of a tuple from 3
to 0.3.

An exception of the code that is run becomes a TranslationError with
the new code ClientEvaluationFailed and the exception itself in the new
field Cause, taken out of the TargetInvocationException of reflection.
A receiver that is null raises NullReferenceException, as compiled code
does, and so does a function that is null when it is applied. Compiled
code finds the null after it has computed the arguments, so an argument
that raises comes first here as well.

A member of a nullable is computed by a function that takes the
nullable. Reflection has nothing to call such a member on: a nullable
is boxed as its value or as null, never as itself, so HasValue of a
nullable without a value would raise where it has to give false.

The default value of a struct is its zeroed value, for which no
constructor runs: a struct of another language can declare a
constructor without parameters, and what that constructor makes is
another value. A nullable without a value is null as an object.

A coercion is a cast: a value that is not of the target type raises
InvalidCastException, as the cast does in compiled code, and is not
passed on as a value of a type it does not have. The compiler writes
only coercions that cannot fail; a quotation that is built through the
API can hold one that does.

A node is computed once. The normalizer writes a value that a let
bound at every place it is used, as the very same node, and the code
computes a bound value once: let made = obj () in (made, made) is a
pair of one object. So the evaluator keeps the values of the nodes it
has computed, by their identity: for one part in tryEvaluate, and in
tryEvaluateOnce for all the parts of one quotation, whose caller keeps
them. That costs a part about 0.1 microseconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every case is code that reads no variable, passed to the test together
with its quotation by the compiler, so the expected value is what the
code gives when it runs. There is a case for every kind of node, for
every operator that FSharp.Core does not let reflection call, for
every member of a nullable with and without a value, and for
enumeration values, whose computed value also has to be of the
enumeration type and not the number under it.

Code that raises is checked for the error it becomes: the code, the
exception as the cause, its name in the message and the place in the
source. A coercion that its value does not fit is built through the
API and checked for the exception of the failed cast. The default
value of a struct with a constructor without parameters, which F#
does not declare, is checked on a type that the test makes at run
time.

A value that is bound once is checked to be computed once: at two
places of one part, and in two parts for a caller that keeps what was
computed.

One test asks FSharp.Core which operators it marks as not callable and
fails when one of them has no stand-in in the evaluator, so that a new
version of FSharp.Core cannot add one unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stage that turns values into parameters has to choose the parts of
a quotation to compute before it computes any of them: a part that
turned out not to be computable half way would have run a half of its
code for nothing. Evaluator.canEvaluate tells without running anything
whether a part is made only of what the evaluator computes: it reads no
variable and holds no function, delegate or quotation that is written
in the quotation.

That stage asks about a part and then about the parts under it. To
refuse a part, the evaluator walks down to the reason, so it would look
at the same parts once for every part above them. The parts it refused
are remembered in a set that the caller keeps for all its questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 13:14
xperiandri added a commit that referenced this pull request Oct 12, 2026
The maintainer decided to build the JSON layout narrow first: it
supports the set-ups that it has been compared with the serializer for
and refuses the others. The layout of every setting of
FSharp.SystemTextJson (#61, #62, #63) is shelved, and what those steps
verified stays in the record under a heading of its own.

- the decision table (section 2) and the key decisions get the scope of
  the layout;
- section 10 says that the first layout is narrowed, and section 16 has
  the result of the narrow one (#65): the set-ups that it supports and
  refuses, the answers that it gives, and where it departs from the
  design;
- the record of the application's own converter of records, which the
  layout does not know, and the two ways to describe its records;
- the paragraph on dotnet test through FAKE says that FAKE 6.2.0 has
  DotNet.testMTP and that #64 replaces the hand-written call.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Name collisions can produce ambiguous layouts that generate queries matching the wrong member or union case.

3 open findings
What changed in this PR

Adds the serializer-aware document layout stage to the quotation query translator, including its stacked prerequisite stages.

Changes:

  • Introduces normalization, evaluation, parameterization, and JSON layout APIs.
  • Inspects System.Text.Json/FSharp.SystemTextJson contracts and rejects unsupported configurations.
  • Adds extensive unit-test models, serializer profiles, and solution wiring.
File Description
.github/​copilot-instructions.md Documents the new projects and dependency.
Directory.Packages.props Pins FSharp.SystemTextJson.
FSharp.Azure.Cosmos.slnf Adds quotation projects.
FSharp.Azure.Cosmos.slnx Adds quotation projects.
src/​Cosmos.Quotations/​AssemblyInfo.fs Defines assembly metadata.
src/​Cosmos.Quotations/​Evaluator.fs Evaluates client-side quotation expressions.
src/​Cosmos.Quotations/​FSharp.Azure.Cosmos.Quotations.fsproj Configures the quotation library.
src/​Cosmos.Quotations/​InternalsVisibleTo.fs Exposes internals to tests.
src/​Cosmos.Quotations/​JsonLayout.fs Describes serialized document layouts.
src/​Cosmos.Quotations/​Normalizer.fs Normalizes quotation expressions.
src/​Cosmos.Quotations/​Parameterizer.fs Extracts query shapes and parameter values.
src/​Cosmos.Quotations/​Query.fs Adds constant and parameter markers.
src/​Cosmos.Quotations/​TranslationError.fs Defines translation diagnostics.
tests/​Cosmos.Quotations.Tests/​EvaluatorTests.fs Tests expression evaluation.
tests/​Cosmos.Quotations.Tests/​FSharp.Azure.Cosmos.Quotations.Tests.fsproj Configures quotation tests.
tests/​Cosmos.Quotations.Tests/​JsonLayoutPolymorphismTests.fs Tests discriminator layouts.
tests/​Cosmos.Quotations.Tests/​JsonLayoutTests.fs Tests member names and options.
tests/​Cosmos.Quotations.Tests/​JsonLayoutUnionTests.fs Tests union layouts.
tests/​Cosmos.Quotations.Tests/​JsonLayoutValueTests.fs Tests value encodings.
tests/​Cosmos.Quotations.Tests/​Model.fs Supplies quotation test models.
tests/​Cosmos.Quotations.Tests/​ModelMembers.fs Provides model reflection helpers.
tests/​Cosmos.Quotations.Tests/​NormalizerErrorTests.fs Tests normalization errors.
tests/​Cosmos.Quotations.Tests/​NormalizerSpellingTests.fs Tests equivalent quotation forms.
tests/​Cosmos.Quotations.Tests/​NormalizerTests.fs Tests normalization behavior.
tests/​Cosmos.Quotations.Tests/​ParameterizerShapeTests.fs Tests stable query shapes.
tests/​Cosmos.Quotations.Tests/​ParameterizerTests.fs Tests parameter extraction.
tests/​Cosmos.Quotations.Tests/​QuotationAssert.fs Adds quotation assertion helpers.
tests/​Cosmos.Quotations.Tests/​StoredModel.fs Defines member-layout fixtures.
tests/​Cosmos.Quotations.Tests/​StoredUnions.fs Defines union-layout fixtures.
tests/​Cosmos.Quotations.Tests/​StoredValues.fs Defines value-layout fixtures.
tests/​Cosmos.Quotations.Tests/​TestCategories.fs Adds sorted test categories.
tests/​Cosmos.Quotations.Tests/​WrittenDocuments.fs Compares layouts with serialized JSON.
tests/​Cosmos.Quotations.Tests/​testconfig.json Enables parallel test execution.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
xperiandri and others added 4 commits October 12, 2026 15:35
Two runs of one query differ only in the values the quotation was
written with, so a query has to be translated once, and a later run
only has to find that translation and supply the values. The
parameterizer tells the two apart for a quotation in normal form:

- the values that become parameters, each at a numbered place, a slot;
- the key, ShapeKey: every node of the quotation without those values,
  in a form that can be compared and hashed. A quotation cannot be its
  own key: it compares its variables by identity, and every evaluation
  of a quotation literal creates new ones;
- the template, for a query that has no translation yet: the quotation
  with a variable at the place of every slot.

The rules:

- a literal is a part of the key: a boolean, a character, a string, an
  integer or floating-point number, a value of an enumeration, unit and
  null. The ADR made literals parameters and wrote a value declared
  with Literal into the text, but the compiler writes such a value into
  a quotation exactly as it writes 18, so the two cannot be told apart.
  A decimal is no literal here: the compiler writes 18m as a call that
  makes the decimal, so it is computed and is a slot, which keeps its
  digits. The type that a value is declared with decides, so a nullable
  is no literal, with a value or without one: a nullable with a value
  is boxed as that value, and the compiler writes neither as a value.
  The null of a union type is no literal either: it is the case of the
  union that is represented by null, as the None of an option is, and
  the compiler writes a case as a case;
- a captured value is a slot named after its variable;
- a part that reads nothing of the document, the largest one at its
  place, is computed by the evaluator and is a slot without a name;
- a tuple, record, union case or array that the quotation builds stays
  what it is, because the translator has to see that Some limit is an
  option with a value and that a list is written item by item;
- Query.constant and Query.parameter, two new public functions, say the
  opposite for their argument. A constant has to be a value that a
  literal can be: the key holds it, and equal keys have to mean one
  text, which the equality of an object does not promise.

A slot stands for one node of the quotation. The compiler writes every
use of a captured variable as a node of its own, so a variable that is
used twice is two slots with one name. Nothing in those nodes says that
they are one variable, because two spliced quotations can each have
captured a variable of that name, and their values must not say it
either: the key would depend on whether two values happen to be equal,
and one query would have two texts. A value bound by let is written at
every place it is used by the normalizer as the very same node, so it
is one slot, and its code runs once, also when two different computed
parts read it: the walk keeps what the evaluator computed for it.

A choice whose condition is known before the query runs computes only
the parts of the branch it takes, as compiled code does: in
filter.IsNone || p.Name = filter.Value.Trim () nothing is trimmed
without a filter. The parts of the other branch are slots without a
value, and the key does not depend on which branch is taken. A
condition under Query.constant decides its branch the same way.

A function value that would have to be a parameter or a constant is
reported as CapturedFunctionValue, also when it is passed as an object
and the type of the part does not say what it is. A class made of the
type of F# functions, which the class of every compiled function is
and which code can declare, is a function by its type. The null and
the default value of a function type, which only code can build into
a quotation, are reported the same way: a literal by their looks, and
no value that a query can use.

An error about a part that has no place in the source of its own, as a
captured value has none, gets the place of the nearest node around it.

The template is built only when it is asked for: building a node of a
quotation costs microseconds, and a quotation whose translation is
known needs no node built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
47 functions of a person, every one of a shape of its own, are written
twice over two sets of captured values; with the second set every
choice takes its other branch. Five laws are checked on them: a
function has one key and one hash code whatever values it captured, no
two functions have one key, the key and the values are the same with
and without the template, the template holds a variable for every slot
and no captured value, and the template with the values put back
computes what the compiled code computes. The compiler passes each
function together with its quotation, and Unquote evaluates the
template.

The other tests state what the laws cannot: which values are slots,
under which names and with which values, that two nodes are two slots
whatever they hold, what is a part of the key, that a value which is
bound once is computed once, that a quotation which is spliced into two
places is one slot, that a branch that is not taken is not computed,
what the functions of Query do, the tokens of a small key one by one,
and every error.

The check that tells which parts can be computed is tested with the
evaluator: it accepts every part the evaluator computes, refuses a
function, a variable and a quotation, and runs no code to tell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query has to read the property that the serializer wrote. A query that
reads a property that is not there returns nothing, with no error to say
why, so the translator asks a layout how the documents were written
instead of guessing from the names of members.

IJsonLayout gives the name of the property that holds a member of a
document, and how an enumeration, a tuple, a collection, a map, a case
of a union and a type with derived types are written.
JsonLayout.ofSerializerOptions makes the layout of System.Text.Json with
FSharp.SystemTextJson from the options of the serializer, and asks the
serializer itself: the contract that the options resolve for a type
names the converter that writes it, so the order of the converters, a
converter named in an attribute and the naming policy count as they do
when a document is written.

The layout supports the set-ups that it has been compared with the
serializer for, and refuses the others with one sentence for every
setting that it does not support: unions with an external tag or no tag,
references that are handled, numbers that are written as texts and
read-only members that are left out. A type that it cannot describe is
answered with "not described" and never with a guess: a type that a
converter of the application writes, a record or a union with options of
its own in the overrides or in an attribute, a union with a case that is
null or a tag that is no text, and a type that a resolver of the
application made a contract of.

Optional values are not part of it. A query guards a member that can be
without a value by testing both ways it can be missing, so how an
option is written does not change the query.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every answer of the layout is compared with a document that the real
serializer writes. A model of types is serialized with every way to set
the serializer up that the layout supports: the defaults of
FSharp.SystemTextJson with one change at a time, and the options that
the maintainer's application writes its documents with. The layout has
to say of each member the name of its property, of each enumeration,
tuple, collection and map the kind of JSON value, of each case of a
union the steps to its tag and to its fields, and of each derived type
its discriminator, as the document has them.

What the layout does not describe is tested the same way, with the
serializer as the judge: a member with a converter of its own, a type
that a converter of the application writes, a record or a union with
options of its own, a union with a case that is null, with tags that are
numbers, or with a name that is written twice, and a type that a
resolver of the application made a contract of. Each set-up that is
refused has a test that names the reason.

The model has a type for every case, and a count of how often the
serializer is asked, which shows that an answer is worked out once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-layout-supported branch from a875090 to c1e6d5d Compare October 12, 2026 14:57
xperiandri added a commit that referenced this pull request Oct 12, 2026
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the parameterizer (#60): a number that is not finite is no literal,
  because the text of a query has no NaN and no infinity, so such a
  number that code builds into a quotation is a slot and Query.constant
  of one is reported;
- the layout (#65): a record that writes two of its members under one
  name is not described, nor is a union whose cases share a tag, and
  next to an internal tag a case that writes the members of a record
  that the layout does not describe is taken to write the name of the
  tag twice.

The counts of tests and planted defects follow the branches.

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

This branch has not been deployed

No deployments
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.

2 participants