Repository navigation
feat(quotations): tell the values of a quotation from its shape - #60
Open
xperiandri wants to merge 8 commits into
Open
xperiandri wants to merge 8 commits into
xperiandri wants to merge 8 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 11, 2026
The result of the third step of phase 1 (#60): which values of a quotation are parameters, what its shape key holds, and where the implementation departs from section 11. A literal is a part of the key, a value that the quotation builds is not computed, a branch that is not taken is not computed, and the key is the key of the normal form. Also what the review of #59 changed in the two steps before: the normalizer does not read the branch that a constant condition does not choose, and the evaluator computes the members of a nullable and raises NullReferenceException for a function that is null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Decimal literals, constant function values, and invalid runtime coercions are currently handled incorrectly.
2 open findings
What changed in this PR
Adds the quotation-processing pipeline that normalizes expressions, evaluates document-independent parts, and parameterizes values for future query translation and caching.
Changes:
- Adds normalization, evaluation, shape-key, template, and parameter-slot infrastructure.
- Adds
Query.constantandQuery.parameter. - Adds extensive emulator-free MSTest coverage and project integration.
| File | Description |
|---|---|
.github/copilot-instructions.md |
Documents the quotation projects. |
FSharp.Azure.Cosmos.slnf |
Adds new projects to the filter. |
FSharp.Azure.Cosmos.slnx |
Adds new projects to the solution. |
src/Cosmos.Quotations/AssemblyInfo.fs |
Defines assembly metadata. |
src/Cosmos.Quotations/Evaluator.fs |
Evaluates document-independent expressions. |
src/Cosmos.Quotations/FSharp.Azure.Cosmos.Quotations.fsproj |
Configures the quotation library. |
src/Cosmos.Quotations/InternalsVisibleTo.fs |
Exposes internals to tests. |
src/Cosmos.Quotations/Parameterizer.fs |
Builds shape keys, slots, and templates. |
src/Cosmos.Quotations/Query.fs |
Adds constant/parameter markers. |
src/Cosmos.Quotations/TranslationError.fs |
Defines translation errors and locations. |
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/Model.fs |
Supplies quotation test models. |
tests/Cosmos.Quotations.Tests/NormalizerErrorTests.fs |
Tests normalization failures. |
tests/Cosmos.Quotations.Tests/NormalizerSpellingTests.fs |
Tests equivalent expression forms. |
tests/Cosmos.Quotations.Tests/NormalizerTests.fs |
Tests normalization behavior. |
tests/Cosmos.Quotations.Tests/ParameterizerShapeTests.fs |
Tests shape and template laws. |
tests/Cosmos.Quotations.Tests/ParameterizerTests.fs |
Tests parameterization details. |
tests/Cosmos.Quotations.Tests/QuotationAssert.fs |
Adds quotation assertion helpers. |
tests/Cosmos.Quotations.Tests/TestCategories.fs |
Defines sorted test categories. |
tests/Cosmos.Quotations.Tests/testconfig.json |
Enables method-level parallelism. |
🧠 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-parameterizer
branch
from
October 11, 2026 17:32
395c222 to
e0729c3
Compare
xperiandri
added a commit
that referenced
this pull request
Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a quotation are parameters, what its shape key holds, and where the implementation departs from section 11. A literal is a part of the key, a value that the quotation builds is not computed, a branch that is not taken is not computed, and the key is the key of the normal form. Also what the review of #59 changed in the two steps before: the normalizer does not read the branch that a constant condition does not choose, and the evaluator computes the members of a nullable, raises NullReferenceException for a function that is null and computes a coercion as the cast it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 11, 2026 17:38
e0729c3 to
6928ad0
Compare
xperiandri
added a commit
that referenced
this pull request
Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a quotation are parameters, what its shape key holds, and where the implementation departs from section 11. A literal is a part of the key, a value that the quotation builds is not computed, a branch that is not taken is not computed, and the key is the key of the normal form. Also what the review of #59 changed in the two steps before: the normalizer does not read the branch that a constant condition does not choose, and the evaluator computes the members of a nullable, raises NullReferenceException for a function that is null and computes a coercion as the cast it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 11, 2026 17:51
6928ad0 to
573f72a
Compare
xperiandri
added a commit
that referenced
this pull request
Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a quotation are parameters, what its shape key holds, and where the implementation departs from section 11. A literal is a part of the key, a value that the quotation builds is not computed, a branch that is not taken is not computed, and the key is the key of the normal form. Also what the review of #59 changed in the two steps before: the normalizer does not read the branch that a constant condition does not choose, and the evaluator computes the members of a nullable, raises NullReferenceException for a function that is null and computes a coercion as the cast it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 11, 2026 18:17
573f72a to
8d42d45
Compare
xperiandri
added a commit
that referenced
this pull request
Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a quotation are parameters, what its shape key holds, and where the implementation departs from section 11. A literal is a part of the key, a value that the quotation builds is not computed, a branch that is not taken is not computed, and the key is the key of the normal form. Also what the review of #59 changed in the two steps before: the normalizer does not read the branch that a constant condition does not choose, and the evaluator computes the members of a nullable, raises NullReferenceException for a function that is null and computes a coercion as the cast it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 of 6 tasks
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 11, 2026 19:39
8d42d45 to
a14a43d
Compare
3 of 6 tasks
xperiandri
added a commit
that referenced
this pull request
Oct 11, 2026
The result of the third step of phase 1 (#60): which values of a quotation are parameters, what its shape key holds, and where the implementation departs from section 11. A literal is a part of the key, a value that the quotation builds is not computed, a branch that is not taken is not computed, and the key is the key of the normal form. Also what the review of #59 changed in the two steps before: the normalizer does not read the branch that a constant condition does not choose, and the evaluator computes the members of a nullable, raises NullReferenceException for a function that is null and computes a coercion as the cast it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 11, 2026 20:43
a14a43d to
e1514b1
Compare
3 of 6 tasks
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
2 times, most recently
from
October 12, 2026 05:22
8ebda1e to
2e40b28
Compare
3 of 6 tasks
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 12, 2026 06:22
2e40b28 to
621a1d9
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>
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 12, 2026 07:42
621a1d9 to
82958a4
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>
One condition can be written in many ways: with pipes, composition, let bindings, functions marked with ReflectedDefinition and spliced quotations. The stages after this one should not have to know every spelling, so the normalizer rewrites them away. What is left holds operators, members of the document and calls whose code cannot be read. Rewritten away: - |>, <|, ||>, |||>, <||, <|||, >> and <<, recognised by the methods of FSharp.Core and never by name; - a function that is written and applied in place, and a let whose variable is never assigned to. That includes what the compiler binds itself: the value a match looks at, the parts of a tuple pattern and the copy of a struct, which is a mutable variable when the compiler cannot tell that the called member leaves the struct alone; - a call of a function, method, property getter or extension member with a reflected definition, also an inline one that takes its operators as witnesses, a generic one and a member of a base class. A member that a derived class can override stays a call, and so does a module-level value, which is computed once; - the reading of an item, field or case of a tuple, record, anonymous record or union that is built in the same place, and fst, snd and Item1..Item7, which become the reading of an item; - a comparison of a boolean with true or false, a choice between true and false, a condition that is a constant and a double negation. The branch that a constant condition does not choose is not read at all: code that never runs needs no normal form. A captured boolean is not a constant: it is a parameter of the query. The default value of a boolean, which the compiler writes for bool (), is false; - the witnesses of a call, the address of a struct anonymous record and the compiled value that an auto-quoted argument carries. Reported as a TranslationError instead: - a call of a function with a reflected definition that calls itself, directly or through others, when the call reads a value of the document; - a quotation whose rewriting does not end, after 10 000 steps or when the stack of the thread runs low, whichever comes first; - a compiled function that is applied to a value of the document, however the quotation gets to it: by its name, from a property or a field of a captured value, or as what a call returns. A function that the document chooses, as the result of a choice does, is reported as one that is chosen when the query runs. An application to captured values only is left for the stage that computes values outside the query; - a mutable variable that is assigned to, an assignment, the address of a value, a sequence of statements, a loop, exception handling and a recursive function. Of the constructs that stay in the normal form, the outermost one is reported. An assignment, a sequence of statements, a loop, exception handling and a recursive function are errors whatever is inside them, so the outermost of these is reported also when the quotation inside it has no normal form at all. A quotation that is nested too deeply stays the error whatever stands around it: the error of a construct shows the construct, and printing something that deep needs the stack that has run out. Only what stays is an error. A call of a function that calls itself is not replaced by its definition, which would never end: it stays a call, and so does the call that led to it. What becomes of such a call is decided when the whole quotation is normalized. One that reads a value of the document is the error. One that reads none is a value to compute outside the query, as a call of any compiled function is. And one in code that never runs is gone by then, so the branch that a constant does not choose may hold one however the constant gets to the condition: bound by a let, passed to a function written in place or to a function with a definition. The compiler keeps the definition of an inline function of a module for the form of the function that takes the witnesses, and the definition of an inline member of a type for the member itself; both take the witnesses first, then the receiver, then the arguments. A definition is applied only to arguments of the types it takes: a call that is built through the API can pass an inline member no witnesses, and such a call stays a call. Reflected definitions and the argument groups of their methods are kept for the process: FSharp.Core unpickles a definition on every lookup, which took 16 microseconds here, and searches the resources of the assembly for a method that has none, which took one. Substitution is left to FSharp.Core where nothing inside the body needs care, because it builds nodes without validating them, while rebuilding one member access through the public API cost 4 microseconds of reflection. Two things need care. One variable can be bound at several places, since a quotation that is spliced twice arrives as the same nodes, so a binding that hides the variable or would capture a variable of the value is respected, also when the question is whether a mutable variable is assigned to. And a value that is bound once and used at several places has to be one node at all of them: the code computes it once, and the stage that computes values outside the query tells by the node that the places are one value. FSharp.Core builds every node of a body anew, which makes two nodes of such a value as soon as something else is written into the body that holds it. So a body with such a node is substituted node by node, and the node is rewritten and normalized once for all its places. The places are those of one call: the definition of a function is one tree for the process, so every call after the first normalizes a copy of it, and a value that the function binds is not one node across calls, which each compute it for themselves. A node that the quotation comes with at several places is kept one node in the same way: a quotation that is spliced into two places is the same nodes at both, and code can build a tree with one node twice. The quotation is walked once for such nodes before it is normalized, and not to its end when it is nested too deeply, which the normalization then reports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new test project, FSharp.Azure.Cosmos.Quotations.Tests, with unit tests that need no emulator. Most tests are pairs: one way to write a function next to the plain way to write the same function. For every pair three things are checked: both ways have one normal form, the normal form is returned as the same node when it is normalized again, and it computes what the compiled code computes on sample values. The compiled code and the quotation come from one lambda, which the compiler passes both ways to a parameter marked with ReflectedDefinition(true), and Unquote evaluates the quotation. The other tests state what a pair cannot: what must stay as it is, what must be gone, how one variable that is bound at two nested places is treated, that a value which is bound once stays one node at all its places, and so does a quotation that is spliced into two places, what every error says and which of several errors is reported. The test project is compiled with --quotations-debug, so the errors carry the place in the source; a quotation built through the API covers the case without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A query has parts that do not depend on the document, such as DateTime.UtcNow.AddDays -30.0 or limit + 1. Their values are known before the query runs and become its parameters. The evaluator computes such a value from a quotation in normal form. It works by reflection: properties and fields are read, methods and constructors called, tuples, records, unions and arrays built and taken apart, types and union cases tested, captured functions applied. Nothing is compiled, because turning a quotation into a delegate costs hundreds of microseconds. A choice computes only the branch its condition chooses, so a && b does not compute b when a is false. 37 operators of FSharp.Core refuse to be called by reflection: -, /, %, the unary and bitwise operators, the shifts, the conversions between number types and their checked forms. Their methods raise NotSupportedException, "Dynamic invocation of op_Subtraction is not supported", while + and * can be called. For each of them LanguagePrimitives has a function for operands of any types, which the compiler passes to the operator as a hidden argument; the evaluator takes the pairs from there. The checked operators are paired by hand: the compiler passes them the unchecked functions, with which Checked.byte 300 would give 44 instead of raising. Those functions fail for an enumeration type, so they are called for the number type under it, and a result that is an enumeration value is made one again. That also covers int over an enumeration value, whose own method can be called for other types. What reflection finds out about a type or a method is kept for the process: the precomputed constructors and readers of FSharp.Reflection take a record from 8 microseconds to 0.3 and an item of a tuple from 3 to 0.3. An exception of the code that is run becomes a TranslationError with the new code ClientEvaluationFailed and the exception itself in the new field Cause, taken out of the TargetInvocationException of reflection. A receiver that is null raises NullReferenceException, as compiled code does, and so does a function that is null when it is applied. Compiled code finds the null after it has computed the arguments, so an argument that raises comes first here as well. A member of a nullable is computed by a function that takes the nullable. Reflection has nothing to call such a member on: a nullable is boxed as its value or as null, never as itself, so HasValue of a nullable without a value would raise where it has to give false. The default value of a struct is its zeroed value, for which no constructor runs: a struct of another language can declare a constructor without parameters, and what that constructor makes is another value. A nullable without a value is null as an object. A coercion is a cast: a value that is not of the target type raises InvalidCastException, as the cast does in compiled code, and is not passed on as a value of a type it does not have. The compiler writes only coercions that cannot fail; a quotation that is built through the API can hold one that does. A node is computed once. The normalizer writes a value that a let bound at every place it is used, as the very same node, and the code computes a bound value once: let made = obj () in (made, made) is a pair of one object. So the evaluator keeps the values of the nodes it has computed, by their identity: for one part in tryEvaluate, and in tryEvaluateOnce for all the parts of one quotation, whose caller keeps them. That costs a part about 0.1 microseconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every case is code that reads no variable, passed to the test together with its quotation by the compiler, so the expected value is what the code gives when it runs. There is a case for every kind of node, for every operator that FSharp.Core does not let reflection call, for every member of a nullable with and without a value, and for enumeration values, whose computed value also has to be of the enumeration type and not the number under it. Code that raises is checked for the error it becomes: the code, the exception as the cause, its name in the message and the place in the source. A coercion that its value does not fit is built through the API and checked for the exception of the failed cast. The default value of a struct with a constructor without parameters, which F# does not declare, is checked on a type that the test makes at run time. A value that is bound once is checked to be computed once: at two places of one part, and in two parts for a caller that keeps what was computed. One test asks FSharp.Core which operators it marks as not callable and fails when one of them has no stand-in in the evaluator, so that a new version of FSharp.Core cannot add one unnoticed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stage that turns values into parameters has to choose the parts of a quotation to compute before it computes any of them: a part that turned out not to be computable half way would have run a half of its code for nothing. Evaluator.canEvaluate tells without running anything whether a part is made only of what the evaluator computes: it reads no variable and holds no function, delegate or quotation that is written in the quotation. That stage asks about a part and then about the parts under it. To refuse a part, the evaluator walks down to the reason, so it would look at the same parts once for every part above them. The parts it refused are remembered in a set that the caller keeps for all its questions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
xperiandri
force-pushed
the
feat/quotations-parameterizer
branch
from
October 12, 2026 08:54
82958a4 to
43f5fab
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, raised on #60): the default value of a boolean, which the compiler writes for bool (), is the constant false, so the branch that it does not choose is gone from the normal form and the parameterizer computes nothing of it; - the values of the layout (#62): a converter of tuples is the one of FSharp.SystemTextJson only when its class is the library's, because an application can derive a converter from the public base class; - the unions of the layout (#63): no case of a union is described when one of its cases is written with the name of the tag twice, for the fields or for one field next to an internal tag. The counts of tests and planted defects follow the branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines
+398
to
+399
| | TypeCode.Single | ||
| | TypeCode.Double -> true |
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 third step of phase 1 of ADR 0001 (#34). Two runs of one query differ only in the values it was written with:
The text of the query is the same every time,
… WHERE (p["age"] > @limit) AND (p["name"] = @p1); only the values of the two parameters change. So a query has to be translated once, and every later run only has to find that translation and supply the values. This pull request adds what tells the two apart,Parameterizer. For a quotation in the normal form of #58 it gives three things:limitand whatname.Trim ()computes.ShapeKey): everything else in the quotation, in a form that can be compared and hashed. Two quotations with equal keys give the same query text, so the key is what a cache of translations will be looked up by. A quotation cannot be its own key: it compares its variables by identity, and every evaluation of<@ … @>creates new variables; it also compares the captured values, which are what a key has to leave out.18,"vip",true,null,StringComparison.Ordinal, a value declared[<Literal>]18m[<Literal>]; as a parameter it keeps its digits, where a number in the text of a query is read as a floating-point numberlimitlimit + 1,name.Trim (),settings.Tenant,DateTime.UtcNow.AddDays -30.0, a module-level valueSome name,[ a; b ],(limit, 1)Query.parameter 18Query.constant kindQuery.constantandQuery.parameterare new public functions; outside a quotation each returns its argument.What needed a decision
A literal is a part of the text. The ADR made a literal a parameter by default and, in another place, wrote a value declared
[<Literal>]into the text. Both cannot hold: the compiler writes such a value into the quotation exactly as it writes18, so the two cannot be told apart. The rule here is the one of EF Core: what is written as a constant is a constant, what is captured is a parameter. It is also what the translator needs: it has to see thatp.Name = nullcompares with null, and whichStringComparisonwas written.Query.parameteris for the literal that should be a parameter all the same.A value that the quotation builds stays visible. The ADR computed every part that reads nothing of the document,
Some nameand[ a; b ]included, and each would have been one parameter. But the ADR also wantsp.Nick = Some nameto become "has a value, and the value is@name", and a list written item by item to becomeIN (@a, @b). The translator can do neither with a parameter that holds an option or a list. So what is computed is code — calls, readings of properties, constructors of classes — and what the quotation builds is left for the translator, part by part.A branch that is not taken is not computed. In
compiled code never reads
filter.Valuewhen there is no filter. Computing every part beforehand raisesNullReferenceExceptionhere, although the function is correct. So when the condition of a choice is known before the query runs, the parts of the branch it does not take are slots without a value. The key still holds both branches and does not depend on which one is taken, so the query text stays the same. The query does not take that branch either, so whatever stands in for the missing value changes no result.One node, one slot. 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
p.Owner = userId || p.Editor = userIdhas two slots nameduserId. The first version of this pull request made them one slot, by name, type and value. But nothing in a quotation says that two such nodes are one variable: two quotations that are spliced together can each have captured auserId, and then telling by the values made the query text depend on them.byOwner a || byOwner bhad one parameter whenaandbhappened to be equal and two when they were not (found by the review). The ADR rules that out, "SQL text never depends on run-time values", so the values decide nothing any more. This replaces the sentence of the ADR that identical captured values are one parameter. A value that has to be one parameter is bound inside the quotation,let id = userId in …: the normalizer writes a bound value at every place as the very same node.A value that is bound once is computed once, however often and wherever it is used, as in compiled code.
let now = DateTime.UtcNow in p.From <= now && p.To >= now.AddDays 1.0reads the clock once, althoughnowandnow.AddDays 1.0are two parameters: what the parameterizer has computed is kept node by node for the whole quotation, by the evaluator of feat(quotations): compute the parts of a quotation that read no variable #59. The review led to two cases where this did not hold. With twolets the normalizer made two nodes of one of the values (fixed in feat(quotations): bring a quotation into its normal form #58), and a bound value inside two different computed parts ran its code once for each.The key is the key of the normal form. feat(quotations): bring a quotation into its normal form #58 left this open: a key of the quotation as it is written would spare normalizing a query that was seen before. Three things speak for the normal form. Two ways to write one condition share one translation. The slots are the ones the translator sees, with no mapping between two sets of them. And a value that a
letbinds is one slot because the normalizer writes it as one node. The price is the normalization on every run, 5 to 25 µs; evaluating the quotation literal itself costs more (24 to 75 µs, test(benchmarks): measure what a quotation query costs on the client #56). A query on a hot path is a compiled query of the ADR, which is normalized once.A function value is an error,
CapturedFunctionValue: a compiled function can neither be read nor be the value of a parameter. feat(quotations): bring a quotation into its normal form #58 reports the function that is applied to a value of the document; this is the function that is passed, as inp.Tags |> List.exists isVip. It is refused by the type of its part before anything is computed, and by its value where the type cannot say what it is, as for a function passed as anobj.Query.constantrefuses a function the same way. A function is told by its type also when the type is a class made of the type of F# functions, which code can declare and which the class of every compiled function is. The null and the default value of a function type, which only code can build into a quotation (Expr.Value (null, typeof<string -> bool>)), are refused the same way. (Found by the review: such a null looked like a literal and was written into the key before its type was asked.)Query.constanttakes only what a literal can be: a boolean, a character, a string, an integer or a floating-point number of a built-in type, a value of an enumeration or null. The key holds the value, and equal keys have to mean one query text. Only such a value says by its equality what is written for it: an object is equal to itself after it has changed, an array is equal to nothing but itself, and two objects can be equal and be written differently. Any other value is reported and stays what it was without the function, a parameter. (Found by the review.) The type that a value is declared with decides, so a nullable is no literal, with a value or without one, and neither is the null of a union type: it is the case of the union that is represented by null, as theNoneof an option is, and the compiler writes a case as a case. (Found by the review too: a nullable with a value is boxed as that value, soQuery.constant (Nullable 5)was taken, and a nullable that code put into a quotation as a value was a literal. And only the null of an option was told: the null of a union markedUseNullAsTrueValuewas a literal.)Types of changes
The assembly is not packed yet (#58), so the packages are unchanged.
Checklist
Further comments
Tests: 410 for the parameterizer and 2 more for the evaluator (
FSharp.Azure.Cosmos.Quotations.Testshas 877 now).ParameterizerShapeTests: 47 functions of a person, every one of a shape of its own, written twice over two sets of captured values. With the second set every choice takes its other branch. Five laws: a function has one key whatever values it captured (and one hash code); no two of the 47 have one key; the key and the values are the same whether or not the template is asked for; 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. As in feat(quotations): bring a quotation into its normal form #58, the compiler passes each function together with its quotation, and Unquote evaluates the template.ParameterizerTests: what the laws cannot state. Which values are slots, under which names and with which values; what is a part of the key; that two nodes are two slots whatever they hold, with equal keys; that a value which is bound once is one slot and is computed once, also after a secondletand inside two computed parts, and that a function with a definition that is called twice has the slots of each call; that a quotation which is spliced into two places is one slot with one value, also when the normalizer has to rewrite it (the review of feat(quotations): describe how the serializer writes a member #61 found that it came out as two nodes then, which feat(quotations): bring a quotation into its normal form #58 corrects); that a branch that is not taken is not computed, also under another such branch, and that a value used both inside and outside it is; the two functions ofQuery; the tokens of a small key, one by one; and every error.EvaluatorTests: the check that tells which parts can be computed accepts every part the evaluator computes, refuses a function, a variable and a quotation, and runs no code to tell.Can the tests fail? 63 one-line defects are planted, one at a time, and reverted: a field left out of the comparison of keys, a captured value treated as a literal, two nodes made one slot because they hold one value, a node computed anew for every part that reads it, a branch computed that is not taken, the template built with its parts in another order, a function value let through, and others. Each of them fails at least one test. Where one did not when it was first planted, a test was missing and is added: the fields of a token one by one, the index of a slot, the kind of a tuple, a choice that is computed as a whole. Writing the defects down also found one in the code: a condition that is a captured variable used earlier in the quotation did not keep its branch from being computed. It is fixed and has a test.
Cost, measured in a Release build on a machine under other load, so orders of magnitude. For a quotation in normal form:
p.Created > DateTime.UtcNow.AddDays -30.0 && p.Age > limit + 1 && p.Tenant = settings.TenantThe template is built only for a query that has no translation yet: building a node of a quotation through FSharp.Core costs microseconds (feat(quotations): bring a quotation into its normal form #58), and a query that is found by its key needs no node built. One thing made a difference: to refuse a part as not computable, the evaluator has to walk down to the variable that is the reason, and the walk then asked about every part on that way again. The parts that were refused are remembered for the walk now (from about 4.6 to about 3.5 µs for the four comparisons).
Where an error is. A captured value has no place in the source of its own, and a node that the normalizer built has the place of a part under it. An error about such a part gets the place of the nearest node around it that has one.
Review of feat(quotations): compute the parts of a quotation that read no variable #59. Its five findings are fixed in the commits of feat(quotations): bring a quotation into its normal form #58 and feat(quotations): compute the parts of a quotation that read no variable #59, and this branch is built on the result.
Not in this step: the cache that the key is for (phase 2 of the ADR); the
EvaluationPolicyof the ADR, which comes with the options of the translator; the list of functions that must never be computed on the client, which comes with those functions (Cosmos.Functions); the names of the parameters in the query text, which the translator gives. A slot only carries the name of its variable.Verification:
dotnet build FSharp.Azure.Cosmos.slnxin Debug and Release with the warningsmainhas and none in the two Quotations projects; the tests pass in both configurations; Fantomas clean; the strict documentation build passes; everycrefof the changed library files names a member that a documentation file declares; each of the three commits builds, and the last one passes the tests.No changelog entry: nothing is packed.
🤖 Generated with Claude Code