Repository navigation
feat(quotations): describe how a union is written - #63
Open
xperiandri wants to merge 16 commits into
Open
xperiandri wants to merge 16 commits into
xperiandri wants to merge 16 commits into
Conversation
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>
xperiandri
added a commit
that referenced
this pull request
Oct 12, 2026
The result of the sixth step of phase 1 (#63): IJsonLayout answers how a case of a union is written, as a mark that tells the case and a place for each of its fields, both made of steps from the value of the union. The record says where this departs from the sketch of sections 10 and 20.9, what FSharp.SystemTextJson really does, what is not described, and how the step is tested. The ninth review found two things. A node that a quotation comes with at several places, a quotation that is spliced twice, was one node in the normal form only as long as nothing was rewritten in it or around it; the normalizer keeps it one node now and walks the quotation once for such nodes, which is measured. And a reference or a nullable of a member that names a converter is described as any other member: the serializer leaves its null out, or writes it, before it asks the converter. The counts of tests and planted defects follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Enum-like fieldless unions can report a null mark even though the serializer writes their case tag.
1 open finding
What changed in this PR
Adds the serializer-aware quotation infrastructure needed to translate F# union operations into Cosmos DB queries.
Changes:
- Adds quotation normalization, evaluation, parameterization, and error reporting.
- Describes serializer layouts for members, values, and union cases.
- Adds extensive unit tests and integrates the project into builds and documentation checks.
| File | Description |
|---|---|
.github/copilot-instructions.md |
Documents project standards and architecture. |
.github/workflows/strict-documentation.yml |
Adds strict documentation validation. |
Directory.Packages.props |
Adds centrally managed dependencies. |
FSharp.Azure.Cosmos.slnf |
Adds the quotation projects. |
FSharp.Azure.Cosmos.slnx |
Adds the quotation projects. |
src/Cosmos.Quotations/AssemblyInfo.fs |
Defines assembly metadata. |
src/Cosmos.Quotations/Cosmos.Quotations.fsproj |
Configures the quotation library. |
src/Cosmos.Quotations/Internals.fs |
Adds shared internal helpers. |
src/Cosmos.Quotations/TranslationError.fs |
Models translation failures and locations. |
src/Cosmos.Quotations/Normalizer.fs |
Normalizes quotation trees. |
src/Cosmos.Quotations/Evaluator.fs |
Evaluates variable-free expressions. |
src/Cosmos.Quotations/Query.fs |
Identifies query constants and parameters. |
src/Cosmos.Quotations/Parameterizer.fs |
Produces query shapes, templates, and values. |
src/Cosmos.Quotations/JsonLayout.fs |
Describes serializer document layouts. |
tests/Cosmos.Quotations.Tests/Cosmos.Quotations.Tests.fsproj |
Configures the quotation tests. |
tests/Cosmos.Quotations.Tests/testconfig.json |
Enables parallel test execution. |
tests/Cosmos.Quotations.Tests/TestCategories.fs |
Defines test categories. |
tests/Cosmos.Quotations.Tests/Model.fs |
Supplies quotation test models. |
tests/Cosmos.Quotations.Tests/QuotationAssert.fs |
Adds quotation assertion helpers. |
tests/Cosmos.Quotations.Tests/NormalizerSpellingTests.fs |
Tests normalized-expression formatting. |
tests/Cosmos.Quotations.Tests/NormalizerTests.fs |
Tests normalization behavior. |
tests/Cosmos.Quotations.Tests/NormalizerErrorTests.fs |
Tests normalization failures. |
tests/Cosmos.Quotations.Tests/EvaluatorTests.fs |
Tests expression evaluation. |
tests/Cosmos.Quotations.Tests/ParameterizerShapeTests.fs |
Tests query-shape identity. |
tests/Cosmos.Quotations.Tests/ParameterizerTests.fs |
Tests parameter extraction and templates. |
tests/Cosmos.Quotations.Tests/ModelMembers.fs |
Defines serializer member fixtures. |
tests/Cosmos.Quotations.Tests/StoredModel.fs |
Defines stored-document fixtures. |
tests/Cosmos.Quotations.Tests/StoredValues.fs |
Defines serialized value fixtures. |
tests/Cosmos.Quotations.Tests/StoredUnions.fs |
Defines serialized union fixtures. |
tests/Cosmos.Quotations.Tests/JsonLayoutTests.fs |
Tests member and optional layouts. |
tests/Cosmos.Quotations.Tests/JsonLayoutValueTests.fs |
Tests non-object value layouts. |
tests/Cosmos.Quotations.Tests/JsonLayoutUnionTests.fs |
Tests union-case layouts. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
xperiandri
force-pushed
the
feat/quotations-layout-unions
branch
from
October 12, 2026 06:28
ff084f4 to
9d25912
Compare
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 normalizer (#58): a function that is applied to the document is a captured compiled one whenever it reads no variable of the quotation, however the quotation gets to it, and a quotation that is nested too deeply stays the error inside a construct whose own error would print it; - the parameterizer (#60): the null and the default value of a function type are refused as a function is; - the layout (#61): the layout asks the resolver that the serializer asks, and why its modifiers are not copied; - the values of the layout (#62): the encoding of an enumeration is a kind of JSON value, and what stands for one value in a query is a requirement on the translator step; - the unions of the layout (#63): why the converter of the unions without fields never writes a case that is null. The counts of tests and planted defects follow the branches. 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 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>
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 reads a member of a document under the name the serializer wrote it with, and a query that asks whether a member has a value has to know what a member without one looks like: null, or not there at all. IJsonLayout answers both, and JsonLayout.ofSerializerOptions is the layout of System.Text.Json, with or without the converter of FSharp.SystemTextJson among its options. The layout asks the serializer who writes a type: the contract that the options resolve for a type names the converter. So the order of the converters, the kinds of types FSharp.SystemTextJson is set to handle and a converter that a type names in an attribute count as they do when a document is written, and nothing is told to the layout by hand. The layout is asked about a member together with the type of the value that the member is read from, a DocumentMember. The member alone does not tell that type: a quotation names an inherited property as its declaring type has it, also when the property is read from a value of a derived type, and the serializer can write the two types in different ways. In the contract of that type the layout looks for the entry of the member itself, and when there is none, for the entry of the property that overrides it. What the serializer reads from a member, the layout reads from the one the entry was made of. - A type that System.Text.Json writes as an object: the contract lists the properties with their names, so the naming policy, declared names, included fields and an override next to the declaration it overrides are what the serializer makes of them. A member without a value follows its own JsonIgnore condition, then the default condition of the options. A value option and a skippable value are structs, so "when null" never leaves them out and "when default" does. A read-only member is never written when the options ignore read-only members, which the contract does not show: the property keeps its getter and gets no rule. The rule of the serializer is applied, with its exceptions for a member that has a rule, from a JsonIgnore condition of its own or from code, and for a collection. A member is a collection by the converter that writes it, which can be one that the member names: a converter that writes the items as something else makes it no collection, and a factory that hands the type back to the converter of the options leaves it one. One function gives the contract of such a member. It has the factory make the converter with the options first: a factory that asks the options it is given for a converter would be asked without end by options that hold the factory itself. The contract is asked by the rules of the serializer and not of the resolver that the options have: the serializer writes the member with its converter whatever contract a resolver of the application makes for the type. One case cannot be told: code that sets the rule of a member to null has set a rule to the serializer, which then writes the read-only member, and the contract shows the same null as for a member that nothing touched. Such a member is taken to be left out. The extension data has no name and no absence that the layout describes: its entries are properties of the document itself, and nothing is written for it when it is null. - A record that FSharp.SystemTextJson writes: the rules of that library are followed, read from its source and compared with what it writes. A field is named by JsonName, then by JsonPropertyName, then by the policy. A field with JsonIgnore is never written, whatever its condition says. An option without a value is left out when every option is skippable, or when the options ignore null and the setting follows them; a value option only in the first case; a skipped value always. A reference that is null is left out when null is ignored, but not a record, a union or a tuple of F#, which are written even then. A union with a case that is null is left out again, because that null is a value to the library, and so is a union that is written as the one value it wraps where that value can be null: an option, a value option that is written as its value, or any type where the options let every field be null. The rule is the one of the function of the library that finds the value it reads from a null. The options of a record type come from the overrides, then from the attribute on the type when that is allowed, then from the converter that takes the type: the first one among the options that does, since the options can hold several converters for different kinds of types. Only the factory of FSharp.SystemTextJson itself shows them: a class that an application derives from it can make the converter of a record with other options, so its records are not described. A converter of records that is added to the options by itself keeps its options to itself, so its record is not described, and neither is a record whose converter an attribute of the application makes: without a converter of the options that takes it, the options of a record are read only from the attribute of FSharp.SystemTextJson, which shows them. All of this holds for a contract that the serializer made itself, which the contract tells: 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, and such a record is not described. Of the properties that are no fields the library looks at the public ones only, so another one is not written, whatever the options or its JsonInclude say. - A member that is never written and declares a name has that name: it stands for a property that something else writes, such as a type discriminator. Without a value it is null or left out, as whatever writes the property decides. That holds for a member of an object and for a member of a record that FSharp.SystemTextJson does not write, because it is marked to be ignored or is a property that is not included. What a member declares is its own: an override has neither the name nor the JsonIgnore condition of the declaration it overrides. What is written for a value without a value where it is not left out is asked of the serializer: it is given a null, or the value option or the skippable value that has none, to write with the contract of the type or of the converter that a member names. The serializer writes null itself unless the converter asks for the null with HandleNull. Such a converter writes what it likes, and the ones of FSharp.SystemTextJson for tuples, for maps and for unions without fields ask for it and fail on it. Only null is described: whatever else comes out, or an exception, means that the layout does not describe the value without one. A type that the serializer has no contract for is not described either: the serializer writes no document with a member of such a type. The obsolete setting IgnoreNullValues counts as the default condition "when null", for an object and for a record: both serializers still follow it, and neither the condition of the options nor the contract shows it. The layout keeps a copy of the options: a contract can be asked only from options that have a resolver and do not change any more, and the options of the caller are not to be touched. The copy asks the resolver of the caller, which cannot be copied, since it can be any object. So the layout and the serializer ask one resolver, and the one of System.Text.Json takes no change after either has asked it: the two cannot come to disagree unnoticed. What the layout finds out is kept, and every question is answered once, also when several threads ask it at a time: finding an answer asks the serializer, which can run code of the application. An option that FSharp.SystemTextJson writes as a union with the name of its case and a type that another converter writes are reported as not described. A member can name a converter of its own. The serializer leaves a reference or a nullable of such a member out, or writes it as null, before it asks that converter, so the member is described as any other, unless the converter asks for null itself. An option, a value option or a skippable value of such a member is not described: the converter writes all of it. FSharp.SystemTextJson 1.4.36 becomes a dependency of the Quotations assembly, as ADR 0001 decided. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The serializer is the oracle. Nine types of a model made for the layout are written with twenty-five ways to set the serializer up, once with every optional member present and once with every one absent. Each member is looked for in the document by a value that no other member holds. The layout has to give the name it is found under, and for a member without a value what the second document shows: null, nothing, or a form the layout does not describe. The class of the model has a read-only collection for each thing a converter of the member can do with it: write it as one text, and hand it back to the converter of the options. A null of every type of F# that the library writes in a way of its own, leaves out or fails to write is asked about by itself, in a field of a record and in a property of a class, with nine ways to set the library up: a tuple, the maps, a union without fields, a record, a list, a set, and a union of one case around every kind of value. The layout has to say what the serializer does: null where it writes null, left out where the member is missing, and nothing where it fails. The other tests state what a document cannot: the name of a member that is never written, also when it is left out for being read-only, an override next to the declaration it overrides, a member that code decides about, a member with a converter of its own, a type that another converter writes or that the serializer refuses, a member that is read from a derived class, inherited or overridden, as a quotation names it, a record that a factory made of the one of FSharp.SystemTextJson writes itself or with options of its own, a record that names a converter in an attribute of its type, a record whose converter was added to the options by itself or is made by an attribute of the application, a record whose contract a resolver of the application makes, a member with a converter of its own whose type has a contract of such a resolver, a member of a union, a member of a record that is not written, the extension data, a read-only member with a rule that code has set, a value whose converter writes null itself, a skippable value that System.Text.Json alone would write, a null of a type that the serializer has no contract for, the encoding of unions that the options of a record inherit, a value that is no member, that a question is answered once, also for several threads that ask it at a time, that the options of the caller keep their resolver and stay as they are, and that the resolver takes no change once the layout has asked it. F# declares no public field that is read-only, so the test of such a field makes its type at run time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests of the JSON layout compare what the layout says of every member of a model type with a document that the serializer wrote. The functions that list those members, read their values, give their types and take an optional value off its outer layer move from the test class into a module, ModelMembers, because the tests of the next step walk a model the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The converter factory that counts how often it makes a converter kept one count for all its uses, and tests run at a time: two tests that count would each see the converters of the other. The factory takes a type argument now and keeps a count for each, so a test that counts names a type that no other test names. The tests of the next step count as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query compares an enumeration with a number or with a name, reads an item of a tuple at an index or under a name, and looks a key up in a JSON object or in an array of pairs, depending on how the serializer wrote the value. IJsonLayout answers four more questions, each about a type and the member that holds the value, when a member does: - EnumEncoding: a number or a string. System.Text.Json has one converter for both, which keeps to itself what it writes, so the layout has the serializer write every value of the enumeration that has a name and looks at what comes out: a number for each of them, or a string for each. That describes a converter of the application as well; one that writes some values as names and others as numbers is not described, and neither is one that refuses a value, with either of the two exceptions that the serializer has for that. - TupleEncoding: an array, as FSharp.SystemTextJson writes both kinds of tuple, or an object with a property for every item that is written, as System.Text.Json does. MemberName names those properties, and has no name for the items of a struct tuple unless the options include fields. - CollectionEncoding: an array, with the type of an item, for what System.Text.Json writes item by item and for the lists and sets of FSharp.SystemTextJson. An array of bytes is one string and is not described as an array. - MapEncoding: an object, or an array of pairs of a key and a value, with the types of both. A member can name a converter of its own. System.Text.Json follows it, also for a field of a record that it writes as an object, and FSharp.SystemTextJson does not read it. Such a converter writes the whole value of the member, so the type of the member, or the type that the member is a nullable of, is described by it, and a value under an option that such a converter writes is not described. Without such a converter the value under an option is described as any value of its type: OptionalEncoding says whether the option is written as that value. A member that the serializer never writes is not described: something else writes its property, in a way of its own. Neither is a value that a converter of the application writes, except for an enumeration. The layout keeps every answer, with the function that keeps what it finds out about names and missing values. Where this departs from the sketch of ADR 0001, section 10: the questions about an enumeration and a map take the member too, because a member can name a converter; an answer that cannot be given is the case NotDescribed and no missing value, as for OptionalEncoding; a struct tuple that System.Text.Json writes as an empty object is an object whose items have no names; and whether an application stores an empty collection as null is no fact of the serializer, so the layout does not say it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The serializer is the oracle, as for the names: the kind of JSON value that a document holds for a member says what the layout has to say. A number or a string for an enumeration, an array or an object for a tuple, an array for a collection, an object or an array for a map, and nothing at all for a member that was not written. Five types of a model made for these values are written with every way to set the serializer up: a class and a record with a member for every kind of value, a class whose members name a converter that writes text, a class whose members name a converter factory that hands them back to the options, and a class whose members are only read. The value under an option or a nullable is asked about as well, where the member is written as that value. The same values are asked about without a member, as an item of a collection is. Six ways to set the serializer up join the ones of the layout tests, which run with them too: enumerations by name with and without FSharp.SystemTextJson, every map as an object, every map as an array of pairs, FSharp.SystemTextJson without its converters of tuples, and without its converters of lists, sets and maps. The set-up of the application writes enumerations by name now. The other tests state what a document cannot: a type of another kind, an enumeration without a named value, a converter that refuses the value, a converter that writes one value as a name and another as a number, a nullable asked by the type under it, a part of a value that a converter of the member writes, a converter of the options, a member of a type that another converter writes, a value whose member names a converter while a resolver of the application makes the contract of its type, a value whose member is read from a class that inherits it, the extension data, that an answer of every kind is kept, and that the model holds every kind of value in its documents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query that tests the case of a union and reads a field of the case has to know what a value of that case looks like in a document. FSharp.SystemTextJson writes a union in many ways: the tag next to the fields, as the name of the fields, before or among the fields, or no tag at all, the fields as an array or under names, and a case without fields, a case with one field or a case with a record in ways of their own. IJsonLayout answers one more question, about a case and the member that holds the union, when a member does: - UnionCaseEncoding: the mark of the case and the place of each of its fields. The mark is what tells that a value is of the case: a tag at the end of some steps from the value of the union, a property that only values of the case have, null, or nothing. A place is the steps from the value of the union to the value of a field. A record that a case unwraps next to an internal tag has no value of its own, so its place says where its members are. The contract of the union names the converter, and the library has one class for a union in general, one for a union without any field and one for a union that is written as its one field. The options that the union is written with say the rest. They are found as the ones of a record are, which is one function for both now: the overrides, then the attribute on the type when that is allowed, then the converter among the options that takes the type. Options that say that they inherit the encoding get the whole encoding of the common ones, as the library does it. The rules are the ones of the library, read from its source and compared with what it writes. A value that is null is written as null before anything else. A case without fields is its tag alone when the encoding unwraps such cases, in every encoding. One field stands in place of the fields only where the tag is next to the fields or names them. The flag that unwraps a record holds the one of named fields, and the converter of the union writes the members of the record itself: with its own options of the serializer, and with the options of the library that it has or that it finds for the record. So such a case is described only when the union is written with the common options and the converter among the options that writes the union makes the converter of the record as well. A converter that an attribute of the record makes by itself has no naming policy, and another converter of the library among the options has other options; then what the layout says of the record is not about the record in the case. A tag is the first name that the case declares, a text, a number or a boolean, or the name of the case as the policy of the tags spells it. A field has the name that F# gives it, the name of its type when the encoding says so, or the name that the case declares for it, spelled by the policy of the fields and then by the one of the serializer. Not described: a union of a member that names a converter, a union whose contract a resolver of the application makes, a union that a class derived from the converter of the library writes, an encoding with two places for the tag, and an option, a value option or a skippable value that is written as its value, which OptionalEncoding describes. FSharp.SystemTextJson makes its converters by reflection, so a type that one of them refuses arrives as a TargetInvocationException, which counts as a refusal now. Where this departs from the sketch of ADR 0001, sections 10 and 20.9: the question is about a case and not about a union, because the library decides case by case; the answer is made of steps and not of names of encodings; and a union that is written as its one field is a case without a mark whose field is in place of the union. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The serializer is the oracle, and the tests do not know its rules. A value of every case of twelve union types is written with every way to set the serializer up. The document has to hold the mark that the layout gives the case and the mark 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 has to be not described. The same is asked about each union as the value of a member of a record. Twenty-seven ways to set the serializer up join the ones of the layout tests for these rows: every place for the tag with every way to write the fields, names and naming policies for tags and fields, and options for single types, in the overrides and as ones that inherit the encoding. The other tests state what the rows cannot: that the model is written with every kind of mark and place, a part of the encoding in an attribute, a union that the library refuses, an encoding with two places for the tag, a member with a converter of its own, an option as a union and as a value, a record in a case that is not written as the record is by itself, a contract that a resolver makes, a factory derived from the one of the library, and that an answer is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/quotations-layout-unions
branch
from
October 12, 2026 08:03
9d25912 to
e2b4aa0
Compare
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): the null of a union type is a case of the union, which the compiler writes as a case, so as a value it is data and no null literal; - the layout (#61): what is written for a null is asked of the serializer, because three converters of FSharp.SystemTextJson ask for the null and fail on it; a null in a field of a record is left out by the rule of the library, which a union that is written as the value it wraps follows too; and a type without a contract is not described; - the values of the layout (#62): a converter of an enumeration that refuses a value with either exception leaves it not described; - the unions of the layout (#63): a record that a case unwraps is described only when the converter of the union makes the converter of the record. The counts of tests and planted defects follow the branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Proposed Changes
The sixth step of phase 1 of ADR 0001 (#34). A query that tests the case of a union and reads a field of it has to know what a value of that case looks like in the document:
FSharp.SystemTextJson writes
Card ("1234", 2030)andCashin a way that its options choose:Card ("1234", 2030)Cash{"Case":"Card","Fields":["1234",2030]}{"Case":"Cash"}{"Case":"Card","Fields":{"number":"1234","year":2030}}{"Case":"Cash"}{"Card":["1234",2030]}{"Cash":[]}["Card","1234",2030]["Cash"]{"Case":"Card","number":"1234","year":2030}{"Case":"Cash"}{"number":"1234","year":2030}{}"Cash"IJsonLayoutanswers one more question, about one case of a union:The mark is what a query tests to know the case:
o["Payment"]["Case"] = "Card",o["Payment"][0] = "Card",IS_DEFINED(o["Payment"]["Card"]), oro["Payment"] = "Cash"when the path has no step. A place is the path from the value of the union to the value of a field:["Fields"][0],["Card"]["number"],[1]. Both start at the value of the union, so they hold wherever the union is: in a member, in an item of a collection, in a field of another union. The question is asked as the ones of #62 are, with the member that holds the union when a member does.As before, the layout asks the serializer who writes the type: the contract of the union names the converter, and FSharp.SystemTextJson has a converter class for a union in general, one for a union without any field and one for a union that is written as its one field. The options that the union is written with say the rest; they are found as the ones of a record are (#61), which is one function for both now.
What the library really does, found in its source and by writing documents, and kept as tests:
nullbefore anything else. The case that a union markedUseNullAsTrueValuerepresents by null is written asnullin every encoding, also where a case without fields is its tag alone. The converter that the library has for a union without any field writes a tag for every case, but it never meets such a null: the compiler lets a union be marked so only when it has a case with fields as well (error FS1196 otherwise). The mark of such a case isNull; how a member that holds the null is written is the question of feat(quotations): describe how the serializer writes a member #61,OptionalEncoding.UnwrapFieldlessTags), and then in every encoding. Without it such a case is{"Case":"Cash"},{"Cash":[]},["Cash"]or{}.UnwrapSingleFieldCases):{"Case":"Circle","Fields":3}and{"Circle":3}. The tag among the fields and the encoding without tags do not do it.UnwrapRecordCases): the flag holds the one of named fields, so every case of the union gets named fields with it. Next to an internal tag the object is more than the record,{"Case":"Signed","Name":"n"}, which is the placeMembers; everywhere else the object is the record as it is written anywhere, which is the placeValue. The converter of the union writes the members of such a record itself: with its own options of the serializer, and with the options of the library that it has or that it finds for the record. So the case is described only when the union is written with the common options and the converter among the options that writes the union makes the converter of the record as well. Otherwise what the layout says about the members of the record would not be about this object: a converter that[<JsonFSharpConverter>]on the record makes by itself has no naming policy, and another converter of the library among the options has other options. (The last two found by the review.)UnwrapSingleCaseUnions, on by default):StoredTicket guidis the text of the GUID. Its mark isUnmarkedand the place of its field has no step. With several fields or none, the one case is written as a case of any union.JsonNameattribute of the case, which can be a text, a number or a boolean ({"Case":7}, and"7"and"true"as the name of a property), then the name of itsJsonPropertyName, then the name of the case spelled byUnionTagNamingPolicy. The naming policy of the serializer does not reach a tag, and a declared name is not spelled by any policy.Itemfor the one field of a case that the source does not name andItem1,Item2for several. WithUnionFieldNamesFromTypessuch a field has the name of its type, and a name that the case then has twice gets a number:Int321,Int322. The case can declare another name for a field withJsonName("x", Field = "Item1"). Without one the name is spelled byUnionFieldNamingPolicy, and by the naming policy of the serializer when there is none.[<JsonFSharpConverter(UnionNamedFields = true, UnionTagName = "kind")>]makes options that say that they inherit the encoding, and the library then takes the whole encoding from the common options: the fields stay an array, and the name of the tag is the one of the attribute. Only an attribute that sets the whole encoding (UnionEncoding = …) keeps it. The same holds for options in the overrides that are made withJsonFSharpOptions.InheritUnionEncoding ().NotSupportedExceptionwhen it is to write a value. It writes a null without asking what it is a null of, so the null of a union does not show the refusal.JsonNameattributes is refused when the converter is made, and the library makes its converters by reflection: the serializer throwsTargetInvocationException. The layout takes it for a type that is not described, as the other refusals.Not described, because nothing shows how the union is written: a member that names a converter of its own, also when that converter is the one of FSharp.SystemTextJson (it is made with options that nothing shows); a union whose contract a resolver of the application makes; a union that a class derived from the converter of FSharp.SystemTextJson writes; an encoding with two places for the tag, which the library fails to write. An option, a value option and a skippable value that are written as their value are no unions to this question:
OptionalEncodingdescribes them. An option that the library writes as a union (UnwrapOptionoff) is described as any union, withNoneas its null.Types of changes
The assembly is not packed yet (#58), so the packages are unchanged.
Checklist
Further comments
JsonLayoutUnionTests(FSharp.Azure.Cosmos.Quotations.Testshas 1637 now). The serializer is the oracle, and the test does not know the rules: it only reads the document.ScalarWrapperin the ADR): it is the caseUnmarkedwith a field in place of the union;withLegacyUnionEncoding) come with phase 2, as the ADR has them.Noneis left out, and how an enumeration, a tuple, a collection or a map in a field of a case is written. FSharp.SystemTextJson leaves a named field out as it does a field of a record, and never an item of an array; the next step letsOptionalEncodingand the questions of feat(quotations): describe how values that are no objects are written #62 answer for such a member;Identity, the remaining members ofIJsonLayoutin the ADR;OverrideMembersof FSharp.SystemTextJson, which gives a case other attributes by its name: the library keeps it internal, so the layout cannot read it (feat(quotations): describe how the serializer writes a member #61 has the same limit for records).dotnet build FSharp.Azure.Cosmos.slnxin Debug and Release with the warningsmainhas 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; both commits build, and the second passes the tests.🤖 Generated with Claude Code