Skip to content

feat(quotations): describe how the serializer writes a member - #61

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

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

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 11, 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). A query is written against the document as it is stored, not against the F# type:

<@ fun (p : Person) -> p.NickName.IsSome && p.Age > 18 @>
// WHERE (IS_DEFINED(p["nick_name"]) AND NOT IS_NULL(p["nick_name"])) AND (p["age"] > 18)

Two things in that text come from the serializer, not from the quotation: the name nick_name, and how a nickname that is None was written, as null or not at all. A wrong answer to either gives a query that runs and returns nothing. This pull request adds what answers both. The ADR calls it the layout: what the documents look like as the serializer left them.

[<Struct>]
type DocumentMember = { Holder : Type; Member : MemberInfo }

type IJsonLayout =
    abstract MemberName : memberOfDocument : DocumentMember -> string voption
    abstract OptionalEncoding : memberOfDocument : DocumentMember voption * valueType : Type -> OptionalEncoding

JsonLayout.ofSerializerOptions : JsonSerializerOptions -> IJsonLayout

ofSerializerOptions is the layout of System.Text.Json, with or without the converter of FSharp.SystemTextJson among the options. Pass it the options the Cosmos DB client serializes with.

A member is asked about together with the type it is read from. The member alone does not tell that type. A quotation names an inherited property as the class that declares it has it: fun (talk : Talk) -> talk.Room holds Meeting.Room, whose ReflectedType is Meeting. And the serializer can write the two classes in different ways, the base class as an object and the derived one by a converter. So the translator passes the type of the receiver with the member, as a DocumentMember. 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 (its name, its JsonIgnore condition), the layout reads from the member that the entry was made of. (Found by the review: the layout took the type from the member, which is right for a member that a test asks of the derived class through reflection, and wrong for the member that a quotation holds.)

It asks the serializer instead of repeating its configuration. For every type, JsonSerializerOptions.GetTypeInfo gives the contract the serializer itself will use, and the contract names the converter that writes the type. So the layout does not have to be told which serializer is in use, and the things that decide it count as they do when a document is written: the order of the converters, the kinds of types FSharp.SystemTextJson is set to handle (JsonFSharpTypes), a converter that a type names in an attribute.

Who writes the type Names A member without a value
System.Text.Json, as an object: classes, and records when FSharp.SystemTextJson does not take them from the contract: naming policy, JsonPropertyName, JsonInclude, IncludeFields the JsonIgnore condition of the member, then DefaultIgnoreCondition
FSharp.SystemTextJson, as a record: records, struct records, anonymous records JsonName, then JsonPropertyName, then the naming policy SkippableOptionFields and DefaultIgnoreCondition, with the options of the converter that takes the type, or the ones the type has in the overrides or in its attribute
anything else none not described

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

  • A value option is never "null". WhenWritingNull leaves out None and does not leave out ValueNone: a value option is a struct, so it is written, as null. The same holds for Skip. Only WhenWritingDefault leaves them out. System.Text.Json even refuses [<JsonIgnore(Condition = WhenWritingNull)>] on such a member.
  • FSharp.SystemTextJson has the same split for records. With SkippableOptionFields.FromJsonSerializerOptions and null ignored, a field that is None is left out and a field that is ValueNone is written as null. Only SkippableOptionFields.Always leaves out both.
  • A null of a union is written, unless it is a case of the union. With null ignored, FSharp.SystemTextJson still writes a field that is a record or a union and is null, as null. A union marked UseNullAsTrueValue has a case that is represented by null; that null is a value, and it is left out like any null. (Found by the review.) A tuple that is null it cannot write at all, so the layout has no rule for one.
  • FSharp.SystemTextJson does not read the condition of JsonIgnore on a record field. Any JsonIgnore means "never written", Condition = Never included.
  • An override gets a name of its own. When a base class declares [<JsonPropertyName "pk">] abstract Tenant and the derived class overrides it without the attribute, the document has both pk and Tenant. The layout names each declaration as the serializer does.
  • A record with [<JsonFSharpConverter>] on it and no converter in the options that takes records is written by a converter that the attribute makes with options of its own: no naming policy, and null is not ignored, whatever the options of the caller say.
  • A read-only member can be left out without the contract saying so. With IgnoreReadOnlyProperties a property that nothing can assign is not written, and its entry in the contract still has a getter and no rule. So the layout applies the rule of the serializer itself: read-only, no JsonIgnore of any condition on the member, and no collection or dictionary, which the serializer writes all the same. IgnoreReadOnlyFields is the same for fields. An F# record that System.Text.Json writes as an object is {} under that option, because every field of a record is read-only. (Found by the review.) A read-only member with a rule that code has set (ShouldSerialize) is not left out that way: the serializer asks the rule instead. (Found by the review too.) And a member is a collection by the converter that writes it, which can be one that the member names with [<JsonConverter>]: a read-only array whose converter writes it as one text is left out, and one whose converter factory hands it back to the converter of the options is written. (Found while building the next step: the layout went by the type of the member.) 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. The serializer keeps the difference in a field that is not public. Such a member is taken to be left out. (Found by the review, and left as a known limit.)
  • The extension data (JsonExtensionData) 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. (The absence was found by the review: the layout said null.)
  • The options can hold several converters of FSharp.SystemTextJson, for different kinds of types. A record is written by the first one that takes it, as the serializer chooses, so its options are the ones the layout reads; the first converter of the list can be one for unions only. A converter of records that is added to the options by itself keeps its options to itself, so such a record is not described. (Found by the review.) Neither is a record whose converter an attribute of the application makes: when no converter of the options takes a record, its options are read only from the attribute of FSharp.SystemTextJson, which shows them. (Found by the review too.) Only the factory of FSharp.SystemTextJson itself shows the options: a class that an application derives from it can make the converter of a record with other options than its own, so its records are not described. (Found by the review of the next step.)
  • A record is described only when the serializer made its contract. The rules above are the ones the serializer follows when it chooses a converter: the first one among the options that takes the type, then the one an attribute names. A resolver of the application (IJsonTypeInfoResolver) can make the contract of a record from any converter instead, also from the converter of FSharp.SystemTextJson with options of its own, while the options hold that converter with other options. A contract names the resolver that made it (JsonTypeInfo.OriginatingResolver): the resolver of the serializer, also as a class that an application derives from it to change some contracts, or nothing for a contract that the application made from a converter. A record with such a contract is not described. (Found by a planted defect that no test failed for any more, see below: the layout read the options of the converter among the options for such a record.) For the same reason the contract of a member with a converter of its own is asked by the rules of the serializer, and not of the resolver that the options have: the serializer writes such a member with its converter whatever contract a resolver of the application makes for the type. (Found by reading the layout for other places that went by a resolver: a read-only array that its converter writes as one text was taken for a collection, and so for written, when a resolver made the contract of arrays itself.)
  • The setting of old that ignores null counts. JsonSerializerOptions.IgnoreNullValues is obsolete, and System.Text.Json and FSharp.SystemTextJson still follow it: a null is left out as with DefaultIgnoreCondition = WhenWritingNull, while the options show the condition Never and the contract no rule. The layout reads the setting where it reads the condition; the options refuse the two together. (Found by the review.)
  • What a member declares is its own. An override has neither the JsonPropertyName nor the JsonIgnore condition of the declaration it overrides, for the serializer and for the layout. (The condition was found by the review: the layout read it from the base declaration.)
  • A property of a record that is not public is not written. FSharp.SystemTextJson writes the fields of a record, whatever their accessibility, and of its other properties only public ones, when its options or JsonInclude say so. An internal property with JsonInclude is written by System.Text.Json for an object and not by FSharp.SystemTextJson for a record. (Found by the review.) A member of a record that is not written, for this reason or because it is marked to be ignored, has the name it declares when it declares one, and without a value it is null or left out, as whatever writes its property decides. (Found by the review: the layout said null of a property that the library does not write.)
  • A member that names a converter is left out, or written as null, before the converter is asked. With [<JsonConverter>] on a property, the serializer leaves a null out by the JsonIgnore condition of the member or by the condition of the options, and writes null itself otherwise; the converter of the member gets the null only when it asks for it with HandleNull. So a reference or a nullable of such a member is described as any other member, and not described only when its converter asks for the null. An option, a value option or a skippable value of such a member is not described: the converter writes all of it, with a value and without one. (Found by the review: the layout said nothing of any member with a converter.)
  • A converter that writes null itself. The serializer writes a null that is not left out as null, unless the converter of the type asks for it with HandleNull; such a converter of an application can write anything for it. When the layout does not know the converter, a value without one is not described where it is written, and is left out as always where the options or the member leave a null out. (Found by the review.)
  • System.Text.Json writes F# options by itself: Some x as x, None as null. The ADR had this layout refuse options without FSharp.SystemTextJson; since what is written is known and tested, it describes them.

A member that is never written and declares a name has that name. [<JsonPropertyName "$type"; JsonIgnore>] is how the application marks a member that stands for a property 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 (decision of the ADR, §21.2).

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: 228 in JsonLayoutTests (FSharp.Azure.Cosmos.Quotations.Tests has 1093 now). The serializer is the oracle.
    • 198 rows: nine types of a model made for the layout, each written with every way to set the serializer up that can write it, 25 in all (9 of System.Text.Json alone, 16 with FSharp.SystemTextJson; one of them is the set-up of the application: internal tags, options always skipped, snake case). A type is written twice, once with every optional member present and once with every one absent. Each member is looked for in the first 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.
    • 30 tests of what a document cannot show: the name of a member that is never written, also when it is left out for being read-only, an override next to its base declaration, a member that code decides about (ShouldSerialize), 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 (that converter writes it only when no converter of the options takes records, because the serializer asks the options first), 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, a read-only member with a rule that code has set, a value whose converter writes null itself, a value that is no member, a read-only field (of a type made at run time, because F# declares no public read-only field), that a question is answered once, also when several threads ask it at a time, that the options of the caller keep their resolver and are left as they are, and that the resolver takes no change once the layout has asked it.
  • Can the tests fail? 91 one-line defects are planted in the layout, one at a time, and reverted: a rule of either serializer switched off or swapped, a name read from the wrong attribute, the overrides not read, the options of the wrong converter taken, a read-only member written, the copy of the options not used. Each fails at least one test. Where one did not when it was first planted, the model lacked a member or a set-up, which is added: a nullable field in the record with the attribute, the encoding of an ignored field of a record, a field that carries a name meant for a union, options that bring a resolver of their own, and a record that names a converter in an attribute. The whole list is run again after every change of the layout: after the second review two of the defects passed unnoticed, one in a case that could not be reached any more, which is removed, and one that the record with the converter tells now. The last defect that was added, a member laid out as the type that declares it is written and not as the type that inherits it, passed unnoticed at first, and the test of an inherited member tells it now. One defect ends the test process with a stack overflow, which counts as a failure: a converter factory of a member that asks the options it is given for a converter is asked without end when those options hold the factory itself, so the layout has the factory make the converter first. After the eighth review one defect passed unnoticed again: any record taken for one that FSharp.SystemTextJson writes, whatever converter its contract names. The check of the factory had taken over the case that its test was about. What was left to tell the two apart was a gap in the layout itself, the record whose contract a resolver of the application makes, which is closed and has a test now; a member of a union, which the same line keeps from being read as a member of a record, has one too.
  • The model is written by hand. Its values with every optional member present and absent are fixed values in StoredModel.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.
  • 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. The layout makes a copy and asks the copy, so options that are still being set up stay that way, and a change of them after the layout is made does not reach it. The copy has the resolver of the options when they have one, and the default one otherwise. That resolver is not copied, because it cannot be: it can be any object. So the layout asks the resolver that the serializer asks. A change of it before the layout is asked for the first time reaches the layout as it reaches the serializer, and from that question on the resolver of System.Text.Json refuses a change of its modifiers, as it does after the first document is written. The layout and the serializer cannot come to disagree unnoticed that way, which a copy of the modifiers would allow: a modifier added later would reach the serializer alone. (Raised by the review, which asked for such a copy; the documentation said that no later change reaches the layout, which was wrong for the resolver and is corrected, with a test.) 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, such as a converter factory that a member names. (Found by the review of the next step: two threads that asked at a time could both work the answer out.)
  • Not in this step:
    • an option that FSharp.SystemTextJson writes as a union with the name of its case (UnwrapOption off), which is NotDescribed until unions are;
    • unions, collections, maps, tuples, enumerations and type discriminators, the other members of IJsonLayout in the ADR;
    • JsonLayout.ofClientOptions, which needs the Cosmos DB client and comes with the functions that run a query;
    • a member that is not optional and is left out all the same, because the options ignore default values and it holds one (Count = 0). The ADR has MemberDefaults for it.
    • OverrideMembers of FSharp.SystemTextJson, which gives a field other attributes by its name: the library keeps it internal, so the layout cannot read it.
  • One function instead of two. The ADR has JsonLayout.fSharpSystemTextJson and JsonLayout.systemTextJson. Since the layout asks the serializer who writes each type, the two are one function, and options with FSharp.SystemTextJson for some types and without it for others are described too.
  • 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; both commits build, and the second passes the tests.
  • No changelog entry: nothing is packed.

🤖 Generated with Claude Code

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>
Copilot AI balanced review requested due to automatic review settings October 11, 2026 18:33

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

Supported converter and null-ignore configurations can produce layout results that disagree with serialized documents.

2 open findings
What changed in this PR

Adds serializer-aware JSON layout discovery for quotation-to-Cosmos query translation, alongside prerequisite normalization, evaluation, and parameterization stages.

Changes:

  • Adds IJsonLayout and optional-value encoding inference.
  • Introduces the prerequisite quotation translation pipeline.
  • Adds comprehensive unit tests and project wiring.
File Description
.github/​copilot-instructions.md Documents the new projects and dependency.
Directory.Packages.props Adds FSharp.SystemTextJson.
FSharp.Azure.Cosmos.slnf Includes the new projects.
FSharp.Azure.Cosmos.slnx Includes the new projects.
src/​Cosmos.Quotations/​AssemblyInfo.fs Adds generated assembly metadata.
src/​Cosmos.Quotations/​Evaluator.fs Evaluates quotation-independent expressions.
src/​Cosmos.Quotations/​FSharp.Azure.Cosmos.Quotations.fsproj Configures the quotations library.
src/​Cosmos.Quotations/​InternalsVisibleTo.fs Exposes internals to tests.
src/​Cosmos.Quotations/​JsonLayout.fs Implements serializer-aware document layout.
src/​Cosmos.Quotations/​Normalizer.fs Normalizes quotation expressions.
src/​Cosmos.Quotations/​Parameterizer.fs Builds query shapes and parameter slots.
src/​Cosmos.Quotations/​Query.fs Adds constant and parameter markers.
src/​Cosmos.Quotations/​TranslationError.fs Defines translation errors and source ranges.
tests/​Cosmos.Quotations.Tests/​EvaluatorTests.fs Tests expression evaluation.
tests/​Cosmos.Quotations.Tests/​FSharp.Azure.Cosmos.Quotations.Tests.fsproj Configures the unit-test project.
tests/​Cosmos.Quotations.Tests/​JsonLayoutTests.fs Tests layout against serialized documents.
tests/​Cosmos.Quotations.Tests/​Model.fs Provides quotation test models.
tests/​Cosmos.Quotations.Tests/​NormalizerErrorTests.fs Tests normalization failures.
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 shape-key invariants.
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 Provides serialization test models.
tests/​Cosmos.Quotations.Tests/​TestCategories.fs Defines sorted test categories.
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/Parameterizer.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from b8a89b2 to 9c65c4c Compare October 11, 2026 19:39
@xperiandri
xperiandri requested a balanced review from Copilot October 11, 2026 19:40
xperiandri added a commit that referenced this pull request Oct 11, 2026
The fourth step of phase 1 (#61) has the first two members of
IJsonLayout and one implementation, JsonLayout.ofSerializerOptions.
The record says where it departs from section 10: the serializer is
asked for its contract instead of having its configuration read again,
so one function describes System.Text.Json with and without
FSharp.SystemTextJson; plain System.Text.Json options are described,
not refused; and what the two serializers were found to write for a
member without a value.

Co-Authored-By: Claude Opus 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

Serializer settings can currently produce incorrect member names and null-versus-undefined encodings.

3 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch 2 times, most recently from 8728fac to 7a3e35e Compare October 11, 2026 20:44
@xperiandri
xperiandri requested a balanced review from Copilot October 11, 2026 20:46

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

JSON layouts currently disagree with both serializers when the supported legacy IgnoreNullValues option is enabled.

1 open finding
4 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from 7a3e35e to 32bca37 Compare October 11, 2026 21:05
xperiandri added a commit that referenced this pull request Oct 11, 2026
The fourth step of phase 1 (#61) has the first two members of
IJsonLayout and one implementation, JsonLayout.ofSerializerOptions.
The record says where it departs from section 10: the serializer is
asked for its contract instead of having its configuration read again,
so one function describes System.Text.Json with and without
FSharp.SystemTextJson; plain System.Text.Json options are described,
not refused; and what the two serializers were found to write for a
member without a value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from 32bca37 to d58592f Compare October 11, 2026 21:40
xperiandri added a commit that referenced this pull request Oct 11, 2026
The normalizer reports the outermost construct that a query cannot
express also when the rewriting fails inside it, and an inner binding
of a variable hides an assignment from the outer one (#58). The layout
reads the obsolete setting that ignores null, which both serializers
still follow (#61). The counts of tests and planted defects follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri requested a balanced review from Copilot October 11, 2026 21:41

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

Serializer layout results can still diverge for inherited ignore attributes, private record properties, and options-level converters.

3 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Cosmos.Quotations/JsonLayout.fs
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from d58592f to 80bfb61 Compare October 11, 2026 22:45
xperiandri added a commit that referenced this pull request Oct 11, 2026
Every call of a function with a reflected definition gets nodes of its
own (#58). The type that a value is declared with decides whether it
is a literal, so a nullable and an option are none (#60). In the
layout an override has no condition of the declaration it overrides, a
property of a record that is not public is not written, and a value
whose converter writes null itself is not described (#61). The counts
of tests and planted defects follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri requested a balanced review from Copilot October 11, 2026 22:46

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.

Comment on lines +450 to +454
match computed.TryFind expr with
| ValueSome value -> value
| ValueNone ->
let value = computedValueOf computed expr
computed.Add (expr, value)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the rule and no oversight; it came up in the review of #59. One node of the quotation is one value and one slot, at every place the node is at. The compiler writes every use of a value as a node of its own, so one node at two places has two sources: a value that was bound once, which the normalizer writes at its places as one node, and a quotation that holds one node twice, because one quotation was spliced into two places or the tree was built that way through the API.

For the second source compiled code runs the part at each place. Here it runs once, as every part that reads nothing of the document runs once for the whole query and not once for every document. The two differ only for code that gives another value each time it runs. What it gives: a fragment that is spliced twice, (%isRecent) p.Created && (%isRecent) p.Changed, has one parameter for what it captured and one moment of DateTime.UtcNow for both places.

Tracking bindings instead means a pass over every quotation that gives each place nodes of its own before anything is bound (about a microsecond for a small query, on the path of every query), or a set of bound nodes that the normalizer hands to the two later stages. I have not done either: the gain is two runs instead of one for code with side effects, which the normal form does not promise to keep.

The documentation says the rule in both places now (b6c4b45 for the evaluator, f12eff3 for the slots). The change of the last round for functions is the other side of it: the nodes of a definition are not nodes of the caller's quotation, so every call gets its own.

I leave this thread open for the maintainer: if two places of one spliced quotation have to be two values, the pass above is the change.

Comment thread src/Cosmos.Quotations/JsonLayout.fs Outdated
Comment thread src/Cosmos.Quotations/JsonLayout.fs
Comment on lines +400 to +401
if found.Uses = 0 then
body

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

That is the contract of the normal form, as the documentation of Normalizer.normalize states it: the normal form computes what the quotation computes as long as the code in it has no side effects. A value bound by let is written at the places of its variable, so its code runs where they are, and a bound value that nothing reads is dropped with its code, as a part of a tuple that nothing reads is.

It is the design of the ADR (§7, inlineLets). A query has no statements, so a binding cannot stay one, and refusing every binding whose value might raise would refuse let limit = settings.Limit too.

The sentence names exceptions now (7cf87ad): "An exception is such an effect: code that is dropped raises none, and code that is written at the places of its variable runs where they are." I leave this thread open for the maintainer.

@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from 80bfb61 to 80e56bd Compare October 12, 2026 00:44
xperiandri added a commit that referenced this pull request Oct 12, 2026
A receiver that is null is reported after the arguments of the call
are computed (#59). A record whose converter an attribute of the
application makes is not described, and a read-only member with a rule
that code has set is not taken to be left out (#61). One node at two
places stays one value and one slot: the record says why, and what
would change it.

Found while the next step was built, and fixed in #61: a read-only
member is a collection, which the serializer writes all the same, by
the converter that writes it, and that can be one the member names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 00:45

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

Serializer layout handling remains incorrect for extension data, omitted record properties, and explicitly cleared serialization predicates.

5 open findings
2 resolved since last review

🧠 Review effort: Balanced

Comment on lines +513 to +516
&& (
match property.ShouldSerialize with
| null -> true
| _ -> false

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right about the serializer, and confirmed: with IgnoreReadOnlyProperties, a read-only Id whose rule a modifier sets to null is written, {"Id":"1","Kept":1}, and the contract shows ShouldSerialize = null for it exactly as for a property that nothing touched. The serializer keeps the difference in _isUserSpecifiedShouldSerialize, a field that is not public.

Not changed, because nothing public tells the two apart, and each guess is wrong somewhere. Taken to be left out, as now, the layout is right for every read-only member that nothing touched and wrong for one whose rule code sets to null on purpose. Taken to be written, it would be wrong for every read-only member of a contract that some modifier went over, which is the common case of the two; the test of the member with a rule next to one without is that case. Reading the private field would settle it, with a fallback for a runtime that names it otherwise. The ADR keeps this library away from the internals of what it builds on, so I did not do that without being asked.

ae02e95 states the limit where the rule is applied, and the description of the pull request has it: a member whose rule is set to null is taken to be left out. I leave this thread open for the maintainer.

Comment thread src/Cosmos.Quotations/JsonLayout.fs
Comment thread src/Cosmos.Quotations/JsonLayout.fs
xperiandri added a commit that referenced this pull request Oct 12, 2026
A call of a function that calls itself stays a call and is an error
only when it is left in the normal form and reads the document, so
code that never runs may hold one however the constant gets to its
condition (#58). The layout is asked about a member together with the
type it is read from, because a quotation names an inherited property
as its declaring type has it, and a union with a case that is null is
left out where a null is left out (#61).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from 74f623a to 2e723eb Compare October 12, 2026 03:57
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 03:58

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.

Comment thread src/Cosmos.Quotations/JsonLayout.fs
Comment thread src/Cosmos.Quotations/Normalizer.fs

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.

Comment on lines +302 to +304
match options.TypeInfoResolver with
| null -> JsonSerializerOptions (options, TypeInfoResolver = DefaultJsonTypeInfoResolver ())
| _ -> JsonSerializerOptions options

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both observations are right, and I checked them:

  • the copy of the options has the resolver instance of the caller;
  • a modifier that is added after ofSerializerOptions and before the layout is asked for the first time reaches the layout;
  • after the first question resolver.Modifiers.Add throws InvalidOperationException ("Cannot add callbacks to the 'Modifiers' property after the resolver has been used for the first time").

The documentation was wrong to say that no later change reaches the layout. It is corrected in f4554e7, and a test in 8613030 pins the three facts.

I did not take the snapshot, for this reason. The layout has to say what the serializer writes, and the serializer keeps asking the resolver of the caller. With a copy of the modifiers, a modifier that is added after the layout is made would reach the serializer alone: the layout would describe documents that are not written that way, and nothing would tell. With one resolver for both, the two cannot come to disagree unnoticed. A change before the first question reaches both, and a change after it is refused at the place of the change, exactly as after the first JsonSerializer.Serialize.

Two more points. A resolver that is no DefaultJsonTypeInfoResolver cannot be copied at all, because it can be any object. And requiring a resolver that is already final would refuse options that are still being set up, which the layout otherwise takes care to leave as they are.

This is a choice between two behaviours and not a defect either way, so I leave the thread open for the maintainer.

xperiandri and others added 9 commits October 12, 2026 07:54
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
  and an option are no literals, with a value or without one: a
  nullable with a value is boxed as that value, and the compiler writes
  neither as a value;
- 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 or a union of F#, which are written as null even then; a
  union with a case that is null is left out again, because that null is
  a value. 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.

A null that is not left out is written as null, unless the converter of
its type asks for it with HandleNull. Such a converter can write
anything for it, so when the layout does not know the converter, it
does not describe the value without one.

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.

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 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>
@xperiandri
xperiandri force-pushed the feat/quotations-json-layout branch from 8c92109 to 8613030 Compare October 12, 2026 06:27
@xperiandri
xperiandri requested a balanced review from Copilot October 12, 2026 06:28
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>

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

Tuple-valued record members can receive an encoding even though FSharp.SystemTextJson cannot serialize a null tuple.

5 open findings

🧠 Review effort: Balanced

Comment on lines +693 to +698
recordOptions.IgnoresNull
&& (hasNullCase valueType
|| not (
FSharpType.IsRecord (valueType, AnyAccessibility)
|| FSharpType.IsUnion (valueType, AnyAccessibility)
))

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