diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 46978db5..2d6befe1 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,6 +40,7 @@
├── 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
├── 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/FSharp.Azure.Cosmos.slnf b/FSharp.Azure.Cosmos.slnf
index 1a0488cc..c7e70fc3 100644
--- a/FSharp.Azure.Cosmos.slnf
+++ b/FSharp.Azure.Cosmos.slnf
@@ -2,7 +2,9 @@
"solution": {
"path": "FSharp.Azure.Cosmos.slnx",
"projects": [
+ "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..d33314c7 100644
--- a/FSharp.Azure.Cosmos.slnx
+++ b/FSharp.Azure.Cosmos.slnx
@@ -8,9 +8,12 @@
+
+
+
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/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: .
+///
+type ValidatorUnitTestCategoryAttribute () =
+ inherit TestCategoryBaseAttribute ()
+
+ ///
+ override _.TestCategories = [| "Unit"; "Validator" |] :> IList
diff --git a/tests/Cosmos.Sql.Tests/ValidatorTests.fs b/tests/Cosmos.Sql.Tests/ValidatorTests.fs
new file mode 100644
index 00000000..853155c0
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/ValidatorTests.fs
@@ -0,0 +1,387 @@
+namespace FSharp.Azure.Cosmos.Sql.Tests
+
+open System
+open System.Collections.Immutable
+open Microsoft.VisualStudio.TestTools.UnitTesting
+
+open FSharp.Azure.Cosmos.Sql
+open FSharp.Azure.Cosmos.Sql.Tests.Ast
+
+///
+/// Accept and reject cases of ,
+/// one rule at a time.
+///
+[]
+type ValidatorTests () =
+
+ static let c = alias "c"
+ static let selectStar = { selectValue nul with Select = SelectSpec.Star }
+
+ static let codes (errors : ImmutableArray) = errors |> Seq.map _.Code |> Seq.toArray
+
+ /// Asserts that the query is valid.
+ static let accepts (query : SqlQuery) (case : string) =
+ let errors = SqlQuery.validate query
+ Assert.IsEmpty (errors, $"{case} should be valid; the errors are listed in the failure.")
+
+ /// Asserts that the query breaks exactly the given rules, in order.
+ static let rejects (expected : ValidationErrorCode array) (query : SqlQuery) (case : string) =
+ CollectionAssert.AreEqual (expected, codes (SqlQuery.validate query), $"{case} should break exactly these rules.")
+
+ static let where (predicate : ScalarExpression) = { selectValue c with From = from "c"; Where = ValueSome predicate }
+
+ static let orderedBy (rank : bool) (items : OrderByItem array) = {
+ selectValue c with
+ From = from "c"
+ OrderBy = EquatableArray.unsafeOfArray items
+ OrderByRank = rank
+ }
+
+ []
+ member _.``A typical filtered, sorted and paged query is valid`` () =
+ accepts
+ {
+ selectValue (objectOf [| "name", c |> prop "name"; "nick", c |> prop "nick" |]) with
+ Top = ValueSome (SpecValue.Parameter (ParameterName "@take"))
+ From = from "c"
+ Where =
+ ValueSome (
+ binary
+ BinaryOperator.And
+ (binary BinaryOperator.GreaterThanOrEqual (c |> prop "age") (param "@minAge"))
+ (binary
+ BinaryOperator.And
+ (call "IS_DEFINED" [| c |> prop "nick" |])
+ (call "ARRAY_CONTAINS" [| c |> prop "tags"; param "@p0" |]))
+ )
+ OrderBy = EquatableArray.singleton (orderBy ValueNone (c |> prop "name"))
+ }
+ "The query of the API sketch"
+
+ accepts
+ {
+ selectValue c with
+ From = from "c"
+ OffsetLimit =
+ ValueSome (
+ struct (SpecValue.Parameter (ParameterName "@skip"), SpecValue.Parameter (ParameterName "@take"))
+ )
+ }
+ "OFFSET and LIMIT parameters outside ORDER BY RANK"
+
+ []
+ member _.``SELECT * needs exactly one collection`` () =
+ accepts { selectStar with From = from "c" } "SELECT * FROM c"
+
+ accepts
+ {
+ selectStar with
+ From =
+ ValueSome (
+ CollectionExpression.ArrayIterator (Identifier "t", Collection.InputPath (Identifier "c", ValueNone))
+ )
+ }
+ "SELECT * FROM t IN c"
+
+ rejects [| ValidationErrorCode.SelectStarWithoutSingleAlias |] selectStar "SELECT * without FROM"
+
+ rejects
+ [| ValidationErrorCode.SelectStarWithoutSingleAlias |]
+ {
+ selectStar with
+ From =
+ ValueSome (
+ CollectionExpression.Join (
+ CollectionExpression.Aliased (Collection.InputPath (Identifier "c", ValueNone), ValueNone),
+ CollectionExpression.ArrayIterator (
+ Identifier "t",
+ Collection.InputPath (Identifier "c", ValueSome (PathExpression.String (ValueNone, "tags")))
+ )
+ )
+ )
+ }
+ "SELECT * over a JOIN"
+
+ []
+ member _.``TOP is accepted in the outer query and refused in every subquery`` () =
+ let topQuery = { selectValue c with Top = ValueSome (SpecValue.Literal 1L); From = from "c" }
+
+ accepts topQuery "TOP in the outer query"
+
+ rejects [| ValidationErrorCode.TopInSubquery |] (where (ScalarExpression.Exists topQuery)) "TOP in an EXISTS subquery"
+
+ rejects
+ [| ValidationErrorCode.TopInSubquery |]
+ {
+ selectValue (alias "x") with
+ From = ValueSome (CollectionExpression.Aliased (Collection.Subquery topQuery, ValueSome (Identifier "x")))
+ }
+ "TOP in a FROM subquery"
+
+ rejects [| ValidationErrorCode.TopInSubquery |] (selectValue (ScalarExpression.Array topQuery)) "TOP in an ARRAY subquery"
+
+ []
+ member _.``Parameters cannot choose an ORDER BY or GROUP BY key`` () =
+ rejects
+ [| ValidationErrorCode.OrderByItemNotPath; ValidationErrorCode.ParameterInOrderBy |]
+ (orderedBy false [| orderBy ValueNone (param "@sort") |])
+ "A parameter as ORDER BY item"
+
+ rejects
+ [| ValidationErrorCode.OrderByItemNotPath; ValidationErrorCode.ParameterInOrderBy |]
+ (orderedBy false [| orderBy ValueNone (ScalarExpression.MemberIndexer (c, param "@property")) |])
+ "A parameter as property name of an ORDER BY item"
+
+ rejects
+ [| ValidationErrorCode.ParameterInGroupBy |]
+ {
+ selectValue (call "COUNT" [| integer 1L |]) with
+ From = from "c"
+ GroupBy = EquatableArray.singleton (binary BinaryOperator.Add (c |> prop "a") (param "@p"))
+ }
+ "A parameter in a GROUP BY key"
+
+ []
+ member _.``Parameters are accepted as arguments of ORDER BY functions and as paging counts`` () =
+ // The parameterized vector search of the Learn page "Tips for optimizing vector indexing and search performance"
+ // and of the SDK's QueryPlanBaselineTests.VectorSearch.xml
+ accepts
+ (orderedBy false [| orderBy ValueNone (call "VectorDistance" [| c |> prop "embedding"; param "@embedding" |]) |])
+ "A parameter as the query vector of VectorDistance in ORDER BY"
+
+ // The parameterized hybrid search of the Learn page "Hybrid search"
+ let rank =
+ orderedBy true [|
+ orderBy
+ ValueNone
+ (call "RRF" [|
+ call "VectorDistance" [| c |> prop "vector"; param "@queryVector" |]
+ call "FullTextScore" [| c |> prop "content"; param "@searchTerm1"; param "@searchTerm2" |]
+ |])
+ |]
+
+ accepts { rank with Top = ValueSome (SpecValue.Parameter (ParameterName "@k")) } "Parameters in ORDER BY RANK"
+
+ accepts
+ {
+ rank with
+ OffsetLimit =
+ ValueSome (
+ struct (SpecValue.Parameter (ParameterName "@skip"), SpecValue.Parameter (ParameterName "@take"))
+ )
+ }
+ "Parameter paging of ORDER BY RANK"
+
+ []
+ member _.``Parameter names are an at sign and an identifier`` () =
+ accepts (where (binary BinaryOperator.Equal (c |> prop "a") (param "@p_0"))) "@p_0"
+
+ for name in [ "p"; "@"; "@1p"; "@p-q"; "" ] do
+ rejects
+ [| ValidationErrorCode.InvalidParameterName |]
+ (where (binary BinaryOperator.Equal (c |> prop "a") (param name)))
+ $"The parameter name '%s{name}'"
+
+ []
+ member _.``ORDER BY items are property paths or functions allowed in ORDER BY`` () =
+ accepts (orderedBy false [| orderBy ValueNone (c |> prop "name") |]) "A property path"
+ accepts
+ (orderedBy false [| orderBy (ValueSome SortOrder.Descending) (c |> prop "items" |> at 0L |> prop "price") |])
+ "A nested path with an index"
+
+ accepts
+ (orderedBy false [|
+ orderBy ValueNone (call "VectorDistance" [| c |> prop "vector"; arrayOf [| number 1.0; number 2.0 |] |])
+ |])
+ "VectorDistance, which is allowed in ORDER BY"
+
+ rejects
+ [| ValidationErrorCode.OrderByItemNotPath |]
+ (orderedBy false [| orderBy ValueNone (binary BinaryOperator.Add (c |> prop "a") (integer 1L)) |])
+ "A computed ORDER BY item"
+
+ rejects
+ [| ValidationErrorCode.OrderByItemNotPath |]
+ (orderedBy false [| orderBy ValueNone (call "LOWER" [| c |> prop "name" |]) |])
+ "A function not allowed in ORDER BY"
+
+ []
+ member _.``ORDER BY RANK items are scoring functions, which appear nowhere else`` () =
+ let fullTextScore = call "FullTextScore" [| c |> prop "text"; str "keyword" |]
+ let vectorDistance = call "VectorDistance" [| c |> prop "vector"; arrayOf [| number 1.0 |] |]
+
+ accepts (orderedBy true [| orderBy ValueNone fullTextScore |]) "FullTextScore"
+ accepts
+ (orderedBy true [| orderBy ValueNone (call "RRF" [| fullTextScore; vectorDistance |]) |])
+ "RRF over scoring functions"
+ accepts (orderedBy true [| orderBy (ValueSome SortOrder.Descending) vectorDistance |]) "VectorDistance"
+
+ rejects
+ [| ValidationErrorCode.RankItemNotScoringFunction |]
+ (orderedBy true [| orderBy ValueNone (c |> prop "name") |])
+ "A property path in ORDER BY RANK"
+
+ rejects
+ [| ValidationErrorCode.OrderByItemNotPath; ValidationErrorCode.ScoringFunctionOutsideRank |]
+ (orderedBy false [| orderBy ValueNone fullTextScore |])
+ "FullTextScore in a plain ORDER BY"
+
+ rejects [| ValidationErrorCode.ScoringFunctionOutsideRank |] (selectValue fullTextScore) "FullTextScore in a projection"
+
+ rejects
+ [| ValidationErrorCode.ScoringFunctionOutsideRank; ValidationErrorCode.ScoringFunctionOutsideRank |]
+ (where (binary BinaryOperator.GreaterThan (call "RRF" [| fullTextScore; vectorDistance |]) (integer 0L)))
+ "RRF and the FullTextScore in it in a filter"
+
+ []
+ member _.``Identifiers must match the identifier rule and not be reserved`` () =
+
+ rejects
+ [| ValidationErrorCode.ReservedIdentifier; ValidationErrorCode.ReservedIdentifier |]
+ {
+ selectValue (alias "value") with
+ From =
+ ValueSome (CollectionExpression.Aliased (Collection.InputPath (Identifier "value", ValueNone), ValueNone))
+ }
+ "The reserved word value as reference and input name"
+
+ rejects
+ [| ValidationErrorCode.InvalidIdentifier |]
+ {
+ selectValue c with
+ From =
+ ValueSome (
+ CollectionExpression.Aliased (
+ Collection.InputPath (Identifier "c", ValueNone),
+ ValueSome (Identifier "some alias")
+ )
+ )
+ }
+ "An alias with a space"
+
+ rejects
+ [| ValidationErrorCode.ReservedIdentifier |]
+ {
+ selectStar with
+ Select =
+ SelectSpec.List (
+ EquatableArray.singleton { Expression = c |> prop "a"; Alias = ValueSome (Identifier "Select") }
+ )
+ From = from "c"
+ }
+ "A select alias that is a keyword in another letter case"
+
+ rejects
+ [| ValidationErrorCode.InvalidIdentifier |]
+ {
+ selectValue c with
+ From =
+ ValueSome (
+ CollectionExpression.Aliased (
+ Collection.InputPath (
+ Identifier "c",
+ ValueSome (PathExpression.Identifier (ValueNone, Identifier "1st"))
+ ),
+ ValueNone
+ )
+ )
+ }
+ "A path segment that starts with a digit"
+
+ accepts
+ {
+ selectValue (alias "_root1") with
+ From =
+ ValueSome (
+ CollectionExpression.Aliased (Collection.InputPath (Identifier "_root1", ValueNone), ValueNone)
+ )
+ }
+ "An identifier with an underscore and digits"
+
+ []
+ member _.``IN lists and select lists need an element`` () =
+ accepts (where (ScalarExpression.In (c |> prop "a", false, EquatableArray.singleton (integer 1L)))) "IN with one element"
+
+ rejects
+ [| ValidationErrorCode.EmptyInList |]
+ (where (ScalarExpression.In (c |> prop "a", true, EquatableArray.empty)))
+ "NOT IN with no element"
+
+ rejects
+ [| ValidationErrorCode.EmptySelectList |]
+ {
+ selectStar with
+ Select = SelectSpec.List EquatableArray.empty
+ From = from "c"
+ }
+ "An empty select list"
+
+ []
+ member _.``Built-in functions must be known and called with an accepted arity`` () =
+ accepts (where (call "is_defined" [| c |> prop "a" |])) "A known function in another letter case"
+ accepts (where (call "ABS" [| c |> prop "a" |])) "A known function the catalog does not specify yet"
+ accepts (selectValue (call "LEFT" [| str "abc"; integer 1L |])) "LEFT, a keyword the grammar accepts as function name"
+ accepts (where (udf "isVip" [| c |])) "A user-defined function"
+
+ rejects [| ValidationErrorCode.UnknownFunction |] (where (call "IS_VIP" [| c |])) "An unknown built-in function"
+ rejects [| ValidationErrorCode.FunctionArity |] (where (call "IS_DEFINED" [||])) "IS_DEFINED without an argument"
+
+ rejects
+ [| ValidationErrorCode.FunctionArity |]
+ (where (call "CONTAINS" [| c |> prop "a"; str "b"; boolean true; boolean false |]))
+ "CONTAINS with four arguments"
+
+ rejects [| ValidationErrorCode.InvalidFunctionName |] (selectValue (call "LIKE" [| str "a"; str "b" |])) "LIKE, a keyword"
+ rejects
+ [| ValidationErrorCode.InvalidFunctionName |]
+ (where (udf "select" [| c |]))
+ "A user-defined function named like a keyword"
+ rejects
+ [| ValidationErrorCode.InvalidFunctionName |]
+ (where (udf "is-vip" [| c |]))
+ "A user-defined function name with a hyphen"
+
+ []
+ member _.``Number literals must be finite and integers exact`` () =
+ accepts (selectValue (integer 9007199254740992L)) "2^53"
+ accepts (selectValue (integer -9007199254740992L)) "-2^53"
+ rejects [| ValidationErrorCode.LossyInteger |] (selectValue (integer 9007199254740993L)) "2^53 + 1"
+ rejects [| ValidationErrorCode.LossyInteger |] (selectValue (integer Int64.MinValue)) "Int64.MinValue"
+
+ Assert.IsEmpty (
+ SqlQuery.validateWith { AllowLossyInt64 = true } (selectValue (integer Int64.MaxValue)),
+ "AllowLossyInt64 should accept integers beyond 2^53."
+ )
+
+ rejects [| ValidationErrorCode.NonFiniteNumber |] (selectValue (number Double.NaN)) "NaN"
+ rejects [| ValidationErrorCode.NonFiniteNumber |] (selectValue (number Double.NegativeInfinity)) "-Infinity"
+
+ Assert.IsEmpty (
+ SqlQuery.validate (selectValue (param "@big")),
+ "A parameter is never range checked, whatever value it will get."
+ )
+
+ []
+ member _.``Counts must not be negative`` () =
+ rejects
+ [| ValidationErrorCode.NegativeCount |]
+ { selectValue c with Top = ValueSome (SpecValue.Literal -1L); From = from "c" }
+ "TOP -1"
+
+ rejects
+ [| ValidationErrorCode.NegativeCount; ValidationErrorCode.NegativeCount |]
+ {
+ selectValue c with
+ From = from "c"
+ OffsetLimit = ValueSome (struct (SpecValue.Literal -1L, SpecValue.Literal -2L))
+ }
+ "OFFSET -1 LIMIT -2"
+
+ []
+ member _.``Errors name the rule and quote the fragment`` () =
+ let errors =
+ SqlQuery.validate (orderedBy false [| orderBy ValueNone (binary BinaryOperator.Add (c |> prop "a") (integer 1L)) |])
+
+ let error = Assert.ContainsSingle (errors, "The computed ORDER BY item should be the only error.")
+ Assert.AreEqual (ValidationErrorCode.OrderByItemNotPath, error.Code, "The error should name the rule.")
+ Assert.Contains ("(c[\"a\"] + 1)", error.Message, StringComparison.Ordinal, "The message should quote the item.")
diff --git a/tests/Cosmos.Sql.Tests/testconfig.json b/tests/Cosmos.Sql.Tests/testconfig.json
new file mode 100644
index 00000000..b43d5de5
--- /dev/null
+++ b/tests/Cosmos.Sql.Tests/testconfig.json
@@ -0,0 +1,9 @@
+{
+ "mstest": {
+ "parallelism": {
+ "enabled": true,
+ "scope": "method",
+ "workers": 0
+ }
+ }
+}
\ No newline at end of file