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