diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 46978db5..924fc85b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -12,6 +12,18 @@
```text
/
+├── src/Cosmos.Sql/ – Cosmos DB SQL model (FSharp.Azure.Cosmos.Sql)
+│ ├── EquatableArray.fs – immutable array with structural equality
+│ ├── ImmutableArrayPatterns.fs – active patterns that match an `ImmutableArray` by its length
+│ ├── Syntax.fs – syntax tree of the query language
+│ ├── Keywords.fs – reserved words and the identifier rule
+│ ├── FunctionSpec.fs – specification of a built-in function
+│ ├── CatalogData.fs – one row per built-in function
+│ ├── Catalog.fs – lookup of built-in functions
+│ ├── ValidationError.fs – validation errors and options
+│ ├── Inline.fs – replacement of parameters by literals
+│ ├── Printer.fs – printer of the query text
+│ └── Validator.fs – rules of the query language that the grammar cannot express
├── src/Cosmos/ – main library (FSharp.Azure.Cosmos)
│ ├── SubStatusCodes.fs – documented Cosmos DB sub-status codes
│ ├── Cosmos.fs – core types and container extensions
@@ -28,7 +40,9 @@
├── src/Shared/ – source files that every F# project under src and tests compiles
│ └── ValueCollections.fs – `Seq`, `List` and `Array` functions that return `voption` and struct tuples
├── tests/Cosmos.Tests/ – MSTest integration test project
+├── tests/Cosmos.Sql.Tests/ – MSTest unit tests of FSharp.Azure.Cosmos.Sql, no emulator needed
├── tests/Cosmos.Tests.Infrastructure/ – shared test fixtures, emulator settings and assertion helpers
+├── benchmarks/Cosmos.Quotations.Benchmarks/ – BenchmarkDotNet measurements for the quotation translator, run by hand
├── build/ – FAKE build scripts
└── docsSrc/ – FSharp.Formatting documentation source
```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c2c4e5c..a3df1aa1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+* `FSharp.Azure.Cosmos.Sql` package: an immutable syntax tree of the Azure Cosmos DB NoSQL query language that mirrors the grammar of the .NET SDK, a deterministic printer with a parameterized and an inline mode, a validator for the rules the grammar cannot express and the catalog of the built-in functions as data, without a dependency on the Azure Cosmos DB SDK
+
### Changed
* BREAKING: the package targets `net10.0` only and no longer supports `net8.0`, which makes this a major release
* BREAKING: `Microsoft.Azure.Cosmos` updated from 3.60.0 to 3.62.0, which becomes the minimum version for consumers. Its release notes list a breaking change of their own: thin client mode is enabled by default and does not support resource-token authentication (opt out with `AZURE_COSMOS_THIN_CLIENT_ENABLED=false`)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8ceacae9..1754c59b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,10 +24,12 @@
+
+
diff --git a/FSharp.Azure.Cosmos.slnf b/FSharp.Azure.Cosmos.slnf
index 1a0488cc..f4170e2d 100644
--- a/FSharp.Azure.Cosmos.slnf
+++ b/FSharp.Azure.Cosmos.slnf
@@ -2,7 +2,10 @@
"solution": {
"path": "FSharp.Azure.Cosmos.slnx",
"projects": [
+ "benchmarks\\Cosmos.Quotations.Benchmarks\\FSharp.Azure.Cosmos.Quotations.Benchmarks.fsproj",
+ "src\\Cosmos.Sql\\FSharp.Azure.Cosmos.Sql.fsproj",
"src\\Cosmos\\FSharp.Azure.Cosmos.fsproj",
+ "tests\\Cosmos.Sql.Tests\\FSharp.Azure.Cosmos.Sql.Tests.fsproj",
"tests\\Cosmos.Tests.Infrastructure\\FSharp.Azure.Cosmos.Tests.Infrastructure.fsproj",
"tests\\Cosmos.Tests\\FSharp.Azure.Cosmos.Tests.fsproj"
]
diff --git a/FSharp.Azure.Cosmos.slnx b/FSharp.Azure.Cosmos.slnx
index 4ec89bbd..7b82a889 100644
--- a/FSharp.Azure.Cosmos.slnx
+++ b/FSharp.Azure.Cosmos.slnx
@@ -8,9 +8,13 @@
+
+
+
+
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
new file mode 100644
index 00000000..54de4419
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,42 @@
+# Third-party notices
+
+This repository and the packages built from it include material from the third-party projects listed below. The
+original copyright notices and licences are reproduced here.
+
+## Azure Cosmos DB .NET SDK v3
+
+* Source: , tag `3.62.0` (commit `fe16fe00617e962dbab246244813d605871c086d`)
+* Licence: MIT
+* Used in `FSharp.Azure.Cosmos.Sql` (`src/Cosmos.Sql`) and its tests (`tests/Cosmos.Sql.Tests`):
+ * the node inventory of the syntax tree, after the classes in `Microsoft.Azure.Cosmos/src/SqlObjects` and the
+ grammar `Microsoft.Azure.Cosmos/src/Query/Core/Parser/sql.g4`;
+ * the printing rules, after `Microsoft.Azure.Cosmos/src/SqlObjects/Visitors/SqlObjectTextSerializer.cs`;
+ * the names of the built-in functions in the catalog, from `SqlFunctionCallScalarExpression.Names` in
+ `Microsoft.Azure.Cosmos/src/SqlObjects/SqlFunctionCallScalarExpression.cs`;
+ * expected query texts in the tests, from the baselines in
+ `Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/BaselineTest/TestBaseline` and
+ `Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline`.
+
+```text
+MIT License
+
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE
+```
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/EvaluationBenchmarks.fs b/benchmarks/Cosmos.Quotations.Benchmarks/EvaluationBenchmarks.fs
new file mode 100644
index 00000000..3b3d205f
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/EvaluationBenchmarks.fs
@@ -0,0 +1,28 @@
+namespace FSharp.Azure.Cosmos.Quotations.Benchmarks
+
+open FSharp.Linq.RuntimeHelpers
+open BenchmarkDotNet.Attributes
+
+///
+/// What it costs to turn a subexpression that does not read the document into the value of a parameter, in two ways:
+/// by reflection over its nodes, as does, and by
+/// ,
+/// which converts the quotation to a LINQ expression tree and compiles it.
+///
+[]
+type EvaluationBenchmarks () =
+
+ let memberOfRecord = SampleQuery.minAgeOf { MinAge = 18; City = "Kyiv" }
+ let clockArithmetic = SampleQuery.thirtyDaysAgo ()
+
+ []
+ member _.ReflectMember () : QueryShape = ShapeKey.compute memberOfRecord
+
+ []
+ member _.CompileMember () : objnull = LeafExpressionConverter.EvaluateQuotation memberOfRecord
+
+ []
+ member _.ReflectClock () : QueryShape = ShapeKey.compute clockArithmetic
+
+ []
+ member _.CompileClock () : objnull = LeafExpressionConverter.EvaluateQuotation clockArithmetic
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/FSharp.Azure.Cosmos.Quotations.Benchmarks.fsproj b/benchmarks/Cosmos.Quotations.Benchmarks/FSharp.Azure.Cosmos.Quotations.Benchmarks.fsproj
new file mode 100644
index 00000000..289311fb
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/FSharp.Azure.Cosmos.Quotations.Benchmarks.fsproj
@@ -0,0 +1,41 @@
+
+
+
+
+ net10.0
+ $(AssemblyBaseName).Quotations.Benchmarks
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/Program.fs b/benchmarks/Cosmos.Quotations.Benchmarks/Program.fs
new file mode 100644
index 00000000..e9bb44b0
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/Program.fs
@@ -0,0 +1,14 @@
+///
+/// Runs the benchmarks chosen on the command line, such as --filter *QuotationBenchmarks*; without arguments
+/// BenchmarkDotNet asks which ones to run.
+///
+module FSharp.Azure.Cosmos.Quotations.Benchmarks.Program
+
+open BenchmarkDotNet.Running
+
+[]
+let main (arguments : string array) : int =
+ BenchmarkSwitcher.FromAssembly(typeof.Assembly).Run arguments
+ |> ignore
+
+ 0
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/QuotationBenchmarks.fs b/benchmarks/Cosmos.Quotations.Benchmarks/QuotationBenchmarks.fs
new file mode 100644
index 00000000..68dea72e
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/QuotationBenchmarks.fs
@@ -0,0 +1,76 @@
+namespace FSharp.Azure.Cosmos.Quotations.Benchmarks
+
+open System.Collections.Generic
+open System.Collections.Immutable
+open Microsoft.Azure.Cosmos
+open FSharp.Quotations
+open BenchmarkDotNet.Attributes
+
+open FSharp.Azure.Cosmos.Sql
+
+/// A translated query the way a plan cache holds it: the query text and, for every slot, the name of its parameter.
+[]
+type CachedPlan = { Sql : string; ParameterNames : ImmutableArray }
+
+///
+/// What an execution of a query costs on the client before the request leaves, once its translation is cached: the
+/// quotation literal is evaluated, walked for its
+/// , the plan is found by the key, and the values
+/// are bound to a new .
+///
+[]
+type QuotationBenchmarks () =
+
+ let minAge = 18
+ let city = "Kyiv"
+ let predicate = SampleQuery.predicate minAge city
+ let shape = ShapeKey.compute predicate
+
+ let plan = {
+ Sql = Printer.print PropertyStyle.Brackets (SampleQuery.syntaxTree ())
+ ParameterNames = SampleQuery.parameterNames
+ }
+
+ let plans = Dictionary, CachedPlan>()
+
+ do
+ plans.Add (shape.Key, plan)
+
+ // The prototype has no tests of its own, so what the numbers rest on is checked before anything is measured
+ if shape.SlotValues.Length <> plan.ParameterNames.Length then
+ invalidOp $"The predicate has {shape.SlotValues.Length} slots, the query {plan.ParameterNames.Length} parameters."
+
+ if not (plans.ContainsKey (ShapeKey.compute (SampleQuery.predicate 21 "Lviv")).Key) then
+ invalidOp "The same predicate over other values should have the same key."
+
+ if plans.ContainsKey (ShapeKey.compute <@ fun (p : Person) -> p.Age > minAge && p.City = city @>).Key then
+ invalidOp "Another predicate should have another key."
+
+ let bind (plan : CachedPlan) (values : ImmutableArray) : QueryDefinition =
+ let mutable definition = QueryDefinition plan.Sql
+
+ for index in 0 .. values.Length - 1 do
+ definition <- definition.WithParameter (plan.ParameterNames[index], values[index])
+
+ definition
+
+ /// The cost FSharp.Core adds to every use of a quotation literal: it reads the pickled tree anew each time.
+ []
+ member _.EvaluateLiteral () : Expr bool> = SampleQuery.predicate minAge city
+
+ []
+ member _.EvaluateShortLiteral () : Expr bool> = SampleQuery.shortPredicate minAge
+
+ []
+ member _.ComputeShape () : QueryShape = ShapeKey.compute predicate
+
+ []
+ member _.FindPlan () : CachedPlan = plans[shape.Key]
+
+ []
+ member _.Bind () : QueryDefinition = bind plan shape.SlotValues
+
+ []
+ member _.CachedExecution () : QueryDefinition =
+ let shape = ShapeKey.compute (SampleQuery.predicate minAge city)
+ bind plans[shape.Key] shape.SlotValues
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/README.md b/benchmarks/Cosmos.Quotations.Benchmarks/README.md
new file mode 100644
index 00000000..a583baa4
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/README.md
@@ -0,0 +1,87 @@
+# Benchmarks of the quotation translator
+
+ADR 0001 plans a translator from F# quotations to Cosmos DB SQL. Its design rests on a cost model: a query is translated once, the translation is cached, and every later call only binds the values the quotation captured (section 11 of the ADR). These benchmarks measure the parts of that model that can be measured before the translator exists, and one request to the emulator to compare them with.
+
+The project is a console application that the solution builds and nothing runs: it is run by hand.
+
+## What is measured
+
+| Class | Question |
+|---|---|
+| `QuotationBenchmarks` | What does a call cost on the client once its translation is cached? The quotation literal is evaluated, walked once for its cache key and its captured values, the cached query text is found, and the values are bound to a new `QueryDefinition`. |
+| `EvaluationBenchmarks` | What does it cost to turn a subexpression that does not read the document, such as `filter.MinAge` or `DateTimeOffset.UtcNow.AddDays -30.0`, into the value of a parameter: by reflection over its nodes, or by compiling it the way `LeafExpressionConverter.EvaluateQuotation` does? |
+| `SqlModelBenchmarks` | What does `FSharp.Azure.Cosmos.Sql` add to a translation that is not cached yet: building, validating and printing the syntax tree? |
+| `RoundTripBenchmarks` | What does one request cost: a point read and a query against the Cosmos DB Emulator? |
+
+The sample query is
+
+```fsharp
+<@ fun p -> p.Age >= minAge && p.City = city && p.Nick.IsSome && p.Tags.Contains "vip" @>
+```
+
+and its translation, written by hand in `SampleQuery.fs` until the translator exists:
+
+```sql
+SELECT VALUE p FROM p WHERE ((((p["Age"] >= @minAge) AND (p["City"] = @city)) AND (IS_DEFINED(p["Nick"]) AND (NOT IS_NULL(p["Nick"])))) AND ARRAY_CONTAINS(p["Tags"], @p0))
+```
+
+`ShapeKey.fs` is a prototype of the pass that computes the cache key. A quotation cannot be a dictionary key as it is: every evaluation of a literal builds a new tree, and two such trees are not equal. The pass walks the tree once and writes down its structure, with the values taken out, as a sequence of numbers. It is written to be measured: it covers the nodes of the sample query and refuses every other one.
+
+## Running
+
+```powershell
+# No emulator needed
+dotnet run -c Release --project benchmarks/Cosmos.Quotations.Benchmarks -- --filter *QuotationBenchmarks* *EvaluationBenchmarks* *SqlModelBenchmarks*
+
+# Needs a running Cosmos DB Emulator; creates one database with one container of 100 items and deletes it afterwards
+dotnet run -c Release --project benchmarks/Cosmos.Quotations.Benchmarks -- --filter *RoundTripBenchmarks*
+```
+
+BenchmarkDotNet writes its reports to `BenchmarkDotNet.Artifacts`, which Git ignores. On a machine that does other work at the same time a benchmark may take a hundred iterations to settle; `--maxIterationCount 20` bounds that.
+
+The round trip benchmarks read the emulator's endpoint and key the way the tests do (`COSMOS_EMULATOR_ENDPOINT`, `COSMOS_EMULATOR_KEY`), and name their database with the prefix of the test databases, so the sweep of a later test run deletes it if a benchmark run is aborted.
+
+## Results
+
+Measured on 2026-10-11: AMD Ryzen AI Max+ 395, Windows 11, .NET 10.0.12, FSharp.Core 10.0.100, BenchmarkDotNet 0.15.8 with `--maxIterationCount 20`, the Windows Cosmos DB Emulator on the same machine. The machine did other work at the time. Three runs of the same benchmarks within two hours differed by a factor of up to three, so read the times as orders of magnitude; the allocations were the same in every run. The last row of the first table is lower than its first row for that reason: the load changed while the run went on.
+
+A call whose translation is cached:
+
+| Step | Mean | Allocated |
+|---|---:|---:|
+| Evaluate the quotation literal (four conditions) | 74.7 µs | 55.0 KB |
+| Evaluate a literal of one condition | 23.7 µs | 21.8 KB |
+| Shape key and slot values | 0.89 µs | 2.9 KB |
+| Find the plan by the key | 0.11 µs | 0 |
+| `QueryDefinition` with 3 parameters | 0.05 µs | 0.5 KB |
+| The literal of four conditions and the three steps after it together | 53.6 µs | 58.4 KB |
+
+A value that does not come from the document:
+
+| Expression | By reflection | Compiled |
+|---|---:|---:|
+| `filter.MinAge` | 0.23 µs | 98 µs |
+| `DateTimeOffset.UtcNow.AddDays -30.0` | 0.42 µs | 705 µs |
+
+The SQL model:
+
+| Step | Mean | Allocated |
+|---|---:|---:|
+| Build the syntax tree | 0.26 µs | 1.5 KB |
+| Validate | 0.24 µs | 0.2 KB |
+| Print with parameters | 0.59 µs | 2.0 KB |
+| Print with the values inline | 0.98 µs | 4.2 KB |
+
+One request to the emulator:
+
+| Request | Mean | Allocated |
+|---|---:|---:|
+| Point read of one item | 1.38 ms | 46 KB |
+| Query in one partition, 10 of 100 items | 2.12 ms | 150 KB |
+
+## What the numbers say
+
+1. **The quotation literal is the cost of a cached call.** FSharp.Core keeps a literal as pickled bytes and unpickles the whole tree on every evaluation of `<@ … @>`, resolving every type, method and property through reflection again. That is 24 µs for one condition and 75 µs for four, against about 1 µs for everything this library does afterwards: the walk, the lookup and the binding. A cache of translations takes the translation off the path of a call, but it cannot make an inline quotation cheaper than this, because the library only receives the tree once it is built.
+2. **A query that is held in a value pays none of it.** The compiled queries of the ADR evaluate their quotation once and take what changes as arguments. On a path where 25 to 75 µs per call matter, that is the form to use; against a request of 1.4 to 2.1 ms an inline quotation adds between 1 and 5 percent.
+3. **Values are evaluated by reflection, never compiled.** Reflection over the nodes costs a fraction of a microsecond; compiling the same expression costs 400 to 1700 times as much, as much as the quotation literal itself or ten times more.
+4. **The SQL model is not a cost.** Building, validating and printing the tree of the sample query takes about 1 µs together.
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/RoundTripBenchmarks.fs b/benchmarks/Cosmos.Quotations.Benchmarks/RoundTripBenchmarks.fs
new file mode 100644
index 00000000..3dcce2a5
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/RoundTripBenchmarks.fs
@@ -0,0 +1,77 @@
+namespace FSharp.Azure.Cosmos.Quotations.Benchmarks
+
+open System
+open System.Threading.Tasks
+open Microsoft.Azure.Cosmos
+open BenchmarkDotNet.Attributes
+
+open FSharp.Azure.Cosmos.Tests.Integration
+
+/// The item of the round trip benchmarks, with the lower-case names the default serializer of the SDK writes as they are.
+type StoredPerson = { id : string; partitionKey : string; name : string; age : int; city : string }
+
+///
+/// What the client-side costs are compared with: one request to the Cosmos DB Emulator and back. It needs a running
+/// emulator () and creates one database with
+/// one container of 100 items, which it deletes afterwards.
+///
+[]
+type RoundTripBenchmarks () =
+
+ []
+ let PartitionKeyValue = "benchmark"
+
+ // The prefix lets the sweep of the test runs delete the database if a run is aborted before its cleanup
+ let databaseId = $"{DatabaseIdentifier.Prefix}benchmarks-{Guid.NewGuid ():N}"
+
+ // Both are created in the setup, which BenchmarkDotNet runs in the process that measures, and not here: the
+ // host process constructs this class as well and would never dispose a client
+ let mutable client = Unchecked.defaultof
+ let mutable container = Unchecked.defaultof
+
+ []
+ member _.SetupAsync () : Task = task {
+ client <- Emulator.createClient ()
+ let! database = client.CreateDatabaseAsync databaseId
+ let! created = database.Database.CreateContainerAsync (ContainerProperties ("people", "/partitionKey"))
+ container <- created.Container
+
+ for index in 0..99 do
+ let item = {
+ id = $"person-{index}"
+ partitionKey = PartitionKeyValue
+ name = $"Person {index}"
+ age = index
+ city = if index % 2 = 0 then "Kyiv" else "Lviv"
+ }
+
+ let! _ = container.CreateItemAsync (item, PartitionKey PartitionKeyValue)
+ ()
+ }
+
+ []
+ member _.CleanupAsync () : Task = task {
+ let! _ = client.GetDatabase(databaseId).DeleteAsync()
+ client.Dispose ()
+ }
+
+ []
+ member _.ReadItemAsync () : Task> =
+ container.ReadItemAsync("person-50", PartitionKey PartitionKeyValue)
+
+ []
+ member _.QueryAsync () : Task = task {
+ let definition =
+ QueryDefinition("SELECT VALUE p FROM p WHERE ((p[\"age\"] >= @minAge) AND (p[\"city\"] = @city))")
+ .WithParameter("@minAge", 80)
+ .WithParameter("@city", "Kyiv")
+
+ use iterator =
+ container.GetItemQueryIterator(
+ definition,
+ requestOptions = QueryRequestOptions (PartitionKey = PartitionKey PartitionKeyValue)
+ )
+
+ let! page = iterator.ReadNextAsync ()
+ return page.Count
+ }
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/SampleQuery.fs b/benchmarks/Cosmos.Quotations.Benchmarks/SampleQuery.fs
new file mode 100644
index 00000000..5f21eba6
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/SampleQuery.fs
@@ -0,0 +1,113 @@
+namespace FSharp.Azure.Cosmos.Quotations.Benchmarks
+
+open System
+open System.Collections.Immutable
+open FSharp.Quotations
+
+open FSharp.Azure.Cosmos.Sql
+
+/// The document the sample query is written against.
+type Person = {
+ Id : string
+ Name : string
+ Age : int
+ City : string
+ Nick : string voption
+ Tags : ImmutableArray
+ Registered : DateTimeOffset
+}
+
+/// Query arguments that arrive together, the way a request handler holds them.
+type PersonFilter = { MinAge : int; City : string }
+
+///
+/// The query every benchmark measures, in the forms a translator passes through: the quotation a caller writes and the
+/// it is translated into. Until the translator exists, the second form
+/// is written by hand here.
+///
+[]
+module SampleQuery =
+
+ /// A predicate with what a typical one has: two captured arguments, a literal, an optional member and a collection.
+ let predicate (minAge : int) (city : string) : Expr bool> =
+ <@
+ fun p ->
+ p.Age >= minAge
+ && p.City = city
+ && p.Nick.IsSome
+ && p.Tags.Contains "vip"
+ @>
+
+ /// The first of the four conditions alone, to see how the cost of a quotation literal grows with its size.
+ let shortPredicate (minAge : int) : Expr bool> = <@ fun p -> p.Age >= minAge @>
+
+ /// A member of a captured record: an expression that does not read the document, so its value is a parameter.
+ let minAgeOf (filter : PersonFilter) : Expr = <@ filter.MinAge @>
+
+ /// Arithmetic on the clock: another expression that does not read the document.
+ let thirtyDaysAgo () : Expr = <@ DateTimeOffset.UtcNow.AddDays -30.0 @>
+
+ ///
+ /// The names of the parameters of , in the order in which holds
+ /// their values.
+ ///
+ let parameterNames =
+ seq {
+ "@minAge"
+ "@city"
+ "@p0"
+ }
+ |> ImmutableArray.CreateRange
+
+ ///
+ /// The translation of , built anew on every call:
+ /// SELECT VALUE p FROM p WHERE ((((p["Age"] >= @minAge) AND (p["City"] = @city)) AND
+ /// (IS_DEFINED(p["Nick"]) AND (NOT IS_NULL(p["Nick"])))) AND ARRAY_CONTAINS(p["Tags"], @p0)).
+ ///
+ let syntaxTree () : SqlQuery =
+ let person = ScalarExpression.PropertyRef (Identifier "p")
+
+ let field (name : string) =
+ ScalarExpression.MemberIndexer (person, ScalarExpression.Literal (Literal.String name))
+
+ let parameter (name : string) = ScalarExpression.ParameterRef (ParameterName name)
+
+ let call (name : string) (arguments : ScalarExpression array) =
+ ScalarExpression.FunctionCall (FunctionRef.BuiltIn name, EquatableArray.unsafeOfArray arguments)
+
+ let both (left : ScalarExpression) (right : ScalarExpression) = ScalarExpression.Binary (BinaryOperator.And, left, right)
+
+ let isAdult =
+ ScalarExpression.Binary (BinaryOperator.GreaterThanOrEqual, field "Age", parameter "@minAge")
+
+ let livesInCity = ScalarExpression.Binary (BinaryOperator.Equal, field "City", parameter "@city")
+
+ let hasNick =
+ both
+ (call "IS_DEFINED" [| field "Nick" |])
+ (ScalarExpression.Unary (UnaryOperator.Not, call "IS_NULL" [| field "Nick" |]))
+
+ let isVip = call "ARRAY_CONTAINS" [| field "Tags"; parameter "@p0" |]
+
+ {
+ Select = SelectSpec.Value person
+ Distinct = false
+ Top = ValueNone
+ From = ValueSome (CollectionExpression.Aliased (Collection.InputPath (Identifier "p", ValueNone), ValueNone))
+ Where = ValueSome (both (both (both isAdult livesInCity) hasNick) isVip)
+ GroupBy = EquatableArray.empty
+ OrderBy = EquatableArray.empty
+ OrderByRank = false
+ OffsetLimit = ValueNone
+ }
+
+ ///
+ /// The literal of every parameter of , for the print mode that writes the values into the
+ /// query text.
+ ///
+ let literalOf (name : ParameterName) : ScalarExpression voption =
+ match name.Value with
+ | "@minAge" -> ValueSome (ScalarExpression.Literal (Literal.Int 18L))
+ | "@city" -> ValueSome (ScalarExpression.Literal (Literal.String "Kyiv"))
+ | "@p0" -> ValueSome (ScalarExpression.Literal (Literal.String "vip"))
+ | _ -> ValueNone
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/ShapeKey.fs b/benchmarks/Cosmos.Quotations.Benchmarks/ShapeKey.fs
new file mode 100644
index 00000000..f98e8f35
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/ShapeKey.fs
@@ -0,0 +1,266 @@
+namespace FSharp.Azure.Cosmos.Quotations.Benchmarks
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Reflection
+open FSharp.Quotations
+open FSharp.Quotations.Patterns
+
+open FSharp.Azure.Cosmos.Sql
+
+/// What one walk over a quotation gives: the key its translation is cached under, and the values to bind to the
+/// parameters of the cached query text.
+[]
+type QueryShape = {
+ /// The structure of the quotation without the values in it: two quotations with equal keys differ only in the
+ /// values of their slots, so they share one translation.
+ Key : EquatableArray
+ ///
+ /// The value of every slot, in the order of the slots in
+ /// . A slot is a place in the quotation
+ /// whose value is known before the query runs, so it becomes a parameter of the query: a captured local, a literal,
+ /// or a subexpression that does not read the document.
+ ///
+ SlotValues : ImmutableArray
+}
+
+/// The kinds of entries in a shape key. A kind is followed by what identifies the node: types and members by their
+/// runtime handles and metadata tokens, a variable by its distance to its binding.
+module private Token =
+
+ []
+ let Slot = 1L
+
+ []
+ let Variable = 2L
+
+ []
+ let Lambda = 3L
+
+ []
+ let Let = 4L
+
+ []
+ let And = 5L
+
+ []
+ let Or = 6L
+
+ []
+ let Conditional = 7L
+
+ []
+ let Property = 8L
+
+ []
+ let Field = 9L
+
+ []
+ let Call = 10L
+
+/// A variable in scope. The variable of a closed binding, one that does not read the document, stands for its value.
+[]
+type private ScopedVariable = { Variable : Var; IsClosed : bool; Value : objnull }
+
+///
+/// One walk over a quotation that writes its .
+///
+/// A node is closed when nothing under it reads a lambda parameter, which is where the document comes in. A closed
+/// node is evaluated on the spot, by reflection, and becomes one slot; the entries its children wrote are taken back.
+/// An open node writes its kind and identity, and its children follow.
+///
+///
+[]
+type private ShapeWalker () =
+
+ let tokens = ImmutableArray.CreateBuilder 64
+ let slots = ImmutableArray.CreateBuilder 8
+ // The variables in scope, innermost last
+ let scope = List()
+ // What every visited child leaves for its parent: its value when it is closed, null when it is open
+ let values = List()
+
+ let typeToken (clrType : Type) = int64 clrType.TypeHandle.Value
+
+ // The handle of the declaring type also tells the type arguments of a generic type apart, which the metadata
+ // token of the member does not
+ let declaringTypeToken (info : MemberInfo) =
+ match info.DeclaringType with
+ | null -> 0L
+ | declaringType -> typeToken declaringType
+
+ let closedAs (slotType : Type) (value : objnull) =
+ tokens.Add Token.Slot
+ tokens.Add (typeToken slotType)
+ slots.Add value
+ values.Add value
+ true
+
+ let opened () =
+ values.Add null
+ false
+
+ // The values of the children of a closed member node lie in `values` from `valueMark` on, the target first
+ let evaluate (info : MemberInfo) (hasTarget : bool) (valueMark : int) : objnull =
+ let target = if hasTarget then values[valueMark] else null
+ let first = if hasTarget then valueMark + 1 else valueMark
+ let arguments = Array.zeroCreate(values.Count - first)
+
+ for index in 0 .. arguments.Length - 1 do
+ arguments[index] <- values[first + index]
+
+ match info with
+ | :? MethodInfo as method -> method.Invoke (target, arguments)
+ | :? PropertyInfo as property -> property.GetValue (target, arguments)
+ | :? FieldInfo as field -> field.GetValue target
+ | _ -> raise (NotSupportedException $"The shape key prototype does not evaluate a %s{info.GetType().Name}.")
+
+ let rec visit (expression : Expr) : bool =
+ match expression with
+ | Value (value, valueType) -> closedAs valueType value
+ | Var variable -> visitVariable variable
+ | PropertyGet (target, property, arguments) -> visitMember Token.Property property target arguments expression
+ | Call (target, method, arguments) -> visitMember Token.Call method target arguments expression
+ | IfThenElse (condition, whenTrue, whenFalse) -> visitConditional condition whenTrue whenFalse
+ | Lambda (variable, body) -> visitLambda variable body
+ | Let (variable, binding, body) -> visitLet variable binding body
+ | FieldGet (target, field) -> visitMember Token.Field field target [] expression
+ | Coerce (inner, _) -> visit inner
+ | _ -> raise (NotSupportedException $"The shape key prototype does not cover this node: %A{expression}")
+
+ and visitVariable (variable : Var) : bool =
+ let mutable index = scope.Count - 1
+
+ while index >= 0
+ && not (obj.ReferenceEquals (scope[index].Variable, variable)) do
+ index <- index - 1
+
+ if index < 0 then
+ raise (NotSupportedException $"The quotation uses the variable %s{variable.Name} without binding it.")
+
+ let scoped = scope[index]
+
+ if scoped.IsClosed then
+ closedAs variable.Type scoped.Value
+ else
+ tokens.Add Token.Variable
+ // The distance to the binding instead of the name, so that renaming a variable keeps the key
+ tokens.Add (int64 (scope.Count - 1 - index))
+ opened ()
+
+ and visitMember (kind : int64) (info : MemberInfo) (target : Expr option) (arguments : Expr list) (expression : Expr) : bool =
+ let tokenMark = tokens.Count
+ let slotMark = slots.Count
+ let valueMark = values.Count
+ tokens.Add kind
+ tokens.Add (declaringTypeToken info)
+ tokens.Add (int64 info.MetadataToken)
+
+ match info with
+ | :? MethodInfo as method when method.IsGenericMethod ->
+ for typeArgument in method.GetGenericArguments () do
+ tokens.Add (typeToken typeArgument)
+ | _ -> ()
+
+ let mutable closed = true
+
+ match target with
+ | Some target -> closed <- visit target
+ | None -> ()
+
+ for argument in arguments do
+ // Every argument is visited, also after an open one: its entries belong to the key
+ closed <- visit argument && closed
+
+ if closed then
+ let value = evaluate info target.IsSome valueMark
+ tokens.Count <- tokenMark
+ slots.Count <- slotMark
+ values.RemoveRange (valueMark, values.Count - valueMark)
+ closedAs expression.Type value
+ else
+ values.RemoveRange (valueMark, values.Count - valueMark)
+ opened ()
+
+ // && and || arrive as conditionals with a constant branch, and that constant is no slot. A conditional is never
+ // evaluated here, so its closed parts are evaluated one by one, eagerly: `text <> null && text.Length > 0` over a
+ // captured null would fail. The real pass has to evaluate a closed conditional as a whole, lazily.
+ and visitConditional (condition : Expr) (whenTrue : Expr) (whenFalse : Expr) : bool =
+ match whenTrue, whenFalse with
+ | _, Value (:? bool as constant, _) when not constant -> visitBoth Token.And condition whenTrue
+ | Value (:? bool as constant, _), _ when constant -> visitBoth Token.Or condition whenFalse
+ | _ ->
+ tokens.Add Token.Conditional
+ visit condition |> ignore
+ visit whenTrue |> ignore
+ visit whenFalse |> ignore
+ values.RemoveRange (values.Count - 3, 3)
+ opened ()
+
+ and visitBoth (kind : int64) (left : Expr) (right : Expr) : bool =
+ tokens.Add kind
+ visit left |> ignore
+ visit right |> ignore
+ values.RemoveRange (values.Count - 2, 2)
+ opened ()
+
+ and visitLambda (variable : Var) (body : Expr) : bool =
+ tokens.Add Token.Lambda
+ tokens.Add (typeToken variable.Type)
+ scope.Add { Variable = variable; IsClosed = false; Value = null }
+ visit body |> ignore
+ scope.RemoveAt (scope.Count - 1)
+ // A function is no parameter value, whatever its body is
+ values[values.Count - 1] <- null
+ false
+
+ and visitLet (variable : Var) (binding : Expr) (body : Expr) : bool =
+ let tokenMark = tokens.Count
+ let slotMark = slots.Count
+ tokens.Add Token.Let
+
+ if visit binding then
+ // A closed binding is substituted: the variable stands for its value and the node leaves nothing in the key
+ tokens.Count <- tokenMark
+ slots.Count <- slotMark
+ let value = values[values.Count - 1]
+ values.RemoveAt (values.Count - 1)
+ scope.Add { Variable = variable; IsClosed = true; Value = value }
+ let closed = visit body
+ scope.RemoveAt (scope.Count - 1)
+ closed
+ else
+ values.RemoveAt (values.Count - 1)
+ scope.Add { Variable = variable; IsClosed = false; Value = null }
+ visit body |> ignore
+ scope.RemoveAt (scope.Count - 1)
+ values[values.Count - 1] <- null
+ false
+
+ /// Walks the quotation and gives what the walk wrote; a walker serves one quotation.
+ member _.Run (expression : Expr) : QueryShape =
+ visit expression |> ignore
+ {
+ Key = EquatableArray (tokens.DrainToImmutable ())
+ SlotValues = slots.DrainToImmutable ()
+ }
+
+///
+/// A prototype of the pass that the plan cache of the quotation translator needs (ADR 0001, section 11), written to
+/// measure it and nothing else: it covers the nodes of
+/// and refuses every other one.
+///
+/// A quotation cannot be a dictionary key as it is: every evaluation of a quotation literal builds a new tree with new
+/// variables, and two such trees are not equal. The pass walks the tree once and writes down its structure, with the
+/// values taken out, as a sequence of numbers that can be compared and hashed.
+///
+///
+[]
+module ShapeKey =
+
+ ///
+ /// Walks once and gives its key and the values of its slots.
+ ///
+ /// The quotation holds a node the prototype does not cover.
+ let compute (expression : Expr) : QueryShape = ShapeWalker().Run expression
diff --git a/benchmarks/Cosmos.Quotations.Benchmarks/SqlModelBenchmarks.fs b/benchmarks/Cosmos.Quotations.Benchmarks/SqlModelBenchmarks.fs
new file mode 100644
index 00000000..b04b797b
--- /dev/null
+++ b/benchmarks/Cosmos.Quotations.Benchmarks/SqlModelBenchmarks.fs
@@ -0,0 +1,32 @@
+namespace FSharp.Azure.Cosmos.Quotations.Benchmarks
+
+open System.Collections.Immutable
+open BenchmarkDotNet.Attributes
+
+open FSharp.Azure.Cosmos.Sql
+
+///
+/// What a query costs once, when its translation is not cached yet, after the translation itself: the
+/// is built, validated and printed.
+///
+[]
+type SqlModelBenchmarks () =
+
+ let query = SampleQuery.syntaxTree ()
+
+ do
+ if not (SqlQuery.validate query).IsEmpty then
+ invalidOp "The sample query should be valid."
+
+ []
+ member _.Build () : SqlQuery = SampleQuery.syntaxTree ()
+
+ []
+ member _.Validate () : ImmutableArray = SqlQuery.validate query
+
+ []
+ member _.Print () : string = Printer.print PropertyStyle.Brackets query
+
+ []
+ member _.PrintInline () : Result> =
+ Printer.printInline PropertyStyle.Brackets SampleQuery.literalOf query
diff --git a/build/build.fs b/build/build.fs
index ceb7f1e9..4624b14b 100644
--- a/build/build.fs
+++ b/build/build.fs
@@ -51,6 +51,9 @@ let testsDirectoryGlob = rootDirectory > "tests/**/*.??proj"
///
let testsGlob = rootDirectory > "tests/**/*.Tests.??proj"
+/// The benchmark applications, which the solution builds and nothing runs
+let benchmarksGlob = rootDirectory > "benchmarks/**/*.??proj"
+
let srcAndTest = !!srcGlob ++ testsDirectoryGlob
let distDir = rootDirectory > "dist"
@@ -254,7 +257,7 @@ let clean _ =
[ "bin"; "temp"; distDir; coverageReportDir; testResultsDir ]
|> Shell.cleanDirs
- !!srcGlob ++ testsDirectoryGlob
+ !!srcGlob ++ testsDirectoryGlob ++ benchmarksGlob
|> Seq.collect (fun p ->
[ "bin"; "obj" ]
|> Seq.map (fun sp -> IO.Path.GetDirectoryName p > sp)
diff --git a/src/Cosmos.Sql/AssemblyInfo.fs b/src/Cosmos.Sql/AssemblyInfo.fs
new file mode 100644
index 00000000..a60652e5
--- /dev/null
+++ b/src/Cosmos.Sql/AssemblyInfo.fs
@@ -0,0 +1,23 @@
+// Auto-Generated by FAKE; do not edit
+namespace System
+open System.Reflection
+
+[]
+[]
+[]
+[]
+[]
+[]
+[]
+[]
+do ()
+
+module internal AssemblyVersionInformation =
+ let [] AssemblyTitle = "FSharp.Azure.Cosmos.Sql"
+ let [] AssemblyProduct = "FSharp.Azure.Cosmos"
+ let [] AssemblyVersion = "1.2.0"
+ let [] AssemblyMetadata_ReleaseDate = "2026-10-01T00:00:00.0000000+02:00"
+ let [] AssemblyFileVersion = "1.2.0"
+ let [] AssemblyInformationalVersion = "1.2.0"
+ let [] AssemblyMetadata_ReleaseChannel = "release"
+ let [] AssemblyMetadata_GitHash = "210cf041a0ce44fcc5a5df369550a77a1f75d436"
diff --git a/src/Cosmos.Sql/Catalog.fs b/src/Cosmos.Sql/Catalog.fs
new file mode 100644
index 00000000..30f5b46c
--- /dev/null
+++ b/src/Cosmos.Sql/Catalog.fs
@@ -0,0 +1,68 @@
+///
+/// The built-in functions of the query language as data: one per
+/// name, which the translator, the validator, the interpreter, the emulator capability gating and the generated
+/// function coverage page share.
+///
+/// The catalog holds every name the SDK lists, except its internal names, and every name Microsoft Learn documents.
+/// Entries the translator does not need yet are marked unsupported in their
+/// , with the reason.
+///
+///
+[]
+module FSharp.Azure.Cosmos.Sql.Catalog
+
+open System
+open System.Collections.Frozen
+open System.Collections.Immutable
+
+/// Every built-in function, grouped like the function index on Microsoft Learn.
+let all : ImmutableArray = CatalogData.functions
+
+///
+/// Every built-in function by name. Function names are case-insensitive in the query language, so the lookup uses
+/// .
+///
+let byName : FrozenDictionary =
+ all.ToFrozenDictionary (_.Name, StringComparer.OrdinalIgnoreCase)
+
+///
+/// Looks up the function named , in any letter case.
+///
+/// The name of the function.
+let tryFind (name : string) =
+ // The byref overload, because matching on the tuple form allocates a reference tuple per lookup
+ let mutable spec = Unchecked.defaultof
+ if byName.TryGetValue (name, &spec) then
+ ValueSome spec
+ else
+ ValueNone
+
+///
+/// Builds a call of the built-in function named with , after
+/// checking that the catalog knows the function, specifies it and accepts the number of arguments.
+///
+/// The call uses the catalog's spelling of the name, so the printed text does not depend on how the caller spelled it.
+/// Where a call may appear, such as a scoring function only in ORDER BY RANK, is checked by
+/// .
+///
+///
+/// The name of the function, in any letter case.
+/// The arguments of the call.
+///
+/// The call, or the that says why it cannot be built: the name is
+/// unknown, the entry is not specified, or does not accept
+/// the number of arguments.
+///
+let call (name : string) (arguments : ImmutableArray) : Result =
+ match tryFind name with
+ | ValueNone -> Error (CatalogError.UnknownFunction name)
+ | ValueSome spec ->
+ match spec.Status with
+ | FunctionStatus.Unsupported reason -> Error (CatalogError.Unsupported (spec.Name, reason))
+ | FunctionStatus.Supported ->
+ let count = if arguments.IsDefault then 0 else arguments.Length
+
+ if spec.Arity.Accepts count then
+ Ok (ScalarExpression.FunctionCall (FunctionRef.BuiltIn spec.Name, EquatableArray arguments))
+ else
+ Error (CatalogError.ArityMismatch (spec.Name, spec.Arity, count))
diff --git a/src/Cosmos.Sql/CatalogData.fs b/src/Cosmos.Sql/CatalogData.fs
new file mode 100644
index 00000000..c4e320a9
--- /dev/null
+++ b/src/Cosmos.Sql/CatalogData.fs
@@ -0,0 +1,403 @@
+// The catalog data file: one row per built-in function of the query language.
+//
+// Seeded with every name of SqlFunctionCallScalarExpression.Names of Azure/azure-cosmos-dotnet-v3 3.62.0 (MIT, see
+// THIRD-PARTY-NOTICES.md) except the internal names, which start with an underscore, plus the names Microsoft Learn
+// documents that the SDK does not list (the INTBIT* spellings, NOW, AGO, NUMBERBIN, REGEXREPLACE, REGEXREPLACEALL,
+// ST_AREA and the GETCURRENT*STATIC functions). Rows the translator does not need yet are Unsupported with a reason;
+// a row becomes Supported once its arity, return kind and placement rules are filled in from its Learn page. The
+// remarks of the Learn pages give the index usage; a property the page does not state stays Unspecified.
+module internal FSharp.Azure.Cosmos.Sql.CatalogData
+
+open System.Collections.Immutable
+
+/// The Microsoft Learn page of a documented function: its name in lower case with underscores as hyphens.
+let private docUrl (name : string) =
+ ValueSome $"""https://learn.microsoft.com/cosmos-db/query/%s{name.ToLowerInvariant().Replace('_', '-')}"""
+
+/// A row whose arity, return kind and placement rules are specified.
+let private supported category name arity returnKind = {
+ Name = name
+ Category = category
+ Arity = arity
+ ReturnKind = returnKind
+ UndefinedRule = UndefinedRule.Unspecified
+ IndexUsage = IndexUsage.Unspecified
+ Availability = Availability.Service
+ Deterministic = true
+ AllowedInOrderBy = false
+ AllowedInOrderByRank = false
+ OnlyInOrderByRank = false
+ DocUrl = docUrl name
+ Status = FunctionStatus.Supported
+}
+
+let private notSpecified =
+ "Not specified yet; the translator pack that uses the function fills in its arity, return kind and placement rules."
+
+/// A documented function that the catalog does not specify yet.
+let private documented category name = {
+ supported category name Arity.Unspecified ReturnKind.Unspecified with
+ Status = FunctionStatus.Unsupported notSpecified
+}
+
+/// A name the SDK lists that Microsoft Learn does not document.
+let private undocumented name reason = {
+ documented FunctionCategory.Undocumented name with
+ Availability = Availability.None
+ DocUrl = ValueNone
+ Status = FunctionStatus.Unsupported reason
+}
+
+let private undocumentedName =
+ "Not documented on Microsoft Learn; the SDK lists the name without a specification."
+
+let private typedValueFunction =
+ "An undocumented typed-value function of the C_ family, not part of the documented NoSQL query language."
+
+/// A keyword of the grammar that the SDK also lists as a function name; the grammar cannot parse it as a call.
+let private keyword name form =
+ undocumented name $"A keyword of the grammar, so it cannot be called as a function; the query language writes %s{form}."
+
+/// An SDK spelling of an integer bitwise function that Learn documents under another name.
+let private sdkSpelling name documentedName =
+ undocumented name $"The SDK's spelling of %s{documentedName}; the documented name is %s{documentedName}."
+
+/// Every built-in function, grouped like the function index on Microsoft Learn.
+let functions : ImmutableArray =
+ seq {
+ // Mathematical functions
+ documented FunctionCategory.Mathematical "ABS"
+ documented FunctionCategory.Mathematical "ACOS"
+ documented FunctionCategory.Mathematical "ASIN"
+ documented FunctionCategory.Mathematical "ATAN"
+ documented FunctionCategory.Mathematical "ATN2"
+ documented FunctionCategory.Mathematical "CEILING"
+ documented FunctionCategory.Mathematical "COS"
+ documented FunctionCategory.Mathematical "COT"
+ documented FunctionCategory.Mathematical "DEGREES"
+ documented FunctionCategory.Mathematical "EXP"
+ documented FunctionCategory.Mathematical "FLOOR"
+ documented FunctionCategory.Mathematical "IntAdd"
+ supported FunctionCategory.Mathematical "INTBITAND" (Arity.Exactly 2) ReturnKind.Number
+ supported FunctionCategory.Mathematical "INTBITLEFTSHIFT" (Arity.Exactly 2) ReturnKind.Number
+ supported FunctionCategory.Mathematical "INTBITNOT" (Arity.Exactly 1) ReturnKind.Number
+ supported FunctionCategory.Mathematical "INTBITOR" (Arity.Exactly 2) ReturnKind.Number
+ supported FunctionCategory.Mathematical "INTBITRIGHTSHIFT" (Arity.Exactly 2) ReturnKind.Number
+ supported FunctionCategory.Mathematical "INTBITXOR" (Arity.Exactly 2) ReturnKind.Number
+ supported FunctionCategory.Mathematical "IntDiv" (Arity.Exactly 2) ReturnKind.Number
+ supported FunctionCategory.Mathematical "IntMod" (Arity.Exactly 2) ReturnKind.Number
+ documented FunctionCategory.Mathematical "IntMul"
+ documented FunctionCategory.Mathematical "IntSub"
+ documented FunctionCategory.Mathematical "LOG"
+ documented FunctionCategory.Mathematical "LOG10"
+ documented FunctionCategory.Mathematical "NUMBERBIN"
+ documented FunctionCategory.Mathematical "PI"
+ documented FunctionCategory.Mathematical "POWER"
+ documented FunctionCategory.Mathematical "RADIANS"
+ { documented FunctionCategory.Mathematical "RAND" with Deterministic = false }
+ documented FunctionCategory.Mathematical "ROUND"
+ documented FunctionCategory.Mathematical "SIGN"
+ documented FunctionCategory.Mathematical "SIN"
+ documented FunctionCategory.Mathematical "SQRT"
+ documented FunctionCategory.Mathematical "SQUARE"
+ documented FunctionCategory.Mathematical "TAN"
+ documented FunctionCategory.Mathematical "TRUNC"
+
+ // Array functions
+ documented FunctionCategory.Array "ARRAY_AVG"
+ documented FunctionCategory.Array "ARRAY_CONCAT"
+ {
+ supported FunctionCategory.Array "ARRAY_CONTAINS" (Arity.Between (2, 3)) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ documented FunctionCategory.Array "ARRAY_CONTAINS_ALL"
+ documented FunctionCategory.Array "ARRAY_CONTAINS_ANY"
+ {
+ supported FunctionCategory.Array "ARRAY_LENGTH" (Arity.Exactly 1) ReturnKind.Number with
+ UndefinedRule = UndefinedRule.UndefinedOnInvalidArgument
+ IndexUsage = IndexUsage.NoIndex
+ }
+ documented FunctionCategory.Array "ARRAY_MAX"
+ documented FunctionCategory.Array "ARRAY_MEDIAN"
+ documented FunctionCategory.Array "ARRAY_MIN"
+ documented FunctionCategory.Array "ARRAY_SLICE"
+ documented FunctionCategory.Array "ARRAY_SUM"
+ documented FunctionCategory.Array "CHOOSE"
+ documented FunctionCategory.Array "ObjectToArray"
+ documented FunctionCategory.Array "SetDifference"
+ documented FunctionCategory.Array "SetEqual"
+ documented FunctionCategory.Array "SetIntersect"
+ documented FunctionCategory.Array "SetUnion"
+
+ // Aggregation functions
+ {
+ supported FunctionCategory.Aggregate "AVG" (Arity.Exactly 1) ReturnKind.Number with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ supported FunctionCategory.Aggregate "COUNT" (Arity.Exactly 1) ReturnKind.Number
+ supported FunctionCategory.Aggregate "MAX" (Arity.Exactly 1) ReturnKind.Any
+ supported FunctionCategory.Aggregate "MIN" (Arity.Exactly 1) ReturnKind.Any
+ {
+ supported FunctionCategory.Aggregate "SUM" (Arity.Exactly 1) ReturnKind.Number with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+
+ // String functions
+ {
+ supported FunctionCategory.String "CONCAT" (Arity.AtLeast 2) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ {
+ supported FunctionCategory.String "CONTAINS" (Arity.Between (2, 3)) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.FullScan
+ }
+ supported FunctionCategory.String "ENDSWITH" (Arity.Between (2, 3)) ReturnKind.Boolean
+ supported FunctionCategory.String "INDEX_OF" (Arity.Between (2, 3)) ReturnKind.Number
+ documented FunctionCategory.String "LastIndexOf"
+ documented FunctionCategory.String "LastSubstringAfter"
+ documented FunctionCategory.String "LastSubstringBefore"
+ {
+ supported FunctionCategory.String "LEFT" (Arity.Exactly 2) ReturnKind.String with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.String "LENGTH" (Arity.Exactly 1) ReturnKind.Number with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ {
+ supported FunctionCategory.String "LOWER" (Arity.Exactly 1) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ {
+ supported FunctionCategory.String "LTRIM" (Arity.Between (1, 2)) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ documented FunctionCategory.String "RegexExtract"
+ documented FunctionCategory.String "RegexExtractAll"
+ {
+ supported FunctionCategory.String "RegexMatch" (Arity.Between (2, 3)) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ documented FunctionCategory.String "REGEXREPLACE"
+ documented FunctionCategory.String "REGEXREPLACEALL"
+ {
+ supported FunctionCategory.String "REPLACE" (Arity.Exactly 3) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ documented FunctionCategory.String "REPLICATE"
+ documented FunctionCategory.String "REVERSE"
+ {
+ supported FunctionCategory.String "RIGHT" (Arity.Exactly 2) ReturnKind.String with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.String "RTRIM" (Arity.Between (1, 2)) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ {
+ supported FunctionCategory.String "STARTSWITH" (Arity.Between (2, 3)) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.PreciseIndexScan
+ }
+ {
+ supported FunctionCategory.String "StringEquals" (Arity.Between (2, 3)) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.IndexSeek
+ }
+ documented FunctionCategory.String "StringJoin"
+ documented FunctionCategory.String "StringSplit"
+ documented FunctionCategory.String "StringToArray"
+ documented FunctionCategory.String "StringToBoolean"
+ {
+ supported FunctionCategory.String "SUBSTRING" (Arity.Exactly 3) ReturnKind.String with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ documented FunctionCategory.String "SubstringAfter"
+ documented FunctionCategory.String "SubstringBefore"
+ {
+ supported FunctionCategory.String "ToString" (Arity.Exactly 1) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ {
+ supported FunctionCategory.String "TRIM" (Arity.Between (1, 2)) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ {
+ supported FunctionCategory.String "UPPER" (Arity.Exactly 1) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ }
+
+ // Date and time functions
+ { documented FunctionCategory.DateTime "AGO" with Deterministic = false }
+ {
+ supported FunctionCategory.DateTime "DateTimeAdd" (Arity.Exactly 3) ReturnKind.String with
+ UndefinedRule = UndefinedRule.UndefinedOnInvalidArgument
+ }
+ documented FunctionCategory.DateTime "DateTimeBin"
+ supported FunctionCategory.DateTime "DateTimeDiff" (Arity.Exactly 3) ReturnKind.Number
+ documented FunctionCategory.DateTime "DateTimeFormat"
+ documented FunctionCategory.DateTime "DateTimeFromParts"
+ {
+ supported FunctionCategory.DateTime "DateTimePart" (Arity.Exactly 2) ReturnKind.Number with
+ IndexUsage = IndexUsage.NoIndex
+ }
+ documented FunctionCategory.DateTime "DateTimeToTicks"
+ documented FunctionCategory.DateTime "DateTimeToTimestamp"
+ documented FunctionCategory.DateTime "DAY"
+ {
+ supported FunctionCategory.DateTime "GetCurrentDateTime" (Arity.Exactly 0) ReturnKind.String with
+ IndexUsage = IndexUsage.NoIndex
+ Deterministic = false
+ }
+ {
+ documented FunctionCategory.DateTime "GETCURRENTDATETIMESTATIC" with
+ Deterministic = false
+ }
+ {
+ documented FunctionCategory.DateTime "GetCurrentTicks" with
+ Deterministic = false
+ }
+ {
+ documented FunctionCategory.DateTime "GETCURRENTTICKSSTATIC" with
+ Deterministic = false
+ }
+ {
+ documented FunctionCategory.DateTime "GetCurrentTimestamp" with
+ Deterministic = false
+ }
+ {
+ documented FunctionCategory.DateTime "GETCURRENTTIMESTAMPSTATIC" with
+ Deterministic = false
+ }
+ documented FunctionCategory.DateTime "MONTH"
+ { documented FunctionCategory.DateTime "NOW" with Deterministic = false }
+ documented FunctionCategory.DateTime "TicksToDateTime"
+ documented FunctionCategory.DateTime "TimestampToDateTime"
+ documented FunctionCategory.DateTime "YEAR"
+
+ // Item functions
+ documented FunctionCategory.Item "DOCUMENTID"
+
+ // Full text search functions
+ documented FunctionCategory.FullTextSearch "FullTextContains"
+ documented FunctionCategory.FullTextSearch "FullTextContainsAll"
+ documented FunctionCategory.FullTextSearch "FullTextContainsAny"
+ {
+ supported FunctionCategory.FullTextSearch "FullTextScore" (Arity.AtLeast 2) ReturnKind.Number with
+ AllowedInOrderByRank = true
+ OnlyInOrderByRank = true
+ }
+ {
+ supported FunctionCategory.FullTextSearch "RRF" (Arity.AtLeast 2) ReturnKind.Number with
+ AllowedInOrderByRank = true
+ OnlyInOrderByRank = true
+ }
+
+ // Conditional functions
+ supported FunctionCategory.Conditional "IIF" (Arity.Exactly 3) ReturnKind.Any
+
+ // Type checking functions
+ {
+ supported FunctionCategory.TypeChecking "IS_ARRAY" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_BOOL" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_DATETIME" (Arity.Exactly 1) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.FullScan
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_DEFINED" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_FINITE_NUMBER" (Arity.Exactly 1) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_INTEGER" (Arity.Exactly 1) ReturnKind.Boolean with
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_NULL" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_NUMBER" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_OBJECT" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_PRIMITIVE" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ {
+ supported FunctionCategory.TypeChecking "IS_STRING" (Arity.Exactly 1) ReturnKind.Boolean with
+ UndefinedRule = UndefinedRule.NeverUndefined
+ IndexUsage = IndexUsage.RangeIndex
+ }
+ documented FunctionCategory.TypeChecking "StringToNull"
+ documented FunctionCategory.TypeChecking "StringToNumber"
+ documented FunctionCategory.TypeChecking "StringToObject"
+
+ // Spatial functions; Learn lists VectorDistance among them
+ documented FunctionCategory.Spatial "ST_AREA"
+ documented FunctionCategory.Spatial "ST_DISTANCE"
+ documented FunctionCategory.Spatial "ST_INTERSECTS"
+ documented FunctionCategory.Spatial "ST_ISVALID"
+ documented FunctionCategory.Spatial "ST_ISVALIDDETAILED"
+ documented FunctionCategory.Spatial "ST_WITHIN"
+ {
+ supported FunctionCategory.Spatial "VectorDistance" (Arity.Between (2, 4)) ReturnKind.Number with
+ AllowedInOrderBy = true
+ AllowedInOrderByRank = true
+ }
+
+ // Names the SDK lists that Microsoft Learn does not document
+ keyword "ALL" "ALL(subquery)"
+ undocumented "ANY" undocumentedName
+ keyword "ARRAY" "ARRAY(subquery)"
+ undocumented "C_BINARY" typedValueFunction
+ undocumented "C_FLOAT32" typedValueFunction
+ undocumented "C_FLOAT64" typedValueFunction
+ undocumented "C_GUID" typedValueFunction
+ undocumented "C_INT16" typedValueFunction
+ undocumented "C_INT32" typedValueFunction
+ undocumented "C_INT64" typedValueFunction
+ undocumented "C_INT8" typedValueFunction
+ undocumented "C_LIST" typedValueFunction
+ undocumented "C_LISTCONTAINS" typedValueFunction
+ undocumented "C_MAP" typedValueFunction
+ undocumented "C_MAPCONTAINS" typedValueFunction
+ undocumented "C_MAPCONTAINSKEY" typedValueFunction
+ undocumented "C_MAPCONTAINSVALUE" typedValueFunction
+ undocumented "C_SET" typedValueFunction
+ undocumented "C_SETCONTAINS" typedValueFunction
+ undocumented "C_TUPLE" typedValueFunction
+ undocumented "C_UDT" typedValueFunction
+ undocumented "C_UINT32" typedValueFunction
+ undocumented "ContainsAllCi" undocumentedName
+ undocumented "ContainsAllCs" undocumentedName
+ undocumented "ContainsAnyCi" undocumentedName
+ undocumented "ContainsAnyCs" undocumentedName
+ undocumented "HASH" undocumentedName
+ sdkSpelling "IntBitwiseAnd" "INTBITAND"
+ sdkSpelling "IntBitwiseLeftShift" "INTBITLEFTSHIFT"
+ sdkSpelling "IntBitwiseNot" "INTBITNOT"
+ sdkSpelling "IntBitwiseOr" "INTBITOR"
+ sdkSpelling "IntBitwiseRightShift" "INTBITRIGHTSHIFT"
+ sdkSpelling "IntBitwiseXor" "INTBITXOR"
+ keyword "LIKE" "the LIKE operator, (value LIKE pattern)"
+ }
+ |> ImmutableArray.CreateRange
diff --git a/src/Cosmos.Sql/EquatableArray.fs b/src/Cosmos.Sql/EquatableArray.fs
new file mode 100644
index 00000000..eb17366e
--- /dev/null
+++ b/src/Cosmos.Sql/EquatableArray.fs
@@ -0,0 +1,199 @@
+namespace FSharp.Azure.Cosmos.Sql
+
+open System
+open System.Collections
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Runtime.InteropServices
+
+///
+/// An immutable array with structural equality: two instances are equal when they hold equal elements in the same
+/// order, and the hash code combines the hash codes of the elements.
+///
+/// The own equality of , its
+///
+/// that calls, compares the reference of the array
+/// it wraps. Only comparers that go through , such as the equality
+/// the F# compiler generates for records and unions, compare its elements;
+/// , a
+/// of arrays or a C# caller compares references, and a default
+/// array never equals an empty one. The nodes of the syntax tree hold their child sequences in this wrapper instead
+/// (the pattern Roslyn incremental generators use), so their equality is structural under every comparer, for
+/// round-trip tests, cache keys and the idempotence of passes.
+///
+///
+/// The default value holds no array; it behaves as an empty array and is equal to one.
+///
+///
+/// The elements, in order.
+[]
+type EquatableArray<'T> (array : ImmutableArray<'T>) =
+
+ ///
+ /// The elements; never a default , so it can be
+ /// enumerated and indexed without a check.
+ ///
+ member _.Items = if array.IsDefault then ImmutableArray<'T>.Empty else array
+
+ /// The number of elements.
+ member _.Length = if array.IsDefault then 0 else array.Length
+
+ /// Whether the array has no elements.
+ member this.IsEmpty = this.Length = 0
+
+ ///
+ /// The element at .
+ ///
+ /// The zero-based position of the element.
+ member this.Item
+ with get (index : int) = this.Items[index]
+
+ ///
+ /// Returns an allocation-free enumerator over the elements, which F# loops use.
+ ///
+ member this.GetEnumerator () = this.Items.GetEnumerator ()
+
+ ///
+ /// Whether holds equal elements in the same order; elements are compared with
+ /// .
+ ///
+ /// The array to compare with.
+ member this.Equals (other : EquatableArray<'T>) =
+ let left = this.Items
+ let right = other.Items
+
+ if left.Length <> right.Length then
+ false
+ else
+ let comparer = EqualityComparer<'T>.Default
+ let mutable equal = true
+ let mutable index = 0
+
+ while equal && index < left.Length do
+ equal <- comparer.Equals (left[index], right[index])
+ index <- index + 1
+
+ equal
+
+ ///
+ override this.Equals (other : objnull) =
+ match other with
+ | :? EquatableArray<'T> as other -> this.Equals other
+ | _ -> false
+
+ /// Combines the hash codes of the elements, in order, so equal arrays have equal hash codes.
+ override this.GetHashCode () =
+ let mutable hash = HashCode ()
+
+ for item in this.Items do
+ hash.Add item
+
+ hash.ToHashCode ()
+
+ /// Shows the elements in square brackets, separated by semicolons, the way F# shows an array.
+ override this.ToString () =
+ let items = this.Items |> Seq.map (fun item -> $"%A{item}")
+ $"""[| {String.Join ("; ", items)} |]"""
+
+ ///
+ /// Wraps without copying it, so that an
+ /// can be given where an
+ /// is expected.
+ ///
+ /// The elements.
+ static member op_Implicit (items : ImmutableArray<'T>) : EquatableArray<'T> = EquatableArray items
+
+ ///
+ /// The elements of as an ,
+ /// without copying them and never as a default array, so that an
+ /// can be given where an
+ /// is expected.
+ ///
+ /// The array.
+ static member op_Implicit (array : EquatableArray<'T>) : ImmutableArray<'T> = array.Items
+
+ interface IEquatable> with
+ ///
+ member this.Equals other = this.Equals other
+
+ interface IReadOnlyList<'T> with
+ ///
+ member this.Count = this.Length
+
+ ///
+ member this.Item
+ with get index = this.Items[index]
+
+ ///
+ member this.GetEnumerator () : IEnumerator<'T> = (this.Items :> IEnumerable<'T>).GetEnumerator()
+
+ ///
+ member this.GetEnumerator () : IEnumerator = (this.Items :> IEnumerable).GetEnumerator()
+
+///
+/// Functions that create and transform values.
+///
+[]
+[]
+module EquatableArray =
+
+ /// The array without elements.
+ []
+ let empty<'T> : EquatableArray<'T> = EquatableArray ImmutableArray<'T>.Empty
+
+ ///
+ /// Wraps without copying it.
+ ///
+ /// The elements.
+ let ofImmutableArray (items : ImmutableArray<'T>) = EquatableArray items
+
+ ///
+ /// Copies into a new array.
+ ///
+ /// The elements.
+ let ofArray (items : 'T array) = EquatableArray (ImmutableArray.Create<'T> items)
+
+ ///
+ /// Wraps without copying it, as
+ /// does.
+ ///
+ /// The caller gives the array away: the result is immutable only as long as nothing writes to
+ /// any more. A later write changes the elements, the equality and the hash code of the
+ /// result, also where it already is a key of a dictionary or a member of a set.
+ ///
+ ///
+ /// The elements, in an array that is never written to again.
+ let unsafeOfArray (items : 'T array) = EquatableArray (ImmutableCollectionsMarshal.AsImmutableArray<'T> items)
+
+ ///
+ /// Copies into a new array.
+ ///
+ /// The elements.
+ let ofSeq (items : 'T seq) = EquatableArray (ImmutableArray.CreateRange<'T> items)
+
+ ///
+ /// An array that holds only.
+ ///
+ /// The element.
+ let singleton (item : 'T) = EquatableArray (ImmutableArray.Create<'T> item)
+
+ ///
+ /// The elements of as an ,
+ /// without copying them.
+ ///
+ /// The array.
+ let toImmutableArray (array : EquatableArray<'T>) = array.Items
+
+ ///
+ /// Applies to every element of , in order.
+ ///
+ /// The function to apply.
+ /// The array.
+ let map (mapping : 'T -> 'U) (array : EquatableArray<'T>) =
+ let items = array.Items
+ let builder = ImmutableArray.CreateBuilder<'U> items.Length
+
+ for item in items do
+ builder.Add (mapping item)
+
+ EquatableArray (builder.MoveToImmutable ())
diff --git a/src/Cosmos.Sql/FSharp.Azure.Cosmos.Sql.fsproj b/src/Cosmos.Sql/FSharp.Azure.Cosmos.Sql.fsproj
new file mode 100644
index 00000000..caab01b1
--- /dev/null
+++ b/src/Cosmos.Sql/FSharp.Azure.Cosmos.Sql.fsproj
@@ -0,0 +1,50 @@
+
+
+
+ net10.0
+ $(AssemblyBaseName).Sql
+ true
+ true
+
+
+
+ FSharp.Azure.Cosmos.Sql
+ FSharp.Azure.Cosmos.Sql
+ Syntax tree, printer, validator and function catalog of the Azure Cosmos DB NoSQL query language
+ An immutable syntax tree of the Azure Cosmos DB NoSQL query language that mirrors the grammar of the .NET SDK, a deterministic printer with a parameterized and an inline mode, a validator for the rules the grammar cannot express and the catalog of the built-in functions as data. It has no dependency on the Azure Cosmos DB SDK.
+ $(PackageTags);SQL;Query;Syntax tree
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/src/Cosmos.Sql/FunctionSpec.fs b/src/Cosmos.Sql/FunctionSpec.fs
new file mode 100644
index 00000000..80e96a66
--- /dev/null
+++ b/src/Cosmos.Sql/FunctionSpec.fs
@@ -0,0 +1,233 @@
+namespace FSharp.Azure.Cosmos.Sql
+
+open System
+
+/// The group a built-in function belongs to, following the function index of the query language on Microsoft Learn;
+/// the functions that Learn does not document form a group of their own.
+[]
+type FunctionCategory =
+ ///
+ /// Aggregation functions such as COUNT.
+ ///
+ | Aggregate
+ ///
+ /// Array functions such as ARRAY_CONTAINS.
+ ///
+ | Array
+ ///
+ /// Conditional functions: IIF.
+ ///
+ | Conditional
+ ///
+ /// Date and time functions such as DateTimeAdd.
+ ///
+ | DateTime
+ ///
+ /// Full text search functions such as FullTextContains and RRF.
+ ///
+ | FullTextSearch
+ ///
+ /// Item functions: DOCUMENTID.
+ ///
+ | Item
+ ///
+ /// Mathematical functions such as ABS and INTBITAND.
+ ///
+ | Mathematical
+ ///
+ /// Spatial functions such as ST_DISTANCE, and VectorDistance, which Learn lists among them.
+ ///
+ | Spatial
+ ///
+ /// String functions such as CONTAINS.
+ ///
+ | String
+ ///
+ /// Type checking functions such as IS_DEFINED, and the StringTo… conversions Learn lists among them.
+ ///
+ | TypeChecking
+ /// Names the SDK knows that Microsoft Learn does not document.
+ | Undocumented
+
+/// How many arguments a built-in function takes.
+[]
+type Arity =
+ /// Exactly the given number of arguments.
+ | Exactly of count : int
+ ///
+ /// From to arguments, both inclusive.
+ ///
+ | Between of min : int * max : int
+ ///
+ /// arguments or more.
+ ///
+ | AtLeast of min : int
+ ///
+ /// Not specified yet; every count is accepted. Only entries whose
+ /// is unsupported use it.
+ ///
+ | Unspecified
+
+ ///
+ /// Whether a call with arguments satisfies this arity.
+ ///
+ /// The number of arguments of the call.
+ member this.Accepts (count : int) =
+ match this with
+ | Exactly expected -> count = expected
+ | Between (min, max) -> count >= min && count <= max
+ | AtLeast min -> count >= min
+ | Unspecified -> true
+
+ ///
+ /// The arity as English text for diagnostics, such as 2 to 3.
+ ///
+ override this.ToString () =
+ match this with
+ | Exactly count -> $"%d{count}"
+ | Between (min, max) -> $"%d{min} to %d{max}"
+ | AtLeast min -> $"%d{min} or more"
+ | Unspecified -> "unspecified"
+
+/// The JSON type a built-in function returns when it returns a defined value.
+[]
+type ReturnKind =
+ /// A boolean.
+ | Boolean
+ /// A number.
+ | Number
+ /// A string, including the ISO 8601 date and time strings of the date and time functions.
+ | String
+ /// An array.
+ | Array
+ /// An object.
+ | Object
+ /// Any JSON value, depending on the arguments.
+ | Any
+ /// Not specified yet.
+ | Unspecified
+
+/// How a built-in function treats arguments that are undefined or of the wrong type.
+[]
+type UndefinedRule =
+ /// Returns a defined value for every argument, such as the type checking functions.
+ | NeverUndefined
+ ///
+ /// Returns undefined when an argument is undefined or of a type the function does not accept.
+ ///
+ | UndefinedOnInvalidArgument
+ /// Not specified yet.
+ | Unspecified
+
+/// How a built-in function uses the index when it appears in a filter, as the remarks of its Microsoft Learn page state.
+[]
+type IndexUsage =
+ /// The function performs an index seek.
+ | IndexSeek
+ /// The function performs a precise index scan.
+ | PreciseIndexScan
+ /// The function benefits from a range index.
+ | RangeIndex
+ /// The function performs a full scan.
+ | FullScan
+ /// The function does not use the index.
+ | NoIndex
+ /// Not specified yet, or not stated on the function's page.
+ | Unspecified
+
+/// Where a built-in function is known to work. A flag is set only once it is confirmed: the service flag by the
+/// function's Microsoft Learn page, the emulator flags by the emulator probes of the roadmap.
+[]
+type Availability =
+ /// Not confirmed anywhere.
+ | None = 0
+ /// The Azure Cosmos DB service.
+ | Service = 1
+ /// The Windows Azure Cosmos DB Emulator.
+ | WindowsEmulator = 2
+ /// The Linux vNext Azure Cosmos DB Emulator.
+ | VNextEmulator = 4
+
+///
+/// Whether the catalog specifies a built-in function well enough for the translator and
+///
+/// to use it.
+///
+[]
+type FunctionStatus =
+ /// The entry is specified: arity, return kind and placement rules are filled in.
+ | Supported
+ ///
+ /// The entry is not specified yet, or the name cannot be called; says which.
+ ///
+ | Unsupported of reason : string
+
+/// The specification of one built-in function of the query language: the data the translator, the validator, the
+/// interpreter, the emulator capability gating and the generated function coverage page share.
+type FunctionSpec = {
+ /// The name, spelled the way the SDK spells it, or the way Microsoft Learn spells it for names the SDK does not
+ /// know. The query language compares function names case-insensitively.
+ Name : string
+ /// The group the function belongs to.
+ Category : FunctionCategory
+ /// How many arguments the function takes.
+ Arity : Arity
+ /// The JSON type of a defined result.
+ ReturnKind : ReturnKind
+ /// How the function treats undefined arguments and arguments of the wrong type.
+ UndefinedRule : UndefinedRule
+ /// How the function uses the index in a filter.
+ IndexUsage : IndexUsage
+ /// Where the function is confirmed to work.
+ Availability : Availability
+ ///
+ /// Whether the function returns the same result for the same arguments; RAND and the functions that read
+ /// the current time do not.
+ ///
+ Deterministic : bool
+ ///
+ /// Whether a call may be an item of a plain ORDER BY clause, which otherwise accepts property paths only.
+ ///
+ AllowedInOrderBy : bool
+ ///
+ /// Whether a call may be an item of an ORDER BY RANK clause: the scoring functions.
+ ///
+ AllowedInOrderByRank : bool
+ ///
+ /// Whether the function may appear only in an ORDER BY RANK item, directly or as an argument of another
+ /// scoring function, and nowhere else in a query.
+ ///
+ OnlyInOrderByRank : bool
+ /// The function's page on Microsoft Learn, if it has one.
+ DocUrl : string voption
+ /// Whether the entry is specified.
+ Status : FunctionStatus
+}
+
+///
+/// Why
+/// refused to build a call.
+///
+[]
+type CatalogError =
+ ///
+ /// The catalog has no function named .
+ ///
+ | UnknownFunction of name : string
+ ///
+ /// The catalog does not specify the function named yet, or the name cannot be called, as
+ /// its says, for .
+ ///
+ | Unsupported of name : string * reason : string
+ ///
+ /// The call has arguments, which does not accept.
+ ///
+ | ArityMismatch of name : string * expected : Arity * actual : int
+
+ /// The error as an English sentence.
+ override this.ToString () =
+ match this with
+ | UnknownFunction name -> $"The query language has no built-in function '%s{name}'."
+ | Unsupported (name, reason) -> $"The built-in function '%s{name}' is not supported: %s{reason}"
+ | ArityMismatch (name, expected, actual) ->
+ $"The number of arguments of the built-in function '%s{name}' must be %O{expected}, but the call has %d{actual}."
diff --git a/src/Cosmos.Sql/ImmutableArrayPatterns.fs b/src/Cosmos.Sql/ImmutableArrayPatterns.fs
new file mode 100644
index 00000000..629bed70
--- /dev/null
+++ b/src/Cosmos.Sql/ImmutableArrayPatterns.fs
@@ -0,0 +1,74 @@
+///
+/// Struct partial active patterns that match an by its
+/// length, so code over the children of a syntax node reads like a list pattern without converting to an F# list.
+///
+/// Each pattern returns a value option made a struct by on its
+/// return value, or a , and its payload is a struct tuple, so a match allocates nothing.
+/// A multi-case active pattern over the lengths would return a heap
+/// on every match, which is why every length is its own partial
+/// pattern and lengths a pattern does not cover fall through to the next case.
+///
+///
+///
+///
+/// match arguments with
+/// | Arr0 -> "no argument"
+/// | Arr1 single -> "one argument"
+/// | Arr2 (first, second) -> "two arguments"
+/// | Arr3 (first, second, third) -> "three arguments"
+/// | ArrN 4 -> "four arguments"
+/// | _ -> "more arguments"
+///
+///
+[]
+module FSharp.Azure.Cosmos.Sql.ImmutableArrayPatterns
+
+open System.Collections.Immutable
+
+///
+/// Matches an array without elements. A default (uninitialized) array counts as empty.
+///
+/// The array to match.
+let (|Arr0|_|) (items : ImmutableArray<'T>) = items.IsDefaultOrEmpty
+
+///
+/// Matches an array of exactly one element and returns that element.
+///
+/// The array to match.
+[]
+let (|Arr1|_|) (items : ImmutableArray<'T>) =
+ if not items.IsDefault && items.Length = 1 then
+ ValueSome items[0]
+ else
+ ValueNone
+
+///
+/// Matches an array of exactly two elements and returns them as a struct tuple, which the pattern binds with or
+/// without the keyword.
+///
+/// The array to match.
+[]
+let (|Arr2|_|) (items : ImmutableArray<'T>) =
+ if not items.IsDefault && items.Length = 2 then
+ ValueSome (struct (items[0], items[1]))
+ else
+ ValueNone
+
+///
+/// Matches an array of exactly three elements and returns them as a struct tuple.
+///
+/// The array to match.
+[]
+let (|Arr3|_|) (items : ImmutableArray<'T>) =
+ if not items.IsDefault && items.Length = 3 then
+ ValueSome (struct (items[0], items[1], items[2]))
+ else
+ ValueNone
+
+///
+/// Matches an array of exactly elements, for the lengths that have no pattern of their own;
+/// index the array to read its elements.
+///
+/// The number of elements to match, written as the argument of the pattern.
+/// The array to match.
+let (|ArrN|_|) (length : int) (items : ImmutableArray<'T>) = (if items.IsDefault then 0 else items.Length) = length
diff --git a/src/Cosmos.Sql/Inline.fs b/src/Cosmos.Sql/Inline.fs
new file mode 100644
index 00000000..6a9a9537
--- /dev/null
+++ b/src/Cosmos.Sql/Inline.fs
@@ -0,0 +1,271 @@
+///
+/// The pass behind the inline print mode: it replaces every parameter of a query with the literal its encoder gives.
+///
+/// The encoder contract is one function that gives, for a , the
+/// literal that replaces it, if the parameter has one. The translator builds it from the encoders of its parameter slots;
+/// turns a value that a
+/// serializer has written as JSON into such a literal.
+///
+///
+[]
+module FSharp.Azure.Cosmos.Sql.Inline
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Globalization
+open System.Text.Json
+
+let private forAll (predicate : 'T -> bool) (items : EquatableArray<'T>) =
+ let mutable result = true
+ let mutable index = 0
+
+ while result && index < items.Length do
+ result <- predicate items[index]
+ index <- index + 1
+
+ result
+
+///
+/// Whether is a constant that can replace a parameter: a literal, or an array or object
+/// literal whose elements are constants.
+///
+/// The expression to check.
+let rec isConstant (expression : ScalarExpression) =
+ match expression with
+ | ScalarExpression.Literal _ -> true
+ | ScalarExpression.ArrayCreate items -> items |> forAll isConstant
+ | ScalarExpression.ObjectCreate properties ->
+ properties
+ |> forAll (fun struct (_, value) -> isConstant value)
+ | _ -> false
+
+/// Rewrites a query or a scalar expression with the inline values of its parameters, collecting the errors.
+type private Substitution (encode : ParameterName -> ScalarExpression voption) =
+ let errors = ImmutableArray.CreateBuilder()
+ // A parameter used several times is reported once
+ let reported = HashSet(StringComparer.Ordinal)
+
+ let fail code (name : ParameterName) message =
+ if reported.Add name.Value then
+ errors.Add { Code = code; Message = message }
+
+ // A parameter has one value in a query, however often the query uses it, so the encoder is asked once per name:
+ // an encoder that reads a clock or serializes a value must not give two literals for one parameter
+ let answers = Dictionary(StringComparer.Ordinal)
+
+ let encodeOnce (name : ParameterName) : ScalarExpression voption =
+ let mutable answer = ValueNone
+
+ if not (answers.TryGetValue (name.Value, &answer)) then
+ answer <- encode name
+ answers.Add (name.Value, answer)
+
+ answer
+
+ member _.Errors = errors.ToImmutable ()
+
+ member this.Scalar (expression : ScalarExpression) : ScalarExpression =
+ let scalar = this.Scalar
+ let query = this.Query
+
+ match expression with
+ | ScalarExpression.ParameterRef name ->
+ match encodeOnce name with
+ | ValueSome value when isConstant value -> value
+ | ValueSome _ ->
+ fail
+ ValidationErrorCode.InlineValueNotConstant
+ name
+ ($"The inline value of the parameter %s{name.Value} is not a constant: "
+ + "a literal, or an array or object literal of constants.")
+
+ expression
+ | ValueNone ->
+ fail ValidationErrorCode.MissingInlineValue name $"The parameter %s{name.Value} has no inline value."
+ expression
+ | ScalarExpression.Literal _
+ | ScalarExpression.PropertyRef _ -> expression
+ | ScalarExpression.MemberIndexer (target, index) -> ScalarExpression.MemberIndexer (scalar target, scalar index)
+ | ScalarExpression.Binary (operator, left, right) -> ScalarExpression.Binary (operator, scalar left, scalar right)
+ | ScalarExpression.Unary (operator, operand) -> ScalarExpression.Unary (operator, scalar operand)
+ | ScalarExpression.Conditional (condition, whenTrue, whenFalse) ->
+ ScalarExpression.Conditional (scalar condition, scalar whenTrue, scalar whenFalse)
+ | ScalarExpression.Coalesce (left, right) -> ScalarExpression.Coalesce (scalar left, scalar right)
+ | ScalarExpression.In (needle, negated, haystack) ->
+ ScalarExpression.In (scalar needle, negated, haystack |> EquatableArray.map scalar)
+ | ScalarExpression.Between (value, negated, low, high) ->
+ ScalarExpression.Between (scalar value, negated, scalar low, scalar high)
+ | ScalarExpression.Like (value, pattern, negated, escape) ->
+ ScalarExpression.Like (scalar value, scalar pattern, negated, escape)
+ | ScalarExpression.FunctionCall (func, arguments) ->
+ ScalarExpression.FunctionCall (func, arguments |> EquatableArray.map scalar)
+ | ScalarExpression.ArrayCreate items -> ScalarExpression.ArrayCreate (items |> EquatableArray.map scalar)
+ | ScalarExpression.ObjectCreate properties ->
+ properties
+ |> EquatableArray.map (fun struct (key, value) -> struct (key, scalar value))
+ |> ScalarExpression.ObjectCreate
+ | ScalarExpression.Exists subquery -> ScalarExpression.Exists (query subquery)
+ | ScalarExpression.Array subquery -> ScalarExpression.Array (query subquery)
+ | ScalarExpression.All subquery -> ScalarExpression.All (query subquery)
+ | ScalarExpression.First subquery -> ScalarExpression.First (query subquery)
+ | ScalarExpression.Last subquery -> ScalarExpression.Last (query subquery)
+ | ScalarExpression.Subquery subquery -> ScalarExpression.Subquery (query subquery)
+
+ member this.Count (count : SpecValue) : SpecValue =
+ match count with
+ | SpecValue.Literal _ -> count
+ | SpecValue.Parameter name ->
+ match encodeOnce name with
+ | ValueSome (ScalarExpression.Literal (Literal.Int value)) -> SpecValue.Literal value
+ | ValueSome _ ->
+ fail
+ ValidationErrorCode.InlineCountNotInteger
+ name
+ $"The inline value of the TOP, OFFSET or LIMIT parameter %s{name.Value} is not an integer literal."
+
+ count
+ | ValueNone ->
+ fail ValidationErrorCode.MissingInlineValue name $"The parameter %s{name.Value} has no inline value."
+ count
+
+ member this.Collection (collection : Collection) : Collection =
+ match collection with
+ | Collection.InputPath _ -> collection
+ | Collection.Subquery subquery -> Collection.Subquery (this.Query subquery)
+
+ member this.CollectionExpression (expression : CollectionExpression) : CollectionExpression =
+ match expression with
+ | CollectionExpression.Aliased (collection, alias) -> CollectionExpression.Aliased (this.Collection collection, alias)
+ | CollectionExpression.ArrayIterator (alias, collection) ->
+ CollectionExpression.ArrayIterator (alias, this.Collection collection)
+ | CollectionExpression.Join (left, right) ->
+ CollectionExpression.Join (this.CollectionExpression left, this.CollectionExpression right)
+
+ member this.Query (query : SqlQuery) : SqlQuery =
+ let scalar = this.Scalar
+
+ let select =
+ match query.Select with
+ | SelectSpec.Star -> SelectSpec.Star
+ | SelectSpec.Value expression -> SelectSpec.Value (scalar expression)
+ | SelectSpec.List items ->
+ items
+ |> EquatableArray.map (fun item -> { item with Expression = scalar item.Expression })
+ |> SelectSpec.List
+
+ {
+ Select = select
+ Distinct = query.Distinct
+ Top = query.Top |> ValueOption.map this.Count
+ From = query.From |> ValueOption.map this.CollectionExpression
+ Where = query.Where |> ValueOption.map scalar
+ GroupBy = query.GroupBy |> EquatableArray.map scalar
+ OrderBy =
+ query.OrderBy
+ |> EquatableArray.map (fun item -> { item with Expression = scalar item.Expression })
+ OrderByRank = query.OrderByRank
+ OffsetLimit =
+ query.OffsetLimit
+ |> ValueOption.map (fun struct (offset, limit) -> struct (this.Count offset, this.Count limit))
+ }
+
+let private result (substitution : Substitution) value =
+ let errors = substitution.Errors
+ if errors.IsEmpty then Ok value else Error errors
+
+///
+/// Replaces every parameter of with the literal gives for it, in
+/// every clause and subquery.
+///
+///
+/// Gives the literal that replaces a parameter. It must be a constant (see
+/// ), and an
+/// integer literal for a TOP, OFFSET or LIMIT count. It is asked once for every parameter name,
+/// however often the query uses the parameter, so every use gets the same literal.
+///
+/// The query whose parameters are replaced.
+///
+/// The query without parameters, or one error per parameter that has no literal or an unusable one.
+///
+let substituteParameters
+ (encode : ParameterName -> ScalarExpression voption)
+ (query : SqlQuery)
+ : Result> =
+ let substitution = Substitution encode
+ let substituted = substitution.Query query
+ result substitution substituted
+
+///
+/// Replaces every parameter of with the literal gives for it,
+/// like
+/// does for a whole query.
+///
+///
+/// Gives the literal that replaces a parameter; it must be a constant. It is asked once for every parameter name.
+///
+/// The expression whose parameters are replaced.
+///
+/// The expression without parameters, or one error per parameter that has no literal or an unusable one.
+///
+let substituteScalarParameters
+ (encode : ParameterName -> ScalarExpression voption)
+ (expression : ScalarExpression)
+ : Result> =
+ let substitution = Substitution encode
+ let substituted = substitution.Scalar expression
+ result substitution substituted
+
+///
+/// Converts a JSON value, such as one that
+/// wrote with the serializer options of the client, into the constant that has the value the service reads from that
+/// JSON.
+///
+/// An integral number in the range of becomes an integer
+/// , any other number a double-precision one; object properties keep
+/// their order. The default , which holds no value, becomes the literal
+/// undefined.
+///
+///
+/// The constant does not always write the digits of the JSON number: Azure Cosmos DB keeps every number as an IEEE 754
+/// double, so a number with more digits than a double holds, such as 0.123456789012345678901, becomes the
+/// nearest double, 0.12345678901234568. That is the number the service stores for the same JSON in an item, so
+/// the constant still compares equal to it. A value that must keep every digit has to be stored and compared as a
+/// string.
+///
+///
+/// The JSON value.
+let rec ofJsonElement (element : JsonElement) : ScalarExpression =
+ match element.ValueKind with
+ | JsonValueKind.Object ->
+ let builder = ImmutableArray.CreateBuilder()
+
+ for property in element.EnumerateObject () do
+ builder.Add (struct (property.Name, ofJsonElement property.Value))
+
+ ScalarExpression.ObjectCreate (EquatableArray (builder.ToImmutable ()))
+ | JsonValueKind.Array ->
+ let builder = ImmutableArray.CreateBuilder(element.GetArrayLength ())
+
+ for item in element.EnumerateArray () do
+ builder.Add (ofJsonElement item)
+
+ ScalarExpression.ArrayCreate (EquatableArray (builder.MoveToImmutable ()))
+ | JsonValueKind.String -> ScalarExpression.Literal (Literal.String (element.GetString () |> nonNull))
+ | JsonValueKind.Number ->
+ let mutable integer = 0L
+
+ if element.TryGetInt64 (&integer) then
+ ScalarExpression.Literal (Literal.Int integer)
+ else
+ // The service keeps a number as a double, so the nearest double is the value it reads from these digits
+ // as well; keeping the digits would only make the query text differ from what the service compares.
+ // Double.Parse rather than GetDouble, which throws for a number beyond the range of a double; the
+ // resulting infinity is rejected by the validator
+ ScalarExpression.Literal (
+ Literal.Float (Double.Parse (element.GetRawText (), NumberStyles.Float, CultureInfo.InvariantCulture))
+ )
+ | JsonValueKind.True -> ScalarExpression.Literal (Literal.Boolean true)
+ | JsonValueKind.False -> ScalarExpression.Literal (Literal.Boolean false)
+ | JsonValueKind.Null -> ScalarExpression.Literal Literal.Null
+ | _ -> ScalarExpression.Literal Literal.Undefined
diff --git a/src/Cosmos.Sql/Keywords.fs b/src/Cosmos.Sql/Keywords.fs
new file mode 100644
index 00000000..7ef73f67
--- /dev/null
+++ b/src/Cosmos.Sql/Keywords.fs
@@ -0,0 +1,94 @@
+/// The reserved words of the query language and the identifier rule, used by the validator to check aliases and by the
+/// printer to decide when a property name can be written with a dot.
+[]
+module FSharp.Azure.Cosmos.Sql.Keywords
+
+open System
+open System.Collections.Frozen
+
+///
+/// The keywords of the SDK grammar sql.g4 (its K_ tokens), compared case-insensitively.
+///
+/// The grammar matches most keywords case-insensitively; true, false, null, undefined and
+/// udf only in lower case, and it accepts ALL, FIRST and LAST as identifiers. The set
+/// treats all of them as reserved in every spelling, so an identifier that passes
+/// is never mistaken for a keyword.
+///
+///
+let reserved : FrozenSet =
+ [|
+ "ALL"
+ "AND"
+ "ARRAY"
+ "AS"
+ "ASC"
+ "BETWEEN"
+ "BY"
+ "DESC"
+ "DISTINCT"
+ "ESCAPE"
+ "EXISTS"
+ "FALSE"
+ "FIRST"
+ "FROM"
+ "GROUP"
+ "IN"
+ "JOIN"
+ "LAST"
+ "LEFT"
+ "LIKE"
+ "LIMIT"
+ "NOT"
+ "NULL"
+ "OFFSET"
+ "OR"
+ "ORDER"
+ "RANK"
+ "RIGHT"
+ "SELECT"
+ "TOP"
+ "TRUE"
+ "UDF"
+ "UNDEFINED"
+ "VALUE"
+ "WHERE"
+ |]
+ |> _.ToFrozenSet(StringComparer.OrdinalIgnoreCase)
+
+///
+/// Whether is a reserved word of the query language, in any letter case.
+///
+/// The text to check.
+let isReserved (text : string) = reserved.Contains text
+
+/// Whether the characters match the identifier rule; a span, so a parameter name is checked without its '@' and without
+/// copying it.
+let internal isValidIdentifierSpan (span : ReadOnlySpan) =
+ let isStart (character : char) = Char.IsAsciiLetter character || character = '_'
+
+ if span.IsEmpty || not (isStart span[0]) then
+ false
+ else
+ let mutable valid = true
+ let mutable index = 1
+
+ while valid && index < span.Length do
+ valid <- isStart span[index] || Char.IsAsciiDigit span[index]
+ index <- index + 1
+
+ valid
+
+///
+/// Whether matches the identifier rule of the grammar, [A-Za-z_][A-Za-z_0-9]*, with
+/// ASCII letters only. The empty alternative of the grammar's LEX_IDENTIFIER token is not accepted.
+///
+/// The text to check.
+let isValidIdentifier (text : string) = isValidIdentifierSpan (text.AsSpan ())
+
+///
+/// Whether can be written as a bare identifier: it matches
+/// and is not
+/// .
+///
+/// The text to check.
+let isSafeIdentifier (text : string) = isValidIdentifier text && not (isReserved text)
diff --git a/src/Cosmos.Sql/Printer.fs b/src/Cosmos.Sql/Printer.fs
new file mode 100644
index 00000000..3dcc24d5
--- /dev/null
+++ b/src/Cosmos.Sql/Printer.fs
@@ -0,0 +1,466 @@
+// The printing rules follow SqlObjectTextSerializer of Azure/azure-cosmos-dotnet-v3 3.62.0 (MIT, see
+// THIRD-PARTY-NOTICES.md) without pretty printing, so the output matches the SDK's baselines once whitespace is
+// normalized. The whitespace itself differs in two places, both SDK artefacts: the SDK writes "NOT LIKE" with two
+// spaces and a space after the GROUP BY clause, which doubles the space before ORDER BY or trails the text; this printer
+// writes single spaces only.
+namespace FSharp.Azure.Cosmos.Sql
+
+open System
+open System.Collections.Immutable
+open System.Globalization
+open System.Text
+
+/// How the printer writes an access to a property with a string name.
+[]
+type PropertyStyle =
+ ///
+ /// Always with brackets, c["name"], as the SDK's LINQ provider does: safe for reserved words such as
+ /// value and for any property name. The default.
+ ///
+ | Brackets
+ ///
+ /// With a dot, c.name, when the name passes
+ /// , and with brackets otherwise.
+ ///
+ | DotWhenSafe
+
+///
+/// Writes the syntax tree as query text, deterministically and independently of the current culture.
+///
+/// The rules are those of the SDK's printer: aliases are written bare; every binary, unary, conditional, coalesce,
+/// IN, BETWEEN and LIKE expression is written in parentheses, so the text never depends on operator
+/// precedence; strings are written in double quotes with \", \\, \b, \f, \n,
+/// \r, \t and \uXXXX for the other control characters, while / is written as is like the
+/// SDK does; integers are written in full, other numbers in the round-trip format of the invariant culture;
+/// true, false, null and undefined in lower case; user-defined functions with the
+/// udf. prefix.
+///
+///
+/// Printing is total: every tree is written, valid or not.
+/// decides whether
+/// the text is a valid query. There are two modes: the parameterized mode of
+///
+/// writes parameters as @name, and the inline mode of
+///
+/// writes their values as literals, for APIs that take query text without parameters, such as the filter predicate of
+/// a patch, and for diagnostics.
+///
+///
+[]
+module Printer =
+
+ let private hexDigit (value : int) =
+ if value < 10 then
+ char (int '0' + value)
+ else
+ char (int 'A' + value - 10)
+
+ /// Writes a string literal with the escaping of the SDK's printer.
+ let private writeString (builder : StringBuilder) (text : string) =
+ builder.Append '"' |> ignore
+
+ for character in text do
+ match character with
+ | '\\' -> builder.Append "\\\\" |> ignore
+ | '"' -> builder.Append "\\\"" |> ignore
+ | '\b' -> builder.Append "\\b" |> ignore
+ | '\f' -> builder.Append "\\f" |> ignore
+ | '\n' -> builder.Append "\\n" |> ignore
+ | '\r' -> builder.Append "\\r" |> ignore
+ | '\t' -> builder.Append "\\t" |> ignore
+ | control when control < ' ' ->
+ let code = int control
+
+ builder
+ .Append("\\u")
+ .Append(hexDigit ((code >>> 12) &&& 0xF))
+ .Append(hexDigit ((code >>> 8) &&& 0xF))
+ .Append(hexDigit ((code >>> 4) &&& 0xF))
+ .Append(hexDigit (code &&& 0xF))
+ |> ignore
+ | other -> builder.Append other |> ignore
+
+ builder.Append '"' |> ignore
+
+ let private writeInteger (builder : StringBuilder) (value : int64) =
+ builder.Append (value.ToString (CultureInfo.InvariantCulture))
+ |> ignore
+
+ let private writeLiteral (builder : StringBuilder) (literal : Literal) =
+ match literal with
+ | Literal.Boolean true -> builder.Append "true" |> ignore
+ | Literal.Boolean false -> builder.Append "false" |> ignore
+ | Literal.Null -> builder.Append "null" |> ignore
+ | Literal.Undefined -> builder.Append "undefined" |> ignore
+ | Literal.Int value -> writeInteger builder value
+ | Literal.Float value ->
+ builder.Append (value.ToString ("R", CultureInfo.InvariantCulture))
+ |> ignore
+ | Literal.String value -> writeString builder value
+
+ ///
+ /// Writes a property name after its target: .name or ["name"], depending on the style.
+ ///
+ let private writePropertyName (style : PropertyStyle) (builder : StringBuilder) (name : string) =
+ match style with
+ | PropertyStyle.DotWhenSafe when Keywords.isSafeIdentifier name -> builder.Append('.').Append name |> ignore
+ | PropertyStyle.DotWhenSafe
+ | PropertyStyle.Brackets ->
+ builder.Append '[' |> ignore
+ writeString builder name
+ builder.Append ']' |> ignore
+
+ let private binaryOperator (operator : BinaryOperator) =
+ match operator with
+ | BinaryOperator.Add -> "+"
+ | BinaryOperator.And -> "AND"
+ | BinaryOperator.BitwiseAnd -> "&"
+ | BinaryOperator.BitwiseOr -> "|"
+ | BinaryOperator.BitwiseXor -> "^"
+ | BinaryOperator.Divide -> "/"
+ | BinaryOperator.Equal -> "="
+ | BinaryOperator.GreaterThan -> ">"
+ | BinaryOperator.GreaterThanOrEqual -> ">="
+ | BinaryOperator.LessThan -> "<"
+ | BinaryOperator.LessThanOrEqual -> "<="
+ | BinaryOperator.Modulo -> "%"
+ | BinaryOperator.Multiply -> "*"
+ | BinaryOperator.NotEqual -> "!="
+ | BinaryOperator.Or -> "OR"
+ | BinaryOperator.StringConcat -> "||"
+ | BinaryOperator.Subtract -> "-"
+
+ let private unaryOperator (operator : UnaryOperator) =
+ match operator with
+ | UnaryOperator.BitwiseNot -> "~"
+ | UnaryOperator.Not -> "NOT"
+ | UnaryOperator.Minus -> "-"
+ | UnaryOperator.Plus -> "+"
+
+ let private writeCount (builder : StringBuilder) (count : SpecValue) =
+ match count with
+ | SpecValue.Literal value -> writeInteger builder value
+ | SpecValue.Parameter name -> builder.Append name.Value |> ignore
+
+ /// Writes the items with the separator between them.
+ let private writeSeparated (builder : StringBuilder) (separator : string) (write : 'T -> unit) (items : EquatableArray<'T>) =
+ for index in 0 .. items.Length - 1 do
+ if index > 0 then
+ builder.Append separator |> ignore
+
+ write items[index]
+
+ let rec private writePath (style : PropertyStyle) (builder : StringBuilder) (path : PathExpression) =
+ let writeParent parent =
+ match parent with
+ | ValueSome parent -> writePath style builder parent
+ | ValueNone -> ()
+
+ match path with
+ | PathExpression.Identifier (parent, name) ->
+ writeParent parent
+ builder.Append('.').Append name.Value |> ignore
+ | PathExpression.Number (parent, index) ->
+ writeParent parent
+ builder.Append '[' |> ignore
+ writeInteger builder index
+ builder.Append ']' |> ignore
+ | PathExpression.String (parent, name) ->
+ writeParent parent
+ writePropertyName style builder name
+
+ let rec private writeScalar (style : PropertyStyle) (builder : StringBuilder) (expression : ScalarExpression) =
+ let scalar = writeScalar style builder
+ let append (text : string) = builder.Append text |> ignore
+
+ let subquery (keyword : string) query =
+ append keyword
+ append "("
+ writeQuery style builder query
+ append ")"
+
+ match expression with
+ | ScalarExpression.Literal literal -> writeLiteral builder literal
+ | ScalarExpression.ParameterRef name -> append name.Value
+ | ScalarExpression.PropertyRef identifier -> append identifier.Value
+ | ScalarExpression.MemberIndexer (target, ScalarExpression.Literal (Literal.String name)) ->
+ scalar target
+ writePropertyName style builder name
+ | ScalarExpression.MemberIndexer (target, index) ->
+ scalar target
+ append "["
+ scalar index
+ append "]"
+ | ScalarExpression.Binary (operator, left, right) ->
+ append "("
+ scalar left
+ append " "
+ append (binaryOperator operator)
+ append " "
+ scalar right
+ append ")"
+ | ScalarExpression.Unary (operator, operand) ->
+ append "("
+ append (unaryOperator operator)
+ append " "
+ scalar operand
+ append ")"
+ | ScalarExpression.Conditional (condition, whenTrue, whenFalse) ->
+ append "("
+ scalar condition
+ append " ? "
+ scalar whenTrue
+ append " : "
+ scalar whenFalse
+ append ")"
+ | ScalarExpression.Coalesce (left, right) ->
+ append "("
+ scalar left
+ append " ?? "
+ scalar right
+ append ")"
+ | ScalarExpression.In (needle, negated, haystack) ->
+ append "("
+ scalar needle
+ append (if negated then " NOT IN (" else " IN (")
+ haystack |> writeSeparated builder ", " scalar
+ append "))"
+ | ScalarExpression.Between (value, negated, low, high) ->
+ append "("
+ scalar value
+ append (if negated then " NOT BETWEEN " else " BETWEEN ")
+ scalar low
+ append " AND "
+ scalar high
+ append ")"
+ | ScalarExpression.Like (value, pattern, negated, escape) ->
+ append "("
+ scalar value
+ append (if negated then " NOT LIKE " else " LIKE ")
+ scalar pattern
+
+ match escape with
+ | ValueSome escape ->
+ append " ESCAPE "
+ writeString builder escape
+ | ValueNone -> ()
+
+ append ")"
+ | ScalarExpression.FunctionCall (func, arguments) ->
+ match func with
+ | FunctionRef.BuiltIn name -> append name
+ | FunctionRef.Udf name ->
+ append "udf."
+ append name
+
+ append "("
+ arguments |> writeSeparated builder ", " scalar
+ append ")"
+ | ScalarExpression.ArrayCreate items ->
+ append "["
+ items |> writeSeparated builder ", " scalar
+ append "]"
+ | ScalarExpression.ObjectCreate properties ->
+ append "{"
+
+ properties
+ |> writeSeparated
+ builder
+ ", "
+ (fun struct (name, value) ->
+ // The SDK writes property names without escaping them; this printer escapes them like every
+ // other string, which yields the same text for every name that does not need escaping
+ writeString builder name
+ append ": "
+ scalar value
+ )
+
+ append "}"
+ | ScalarExpression.Exists query -> subquery "EXISTS" query
+ | ScalarExpression.Array query -> subquery "ARRAY" query
+ | ScalarExpression.All query -> subquery "ALL" query
+ | ScalarExpression.First query -> subquery "FIRST" query
+ | ScalarExpression.Last query -> subquery "LAST" query
+ | ScalarExpression.Subquery query -> subquery "" query
+
+ and private writeCollection (style : PropertyStyle) (builder : StringBuilder) (collection : Collection) =
+ match collection with
+ | Collection.InputPath (input, path) ->
+ builder.Append input.Value |> ignore
+
+ match path with
+ | ValueSome path -> writePath style builder path
+ | ValueNone -> ()
+ | Collection.Subquery query ->
+ builder.Append '(' |> ignore
+ writeQuery style builder query
+ builder.Append ')' |> ignore
+
+ and private writeCollectionExpression (style : PropertyStyle) (builder : StringBuilder) (expression : CollectionExpression) =
+ match expression with
+ | CollectionExpression.Aliased (collection, alias) ->
+ writeCollection style builder collection
+
+ match alias with
+ | ValueSome alias -> builder.Append(" AS ").Append alias.Value |> ignore
+ | ValueNone -> ()
+ | CollectionExpression.ArrayIterator (alias, collection) ->
+ builder.Append(alias.Value).Append " IN " |> ignore
+ writeCollection style builder collection
+ | CollectionExpression.Join (left, right) ->
+ writeCollectionExpression style builder left
+ builder.Append " JOIN " |> ignore
+ writeCollectionExpression style builder right
+
+ and private writeQuery (style : PropertyStyle) (builder : StringBuilder) (query : SqlQuery) =
+ let scalar = writeScalar style builder
+ let append (text : string) = builder.Append text |> ignore
+
+ append "SELECT "
+
+ if query.Distinct then
+ append "DISTINCT "
+
+ match query.Top with
+ | ValueSome top ->
+ append "TOP "
+ writeCount builder top
+ append " "
+ | ValueNone -> ()
+
+ match query.Select with
+ | SelectSpec.Star -> append "*"
+ | SelectSpec.Value expression ->
+ append "VALUE "
+ scalar expression
+ | SelectSpec.List items ->
+ items
+ |> writeSeparated
+ builder
+ ", "
+ (fun item ->
+ scalar item.Expression
+
+ match item.Alias with
+ | ValueSome alias -> builder.Append(" AS ").Append alias.Value |> ignore
+ | ValueNone -> ()
+ )
+
+ match query.From with
+ | ValueSome from ->
+ append " FROM "
+ writeCollectionExpression style builder from
+ | ValueNone -> ()
+
+ match query.Where with
+ | ValueSome predicate ->
+ append " WHERE "
+ scalar predicate
+ | ValueNone -> ()
+
+ if not query.GroupBy.IsEmpty then
+ append " GROUP BY "
+ query.GroupBy |> writeSeparated builder ", " scalar
+
+ if not query.OrderBy.IsEmpty then
+ append (
+ if query.OrderByRank then
+ " ORDER BY RANK "
+ else
+ " ORDER BY "
+ )
+
+ query.OrderBy
+ |> writeSeparated
+ builder
+ ", "
+ (fun item ->
+ scalar item.Expression
+
+ match item.Order with
+ | ValueSome SortOrder.Ascending -> append " ASC"
+ | ValueSome SortOrder.Descending -> append " DESC"
+ | ValueNone -> ()
+ )
+
+ match query.OffsetLimit with
+ | ValueSome (struct (offset, limit)) ->
+ append " OFFSET "
+ writeCount builder offset
+ append " LIMIT "
+ writeCount builder limit
+ | ValueNone -> ()
+
+ let private render (write : StringBuilder -> unit) =
+ let builder = StringBuilder ()
+ write builder
+ builder.ToString ()
+
+ ///
+ /// Writes as query text, with parameters as @name.
+ ///
+ /// How to write accesses to properties with string names.
+ /// The query to write.
+ let print (style : PropertyStyle) (query : SqlQuery) : string = render (fun builder -> writeQuery style builder query)
+
+ ///
+ /// Writes as query text, with parameters as @name.
+ ///
+ /// How to write accesses to properties with string names.
+ /// The expression to write.
+ let printScalar (style : PropertyStyle) (expression : ScalarExpression) : string =
+ render (fun builder -> writeScalar style builder expression)
+
+ ///
+ /// Writes as the filter text FROM alias WHERE predicate that the SDK's
+ /// PatchItemRequestOptions.FilterPredicate
+ /// expects, with parameters as @name.
+ ///
+ /// How to write accesses to properties with string names.
+ /// The alias the predicate refers to the item by.
+ /// The condition.
+ let printPredicate (style : PropertyStyle) (alias : Identifier) (predicate : ScalarExpression) : string =
+ render (fun builder ->
+ builder.Append("FROM ").Append(alias.Value).Append " WHERE "
+ |> ignore
+ writeScalar style builder predicate
+ )
+
+ ///
+ /// Writes as query text with every parameter replaced by the literal
+ /// gives for it, through
+ /// .
+ ///
+ /// How to write accesses to properties with string names.
+ /// Gives the literal that replaces a parameter.
+ /// The query to write.
+ /// The text, or one error per parameter that has no literal or an unusable one.
+ let printInline
+ (style : PropertyStyle)
+ (encode : ParameterName -> ScalarExpression voption)
+ (query : SqlQuery)
+ : Result> =
+ query
+ |> Inline.substituteParameters encode
+ |> Result.map (print style)
+
+ ///
+ /// Writes as the filter text FROM alias WHERE predicate, like
+ /// ,
+ /// with every parameter replaced by the literal gives for it: the form the filter
+ /// predicate of a patch needs, because it takes no parameters.
+ ///
+ /// How to write accesses to properties with string names.
+ /// Gives the literal that replaces a parameter.
+ /// The alias the predicate refers to the item by.
+ /// The condition.
+ /// The text, or one error per parameter that has no literal or an unusable one.
+ let printPredicateInline
+ (style : PropertyStyle)
+ (encode : ParameterName -> ScalarExpression voption)
+ (alias : Identifier)
+ (predicate : ScalarExpression)
+ : Result> =
+ predicate
+ |> Inline.substituteScalarParameters encode
+ |> Result.map (printPredicate style alias)
diff --git a/src/Cosmos.Sql/Syntax.fs b/src/Cosmos.Sql/Syntax.fs
new file mode 100644
index 00000000..07a0834e
--- /dev/null
+++ b/src/Cosmos.Sql/Syntax.fs
@@ -0,0 +1,384 @@
+// The syntax tree of the Azure Cosmos DB NoSQL query language. One union case or record per non-terminal of the SDK
+// grammar sql.g4, named after the SDK's SqlObjects classes, so SDK baselines, parser baselines and the port of the SDK's
+// offline engine line up with it one to one. Derived from Azure/azure-cosmos-dotnet-v3 3.62.0 (MIT), see
+// THIRD-PARTY-NOTICES.md.
+namespace FSharp.Azure.Cosmos.Sql
+
+///
+/// An identifier of the query language: a collection alias, an input name, a select-item alias or a path segment
+/// written with a dot, stored without any quoting.
+///
+/// Mirrors SqlIdentifier.
+/// The validator checks every identifier against the pattern [A-Za-z_][A-Za-z_0-9]* and the reserved words in
+/// .
+///
+///
+[]
+type Identifier =
+ /// An identifier with the given text.
+ | Identifier of string
+
+ /// The text of the identifier.
+ member this.Value =
+ let (Identifier value) = this
+ value
+
+///
+/// The name of a query parameter, including its leading @, as the SDK's
+/// QueryDefinition.WithParameter
+/// expects it.
+///
+/// Mirrors SqlParameter.
+/// The validator checks every name against the pattern @[A-Za-z_][A-Za-z_0-9]*.
+///
+///
+[]
+type ParameterName =
+ ///
+ /// A parameter name with the given text, including the leading @.
+ ///
+ | ParameterName of string
+
+ ///
+ /// The text of the name, including the leading @.
+ ///
+ member this.Value =
+ let (ParameterName value) = this
+ value
+
+///
+/// A literal value of the literal rule of the grammar.
+///
+/// Numbers are split the way the SDK's printer writes them: integers are written in full and double-precision numbers
+/// in the round-trip format, so a double-precision number without a fractional part is written without one, as the
+/// SDK does. Decimals are never literals; the translator passes them as parameters.
+///
+///
+[]
+type Literal =
+ ///
+ /// The literal true or false.
+ ///
+ | Boolean of bool
+ /// The literal null.
+ | Null
+ /// The literal undefined.
+ | Undefined
+ ///
+ /// An integer. The validator rejects a value outside -2^53..2^53, which a JSON number cannot hold exactly, unless
+ /// is set; parameters are never range checked.
+ ///
+ | Int of int64
+ ///
+ /// A double-precision number. The validator rejects and the infinities, which have
+ /// no literal form.
+ ///
+ | Float of float
+ /// A string, written in double quotes with the escaping of the SDK's printer.
+ | String of string
+
+///
+/// The binary operators of the grammar: the seventeen kinds of the SDK's
+/// SqlBinaryScalarOperatorKind
+/// except ??, which represents with a case of its own.
+///
+/// The grammar has no shift operators; shifts are the functions INTBITLEFTSHIFT and INTBITRIGHTSHIFT.
+///
+///
+[]
+type BinaryOperator =
+ /// +
+ | Add
+ /// AND
+ | And
+ /// &
+ | BitwiseAnd
+ /// |
+ | BitwiseOr
+ /// ^
+ | BitwiseXor
+ /// /
+ | Divide
+ /// =
+ | Equal
+ /// >
+ | GreaterThan
+ /// >=
+ | GreaterThanOrEqual
+ /// <
+ | LessThan
+ /// <=
+ | LessThanOrEqual
+ /// %
+ | Modulo
+ /// *
+ | Multiply
+ /// !=
+ | NotEqual
+ /// OR
+ | Or
+ /// ||
+ | StringConcat
+ /// -
+ | Subtract
+
+///
+/// The unary operators of the grammar, the four kinds of the SDK's
+/// SqlUnaryScalarOperatorKind.
+///
+[]
+type UnaryOperator =
+ /// ~
+ | BitwiseNot
+ /// NOT
+ | Not
+ /// -
+ | Minus
+ /// +
+ | Plus
+
+///
+/// The sort order of an ORDER BY item, the sort_order rule of the grammar.
+///
+[]
+type SortOrder =
+ /// ASC
+ | Ascending
+ /// DESC
+ | Descending
+
+///
+/// The function that a function call in a calls.
+///
+[]
+type FunctionRef =
+ ///
+ /// A built-in function, looked up case-insensitively in .
+ ///
+ | BuiltIn of name : string
+ ///
+ /// A user-defined function, written with the udf. prefix.
+ ///
+ | Udf of name : string
+
+ ///
+ /// The name of the function, without the udf. prefix.
+ ///
+ member this.Name =
+ match this with
+ | BuiltIn name
+ | Udf name -> name
+
+///
+/// The count of TOP, OFFSET or LIMIT, which the grammar allows as an integer literal or a
+/// parameter only (top_spec, offset_count, limit_count).
+///
+[]
+type SpecValue =
+ /// An integer literal; the validator rejects a negative one.
+ | Literal of int64
+ /// A parameter.
+ | Parameter of ParameterName
+
+///
+/// The path after the input name of an input-path collection, such as .tags in FROM t IN c.tags; the
+/// path_expression rule of the grammar. Each segment holds the path before it.
+///
+[]
+type PathExpression =
+ ///
+ /// A segment written with a dot, parent.name.
+ ///
+ | Identifier of parent : PathExpression voption * name : Identifier
+ ///
+ /// An array index, parent[index].
+ ///
+ | Number of parent : PathExpression voption * index : int64
+ ///
+ /// A property name in brackets, parent["name"], which is how the translator always writes it.
+ ///
+ | String of parent : PathExpression voption * name : string
+
+///
+/// A scalar expression, the scalar_expression rule of the grammar with every alternative of
+/// primary_expression.
+///
+/// There is no case for raw SQL text: text that has to become part of a query is parsed into these cases. A member
+/// access c.name is represented as a member indexer with a string literal index, which the printer writes as
+/// c["name"], so reserved words such as value and arbitrary property names need no special treatment.
+///
+///
+[]
+type ScalarExpression =
+ /// A literal value.
+ | Literal of Literal
+ /// A reference to a query parameter.
+ | ParameterRef of ParameterName
+ ///
+ /// A bare identifier, such as the collection alias c.
+ ///
+ | PropertyRef of Identifier
+ ///
+ /// A member or element access, target[index]: c["name"] with a string literal index, arr[0]
+ /// with an integer one.
+ ///
+ | MemberIndexer of target : ScalarExpression * index : ScalarExpression
+ /// A binary operation, written in parentheses.
+ | Binary of operator : BinaryOperator * left : ScalarExpression * right : ScalarExpression
+ /// A unary operation, written in parentheses.
+ | Unary of operator : UnaryOperator * operand : ScalarExpression
+ ///
+ /// The ternary operator (condition ? whenTrue : whenFalse).
+ ///
+ | Conditional of condition : ScalarExpression * whenTrue : ScalarExpression * whenFalse : ScalarExpression
+ ///
+ /// The operator (left ?? right), which yields when is
+ /// undefined.
+ ///
+ | Coalesce of left : ScalarExpression * right : ScalarExpression
+ ///
+ /// (needle IN (haystack)) or, when , (needle NOT IN (haystack)). The grammar
+ /// and the validator require at least one element in .
+ ///
+ | In of needle : ScalarExpression * negated : bool * haystack : EquatableArray
+ ///
+ /// (value BETWEEN low AND high) or, when , (value NOT BETWEEN low AND high).
+ ///
+ | Between of value : ScalarExpression * negated : bool * low : ScalarExpression * high : ScalarExpression
+ ///
+ /// (value LIKE pattern) or, when , (value NOT LIKE pattern), optionally
+ /// followed by ESCAPE "escape".
+ ///
+ | Like of value : ScalarExpression * pattern : ScalarExpression * negated : bool * escape : string voption
+ ///
+ /// A call of a built-in or user-defined function.
+ /// builds calls of built-in functions after checking their arity.
+ ///
+ | FunctionCall of func : FunctionRef * arguments : EquatableArray
+ /// An array literal, [a, b].
+ | ArrayCreate of items : EquatableArray
+ ///
+ /// An object literal, {"a": x, "b": y}. Every key is a string, so the grammar rule that object keys are
+ /// string literals holds by construction.
+ ///
+ | ObjectCreate of properties : EquatableArray
+ /// EXISTS(subquery).
+ | Exists of SqlQuery
+ /// ARRAY(subquery).
+ | Array of SqlQuery
+ /// ALL(subquery).
+ | All of SqlQuery
+ /// FIRST(subquery).
+ | First of SqlQuery
+ /// LAST(subquery).
+ | Last of SqlQuery
+ ///
+ /// A scalar subquery, (subquery).
+ ///
+ | Subquery of SqlQuery
+
+///
+/// A query, the sql_query rule of the grammar, with the select clause flattened into it. The printer writes the
+/// clauses in the order of the grammar:
+/// SELECT [DISTINCT] [TOP n] spec FROM … WHERE … GROUP BY … ORDER BY [RANK] … OFFSET n LIMIT m.
+///
+and SqlQuery = {
+ /// What the query projects.
+ Select : SelectSpec
+ ///
+ /// Whether the projection is SELECT DISTINCT.
+ ///
+ Distinct : bool
+ ///
+ /// The count of TOP, if any.
+ ///
+ Top : SpecValue voption
+ ///
+ /// The FROM clause, if any.
+ ///
+ From : CollectionExpression voption
+ ///
+ /// The WHERE predicate, if any.
+ ///
+ Where : ScalarExpression voption
+ ///
+ /// The GROUP BY keys; empty for no GROUP BY clause.
+ ///
+ GroupBy : EquatableArray
+ ///
+ /// The ORDER BY items; empty for no ORDER BY clause.
+ ///
+ OrderBy : EquatableArray
+ ///
+ /// Whether the ORDER BY clause is ORDER BY RANK, which applies to all of its items, so plain and rank
+ /// items cannot be mixed.
+ ///
+ OrderByRank : bool
+ ///
+ /// The counts of OFFSET and LIMIT, which the grammar only allows together.
+ ///
+ OffsetLimit : struct (SpecValue * SpecValue) voption
+}
+
+///
+/// The projection of a query, the selection rule of the grammar.
+///
+and [] SelectSpec =
+ ///
+ /// SELECT *, valid with exactly one collection in the FROM clause.
+ ///
+ | Star
+ /// SELECT VALUE expression.
+ | Value of ScalarExpression
+ ///
+ /// SELECT a, b AS x; at least one item.
+ ///
+ | List of EquatableArray
+
+///
+/// An item of a select list, expression [AS alias].
+///
+and SelectItem = {
+ /// The projected expression.
+ Expression : ScalarExpression
+ /// The name of the projected property, if given.
+ Alias : Identifier voption
+}
+
+///
+/// A source of the FROM clause, the collection_expression rule of the grammar.
+///
+and [] CollectionExpression =
+ ///
+ /// A collection with an optional alias, c, c AS d or (subquery) AS x.
+ ///
+ | Aliased of collection : Collection * alias : Identifier voption
+ ///
+ /// An iteration over an array, t IN c.tags.
+ ///
+ | ArrayIterator of alias : Identifier * collection : Collection
+ ///
+ /// A self join with an array of the same item, left JOIN right.
+ ///
+ | Join of left : CollectionExpression * right : CollectionExpression
+
+///
+/// A collection, the collection rule of the grammar.
+///
+and [] Collection =
+ ///
+ /// An input name with an optional path, c or c.tags.
+ ///
+ | InputPath of input : Identifier * path : PathExpression voption
+ /// A subquery, (SELECT …).
+ | Subquery of SqlQuery
+
+///
+/// An item of an ORDER BY clause, expression [ASC|DESC].
+///
+and OrderByItem = {
+ /// The sort key.
+ Expression : ScalarExpression
+ /// The sort order, if written; the service sorts ascending without one.
+ Order : SortOrder voption
+}
diff --git a/src/Cosmos.Sql/ValidationError.fs b/src/Cosmos.Sql/ValidationError.fs
new file mode 100644
index 00000000..23c679d4
--- /dev/null
+++ b/src/Cosmos.Sql/ValidationError.fs
@@ -0,0 +1,97 @@
+namespace FSharp.Azure.Cosmos.Sql
+
+///
+/// The rule a reports: a rule of the query language that the
+/// grammar cannot express, or a parameter that inline printing cannot replace.
+///
+[]
+type ValidationErrorCode =
+ ///
+ /// SELECT * without exactly one collection in the FROM clause.
+ ///
+ | SelectStarWithoutSingleAlias
+ ///
+ /// TOP in a subquery, which the service rejects.
+ ///
+ | TopInSubquery
+ ///
+ /// A parameter in an ORDER BY item that is not a call of a function allowed there: a parameter cannot choose
+ /// the sort key. The arguments of an allowed call, such as the query vector of VectorDistance, may be
+ /// parameters.
+ ///
+ | ParameterInOrderBy
+ ///
+ /// A parameter in a GROUP BY key, which the service rejects.
+ ///
+ | ParameterInGroupBy
+ ///
+ /// An ORDER BY item that is neither a property path nor a call of a function allowed in ORDER BY.
+ ///
+ | OrderByItemNotPath
+ ///
+ /// An ORDER BY RANK item that is not a call of a scoring function.
+ ///
+ | RankItemNotScoringFunction
+ ///
+ /// A function that may appear only in ORDER BY RANK used anywhere else.
+ ///
+ | ScoringFunctionOutsideRank
+ ///
+ /// An identifier that does not match [A-Za-z_][A-Za-z_0-9]*.
+ ///
+ | InvalidIdentifier
+ /// An identifier that is a reserved word of the query language.
+ | ReservedIdentifier
+ ///
+ /// A parameter name that does not match @[A-Za-z_][A-Za-z_0-9]*.
+ ///
+ | InvalidParameterName
+ ///
+ /// An IN list without elements.
+ ///
+ | EmptyInList
+ /// A select list without items.
+ | EmptySelectList
+ /// An integer literal outside -2^53..2^53, which a JSON number cannot hold exactly.
+ | LossyInteger
+ /// A number literal that is not a finite number.
+ | NonFiniteNumber
+ ///
+ /// A negative TOP, OFFSET or LIMIT count.
+ ///
+ | NegativeCount
+ /// A call of a built-in function the catalog does not know.
+ | UnknownFunction
+ /// A call of a built-in function with a number of arguments the catalog does not accept.
+ | FunctionArity
+ /// A function name that is not a valid identifier, or a keyword the grammar cannot call.
+ | InvalidFunctionName
+ /// A parameter for which inline printing has no literal.
+ | MissingInlineValue
+ /// A parameter whose inline value is not a constant: a literal, or an array or object literal of constants.
+ | InlineValueNotConstant
+ ///
+ /// A TOP, OFFSET or LIMIT parameter whose inline value is not an integer literal.
+ ///
+ | InlineCountNotInteger
+
+/// A rule a query breaks, with an English message that quotes the offending fragment.
+[]
+type ValidationError = {
+ /// The rule that is broken.
+ Code : ValidationErrorCode
+ /// What is wrong, in English.
+ Message : string
+}
+
+/// The settings of validation.
+type ValidationOptions = {
+ /// Whether integer literals outside -2^53..2^53 are accepted. Such a literal loses precision as a JSON number, so it
+ /// is rejected unless the translator's semantics explicitly allow lossy 64-bit integers.
+ AllowLossyInt64 : bool
+} with
+
+ static let ``default`` = { AllowLossyInt64 = false }
+
+ /// The default settings: lossy integer literals are rejected.
+ static member Default = ``default``
diff --git a/src/Cosmos.Sql/Validator.fs b/src/Cosmos.Sql/Validator.fs
new file mode 100644
index 00000000..093d127e
--- /dev/null
+++ b/src/Cosmos.Sql/Validator.fs
@@ -0,0 +1,454 @@
+///
+/// Validation of a against the rules of the query language that the
+/// grammar cannot express, so that nothing invalid reaches the printer unnoticed.
+///
+/// The rules checked are those the first translator slice needs:
+///
+///
+/// - SELECT * only with exactly one collection in the FROM clause;
+/// - TOP never in a subquery;
+/// -
+/// no parameter in a GROUP BY key or in an ORDER BY item other than the arguments of a function call
+/// allowed there, and parameter names of the form @name;
+///
+/// -
+/// ORDER BY items only property paths or calls of functions flagged
+/// ; ORDER BY RANK items only calls of
+/// functions flagged ; functions flagged
+/// nowhere else;
+///
+/// - identifiers of the form [A-Za-z_][A-Za-z_0-9]* that are not reserved words;
+/// - IN lists and select lists with at least one element;
+/// -
+/// built-in functions known to the catalog and called with an accepted number of arguments;
+///
+/// -
+/// finite number literals, integer literals within -2^53..2^53 unless
+/// is set, and non-negative
+/// TOP, OFFSET and LIMIT counts.
+///
+///
+///
+/// Some rules hold by construction of the syntax tree: OFFSET and LIMIT are one field, counts are
+/// integer literals or parameters, object keys are strings, and plain and rank ORDER BY items cannot be mixed.
+///
+///
+[]
+[]
+module FSharp.Azure.Cosmos.Sql.SqlQuery
+
+open System
+open System.Collections.Immutable
+open System.Globalization
+
+/// The largest magnitude a JSON number holds exactly as an integer, 2^53.
+let private maxExactInteger = 9007199254740992L
+
+/// A fragment of the query for messages.
+let private fragment (expression : ScalarExpression) = Printer.printScalar PropertyStyle.Brackets expression
+
+/// Whether a parameter occurs anywhere in an expression, its subqueries included.
+let rec private containsParameter (expression : ScalarExpression) =
+ let any (items : EquatableArray) = items.Items |> Seq.exists containsParameter
+
+ match expression with
+ | ScalarExpression.ParameterRef _ -> true
+ | ScalarExpression.Literal _
+ | ScalarExpression.PropertyRef _ -> false
+ | ScalarExpression.MemberIndexer (left, right)
+ | ScalarExpression.Binary (_, left, right)
+ | ScalarExpression.Coalesce (left, right) -> containsParameter left || containsParameter right
+ | ScalarExpression.Unary (_, operand) -> containsParameter operand
+ | ScalarExpression.Conditional (first, second, third)
+ | ScalarExpression.Between (first, _, second, third) ->
+ containsParameter first
+ || containsParameter second
+ || containsParameter third
+ | ScalarExpression.Like (value, pattern, _, _) -> containsParameter value || containsParameter pattern
+ | ScalarExpression.In (needle, _, haystack) -> containsParameter needle || any haystack
+ | ScalarExpression.FunctionCall (_, items)
+ | ScalarExpression.ArrayCreate items -> any items
+ | ScalarExpression.ObjectCreate properties ->
+ properties.Items
+ |> Seq.exists (fun struct (_, value) -> containsParameter value)
+ | ScalarExpression.Exists query
+ | ScalarExpression.Array query
+ | ScalarExpression.All query
+ | ScalarExpression.First query
+ | ScalarExpression.Last query
+ | ScalarExpression.Subquery query -> queryContainsParameter query
+
+/// Whether a parameter occurs anywhere in a query, its subqueries included.
+and private queryContainsParameter (query : SqlQuery) =
+ let countIsParameter (count : SpecValue) = count.IsParameter
+
+ let rec collectionContainsParameter (expression : CollectionExpression) =
+ let inCollection (collection : Collection) =
+ match collection with
+ | Collection.InputPath _ -> false
+ | Collection.Subquery subquery -> queryContainsParameter subquery
+
+ match expression with
+ | CollectionExpression.Aliased (collection, _)
+ | CollectionExpression.ArrayIterator (_, collection) -> inCollection collection
+ | CollectionExpression.Join (left, right) ->
+ collectionContainsParameter left
+ || collectionContainsParameter right
+
+ (query.Top |> ValueOption.exists countIsParameter)
+ || (
+ match query.Select with
+ | SelectSpec.Star -> false
+ | SelectSpec.Value expression -> containsParameter expression
+ | SelectSpec.List items ->
+ items.Items
+ |> Seq.exists (fun item -> containsParameter item.Expression)
+ )
+ || (query.From |> ValueOption.exists collectionContainsParameter)
+ || (query.Where |> ValueOption.exists containsParameter)
+ || (query.GroupBy.Items |> Seq.exists containsParameter)
+ || (query.OrderBy.Items
+ |> Seq.exists (fun item -> containsParameter item.Expression))
+ || (query.OffsetLimit
+ |> ValueOption.exists (fun struct (offset, limit) -> countIsParameter offset || countIsParameter limit))
+
+/// Whether an expression is a property path: an alias followed by member accesses with literal names or indexes.
+let rec private isPropertyPath (expression : ScalarExpression) =
+ match expression with
+ | ScalarExpression.PropertyRef _ -> true
+ | ScalarExpression.MemberIndexer (target, ScalarExpression.Literal (Literal.String _ | Literal.Int _)) ->
+ isPropertyPath target
+ | _ -> false
+
+/// The catalog entry of a call of a built-in function, if the expression is one.
+let private builtInCall (expression : ScalarExpression) =
+ match expression with
+ | ScalarExpression.FunctionCall (FunctionRef.BuiltIn name, _) -> Catalog.tryFind name
+ | _ -> ValueNone
+
+/// Walks a query and collects the errors.
+type private Validator (options : ValidationOptions) =
+ let errors = ImmutableArray.CreateBuilder()
+
+ let fail code message = errors.Add { Code = code; Message = message }
+
+ /// The errors found so far, in the order the walk met them.
+ member _.Errors = errors.ToImmutable ()
+
+ ///
+ /// Checks that has the form of an identifier and is not a reserved word.
+ ///
+ /// What the identifier is in the query, for the message.
+ /// The identifier to check.
+ member _.Identifier (role : string) (identifier : Identifier) =
+ let text = identifier.Value
+
+ if not (Keywords.isValidIdentifier text) then
+ fail
+ ValidationErrorCode.InvalidIdentifier
+ $"The %s{role} '%s{text}' is not an identifier of the form [A-Za-z_][A-Za-z_0-9]*."
+ elif Keywords.isReserved text then
+ fail ValidationErrorCode.ReservedIdentifier $"The %s{role} '%s{text}' is a reserved word of the query language."
+
+ ///
+ /// Checks that is @ followed by an identifier.
+ ///
+ /// The parameter name to check.
+ member _.Parameter (name : ParameterName) =
+ let text = name.Value
+
+ if
+ text.Length < 2
+ || text[0] <> '@'
+ || not (Keywords.isValidIdentifierSpan (text.AsSpan 1))
+ then
+ fail
+ ValidationErrorCode.InvalidParameterName
+ $"The parameter name '%s{text}' is not of the form @name with a name matching [A-Za-z_][A-Za-z_0-9]*."
+
+ ///
+ /// Checks a TOP, OFFSET or LIMIT count: a literal must not be negative, and a parameter must
+ /// have a valid name.
+ ///
+ /// The count to check.
+ member this.Count (count : SpecValue) =
+ match count with
+ | SpecValue.Literal value when value < 0L ->
+ fail ValidationErrorCode.NegativeCount $"The TOP, OFFSET or LIMIT count %d{value} is negative."
+ | SpecValue.Literal _ -> ()
+ | SpecValue.Parameter name -> this.Parameter name
+
+ ///
+ /// Checks that can be written exactly: an integer within -2^53..2^53, unless
+ /// is set, and a finite number.
+ ///
+ /// The literal to check.
+ member _.Literal (literal : Literal) =
+ match literal with
+ | Literal.Int value when
+ not options.AllowLossyInt64
+ && (value > maxExactInteger || value < -maxExactInteger)
+ ->
+ fail
+ ValidationErrorCode.LossyInteger
+ ($"The integer literal %d{value} is outside -2^53..2^53, which a JSON number cannot hold exactly; "
+ + "pass it as a parameter.")
+ | Literal.Float value when not (Double.IsFinite value) ->
+ fail
+ ValidationErrorCode.NonFiniteNumber
+ $"The number literal %s{value.ToString (CultureInfo.InvariantCulture)} is not a finite number."
+ | _ -> ()
+
+ ///
+ /// Checks a function call: the name of a user-defined function, and for a built-in function that the catalog knows
+ /// it, that it can be called, that it accepts arguments and that it may appear here.
+ ///
+ /// Whether the call is an item of an ORDER BY RANK clause or part of one.
+ /// The function that is called.
+ /// The number of arguments of the call.
+ member _.Function (inRank : bool) (func : FunctionRef) (count : int) =
+ match func with
+ | FunctionRef.Udf name ->
+ if not (Keywords.isSafeIdentifier name) then
+ fail
+ ValidationErrorCode.InvalidFunctionName
+ $"The user-defined function name '%s{name}' is not an identifier or is a reserved word."
+ | FunctionRef.BuiltIn name ->
+ match Catalog.tryFind name with
+ | ValueNone ->
+ fail
+ ValidationErrorCode.UnknownFunction
+ $"The query language has no built-in function '%s{name}'; call a user-defined function as udf.%s{name}."
+ | ValueSome spec ->
+ // LEFT and RIGHT are keywords that the grammar accepts as function names; the other keywords the SDK
+ // lists as names (ALL, ARRAY, LIKE) are expressions of their own
+ if
+ Keywords.isReserved name
+ && not (String.Equals (name, "LEFT", StringComparison.OrdinalIgnoreCase))
+ && not (String.Equals (name, "RIGHT", StringComparison.OrdinalIgnoreCase))
+ then
+ fail
+ ValidationErrorCode.InvalidFunctionName
+ $"'%s{name}' is a keyword of the query language and cannot be called as a function."
+ elif not (spec.Arity.Accepts count) then
+ fail
+ ValidationErrorCode.FunctionArity
+ ($"The number of arguments of the built-in function %s{spec.Name} must be %O{spec.Arity}, "
+ + $"but the call has %d{count}.")
+
+ if spec.OnlyInOrderByRank && not inRank then
+ fail
+ ValidationErrorCode.ScoringFunctionOutsideRank
+ $"The scoring function %s{spec.Name} may appear only in an ORDER BY RANK clause."
+
+ ///
+ /// Validates an expression; allows the functions that may appear only in
+ /// ORDER BY RANK.
+ ///
+ /// Whether the expression is an item of an ORDER BY RANK clause or part of one.
+ /// The expression to validate.
+ member this.Scalar (inRank : bool) (expression : ScalarExpression) : unit =
+ let scalar = this.Scalar inRank
+
+ match expression with
+ | ScalarExpression.Literal literal -> this.Literal literal
+ | ScalarExpression.ParameterRef name -> this.Parameter name
+ | ScalarExpression.PropertyRef identifier -> this.Identifier "property reference" identifier
+ | ScalarExpression.MemberIndexer (left, right)
+ | ScalarExpression.Binary (_, left, right)
+ | ScalarExpression.Coalesce (left, right) ->
+ scalar left
+ scalar right
+ | ScalarExpression.Unary (_, operand) -> scalar operand
+ | ScalarExpression.Conditional (first, second, third)
+ | ScalarExpression.Between (first, _, second, third) ->
+ scalar first
+ scalar second
+ scalar third
+ | ScalarExpression.Like (value, pattern, _, _) ->
+ scalar value
+ scalar pattern
+ | ScalarExpression.In (needle, _, haystack) ->
+ if haystack.IsEmpty then
+ fail ValidationErrorCode.EmptyInList $"The IN list of {fragment needle} has no elements."
+
+ scalar needle
+
+ for item in haystack.Items do
+ scalar item
+ | ScalarExpression.FunctionCall (func, arguments) ->
+ this.Function inRank func arguments.Length
+
+ for argument in arguments.Items do
+ scalar argument
+ | ScalarExpression.ArrayCreate items ->
+ for item in items.Items do
+ scalar item
+ | ScalarExpression.ObjectCreate properties ->
+ for struct (_, value) in properties.Items do
+ scalar value
+ | ScalarExpression.Exists query
+ | ScalarExpression.Array query
+ | ScalarExpression.All query
+ | ScalarExpression.First query
+ | ScalarExpression.Last query
+ | ScalarExpression.Subquery query -> this.Query true query
+
+ ///
+ /// Checks the identifiers of and of the path before it.
+ ///
+ /// The last segment of the path.
+ member this.Path (path : PathExpression) =
+ let parent (parent : PathExpression voption) =
+ match parent with
+ | ValueSome parent -> this.Path parent
+ | ValueNone -> ()
+
+ match path with
+ | PathExpression.Identifier (previous, name) ->
+ parent previous
+ this.Identifier "path segment" name
+ | PathExpression.Number (previous, _)
+ | PathExpression.String (previous, _) -> parent previous
+
+ ///
+ /// Checks a collection: the input name and the path of an input path, or the subquery.
+ ///
+ /// The collection to check.
+ member this.Collection (collection : Collection) =
+ match collection with
+ | Collection.InputPath (input, path) ->
+ this.Identifier "input name" input
+ path |> ValueOption.iter this.Path
+ | Collection.Subquery query -> this.Query true query
+
+ ///
+ /// Checks a source of the FROM clause: its collections and their aliases.
+ ///
+ /// The source to check.
+ member this.CollectionExpression (expression : CollectionExpression) =
+ match expression with
+ | CollectionExpression.Aliased (collection, alias) ->
+ this.Collection collection
+ alias
+ |> ValueOption.iter (this.Identifier "collection alias")
+ | CollectionExpression.ArrayIterator (alias, collection) ->
+ this.Identifier "collection alias" alias
+ this.Collection collection
+ | CollectionExpression.Join (left, right) ->
+ this.CollectionExpression left
+ this.CollectionExpression right
+
+ ///
+ /// Checks an item of an ORDER BY clause: what it may sort by, that no parameter chooses the sort key, and
+ /// the expression itself.
+ ///
+ /// Whether the clause is ORDER BY RANK.
+ /// The item to check.
+ member this.OrderByItem (rank : bool) (item : OrderByItem) =
+ let expression = item.Expression
+
+ // The arguments of an allowed call may be parameters: Microsoft Learn passes the query vector of a vector search
+ // as one (ORDER BY VectorDistance(c.embedding, @embedding)), and so does the SDK's query plan baseline
+ let allowedCall =
+ match builtInCall expression with
+ | ValueSome spec ->
+ if rank then
+ spec.AllowedInOrderByRank
+ else
+ spec.AllowedInOrderBy
+ | ValueNone -> false
+
+ if rank then
+ if not allowedCall then
+ fail
+ ValidationErrorCode.RankItemNotScoringFunction
+ $"The ORDER BY RANK item {fragment expression} is not a call of a scoring function."
+ elif not allowedCall && not (isPropertyPath expression) then
+ fail
+ ValidationErrorCode.OrderByItemNotPath
+ ($"The ORDER BY item {fragment expression} is neither a property path nor a call of a function "
+ + "allowed in ORDER BY.")
+
+ if not allowedCall && containsParameter expression then
+ fail
+ ValidationErrorCode.ParameterInOrderBy
+ $"The ORDER BY item {fragment expression} contains a parameter, which cannot choose the sort key."
+
+ this.Scalar rank expression
+
+ ///
+ /// Checks a query clause by clause, in the order the query text shows them, and its subqueries.
+ ///
+ /// Whether the query is a subquery, where TOP is not allowed.
+ /// The query to check.
+ member this.Query (inSubquery : bool) (query : SqlQuery) : unit =
+ match query.Top with
+ | ValueSome top ->
+ if inSubquery then
+ fail
+ ValidationErrorCode.TopInSubquery
+ "A subquery has a TOP clause; the service accepts TOP only in the outermost query."
+
+ this.Count top
+ | ValueNone -> ()
+
+ match query.Select with
+ | SelectSpec.Star ->
+ match query.From with
+ | ValueSome (CollectionExpression.Aliased _)
+ | ValueSome (CollectionExpression.ArrayIterator _) -> ()
+ | ValueSome (CollectionExpression.Join _)
+ | ValueNone ->
+ fail
+ ValidationErrorCode.SelectStarWithoutSingleAlias
+ "SELECT * needs exactly one collection in the FROM clause; project the collections explicitly."
+ | SelectSpec.Value expression -> this.Scalar false expression
+ | SelectSpec.List items ->
+ if items.IsEmpty then
+ fail ValidationErrorCode.EmptySelectList "The select list has no items."
+
+ for item in items.Items do
+ this.Scalar false item.Expression
+ item.Alias
+ |> ValueOption.iter (this.Identifier "select alias")
+
+ query.From |> ValueOption.iter this.CollectionExpression
+ query.Where |> ValueOption.iter (this.Scalar false)
+
+ for key in query.GroupBy.Items do
+ if containsParameter key then
+ fail
+ ValidationErrorCode.ParameterInGroupBy
+ $"The GROUP BY key {fragment key} contains a parameter; the service accepts none there."
+
+ this.Scalar false key
+
+ for item in query.OrderBy.Items do
+ this.OrderByItem query.OrderByRank item
+
+ match query.OffsetLimit with
+ | ValueSome (struct (offset, limit)) ->
+ this.Count offset
+ this.Count limit
+ | ValueNone -> ()
+
+///
+/// Checks and its subqueries against the rules the grammar cannot express, with
+/// .
+///
+/// The settings of validation.
+/// The query to check.
+/// The broken rules, in the order the query text would show them; empty for a valid query.
+let validateWith (options : ValidationOptions) (query : SqlQuery) : ImmutableArray =
+ let validator = Validator options
+ validator.Query false query
+ validator.Errors
+
+///
+/// Checks and its subqueries against the rules the grammar cannot express, with
+/// .
+///
+/// The query to check.
+/// The broken rules, in the order the query text would show them; empty for a valid query.
+let validate (query : SqlQuery) : ImmutableArray = validateWith ValidationOptions.Default query
diff --git a/tests/Cosmos.Sql.Tests/Ast.fs b/tests/Cosmos.Sql.Tests/Ast.fs
new file mode 100644
index 00000000..52abe7af
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/Ast.fs
@@ -0,0 +1,138 @@
+/// Terse builders of syntax trees for the tests, and the whitespace normalization that compares printed text with the
+/// SDK's pretty-printed baselines.
+module FSharp.Azure.Cosmos.Sql.Tests.Ast
+
+open System
+open System.Text
+
+open FSharp.Azure.Cosmos.Sql
+
+/// A bare identifier, such as the collection alias.
+let alias (name : string) = ScalarExpression.PropertyRef (Identifier name)
+
+/// An integer literal.
+let integer (value : int64) = ScalarExpression.Literal (Literal.Int value)
+
+/// A double literal.
+let number (value : float) = ScalarExpression.Literal (Literal.Float value)
+
+/// A string literal.
+let str (value : string) = ScalarExpression.Literal (Literal.String value)
+
+/// A boolean literal.
+let boolean (value : bool) = ScalarExpression.Literal (Literal.Boolean value)
+
+/// The null literal.
+let nul = ScalarExpression.Literal Literal.Null
+
+/// The undefined literal.
+let undefined = ScalarExpression.Literal Literal.Undefined
+
+///
+/// A parameter reference; the name includes its @.
+///
+let param (name : string) = ScalarExpression.ParameterRef (ParameterName name)
+
+///
+/// A property access with a string name, target["name"].
+///
+let prop (name : string) (target : ScalarExpression) = ScalarExpression.MemberIndexer (target, str name)
+
+///
+/// An element access with an integer index, target[index].
+///
+let at (index : int64) (target : ScalarExpression) = ScalarExpression.MemberIndexer (target, integer index)
+
+/// A binary operation.
+let binary operator left right = ScalarExpression.Binary (operator, left, right)
+
+/// A call of a built-in function.
+let call (name : string) (arguments : ScalarExpression array) =
+ ScalarExpression.FunctionCall (FunctionRef.BuiltIn name, EquatableArray.unsafeOfArray arguments)
+
+/// A call of a user-defined function.
+let udf (name : string) (arguments : ScalarExpression array) =
+ ScalarExpression.FunctionCall (FunctionRef.Udf name, EquatableArray.unsafeOfArray arguments)
+
+/// An array literal.
+let arrayOf (items : ScalarExpression array) = ScalarExpression.ArrayCreate (EquatableArray.unsafeOfArray items)
+
+/// An object literal, from its properties in order.
+let objectOf (properties : struct (string * ScalarExpression) array) =
+ ScalarExpression.ObjectCreate (EquatableArray.unsafeOfArray properties)
+
+///
+/// A FROM clause over an input name without an alias, FROM name.
+///
+let from (name : string) =
+ ValueSome (CollectionExpression.Aliased (Collection.InputPath (Identifier name, ValueNone), ValueNone))
+
+/// A query without clauses that projects the given value; extend it with a copy-and-update expression.
+let selectValue (expression : ScalarExpression) = {
+ Select = SelectSpec.Value expression
+ Distinct = false
+ Top = ValueNone
+ From = ValueNone
+ Where = ValueNone
+ GroupBy = EquatableArray.empty
+ OrderBy = EquatableArray.empty
+ OrderByRank = false
+ OffsetLimit = ValueNone
+}
+
+///
+/// SELECT VALUE root FROM root, the shape the SDK's LINQ baselines start from.
+///
+let selectRoot = { selectValue (alias "root") with From = from "root" }
+
+///
+/// An ORDER BY item.
+///
+let orderBy (order : SortOrder voption) (expression : ScalarExpression) = { Expression = expression; Order = order }
+
+///
+/// Normalizes the whitespace of query text outside string literals, the comparison the SDK's baselines need: they are
+/// pretty-printed with line breaks and indentation, and the SDK writes a space after GROUP BY. Runs of
+/// whitespace become one space, whitespace after an opening and before a closing bracket disappears, and the text is
+/// trimmed.
+///
+let normalize (text : string) =
+ let builder = StringBuilder ()
+ let mutable inString = false
+ let mutable escaped = false
+ let mutable pendingSpace = false
+
+ for character in text do
+ if inString then
+ builder.Append character |> ignore
+
+ if escaped then
+ escaped <- false
+ elif character = '\\' then
+ escaped <- true
+ elif character = '"' then
+ inString <- false
+ elif Char.IsWhiteSpace character then
+ pendingSpace <- true
+ else
+ let afterOpening =
+ builder.Length > 0
+ && (let last = builder[builder.Length - 1] in last = '(' || last = '[' || last = '{')
+
+ let beforeClosing = character = ')' || character = ']' || character = '}'
+
+ if
+ pendingSpace
+ && builder.Length > 0
+ && not afterOpening
+ && not beforeClosing
+ then
+ builder.Append ' ' |> ignore
+
+ pendingSpace <- false
+ builder.Append character |> ignore
+
+ if character = '"' then
+ inString <- true
+
+ builder.ToString ()
diff --git a/tests/Cosmos.Sql.Tests/CatalogTests.fs b/tests/Cosmos.Sql.Tests/CatalogTests.fs
new file mode 100644
index 00000000..16fa5a62
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/CatalogTests.fs
@@ -0,0 +1,267 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Reflection
+open Microsoft.Azure.Cosmos
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+open FSharp.Azure.Cosmos.Sql.Tests.Ast
+
+///
+/// The function catalog: its seed against the SDK's function names, lookups and
+/// .
+///
+[]
+type CatalogTests () =
+
+ ///
+ /// The names Microsoft Learn documents that the SDK's
+ /// SqlFunctionCallScalarExpression.Names
+ /// does not list.
+ ///
+ static let learnOnlyNames = [|
+ "AGO"
+ "GETCURRENTDATETIMESTATIC"
+ "GETCURRENTTICKSSTATIC"
+ "GETCURRENTTIMESTAMPSTATIC"
+ "INTBITAND"
+ "INTBITLEFTSHIFT"
+ "INTBITNOT"
+ "INTBITOR"
+ "INTBITRIGHTSHIFT"
+ "INTBITXOR"
+ "NOW"
+ "NUMBERBIN"
+ "REGEXREPLACE"
+ "REGEXREPLACEALL"
+ "ST_AREA"
+ |]
+
+ ///
+ /// The function names of the SDK version this repository uses, read by reflection from the constants of its
+ /// internal class
+ /// SqlFunctionCallScalarExpression.Names.
+ /// Reading SDK internals is acceptable in a test, never in the library; the test fails when an SDK update adds or
+ /// removes names, so the catalog follows the SDK.
+ ///
+ static let sdkNames =
+ let names =
+ typeof
+ .Assembly.GetType("Microsoft.Azure.Cosmos.SqlObjects.SqlFunctionCallScalarExpression+Names", throwOnError = true)
+ |> nonNull
+
+ names.GetFields (BindingFlags.Public ||| BindingFlags.Static)
+ |> Seq.filter _.IsLiteral
+ |> Seq.map (fun field -> field.GetRawConstantValue () |> nonNull |> unbox)
+ |> Seq.toArray
+
+ static let ignoreCase (names : string seq) = HashSet(names, StringComparer.OrdinalIgnoreCase)
+
+ static let arguments (items : ScalarExpression list) = ImmutableArray.CreateRange items
+
+ []
+ member _.``The catalog holds every non-internal name of the SDK and the Learn-only names, and nothing else`` () =
+ let sdkPublicNames =
+ sdkNames
+ |> Array.filter (fun name -> not (name.StartsWith ("_", StringComparison.Ordinal)))
+ let catalogNames = ignoreCase (Catalog.all |> Seq.map _.Name)
+
+ Assert.HasCount (176, sdkNames, "SDK 3.62.0 lists 176 function names; a different count means the SDK changed.")
+
+ let missing =
+ sdkPublicNames
+ |> Array.filter (fun name -> not (catalogNames.Contains name))
+ Assert.IsEmpty (missing, "Every SDK function name except the internal ones should be in the catalog.")
+
+ let internalNames =
+ sdkNames
+ |> Array.filter (fun name -> name.StartsWith ("_", StringComparison.Ordinal))
+ |> ignoreCase
+ let leaked =
+ catalogNames
+ |> Seq.filter (fun name -> internalNames.Contains name)
+ |> Seq.toArray
+ Assert.IsEmpty (leaked, "The internal SDK names, which start with an underscore, should not be in the catalog.")
+
+ let expected =
+ ignoreCase (
+ seq {
+ yield! sdkPublicNames
+ yield! learnOnlyNames
+ }
+ )
+ let extra =
+ catalogNames
+ |> Seq.filter (fun name -> not (expected.Contains name))
+ |> Seq.toArray
+ Assert.IsEmpty (extra, "Every catalog name should come from the SDK or from the list of Learn-only names.")
+
+ Assert.HasCount (sdkPublicNames.Length + learnOnlyNames.Length, Catalog.all, "The catalog should hold each name once.")
+
+ []
+ member _.``Names are unique ignoring case and the lookup ignores case`` () =
+ Assert.HasCount (Catalog.all.Length, Catalog.byName, "Each name should be one dictionary entry.")
+
+ let spec =
+ Catalog.tryFind "is_defined"
+ |> ValueOption.defaultWith (fun () -> failwith "IS_DEFINED should be found.")
+ Assert.AreEqual ("IS_DEFINED", spec.Name, "The lookup should ignore case and return the catalog's spelling.")
+ Assert.IsTrue (Catalog.tryFind "NoSuchFunction" |> ValueOption.isNone, "An unknown name should not be found.")
+
+ []
+ member _.``Every entry is either specified or carries the reason why not`` () =
+ for spec in Catalog.all do
+ match spec.Status with
+ | FunctionStatus.Supported ->
+ Assert.AreNotEqual (Arity.Unspecified, spec.Arity, $"{spec.Name} is supported, so its arity should be set.")
+ Assert.AreNotEqual (
+ ReturnKind.Unspecified,
+ spec.ReturnKind,
+ $"{spec.Name} is supported, so its return kind should be set."
+ )
+ Assert.IsTrue (spec.DocUrl.IsValueSome, $"{spec.Name} is supported, so it should be documented.")
+ Assert.IsTrue (spec.Availability.HasFlag Availability.Service, $"{spec.Name} should be confirmed on the service.")
+ | FunctionStatus.Unsupported reason ->
+ Assert.IsFalse (String.IsNullOrWhiteSpace reason, $"{spec.Name} is unsupported, so it should say why.")
+
+ if spec.OnlyInOrderByRank then
+ Assert.IsTrue (
+ spec.AllowedInOrderByRank,
+ $"{spec.Name} may appear only in ORDER BY RANK, so it should be allowed there."
+ )
+
+ match spec.DocUrl with
+ | ValueSome url ->
+ Assert.StartsWith (
+ "https://learn.microsoft.com/cosmos-db/query/",
+ url,
+ StringComparison.Ordinal,
+ $"{spec.Name} should link to its Learn page."
+ )
+ | ValueNone ->
+ Assert.AreEqual (
+ FunctionCategory.Undocumented,
+ spec.Category,
+ $"{spec.Name} has no page, so it should be undocumented."
+ )
+
+ []
+ member _.``The functions that read the clock or random numbers are not deterministic`` () =
+ let nonDeterministic =
+ Catalog.all
+ |> Seq.filter (fun spec -> not spec.Deterministic)
+ |> Seq.map _.Name
+ |> Seq.sort
+ |> Seq.toArray
+
+ CollectionAssert.AreEqual (
+ [|
+ "AGO"
+ "GETCURRENTDATETIMESTATIC"
+ "GETCURRENTTICKSSTATIC"
+ "GETCURRENTTIMESTAMPSTATIC"
+ "GetCurrentDateTime"
+ "GetCurrentTicks"
+ "GetCurrentTimestamp"
+ "NOW"
+ "RAND"
+ |],
+ nonDeterministic,
+ "Only RAND and the functions that read the current time should be non-deterministic."
+ )
+
+ []
+ member _.``The ORDER BY flags mark the scoring functions`` () =
+ let flagged predicate =
+ Catalog.all
+ |> Seq.filter predicate
+ |> Seq.map _.Name
+ |> Seq.sort
+ |> Seq.toArray
+
+ CollectionAssert.AreEqual (
+ [| "VectorDistance" |],
+ flagged _.AllowedInOrderBy,
+ "Only VectorDistance may be a plain ORDER BY item."
+ )
+
+ CollectionAssert.AreEqual (
+ [| "FullTextScore"; "RRF"; "VectorDistance" |],
+ flagged _.AllowedInOrderByRank,
+ "The scoring functions may be ORDER BY RANK items."
+ )
+
+ CollectionAssert.AreEqual (
+ [| "FullTextScore"; "RRF" |],
+ flagged _.OnlyInOrderByRank,
+ "FullTextScore and RRF may appear only in ORDER BY RANK."
+ )
+
+ []
+ member _.``Call builds a call with the catalog's spelling when the arity fits`` () =
+ let path = alias "c" |> prop "name"
+
+ match Catalog.call "is_defined" (arguments [ path ]) with
+ | Ok call ->
+ Assert.AreEqual (
+ ScalarExpression.FunctionCall (FunctionRef.BuiltIn "IS_DEFINED", EquatableArray.singleton path),
+ call,
+ "The call should use the catalog's spelling of the name."
+ )
+
+ Assert.AreEqual ("IS_DEFINED(c[\"name\"])", Printer.printScalar PropertyStyle.Brackets call, "The call should print.")
+ | Error error -> Assert.Fail $"IS_DEFINED with one argument should be accepted: {error}"
+
+ let optionalArgument = Catalog.call "StringEquals" (arguments [ path; str "x"; boolean true ])
+ Assert.IsTrue (optionalArgument.IsOk, "StringEquals should accept its optional third argument.")
+
+ let variadic = Catalog.call "CONCAT" (arguments [ str "a"; str "b"; str "c"; str "d" ])
+ Assert.IsTrue (variadic.IsOk, "CONCAT should accept any number of arguments from two on.")
+
+ let noArguments = Catalog.call "GetCurrentDateTime" ImmutableArray.Empty
+ Assert.IsTrue (noArguments.IsOk, "GetCurrentDateTime should accept no arguments.")
+
+ []
+ member _.``Call refuses unknown names, unsupported entries and wrong arities`` () =
+ Assert.AreEqual (
+ Error (CatalogError.UnknownFunction "NoSuchFunction"),
+ Catalog.call "NoSuchFunction" ImmutableArray.Empty,
+ "An unknown name should be refused."
+ )
+
+ match Catalog.call "abs" (arguments [ integer -1L ]) with
+ | Error (CatalogError.Unsupported (name, reason)) ->
+ Assert.AreEqual ("ABS", name, "The error should use the catalog's spelling.")
+ Assert.IsFalse (String.IsNullOrWhiteSpace reason, "The error should carry the reason.")
+ | other -> Assert.Fail $"ABS is not specified yet, so the call should be refused: %A{other}"
+
+ Assert.AreEqual (
+ Error (CatalogError.ArityMismatch ("IS_DEFINED", Arity.Exactly 1, 2)),
+ Catalog.call "IS_DEFINED" (arguments [ integer 1L; integer 2L ]),
+ "Two arguments for a one-argument function should be refused."
+ )
+
+ Assert.AreEqual (
+ Error (CatalogError.ArityMismatch ("VectorDistance", Arity.Between (2, 4), 1)),
+ Catalog.call "VectorDistance" (arguments [ integer 1L ]),
+ "Fewer arguments than the minimum should be refused."
+ )
+
+ Assert.AreEqual (
+ "The number of arguments of the built-in function 'IS_DEFINED' must be 1, but the call has 2.",
+ string (CatalogError.ArityMismatch ("IS_DEFINED", Arity.Exactly 1, 2)),
+ "The error should read as a sentence."
+ )
+
+ []
+ member _.``Arity accepts the counts it describes`` () =
+ Assert.IsTrue ((Arity.Exactly 2).Accepts 2, "Exactly 2 should accept 2.")
+ Assert.IsFalse ((Arity.Exactly 2).Accepts 3, "Exactly 2 should refuse 3.")
+ Assert.IsTrue ((Arity.Between (2, 3)).Accepts 3, "2 to 3 should accept 3.")
+ Assert.IsFalse ((Arity.Between (2, 3)).Accepts 1, "2 to 3 should refuse 1.")
+ Assert.IsTrue ((Arity.AtLeast 2).Accepts 20, "2 or more should accept 20.")
+ Assert.IsFalse ((Arity.AtLeast 2).Accepts 1, "2 or more should refuse 1.")
+ Assert.IsTrue (Arity.Unspecified.Accepts 7, "An unspecified arity should accept any count.")
diff --git a/tests/Cosmos.Sql.Tests/EquatableArrayModuleTests.fs b/tests/Cosmos.Sql.Tests/EquatableArrayModuleTests.fs
new file mode 100644
index 00000000..a77daa40
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/EquatableArrayModuleTests.fs
@@ -0,0 +1,57 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System.Collections.Immutable
+open System.Runtime.InteropServices
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+
+///
+/// The functions of the module , which create and
+/// transform values.
+///
+[]
+type EquatableArrayModuleTests () =
+
+ []
+ member _.``ofArray and ofSeq copy the elements in order`` () =
+ let source = [| 1; 2; 3 |]
+ let fromArray = EquatableArray.ofArray source
+ let fromSeq = EquatableArray.ofSeq (Seq.ofArray source)
+
+ source[0] <- 9
+
+ CollectionAssert.AreEqual ([| 1; 2; 3 |], Seq.toArray fromArray, "ofArray should copy the array it is given.")
+ CollectionAssert.AreEqual ([| 1; 2; 3 |], Seq.toArray fromSeq, "ofSeq should copy the sequence it is given.")
+
+ []
+ member _.``unsafeOfArray wraps the array it is given without copying it`` () =
+ let source = [| 1; 2; 3 |]
+ let array = EquatableArray.unsafeOfArray source
+
+ Assert.AreSame(
+ source,
+ ImmutableCollectionsMarshal.AsArray array.Items,
+ "unsafeOfArray should hold the very array it was given."
+ )
+
+ Assert.AreEqual (EquatableArray.unsafeOfArray [| 1; 2; 3 |], array, "The wrapped array should hold the elements.")
+
+ []
+ member _.``Map, singleton, empty and the conversion back keep the elements`` () =
+ let doubled =
+ EquatableArray.unsafeOfArray [| 1; 2; 3 |]
+ |> EquatableArray.map (fun item -> item * 2)
+
+ Assert.AreEqual (EquatableArray.unsafeOfArray [| 2; 4; 6 |], doubled, "Map should apply the function in order.")
+ Assert.AreEqual (EquatableArray.unsafeOfArray [| 7 |], EquatableArray.singleton 7, "Singleton should hold one element.")
+ Assert.IsTrue (EquatableArray.empty.IsEmpty, "The empty array should have no elements.")
+
+ let immutable = ImmutableArray.Create (4, 5)
+
+ let unwrapped =
+ EquatableArray.ofImmutableArray immutable
+ |> EquatableArray.toImmutableArray
+
+ // ImmutableArray equality compares the reference of the underlying array, so this asserts that nothing was copied
+ Assert.AreEqual>(immutable, unwrapped, "Wrapping and unwrapping should not copy the array.")
diff --git a/tests/Cosmos.Sql.Tests/EquatableArrayTests.fs b/tests/Cosmos.Sql.Tests/EquatableArrayTests.fs
new file mode 100644
index 00000000..6428055b
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/EquatableArrayTests.fs
@@ -0,0 +1,161 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System.Collections.Generic
+open System.Collections.Immutable
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+
+/// An element that writes its number into the list it is given whenever it is asked for its hash code.
+[]
+type private HashProbe (number : int, asked : List) =
+
+ /// Records the request and gives the number as the hash code.
+ override _.GetHashCode () =
+ asked.Add number
+ number
+
+ /// Equal to itself only: the test needs no equal probes.
+ override this.Equals (other : objnull) = obj.ReferenceEquals (this, other)
+
+///
+/// The struct : its equality and hash code, its default
+/// value, its views and its implicit conversions.
+///
+[]
+type EquatableArrayTests () =
+
+ []
+ member _.``Arrays with equal elements in the same order are equal and hash alike`` () =
+ let first = EquatableArray.unsafeOfArray [| 1; 2; 3 |]
+ let second = EquatableArray.unsafeOfArray [| 1; 2; 3 |]
+
+ Assert.IsTrue (first.Equals second, "Arrays built separately from equal elements should be equal.")
+ Assert.AreEqual (first, second, "Object equality should agree with the typed equality.")
+ Assert.AreEqual (first.GetHashCode (), second.GetHashCode (), "Equal arrays should have equal hash codes.")
+
+ []
+ member _.``Arrays that differ in order, length or an element are not equal`` () =
+ let array = EquatableArray.unsafeOfArray [| 1; 2; 3 |]
+
+ Assert.AreNotEqual (EquatableArray.unsafeOfArray [| 3; 2; 1 |], array, "The order of the elements should matter.")
+ Assert.AreNotEqual (EquatableArray.unsafeOfArray [| 1; 2 |], array, "The length should matter.")
+ Assert.AreNotEqual (EquatableArray.unsafeOfArray [| 1; 2; 4 |], array, "Every element should matter.")
+ Assert.IsFalse (array.Equals (box [| 1; 2; 3 |]), "An array of another type should not be equal.")
+
+ []
+ member _.``The hash code takes in every element once, first to last`` () =
+ let asked = List()
+ // Twenty elements, because the structural hash code of an ImmutableArray takes in only the last eight
+ let array = EquatableArray.unsafeOfArray (Array.init 20 (fun number -> HashProbe (number, asked)))
+
+ array.GetHashCode () |> ignore
+
+ CollectionAssert.AreEqual (
+ Array.init 20 id,
+ asked.ToArray (),
+ "Every element should be asked for its hash code once, in the order of the array."
+ )
+
+ []
+ member _.``The default value behaves as and equals an empty array`` () =
+ let defaultArray = Unchecked.defaultof>
+
+ Assert.IsEmpty (defaultArray, "The default value should have no elements.")
+ Assert.IsTrue (defaultArray.IsEmpty, "The default value should be empty.")
+ Assert.IsTrue (defaultArray.Items.IsEmpty, "The default value should expose an empty, not a default, array.")
+ Assert.AreEqual (EquatableArray.empty, defaultArray, "The default value should equal the empty array.")
+ Assert.AreEqual (
+ EquatableArray.empty.GetHashCode(),
+ defaultArray.GetHashCode (),
+ "The default value should hash like the empty array."
+ )
+
+ []
+ member _.``Elements are compared with their own equality`` () =
+ let first = EquatableArray.unsafeOfArray [| "a"; "b" |]
+ let second = EquatableArray.unsafeOfArray [| "a"; System.String ([| 'b' |]) |]
+
+ Assert.AreEqual (first, second, "Equal strings in different instances should make the arrays equal.")
+
+ []
+ member _.``Equality holds under every comparer, unlike the own equality of ImmutableArray`` () =
+ let first = ImmutableArray.Create (1, 2)
+ let second = ImmutableArray.Create (1, 2)
+
+ // The reason the syntax tree does not hold ImmutableArray: its own equality compares array references
+ Assert.IsFalse (
+ EqualityComparer>.Default.Equals(first, second),
+ "ImmutableArray's default equality should compare references."
+ )
+
+ Assert.IsFalse (
+ (struct (1, first)).Equals(struct (1, second)),
+ "A struct tuple of ImmutableArrays should compare references."
+ )
+
+ Assert.IsTrue (
+ EqualityComparer>.Default.Equals(EquatableArray first, EquatableArray second),
+ "EquatableArray's default equality should compare elements."
+ )
+
+ Assert.IsTrue (
+ (struct (1, EquatableArray first)).Equals(struct (1, EquatableArray second)),
+ "A struct tuple of EquatableArrays should compare elements."
+ )
+
+ let set = HashSet>()
+ set.Add (EquatableArray first) |> ignore
+
+ // Assert.Contains asks the set itself, so the lookup goes through the hash code and the equality of the array
+ Assert.Contains (EquatableArray second, set, "A hash set with the default comparer should find an equal array.")
+
+ []
+ member _.``The read-only list view and the enumerator yield the elements in order`` () =
+ let array = EquatableArray.unsafeOfArray [| 10; 20; 30 |]
+ let list = array :> IReadOnlyList
+
+ Assert.HasCount (3, list, "The list view should report the length.")
+ Assert.AreEqual (20, list[1], "The list view should index the elements.")
+ CollectionAssert.AreEqual ([| 10; 20; 30 |], Seq.toArray list, "The list view should enumerate in order.")
+
+ let mutable sum = 0
+
+ for item in array do
+ sum <- sum + item
+
+ Assert.AreEqual (60, sum, "A for loop over the array should visit every element.")
+
+ // F# applies an implicit conversion without warning FS3391 at method arguments only, so the conversions are
+ // exercised through methods that take the other type
+ static member private AsEquatable (array : EquatableArray) = array
+ static member private AsImmutable (items : ImmutableArray) = items
+
+ []
+ member _.``An ImmutableArray is accepted where an EquatableArray is expected, and the other way round`` () =
+ let immutable = ImmutableArray.Create (4, 5)
+ let equatable = EquatableArrayTests.AsEquatable immutable
+
+ Assert.AreEqual (EquatableArray.unsafeOfArray [| 4; 5 |], equatable, "The conversion should keep the elements.")
+
+ // ImmutableArray equality compares the reference of the underlying array, so these assert that nothing was copied
+ Assert.AreEqual>(immutable, equatable.Items, "The conversion should wrap, not copy, the array.")
+
+ Assert.AreEqual>(
+ immutable,
+ EquatableArrayTests.AsImmutable equatable,
+ "The conversion back should hand out the wrapped array."
+ )
+
+ []
+ member _.``The implicit conversions turn a default array into an empty one`` () =
+ let fromDefault = EquatableArrayTests.AsEquatable Unchecked.defaultof>
+ let toImmutable = EquatableArrayTests.AsImmutable Unchecked.defaultof>
+
+ Assert.AreEqual (EquatableArray.empty, fromDefault, "A default ImmutableArray should convert to the empty array.")
+ Assert.IsFalse (toImmutable.IsDefault, "The default value should not convert to a default ImmutableArray.")
+ Assert.IsEmpty (toImmutable, "The default value should convert to an empty ImmutableArray.")
+
+ []
+ member _.``ToString shows the elements like an F# array`` () =
+ Assert.AreEqual ("[| 1; 2 |]", (EquatableArray.unsafeOfArray [| 1; 2 |]).ToString(), "ToString should list the elements.")
diff --git a/tests/Cosmos.Sql.Tests/FSharp.Azure.Cosmos.Sql.Tests.fsproj b/tests/Cosmos.Sql.Tests/FSharp.Azure.Cosmos.Sql.Tests.fsproj
new file mode 100644
index 00000000..5ac744a9
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/FSharp.Azure.Cosmos.Sql.Tests.fsproj
@@ -0,0 +1,44 @@
+
+
+
+
+ net10.0
+ $(AssemblyBaseName).Sql.Tests
+
+ Exe
+ true
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Cosmos.Sql.Tests/ImmutableArrayPatternsTests.fs b/tests/Cosmos.Sql.Tests/ImmutableArrayPatternsTests.fs
new file mode 100644
index 00000000..e15455db
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/ImmutableArrayPatternsTests.fs
@@ -0,0 +1,38 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System.Collections.Immutable
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+
+///
+/// The active patterns that match an by its length.
+///
+[]
+type ImmutableArrayPatternsTests () =
+
+ []
+ member _.``The length patterns match exactly their length`` () =
+ let describe (items : ImmutableArray) =
+ match items with
+ | Arr0 -> "none"
+ | Arr1 single -> $"one %d{single}"
+ | Arr2 (first, second) -> $"two %d{first} %d{second}"
+ | Arr3 (first, second, third) -> $"three %d{first} %d{second} %d{third}"
+ | ArrN 4 -> "four"
+ | _ -> "more"
+
+ Assert.AreEqual ("none", describe ImmutableArray.Empty, "Arr0 should match an empty array.")
+ Assert.AreEqual ("none", describe Unchecked.defaultof>, "Arr0 should match a default array.")
+ Assert.AreEqual ("one 1", describe (ImmutableArray.Create 1), "Arr1 should match one element.")
+ Assert.AreEqual ("two 1 2", describe (ImmutableArray.Create (1, 2)), "Arr2 should match two elements.")
+ Assert.AreEqual ("three 1 2 3", describe (ImmutableArray.Create (1, 2, 3)), "Arr3 should match three elements.")
+ Assert.AreEqual ("four", describe (ImmutableArray.Create (1, 2, 3, 4)), "ArrN 4 should match four elements.")
+ Assert.AreEqual ("more", describe (ImmutableArray.Create (1, 2, 3, 4, 5)), "Longer arrays should fall through.")
+
+ []
+ member _.``Arr2 binds a struct tuple pattern too`` () =
+ match ImmutableArray.Create ("a", "b") with
+ | Arr2 (struct (first, second)) ->
+ Assert.AreEqual ("ab", first + second, "The struct tuple pattern should bind both elements.")
+ | _ -> Assert.Fail "Arr2 should match an array of two elements."
diff --git a/tests/Cosmos.Sql.Tests/InlineTests.fs b/tests/Cosmos.Sql.Tests/InlineTests.fs
new file mode 100644
index 00000000..57967c13
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/InlineTests.fs
@@ -0,0 +1,255 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System
+open System.Collections.Generic
+open System.Text.Json
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+open FSharp.Azure.Cosmos.Sql.Tests.Ast
+
+///
+/// The inline print mode:
+/// and the printing functions built on it.
+///
+[]
+type InlineTests () =
+
+ /// An encoder over a fixed set of literals, given by parameter name.
+ static let encoder (values : struct (string * ScalarExpression) list) =
+ let lookup = Dictionary(StringComparer.Ordinal)
+
+ for struct (name, value) in values do
+ lookup[name] <- value
+
+ fun (name : ParameterName) ->
+ let mutable value = Unchecked.defaultof
+
+ if lookup.TryGetValue (name.Value, &value) then
+ ValueSome value
+ else
+ ValueNone
+
+ static let codes (errors : ValidationError seq) = errors |> Seq.map _.Code |> Seq.toArray
+
+ []
+ member _.``Inline mode replaces parameters in every clause`` () =
+ let query = {
+ selectRoot with
+ Top = ValueSome (SpecValue.Parameter (ParameterName "@top"))
+ Where =
+ ValueSome (
+ binary
+ BinaryOperator.And
+ (binary BinaryOperator.GreaterThanOrEqual (alias "root" |> prop "age") (param "@minAge"))
+ (call "ARRAY_CONTAINS" [| param "@tags"; alias "root" |> prop "tag" |])
+ )
+ }
+
+ let encode =
+ encoder [
+ struct ("@top", integer 10L)
+ struct ("@minAge", integer 18L)
+ struct ("@tags", arrayOf [| str "a"; str "b" |])
+ ]
+
+ let printed =
+ Printer.printInline PropertyStyle.Brackets encode query
+ |> Result.defaultWith (fun errors -> $"%A{errors}")
+
+ Assert.AreEqual (
+ "SELECT TOP 10 VALUE root FROM root WHERE ((root[\"age\"] >= 18) AND ARRAY_CONTAINS([\"a\", \"b\"], root[\"tag\"]))",
+ printed,
+ "Every parameter should be replaced by its literal"
+ )
+
+ Assert.AreEqual (
+ "SELECT TOP @top VALUE root FROM root WHERE ((root[\"age\"] >= @minAge) AND ARRAY_CONTAINS(@tags, root[\"tag\"]))",
+ Printer.print PropertyStyle.Brackets query,
+ "The parameterized mode should keep the parameters"
+ )
+
+ []
+ member _.``Inline mode replaces parameters in subqueries, OFFSET and LIMIT`` () =
+ let subquery = {
+ selectValue (alias "t") with
+ From =
+ ValueSome (
+ CollectionExpression.ArrayIterator (
+ Identifier "t",
+ Collection.InputPath (Identifier "c", ValueSome (PathExpression.String (ValueNone, "tags")))
+ )
+ )
+ Where = ValueSome (binary BinaryOperator.Equal (alias "t") (param "@tag"))
+ }
+
+ let query = {
+ selectValue (alias "c") with
+ From = from "c"
+ Where = ValueSome (ScalarExpression.Exists subquery)
+ OffsetLimit =
+ ValueSome (struct (SpecValue.Parameter (ParameterName "@skip"), SpecValue.Parameter (ParameterName "@take")))
+ }
+
+ let encode =
+ encoder [ struct ("@tag", str "vip"); struct ("@skip", integer 20L); struct ("@take", integer 10L) ]
+
+ Assert.AreEqual (
+ "SELECT VALUE c FROM c WHERE EXISTS(SELECT VALUE t FROM t IN c[\"tags\"] WHERE (t = \"vip\")) OFFSET 20 LIMIT 10",
+ Printer.printInline PropertyStyle.Brackets encode query
+ |> Result.defaultWith (fun errors -> $"%A{errors}"),
+ "Parameters in a subquery, OFFSET and LIMIT should be replaced"
+ )
+
+ []
+ member _.``A predicate prints inline as the filter text of a patch`` () =
+ let predicate = binary BinaryOperator.GreaterThan (alias "c" |> prop "qty") (param "@min")
+
+ Assert.AreEqual (
+ "FROM c WHERE (c[\"qty\"] > 0)",
+ Printer.printPredicateInline
+ PropertyStyle.Brackets
+ (encoder [ struct ("@min", integer 0L) ])
+ (Identifier "c")
+ predicate
+ |> Result.defaultWith (fun errors -> $"%A{errors}"),
+ "The filter predicate of a patch takes no parameters, so they should be written as literals"
+ )
+
+ []
+ member _.``A parameter without an inline value is reported once`` () =
+ let query = {
+ selectRoot with
+ Where =
+ ValueSome (
+ binary
+ BinaryOperator.Or
+ (binary BinaryOperator.Equal (param "@x") (integer 1L))
+ (binary BinaryOperator.Equal (param "@x") (integer 2L))
+ )
+ }
+
+ match Inline.substituteParameters (encoder []) query with
+ | Ok _ -> Assert.Fail "A parameter without a value should fail the substitution."
+ | Error errors ->
+ let error = Assert.ContainsSingle (errors, "A parameter used twice should be reported once.")
+ Assert.AreEqual (ValidationErrorCode.MissingInlineValue, error.Code, "The error should name the missing value.")
+ Assert.Contains ("@x", error.Message, StringComparison.Ordinal, "The message should name the parameter.")
+
+ []
+ member _.``Inline values must be constants and counts integers`` () =
+ let query = {
+ selectRoot with
+ Top = ValueSome (SpecValue.Parameter (ParameterName "@top"))
+ Where = ValueSome (binary BinaryOperator.Equal (alias "root" |> prop "a") (param "@value"))
+ }
+
+ let encode = encoder [ struct ("@top", str "ten"); struct ("@value", alias "root" |> prop "b") ]
+
+ match Inline.substituteParameters encode query with
+ | Ok _ -> Assert.Fail "Unusable inline values should fail the substitution."
+ | Error errors ->
+ CollectionAssert.AreEqual (
+ [| ValidationErrorCode.InlineCountNotInteger; ValidationErrorCode.InlineValueNotConstant |],
+ codes errors,
+ "A string TOP and a property path value should both be reported, in clause order."
+ )
+
+ []
+ member _.``A parameter used several times gets one value, and its encoder is asked once`` () =
+ let asked = ref 0
+
+ // An encoder that would give another value every time it is asked
+ let encode (_ : ParameterName) =
+ asked.Value <- asked.Value + 1
+ ValueSome (integer (int64 asked.Value))
+
+ let query = {
+ selectRoot with
+ Top = ValueSome (SpecValue.Parameter (ParameterName "@n"))
+ Where =
+ ValueSome (
+ binary
+ BinaryOperator.And
+ (binary BinaryOperator.Equal (alias "root" |> prop "a") (param "@n"))
+ (binary BinaryOperator.Equal (alias "root" |> prop "b") (param "@n"))
+ )
+ }
+
+ let printed =
+ Printer.printInline PropertyStyle.Brackets encode query
+ |> Result.defaultWith (fun errors -> $"%A{errors}")
+
+ Assert.AreEqual(
+ "SELECT TOP 1 VALUE root FROM root WHERE ((root[\"a\"] = 1) AND (root[\"b\"] = 1))",
+ printed,
+ "Every use of the parameter should get the literal of the one answer."
+ )
+
+ Assert.AreEqual (1, asked.Value, "The encoder should be asked once for a parameter name.")
+
+ []
+ member _.``Constants are literals and array or object literals of literals`` () =
+ Assert.IsTrue (Inline.isConstant (integer 1L), "A literal should be a constant.")
+ Assert.IsTrue (
+ Inline.isConstant (objectOf [| struct ("a", arrayOf [| nul; str "x" |]) |]),
+ "Nested literals should be a constant."
+ )
+
+ Assert.IsFalse (Inline.isConstant (param "@p"), "A parameter should not be a constant.")
+ Assert.IsFalse (Inline.isConstant (arrayOf [| alias "c" |]), "An array with a reference should not be a constant.")
+
+ []
+ member _.``JSON values become the literals that print the same JSON`` () =
+ use document =
+ JsonDocument.Parse
+ """{"name":"a\"b","count":3,"price":2.5,"big":1e400,"flag":true,"none":null,"tags":["x",1],"nested":{"k":-7}}"""
+
+ let literal = Inline.ofJsonElement document.RootElement
+
+ Assert.AreEqual (
+ objectOf [|
+ struct ("name", str "a\"b")
+ struct ("count", integer 3L)
+ struct ("price", number 2.5)
+ struct ("big", number Double.PositiveInfinity)
+ struct ("flag", boolean true)
+ struct ("none", nul)
+ struct ("tags", arrayOf [| str "x"; integer 1L |])
+ struct ("nested", objectOf [| struct ("k", integer -7L) |])
+ |],
+ literal,
+ "Every JSON kind should map to its literal, with property order kept"
+ )
+
+ Assert.AreEqual (
+ undefined,
+ Inline.ofJsonElement Unchecked.defaultof,
+ "The default JsonElement holds no value."
+ )
+
+ let serialized = JsonSerializer.SerializeToElement {| Id = "1"; Score = 0.75 |}
+
+ Assert.AreEqual (
+ "{\"Id\": \"1\", \"Score\": 0.75}",
+ Printer.printScalar PropertyStyle.Brackets (Inline.ofJsonElement serialized),
+ "A serialized value should print as the JSON it was serialized to"
+ )
+
+ []
+ member _.``A JSON number with more digits than a double holds becomes the nearest double`` () =
+ use document = JsonDocument.Parse "[0.123456789012345678901,18446744073709551616]"
+
+ let literal = Inline.ofJsonElement document.RootElement
+
+ Assert.AreEqual (
+ arrayOf [| number 0.12345678901234568; number 18446744073709551616.0 |],
+ literal,
+ "A long fraction and an integer beyond Int64 should both become the doubles the service reads"
+ )
+
+ Assert.AreEqual (
+ "[0.12345678901234568, 1.8446744073709552E+19]",
+ Printer.printScalar PropertyStyle.Brackets literal,
+ "The constant should print the shortest digits that give the same double"
+ )
diff --git a/tests/Cosmos.Sql.Tests/PrinterTests.fs b/tests/Cosmos.Sql.Tests/PrinterTests.fs
new file mode 100644
index 00000000..6a23eb3d
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/PrinterTests.fs
@@ -0,0 +1,923 @@
+// Golden strings for every node kind of the syntax tree. Where the SDK has a baseline for the same node, the expected
+// text is the SDK's, cited with its file and case under Microsoft.Azure.Cosmos/tests of Azure/azure-cosmos-dotnet-v3
+// 3.62.0 (MIT, see THIRD-PARTY-NOTICES.md):
+// - SqlObjectVisitorBaselineTests.*.xml (Microsoft.Azure.Cosmos.Tests/BaselineTest/TestBaseline): the SDK's printer
+// applied to hand-built SqlObjects;
+// - *SqlParserBaselineTests.*.xml (same folder): the SDK's printer applied to the parse of a query, written with dots
+// where the query had them, so those cases print with PropertyStyle.DotWhenSafe;
+// - Linq*BaselineTests.*.xml (Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline): the SQL of the SDK's
+// LINQ provider, which writes every property access with brackets.
+// The SDK's baselines are pretty-printed, so they are compared after Ast.normalize.
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System
+open System.Globalization
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+open FSharp.Azure.Cosmos.Sql.Tests.Ast
+
+///
+/// Golden strings of
+/// and its relatives for every node kind, escaping and culture independence.
+///
+[]
+type PrinterTests () =
+
+ static let brackets (expression : ScalarExpression) = Printer.printScalar PropertyStyle.Brackets expression
+ static let dots (expression : ScalarExpression) = Printer.printScalar PropertyStyle.DotWhenSafe expression
+
+ ///
+ /// "some"["random"]["path"][42], the path of the SDK's baselines SqlObjectVisitorBaselineTests.*.xml.
+ ///
+ static let somePath = str "some" |> prop "random" |> prop "path" |> at 42L
+
+ static let inputPathCollection =
+ Collection.InputPath (Identifier "inputPathCollection", ValueSome (PathExpression.String (ValueNone, "somePath")))
+
+ ///
+ /// SELECT * FROM inputPathCollection["somePath"] AS some alias WHERE ("this path" < 42) GROUP BY … ORDER BY …
+ /// OFFSET 0 LIMIT 0, the case SqlQuery of the SDK's baseline
+ /// SqlObjectVisitorBaselineTests.SqlQueries.xml (the alias is deliberately not a valid identifier there; the
+ /// printer is total).
+ ///
+ static let sdkSqlQuery = {
+ selectValue nul with
+ Select = SelectSpec.Star
+ From = ValueSome (CollectionExpression.Aliased (inputPathCollection, ValueSome (Identifier "some alias")))
+ Where = ValueSome (binary BinaryOperator.LessThan (str "this path") (integer 42L))
+ GroupBy = EquatableArray.singleton somePath
+ OrderBy = EquatableArray.singleton (orderBy (ValueSome SortOrder.Ascending) somePath)
+ OffsetLimit = ValueSome (struct (SpecValue.Literal 0L, SpecValue.Literal 0L))
+ }
+
+ ///
+ /// The SDK's text of the query above, from the same baseline case; the SDK writes two spaces before
+ /// ORDER BY.
+ ///
+ static let sdkSqlQueryText =
+ """SELECT * FROM inputPathCollection["somePath"] AS some alias WHERE ("this path" < 42) """
+ + """GROUP BY "some"["random"]["path"][42] ORDER BY "some"["random"]["path"][42] ASC OFFSET 0 LIMIT 0"""
+
+ static let selectStar = { selectValue nul with Select = SelectSpec.Star }
+
+ /// Prints with the culture of the current thread set to the given one, restoring it afterwards.
+ static let withCulture (culture : CultureInfo) (print : unit -> string) =
+ let previous = CultureInfo.CurrentCulture
+ let previousUi = CultureInfo.CurrentUICulture
+ CultureInfo.CurrentCulture <- culture
+ CultureInfo.CurrentUICulture <- culture
+
+ try
+ print ()
+ finally
+ CultureInfo.CurrentCulture <- previous
+ CultureInfo.CurrentUICulture <- previousUi
+
+ []
+ member _.``Control characters are escaped like the SDK escapes them`` () =
+ // SqlObjectVisitorBaselineTests.SqlLiteral.xml, Escape Sequence 0 to 31
+ for code in 0..31 do
+ let expected =
+ match code with
+ | 8 -> "\"\\b\""
+ | 9 -> "\"\\t\""
+ | 10 -> "\"\\n\""
+ | 12 -> "\"\\f\""
+ | 13 -> "\"\\r\""
+ | _ -> $"\"\\u%04X{code}\""
+
+ Assert.AreEqual (expected, brackets (str (string (char code))), $"Escape Sequence %d{code}")
+
+ []
+ member _.``Quotes and backslashes are escaped and the solidus is not`` () =
+ Assert.AreEqual ("\"a\\\"b\"", brackets (str "a\"b"), "A double quote should be escaped.")
+ Assert.AreEqual ("\"a\\\\b\"", brackets (str "a\\b"), "A backslash should be escaped.")
+ // StringLiteralSqlParserBaselineTests: "\/solidus" parses and prints as "/solidus"
+ Assert.AreEqual ("\"/solidus\"", brackets (str "/solidus"), "A solidus should be written as is, as the SDK does.")
+ Assert.AreEqual ("\"'single'\"", brackets (str "'single'"), "A single quote should be written as is.")
+
+ []
+ member _.``String, null, boolean and undefined literals print like the SDK baselines`` () =
+ // SqlObjectVisitorBaselineTests.SqlLiteral.xml
+ Assert.AreEqual ("\"\"", brackets (str ""), "Empty String")
+ Assert.AreEqual ("\"Hello\"", brackets (str "Hello"), "SqlStringLiteral")
+ Assert.AreEqual ("\"💩\"", brackets (str "💩"), "SqlStringLiteral With Unicode")
+ Assert.AreEqual ("null", brackets nul, "SqlNullLiteral")
+ Assert.AreEqual ("true", brackets (boolean true), "SqlBooleanLiteralTrue")
+ Assert.AreEqual ("false", brackets (boolean false), "SqlBooleanLiteralFalse")
+ Assert.AreEqual ("undefined", brackets undefined, "SqlUndefinedLiteral")
+
+ []
+ member _.``Number literals print like the SDK baselines`` () =
+ // SqlObjectVisitorBaselineTests.SqlLiteral.xml
+ let cases = [
+ struct ("SqlNumberLiteral", integer 1597463007L, "1597463007")
+ struct ("Zero", integer 0L, "0")
+ struct ("Positive Number", integer 1L, "1")
+ struct ("Negative Number", integer -1L, "-1")
+ struct ("Double", number 1337.1337, "1337.1337")
+ struct ("E", number Math.E, "2.718281828459045")
+ struct ("Pi", number Math.PI, "3.141592653589793")
+ struct ("1/3", number (1.0 / 3.0), "0.3333333333333333")
+ struct ("Max Safe Integer 9007199254740991", integer 9007199254740991L, "9007199254740991")
+ struct ("Max Safe Integer 9007199254740991 Plus One", integer 9007199254740992L, "9007199254740992")
+ struct ("Min Safe Integer: -9007199254740991", integer -9007199254740991L, "-9007199254740991")
+ struct ("Min Safe Integer: -9007199254740991 Minus One", integer -9007199254740992L, "-9007199254740992")
+ struct ("PositiveInfinity", number Double.PositiveInfinity, "Infinity")
+ struct ("NegativeInfinity", number Double.NegativeInfinity, "-Infinity")
+ struct ("NaN NaN", number Double.NaN, "NaN")
+ struct ("Epsilon 5E-324", number Double.Epsilon, "5E-324")
+ struct ("MaxValue 1.7976931348623157E+308", number Double.MaxValue, "1.7976931348623157E+308")
+ struct ("MinValue -1.7976931348623157E+308", number Double.MinValue, "-1.7976931348623157E+308")
+ struct ("MinValue -9223372036854775808", integer Int64.MinValue, "-9223372036854775808")
+ struct ("MaxValue 9223372036854775807", integer Int64.MaxValue, "9223372036854775807")
+ ]
+
+ for struct (case, literal, expected) in cases do
+ Assert.AreEqual (expected, brackets literal, case)
+
+ []
+ member _.``A float without a fractional part prints without one, as the SDK prints it`` () =
+ // LinqTranslationBaselineTests.TestVectorDistanceFunction.xml prints the float32 vector [2, 3, 4] that way
+ Assert.AreEqual ("[2, 3, 4]", brackets (arrayOf [| number 2.0; number 3.0; number 4.0 |]), "Integral floats")
+
+ []
+ member _.``Parameters and bare identifiers print as they are`` () =
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml, SqlParameterRefScalarExpression
+ Assert.AreEqual ("@param0", brackets (param "@param0"), "SqlParameterRefScalarExpression")
+ // ScalarExpressionSqlParserBaselineTests.PropertyRef.xml, root
+ Assert.AreEqual ("c", brackets (alias "c"), "root")
+
+ []
+ member _.``Property accesses print with brackets and JSON escaping`` () =
+ Assert.AreEqual ("c[\"name\"]", brackets (alias "c" |> prop "name"), "A property access")
+ Assert.AreEqual ("c[\"value\"]", brackets (alias "c" |> prop "value"), "A reserved word as property name")
+ Assert.AreEqual ("c[\"first name\"]", brackets (alias "c" |> prop "first name"), "A name with a space")
+ Assert.AreEqual ("c[\"a\\\"b\"]", brackets (alias "c" |> prop "a\"b"), "A name with a quote")
+ Assert.AreEqual ("c[\"a\"][\"b\"]", brackets (alias "c" |> prop "a" |> prop "b"), "A nested access")
+ // SqlObjectVisitorBaselineTests.SqlQueries.xml prints this path in several cases
+ Assert.AreEqual ("\"some\"[\"random\"][\"path\"][42]", brackets somePath, "A path over a literal")
+
+ []
+ member _.``Property accesses print with dots when the name is safe and the style asks for it`` () =
+ Assert.AreEqual ("c.name", dots (alias "c" |> prop "name"), "A safe name")
+ Assert.AreEqual ("c[\"value\"]", dots (alias "c" |> prop "value"), "A reserved word keeps brackets")
+ Assert.AreEqual ("c[\"first name\"]", dots (alias "c" |> prop "first name"), "An unsafe name keeps brackets")
+ Assert.AreEqual ("c[\"1a\"]", dots (alias "c" |> prop "1a"), "A name starting with a digit keeps brackets")
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml, SqlPropertyRefScalarExpression
+ Assert.AreEqual ("\"some\".path", dots (str "some" |> prop "path"), "SqlPropertyRefScalarExpression")
+ // ScalarExpressionSqlParserBaselineTests.MemberIndexer.xml, Basic and Expression as indexer
+ Assert.AreEqual ("c.arr[2]", dots (alias "c" |> prop "arr" |> at 2L), "Basic")
+
+ Assert.AreEqual (
+ "c.arr[(2 + 2)]",
+ dots (ScalarExpression.MemberIndexer (alias "c" |> prop "arr", binary BinaryOperator.Add (integer 2L) (integer 2L))),
+ "Expression as indexer"
+ )
+
+ []
+ member _.``Every binary operator prints in parentheses like the SDK baseline`` () =
+ // SqlObjectVisitorBaselineTests.SqlBinaryScalarOperators.xml
+ let cases = [
+ struct (BinaryOperator.Add, "+")
+ struct (BinaryOperator.And, "AND")
+ struct (BinaryOperator.BitwiseAnd, "&")
+ struct (BinaryOperator.BitwiseOr, "|")
+ struct (BinaryOperator.BitwiseXor, "^")
+ struct (BinaryOperator.Divide, "/")
+ struct (BinaryOperator.Equal, "=")
+ struct (BinaryOperator.GreaterThan, ">")
+ struct (BinaryOperator.GreaterThanOrEqual, ">=")
+ struct (BinaryOperator.LessThan, "<")
+ struct (BinaryOperator.LessThanOrEqual, "<=")
+ struct (BinaryOperator.Modulo, "%")
+ struct (BinaryOperator.Multiply, "*")
+ struct (BinaryOperator.NotEqual, "!=")
+ struct (BinaryOperator.Or, "OR")
+ struct (BinaryOperator.StringConcat, "||")
+ struct (BinaryOperator.Subtract, "-")
+ ]
+
+ Assert.HasCount (17, cases, "The cases should cover all seventeen binary operators.")
+
+ for struct (operator, text) in cases do
+ Assert.AreEqual (
+ $"(3735928559 %s{text} 3131746989)",
+ brackets (binary operator (integer 3735928559L) (integer 3131746989L)),
+ $"%A{operator}"
+ )
+
+ []
+ member _.``Every unary operator prints in parentheses like the SDK baseline`` () =
+ // SqlObjectVisitorBaselineTests.SqlUnaryScalarOperators.xml
+ let cases = [
+ struct (UnaryOperator.BitwiseNot, "(~ 3735928559)")
+ struct (UnaryOperator.Not, "(NOT 3735928559)")
+ struct (UnaryOperator.Minus, "(- 3735928559)")
+ struct (UnaryOperator.Plus, "(+ 3735928559)")
+ ]
+
+ for struct (operator, expected) in cases do
+ Assert.AreEqual (expected, brackets (ScalarExpression.Unary (operator, integer 3735928559L)), $"%A{operator}")
+
+ []
+ member _.``Nested operations keep every parenthesis`` () =
+ // ScalarExpressionSqlParserBaselineTests.Binary.xml, Multiplicative > Additive
+ let expression =
+ binary
+ BinaryOperator.Subtract
+ (binary BinaryOperator.Add (integer 1L) (binary BinaryOperator.Multiply (integer 2L) (integer 3L)))
+ (binary BinaryOperator.Divide (integer 4L) (integer 5L))
+
+ Assert.AreEqual ("((1 + (2 * 3)) - (4 / 5))", brackets expression, "Multiplicative > Additive")
+
+ []
+ member _.``Conditional and coalesce print like the SDK baselines`` () =
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml
+ Assert.AreEqual (
+ "(\"if true\" ? \"then this\" : \"else this\")",
+ brackets (ScalarExpression.Conditional (str "if true", str "then this", str "else this")),
+ "SqlConditionalScalarExpression"
+ )
+
+ Assert.AreEqual (
+ "(\"if this is null\" ?? \"then return this\")",
+ brackets (ScalarExpression.Coalesce (str "if this is null", str "then return this")),
+ "SqlCoalesceScalarExpression"
+ )
+
+ []
+ member _.``IN and NOT IN print like the SDK baselines`` () =
+ let haystack = EquatableArray.unsafeOfArray [| str "this"; str "set"; str "of"; str "values" |]
+
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml
+ Assert.AreEqual (
+ "(\"is this\" NOT IN (\"this\", \"set\", \"of\", \"values\"))",
+ brackets (ScalarExpression.In (str "is this", true, haystack)),
+ "SqlInScalarExpression Not: True"
+ )
+
+ Assert.AreEqual (
+ "(\"is this\" IN (\"this\", \"set\", \"of\", \"values\"))",
+ brackets (ScalarExpression.In (str "is this", false, haystack)),
+ "SqlInScalarExpression Not: False"
+ )
+
+ // ScalarExpressionSqlParserBaselineTests.In.xml, Basic
+ Assert.AreEqual (
+ "(42 IN (42))",
+ brackets (ScalarExpression.In (integer 42L, false, EquatableArray.singleton (integer 42L))),
+ "Basic"
+ )
+
+ Assert.AreEqual (
+ "(42 IN ())",
+ brackets (ScalarExpression.In (integer 42L, false, EquatableArray.empty)),
+ "An empty list prints, although the validator rejects it"
+ )
+
+ []
+ member _.``BETWEEN and NOT BETWEEN print like the SDK baselines`` () =
+ // ScalarExpressionSqlParserBaselineTests.Between.xml
+ Assert.AreEqual (
+ "(42 BETWEEN 15 AND 1337)",
+ brackets (ScalarExpression.Between (integer 42L, false, integer 15L, integer 1337L)),
+ "Regular Betweeen"
+ )
+
+ Assert.AreEqual (
+ "(42 NOT BETWEEN 15 AND 1337)",
+ brackets (ScalarExpression.Between (integer 42L, true, integer 15L, integer 1337L)),
+ "NOT Betweeen"
+ )
+
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml
+ Assert.AreEqual (
+ "(\"some\"[\"random\"][\"path\"][42] BETWEEN 42 AND 1337)",
+ brackets (ScalarExpression.Between (somePath, false, integer 42L, integer 1337L)),
+ "SqlBetweenScalarExpression"
+ )
+
+ []
+ member _.``LIKE prints like the SDK baselines with single spaces`` () =
+ let age = alias "c" |> prop "age"
+
+ // LikeClauseSqlParserBaselineTests.Tests.xml
+ Assert.AreEqual ("(c.age LIKE \"$a\")", dots (ScalarExpression.Like (age, str "$a", false, ValueNone)), "Basic")
+
+ Assert.AreEqual (
+ "(c.age LIKE \"a!%\" ESCAPE \"!\")",
+ dots (ScalarExpression.Like (age, str "a!%", false, ValueSome "!")),
+ "With ESCAPE"
+ )
+
+ let negated = dots (ScalarExpression.Like (age, str "a!%", true, ValueSome "!"))
+
+ Assert.AreEqual ("(c.age NOT LIKE \"a!%\" ESCAPE \"!\")", negated, "With NOT, single-spaced")
+
+ Assert.AreEqual (
+ normalize "(c.age NOT LIKE \"a!%\" ESCAPE \"!\")",
+ normalize negated,
+ "With NOT, equal to the SDK's double-spaced text after normalization"
+ )
+
+ []
+ member _.``Function calls print like the SDK baselines`` () =
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml, SqlFunctionCallScalarExpression
+ Assert.AreEqual ("ABS(-42)", brackets (call "ABS" [| integer -42L |]), "SqlFunctionCallScalarExpression")
+ // SqlObjectVisitorBaselineTests.SqlFunctionCalls.xml
+ Assert.AreEqual ("PI()", brackets (call "PI" [||]), "PI")
+
+ Assert.AreEqual (
+ "ARRAY_CONTAINS([1, 2, 3], 42, true)",
+ brackets (call "ARRAY_CONTAINS" [| arrayOf [| integer 1L; integer 2L; integer 3L |]; integer 42L; boolean true |]),
+ "ARRAY_CONTAINS"
+ )
+
+ Assert.AreEqual ("IIF(true, \"YES\", \"NO\")", brackets (call "IIF" [| boolean true; str "YES"; str "NO" |]), "IIF")
+
+ // ScalarExpressionSqlParserBaselineTests.FunctionCall.xml, udf
+ Assert.AreEqual ("udf.my_udf(-123)", brackets (udf "my_udf" [| integer -123L |]), "udf")
+
+ []
+ member _.``Array and object literals print like the SDK baselines`` () =
+ // SqlObjectVisitorBaselineTests.SqlScalarExpression.xml
+ Assert.AreEqual ("[]", brackets (arrayOf [||]), "SqlArrayCreateScalarExpressionEmpty")
+ Assert.AreEqual ("[null]", brackets (arrayOf [| nul |]), "SqlArrayCreateScalarExpressionOneItem")
+ Assert.AreEqual ("[null, null, null]", brackets (arrayOf [| nul; nul; nul |]), "SqlArrayCreateScalarExpressionMultItems")
+ Assert.AreEqual ("{}", brackets (objectOf [||]), "SqlObjectCreateScalarExpression Empty")
+
+ let hello = struct ("Hello", str "World")
+
+ Assert.AreEqual ("{\"Hello\": \"World\"}", brackets (objectOf [| hello |]), "SqlObjectCreateScalarExpression OneProperty")
+
+ Assert.AreEqual (
+ "{\"Hello\": \"World\", \"Hello\": \"World\", \"Hello\": \"World\"}",
+ brackets (objectOf [| hello; hello; hello |]),
+ "SqlObjectCreateScalarExpression MultiProperty"
+ )
+
+ Assert.AreEqual (
+ "{\"a\\\"b\": 1}",
+ brackets (objectOf [| struct ("a\"b", integer 1L) |]),
+ "Object keys are escaped like every other string"
+ )
+
+ []
+ member _.``Subquery expressions print like the SDK baselines`` () =
+ let cases = [
+ // ScalarExpressionSqlParserBaselineTests.Exists.xml, Array.xml, All.xml, First.xml, Last.xml, Subquery.xml
+ struct ("EXISTS(SELECT *)", ScalarExpression.Exists selectStar)
+ struct ("ARRAY(SELECT *)", ScalarExpression.Array selectStar)
+ struct ("ALL(SELECT *)", ScalarExpression.All selectStar)
+ struct ("FIRST(SELECT *)", ScalarExpression.First selectStar)
+ struct ("LAST(SELECT *)", ScalarExpression.Last selectStar)
+ struct ("(SELECT *)", ScalarExpression.Subquery selectStar)
+ ]
+
+ for struct (expected, expression) in cases do
+ Assert.AreEqual (expected, brackets expression, expected)
+
+ // SqlObjectVisitorBaselineTests.SqlQueries.xml, SqlSubqueryScalarExpression, SqlArrayScalarExpression and
+ // SqlExistsScalarExpression
+ let sdkQuery = sdkSqlQueryText
+
+ Assert.AreEqual (
+ normalize $"({sdkQuery})",
+ normalize (brackets (ScalarExpression.Subquery sdkSqlQuery)),
+ "SqlSubqueryScalarExpression"
+ )
+ Assert.AreEqual (
+ normalize $"ARRAY({sdkQuery})",
+ normalize (brackets (ScalarExpression.Array sdkSqlQuery)),
+ "SqlArrayScalarExpression"
+ )
+ Assert.AreEqual (
+ normalize $"EXISTS({sdkQuery})",
+ normalize (brackets (ScalarExpression.Exists sdkSqlQuery)),
+ "SqlExistsScalarExpression"
+ )
+
+ []
+ member _.``The select clause prints like the SDK baselines`` () =
+ let top = ValueSome (SpecValue.Literal 42L)
+ let list =
+ SelectSpec.List (EquatableArray.singleton { Expression = somePath; Alias = ValueSome (Identifier "some alias") })
+
+ // SqlObjectVisitorBaselineTests.SqlQueries.xml, SqlSelectClause
+ let cases = [
+ struct ("SELECT DISTINCT TOP 42 *", { selectStar with Distinct = true; Top = top })
+ struct ("SELECT TOP 42 *", { selectStar with Top = top })
+ struct ("SELECT DISTINCT *", { selectStar with Distinct = true })
+ struct ("SELECT *", selectStar)
+ struct ("SELECT DISTINCT TOP 42 \"some\"[\"random\"][\"path\"][42] AS some alias",
+ { selectStar with Select = list; Distinct = true; Top = top })
+ struct ("SELECT \"some\"[\"random\"][\"path\"][42] AS some alias", { selectStar with Select = list })
+ ]
+
+ for struct (expected, query) in cases do
+ Assert.AreEqual (expected, Printer.print PropertyStyle.Brackets query, expected)
+
+ // SelectClauseSqlParserBaselineTests.Tests.xml, Select List with aliases, Select Value and TOP with parameters
+ let item (expression : ScalarExpression) (name : string voption) = {
+ Expression = expression
+ Alias = name |> ValueOption.map Identifier
+ }
+
+ let items =
+ EquatableArray.unsafeOfArray [|
+ item (integer 1L) (ValueSome "asdf")
+ item (integer 2L) ValueNone
+ item (integer 3L) (ValueSome "asdf2")
+ |]
+
+ Assert.AreEqual (
+ "SELECT 1 AS asdf, 2, 3 AS asdf2",
+ Printer.print PropertyStyle.Brackets { selectStar with Select = SelectSpec.List items },
+ "Select List with aliases"
+ )
+
+ Assert.AreEqual ("SELECT VALUE 1", Printer.print PropertyStyle.Brackets (selectValue (integer 1L)), "Select Value")
+
+ Assert.AreEqual (
+ "SELECT TOP @TOPCOUNT *",
+ Printer.print PropertyStyle.Brackets {
+ selectStar with
+ Top = ValueSome (SpecValue.Parameter (ParameterName "@TOPCOUNT"))
+ },
+ "TOP with parameters"
+ )
+
+ []
+ member _.``Collections print like the SDK baselines`` () =
+ let print (from : CollectionExpression) = Printer.print PropertyStyle.Brackets { selectStar with From = ValueSome from }
+ let someAlias = Identifier "some alias"
+ let subquery = Collection.Subquery selectStar
+
+ // SqlObjectVisitorBaselineTests.SqlQueries.xml, the collection cases
+ Assert.AreEqual (
+ "SELECT * FROM inputPathCollection[\"somePath\"] AS some alias",
+ print (CollectionExpression.Aliased (inputPathCollection, ValueSome someAlias)),
+ "SqlAliasedCollectionExpression collectionType: SqlInputPathCollection"
+ )
+
+ Assert.AreEqual (
+ "SELECT * FROM some alias IN inputPathCollection[\"somePath\"]",
+ print (CollectionExpression.ArrayIterator (someAlias, inputPathCollection)),
+ "SqlArrayIteratorCollectionExpression collectionType: SqlInputPathCollection"
+ )
+
+ Assert.AreEqual (
+ "SELECT * FROM inputPathCollection[\"somePath\"] AS some alias JOIN some alias IN inputPathCollection[\"somePath\"]",
+ print (
+ CollectionExpression.Join (
+ CollectionExpression.Aliased (inputPathCollection, ValueSome someAlias),
+ CollectionExpression.ArrayIterator (someAlias, inputPathCollection)
+ )
+ ),
+ "SqlJoinCollectionExpression collectionType: SqlInputPathCollection"
+ )
+
+ Assert.AreEqual (
+ "SELECT * FROM (SELECT *) AS some alias JOIN some alias IN (SELECT *)",
+ print (
+ CollectionExpression.Join (
+ CollectionExpression.Aliased (subquery, ValueSome someAlias),
+ CollectionExpression.ArrayIterator (someAlias, subquery)
+ )
+ ),
+ "SqlJoinCollectionExpression collectionType: SqlSubqueryCollection"
+ )
+
+ []
+ member _.``Path expressions print like the SDK parser baselines`` () =
+ let print (from : CollectionExpression) = Printer.print PropertyStyle.Brackets { selectStar with From = ValueSome from }
+ let c = Identifier "c"
+ let arr = ValueSome (PathExpression.Identifier (ValueNone, Identifier "arr"))
+ let blah = ValueSome (PathExpression.Identifier (ValueNone, Identifier "blah"))
+
+ // FromClauseSqlParserBaselineTests.AliasedCollection.xml
+ Assert.AreEqual (
+ "SELECT * FROM c.arr[5] AS asdf",
+ print (
+ CollectionExpression.Aliased (
+ Collection.InputPath (c, ValueSome (PathExpression.Number (arr, 5L))),
+ ValueSome (Identifier "asdf")
+ )
+ ),
+ "collection: c.arr[5] + useAlias with AS: True"
+ )
+
+ Assert.AreEqual (
+ "SELECT * FROM c.blah[\"asdf\"]",
+ print (
+ CollectionExpression.Aliased (
+ Collection.InputPath (c, ValueSome (PathExpression.String (blah, "asdf"))),
+ ValueNone
+ )
+ ),
+ "collection: c.blah['asdf'] + useAlias with AS: False"
+ )
+
+ // FromClauseSqlParserBaselineTests.ArrayIteratorCollection.xml and JoinCollection.xml
+ Assert.AreEqual (
+ "SELECT * FROM item IN c.age",
+ print (
+ CollectionExpression.ArrayIterator (
+ Identifier "item",
+ Collection.InputPath (c, ValueSome (PathExpression.Identifier (ValueNone, Identifier "age")))
+ )
+ ),
+ "collection: c.age"
+ )
+
+ Assert.AreEqual (
+ "SELECT * FROM c JOIN d IN c.children",
+ print (
+ CollectionExpression.Join (
+ CollectionExpression.Aliased (Collection.InputPath (c, ValueNone), ValueNone),
+ CollectionExpression.ArrayIterator (
+ Identifier "d",
+ Collection.InputPath (c, ValueSome (PathExpression.Identifier (ValueNone, Identifier "children")))
+ )
+ )
+ ),
+ "Basic join"
+ )
+
+ Assert.AreEqual (
+ "SELECT * FROM c.blah.asdf",
+ Printer.print PropertyStyle.DotWhenSafe {
+ selectStar with
+ From =
+ ValueSome (
+ CollectionExpression.Aliased (
+ Collection.InputPath (c, ValueSome (PathExpression.String (blah, "asdf"))),
+ ValueNone
+ )
+ )
+ },
+ "A safe string segment prints with a dot under DotWhenSafe"
+ )
+
+ []
+ member _.``The full query prints its clauses in order like the SDK baseline`` () =
+ let printed = Printer.print PropertyStyle.Brackets sdkSqlQuery
+
+ // SqlObjectVisitorBaselineTests.SqlQueries.xml, SqlQuery; the SDK writes two spaces before ORDER BY
+ let expected =
+ """SELECT * FROM inputPathCollection["somePath"] AS some alias WHERE ("this path" < 42) """
+ + """GROUP BY "some"["random"]["path"][42] ORDER BY "some"["random"]["path"][42] ASC OFFSET 0 LIMIT 0"""
+
+ Assert.AreEqual (expected, printed, "SqlQuery with single spaces")
+ Assert.AreEqual (normalize sdkSqlQueryText, normalize printed, "SqlQuery equal to the SDK baseline after normalization")
+
+ []
+ member _.``ORDER BY, ORDER BY RANK and OFFSET LIMIT print like the SDK parser baselines`` () =
+ let text = alias "c" |> prop "text"
+ let score = call "FullTextScore" [| text; arrayOf [| str "keyword" |] |]
+
+ // OrderByClauseSqlParserBaselineTests.MultiOrderBy.xml, Only one sort order
+ Assert.AreEqual (
+ "SELECT * ORDER BY 1 ASC, 2 DESC, 3",
+ Printer.print PropertyStyle.Brackets {
+ selectStar with
+ OrderBy =
+ EquatableArray.unsafeOfArray [|
+ orderBy (ValueSome SortOrder.Ascending) (integer 1L)
+ orderBy (ValueSome SortOrder.Descending) (integer 2L)
+ orderBy ValueNone (integer 3L)
+ |]
+ },
+ "An item without a sort order prints without one"
+ )
+
+ // OrderByClauseSqlParserBaselineTests.SingleOrderByRank.xml, Basic and Descending
+ Assert.AreEqual (
+ "SELECT * ORDER BY RANK FullTextScore(c.text, [\"keyword\"])",
+ Printer.print PropertyStyle.DotWhenSafe {
+ selectStar with
+ OrderBy = EquatableArray.singleton (orderBy ValueNone score)
+ OrderByRank = true
+ },
+ "Basic"
+ )
+
+ Assert.AreEqual (
+ "SELECT * ORDER BY RANK FullTextScore(c.text, [\"keyword\"]) DESC",
+ Printer.print PropertyStyle.DotWhenSafe {
+ selectStar with
+ OrderBy = EquatableArray.singleton (orderBy (ValueSome SortOrder.Descending) score)
+ OrderByRank = true
+ },
+ "Descending"
+ )
+
+ // OffsetLimitClauseSqlParserBaselineTests.Tests.xml, Basic and Parameters
+ Assert.AreEqual (
+ "SELECT * OFFSET 10 LIMIT 10",
+ Printer.print PropertyStyle.Brackets {
+ selectStar with
+ OffsetLimit = ValueSome (struct (SpecValue.Literal 10L, SpecValue.Literal 10L))
+ },
+ "Basic"
+ )
+
+ Assert.AreEqual (
+ "SELECT * OFFSET @OFFSETCOUNT LIMIT @LIMITCOUNT",
+ Printer.print PropertyStyle.Brackets {
+ selectStar with
+ OffsetLimit =
+ ValueSome (
+ struct (SpecValue.Parameter (ParameterName "@OFFSETCOUNT"),
+ SpecValue.Parameter (ParameterName "@LIMITCOUNT"))
+ )
+ },
+ "Parameters"
+ )
+
+ []
+ member _.``Queries print like the SDK LINQ baselines`` () =
+ let root = alias "root"
+ let print = Printer.print PropertyStyle.Brackets
+
+ let check (expected : string) (query : SqlQuery) (case : string) =
+ let printed = print query
+ Assert.AreEqual (normalize expected, normalize printed, case)
+ Assert.AreEqual (expected, printed, $"{case}, without normalization")
+
+ // LinqGeneralBaselineTests.TestSkipTake.xml
+ check
+ "SELECT VALUE f0 FROM root JOIN f0 IN root[\"Children\"] WHERE (f0[\"Grade\"] > 100) OFFSET 10 LIMIT 20"
+ {
+ selectValue (alias "f0") with
+ From =
+ ValueSome (
+ CollectionExpression.Join (
+ CollectionExpression.Aliased (Collection.InputPath (Identifier "root", ValueNone), ValueNone),
+ CollectionExpression.ArrayIterator (
+ Identifier "f0",
+ Collection.InputPath (
+ Identifier "root",
+ ValueSome (PathExpression.String (ValueNone, "Children"))
+ )
+ )
+ )
+ )
+ Where = ValueSome (binary BinaryOperator.GreaterThan (alias "f0" |> prop "Grade") (integer 100L))
+ OffsetLimit = ValueSome (struct (SpecValue.Literal 10L, SpecValue.Literal 20L))
+ }
+ "TestSkipTake"
+
+ // LinqGeneralBaselineTests.TestSkipTake.xml; the SDK pretty-prints the subquery, so only the normalized text
+ // is compared
+ let skipTakeSubquery = {
+ selectValue (alias "r0") with
+ Distinct = true
+ From =
+ ValueSome (
+ CollectionExpression.Aliased (
+ Collection.Subquery {
+ selectRoot with
+ OffsetLimit = ValueSome (struct (SpecValue.Literal 3L, SpecValue.Literal 11L))
+ },
+ ValueSome (Identifier "r0")
+ )
+ )
+ }
+
+ Assert.AreEqual (
+ normalize "SELECT DISTINCT VALUE r0 FROM ( SELECT VALUE root FROM root OFFSET 3 LIMIT 11 ) AS r0",
+ normalize (print skipTakeSubquery),
+ "TestSkipTake, subquery collection"
+ )
+
+ // LinqTranslationBaselineTests.TestVectorDistanceFunction.xml
+ check
+ "SELECT VALUE root[\"Pk\"] FROM root ORDER BY VectorDistance(root[\"VectorFloatField\"], [2, 3, 4], false)"
+ {
+ selectValue (root |> prop "Pk") with
+ From = from "root"
+ OrderBy =
+ EquatableArray.singleton (
+ orderBy
+ ValueNone
+ (call "VectorDistance" [|
+ root |> prop "VectorFloatField"
+ arrayOf [| number 2.0; number 3.0; number 4.0 |]
+ boolean false
+ |])
+ )
+ }
+ "TestVectorDistanceFunction"
+
+ // LinqTranslationBaselineTests.TestRRFOrderByRankFunction.xml
+ check
+ ("""SELECT VALUE root["Pk"] FROM root ORDER BY RANK RRF(FullTextScore(root["StringField"], "test1"), """
+ + """FullTextScore(root["StringField2"], "test1", "test2", "test3"))""")
+ {
+ selectValue (root |> prop "Pk") with
+ From = from "root"
+ OrderBy =
+ EquatableArray.singleton (
+ orderBy
+ ValueNone
+ (call "RRF" [|
+ call "FullTextScore" [| root |> prop "StringField"; str "test1" |]
+ call "FullTextScore" [| root |> prop "StringField2"; str "test1"; str "test2"; str "test3" |]
+ |])
+ )
+ OrderByRank = true
+ }
+ "TestRRFOrderByRankFunction"
+
+ // LinqTranslationBaselineTests.TestStringFunctions.xml
+ check
+ "SELECT VALUE (root[\"StringField\"] NOT IN (\"one\", \"two\", \"three\")) FROM root"
+ {
+ selectValue (
+ ScalarExpression.In (
+ root |> prop "StringField",
+ true,
+ EquatableArray.unsafeOfArray [| str "one"; str "two"; str "three" |]
+ )
+ ) with
+ From = from "root"
+ }
+ "TestStringFunctions, NOT IN"
+
+ check
+ "SELECT VALUE CONTAINS(root[\"StringField\"], \"Str\", true) FROM root"
+ {
+ selectValue (call "CONTAINS" [| root |> prop "StringField"; str "Str"; boolean true |]) with
+ From = from "root"
+ }
+ "TestStringFunctions, CONTAINS ignoring case"
+
+ check
+ "SELECT VALUE (NOT false) FROM root"
+ {
+ selectValue (ScalarExpression.Unary (UnaryOperator.Not, boolean false)) with
+ From = from "root"
+ }
+ "TestStringFunctions, NOT"
+
+ // LinqTranslationBaselineTests.TestUDFs.xml
+ check
+ "SELECT VALUE udf.MultiParamterUDF(root[\"NumericField\"], root[\"StringField\"], root[\"Point\"]) FROM root"
+ {
+ selectValue (
+ udf "MultiParamterUDF" [| root |> prop "NumericField"; root |> prop "StringField"; root |> prop "Point" |]
+ ) with
+ From = from "root"
+ }
+ "TestUDFs"
+
+ // LinqTranslationBaselineTests.TestLiteralSerialization.xml
+ check
+ "SELECT VALUE {\"value\": -9223372036854775808} FROM root"
+ {
+ selectValue (objectOf [| struct ("value", integer Int64.MinValue) |]) with
+ From = from "root"
+ }
+ "TestLiteralSerialization, Int64.MinValue"
+
+ check
+ "SELECT VALUE {\"value\": 1.7976931348623157E+308} FROM root"
+ {
+ selectValue (objectOf [| struct ("value", number Double.MaxValue) |]) with
+ From = from "root"
+ }
+ "TestLiteralSerialization, Double.MaxValue"
+
+ // LinqTranslationBaselineTests.TestConditional.xml
+ check
+ ("""SELECT VALUE root["StringField"] FROM root WHERE (root["NumericField"] = ((root["StringField"] = "str") ? 1 : """
+ + """(ARRAY_CONTAINS(root["ArrayField"], 1) ? 3 : 4)))""")
+ {
+ selectValue (root |> prop "StringField") with
+ From = from "root"
+ Where =
+ ValueSome (
+ binary
+ BinaryOperator.Equal
+ (root |> prop "NumericField")
+ (ScalarExpression.Conditional (
+ binary BinaryOperator.Equal (root |> prop "StringField") (str "str"),
+ integer 1L,
+ ScalarExpression.Conditional (
+ call "ARRAY_CONTAINS" [| root |> prop "ArrayField"; integer 1L |],
+ integer 3L,
+ integer 4L
+ )
+ ))
+ )
+ }
+ "TestConditional"
+
+ // LinqTranslationBaselineTests.TestCoalesce.xml
+ check
+ "SELECT VALUE (root[\"StringField\"] ?? \"str\") FROM root"
+ {
+ selectValue (ScalarExpression.Coalesce (root |> prop "StringField", str "str")) with
+ From = from "root"
+ }
+ "TestCoalesce"
+
+ // LinqGeneralBaselineTests.TestGroupByTranslation.xml; the SDK writes a space after the GROUP BY clause
+ let groupBy = {
+ selectValue nul with
+ Select =
+ SelectSpec.List (
+ EquatableArray.unsafeOfArray [|
+ {
+ Expression = root |> prop "FamilyId"
+ Alias = ValueSome (Identifier "familyId")
+ }
+ {
+ Expression = call "COUNT" [| integer 1L |]
+ Alias = ValueSome (Identifier "familyIdCount")
+ }
+ |]
+ )
+ From = from "root"
+ GroupBy = EquatableArray.singleton (root |> prop "FamilyId")
+ }
+
+ Assert.AreEqual (
+ normalize "SELECT root[\"FamilyId\"] AS familyId, COUNT(1) AS familyIdCount FROM root GROUP BY root[\"FamilyId\"] ",
+ normalize (print groupBy),
+ "TestGroupByTranslation"
+ )
+
+ []
+ member _.``A predicate prints as the filter text of a patch`` () =
+ Assert.AreEqual (
+ "FROM c WHERE (c[\"qty\"] > 0)",
+ Printer.printPredicate
+ PropertyStyle.Brackets
+ (Identifier "c")
+ (binary BinaryOperator.GreaterThan (alias "c" |> prop "qty") (integer 0L)),
+ "The filter predicate should start with FROM and the alias"
+ )
+
+ []
+ member _.``Printing does not depend on the culture of the thread`` () =
+ let query = {
+ selectValue (arrayOf [| number 1337.1337; number -0.5; integer -42L; number 1.5e-7; number Double.NaN |]) with
+ From = from "c"
+ Where = ValueSome (binary BinaryOperator.LessThan (alias "c" |> prop "price") (number 1234567.891))
+ OffsetLimit = ValueSome (struct (SpecValue.Literal 1000L, SpecValue.Literal 2000L))
+ }
+
+ let expected =
+ "SELECT VALUE [1337.1337, -0.5, -42, 1.5E-07, NaN] FROM c WHERE (c[\"price\"] < 1234567.891) OFFSET 1000 LIMIT 2000"
+
+ Assert.AreEqual (
+ expected,
+ withCulture CultureInfo.InvariantCulture (fun () -> Printer.print PropertyStyle.Brackets query),
+ "Invariant culture"
+ )
+
+ Assert.AreEqual (
+ expected,
+ withCulture (CultureInfo.GetCultureInfo "fr-FR") (fun () -> Printer.print PropertyStyle.Brackets query),
+ "fr-FR, whose decimal separator is a comma"
+ )
+
+ // A culture whose every number symbol differs from the invariant one
+ let odd =
+ CultureInfo (
+ "fr-FR",
+ NumberFormat =
+ NumberFormatInfo (
+ NegativeSign = "~",
+ PositiveSign = "#",
+ NumberDecimalSeparator = ",",
+ NumberGroupSeparator = ".",
+ NaNSymbol = "PasUnNombre",
+ PositiveInfinitySymbol = "+inf",
+ NegativeInfinitySymbol = "-inf"
+ )
+ )
+
+ Assert.AreEqual (
+ expected,
+ withCulture odd (fun () -> Printer.print PropertyStyle.Brackets query),
+ "A culture with other number symbols"
+ )
+
+ []
+ member _.``Printing is deterministic for equal trees`` () =
+ let build () = {
+ selectRoot with
+ Where = ValueSome (call "IS_DEFINED" [| alias "root" |> prop "nick" |])
+ OrderBy = EquatableArray.singleton (orderBy (ValueSome SortOrder.Descending) (alias "root" |> prop "name"))
+ }
+
+ Assert.AreEqual (
+ Printer.print PropertyStyle.Brackets (build ()),
+ Printer.print PropertyStyle.Brackets (build ()),
+ "Two equal trees should print the same text"
+ )
diff --git a/tests/Cosmos.Sql.Tests/SyntaxTests.fs b/tests/Cosmos.Sql.Tests/SyntaxTests.fs
new file mode 100644
index 00000000..369c3eef
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/SyntaxTests.fs
@@ -0,0 +1,43 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System.Collections.Generic
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+open FSharp.Azure.Cosmos.Sql.Tests.Ast
+
+///
+/// The structural equality of the syntax tree, which its nodes get from holding their children in
+/// .
+///
+[]
+type SyntaxTests () =
+
+ []
+ member _.``Separately built syntax trees with equal children are equal and hash alike`` () =
+ let build name =
+ selectValue (
+ objectOf [|
+ struct ("defined", call "IS_DEFINED" [| prop name (alias "c") |])
+ struct ("tags", arrayOf [| str "a" |])
+ |]
+ )
+
+ let first = build "name"
+ let second = build "name"
+
+ Assert.AreEqual (first, second, "Two separately built syntax trees with equal children should be equal.")
+ Assert.IsTrue (EqualityComparer.Default.Equals(first, second), "The default comparer should agree.")
+ Assert.AreEqual (first.GetHashCode (), second.GetHashCode (), "Equal syntax trees should have equal hash codes.")
+ Assert.AreNotEqual (build "nick", first, "Syntax trees that differ in a child should not be equal.")
+
+ []
+ member _.``Syntax trees can be dictionary keys`` () =
+ let lookup = Dictionary()
+ lookup[selectValue (arrayOf [| integer 1L; integer 2L |])] <- "cached"
+
+ Assert.AreEqual (
+ "cached",
+ lookup[selectValue (arrayOf [| integer 1L; integer 2L |])],
+ "A structurally equal key should find the entry, as the plan cache needs."
+ )
diff --git a/tests/Cosmos.Sql.Tests/TestCategories.fs b/tests/Cosmos.Sql.Tests/TestCategories.fs
new file mode 100644
index 00000000..11053316
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/TestCategories.fs
@@ -0,0 +1,55 @@
+// The categories of the FSharp.Azure.Cosmos.Sql tests. Every test here is a fast, emulator-free unit test, so every
+// category also carries "Unit", which --filter TestCategory=Unit selects. The attributes are sorted by name.
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System.Collections.Generic
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+///
+/// Categorizes a unit test of the function catalog: .
+///
+type CatalogUnitTestCategoryAttribute () =
+ inherit TestCategoryBaseAttribute ()
+
+ ///
+ override _.TestCategories = [| "Unit"; "Catalog" |] :> IList
+
+///
+/// Categorizes a unit test of the collection types that the syntax tree is built from:
+/// ,
+/// and
+/// .
+///
+type CollectionsUnitTestCategoryAttribute () =
+ inherit TestCategoryBaseAttribute ()
+
+ ///
+ override _.TestCategories = [| "Unit"; "Collections" |] :> IList
+
+///
+/// Categorizes a unit test of the printer and its inline mode:
+/// and .
+///
+type PrinterUnitTestCategoryAttribute () =
+ inherit TestCategoryBaseAttribute ()
+
+ ///
+ override _.TestCategories = [| "Unit"; "Printer" |] :> IList
+
+///
+/// Categorizes a unit test of the syntax tree: .
+///
+type SyntaxTreeUnitTestCategoryAttribute () =
+ inherit TestCategoryBaseAttribute ()
+
+ ///
+ override _.TestCategories = [| "Unit"; "SyntaxTree" |] :> IList
+
+///
+/// Categorizes a unit test of the validator: