diff --git a/build/docker/Dockerfile b/build/docker/Dockerfile index 693a3619d2..f4616bc684 100644 --- a/build/docker/Dockerfile +++ b/build/docker/Dockerfile @@ -1,6 +1,6 @@ # --platform tells docker to always use the host platform for the build not the target platform. Runtime container will use target platform. # Use .NET 10 SDK to support SQL script generation tool, targeting net10.0 in builds -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.302-azurelinux3.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0.400-azurelinux3.0 AS build ARG TARGETARCH ARG FHIR_VERSION ARG ASSEMBLY_VER @@ -87,7 +87,7 @@ COPY . . RUN dotnet publish /repo/src/Microsoft.Health.Fhir.${FHIR_VERSION}.Web/Microsoft.Health.Fhir.${FHIR_VERSION}.Web.csproj -o "/build" --no-restore -p:AssemblyVersion="${ASSEMBLY_VER}" -p:FileVersion="${ASSEMBLY_VER}" -p:Version="${ASSEMBLY_VER}" -f net10.0 -a $TARGETARCH # Implicitly uses the target platform for the runtime image. -FROM mcr.microsoft.com/dotnet/aspnet:10.0.10-azurelinux3.0 AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-azurelinux3.0 AS runtime ARG FHIR_VERSION diff --git a/docs/arch/adr-2608-sql-search-parser-remake.md b/docs/arch/adr-2608-sql-search-parser-remake.md new file mode 100644 index 0000000000..598d454faa --- /dev/null +++ b/docs/arch/adr-2608-sql-search-parser-remake.md @@ -0,0 +1,55 @@ +# ADR-2608: SQL Search Parser Remake + +**Status**: Proposed +**Date**: 2026-08-21 +**Feature**: SqlSearchParser + +## Context + +The FHIR server's SQL search query generation pipeline relied on an expression tree architecture where incoming search parameters were first parsed into a generic `Expression` tree, then passed through 14+ chained rewriter/visitor passes (compartment rewriting, date equality semantics, flattening, untyped reference resolution, sort rewriting, partition elimination, predicate pushdown, string overflow handling, numeric range rewriting, include seeding, and more). The final `SqlQueryGenerator` visitor then converted the fully-rewritten expression tree into parameterized SQL, optionally cached as a stored procedure via `CustomQueries`. + +This approach had several problems: + +- **Debuggability**: Tracing how a FHIR search URL became a SQL query required stepping through 14+ visitor passes, each mutating the expression tree in non-obvious ways. Intermediate states were opaque and difficult to inspect. +- **Complexity**: Each new search feature (chained searches, reverse chains, SMART scopes, compartments) required adding or modifying rewriter passes that interacted with all other passes, creating a combinatorial explosion of edge cases. +- **Indirection**: The expression tree abstraction was designed to be storage-agnostic, but in practice the SQL Server backend was the only consumer. The abstraction added layers of indirection without practical benefit. +- **Performance tuning**: The generated SQL was constrained by what the visitor pattern could express. Optimizations like sharing expensive reference CTEs across multiple chain parameters were architecturally difficult to implement. + +## Options Considered + +1. **Incremental refactoring of the expression tree pipeline** — Simplify existing rewriters and improve logging *(rejected: the fundamental problem is the multi-pass visitor architecture itself; incremental fixes would not address debuggability or the indirection cost)* + +2. **Direct SQL generation from query parameters** — Bypass the expression tree entirely and generate CTEs directly from the parsed query parameters using type-specific SQL parsers *(viable)* + +3. **Replace expression tree with a SQL-specific IR** — Keep the expression parsing but introduce a SQL-specific intermediate representation before generation *(rejected: still two translation layers when one suffices; the query parameters already carry all needed information)* + +## Decision + +We chose **direct SQL generation from query parameters** (Option 2). The new `SearchParameterSqlParser` in `SqlSearchParser/` takes `QueryParams` (a dictionary of search parameter names to values) directly from `SearchOptionsFactory` and produces a raw SQL query string composed of CTEs. + +The new pipeline flow is: + +``` +HTTP Request + → SearchOptionsFactory (parses URL into QueryParams dictionary) + → SearchParameterSqlParser.ParseMultiple (generates SQL directly) + → CTE-based SQL query string + → SqlConnection.ExecuteReader +``` + +Each search parameter type has a dedicated parser (`DateTimeSqlParser`, `TokenSqlParser`, `ReferenceSqlParser`, `StringSqlParser`, etc.) that knows how to generate the appropriate CTE for its table. Special parsers handle cross-cutting concerns: `ChainedSqlParser` for forward chains, `ReversedChainSqlParser` for reverse chains, `CompartmentSqlParser` for compartment searches, `SmartCompartmentSqlParser` for SMART scopes, and `IncludeSqlParser` for `_include`/`_revinclude`. + +Key architectural features of the new approach: + +- **Chain grouping**: Multiple chain parameters sharing the same reference lookup are grouped via `ChainSearchGroup`, allowing the expensive reference CTE to be generated once and reused. An intersection CTE enforces AND semantics across grouped chains. +- **Linear CTE pipeline**: Each parser appends its CTE to a `SqlQueryBuilder`, with `LastCteName` threading results forward. No multi-pass rewriting needed. +- **Direct SQL control**: Optimizations like sort-aware paging, continuation token handling, and partition elimination are applied inline during generation rather than as separate visitor passes. + +## Consequences + +- **Debuggability is dramatically improved.** A standalone `SqlSearchDebugger` tool (in `tools/`) can show the mapping from FHIR URL to SQL query without connecting to a database. The single-pass generation makes it straightforward to trace how each parameter contributes to the final query. +- **New search features are easier to add.** Adding SMART scope support, for example, required writing one new parser class (`SmartCompartmentSqlParser`) and a few lines in `ParseMultiple`, rather than inserting a new rewriter into a 14-pass chain. +- **Performance optimizations are more natural.** Chain grouping with shared reference CTEs was a direct architectural addition, not a fight against the visitor pattern. +- **The expression tree pipeline is retained but dormant.** The old `CreateDefaultSearchExpression` method and its rewriters remain in the codebase (commented out) as a fallback reference. Some expression-based validation (e.g., SMART scope type checking in `ExpressionAccessControl`) still operates on expressions built by `SearchOptionsFactory`. +- **Storage abstraction is reduced.** The new parser is SQL Server-specific by design. If a second storage backend needed the same search semantics, it would need its own query generator rather than reusing the expression tree. In practice, this trade-off is acceptable since the Cosmos DB backend has its own query pipeline already. +- **The old `SqlQueryGenerator`, all 14+ rewriter classes, and the `CustomQueries` stored procedure cache are no longer exercised.** These can be removed once the new parser is validated in production. diff --git a/global.json b/global.json index 764f391fe5..b8e07d7ce0 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.302" + "version": "10.0.400" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/nuget.config b/nuget.config index f40187090d..fff45cfde7 100644 --- a/nuget.config +++ b/nuget.config @@ -4,7 +4,7 @@ - + diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionManager.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionManager.cs index 4f82219b34..f16bb79a76 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionManager.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionManager.cs @@ -386,6 +386,11 @@ private async Task LoadSearchParamsFromDataStore(CancellationToken cancellationT searchOptions.UnsupportedSearchParams = new List>(); searchOptions.Expression = Expression.SearchParameter(SearchParameterInfo.ResourceTypeSearchParameter, Expression.StringEquals(FieldName.TokenCode, null, KnownResourceTypes.SearchParameter, false)); searchOptions.MaxItemCount = 10; + + searchOptions.QueryParams = new Dictionary>(); + searchOptions.QueryParams.Add("_count", new List { "10" }); + searchOptions.QueryParams.Add("_type", new List { KnownResourceTypes.SearchParameter }); + searchOptions.ResourceVersionTypes = ResourceVersionType.Latest; if (continuationToken != null) { diff --git a/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs b/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs index 1279f2ed64..4de278aeab 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs @@ -148,5 +148,7 @@ public static class KnownQueryParameterNames public const string ReturnDetails = "_details"; public const string MetaHistory = "_meta-history"; + + public const string ReverseChain = "_has"; } } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/SearchOptions.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/SearchOptions.cs index 06f170e66b..8f95800623 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/SearchOptions.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/SearchOptions.cs @@ -57,6 +57,7 @@ internal SearchOptions(SearchOptions other) IsAsyncOperation = other.IsAsyncOperation; SkipAppendIntersectionWithPredecessor = other.SkipAppendIntersectionWithPredecessor; ContainsIterativeInclude = other.ContainsIterativeInclude; + QueryParams = new Dictionary>(other.QueryParams); } /// @@ -145,7 +146,7 @@ internal set /// /// Gets the collection of search parameters used for filtering and querying resources. /// - public IReadOnlyList SearchParameters { get; internal set; } = new List(); + public IList SearchParameters { get; internal set; } = new List(); /// /// Gets the list of search parameters that were not used in the search. @@ -187,6 +188,10 @@ internal set /// public bool SkipAppendIntersectionWithPredecessor { get; set; } +#pragma warning disable CA2227 // Collection properties should be read only + public IDictionary> QueryParams { get; set; } +#pragma warning restore CA2227 // Collection properties should be read only + /// /// Gets or sets a value indicating whether the search contains iterative includes. /// diff --git a/src/Microsoft.Health.Fhir.Core/Properties/AssemblyInfo.cs b/src/Microsoft.Health.Fhir.Core/Properties/AssemblyInfo.cs index 115d142d47..6d2c3ad1f3 100644 --- a/src/Microsoft.Health.Fhir.Core/Properties/AssemblyInfo.cs +++ b/src/Microsoft.Health.Fhir.Core/Properties/AssemblyInfo.cs @@ -52,3 +52,4 @@ [assembly: InternalsVisibleTo("Microsoft.Health.Fhir.R4.ResourceParser")] [assembly: InternalsVisibleTo("Microsoft.Health.Fhir.SqlServer.UnitTests")] +[assembly: InternalsVisibleTo("SqlSearchDebugger")] diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Operations/MemberMatch/MemberMatchService.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Operations/MemberMatch/MemberMatchService.cs index e7a0a41f13..ca25565560 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Operations/MemberMatch/MemberMatchService.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Operations/MemberMatch/MemberMatchService.cs @@ -66,7 +66,7 @@ public async Task FindMatch(ResourceElement coverage, ResourceE searchOptions.MaxItemCount = 2; searchOptions.Sort = new List<(SearchParameterInfo, SortOrder)>(); searchOptions.UnsupportedSearchParams = new List>(); - searchOptions.Expression = CreateSearchExpression(coverage, patient); + searchOptions.QueryParams = BuildQueryParams(coverage, patient); SearchResult results = null; try @@ -94,43 +94,15 @@ public async Task FindMatch(ResourceElement coverage, ResourceE return CreatePatientWithIdentity(patient, results); } - private ResourceElement CreatePatientWithIdentity(ResourceElement patient, SearchResult results) + private Dictionary> BuildQueryParams(ResourceElement coverage, ResourceElement patient) { - var searchMatchOnly = results.Results.Where(x => x.SearchEntryMode == ValueSets.SearchEntryMode.Match).ToList(); - if (searchMatchOnly.Count > 1) - { - throw new MemberMatchMatchingException(Core.Resources.MemberMatchMultipleMatchesFound); - } + var queryParams = new Dictionary>(); - if (searchMatchOnly.Count == 0) - { - throw new MemberMatchMatchingException(Core.Resources.MemberMatchNoMatchFound); - } + // Resource type filter - search for Patient resources + queryParams["_type"] = new List { KnownResourceTypes.Patient }; - var match = searchMatchOnly[0]; - var element = _resourceDeserializer.Deserialize(match.Resource); - var foundPatient = element.ToPoco(); - var id = foundPatient.Identifier.Where(x => x.Type != null && x.Type.Coding != null && x.Type.Coding.Exists(x => x.Code == "MB")).FirstOrDefault(); - if (id == null) - { - throw new MemberMatchMatchingException(Core.Resources.MemberMatchNoMatchFound); - } - - var resultPatient = patient.ToPoco(); - var resultId = new Identifier(id.System, id.Value); - resultId.Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "UMB", "Member Match"); - resultPatient.Identifier.Add(resultId); - var result = resultPatient.ToResourceElement(); - return result; - } - - private MultiaryExpression CreateSearchExpression(ResourceElement coverage, ResourceElement patient) - { - IReadOnlyCollection coverageValues = _searchIndexer.Extract(coverage); + // Add patient search parameters IReadOnlyCollection patientValues = _searchIndexer.Extract(patient); - var expressions = new List(); - var reverseChainExpressions = new List(); - expressions.Add(Expression.SearchParameter(_resourceTypeSearchParameter, Expression.StringEquals(FieldName.TokenCode, null, KnownResourceTypes.Patient, false))); foreach (SearchIndexEntry patientValue in patientValues) { if (IgnoreInSearch(patientValue)) @@ -138,15 +110,25 @@ private MultiaryExpression CreateSearchExpression(ResourceElement coverage, Reso continue; } - var modifier = string.Empty; + var paramName = patientValue.SearchParameter.Code; if (patientValue.SearchParameter.Type == ValueSets.SearchParamType.String) { - modifier = ":exact"; + paramName += ":exact"; } - expressions.Add(_expressionParser.Parse(new[] { KnownResourceTypes.Patient }, patientValue.SearchParameter.Code + modifier, patientValue.Value.ToString())); + var value = patientValue.Value.ToString(); + if (queryParams.TryGetValue(paramName, out var existingValues)) + { + existingValues.Add(value); + } + else + { + queryParams[paramName] = new List { value }; + } } + // Add coverage search parameters as reverse chain (_has:Coverage:beneficiary:=) + IReadOnlyCollection coverageValues = _searchIndexer.Extract(coverage); foreach (var coverageValue in coverageValues) { if (IgnoreInSearch(coverageValue)) @@ -160,26 +142,49 @@ private MultiaryExpression CreateSearchExpression(ResourceElement coverage, Reso modifier = ":exact"; } - reverseChainExpressions.Add(_expressionParser.Parse(new[] { KnownResourceTypes.Coverage }, coverageValue.SearchParameter.Code + modifier, coverageValue.Value.ToString())); - } - - if (reverseChainExpressions.Count != 0) - { - Expression reverseChainedExpression; - if (reverseChainExpressions.Count == 1) + var hasKey = $"_has:Coverage:beneficiary:{coverageValue.SearchParameter.Code}{modifier}"; + var value = coverageValue.Value.ToString(); + if (queryParams.TryGetValue(hasKey, out var existingValues)) { - reverseChainedExpression = reverseChainExpressions[0]; + existingValues.Add(value); } else { - reverseChainedExpression = Expression.And(reverseChainExpressions); + queryParams[hasKey] = new List { value }; } + } + + return queryParams; + } + + private ResourceElement CreatePatientWithIdentity(ResourceElement patient, SearchResult results) + { + var searchMatchOnly = results.Results.Where(x => x.SearchEntryMode == ValueSets.SearchEntryMode.Match).ToList(); + if (searchMatchOnly.Count > 1) + { + throw new MemberMatchMatchingException(Core.Resources.MemberMatchMultipleMatchesFound); + } + + if (searchMatchOnly.Count == 0) + { + throw new MemberMatchMatchingException(Core.Resources.MemberMatchNoMatchFound); + } - ChainedExpression expression = Expression.Chained(new[] { KnownResourceTypes.Coverage }, _coverageBeneficiaryParameter, new[] { KnownResourceTypes.Patient }, true, reverseChainedExpression); - expressions.Add(expression); + var match = searchMatchOnly[0]; + var element = _resourceDeserializer.Deserialize(match.Resource); + var foundPatient = element.ToPoco(); + var id = foundPatient.Identifier.Where(x => x.Type != null && x.Type.Coding != null && x.Type.Coding.Exists(x => x.Code == "MB")).FirstOrDefault(); + if (id == null) + { + throw new MemberMatchMatchingException(Core.Resources.MemberMatchNoMatchFound); } - return Expression.And(expressions); + var resultPatient = patient.ToPoco(); + var resultId = new Identifier(id.System, id.Value); + resultId.Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "UMB", "Member Match"); + resultPatient.Identifier.Add(resultId); + var result = resultPatient.ToResourceElement(); + return result; } private static bool IgnoreInSearch(SearchIndexEntry searchEntry) => diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs index d35ca615fe..09907ddd3b 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchOptionsFactory.cs @@ -130,10 +130,24 @@ public SearchOptions Create( bool setDefaultBundleTotal = true; var notReferencedSearches = new List(); + searchOptions.QueryParams = new Dictionary>(); + // Extract the continuation token, filter out the other known query parameters that's not search related. // Exclude time travel parameters from evaluation to avoid warnings about unsupported parameters foreach (Tuple query in queryParameters?.Where(_ => !_queryHintParameterNames.Contains(_.Item1)) ?? Enumerable.Empty>()) { + if (!string.IsNullOrEmpty(query.Item1)) + { + if (searchOptions.QueryParams.TryGetValue(query.Item1, out var values)) + { + values.Add(query.Item2); + } + else + { + searchOptions.QueryParams[query.Item1] = new List() { query.Item2 }; + } + } + if (query.Item1 == KnownQueryParameterNames.ContinuationToken) { // This is an unreachable case. The mapping of the query parameters makes it so only one continuation token can exist. @@ -391,6 +405,14 @@ public SearchOptions Create( var resourceTypesString = parsedResourceTypes.Select(x => x.ToString()).ToArray(); + var singleResourceTypesString = string.Join(",", resourceTypesString); + searchOptions.QueryParams.Remove("_type"); + + if (!singleResourceTypesString.Equals(KnownResourceTypes.DomainResource, StringComparison.OrdinalIgnoreCase)) + { + searchOptions.QueryParams.Add("_type", new List { singleResourceTypesString }); + } + // Form all the include revinclude expressions before for the Smart queries access control check // Collect all the resource types required by the include/revinclude expressions var includeRevincludeSearchExpressions = new List(); @@ -398,6 +420,13 @@ public SearchOptions Create( includeRevincludeSearchExpressions.AddRange(ParseIncludeIterateExpressions(searchParams.RevInclude, resourceTypesString, true).Where(e => e != null)); var requiredResourceTypes = includeRevincludeSearchExpressions.SelectMany(x => x.Produces).ToList(); + var invalidRevIncludeParameters = searchParams.RevInclude.Where(x => x.Item2 != IncludeModifier.None && x.Item1.Contains('*', StringComparison.OrdinalIgnoreCase)); + foreach (var invalidRevInclude in invalidRevIncludeParameters) + { + var paramName = KnownQueryParameterNames.ReverseInclude + (invalidRevInclude.Item2 != IncludeModifier.None ? ":" + invalidRevInclude.Item2.ToString().ToLowerInvariant() : string.Empty); + searchOptions.QueryParams[paramName].Remove(invalidRevInclude.Item1); + } + // Add the parsed resource types to the required resource types for access control check // Now it contains all the resource types that are requested by the search, // including those from the search path, _type parameter, and resource types returned via include/revinclude expressions @@ -405,6 +434,25 @@ public SearchOptions Create( CheckFineGrainedAccessControl(searchExpressions, searchParams, requiredResourceTypes); + // Add fine-grained access control resource type restrictions to QueryParams for the SQL parser + if (_contextAccessor.RequestContext?.AccessControlContext?.ApplyFineGrainedAccessControl == true) + { + var allowedActions = _contextAccessor.RequestContext?.AccessControlContext?.AllowedResourceActions; + if (allowedActions != null && !allowedActions.Any(a => a.Resource == KnownResourceTypes.All)) + { + var allowedTypes = allowedActions.Select(a => a.Resource).Distinct().ToList(); + if (allowedTypes.Any()) + { + searchOptions.QueryParams["_fhirScopeAllowedTypes"] = allowedTypes; + } + else + { + // No resource types allowed — block all queries + searchOptions.QueryParams["_fhirScopeAllowedTypes"] = new List { "none" }; + } + } + } + var validSearchParameters = new List(); // Deduplicate exact (name, value) query parameter pairs before parsing. Repeated identical parameters produce @@ -430,6 +478,7 @@ public SearchOptions Create( catch (SearchParameterNotSupportedException) { unsupportedSearchParameters.Add(q); + searchOptions.QueryParams.Remove(q.Item1); return null; } @@ -468,6 +517,10 @@ public SearchOptions Create( { searchExpressions.Add(Expression.CompartmentSearch(compartmentType, compartmentId, resourceTypesString)); } + + // Add compartment info to QueryParams so the SQL parser can generate compartment joins + searchOptions.QueryParams["_compartmentType"] = new List { compartmentType }; + searchOptions.QueryParams["_compartmentId"] = new List { compartmentId }; } else { @@ -493,6 +546,10 @@ public SearchOptions Create( { searchExpressions.Add(Expression.SmartCompartmentSearch(smartCompartmentType, smartCompartmentId, resourceTypesString)); } + + // Add SMART compartment info to QueryParams so the SQL parser can generate SMART compartment joins + searchOptions.QueryParams["_smartCompartmentType"] = new List { smartCompartmentType }; + searchOptions.QueryParams["_smartCompartmentId"] = new List { smartCompartmentId }; } else { @@ -595,6 +652,11 @@ public SearchOptions Create( var allErrors = new List(); foreach (Tuple unsupported in unsupportedSearchParameters) { + if (!string.IsNullOrEmpty(unsupported.Item1)) + { + searchOptions.QueryParams.Remove(unsupported.Item1); + } + allErrors.Add(string.Format(CultureInfo.InvariantCulture, Core.Resources.SearchParameterNotSupported, unsupported.Item1, string.Join(",", resourceTypesString))); } @@ -824,7 +886,7 @@ private void CheckFineGrainedAccessControl(List searchExpressions, S foreach (var param in restriction.SearchParameters.Parameters) { - searchParams.Add(param.Item1, param.Item2); + searchParams.Add(param.Item1, param.Item2); } } diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Validation/ServerProvideProfileValidation.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Validation/ServerProvideProfileValidation.cs index 103e2bc5eb..febace834d 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Features/Validation/ServerProvideProfileValidation.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Validation/ServerProvideProfileValidation.cs @@ -241,7 +241,11 @@ private async Task> GetSummariesAsync(CancellationToken ca { do { - var queryParameters = new List>(); + var queryParameters = new List>() + { + new Tuple(KnownQueryParameterNames.Type, type), + }; + if (ct != null) { ct = ContinuationTokenEncoder.Encode(ct); diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ChainFlatteningRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ChainFlatteningRewriterTests.cs deleted file mode 100644 index 833657dab7..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ChainFlatteningRewriterTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - /// - /// Unit tests for ChainFlatteningRewriter. - /// These tests verify the rewriter's behavior using non-chain expressions to avoid ModelInfoProvider dependencies. - /// The ChainFlatteningRewriter is more comprehensively tested through integration tests where - /// the full FHIR stack (including ModelInfoProvider) is initialized. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ChainFlatteningRewriterTests - { - private readonly ChainFlatteningRewriter _rewriter; - - public ChainFlatteningRewriterTests() - { - var searchParamTypeMap = new SearchParameterToSearchValueTypeMap(); - var queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(searchParamTypeMap); - _rewriter = new ChainFlatteningRewriter(queryGeneratorFactory); - } - - [Fact] - public void GivenASqlRootExpressionWithoutChainExpressions_WhenVisited_ThenSameExpressionIsReturned() - { - // Arrange - Expression with no chain expressions - var normalExpression = Expression.Equals(FieldName.TokenCode, null, "code123"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, normalExpression, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }; - - var sqlRootExpression = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - Should return same expression since there are no chains - Assert.Same(sqlRootExpression, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenEmptySqlRootExpression_WhenVisited_ThenSameExpressionIsReturned() - { - // Arrange - var tableExpressions = new List(); - var sqlRootExpression = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - Empty list means no modifications needed - Assert.Same(sqlRootExpression, result); - } - - [Fact] - public void GivenSqlRootWithMultipleNonChainExpressions_WhenVisited_ThenAllArePreserved() - { - // Arrange - var expr1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var expr2 = Expression.Equals(FieldName.String, null, "value2"); - var expr3 = Expression.GreaterThan(FieldName.Number, null, 10); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, expr1, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, expr2, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, expr3, SearchParamTableExpressionKind.Normal), - }; - - var sqlRootExpression = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - All expressions should be preserved as-is - Assert.Same(sqlRootExpression, result); - Assert.Equal(3, result.SearchParamTableExpressions.Count); - Assert.Same(expr1, result.SearchParamTableExpressions[0].Predicate); - Assert.Same(expr2, result.SearchParamTableExpressions[1].Predicate); - Assert.Same(expr3, result.SearchParamTableExpressions[2].Predicate); - } - - [Fact] - public void GivenSqlRootWithTopExpression_WhenVisited_ThenTopIsPreserved() - { - // Arrange - var normalExpr = Expression.Equals(FieldName.TokenCode, null, "test"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, normalExpr, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }; - - var sqlRootExpression = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - Top expression should be unchanged - Assert.Same(sqlRootExpression, result); - Assert.Equal(SearchParamTableExpressionKind.Top, result.SearchParamTableExpressions[1].Kind); - } - - [Fact] - public void GivenSqlRootWithResourceTableExpressions_WhenVisited_ThenResourceTableExpressionsArePreserved() - { - // Arrange - var tableExpression = new SearchParamTableExpression(null, Expression.Equals(FieldName.TokenCode, null, "test"), SearchParamTableExpressionKind.Normal); - var resourceTableExpression = Expression.SearchParameter(new SearchParameterInfo("_type", "_type"), Expression.Equals(FieldName.TokenCode, null, "Patient")); - - var sqlRootExpression = new SqlRootExpression( - new List { tableExpression }, - new List { resourceTableExpression }); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - ResourceTableExpressions should be preserved - Assert.Same(sqlRootExpression, result); - Assert.Single(result.ResourceTableExpressions); - Assert.Same(resourceTableExpression, result.ResourceTableExpressions[0]); - } - - [Fact] - public void GivenRewriterInstance_WhenCreated_ThenNotNull() - { - // Assert - Verify rewriter was created successfully - Assert.NotNull(_rewriter); - } - - [Fact] - public void GivenSqlRootWithAllExpression_WhenVisited_ThenAllExpressionPreserved() - { - // Arrange - var allExpression = Expression.Equals(FieldName.TokenCode, null, "all-test"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, allExpression, SearchParamTableExpressionKind.All), - }; - - var sqlRootExpression = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - Assert.Same(sqlRootExpression, result); - Assert.Equal(SearchParamTableExpressionKind.All, result.SearchParamTableExpressions[0].Kind); - } - - [Fact] - public void GivenSqlRootWithIncludeExpression_WhenVisited_ThenIncludeExpressionPreserved() - { - // Arrange - var includeExpression = Expression.Equals(FieldName.String, null, "include-test"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var sqlRootExpression = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRootExpression.AcceptVisitor(_rewriter, null); - - // Assert - Non-chain expressions should pass through unchanged - Assert.Same(sqlRootExpression, result); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[0].Kind); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/DateTimeBoundedRangeRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/DateTimeBoundedRangeRewriterTests.cs deleted file mode 100644 index 4acca26ca6..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/DateTimeBoundedRangeRewriterTests.cs +++ /dev/null @@ -1,408 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - /// - /// Unit tests for DateTimeBoundedRangeRewriter. - /// Tests the rewriter's optimization of datetime range queries by adding a bounded range check - /// for dates shorter than one day to improve query performance. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class DateTimeBoundedRangeRewriterTests - { - private static readonly DateTimeOffset BaseDate = new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero); - - [Fact] - public void GivenDateTimeBoundedRange_WhenRewritten_ThenCreatesShortRangeOptimization() - { - // Arrange - Pattern: (DateTimeEnd >= X) AND (DateTimeStart < Y) - var greaterThanExpr = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Should create two table expressions: original + optimization for short ranges - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - // Original expression - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - - // Optimized concatenation for short ranges - var concatenation = result.SearchParamTableExpressions[1]; - Assert.Equal(SearchParamTableExpressionKind.Concatenation, concatenation.Kind); - - var concatenationAnd = Assert.IsType(concatenation.Predicate); - Assert.Equal(MultiaryOperator.And, concatenationAnd.MultiaryOperation); - Assert.Equal(4, concatenationAnd.Expressions.Count); - - // First: DateTimeIsLongerThanADay = false - var isNotLongExpr = Assert.IsType(concatenationAnd.Expressions[0]); - Assert.Equal(SqlFieldName.DateTimeIsLongerThanADay, isNotLongExpr.FieldName); - Assert.Equal(BinaryOperator.Equal, isNotLongExpr.BinaryOperator); - Assert.Equal(false, isNotLongExpr.Value); - - // Second: DateTimeEnd >= X - var endExpr = Assert.IsType(concatenationAnd.Expressions[1]); - Assert.Equal(FieldName.DateTimeEnd, endExpr.FieldName); - - // Third: DateTimeStart >= (X - 1 day) - var startBoundedExpr = Assert.IsType(concatenationAnd.Expressions[2]); - Assert.Equal(FieldName.DateTimeStart, startBoundedExpr.FieldName); - Assert.Equal(BinaryOperator.GreaterThanOrEqual, startBoundedExpr.BinaryOperator); - Assert.Equal(BaseDate.AddTicks(-TimeSpan.TicksPerDay), startBoundedExpr.Value); - - // Fourth: DateTimeStart < Y - var startExpr = Assert.IsType(concatenationAnd.Expressions[3]); - Assert.Equal(FieldName.DateTimeStart, startExpr.FieldName); - } - - [Fact] - public void GivenLongDateRange_WhenRewritten_ThenCreatesLongRangeExpression() - { - // Arrange - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, BaseDate.AddDays(2)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - // Original expression remains - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - - // Concatenation for long ranges - The Scout creates And with 3 expressions (DateTimeIsLongerThanADay=true + 2 original) - // But then VisitMultiary detects this pattern and transforms it to And with 4 expressions (DateTimeIsLongerThanADay=false + optimization) - var concatenation = result.SearchParamTableExpressions[1]; - Assert.Equal(SearchParamTableExpressionKind.Concatenation, concatenation.Kind); - - var concatenationAnd = Assert.IsType(concatenation.Predicate); - Assert.Equal(4, concatenationAnd.Expressions.Count); // Changed from 3 to 4 - - // Should have DateTimeIsLongerThanADay = false (not true - gets flipped by VisitMultiary) - var isLongExpr = Assert.IsType(concatenationAnd.Expressions[0]); - Assert.Equal(SqlFieldName.DateTimeIsLongerThanADay, isLongExpr.FieldName); - Assert.Equal(false, isLongExpr.Value); // Changed from true to false - } - - [Fact] - public void GivenNonDateTimeExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Number expression should not be rewritten - var expression = Expression.GreaterThan(FieldName.Number, null, 10); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Should return unchanged - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenSingleDateTimeExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Single expression (not And) should not be rewritten - var expression = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenOrExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Or instead of And - var expr1 = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var expr2 = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var orExpression = Expression.Or(expr1, expr2); - - var sqlRoot = CreateSqlRootWithExpression(orExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenWrongFieldNames_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Both fields must be DateTimeEnd and DateTimeStart - var expr1 = Expression.GreaterThan(FieldName.DateTimeStart, null, BaseDate); - var expr2 = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(expr1, expr2); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenWrongOperators_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - First must be >= or >, second must be < or <= - var expr1 = Expression.LessThan(FieldName.DateTimeEnd, null, BaseDate); - var expr2 = Expression.GreaterThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(expr1, expr2); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenComponentIndex_WhenRewritten_ThenComponentIndexPreserved() - { - // Arrange - var componentIndex = 1; - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, componentIndex, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, componentIndex, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationAnd = Assert.IsType(result.SearchParamTableExpressions[1].Predicate); - - // Verify all expressions have the same component index using Select - var componentIndices = concatenationAnd.Expressions - .Select(expr => Assert.IsType(expr)) - .Select(binaryExpr => binaryExpr.ComponentIndex) - .ToList(); - - Assert.All(componentIndices, idx => Assert.Equal(componentIndex, idx)); - } - - [Fact] - public void GivenThreeExpressions_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - And with 3 expressions (pattern expects exactly 2) - var expr1 = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var expr2 = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var expr3 = Expression.Equals(FieldName.TokenCode, null, "test"); - var andExpression = Expression.And(expr1, expr2, expr3); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenExpressionsInReverseOrder_WhenRewritten_ThenRewriterHandlesReordering() - { - // Arrange - LessThan before GreaterThan (should still match after reordering) - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var andExpression = Expression.And(lessThanExpr, greaterThanExpr); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Should still be rewritten (rewriter sorts by operator) - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[1].Kind); - } - - [Fact] - public void GivenEmptySqlRoot_WhenRewritten_ThenReturnsUnchanged() - { - // Arrange - var sqlRoot = new SqlRootExpression( - new List(), - new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenChainExpression_WhenRewritten_ThenSkipsChainExpressions() - { - // Arrange - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, andExpression, SearchParamTableExpressionKind.Chain), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Chain expressions should be skipped - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenIncludeExpression_WhenRewritten_ThenSkipsIncludeExpressions() - { - // Arrange - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, andExpression, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenSortExpression_WhenRewritten_ThenSkipsSortExpressions() - { - // Arrange - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, andExpression, SearchParamTableExpressionKind.Sort), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenAllExpression_WhenRewritten_ThenSkipsAllExpressions() - { - // Arrange - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanExpr); - - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, andExpression, SearchParamTableExpressionKind.All), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenGreaterThanWithLessThanOrEqual_WhenRewritten_ThenCreatesOptimization() - { - // Arrange - var greaterThanExpr = Expression.GreaterThan(FieldName.DateTimeEnd, null, BaseDate); - var lessThanOrEqualExpr = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanExpr, lessThanOrEqualExpr); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Should create optimization - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[1].Kind); - } - - [Fact] - public void GivenGreaterThanOrEqualWithLessThan_WhenRewritten_ThenCreatesOptimization() - { - // Arrange - var greaterThanOrEqualExpr = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, BaseDate); - var lessThanExpr = Expression.LessThan(FieldName.DateTimeStart, null, BaseDate.AddHours(6)); - var andExpression = Expression.And(greaterThanOrEqualExpr, lessThanExpr); - - var sqlRoot = CreateSqlRootWithExpression(andExpression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(DateTimeBoundedRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[1].Kind); - } - - private static SqlRootExpression CreateSqlRootWithExpression(Expression expression) - { - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.Normal), - }; - - return new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/FlatteningRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/FlatteningRewriterTests.cs deleted file mode 100644 index da35abbc56..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/FlatteningRewriterTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class FlatteningRewriterTests - { - [Fact] - public void GivenAMultiaryExpressionWithASingleElement_WhenFlattened_RemovesTheMultiary() - { - MultiaryExpression inputExpression = Expression.And(Expression.Equals(FieldName.Number, null, 1)); - Expression visitedExpression = inputExpression.AcceptVisitor(FlatteningRewriter.Instance); - Assert.Equal("(FieldEqual Number 1)", visitedExpression.ToString()); - } - - [Fact] - public void GivenTwoLayersOfAndExpressions_WhenFlattened_CombinesToOneAndExpression() - { - MultiaryExpression inputExpression = - Expression.And( - Expression.And(Expression.GreaterThan(FieldName.Number, null, 1), Expression.LessThan(FieldName.Number, null, 5)), - Expression.And(Expression.GreaterThan(FieldName.Quantity, null, 1), Expression.LessThan(FieldName.Quantity, null, 5))); - - Expression visitedExpression = inputExpression.AcceptVisitor(FlatteningRewriter.Instance); - Assert.Equal("(And (FieldGreaterThan Number 1) (FieldLessThan Number 5) (FieldGreaterThan Quantity 1) (FieldLessThan Quantity 5))", visitedExpression.ToString()); - } - - [Fact] - public void GivenTwoLayersOfOrExpressions_WhenFlattened_CombinesToOneOrExpression() - { - MultiaryExpression inputExpression = - Expression.Or( - Expression.Or(Expression.GreaterThan(FieldName.Number, null, 1), Expression.LessThan(FieldName.Number, null, 5)), - Expression.Or(Expression.GreaterThan(FieldName.Quantity, null, 1), Expression.LessThan(FieldName.Quantity, null, 5))); - - Expression visitedExpression = inputExpression.AcceptVisitor(FlatteningRewriter.Instance); - Assert.Equal("(Or (FieldGreaterThan Number 1) (FieldLessThan Number 5) (FieldGreaterThan Quantity 1) (FieldLessThan Quantity 5))", visitedExpression.ToString()); - } - - [Fact] - public void GivenAnOrExpressionWithAnAndChild_WhenFlattened_RemainsTheSame() - { - MultiaryExpression inputExpression = - Expression.Or( - Expression.And(Expression.GreaterThan(FieldName.Number, null, 1), Expression.LessThan(FieldName.Number, null, 5)), - Expression.And(Expression.GreaterThan(FieldName.Quantity, null, 1), Expression.LessThan(FieldName.Quantity, null, 5))); - - Expression visitedExpression = inputExpression.AcceptVisitor(FlatteningRewriter.Instance); - Assert.Equal("(Or (And (FieldGreaterThan Number 1) (FieldLessThan Number 5)) (And (FieldGreaterThan Quantity 1) (FieldLessThan Quantity 5)))", visitedExpression.ToString()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludeMatchSeedRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludeMatchSeedRewriterTests.cs deleted file mode 100644 index 50a4aa8a84..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludeMatchSeedRewriterTests.cs +++ /dev/null @@ -1,431 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - /// - /// Unit tests for IncludeMatchSeedRewriter. - /// Tests the rewriter's ability to add an All SearchParamTableExpression as a seed for match results - /// when SearchParamTableExpressions consist solely of Include expressions. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class IncludeMatchSeedRewriterTests - { - [Fact] - public void GivenOnlyIncludeExpressions_WhenRewritten_ThenAllExpressionIsAdded() - { - // Arrange - Query like: Observation?_include=Observation:subject - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceTableExpression = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceTableExpression }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Should add an All expression at the beginning - Assert.NotNull(result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - // First expression should be All (seed) - Assert.Equal(SearchParamTableExpressionKind.All, result.SearchParamTableExpressions[0].Kind); - - // Second should be the original Include - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Same(includeExpression, result.SearchParamTableExpressions[1].Predicate); - - // ResourceTableExpressions should be cleared and moved to All expression - Assert.Empty(result.ResourceTableExpressions); - } - - [Fact] - public void GivenMultipleIncludeExpressions_WhenRewritten_ThenAllExpressionAddedBeforeAll() - { - // Arrange - Multiple includes with a resource expression to avoid validation error - var include1 = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - var include2 = Expression.Equals(FieldName.ReferenceResourceType, null, "Practitioner"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, include1, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, include2, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Equal(3, result.SearchParamTableExpressions.Count); - - // First should be All - Assert.Equal(SearchParamTableExpressionKind.All, result.SearchParamTableExpressions[0].Kind); - - // Remaining should be the includes in order - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Same(include1, result.SearchParamTableExpressions[1].Predicate); - - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[2].Kind); - Assert.Same(include2, result.SearchParamTableExpressions[2].Predicate); - } - - [Fact] - public void GivenMixedIncludeAndNormalExpressions_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Mix of Include and Normal (like: Observation?code=abc&_include=Observation:subject) - var normalExpression = Expression.Equals(FieldName.TokenCode, null, "abc"); - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, normalExpression, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Should return unchanged - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenNormalExpressionsOnly_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - No includes - var expression = Expression.Equals(FieldName.TokenCode, null, "test"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenEmptySqlRoot_WhenRewritten_ThenReturnsUnchanged() - { - // Arrange - var sqlRoot = new SqlRootExpression( - new List(), - new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenIncludeWithMultipleResourceTableExpressions_WhenRewritten_ThenResourceExpressionsAreCombined() - { - // Arrange - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr1 = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var resourceExpr2 = Expression.SearchParameter( - new SearchParameterInfo("_lastUpdated", "_lastUpdated"), - Expression.GreaterThan(FieldName.DateTimeStart, null, System.DateTimeOffset.UtcNow)); - - var resourceTableExpressions = new List { resourceExpr1, resourceExpr2 }; - - var sqlRoot = new SqlRootExpression(tableExpressions, resourceTableExpressions); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.All, result.SearchParamTableExpressions[0].Kind); - - // The All expression should contain an And combining the resource expressions - var allPredicate = result.SearchParamTableExpressions[0].Predicate; - Assert.IsType(allPredicate); - var andExpression = (MultiaryExpression)allPredicate; - Assert.Equal(MultiaryOperator.And, andExpression.MultiaryOperation); - Assert.Equal(2, andExpression.Expressions.Count); - } - - [Fact] - public void GivenIncludeWithSingleResourceTableExpression_WhenRewritten_ThenResourceExpressionWrappedInAnd() - { - // Arrange - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Expression.And() always creates And expression even with single element - var allPredicate = result.SearchParamTableExpressions[0].Predicate; - var andExpression = Assert.IsType(allPredicate); - Assert.Equal(MultiaryOperator.And, andExpression.MultiaryOperation); - Assert.Single(andExpression.Expressions); - Assert.Same(resourceExpr, andExpression.Expressions[0]); - } - - [Fact] - public void GivenIncludeWithChainExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Include with Chain - var chainExpression = Expression.Equals(FieldName.String, null, "chain-test"); - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, chainExpression, SearchParamTableExpressionKind.Chain), - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Mixed types, no rewrite - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenIncludeWithTopExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Mixed types, no rewrite - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenIncludeWithSortExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - var sortExpression = Expression.Equals(FieldName.DateTimeStart, null, System.DateTimeOffset.UtcNow); - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, sortExpression, SearchParamTableExpressionKind.Sort), - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenIncludeWithAllExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Already has All expression - var allExpression = Expression.Equals(FieldName.TokenCode, null, "all-test"); - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, allExpression, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Mixed types, no rewrite - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenThreeIncludeExpressions_WhenRewritten_ThenAllAddedAndOrderPreserved() - { - // Arrange - var include1 = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - var include2 = Expression.Equals(FieldName.ReferenceResourceType, null, "Practitioner"); - var include3 = Expression.Equals(FieldName.ReferenceResourceType, null, "Organization"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, include1, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, include2, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, include3, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[3].Kind); - - // Verify order is preserved - Assert.Same(include1, result.SearchParamTableExpressions[1].Predicate); - Assert.Same(include2, result.SearchParamTableExpressions[2].Predicate); - Assert.Same(include3, result.SearchParamTableExpressions[3].Predicate); - } - - [Fact] - public void GivenIncludeWithConcatenationExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - Include with Concatenation - var concatenationExpression = Expression.Equals(FieldName.Number, null, 42); - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, concatenationExpression, SearchParamTableExpressionKind.Concatenation), - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Observation")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenOnlyIncludesWithComplexResourceExpression_WhenRewritten_ThenResourceExpressionCombinedInAnd() - { - // Arrange - var includeExpression = Expression.Equals(FieldName.ReferenceResourceType, null, "Patient"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpression, SearchParamTableExpressionKind.Include), - }; - - // Complex resource expression with Or - var typeExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Or( - Expression.Equals(FieldName.TokenCode, null, "Observation"), - Expression.Equals(FieldName.TokenCode, null, "Condition"))); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { typeExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludeMatchSeedRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.All, result.SearchParamTableExpressions[0].Kind); - - // Expression.And() wraps even single expressions - var allPredicate = result.SearchParamTableExpressions[0].Predicate; - var andExpression = Assert.IsType(allPredicate); - Assert.Equal(MultiaryOperator.And, andExpression.MultiaryOperation); - Assert.Single(andExpression.Expressions); - Assert.Same(typeExpr, andExpression.Expressions[0]); - } - - [Fact] - public void GivenRewriterInstance_WhenAccessed_ThenNotNull() - { - // Assert - Verify singleton instance exists - Assert.NotNull(IncludeMatchSeedRewriter.Instance); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludeRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludeRewriterTests.cs deleted file mode 100644 index 4dfb489fc8..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludeRewriterTests.cs +++ /dev/null @@ -1,1677 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Medino; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Health.Fhir.Core.Features.Definition; -using Microsoft.Health.Fhir.Core.Features.Persistence; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Features.Search.Parameters; -using Microsoft.Health.Fhir.Core.Features.Search.Registry; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.Core.UnitTests.Extensions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class IncludeRewriterTests : IClassFixture, IAsyncLifetime - { - private readonly IncludeRewriterFixture _fixture; - private readonly ISearchParameterDefinitionManager _searchParameterDefinitionManager; - - public IncludeRewriterTests(IncludeRewriterFixture fixture) - { - _fixture = fixture; - _searchParameterDefinitionManager = fixture.SearchParameterDefinitionManager; - } - - public async Task InitializeAsync() - { - await _fixture.Start(); - } - - public Task DisposeAsync() => Task.CompletedTask; - - // Basic Queries with 0-2 include search parameters with all the pair combinations - - [Fact] - public void GivenASqlRootExpressionWithoutIncludes_WhenVisitedByIncludeRewriter_TheSameExpressionShouldBeReturnedAsIs() - { - // Leave the query as is if there's no Include expression. For example: - // [base]/Patient?gender=female&family=Ellison - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false)), - new SearchParameterExpression(new SearchParameterInfo("gender", "gender"), new StringExpression(StringOperator.Equals, FieldName.String, null, "female", false)), - new SearchParameterExpression(new SearchParameterInfo("family", "family"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Ellison", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var rewrittenExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(rewrittenExpressions); - Assert.Equal(2, rewrittenExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, rewrittenExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, rewrittenExpressions[1].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithTwoIncludes_WhenVisitedByIncludeRewriter_TheOrderDoesNotMatterAndShouldRemainUnchanged() - { - // Order the following query: - // [base]/MedicationDispense?_include=MedicationDispense:prescription&_include=MedicationDispense:patient&_id=smart-MedicationDispense-567 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var includeMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "smart-MedicationDispense-567", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneIncludeAndOneIncludeIterate_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=Patient:general-practitioner&_include=MedicationRequest:patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var includeMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneIncludeAndOneRevInclude_WhenVisitedByIncludeRewriter_TheOrderDoesNotMatterAndShouldRemainUnchanged() - { - // Order the following query: - // [base]/MedicationRequest?_include=MedicationRequest:patient&_revinclude=MedicationDispense:prescription&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var revincludeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationRequest", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneIncludeAndOneRevIncludeIterate_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationRequest?_revinclude:iterate=MedicationDispense:patient&_include=MedicationRequest:patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var revincludeIterateMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationRequest", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeIterateMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithTwoRevIncludes_WhenVisitedByIncludeRewriter_TheOrderDoesNotMatterAndShouldRemainUnchanged() - { - // Order the following query: - // [base]/Patient?_revinclude=MedicationDispense:patient&_revinclude=MedicationRequest:patient&_id=patientId - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var revincludeMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var revincludeMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "patientId", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithTwoIncludesSpecifyingTargetType_WhenVisitedByIncludeRewriter_TheOrderDoesNotMatterAndShouldRemainUnchanged() - { - // Order the following query: - // [base]/MedicationDispense?_include=MedicationDispense:prescription&_include=MedicationDispense:subject:Patient&_id=smart-MedicationDispense-567 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "subject"); - var includeMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", "Patient", null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "smart-MedicationDispense-567", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("subject", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneIncludeAndOneIncludeIterateSpecifyingTargetType_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=Patient:general-practitioner:Practitioner&_include=MedicationRequest:patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", "Practitioner", null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var includeMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneIncludeAndOneRevIncludeSpecifyingTargetType_WhenVisitedByIncludeRewriter_TheOrderDoesNotMatterAndShouldRemainUnchanged() - { - // Order the following query: - // [base]/MedicationRequest?_include=MedicationRequest:patient&_revinclude=MedicationDispense:prescription:MedicationRequest&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var revincludeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", "MedicationRequest", null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationRequest", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneIncludeAndOneRevIncludeIterateSpecifyingTargetType_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationRequest?_revinclude:iterate=MedicationDispense:patient&_include=MedicationRequest:subject:Patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var revincludeIterateMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "subject"); - var includeMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", "Patient", null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationRequest", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeIterateMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("subject", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithTwoRevIncludesSpecifyingTargetType_WhenVisitedByIncludeRewriter_TheOrderDoesNotMatterAndShouldRemainUnchanged() - { - // Order the following query: - // [base]/Patient?_revinclude=MedicationDispense:subject:Patient&_revinclude=MedicationRequest:patient&_id=patientId - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "subject"); - var revincludeMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", "Patient", null, false, true, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var revincludeMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "patientId", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("subject", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneRevIncludeAndOneRevIncludeIterate_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/Practitioner?_revinclude:iterate=MedicationRequest:patient&_revinclude=Patient:general-practitioner&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var revincludeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var revincludePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Practitioner", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithOneRevIncludeAndOneIncludeIterate_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationRequest?_include:iterate=MedicationDispense:patient&_revinclude=MedicationDispense:prescription&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var includeIterateMedicationDispensePatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var revincludeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationRequest", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationDispensePatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revincludeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - // Queries with indirect dependencies - // All possible permutations of 3 parameters: _include=MedicationDispense:prescription&_include:iterate=MedicationRequest:patient&_include:iterate=Patient:general-practitioner - - [Fact] - public void GivenASqlRootExpressionWithThreeIncludesFirstPermutation_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include=MedicationDispense:prescription&_include:iterate=MedicationRequest:patient&_include:iterate=Patient:general-practitioner&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithThreeIncludesSecondPermutation_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include=MedicationDispense:prescription&_include:iterate=Patient:general-practitioner&_include:iterate=MedicationRequest:patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithThreeIncludesThirdPermutation_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=MedicationRequest:patient&_include=MedicationDispense:prescription&_include:iterate=Patient:general-practitioner&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithThreeIncludesFourthPermutation_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=MedicationRequest:patient&_include:iterate=Patient:general-practitioner&_include=MedicationDispense:prescription&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithThreeIncludesFifthPermutation_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=Patient:general-practitioner&_include=MedicationDispense:prescription&_include:iterate=MedicationRequest:patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithThreeIncludesSixthPermutation_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=Patient:general-practitioner&_include:iterate=MedicationRequest:patient&_include=MedicationDispense:prescription&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - // Queries with multiple includes/revincludes - - [Fact] - public void GivenASqlRootExpressionWithMultipleIncludes_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=Patient:general-practitioner&_include:iterate=MedicationRequest:patient&_include=MedicationDispense:prescription&_id=smart-MedicationDispense-567 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "smart-MedicationDispense-567", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithMultipleRevIncludes_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/Organization?_revinclude:iterate=MedicationDispense:prescription&_revinclude:iterate=MedicationRequest:patient&_revinclude=Patient:organization&_id=organization-id - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "organization"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Organization", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "organization-id", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("organization", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithMultipleIncludesAndRevIncludes_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/Organization?_include:iterate=MedicationDispense:prescription&_revinclude:iterate=MedicationDispense:patient&_revinclude=Patient:organization&_id=organization-id - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "organization"); - var includeMedicationDispensePrescription = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Organization", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "organization-id", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePrescription, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("organization", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("prescription", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - // Queries with search parameters unrelated to the query - [Fact] - public void GivenASqlRootExpressionWithParametersUnrelatedToTheQuery_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_id=12345&_include:iterate=Device:location&_include:iterate=Location:endpoint&_include=MedicationDispense:performer&_include:iterate=Patient:general-practitioner - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Device", "location"); - var includeIterateDeviceLocation = new IncludeExpression(new[] { "Device" }, refSearchParameter, "Device", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Location", "endpoint"); - var includeIterateLocationEndpoint = new IncludeExpression(new[] { "Location" }, refSearchParameter, "Location", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "performer"); - var includeMedicationDispensePerformer = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Organization", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateDeviceLocation, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateLocationEndpoint, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePerformer, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientPractitioner, SearchParamTableExpressionKind.Include), - - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(11, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("performer", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("Device", includeExpression.ResourceTypes[0]); - Assert.Equal("location", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Location", includeExpression.ResourceTypes[0]); - Assert.Equal("endpoint", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[8].Kind); - includeExpression = (IncludeExpression)orderedExpressions[8].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[9].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[10].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithParametersUnrelatedToTheQuerySortedDiferently_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_id=12345&_include:iterate=Location:endpoint&_include=MedicationDispense:performer&_include:iterate=Patient:general-practitioner&_include:iterate=Device:location - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Device", "location"); - var includeIterateDeviceLocation = new IncludeExpression(new[] { "Device" }, refSearchParameter, "Device", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Location", "endpoint"); - var includeIterateLocationEndpoint = new IncludeExpression(new[] { "Location" }, refSearchParameter, "Location", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "performer"); - var includeMedicationDispensePerformer = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Organization", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateLocationEndpoint, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispensePerformer, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateDeviceLocation, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(11, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("performer", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[9].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Device", includeExpression.ResourceTypes[0]); - Assert.Equal("location", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[8].Kind); - includeExpression = (IncludeExpression)orderedExpressions[8].Predicate; - Assert.Equal("Location", includeExpression.ResourceTypes[0]); - Assert.Equal("endpoint", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[9].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[10].Kind); - } - - // Wildcard Queries - - [Fact] - public void GivenASqlRootExpressionWithIncludeWildcard_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationDispense?_include:iterate=Patient:general-practitioner&_include:iterate=MedicationRequest:patient&_include=MedicationDispense:*&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientGeneralPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - var referencedTypes = new List { "Location", "MedicationRequest", "Patient", "Practitioner", "Organization" }; // partial list of referenced types - var includeMedicationDispenseWildcard = new IncludeExpression(new[] { "MedicationDispense" }, null, "MedicationDispense", null, referencedTypes, true, false, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientGeneralPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispenseWildcard, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(9, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.True(includeExpression.WildCard); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationRequest", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[6].Kind); - includeExpression = (IncludeExpression)orderedExpressions[6].Predicate; - Assert.Equal("Patient", includeExpression.ResourceTypes[0]); - Assert.Equal("general-practitioner", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[7].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[8].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithRevIncludeWildcard_WhenVisitedByIncludeRewriter_TheExpressionsShouldBeOrderedCorrectly() - { - // Order the following query: - // [base]/MedicationRequest?_include:iterate=MedicationDispense:patient&_revinclude=MedicationDispense:*&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, true); - - var referencedTypes = new List { "Location", "MedicationRequest", "Patient", "Practitioner", "Organization" }; // partial list of referenced types - var revIncludeMedicationDispenseWildcard = new IncludeExpression(new[] { "MedicationDispense" }, null, "MedicationDispense", null, referencedTypes, true, true, false); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationRequest", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revIncludeMedicationDispenseWildcard, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - var orderedExpressions = ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions; - - // Assert the number of expressions and their order is correct, including IncludeUnionAll expression, which was added in the IncludeRewriter visit. - Assert.NotNull(orderedExpressions); - Assert.Equal(7, orderedExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.All, orderedExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, orderedExpressions[1].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[2].Kind); - var includeExpression = (IncludeExpression)orderedExpressions[2].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.True(includeExpression.WildCard); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[3].Kind); - - Assert.Equal(SearchParamTableExpressionKind.Include, orderedExpressions[4].Kind); - includeExpression = (IncludeExpression)orderedExpressions[4].Predicate; - Assert.Equal("MedicationDispense", includeExpression.ResourceTypes[0]); - Assert.Equal("patient", includeExpression.ReferenceSearchParameter.Code); - - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, orderedExpressions[5].Kind); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, orderedExpressions[6].Kind); - } - - [Fact] - public void GivenASqlRootExpressionWithCyclicIncludeIterate_WhenVisitedByIncludeRewriter_AnErrorIsExpected() - { - // Order the following cyclic query: - // [base]/MedicationDispense?_include=MedicationDispense:prescription&_include:iterate=MedicationRequest:patient&_include:iterate=Patient:general-practitioner&_revinclude:iterate=DiagnosticReport:performer:Practitioner&_include:iterate=DiagnosticReport:patient&_id=12345 - - var refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationDispense", "prescription"); - var includeMedicationDispense = new IncludeExpression(new[] { "MedicationDispense" }, refSearchParameter, "MedicationDispense", null, null, false, false, false); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("MedicationRequest", "patient"); - var includeIterateMedicationRequestPatient = new IncludeExpression(new[] { "MedicationRequest" }, refSearchParameter, "MedicationRequest", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("Patient", "general-practitioner"); - var includeIteratePatientPractitioner = new IncludeExpression(new[] { "Patient" }, refSearchParameter, "Patient", null, null, false, false, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("DiagnosticReport", "performer"); - var revIncludeIterateDiagnosticReportPerformer = new IncludeExpression(new[] { "DiagnosticReport" }, refSearchParameter, "DiagnosticReport", "Practitioner", null, false, true, true); - - refSearchParameter = _searchParameterDefinitionManager.GetSearchParameter("DiagnosticReport", "patient"); - var includeIterateDiagnosticReportPatient = new IncludeExpression(new[] { "DiagnosticReport" }, refSearchParameter, "DiagnosticReport", null, null, false, false, true); - - Expression predicate = Expression.And(new List - { - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "MedicationDispense", false)), - new SearchParameterExpression(new SearchParameterInfo("_id", "_id"), new StringExpression(StringOperator.Equals, FieldName.String, null, "12345", false)), - }); - - var sqlExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, predicate, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeMedicationDispense, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateMedicationRequestPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIteratePatientPractitioner, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, revIncludeIterateDiagnosticReportPerformer, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeIterateDiagnosticReportPatient, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }, - new List()); - - Assert.Throws(() => ((SqlRootExpression)sqlExpression.AcceptVisitor(IncludeRewriter.Instance)).SearchParamTableExpressions); - } - - public class IncludeRewriterFixture - { - private bool isInitialized = false; - - public IncludeRewriterFixture() - { - IModelInfoProvider modelInfoProvider = MockModelInfoProviderBuilder - .Create(FhirSpecification.R4) - .AddKnownTypes("Device", "DiagnosticReport", "MedicationRequest", "MedicationDispense", "Location", "Practitioner", "Organization", "Bundle") - .Build(); - var mediator = Substitute.For(); - var searchService = Substitute.For(); - var searchParameterComparer = Substitute.For>(); - var statusDataStore = Substitute.For(); - var fhirDataStore = Substitute.For(); - var logger = NullLogger.Instance; - - SearchParameterDefinitionManager = new SearchParameterDefinitionManager( - modelInfoProvider, - mediator, - searchService.CreateMockScopeProvider(), - searchParameterComparer, - statusDataStore.CreateMockScopeProvider(), - fhirDataStore.CreateMockScopeProvider(), - logger); - } - - public ISearchParameterDefinitionManager SearchParameterDefinitionManager { get; } - - public async Task Start() - { - if (!isInitialized) - { - await ((SearchParameterDefinitionManager)SearchParameterDefinitionManager).EnsureInitializedAsync(CancellationToken.None); - isInitialized = true; - } - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludesOperationRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludesOperationRewriterTests.cs deleted file mode 100644 index 9f95ffccde..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/IncludesOperationRewriterTests.cs +++ /dev/null @@ -1,440 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - /// - /// Unit tests for IncludesOperationRewriter. - /// Tests the rewriter's ability to reorder include expressions for the $includes operation, - /// adding IncludeUnionAll and IncludeLimit expressions at the end. - /// Key difference from IncludeRewriter: Does NOT add IncludeLimit after each include expression. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class IncludesOperationRewriterTests - { - [Fact] - public void GivenNullExpression_WhenRewritten_ThenReturnsNull() - { - // Act - var result = IncludesOperationRewriter.Instance.VisitSqlRoot(null, null); - - // Assert - Assert.Null(result); - } - - [Fact] - public void GivenSingleExpression_WhenRewritten_ThenReturnsUnchanged() - { - // Arrange - Only one expression - var normalExpression = Expression.Equals(FieldName.TokenCode, null, "test"); - var tableExpressions = new List - { - new SearchParamTableExpression(null, normalExpression, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Should return unchanged (count == 1) - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenNoIncludeExpressions_WhenRewritten_ThenReturnsUnchanged() - { - // Arrange - No include expressions - var expr1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var expr2 = Expression.Equals(FieldName.String, null, "value2"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, expr1, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, expr2, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - No includes, should return unchanged - Assert.Same(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenMixedExpressionsWithIncludes_WhenRewritten_ThenIncludesMovedToEnd() - { - // Arrange - Normal expression followed by include - var normalExpr = Expression.Equals(FieldName.TokenCode, null, "test"); - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, normalExpr, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Should reorder: Normal, Include, IncludeUnionAll, IncludeLimit - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, result.SearchParamTableExpressions[3].Kind); - } - - [Fact] - public void GivenIncludesOperationRewriter_WhenProcessingIncludes_ThenNoLimitAfterEachInclude() - { - // Arrange - Multiple includes (key difference from base IncludeRewriter) - var include1 = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - var include2 = CreateIncludeExpression("Patient", "Practitioner", "general-practitioner", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, include1, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, include2, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - IncludesOperationRewriter should NOT add IncludeLimit after each include - // Expected: Include1, Include2, IncludeUnionAll, IncludeLimit (4 total, not 6) - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, result.SearchParamTableExpressions[3].Kind); - } - - [Fact] - public void GivenIncludeIterateExpressions_WhenRewritten_ThenSortedCorrectly() - { - // Arrange - Include iterate should come after regular include - var include = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - var includeIterate = CreateIncludeExpression("Observation", "Practitioner", "performer", reversed: false, iterate: true, sourceTypeOverrideForIterate: "Observation"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeIterate, SearchParamTableExpressionKind.Include), // Out of order - new SearchParamTableExpression(null, include, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Include iterate should be sorted after regular include - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - // Regular include should come first - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[0].Kind); - var firstInclude = (IncludeExpression)result.SearchParamTableExpressions[0].Predicate; - Assert.False(firstInclude.Iterate); - - // Include iterate should come second - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - var secondInclude = (IncludeExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.True(secondInclude.Iterate); - - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, result.SearchParamTableExpressions[3].Kind); - } - - [Fact] - public void GivenNormalAndIncludeExpressions_WhenRewritten_ThenNormalComesFirst() - { - // Arrange - Include before normal (should be reordered) - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - var normalExpr = Expression.Equals(FieldName.TokenCode, null, "test"); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, normalExpr, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Normal should be reordered to come first - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - Assert.Same(normalExpr, result.SearchParamTableExpressions[0].Predicate); - - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Same(includeExpr, result.SearchParamTableExpressions[1].Predicate); - } - - [Fact] - public void GivenMultipleNormalAndIncludeExpressions_WhenRewritten_ThenCorrectOrder() - { - // Arrange - Mix of normal and include expressions - var normal1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var include1 = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - var normal2 = Expression.Equals(FieldName.String, null, "value2"); - var include2 = CreateIncludeExpression("Patient", "Practitioner", "general-practitioner", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, include1, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, normal1, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, include2, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, normal2, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - All normals first, then all includes, then union/limit - Assert.Equal(6, result.SearchParamTableExpressions.Count); - - // Normals - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - Assert.Same(normal1, result.SearchParamTableExpressions[0].Predicate); - - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[1].Kind); - Assert.Same(normal2, result.SearchParamTableExpressions[1].Predicate); - - // Includes - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[2].Kind); - Assert.Same(include1, result.SearchParamTableExpressions[2].Predicate); - - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[3].Kind); - Assert.Same(include2, result.SearchParamTableExpressions[3].Predicate); - - // Union and Limit - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, result.SearchParamTableExpressions[4].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, result.SearchParamTableExpressions[5].Kind); - } - - [Fact] - public void GivenChainExpression_WhenRewritten_ThenChainPreservedBeforeIncludes() - { - // Arrange - Chain and include expressions - var chainExpr = Expression.Equals(FieldName.String, null, "chain"); - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, chainExpr, SearchParamTableExpressionKind.Chain), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Chain should come before include - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Chain, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, result.SearchParamTableExpressions[3].Kind); - } - - [Fact] - public void GivenTopExpression_WhenRewritten_ThenTopPreservedBeforeIncludes() - { - // Arrange - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Top should come before include - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Top, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - } - - [Fact] - public void GivenSortExpression_WhenRewritten_ThenSortPreservedBeforeIncludes() - { - // Arrange - var sortExpr = Expression.Equals(FieldName.DateTimeStart, null, System.DateTimeOffset.UtcNow); - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, sortExpr, SearchParamTableExpressionKind.Sort), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Sort should come before include - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Sort, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Include, result.SearchParamTableExpressions[1].Kind); - } - - [Fact] - public void GivenResourceTableExpressions_WhenRewritten_ThenResourceExpressionsPreserved() - { - // Arrange - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - }; - - var resourceExpr = Expression.SearchParameter( - new SearchParameterInfo("_type", "_type"), - Expression.Equals(FieldName.TokenCode, null, "Patient")); - - var sqlRoot = new SqlRootExpression(tableExpressions, new List { resourceExpr }); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Resource table expressions should be preserved - Assert.Single(result.ResourceTableExpressions); - Assert.Same(resourceExpr, result.ResourceTableExpressions[0]); - } - - [Fact] - public void GivenComplexIncludeIterateDependencies_WhenRewritten_ThenSortedByDependency() - { - // Arrange - Create includes with dependencies: Observation -> Device -> Location - var include1 = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - var includeIterate1 = CreateIncludeExpression("Observation", "Device", "device", reversed: false, iterate: true, sourceTypeOverrideForIterate: "Observation"); - var includeIterate2 = CreateIncludeExpression("Device", "Location", "location", reversed: false, iterate: true, sourceTypeOverrideForIterate: "Device"); - - var tableExpressions = new List - { - // Add in wrong order - new SearchParamTableExpression(null, includeIterate2, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, include1, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, includeIterate1, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Should be sorted by dependency chain - Assert.Equal(5, result.SearchParamTableExpressions.Count); - - // Regular include first - var expr0 = (IncludeExpression)result.SearchParamTableExpressions[0].Predicate; - Assert.False(expr0.Iterate); - Assert.Equal("Observation", expr0.TargetResourceType); - - // Then iterate to Device (depends on Observation) - var expr1 = (IncludeExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.True(expr1.Iterate); - Assert.Equal("Device", expr1.TargetResourceType); - - // Then iterate to Location (depends on Device) - var expr2 = (IncludeExpression)result.SearchParamTableExpressions[2].Predicate; - Assert.True(expr2.Iterate); - Assert.Equal("Location", expr2.TargetResourceType); - } - - [Fact] - public void GivenRewriterInstance_WhenAccessed_ThenNotNull() - { - // Assert - Verify singleton instance exists - Assert.NotNull(IncludesOperationRewriter.Instance); - } - - [Fact] - public void GivenOnlyIncludeExpression_WhenRewritten_ThenAddsUnionAndLimit() - { - // Arrange - Only include, no other expressions - var includeExpr = CreateIncludeExpression("Patient", "Observation", "subject", reversed: false, iterate: false); - - var tableExpressions = new List - { - new SearchParamTableExpression(null, includeExpr, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), // Need at least 2 for rewrite - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(IncludesOperationRewriter.Instance, null); - - // Assert - Assert.Equal(4, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.IncludeUnionAll, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.IncludeLimit, result.SearchParamTableExpressions[3].Kind); - } - - private static IncludeExpression CreateIncludeExpression( - string sourceType, - string targetType, - string searchParameter, - bool reversed, - bool iterate, - string sourceTypeOverrideForIterate = null) - { - var referenceSearchParam = new SearchParameterInfo(searchParameter, searchParameter) - { - Type = ValueSets.SearchParamType.Reference, - }; - - // For iterate expressions, use the source type override if provided - string actualSourceType = iterate && sourceTypeOverrideForIterate != null ? sourceTypeOverrideForIterate : sourceType; - - return new IncludeExpression( - new[] { actualSourceType }, - referenceSearchParam, - actualSourceType, - targetType, - new[] { targetType }, - wildCard: false, - iterate: iterate, - reversed: reversed, - allowedResourceTypesByScope: null); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/LastUpdatedToResourceSurrogateIdRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/LastUpdatedToResourceSurrogateIdRewriterTests.cs deleted file mode 100644 index 9a7f8e40bd..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/LastUpdatedToResourceSurrogateIdRewriterTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class LastUpdatedToResourceSurrogateIdRewriterTests - { - [InlineData(BinaryOperator.GreaterThan, "2020-09-24T12:00:00.500Z", BinaryOperator.GreaterThanOrEqual, "2020-09-24T12:00:00.501Z")] - [InlineData(BinaryOperator.GreaterThan, "2020-09-24T12:00:00.5001Z", BinaryOperator.GreaterThanOrEqual, "2020-09-24T12:00:00.501Z")] - [InlineData(BinaryOperator.GreaterThanOrEqual, "2020-09-24T12:00:00.500Z", BinaryOperator.GreaterThanOrEqual, "2020-09-24T12:00:00.500Z")] - [InlineData(BinaryOperator.GreaterThanOrEqual, "2020-09-24T12:00:00.5001Z", BinaryOperator.GreaterThanOrEqual, "2020-09-24T12:00:00.501Z")] - [InlineData(BinaryOperator.LessThan, "2020-09-24T12:00:00.500Z", BinaryOperator.LessThan, "2020-09-24T12:00:00.500Z")] - [InlineData(BinaryOperator.LessThan, "2020-09-24T12:00:00.5001Z", BinaryOperator.LessThan, "2020-09-24T12:00:00.501Z")] // will yield 500, 499 - [InlineData(BinaryOperator.LessThanOrEqual, "2020-09-24T12:00:00.500Z", BinaryOperator.LessThan, "2020-09-24T12:00:00.501Z")] - [InlineData(BinaryOperator.LessThanOrEqual, "2020-09-24T12:00:00.5001Z", BinaryOperator.LessThan, "2020-09-24T12:00:00.501Z")] // will yield 500, 499 - [Theory] - public void GivenAnExpressionOverLastUpdated_WhenTranslatedToResourceSurrogateId_HasCorrectRanges(BinaryOperator inputOperator, string inputDateTimeOffset, BinaryOperator expectedOperator, string expectedDateTimeOffset) - { - var input = new BinaryExpression(inputOperator, FieldName.DateTimeStart, null, DateTimeOffset.Parse(inputDateTimeOffset)); - - var output = input.AcceptVisitor(LastUpdatedToResourceSurrogateIdRewriter.Instance, null); - - BinaryExpression binaryOutput = Assert.IsType(output); - Assert.Equal(SqlFieldName.ResourceSurrogateId, binaryOutput.FieldName); - Assert.Equal(expectedOperator, binaryOutput.BinaryOperator); - Assert.Equal(DateTimeOffset.Parse(expectedDateTimeOffset), ((long)binaryOutput.Value).ToLastUpdated()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/MissingSearchParamVisitorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/MissingSearchParamVisitorTests.cs deleted file mode 100644 index 3ba7cebb56..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/MissingSearchParamVisitorTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class MissingSearchParamVisitorTests - { - [Fact] - public void GivenExpressionWithMissingParameterExpression_WhenVisited_AllExpressionPrependedToExpressionList() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, new MissingSearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), true), SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(MissingSearchParamVisitor.Instance); - Assert.Collection( - visitedExpression.SearchParamTableExpressions, - e => { Assert.Equal(SearchParamTableExpressionKind.All, e.Kind); }, - e => { Assert.NotNull(e.Predicate as MissingSearchParameterExpression); }); - Assert.Equal(tableExpressions.Count + 1, visitedExpression.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenExpressionWithNoMissingParameterExpression_WhenVisited_OriginalExpressionReturned() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(MissingSearchParamVisitor.Instance); - Assert.Equal(inputExpression, visitedExpression); - } - - [Fact] - public void GivenExpressionWithMissingParameterExpressionFalseLast_WhenVisited_OriginalExpressionReturned() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, new MissingSearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), false), SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(MissingSearchParamVisitor.Instance); - Assert.Equal(inputExpression, visitedExpression); - } - - [Fact] - public void GivenExpressionWithMissingParameterExpressionLast_WhenVisited_MissingParameterExpressionNegated() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, new MissingSearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), true), SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(MissingSearchParamVisitor.Instance); - Assert.Collection( - visitedExpression.SearchParamTableExpressions, - e => { Assert.Equal(tableExpressions[0], e); }, - e => { Assert.Equal(SearchParamTableExpressionKind.NotExists, e.Kind); }); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NotExpressionRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NotExpressionRewriterTests.cs deleted file mode 100644 index 6d67367e80..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NotExpressionRewriterTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class NotExpressionRewriterTests - { - [Fact] - public void GivenExpressionWithNotExpression_WhenVisited_AllExpressionPrependedToExpressionList() - { - var subExpression = Expression.StringEquals(FieldName.TokenCode, 0, "TestValue123", false); - var searchParamTableExpressions = new List - { - new SearchParamTableExpression(null, new SearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), Expression.Not(subExpression)), SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(searchParamTableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(NotExpressionRewriter.Instance); - Assert.Collection( - visitedExpression.SearchParamTableExpressions, - e => { Assert.Equal(SearchParamTableExpressionKind.Normal, e.Kind); }, - e => { ValidateNotExpression(subExpression, e); }); - Assert.Equal(searchParamTableExpressions.Count + 1, visitedExpression.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenExpressionWithNoNotExpression_WhenVisited_OriginalExpressionReturned() - { - var searchParamTableExpressions = new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(searchParamTableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(NotExpressionRewriter.Instance); - Assert.Equal(inputExpression, visitedExpression); - } - - [Fact] - public void GivenExpressionWithNotExpressionLast_WhenVisited_NotExpressionUnwrapped() - { - var subExpression = Expression.StringEquals(FieldName.TokenCode, 0, "TestValue123", false); - var searchParamTableExpressions = new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, new SearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), Expression.Not(subExpression)), SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(searchParamTableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(NotExpressionRewriter.Instance); - Assert.Collection( - visitedExpression.SearchParamTableExpressions, - e => { Assert.Equal(searchParamTableExpressions[0], e); }, - e => { ValidateNotExpression(subExpression, e); }); - } - - private static void ValidateNotExpression(Expression subExpression, SearchParamTableExpression expressionToValidate) - { - Assert.Equal(SearchParamTableExpressionKind.NotExists, expressionToValidate.Kind); - - var spExpression = Assert.IsType(expressionToValidate.Predicate); - Assert.Equal(subExpression, spExpression.Expression); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NotReferencingSqlGenerationTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NotReferencingSqlGenerationTests.cs deleted file mode 100644 index 2389e61d42..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NotReferencingSqlGenerationTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class NotReferencingSqlGenerationTests - { - [Fact] - public void GivenNotReferencingExpression_WhenVisited_ThenNotExistsSqlIsGenerated() - { - var model = Substitute.For(); - model.GetResourceTypeId("Device").Returns((short)99); - var patientParam = new SearchParameterInfo( - "patient", - "patient", - ValueSets.SearchParamType.Reference, - new System.Uri("http://hl7.org/fhir/SearchParameter/Device-patient")); - model.GetSearchParamId(patientParam.Url).Returns((short)123); - - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var parameters = new HashingSqlQueryParameterManager(new SqlQueryParameterManager(sqlCommand.Parameters)); - var schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max) { Current = SchemaVersionConstants.Max }; - var context = new SearchParameterQueryGeneratorContext(stringBuilder, parameters, model, schemaInformation, isAsyncOperation: false, tableAlias: null); - - var expression = new NotReferencingExpression("Device", patientParam); - expression.AcceptVisitor(NotReferencedQueryGenerator.Instance, context); - - var sql = stringBuilder.ToString(); - Assert.Contains("= 99", sql); // ResourceTypeId filter - Assert.Contains("NOT EXISTS", sql); // anti-join - Assert.Contains("SearchParamId = 123", sql); - Assert.Contains("RefResourceSurrogateId = ResourceSurrogateId", sql); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NumericRangeRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NumericRangeRewriterTests.cs deleted file mode 100644 index 5509bb629e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/NumericRangeRewriterTests.cs +++ /dev/null @@ -1,411 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - /// - /// Unit tests for NumericRangeRewriter. - /// Tests the rewriter's ability to transform numeric and quantity expressions - /// to account for range values (low/high fields). - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class NumericRangeRewriterTests - { - [Fact] - public void GivenQuantityGreaterThanExpression_WhenRewritten_ThenUsesQuantityHighField() - { - // Arrange - var expression = Expression.GreaterThan(FieldName.Quantity, null, 5.0m); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.NotNull(result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - // First expression is the original - var originalExpr = (BinaryExpression)result.SearchParamTableExpressions[0].Predicate; - Assert.Equal(FieldName.Quantity, originalExpr.FieldName); - Assert.Equal(BinaryOperator.GreaterThan, originalExpr.BinaryOperator); - - // Second expression is the concatenation using QuantityHigh - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.QuantityHigh, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.GreaterThan, concatenationExpr.BinaryOperator); - Assert.Equal(5.0m, concatenationExpr.Value); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[1].Kind); - } - - [Fact] - public void GivenQuantityGreaterThanOrEqualExpression_WhenRewritten_ThenUsesQuantityHighField() - { - // Arrange - var expression = Expression.GreaterThanOrEqual(FieldName.Quantity, null, 10.5m); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.QuantityHigh, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.GreaterThanOrEqual, concatenationExpr.BinaryOperator); - Assert.Equal(10.5m, concatenationExpr.Value); - } - - [Fact] - public void GivenQuantityLessThanExpression_WhenRewritten_ThenUsesQuantityLowField() - { - // Arrange - var expression = Expression.LessThan(FieldName.Quantity, null, 100.0m); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.QuantityLow, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.LessThan, concatenationExpr.BinaryOperator); - Assert.Equal(100.0m, concatenationExpr.Value); - } - - [Fact] - public void GivenQuantityLessThanOrEqualExpression_WhenRewritten_ThenUsesQuantityLowField() - { - // Arrange - var expression = Expression.LessThanOrEqual(FieldName.Quantity, null, 50.25m); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.QuantityLow, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.LessThanOrEqual, concatenationExpr.BinaryOperator); - Assert.Equal(50.25m, concatenationExpr.Value); - } - - [Fact] - public void GivenNumberGreaterThanExpression_WhenRewritten_ThenUsesNumberHighField() - { - // Arrange - var expression = Expression.GreaterThan(FieldName.Number, null, 42); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.NumberHigh, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.GreaterThan, concatenationExpr.BinaryOperator); - Assert.Equal(42, concatenationExpr.Value); - } - - [Fact] - public void GivenNumberLessThanExpression_WhenRewritten_ThenUsesNumberLowField() - { - // Arrange - var expression = Expression.LessThan(FieldName.Number, null, 99); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.NumberLow, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.LessThan, concatenationExpr.BinaryOperator); - } - - [Fact] - public void GivenNumberGreaterThanOrEqualExpression_WhenRewritten_ThenUsesNumberHighField() - { - // Arrange - var expression = Expression.GreaterThanOrEqual(FieldName.Number, null, 0); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.NumberHigh, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.GreaterThanOrEqual, concatenationExpr.BinaryOperator); - } - - [Fact] - public void GivenNumberLessThanOrEqualExpression_WhenRewritten_ThenUsesNumberLowField() - { - // Arrange - var expression = Expression.LessThanOrEqual(FieldName.Number, null, 1000); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.NumberLow, concatenationExpr.FieldName); - Assert.Equal(BinaryOperator.LessThanOrEqual, concatenationExpr.BinaryOperator); - } - - [Fact] - public void GivenNonNumericExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - String expression should not be rewritten - var expression = Expression.Equals(FieldName.String, null, "test"); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Should return same expression - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenTokenCodeExpression_WhenRewritten_ThenNoRewriteOccurs() - { - // Arrange - var expression = Expression.Equals(FieldName.TokenCode, null, "code"); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenQuantityWithComponentIndex_WhenRewritten_ThenComponentIndexPreserved() - { - // Arrange - var componentIndex = 1; - var expression = Expression.GreaterThan(FieldName.Quantity, componentIndex, 5.0m); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.QuantityHigh, concatenationExpr.FieldName); - Assert.Equal(componentIndex, concatenationExpr.ComponentIndex); - } - - [Fact] - public void GivenNumberWithComponentIndex_WhenRewritten_ThenComponentIndexPreserved() - { - // Arrange - var componentIndex = 2; - var expression = Expression.LessThan(FieldName.Number, componentIndex, 100); - var sqlRoot = CreateSqlRootWithExpression(expression); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - var concatenationExpr = (BinaryExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.Equal(SqlFieldName.NumberLow, concatenationExpr.FieldName); - Assert.Equal(componentIndex, concatenationExpr.ComponentIndex); - } - - [Fact] - public void GivenMultipleNumericExpressions_WhenRewritten_ThenAllAreRewritten() - { - // Arrange - var expr1 = Expression.GreaterThan(FieldName.Number, null, 10); - var expr2 = Expression.LessThan(FieldName.Quantity, null, 50.0m); - - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expr1, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, expr2, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Each expression should be doubled (original + concatenation) - Assert.Equal(4, result.SearchParamTableExpressions.Count); - - // First original + concatenation - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[1].Kind); - - // Second original + concatenation - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[2].Kind); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[3].Kind); - } - - [Fact] - public void GivenMixedNumericAndNonNumericExpressions_WhenRewritten_ThenOnlyNumericRewritten() - { - // Arrange - var numericExpr = Expression.GreaterThan(FieldName.Number, null, 5); - var stringExpr = Expression.Equals(FieldName.String, null, "test"); - - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, numericExpr, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, stringExpr, SearchParamTableExpressionKind.Normal), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Numeric gets concatenation, string does not - Assert.Equal(3, result.SearchParamTableExpressions.Count); - - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Concatenation, result.SearchParamTableExpressions[1].Kind); - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[2].Kind); - Assert.Same(stringExpr, result.SearchParamTableExpressions[2].Predicate); - } - - [Fact] - public void GivenEmptySqlRoot_WhenRewritten_ThenReturnsUnchanged() - { - // Arrange - var sqlRoot = new SqlRootExpression( - new System.Collections.Generic.List(), - new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenChainExpression_WhenRewritten_ThenSkipsChainExpressions() - { - // Arrange - Chain expressions should be skipped by ConcatenationRewriter - var expression = Expression.GreaterThan(FieldName.Number, null, 10); - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.Chain), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Chain expression should not be rewritten - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenIncludeExpression_WhenRewritten_ThenSkipsIncludeExpressions() - { - // Arrange - var expression = Expression.GreaterThan(FieldName.Number, null, 10); - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.Include), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenSortExpression_WhenRewritten_ThenSkipsSortExpressions() - { - // Arrange - var expression = Expression.GreaterThan(FieldName.Number, null, 10); - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.Sort), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenAllExpression_WhenRewritten_ThenSkipsAllExpressions() - { - // Arrange - var expression = Expression.GreaterThan(FieldName.Number, null, 10); - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.All), - }; - - var sqlRoot = new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - - // Act - var result = (SqlRootExpression)sqlRoot.AcceptVisitor(NumericRangeRewriter.Instance, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - private static SqlRootExpression CreateSqlRootWithExpression(Expression expression) - { - var tableExpressions = new System.Collections.Generic.List - { - new SearchParamTableExpression(null, expression, SearchParamTableExpressionKind.Normal), - }; - - return new SqlRootExpression(tableExpressions, new System.Collections.Generic.List()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/PartitionEliminationRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/PartitionEliminationRewriterTests.cs deleted file mode 100644 index 3fef49be6f..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/PartitionEliminationRewriterTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Linq; -using System.Reflection; -using Microsoft.Health.Fhir.Core.Features.Definition; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; -using static Microsoft.Health.Fhir.Core.Features.Search.Expressions.Expression; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - [Trait(Traits.Category, Categories.CompartmentSearch)] - public class PartitionEliminationRewriterTests - { - private const short AllergyIntolerance = 1; - private const short Claim = 2; - private const short Condition = 3; - private const short Device = 4; - private const short DiagnosticReport = 5; - - private static readonly short[] AllTypes = Enumerable.Range(AllergyIntolerance, DiagnosticReport - AllergyIntolerance + 1).Select(i => (short)i).ToArray(); - private static readonly SearchParameterInfo TypeParameter = new(SearchParameterNames.ResourceType, SearchParameterNames.ResourceType); - private static readonly SearchParameterInfo IdParameter = new(SearchParameterNames.Id, SearchParameterNames.Id); - - private readonly ISqlServerFhirModel _fhirModel; - private ISearchParameterDefinitionManager _searchParameterDefinitionManager; - private PartitionEliminationRewriter _rewriter; - - public PartitionEliminationRewriterTests() - { - _searchParameterDefinitionManager = Substitute.For(); - _searchParameterDefinitionManager.GetSearchParameter(KnownResourceTypes.Resource, SearchParameterNames.ResourceType).Returns(TypeParameter); - - _fhirModel = Substitute.For(); - _fhirModel.ResourceTypeIdRange.Returns((AllergyIntolerance, DiagnosticReport)); - - foreach (FieldInfo fieldInfo in typeof(TypeConstraintVisitorTests).GetFields(BindingFlags.NonPublic | BindingFlags.Static).Where(fi => fi.IsLiteral && !fi.IsInitOnly)) - { - short id = (short)fieldInfo.GetValue(null); - _fhirModel.GetResourceTypeId(fieldInfo.Name).Returns(id); - _fhirModel.GetResourceTypeName(id).Returns(fieldInfo.Name); - } - - _rewriter = new PartitionEliminationRewriter(_fhirModel, new SchemaInformation(SchemaVersionConstants.PartitionedTables, SchemaVersionConstants.PartitionedTables), () => _searchParameterDefinitionManager); - } - - [Fact] - public void GivenACrossSystemQuery_WhenRewritten_GetsAllResourceTypes() - { - Expression rewritten = new SqlRootExpression( - Array.Empty(), - new[] { SearchParameter(IdParameter, Token("foo")) }) - .AcceptVisitor(_rewriter); - - Assert.Equal( - "(SqlRoot (SearchParamTables:) (ResourceTable: (Param _id (StringEquals TokenCode 'foo'))))", - rewritten.ToString()); - } - - [Fact] - public void GivenAnExpressionWithTypeConstraintWithoutAContinuationToken_WhenRewritten_RemainsTheSame() - { - var inputExpression = new SqlRootExpression( - Array.Empty(), - new[] { SearchParameter(TypeParameter, Token(nameof(Claim))) }); - - Expression rewritten = inputExpression.AcceptVisitor(_rewriter); - - Assert.Same(inputExpression, rewritten); - } - - [Fact] - public void GivenAnExpressionWithTypeSingleTypeAndAContinuationToken_WhenRewritten_GetsResourceSurrogateIdExpression() - { - var inputExpression = new SqlRootExpression( - Array.Empty(), - new[] { SearchParameter(TypeParameter, Token(nameof(Claim))), SearchParameter(SqlSearchParameters.PrimaryKeyParameter, GreaterThan(SqlFieldName.PrimaryKey, null, new PrimaryKeyValue(Claim, 22))) }); - - Expression rewritten = inputExpression.AcceptVisitor(_rewriter); - - Assert.Equal( - "(SqlRoot (SearchParamTables:) (ResourceTable: (Param _type (StringEquals TokenCode 'Claim')) (Param _resourceSurrogateId (FieldGreaterThan 100 22))))", - rewritten.ToString()); - } - - [Fact] - public void GivenAnExpressionWithMultipleTypesAndAContinuationToken_WhenRewritten_GetsResourceSurrogateIdExpression() - { - var inputExpression = new SqlRootExpression( - Array.Empty(), - new[] - { - SearchParameter(TypeParameter, Or(Token(nameof(Claim)), Token(nameof(Condition)), Token(nameof(Device)))), - SearchParameter(IdParameter, Token("foo")), - SearchParameter(SqlSearchParameters.PrimaryKeyParameter, GreaterThan(SqlFieldName.PrimaryKey, null, new PrimaryKeyValue(Condition, 22))), - }); - - Expression rewritten = inputExpression.AcceptVisitor(_rewriter); - - Assert.Equal( - "(SqlRoot (SearchParamTables:) (ResourceTable: (Param _id (StringEquals TokenCode 'foo')) (Param _primaryKey (FieldGreaterThan 108 (PrimaryKeyRange (PrimaryKey 3 22) (Next 4))))))", - rewritten.ToString()); - } - - [Fact] - public void GivenAnExpressionWithMultipleTypesAndAContinuationTokenInDescendingOrder_WhenRewritten_GetsResourceSurrogateIdExpression() - { - var inputExpression = new SqlRootExpression( - Array.Empty(), - new[] - { - SearchParameter(TypeParameter, Or(Token(nameof(Claim)), Token(nameof(Condition)), Token(nameof(Device)))), - SearchParameter(IdParameter, Token("foo")), - SearchParameter(SqlSearchParameters.PrimaryKeyParameter, LessThan(SqlFieldName.PrimaryKey, null, new PrimaryKeyValue(Condition, 22))), - }); - - Expression rewritten = inputExpression.AcceptVisitor(_rewriter); - - Assert.Equal( - "(SqlRoot (SearchParamTables:) (ResourceTable: (Param _id (StringEquals TokenCode 'foo')) (Param _primaryKey (FieldLessThan 108 (PrimaryKeyRange (PrimaryKey 3 22) (Next 2))))))", - rewritten.ToString()); - } - - private static StringExpression Token(string parameterValue) => StringEquals(FieldName.TokenCode, null, parameterValue, false); - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/RemoveIncludesRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/RemoveIncludesRewriterTests.cs deleted file mode 100644 index 491786e166..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/RemoveIncludesRewriterTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class RemoveIncludesRewriterTests - { - [Fact] - public void GivenAnExpressionWithIncludes_WhenVisitedByRemoveIncludesRewriter_IncludesAreRemoved() - { - IncludeExpression includeExpression = Expression.Include(new[] { "a" }, new SearchParameterInfo("p", "Token"), "Observation", "Patient", null, false, false, false); - - BinaryExpression fieldExpression = Expression.Equals(FieldName.Number, null, 1); - - Assert.Null(includeExpression.AcceptVisitor(RemoveIncludesRewriter.Instance)); - Assert.Null(Expression.And(includeExpression, includeExpression).AcceptVisitor(RemoveIncludesRewriter.Instance)); - - Assert.Same(fieldExpression, fieldExpression.AcceptVisitor(RemoveIncludesRewriter.Instance)); - var andWithoutIncludes = Expression.And(fieldExpression, fieldExpression); - Assert.Same(andWithoutIncludes, andWithoutIncludes.AcceptVisitor(RemoveIncludesRewriter.Instance)); - - Assert.Same(fieldExpression, Expression.And(includeExpression, fieldExpression).AcceptVisitor(RemoveIncludesRewriter.Instance)); - Assert.Same(fieldExpression, Expression.And(fieldExpression, includeExpression).AcceptVisitor(RemoveIncludesRewriter.Instance)); - - Assert.Equal(andWithoutIncludes.ToString(), Expression.And(includeExpression, fieldExpression, fieldExpression).AcceptVisitor(RemoveIncludesRewriter.Instance).ToString()); - Assert.Equal(andWithoutIncludes.ToString(), Expression.And(fieldExpression, includeExpression, fieldExpression).AcceptVisitor(RemoveIncludesRewriter.Instance).ToString()); - Assert.Equal(andWithoutIncludes.ToString(), Expression.And(fieldExpression, fieldExpression, includeExpression).AcceptVisitor(RemoveIncludesRewriter.Instance).ToString()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ResourceColumnPredicatePushdownRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ResourceColumnPredicatePushdownRewriterTests.cs deleted file mode 100644 index 398a96b48e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ResourceColumnPredicatePushdownRewriterTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ResourceColumnPredicatePushdownRewriterTests - { - [Fact] - public void GivenExpressionWithNoTableExpressions_WhenRewritten_ReturnsOriginalExpression() - { - var inputExpression = SqlRootExpression.WithResourceTableExpressions( - Expression.SearchParameter(new SearchParameterInfo("abc", "abc"), Expression.Equals(FieldName.Number, null, 1))); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - Assert.Equal(inputExpression, visitedExpression); - } - - [Fact] - public void GivenExpressionWithNoResourceColumnExpressions_WhenRewritten_ReturnsOriginalExpression() - { - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal)); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - Assert.Equal(inputExpression, visitedExpression); - } - - [Theory] - [InlineData(SearchParameterNames.ResourceType)] - [InlineData(SqlSearchParameters.ResourceSurrogateIdParameterName)] - public void GivenExpressionWithExtractableResourceColumnExpression_WhenRewritten_CommonResourceExpressionAddedToTableExpressions(string paramName) - { - var inputExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, new SearchParameterExpression(new SearchParameterInfo("myParam", "myParam"), Expression.Equals(FieldName.String, null, "foo")), SearchParamTableExpressionKind.Normal), - }, - new List - { - new SearchParameterExpression(new SearchParameterInfo(paramName, paramName), Expression.Equals(FieldName.String, null, "TestParamValue")), - }); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - - Assert.Equal( - Expression.And(inputExpression.SearchParamTableExpressions[0].Predicate, inputExpression.ResourceTableExpressions[0]).ToString(), - visitedExpression.SearchParamTableExpressions[0].Predicate.ToString()); - } - - [Fact] - public void GivenExpressionWithMultipleExtractableResourceColumnExpressions_WhenRewritten_CommonResourceExpressionsAddedToTableExpressions() - { - var inputExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal), - }, - new List - { - new SearchParameterExpression(new SearchParameterInfo(SearchParameterNames.ResourceType, SearchParameterNames.ResourceType), Expression.Equals(FieldName.String, null, "TestParamValue1")), - new SearchParameterExpression(new SearchParameterInfo(SqlSearchParameters.ResourceSurrogateIdParameterName, SqlSearchParameters.ResourceSurrogateIdParameterName), Expression.Equals(FieldName.String, null, "TestParamValue2")), - }); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - Assert.Equal(Expression.And(inputExpression.ResourceTableExpressions).ToString(), visitedExpression.SearchParamTableExpressions[0].Predicate.ToString()); - } - - [Theory] - [InlineData(SearchParameterNames.ResourceType)] - [InlineData(SqlSearchParameters.ResourceSurrogateIdParameterName)] - public void GivenExpressionWithMultipleResourceColumnExpressions_WhenRewritten_ResourceColumnPredicatesClearedAndReplacedWithAllExpression(string paramName) - { - var inputExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, new SearchParameterExpression(new SearchParameterInfo("myParam", "myParam"), Expression.Equals(FieldName.String, null, "foo")), SearchParamTableExpressionKind.Normal), - }, - new List - { - new SearchParameterExpression(new SearchParameterInfo(paramName, paramName), Expression.Equals(FieldName.String, null, "ExtractableTestParamValue")), - new SearchParameterExpression(new SearchParameterInfo(SearchParameterNames.Id, SearchParameterNames.Id), Expression.Equals(FieldName.String, null, "myid")), - }); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - Assert.Empty(visitedExpression.ResourceTableExpressions); - Assert.Equal(new SearchParamTableExpression(null, Expression.And(inputExpression.ResourceTableExpressions[0], inputExpression.ResourceTableExpressions[1]), SearchParamTableExpressionKind.All).ToString(), visitedExpression.SearchParamTableExpressions[0].ToString()); - } - - [Fact] - public void GivenSqlRootExpressionWithResourceColumnPredicateAndOnlyIncludeTableExpression_WhenRewritten_ResourceColumnIsPreserved() - { - var inputExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Include), - }, - new List - { - new SearchParameterExpression(new SearchParameterInfo(SearchParameterNames.ResourceType, SearchParameterNames.ResourceType), Expression.Equals(FieldName.String, null, "TestParamValue1")), - }); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - Assert.Same(inputExpression.ResourceTableExpressions, visitedExpression.ResourceTableExpressions); - } - - [Theory] - [InlineData(SearchParameterNames.ResourceType)] - [InlineData(SqlSearchParameters.ResourceSurrogateIdParameterName)] - public void GivenExpressionWithResourceColumnnAndChainedExpressions_WhenRewritten_ResourceColumnPredicatesPromotedToChainTableExpression(string paramName) - { - var inputExpression = new SqlRootExpression( - new List - { - new SearchParamTableExpression( - ChainLinkQueryGenerator.Instance, - new SqlChainLinkExpression(new[] { "Observation" }, new SearchParameterInfo("myref", "myref"), new[] { "Patient" }, false), - SearchParamTableExpressionKind.Chain, - 1), - new SearchParamTableExpression( - null, - new SearchParameterExpression(new SearchParameterInfo("myParam", "myParam"), Expression.Equals(FieldName.String, null, "foo")), - SearchParamTableExpressionKind.Normal, - 1), - }, - new List - { - new SearchParameterExpression(new SearchParameterInfo(paramName, paramName), Expression.Equals(FieldName.String, null, "ExtractableTestParamValue")), - }); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(ResourceColumnPredicatePushdownRewriter.Instance); - Assert.Equal(inputExpression.ResourceTableExpressions[0], ((SqlChainLinkExpression)visitedExpression.SearchParamTableExpressions[0].Predicate).ExpressionOnSource); - Assert.Equal(inputExpression.SearchParamTableExpressions[0].ChainLevel, visitedExpression.SearchParamTableExpressions[0].ChainLevel); - Assert.Same(inputExpression.SearchParamTableExpressions[1], visitedExpression.SearchParamTableExpressions[1]); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ScalarTemporalEqualityRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ScalarTemporalEqualityRewriterTests.cs deleted file mode 100644 index 2f85f02419..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ScalarTemporalEqualityRewriterTests.cs +++ /dev/null @@ -1,242 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ScalarTemporalEqualityRewriterTests : IClassFixture - { - private static readonly DateTimeOffset StartOfDay = new DateTimeOffset(2016, 7, 6, 0, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset EndOfDay = new DateTimeOffset(2016, 7, 6, 23, 59, 59, TimeSpan.Zero).AddTicks(9999999); - private static readonly DateTimeOffset StartOfLastDayOfMonth = new DateTimeOffset(2016, 7, 31, 0, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset EndOfLastDayOfMonth = new DateTimeOffset(2016, 7, 31, 23, 59, 59, TimeSpan.Zero).AddTicks(9999999); - private static readonly DateTimeOffset StartOfLastDayOfYear = new DateTimeOffset(2016, 12, 31, 0, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset EndOfLastDayOfYear = new DateTimeOffset(2016, 12, 31, 23, 59, 59, TimeSpan.Zero).AddTicks(9999999); - private static readonly DateTimeOffset StartOfMonth = new DateTimeOffset(2016, 7, 1, 0, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset EndOfMonth = new DateTimeOffset(2016, 7, 31, 23, 59, 59, TimeSpan.Zero).AddTicks(9999999); - - // The shared fixture initializes the static ModelInfoProvider with a compartment-aware - // provider. The ChainedExpression rewrite test below relies on that provider being set, - // and using the shared fixture (rather than mutating the global inline) keeps this class - // from clobbering the provider other parallel test classes depend on. - public ScalarTemporalEqualityRewriterTests(ModelInfoProviderFixture fixture) - { - _ = fixture; - } - - public static TheoryData ExactDayDates => new() - { - { StartOfDay, EndOfDay }, - { StartOfLastDayOfMonth, EndOfLastDayOfMonth }, - { StartOfLastDayOfYear, EndOfLastDayOfYear }, - }; - - public static TheoryData NonRewritableExpressions => new() - { - EqualityPattern(StartOfMonth, EndOfMonth), // month precision - Expression.GreaterThanOrEqual(FieldName.DateTimeStart, null, StartOfDay), // single-sided predicate - Expression.GreaterThan(FieldName.DateTimeStart, null, EndOfDay), // range operator - EqualityPattern(StartOfDay.AddDays(-30), EndOfDay.AddDays(30)), // approximate / multi-day window - }; - - public static TheoryData NonAllowListedParameters => new() - { - BuildBirthdateParam(new Uri("http://example.org/SearchParameter/test-date")), - BuildBirthdateParam(searchParamType: SearchParamType.String), - new SearchParameterInfo( - "Patient-code-birthdate", - "code-birthdate", - SearchParamType.Composite, - new Uri("http://example.org/SearchParameter/Patient-code-birthdate"), - expression: "Patient", - baseResourceTypes: new[] { "Patient" }), - }; - - private static SearchParameterInfo BuildBirthdateParam(Uri url = null, SearchParamType searchParamType = SearchParamType.Date) - { - return new SearchParameterInfo( - "birthdate", - "birthdate", - searchParamType, - url ?? new Uri("http://hl7.org/fhir/SearchParameter/individual-birthdate"), - expression: "Patient.birthDate", - baseResourceTypes: new[] { "Patient" }); - } - - private static SearchParameterInfo BuildReferenceParam() - { - return new SearchParameterInfo( - "Observation-patient", - "patient", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Observation-patient"), - expression: "Observation.subject", - baseResourceTypes: new[] { "Observation" }, - targetResourceTypes: new[] { "Patient" }); - } - - private static ChainedExpression BuildChainedExpression(Expression inner) - { - var expression = (ChainedExpression)RuntimeHelpers.GetUninitializedObject(typeof(ChainedExpression)); - SetBackingField(expression, nameof(ChainedExpression.ResourceTypes), new[] { "Observation" }); - SetBackingField(expression, nameof(ChainedExpression.ReferenceSearchParameter), BuildReferenceParam()); - SetBackingField(expression, nameof(ChainedExpression.TargetResourceTypes), new[] { "Patient" }); - SetBackingField(expression, nameof(ChainedExpression.Reversed), false); - SetBackingField(expression, nameof(ChainedExpression.Expression), inner); - return expression; - } - - private static void SetBackingField(ChainedExpression expression, string propertyName, T value) - { - FieldInfo field = typeof(ChainedExpression).GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); - field.SetValue(expression, value); - } - - private static MultiaryExpression EqualityPattern(DateTimeOffset start, DateTimeOffset end) => - Expression.And( - Expression.GreaterThanOrEqual(FieldName.DateTimeStart, null, start), - Expression.LessThanOrEqual(FieldName.DateTimeEnd, null, end)); - - [Theory] - [MemberData(nameof(ExactDayDates))] - public void GivenAllowListedBirthdateExactDay_WhenRewritten_ThenEmitsEndOnlyPredicate(DateTimeOffset start, DateTimeOffset end) - { - var expr = new SearchParameterExpression(BuildBirthdateParam(), EqualityPattern(start, end)); - - var result = expr.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance); - - AssertEndOnlyPredicate(result, end); - } - - [Fact] - public void GivenAllowListedBirthdateExactDayReversedOperandOrder_WhenRewritten_ThenEmitsEndOnlyPredicate() - { - var reversedPattern = Expression.And( - Expression.LessThanOrEqual(FieldName.DateTimeEnd, null, EndOfDay), - Expression.GreaterThanOrEqual(FieldName.DateTimeStart, null, StartOfDay)); - var expr = new SearchParameterExpression(BuildBirthdateParam(), reversedPattern); - - var result = expr.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance); - - AssertEndOnlyPredicate(result, EndOfDay); - } - - [Fact] - public void GivenAllowListedBirthdateExactDay_WhenRewritten_ThenNoUnionExpressionIsEmitted() - { - var expr = new SearchParameterExpression(BuildBirthdateParam(), EqualityPattern(StartOfDay, EndOfDay)); - - var result = expr.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance); - - Assert.IsNotType(result); - var searchParameter = Assert.IsType(result); - Assert.IsNotType(searchParameter.Expression); - } - - [Fact] - public void GivenAllowListedBirthdateExactDayInChainedExpression_WhenRewritten_ThenRewritesInnerToEndOnlyPredicate() - { - // Rewriting the inner predicate forces the base visitor to rebuild the ChainedExpression, - // whose constructor validates resource/target types against the static ModelInfoProvider - // (initialized by the shared ModelInfoProviderFixture). - var inner = new SearchParameterExpression(BuildBirthdateParam(), EqualityPattern(StartOfDay, EndOfDay)); - var expr = BuildChainedExpression(inner); - - var result = Assert.IsType(expr.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance)); - - Assert.NotSame(inner, result.Expression); - AssertEndOnlyPredicate(result.Expression, EndOfDay); - } - - [Fact] - public void GivenAllowListedBirthdateExactDayInsideUnionExpression_WhenRewritten_ThenRewritesInnerToEndOnlyPredicateWithoutNestedUnion() - { - // Day-precision collapses to a single end-only predicate (no UNION), so an eligible birthdate - // equality nested inside a UnionExpression (e.g. the SMART v2 scope union built in - // SearchOptionsFactory) is rewritten in place. The outer union is preserved and no invalid - // nested UnionExpression is produced. - var inner = new SearchParameterExpression(BuildBirthdateParam(), EqualityPattern(StartOfDay, EndOfDay)); - var union = Expression.Union(UnionOperator.All, new Expression[] { inner }); - - var result = Assert.IsType(union.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance)); - - Assert.DoesNotContain(result.Expressions, e => e is UnionExpression); - var rewritten = Assert.Single(result.Expressions); - AssertEndOnlyPredicate(rewritten, EndOfDay); - } - - [Theory] - [MemberData(nameof(NonRewritableExpressions))] - public void GivenAllowListedBirthdateWithNonExactDayExpression_WhenRewritten_ThenPassThrough(Expression inner) - { - var expr = new SearchParameterExpression(BuildBirthdateParam(), inner); - - var result = Assert.IsType(expr.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance)); - - Assert.Same(expr, result); - } - - [Theory] - [MemberData(nameof(NonAllowListedParameters))] - public void GivenNonAllowListedParameter_WhenEqualityPatternMatched_ThenPassThrough(SearchParameterInfo param) - { - var expr = new SearchParameterExpression(param, EqualityPattern(StartOfDay, EndOfDay)); - - var result = Assert.IsType(expr.AcceptVisitor(ScalarTemporalEqualityRewriter.Instance)); - - Assert.Same(expr, result); - } - - private static void AssertEndOnlyPredicate(Expression result, DateTimeOffset expectedEnd) - { - Assert.IsNotType(result); - AssertSearchParameterAnd( - result, - and => - { - Assert.Collection( - and.Expressions, - longerFlag => AssertIsLongerThanADayEquals(longerFlag, false), - endEq => - { - var binary = Assert.IsType(endEq); - Assert.Equal(FieldName.DateTimeEnd, binary.FieldName); - Assert.Equal(BinaryOperator.Equal, binary.BinaryOperator); - Assert.Equal(expectedEnd, binary.Value); - }); - }); - } - - private static void AssertSearchParameterAnd(Expression branch, Action assertAnd) - { - var searchParameter = Assert.IsType(branch); - var and = Assert.IsType(searchParameter.Expression); - Assert.Equal(MultiaryOperator.And, and.MultiaryOperation); - assertAnd(and); - } - - private static void AssertIsLongerThanADayEquals(Expression expr, bool expected) - { - var binary = Assert.IsType(expr); - Assert.Equal(SqlFieldName.DateTimeIsLongerThanADay, binary.FieldName); - Assert.Equal(BinaryOperator.Equal, binary.BinaryOperator); - Assert.Equal(expected, binary.Value); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SearchParamTableExpressionReordererTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SearchParamTableExpressionReordererTests.cs deleted file mode 100644 index d8243bfa6e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SearchParamTableExpressionReordererTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class SearchParamTableExpressionReordererTests - { - private static readonly SearchParameterExpression NormalExpression = new SearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), Expression.Equals(FieldName.TokenCode, null, "TestValue")); - private static readonly SearchParameterExpression NotExpression = new SearchParameterExpression(NormalExpression.Parameter, Expression.Not(NormalExpression.Expression)); - - [Fact] - public void GivenExpressionWithSingleTableExpression_WhenReordered_ReturnsOriginalExpression() - { - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Normal)); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(SearchParamTableExpressionReorderer.Instance); - Assert.Equal(inputExpression, visitedExpression); - } - - [Fact] - public void GivenExpressionWithMultipleTableExpressions_WhenReordered_DenormilizedExpressionReturnedFirst() - { - var tableExpressions = new List - { - new SearchParamTableExpression(new ReferenceQueryGenerator(), NormalExpression, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.All), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(SearchParamTableExpressionReorderer.Instance); - Assert.Collection(visitedExpression.SearchParamTableExpressions, new[] { 1, 0 }.Select>(x => e => Assert.Equal(tableExpressions[x], e)).ToArray()); - } - - [Fact] - public void GivenExpressionWithMultipleTableExpressions_WhenReordered_ReferenceExpressionReturnedBeforeNormal() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, NormalExpression, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(new ReferenceQueryGenerator(), NormalExpression, SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(SearchParamTableExpressionReorderer.Instance); - Assert.Collection(visitedExpression.SearchParamTableExpressions, new[] { 1, 0 }.Select>(x => e => Assert.Equal(tableExpressions[x], e)).ToArray()); - } - - [Fact] - [Trait(Traits.Category, Categories.CompartmentSearch)] - public void GivenExpressionWithMultipleTableExpressions_WhenReordered_CompartmentExpressionReturnedBeforeNormal() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, NormalExpression, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(new CompartmentQueryGenerator(), NormalExpression, SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(SearchParamTableExpressionReorderer.Instance); - Assert.Collection(visitedExpression.SearchParamTableExpressions, new[] { 1, 0 }.Select>(x => e => Assert.Equal(tableExpressions[x], e)).ToArray()); - } - - [Fact] - public void GivenExpressionWithMultipleTableExpressions_WhenReordered_MissingParameterExpressionReturnedBeforeNotExpression() - { - var tableExpressions = new List - { - new SearchParamTableExpression(null, NotExpression, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, new MissingSearchParameterExpression(new SearchParameterInfo("TestParam", "TestParam"), true), SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(SearchParamTableExpressionReorderer.Instance); - Assert.Collection(visitedExpression.SearchParamTableExpressions, new[] { 1, 0 }.Select>(x => e => Assert.Equal(tableExpressions[x], e)).ToArray()); - } - - [Fact] - public void GivenExpressionWithMultipleTableExpressions_WhenReordered_IncludeExpressionReturnedLast() - { - var tableExpressions = new List - { - new SearchParamTableExpression(new IncludeQueryGenerator(), NormalExpression, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, NormalExpression, SearchParamTableExpressionKind.Normal), - }; - - var inputExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - var visitedExpression = (SqlRootExpression)inputExpression.AcceptVisitor(SearchParamTableExpressionReorderer.Instance); - Assert.Collection(visitedExpression.SearchParamTableExpressions, new[] { 1, 0 }.Select>(x => e => Assert.Equal(tableExpressions[x], e)).ToArray()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlChainLinkExpressionTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlChainLinkExpressionTests.cs deleted file mode 100644 index 9d22139110..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlChainLinkExpressionTests.cs +++ /dev/null @@ -1,696 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class SqlChainLinkExpressionTests - { - private static readonly SearchParameterInfo ReferenceSearchParam = new SearchParameterInfo( - name: "subject", - code: "subject", - searchParamType: SearchParamType.Reference, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-subject")); - - [Fact] - public void GivenValidParameters_WhenConstructed_ThenPropertiesAreSetCorrectly() - { - var resourceTypes = new[] { "Observation" }; - var targetResourceTypes = new[] { "Patient" }; - var expressionOnSource = Expression.Equals(FieldName.TokenCode, null, "code1"); - var expressionOnTarget = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var expression = new SqlChainLinkExpression( - resourceTypes, - ReferenceSearchParam, - targetResourceTypes, - reversed: false, - expressionOnSource, - expressionOnTarget); - - Assert.Same(resourceTypes, expression.ResourceTypes); - Assert.Same(ReferenceSearchParam, expression.ReferenceSearchParameter); - Assert.Same(targetResourceTypes, expression.TargetResourceTypes); - Assert.False(expression.Reversed); - Assert.Same(expressionOnSource, expression.ExpressionOnSource); - Assert.Same(expressionOnTarget, expression.ExpressionOnTarget); - } - - [Fact] - public void GivenNullResourceTypes_WhenConstructed_ThenThrowsArgumentNullException() - { - Assert.Throws(() => - new SqlChainLinkExpression( - null, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false)); - } - - [Fact] - public void GivenNullReferenceSearchParameter_WhenConstructed_ThenThrowsArgumentNullException() - { - Assert.Throws(() => - new SqlChainLinkExpression( - new[] { "Observation" }, - null, - new[] { "Patient" }, - reversed: false)); - } - - [Fact] - public void GivenNullTargetResourceTypes_WhenConstructed_ThenThrowsArgumentNullException() - { - Assert.Throws(() => - new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - null, - reversed: false)); - } - - [Fact] - public void GivenNullExpressions_WhenConstructed_ThenExpressionsAreNull() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: null, - expressionOnTarget: null); - - Assert.Null(expression.ExpressionOnSource); - Assert.Null(expression.ExpressionOnTarget); - } - - [Fact] - public void GivenReversedTrue_WhenToString_ThenIncludesReverse() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: true); - - var result = expression.ToString(); - - Assert.Contains("Reverse", result); - Assert.Contains("SqlChainLink", result); - Assert.Contains("subject", result); - Assert.Contains("Patient", result); - } - - [Fact] - public void GivenReversedFalse_WhenToString_ThenDoesNotIncludeReverse() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var result = expression.ToString(); - - Assert.DoesNotContain("Reverse", result); - Assert.Contains("SqlChainLink", result); - } - - [Fact] - public void GivenMultipleTargetResourceTypes_WhenToString_ThenIncludesAllTypes() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient", "Practitioner", "Organization" }, - reversed: false); - - var result = expression.ToString(); - - Assert.Contains("Patient", result); - Assert.Contains("Practitioner", result); - Assert.Contains("Organization", result); - } - - [Fact] - public void GivenExpressionOnSource_WhenToString_ThenIncludesSource() - { - var sourceExpression = Expression.Equals(FieldName.TokenCode, null, "testCode"); - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: sourceExpression); - - var result = expression.ToString(); - - Assert.Contains("Source:", result); - } - - [Fact] - public void GivenExpressionOnTarget_WhenToString_ThenIncludesTarget() - { - var targetExpression = Expression.Equals(FieldName.TokenCode, null, "testCode"); - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: targetExpression); - - var result = expression.ToString(); - - Assert.Contains("Target:", result); - } - - [Fact] - public void GivenNullExpressions_WhenToString_ThenDoesNotIncludeSourceOrTarget() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var result = expression.ToString(); - - Assert.DoesNotContain("Source:", result); - Assert.DoesNotContain("Target:", result); - } - - [Fact] - public void GivenSameValues_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - Assert.True(expression2.ValueInsensitiveEquals(expression1)); - } - - [Fact] - public void GivenNull_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - Assert.False(expression.ValueInsensitiveEquals(null)); - } - - [Fact] - public void GivenDifferentExpressionType_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var chainExpression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var otherExpression = Expression.Equals(FieldName.TokenCode, null, "test"); - - Assert.False(chainExpression.ValueInsensitiveEquals(otherExpression)); - } - - [Fact] - public void GivenDifferentResourceTypes_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "DiagnosticReport" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentResourceTypesLength_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation", "DiagnosticReport" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentTargetResourceTypes_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Practitioner" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentTargetResourceTypesLength_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient", "Practitioner" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentReferenceSearchParameter_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var param1 = new SearchParameterInfo( - name: "subject", - code: "subject", - searchParamType: SearchParamType.Reference, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-subject")); - - var param2 = new SearchParameterInfo( - name: "patient", - code: "patient", - searchParamType: SearchParamType.Reference, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-patient")); - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - param1, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - param2, - new[] { "Patient" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentReversed_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: true); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenBothExpressionsOnSourceNull_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: null); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: null); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenOneExpressionOnSourceNull_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var sourceExpression = Expression.Equals(FieldName.TokenCode, null, "test"); - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: sourceExpression); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: null); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - Assert.False(expression2.ValueInsensitiveEquals(expression1)); - } - - [Fact] - public void GivenBothExpressionsOnTargetNull_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: null); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: null); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenOneExpressionOnTargetNull_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var targetExpression = Expression.Equals(FieldName.TokenCode, null, "test"); - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: targetExpression); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: null); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - Assert.False(expression2.ValueInsensitiveEquals(expression1)); - } - - [Fact] - public void GivenDifferentExpressionOnSource_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var sourceExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var sourceExpression2 = Expression.Equals(FieldName.TokenSystem, null, "system1"); // Different field - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: sourceExpression1); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: sourceExpression2); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentExpressionOnTarget_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var targetExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var targetExpression2 = Expression.Equals(FieldName.TokenSystem, null, "system1"); // Different field - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: targetExpression1); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: targetExpression2); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenSameExpressionTypeOnSourceDifferentValues_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - // Value-insensitive equals should ignore parameter values, only structure matters - var sourceExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var sourceExpression2 = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: sourceExpression1); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: sourceExpression2); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenSameExpressionTypeOnTargetDifferentValues_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - // Value-insensitive equals should ignore parameter values, only structure matters - var targetExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var targetExpression2 = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: targetExpression1); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnTarget: targetExpression2); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenMultipleResourceTypesInDifferentOrder_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation", "DiagnosticReport" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "DiagnosticReport", "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenMultipleTargetResourceTypesInDifferentOrder_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient", "Practitioner" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Practitioner", "Patient" }, - reversed: false); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenSameValuesWithExpressions_WhenAddValueInsensitiveHashCode_ThenProducesSameHashCode() - { - var sourceExpression = Expression.Equals(FieldName.TokenCode, null, "code1"); - var targetExpression = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - sourceExpression, - targetExpression); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - sourceExpression, - targetExpression); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.Equal(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenDifferentValues_WhenAddValueInsensitiveHashCode_ThenProducesDifferentHashCodes() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "DiagnosticReport" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenNullExpressions_WhenAddValueInsensitiveHashCode_ThenHandlesGracefully() - { - var expression = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false, - expressionOnSource: null, - expressionOnTarget: null); - - var hashCode = default(HashCode); - - var exception = Record.Exception(() => expression.AddValueInsensitiveHashCode(ref hashCode)); - - Assert.Null(exception); - } - - [Fact] - public void GivenMultipleResourceTypes_WhenAddValueInsensitiveHashCode_ThenIncludesAllTypes() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation", "DiagnosticReport" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenMultipleTargetResourceTypes_WhenAddValueInsensitiveHashCode_ThenIncludesAllTypes() - { - var expression1 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient", "Practitioner" }, - reversed: false); - - var expression2 = new SqlChainLinkExpression( - new[] { "Observation" }, - ReferenceSearchParam, - new[] { "Patient" }, - reversed: false); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlRootExpressionTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlRootExpressionTests.cs deleted file mode 100644 index 1e40ae2483..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlRootExpressionTests.cs +++ /dev/null @@ -1,427 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - /// - /// Unit tests for SqlRootExpression. - /// Tests business logic for constructor, helper methods, ValueInsensitiveEquals, and hash code generation. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class SqlRootExpressionTests - { - private static readonly SearchParameterInfo TestSearchParam = new SearchParameterInfo( - name: "status", - code: "status", - searchParamType: SearchParamType.Token, - url: new Uri("http://hl7.org/fhir/SearchParameter/Resource-status")); - - [Fact] - public void GivenValidParameters_WhenConstructed_ThenPropertiesAreSetCorrectly() - { - var searchParamTableExpressions = new List - { - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal), - }; - - var resourceTableExpressions = new List - { - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active")), - }; - - var expression = new SqlRootExpression(searchParamTableExpressions, resourceTableExpressions); - - Assert.Same(searchParamTableExpressions, expression.SearchParamTableExpressions); - Assert.Same(resourceTableExpressions, expression.ResourceTableExpressions); - } - - [Fact] - public void GivenNullSearchParamTableExpressions_WhenConstructed_ThenThrowsArgumentNullException() - { - var resourceTableExpressions = new List(); - - Assert.Throws(() => new SqlRootExpression(null, resourceTableExpressions)); - } - - [Fact] - public void GivenNullResourceTableExpressions_WhenConstructed_ThenThrowsArgumentNullException() - { - var searchParamTableExpressions = new List(); - - Assert.Throws(() => new SqlRootExpression(searchParamTableExpressions, null)); - } - - [Fact] - public void GivenEmptyCollections_WhenConstructed_ThenPropertiesAreEmpty() - { - var expression = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - Assert.Empty(expression.SearchParamTableExpressions); - Assert.Empty(expression.ResourceTableExpressions); - } - - [Fact] - public void GivenSearchParamTableExpressionsArray_WhenWithSearchParamTableExpressions_ThenCreatesExpressionWithEmptyResourceTable() - { - var tableExpression = new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal); - - var expression = SqlRootExpression.WithSearchParamTableExpressions(tableExpression); - - Assert.Single(expression.SearchParamTableExpressions); - Assert.Same(tableExpression, expression.SearchParamTableExpressions[0]); - Assert.Empty(expression.ResourceTableExpressions); - } - - [Fact] - public void GivenResourceTableExpressionsArray_WhenWithResourceTableExpressions_ThenCreatesExpressionWithEmptySearchParamTable() - { - var resourceExpression = new SearchParameterExpression( - TestSearchParam, - Expression.Equals(FieldName.TokenCode, null, "active")); - - var expression = SqlRootExpression.WithResourceTableExpressions(resourceExpression); - - Assert.Single(expression.ResourceTableExpressions); - Assert.Same(resourceExpression, expression.ResourceTableExpressions[0]); - Assert.Empty(expression.SearchParamTableExpressions); - } - - [Fact] - public void GivenSearchParamTableExpressionsList_WhenWithSearchParamTableExpressions_ThenCreatesExpressionWithEmptyResourceTable() - { - var tableExpressions = new List - { - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Chain), - }; - - var expression = SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - - Assert.Equal(2, expression.SearchParamTableExpressions.Count); - Assert.Same(tableExpressions, expression.SearchParamTableExpressions); - Assert.Empty(expression.ResourceTableExpressions); - } - - [Fact] - public void GivenResourceTableExpressionsList_WhenWithResourceTableExpressions_ThenCreatesExpressionWithEmptySearchParamTable() - { - var resourceExpressions = new List - { - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active")), - new MissingSearchParameterExpression(TestSearchParam, isMissing: true), - }; - - var expression = SqlRootExpression.WithResourceTableExpressions(resourceExpressions); - - Assert.Equal(2, expression.ResourceTableExpressions.Count); - Assert.Same(resourceExpressions, expression.ResourceTableExpressions); - Assert.Empty(expression.SearchParamTableExpressions); - } - - [Fact] - public void GivenEmptyCollections_WhenToString_ThenFormatsWithoutExpressions() - { - var expression = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - var result = expression.ToString(); - - Assert.Contains("SqlRoot", result); - Assert.Contains("SearchParamTables:", result); - Assert.Contains("ResourceTable:", result); - } - - [Fact] - public void GivenSearchParamTableExpressions_WhenToString_ThenIncludesExpressions() - { - var tableExpression = new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal); - var expression = SqlRootExpression.WithSearchParamTableExpressions(tableExpression); - - var result = expression.ToString(); - - Assert.Contains("SqlRoot", result); - Assert.Contains("SearchParamTables:", result); - Assert.Contains("Table", result); - } - - [Fact] - public void GivenResourceTableExpressions_WhenToString_ThenIncludesExpressions() - { - var resourceExpression = new SearchParameterExpression( - TestSearchParam, - Expression.Equals(FieldName.TokenCode, null, "active")); - var expression = SqlRootExpression.WithResourceTableExpressions(resourceExpression); - - var result = expression.ToString(); - - Assert.Contains("SqlRoot", result); - Assert.Contains("ResourceTable:", result); - } - - [Fact] - public void GivenSameExpressions_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - var tableExpression = new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal); - var resourceExpression = new SearchParameterExpression( - TestSearchParam, - Expression.Equals(FieldName.TokenCode, null, "active")); - - var expression1 = new SqlRootExpression( - new[] { tableExpression }, - new[] { resourceExpression }); - - var expression2 = new SqlRootExpression( - new[] { tableExpression }, - new[] { resourceExpression }); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - Assert.True(expression2.ValueInsensitiveEquals(expression1)); - } - - [Fact] - public void GivenNull_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression = SqlRootExpression.WithSearchParamTableExpressions(); - - Assert.False(expression.ValueInsensitiveEquals(null)); - } - - [Fact] - public void GivenDifferentExpressionType_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var rootExpression = SqlRootExpression.WithSearchParamTableExpressions(); - var otherExpression = Expression.Equals(FieldName.TokenCode, null, "test"); - - Assert.False(rootExpression.ValueInsensitiveEquals(otherExpression)); - } - - [Fact] - public void GivenDifferentSearchParamTableExpressionsCount_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal)); - - var expression2 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Chain)); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentResourceTableExpressionsCount_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = SqlRootExpression.WithResourceTableExpressions( - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active"))); - - var expression2 = SqlRootExpression.WithResourceTableExpressions( - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active")), - new MissingSearchParameterExpression(TestSearchParam, isMissing: true)); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentSearchParamTableExpressions_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal)); - - var expression2 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Chain)); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenDifferentResourceTableExpressions_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var expression1 = SqlRootExpression.WithResourceTableExpressions( - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active"))); - - var expression2 = SqlRootExpression.WithResourceTableExpressions( - new MissingSearchParameterExpression(TestSearchParam, isMissing: true)); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenSearchParamTableExpressionsInDifferentOrder_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var tableExpression1 = new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal); - var tableExpression2 = new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Chain); - - var expression1 = SqlRootExpression.WithSearchParamTableExpressions(tableExpression1, tableExpression2); - var expression2 = SqlRootExpression.WithSearchParamTableExpressions(tableExpression2, tableExpression1); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenResourceTableExpressionsInDifferentOrder_WhenValueInsensitiveEquals_ThenReturnsFalse() - { - var resourceExpression1 = new SearchParameterExpression( - TestSearchParam, - Expression.Equals(FieldName.TokenCode, null, "active")); - - var resourceExpression2 = new MissingSearchParameterExpression(TestSearchParam, isMissing: true); - - var expression1 = SqlRootExpression.WithResourceTableExpressions(resourceExpression1, resourceExpression2); - var expression2 = SqlRootExpression.WithResourceTableExpressions(resourceExpression2, resourceExpression1); - - Assert.False(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenEmptyExpressions_WhenValueInsensitiveEquals_ThenReturnsTrue() - { - var expression1 = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - var expression2 = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - Assert.True(expression1.ValueInsensitiveEquals(expression2)); - } - - [Fact] - public void GivenSameExpressions_WhenAddValueInsensitiveHashCode_ThenProducesSameHashCode() - { - var tableExpression = new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal); - var resourceExpression = new SearchParameterExpression( - TestSearchParam, - Expression.Equals(FieldName.TokenCode, null, "active")); - - var expression1 = new SqlRootExpression( - new[] { tableExpression }, - new[] { resourceExpression }); - - var expression2 = new SqlRootExpression( - new[] { tableExpression }, - new[] { resourceExpression }); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.Equal(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenDifferentSearchParamTableExpressions_WhenAddValueInsensitiveHashCode_ThenProducesDifferentHashCodes() - { - var expression1 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal)); - - var expression2 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(StringQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal)); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenDifferentResourceTableExpressions_WhenAddValueInsensitiveHashCode_ThenProducesDifferentHashCodes() - { - var expression1 = SqlRootExpression.WithResourceTableExpressions( - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active"))); - - var expression2 = SqlRootExpression.WithResourceTableExpressions( - new MissingSearchParameterExpression(TestSearchParam, isMissing: true)); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenEmptyCollections_WhenAddValueInsensitiveHashCode_ThenHandlesGracefully() - { - var expression = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - var hashCode = default(HashCode); - - var exception = Record.Exception(() => expression.AddValueInsensitiveHashCode(ref hashCode)); - - Assert.Null(exception); - } - - [Fact] - public void GivenMultipleSearchParamTableExpressions_WhenAddValueInsensitiveHashCode_ThenIncludesAllExpressions() - { - var expression1 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Chain)); - - var expression2 = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(TokenQueryGenerator.Instance, null, SearchParamTableExpressionKind.Normal)); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenMultipleResourceTableExpressions_WhenAddValueInsensitiveHashCode_ThenIncludesAllExpressions() - { - var expression1 = SqlRootExpression.WithResourceTableExpressions( - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active")), - new MissingSearchParameterExpression(TestSearchParam, isMissing: true)); - - var expression2 = SqlRootExpression.WithResourceTableExpressions( - new SearchParameterExpression(TestSearchParam, Expression.Equals(FieldName.TokenCode, null, "active"))); - - var hashCode1 = default(HashCode); - expression1.AddValueInsensitiveHashCode(ref hashCode1); - - var hashCode2 = default(HashCode); - expression2.AddValueInsensitiveHashCode(ref hashCode2); - - Assert.NotEqual(hashCode1.ToHashCode(), hashCode2.ToHashCode()); - } - - [Fact] - public void GivenNullVisitor_WhenAcceptVisitor_ThenThrowsArgumentNullException() - { - var expression = SqlRootExpression.WithSearchParamTableExpressions(); - - Assert.Throws(() => expression.AcceptVisitor(null, null)); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlServerSortingValidatorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlServerSortingValidatorTests.cs deleted file mode 100644 index 9cabbd508a..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SqlServerSortingValidatorTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class SqlServerSortingValidatorTests - { - private SqlServerSortingValidator _sqlServerSortingValidator; - private SchemaInformation _schemaInformation; - - private SearchParameterInfo _lastUpdatedParamInfo = new SearchParameterInfo(name: "lastupdated", code: "lastupdated", SearchParamType.Date, SearchParameterNames.LastUpdatedUri); - private SearchParameterInfo _resourceTypeParamInfo = new SearchParameterInfo(name: "type", code: "type", SearchParamType.String, SearchParameterNames.ResourceTypeUri); - - public SqlServerSortingValidatorTests() - { - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Max, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - _sqlServerSortingValidator = new SqlServerSortingValidator(_schemaInformation); - } - - [Theory] - [MemberData(nameof(GetSupportedSearchParamTypes))] - public void GivenSupportedSortParametersType_WhenValidating_ThenReturnsTrue(SearchParamType searchParamType) - { - SearchParameterInfo paramInfo = new SearchParameterInfo(name: "paramName", code: "paramName", searchParamType); - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (paramInfo, SortOrder.Ascending) }, - }; - - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.True(sortingValid); - Assert.Empty(errorMessage); - } - - [Theory] - [MemberData(nameof(GetSupportedSearchParamTypes))] - public void GivenSupportedSortParametersTypeForSchemaOlderThanV17_WhenValidating_ThenReturnsFalse(SearchParamType searchParamType) - { - SearchParameterInfo paramInfo = new SearchParameterInfo(name: "paramName", code: "paramName", searchParamType); - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (paramInfo, SortOrder.Ascending) }, - }; - - _schemaInformation.Current = (int)SchemaVersion.V16; - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.False(sortingValid); - Assert.NotEmpty(errorMessage); - } - - [Theory] - [MemberData(nameof(GetUnsupportedSearchParamTypes))] - public void GivenUnsupportedSortParametersType_WhenValidating_ThenReturnsFalse(SearchParamType searchParamType) - { - SearchParameterInfo paramInfo = new SearchParameterInfo(name: "paramName", code: "paramName", searchParamType); - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (paramInfo, SortOrder.Ascending) }, - }; - - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.False(sortingValid); - Assert.NotEmpty(errorMessage); - } - - [Fact] - public void GivenMultipleSortParameters_WhenValidating_ThenReturnsFalse() - { - SearchParameterInfo dateParamInfo = new SearchParameterInfo(name: "birthdate", code: "birthdate", SearchParamType.Date, new Uri("http://hl7.org/fhir/SearchParameter/individual-birthdate")); - SearchParameterInfo stringParamInfo = new SearchParameterInfo(name: "name", code: "name", SearchParamType.String, new Uri("http://hl7.org/fhir/SearchParameter/Patient-name")); - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (dateParamInfo, SortOrder.Ascending) }, - { (stringParamInfo, SortOrder.Descending) }, - }; - - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.False(sortingValid); - Assert.NotEmpty(errorMessage); - } - - [Theory] - [InlineData((int)SchemaVersion.V7)] - [InlineData((int)SchemaVersion.V8)] - public void GivenLastUpdatedAndResourceTypeSortForSchemaOlderThanV9_WhenValidating_ThenReturnsFalse(int schemaVersion) - { - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (_resourceTypeParamInfo, SortOrder.Ascending) }, - { (_lastUpdatedParamInfo, SortOrder.Ascending) }, - }; - - _schemaInformation.Current = schemaVersion; - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.False(sortingValid); - Assert.NotEmpty(errorMessage); - } - - [Theory] - [InlineData((int)SchemaVersion.V9)] - [InlineData((int)SchemaVersion.V10)] - public void GivenLastUpdatedAndResourceTypeSortForSchemaNewerThanV9_WhenValidating_ThenReturnsTrue(int schemaVersion) - { - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (_resourceTypeParamInfo, SortOrder.Ascending) }, - { (_lastUpdatedParamInfo, SortOrder.Ascending) }, - }; - - _schemaInformation.Current = schemaVersion; - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.True(sortingValid); - Assert.Empty(errorMessage); - } - - [Theory] - [InlineData(SortOrder.Ascending, SortOrder.Ascending, true)] - [InlineData(SortOrder.Ascending, SortOrder.Descending, false)] - [InlineData(SortOrder.Descending, SortOrder.Ascending, false)] - [InlineData(SortOrder.Descending, SortOrder.Descending, true)] - public void GivenLastUpdatedAndResourceTypeDifferentSortingOrder_WhenValidating_ThenReturnsExpectedResult(SortOrder sortOrder1, SortOrder sortOrder2, bool expectedResult) - { - IReadOnlyList<(SearchParameterInfo, SortOrder)> searchList = new List<(SearchParameterInfo, SortOrder)>() - { - { (_resourceTypeParamInfo, sortOrder1) }, - { (_lastUpdatedParamInfo, sortOrder2) }, - }; - - bool sortingValid = _sqlServerSortingValidator.ValidateSorting(searchList, out IReadOnlyList errorMessage); - Assert.Equal(expectedResult, sortingValid); - } - - public static IEnumerable GetSupportedSearchParamTypes() - { - yield return new object[] { SearchParamType.Date }; - yield return new object[] { SearchParamType.String }; - } - - public static IEnumerable GetUnsupportedSearchParamTypes() - { - yield return new object[] { SearchParamType.Number }; - yield return new object[] { SearchParamType.Quantity }; - yield return new object[] { SearchParamType.Composite }; - yield return new object[] { SearchParamType.Reference }; - yield return new object[] { SearchParamType.Uri }; - yield return new object[] { SearchParamType.Token }; - yield return new object[] { SearchParamType.Special }; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/TypeConstraintVisitorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/TypeConstraintVisitorTests.cs deleted file mode 100644 index 44170e9e48..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/TypeConstraintVisitorTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections; -using System.Linq; -using System.Reflection; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; -using static Microsoft.Health.Fhir.Core.Features.Search.Expressions.Expression; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TypeConstraintVisitorTests - { - private const short AllergyIntolerance = 1; - private const short Claim = 2; - private const short Condition = 3; - private const short Device = 4; - private const short DiagnosticReport = 5; - private const short Encounter = 6; - private const short Immunization = 7; - private const short Observation = 8; - private const short Patient = 9; - private const short Procedure = 10; - - private static readonly short[] AllTypes = Enumerable.Range(AllergyIntolerance, Procedure - AllergyIntolerance + 1).Select(i => (short)i).ToArray(); - private static readonly ISqlServerFhirModel FhirModel = CreateFhirModel(); - private static readonly SearchParameterInfo TypeParameter = new(SearchParameterNames.ResourceType, SearchParameterNames.ResourceType); - private static readonly SearchParameterInfo IdParameter = new(SearchParameterNames.Id, SearchParameterNames.Id); - - public static readonly TheoryData Data = new() - { - { null, AllTypes }, - { SearchParameter(IdParameter, Token("foo")), AllTypes }, - { SearchParameter(TypeParameter, Token(nameof(Patient))), new[] { Patient } }, - { And(SearchParameter(TypeParameter, Token(nameof(Patient))), SearchParameter(TypeParameter, Token(nameof(Observation)))), null }, - { SearchParameter(TypeParameter, Or(Token(nameof(Patient)), Token(nameof(Encounter)))), new[] { Patient, Encounter } }, - { And(SearchParameter(TypeParameter, Token(nameof(Patient))), SearchParameter(TypeParameter, Or(Token(nameof(Patient)), Token(nameof(Encounter))))), new[] { Patient } }, - { And(SearchParameter(TypeParameter, Token(nameof(Patient))), SearchParameter(TypeParameter, Or(Token(nameof(Device)), Token(nameof(Encounter))))), null }, - { And(SearchParameter(TypeParameter, Or(Token(nameof(Patient)), Token(nameof(Encounter)))), SearchParameter(TypeParameter, Token(nameof(Patient)))), new[] { Patient } }, - { new SqlRootExpression(Array.Empty(), new[] { SearchParameter(TypeParameter, Token(nameof(Patient))) }), new[] { Patient } }, - { new SqlRootExpression(Array.Empty(), new[] { SearchParameter(TypeParameter, Or(Token(nameof(Patient)), Token(nameof(Encounter)))) }), new[] { Patient, Encounter } }, - }; - - [Theory] - [MemberData(nameof(Data))] - public void GivenAnExpression_WhenVisited_DeterminesTheCorrectAllowedTypes(Expression expression, short[] expectedTypeIds) - { - var visitor = new TypeConstraintVisitor(); - - var result = visitor.Visit(expression, FhirModel); - - AssertAllowed(result, expectedTypeIds); - } - - private static void AssertAllowed((short? singleAllowedResourceTypeId, BitArray allAllowedTypes) result, params short[] expectedIds) - { - expectedIds ??= Array.Empty(); - - switch (expectedIds.Length) - { - case 0: - Assert.Null(result.singleAllowedResourceTypeId); - Assert.Null(result.allAllowedTypes); - return; - case 1: - Assert.Equal(expectedIds[0], result.singleAllowedResourceTypeId); - break; - default: - Assert.Null(result.singleAllowedResourceTypeId); - break; - } - - for (short i = FhirModel.ResourceTypeIdRange.lowestId; i <= FhirModel.ResourceTypeIdRange.highestId; i++) - { - Assert.Equal(expectedIds.Contains(i), result.allAllowedTypes[i]); - } - } - - private static ISqlServerFhirModel CreateFhirModel() - { - var sqlServerFhirModel = Substitute.For(); - sqlServerFhirModel.ResourceTypeIdRange.Returns((AllergyIntolerance, Procedure)); - - foreach (FieldInfo fieldInfo in typeof(TypeConstraintVisitorTests).GetFields(BindingFlags.NonPublic | BindingFlags.Static).Where(fi => fi.IsLiteral && !fi.IsInitOnly)) - { - sqlServerFhirModel.GetResourceTypeId(fieldInfo.Name).Returns((short)fieldInfo.GetValue(null)); - } - - return sqlServerFhirModel; - } - - private static StringExpression Token(string parameterValue) => StringEquals(FieldName.TokenCode, null, parameterValue, false); - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/UntypedReferenceRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/UntypedReferenceRewriterTests.cs deleted file mode 100644 index 155ae66222..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/UntypedReferenceRewriterTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions -{ - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class UntypedReferenceRewriterTests - { - private static readonly SearchParameterInfo ReferenceSearchParameterWithOneTargetType = new SearchParameterInfo("p", "p", SearchParamType.Reference, targetResourceTypes: new[] { "Organization" }); - - private static readonly SearchParameterInfo ReferenceSearchParameterWithTwoTargetTypes = new SearchParameterInfo("p2", "p2", SearchParamType.Reference, targetResourceTypes: new[] { "Patient", "Practitioner" }); - - private static readonly SearchParameterInfo CompositeParameter = new SearchParameterInfo( - "c", - "c", - SearchParamType.Composite, - components: new[] { new SearchParameterComponentInfo { ResolvedSearchParameter = ReferenceSearchParameterWithTwoTargetTypes }, new SearchParameterComponentInfo() { ResolvedSearchParameter = ReferenceSearchParameterWithOneTargetType }, new SearchParameterComponentInfo() { ResolvedSearchParameter = new SearchParameterInfo("number", "number", SearchParamType.Number) } }); - - [Fact] - public void GivenAnUntypedReferenceExpressionWithOneTargetType_WhenRewritten_ExpressionIncludesType() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - ReferenceSearchParameterWithOneTargetType, - Expression.StringEquals(FieldName.ReferenceResourceId, null, "myId", false)); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal("(Param p (And (StringEquals ReferenceResourceId 'myId') (StringEquals ReferenceResourceType 'Organization')))", outputExpression.ToString()); - } - - [Fact] - public void GivenAnUntypedReferenceExpressionWithOneTargetTypeWithMultipleOrs_WhenRewritten_ExpressionIncludesType() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - ReferenceSearchParameterWithOneTargetType, - Expression.Or( - Expression.StringEquals(FieldName.ReferenceResourceId, null, "myId1", false), - Expression.StringEquals(FieldName.ReferenceResourceId, null, "myId2", false))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal( - "(Param p (Or (And (StringEquals ReferenceResourceId 'myId1') (StringEquals ReferenceResourceType 'Organization')) (And (StringEquals ReferenceResourceId 'myId2') (StringEquals ReferenceResourceType 'Organization'))))", - outputExpression.ToString()); - } - - [Fact] - public void GivenATypedReferenceExpressionWithOneTargetType_WhenRewritten_DoesNotChange() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - ReferenceSearchParameterWithOneTargetType, - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceType, null, "Organization", false), - Expression.StringEquals(FieldName.ReferenceResourceId, null, "myId", false))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Same(inputExpression, outputExpression); - } - - [Fact] - public void GivenAnUntypedReferenceExpressionWithMultipleTargetTypes_WhenRewritten_ExpressionIncludesAllTypes() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - ReferenceSearchParameterWithTwoTargetTypes, - Expression.StringEquals(FieldName.ReferenceResourceId, null, "patientId", false)); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal("(Param p2 (And (StringEquals ReferenceResourceId 'patientId') (Or (StringEquals ReferenceResourceType 'Patient') (StringEquals ReferenceResourceType 'Practitioner') (MissingField ReferenceResourceType))))", outputExpression.ToString()); - } - - [Fact] - public void GivenAnUntypedReferenceExpressionWithMultipleTargetTypesAndMultipleOrs_WhenRewritten_ExpressionIncludesAllTypes() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - ReferenceSearchParameterWithTwoTargetTypes, - Expression.Or( - Expression.StringEquals(FieldName.ReferenceResourceId, null, "id1", false), - Expression.StringEquals(FieldName.ReferenceResourceId, null, "id2", false))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal( - "(Param p2 (Or (And (StringEquals ReferenceResourceId 'id1') (Or (StringEquals ReferenceResourceType 'Patient') (StringEquals ReferenceResourceType 'Practitioner') (MissingField ReferenceResourceType))) (And (StringEquals ReferenceResourceId 'id2') (Or (StringEquals ReferenceResourceType 'Patient') (StringEquals ReferenceResourceType 'Practitioner') (MissingField ReferenceResourceType)))))", - outputExpression.ToString()); - } - - [Fact] - public void GivenAnUntypedReferenceExpressionWithOneTargetTypeInACompositeSearchParameter_WhenRewritten_ExpressionIncludesType() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - CompositeParameter, - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, 0, "patientId", false), - Expression.StringEquals(FieldName.ReferenceResourceId, 1, "orgId", false), - Expression.Equals(FieldName.Number, 2, 8))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal("(Param c (And (StringEquals [0].ReferenceResourceId 'patientId') (StringEquals [1].ReferenceResourceId 'orgId') (FieldEqual [2].Number 8) (Or (StringEquals [0].ReferenceResourceType 'Patient') (StringEquals [0].ReferenceResourceType 'Practitioner') (MissingField [0].ReferenceResourceType)) (StringEquals [1].ReferenceResourceType 'Organization')))", outputExpression.ToString()); - } - - [Fact] - public void GivenAPartiallyTypedReferenceExpressionInACompositeSearchParameter_WhenRewritten_ExpressionIncludesTypesForUntypedComponents() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - CompositeParameter, - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, 0, "patientId", false), - Expression.StringEquals(FieldName.ReferenceResourceType, 1, "Organization", false), - Expression.StringEquals(FieldName.ReferenceResourceId, 1, "orgId", false), - Expression.Equals(FieldName.Number, 2, 8))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal("(Param c (And (StringEquals [0].ReferenceResourceId 'patientId') (StringEquals [1].ReferenceResourceType 'Organization') (StringEquals [1].ReferenceResourceId 'orgId') (FieldEqual [2].Number 8) (Or (StringEquals [0].ReferenceResourceType 'Patient') (StringEquals [0].ReferenceResourceType 'Practitioner') (MissingField [0].ReferenceResourceType))))", outputExpression.ToString()); - } - - [Fact] - public void GivenCompositeSearchParameterWithTypedAndUntypedReferencesORedTogether_WhenRewritten_ExpressionIncludesAllTypes() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - CompositeParameter, - Expression.Or( - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, 0, "patientId", false), - Expression.StringEquals(FieldName.ReferenceResourceId, 1, "orgId1", false), - Expression.Equals(FieldName.Number, 2, 8)), - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, 0, "patientId", false), - Expression.StringEquals(FieldName.ReferenceResourceId, 1, "orgId2", false), - Expression.Equals(FieldName.Number, 2, 8)), - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, 0, "patientId", false), - Expression.StringEquals(FieldName.ReferenceResourceId, 1, "orgId3", false), - Expression.Equals(FieldName.Number, 2, 8)))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal("(Param c (Or (And (StringEquals [0].ReferenceResourceId 'patientId') (StringEquals [1].ReferenceResourceId 'orgId1') (FieldEqual [2].Number 8) (Or (StringEquals [0].ReferenceResourceType 'Patient') (StringEquals [0].ReferenceResourceType 'Practitioner') (MissingField [0].ReferenceResourceType)) (StringEquals [1].ReferenceResourceType 'Organization')) (And (StringEquals [0].ReferenceResourceId 'patientId') (StringEquals [1].ReferenceResourceId 'orgId2') (FieldEqual [2].Number 8) (Or (StringEquals [0].ReferenceResourceType 'Patient') (StringEquals [0].ReferenceResourceType 'Practitioner') (MissingField [0].ReferenceResourceType)) (StringEquals [1].ReferenceResourceType 'Organization')) (And (StringEquals [0].ReferenceResourceId 'patientId') (StringEquals [1].ReferenceResourceId 'orgId3') (FieldEqual [2].Number 8) (Or (StringEquals [0].ReferenceResourceType 'Patient') (StringEquals [0].ReferenceResourceType 'Practitioner') (MissingField [0].ReferenceResourceType)) (StringEquals [1].ReferenceResourceType 'Organization'))))", outputExpression.ToString()); - } - - [Fact] - public void GivenAnUntypedReferenceExpressionWithMultipleTargetTypesInACompositeSearchParameter_WhenRewritten_ExpressionIncludesAllTypes() - { - SearchParameterExpression inputExpression = Expression.SearchParameter( - CompositeParameter, - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, 0, "patientId", false), - Expression.StringEquals(FieldName.ReferenceResourceId, 1, "orgId", false), - Expression.Equals(FieldName.Number, 2, 8))); - - Expression outputExpression = inputExpression.AcceptVisitor(UntypedReferenceRewriter.Instance); - - Assert.Equal("(Param c (And (StringEquals [0].ReferenceResourceId 'patientId') (StringEquals [1].ReferenceResourceId 'orgId') (FieldEqual [2].Number 8) (Or (StringEquals [0].ReferenceResourceType 'Patient') (StringEquals [0].ReferenceResourceType 'Practitioner') (MissingField [0].ReferenceResourceType)) (StringEquals [1].ReferenceResourceType 'Organization')))", outputExpression.ToString()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/DateTimeTableExpressionCombinerTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/DateTimeTableExpressionCombinerTests.cs deleted file mode 100644 index e7eb609555..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/DateTimeTableExpressionCombinerTests.cs +++ /dev/null @@ -1,1068 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors -{ - /// - /// Unit tests for DateTimeTableExpressionCombiner. - /// Tests the logic that combines DateTime search parameter table expressions into more efficient queries. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class DateTimeTableExpressionCombinerTests - { - private static readonly SearchParameterInfo DateSearchParam = new SearchParameterInfo( - name: "issued", - code: "issued", - searchParamType: SearchParamType.Date, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-issued")); - - private static readonly SearchParameterInfo TokenSearchParam = new SearchParameterInfo( - name: "status", - code: "status", - searchParamType: SearchParamType.Token, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-status")); - - private static readonly DateTimeOffset TestStartDate = new DateTimeOffset(2024, 4, 22, 0, 0, 0, TimeSpan.Zero); - private static readonly DateTimeOffset TestEndDate = new DateTimeOffset(2024, 4, 23, 0, 0, 0, TimeSpan.Zero); - - [Fact] - public void GivenEmptySearchParamTableExpressions_WhenVisitSqlRoot_ThenReturnsUnchangedExpression() - { - var rootExpression = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - var result = DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Same(rootExpression, result); - } - - [Fact] - public void GivenOneSearchParamTableExpression_WhenVisitSqlRoot_ThenReturnsUnchangedExpression() - { - var searchParamExpression = new SearchParameterExpression( - DateSearchParam, - Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate)); - - var tableExpression = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParamExpression, - SearchParamTableExpressionKind.Normal); - - var rootExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpression); - - var result = DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Same(rootExpression, result); - } - - [Fact] - public void GivenTwoDateTimeExpressionsWithGreaterThanOrEqualAndLessThanOrEqual_WhenVisitSqlRoot_ThenCombinesExpressions() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - var combinedExpression = result.SearchParamTableExpressions[0]; - Assert.IsType(combinedExpression.Predicate); - var searchParamExpression = (SearchParameterExpression)combinedExpression.Predicate; - Assert.IsType(searchParamExpression.Expression); - var multiaryExpression = (MultiaryExpression)searchParamExpression.Expression; - Assert.Equal(MultiaryOperator.And, multiaryExpression.MultiaryOperation); - Assert.Equal(2, multiaryExpression.Expressions.Count); - } - - [Fact] - public void GivenTwoDateTimeExpressionsWithGreaterThanAndLessThan_WhenVisitSqlRoot_ThenCombinesExpressions() - { - var greaterExpression = Expression.GreaterThan(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThan(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenTwoDateTimeExpressionsWithGreaterThanOrEqualAndLessThan_WhenVisitSqlRoot_ThenCombinesExpressions() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThan(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenTwoDateTimeExpressionsWithGreaterThanAndLessThanOrEqual_WhenVisitSqlRoot_ThenCombinesExpressions() - { - var greaterExpression = Expression.GreaterThan(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenTwoNonDateTimeExpressions_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var tokenExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var tokenExpression2 = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var searchParam1 = new SearchParameterExpression(TokenSearchParam, tokenExpression1); - var searchParam2 = new SearchParameterExpression(TokenSearchParam, tokenExpression2); - - var tableExpression1 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Same(tableExpression1, result.SearchParamTableExpressions[0]); - Assert.Same(tableExpression2, result.SearchParamTableExpressions[1]); - } - - [Fact] - public void GivenThreeDateTimeExpressions_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var expression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var expression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - var expression3 = Expression.GreaterThan(FieldName.DateTimeEnd, null, TestStartDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, expression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, expression2); - var searchParam3 = new SearchParameterExpression(DateSearchParam, expression3); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var tableExpression3 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam3, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2, tableExpression3 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(3, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenOneDateTimeExpression_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var expression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var searchParam = new SearchParameterExpression(DateSearchParam, expression); - - var tableExpression = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam, - SearchParamTableExpressionKind.Normal); - - var rootExpression = SqlRootExpression.WithSearchParamTableExpressions(tableExpression); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Same(rootExpression, result); - } - - [Fact] - public void GivenTwoDateTimeExpressionsWithWrongOperators_WhenVisitSqlRoot_ThenDoesNotCombine() - { - // Both are GreaterThan - missing LessThan - var expression1 = Expression.GreaterThan(FieldName.DateTimeEnd, null, TestStartDate); - var expression2 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, expression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, expression2); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenTwoDateTimeExpressionsWithWrongFields_WhenVisitSqlRoot_ThenDoesNotCombine() - { - // Both use DateTimeStart instead of End for GreaterThan - var expression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeStart, null, TestStartDate); - var expression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, expression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, expression2); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenMultipleSearchParameters_WhenVisitSqlRoot_ThenOnlyCombinesMatchingPairs() - { - var dateSearchParam1 = new SearchParameterInfo( - name: "issued", - code: "issued", - searchParamType: SearchParamType.Date, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-issued")); - - var dateSearchParam2 = new SearchParameterInfo( - name: "date", - code: "date", - searchParamType: SearchParamType.Date, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-date")); - - // First pair - should be combined - var expression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var expression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - // Second pair - different search parameter, should not be combined with first - var expression3 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var expression4 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(dateSearchParam1, expression1); - var searchParam2 = new SearchParameterExpression(dateSearchParam1, expression2); - var searchParam3 = new SearchParameterExpression(dateSearchParam2, expression3); - var searchParam4 = new SearchParameterExpression(dateSearchParam2, expression4); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var tableExpression3 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam3, - SearchParamTableExpressionKind.Normal); - - var tableExpression4 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam4, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2, tableExpression3, tableExpression4 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - // Both pairs should be combined, resulting in 2 expressions - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenMixedDateTimeAndTokenExpressions_WhenVisitSqlRoot_ThenOnlyCombinesDateTime() - { - var dateExpression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var dateExpression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - var tokenExpression = Expression.Equals(FieldName.TokenCode, null, "code1"); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, dateExpression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, dateExpression2); - var searchParam3 = new SearchParameterExpression(TokenSearchParam, tokenExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var tableExpression3 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - searchParam3, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2, tableExpression3 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - // Verify token expression is preserved (find it in the result) - var tokenTableExpression = result.SearchParamTableExpressions - .FirstOrDefault(e => e.QueryGenerator == TokenQueryGenerator.Instance); - Assert.NotNull(tokenTableExpression); - } - - [Fact] - public void GivenCombinedExpressions_WhenVisitSqlRoot_ThenCreatesCorrectMultiaryExpression() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - - var combinedExpression = result.SearchParamTableExpressions[0]; - var searchParamExpression = (SearchParameterExpression)combinedExpression.Predicate; - Assert.Same(DateSearchParam, searchParamExpression.Parameter); - - var multiaryExpression = (MultiaryExpression)searchParamExpression.Expression; - Assert.Equal(MultiaryOperator.And, multiaryExpression.MultiaryOperation); - Assert.Equal(2, multiaryExpression.Expressions.Count); - Assert.Contains(greaterExpression, multiaryExpression.Expressions); - Assert.Contains(lessExpression, multiaryExpression.Expressions); - } - - [Fact] - public void GivenCombinedExpressions_WhenVisitSqlRoot_ThenPreservesOtherExpressions() - { - var dateExpression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var dateExpression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - var tokenExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var tokenExpression2 = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var dateSearchParam1 = new SearchParameterExpression(DateSearchParam, dateExpression1); - var dateSearchParam2 = new SearchParameterExpression(DateSearchParam, dateExpression2); - var tokenSearchParam1 = new SearchParameterExpression(TokenSearchParam, tokenExpression1); - var tokenSearchParam2 = new SearchParameterExpression(TokenSearchParam, tokenExpression2); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - dateSearchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - tokenSearchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression3 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - dateSearchParam2, - SearchParamTableExpressionKind.Normal); - - var tableExpression4 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - tokenSearchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2, tableExpression3, tableExpression4 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(3, result.SearchParamTableExpressions.Count); - - // Verify token expressions are preserved - var tokenExpressions = result.SearchParamTableExpressions - .Where(e => e.QueryGenerator == TokenQueryGenerator.Instance) - .ToList(); - Assert.Equal(2, tokenExpressions.Count); - } - - [Fact] - public void GivenCombinedExpressions_WhenVisitSqlRoot_ThenUsesCorrectQueryGenerator() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - var combinedExpression = result.SearchParamTableExpressions[0]; - Assert.Same(DateTimeQueryGenerator.Instance, combinedExpression.QueryGenerator); - Assert.Equal(SearchParamTableExpressionKind.Normal, combinedExpression.Kind); - } - - [Fact] - public void GivenResourceTableExpressions_WhenVisitSqlRoot_ThenPreservesResourceTableExpressions() - { - var dateExpression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var dateExpression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, dateExpression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, dateExpression2); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var resourceExpression = new SearchParameterExpression( - TokenSearchParam, - Expression.Equals(FieldName.TokenCode, null, "active")); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - new[] { resourceExpression }); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - Assert.Single(result.ResourceTableExpressions); - Assert.Same(resourceExpression, result.ResourceTableExpressions[0]); - } - - [Fact] - public void GivenNullPredicate_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - null, - SearchParamTableExpressionKind.Normal); - - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var searchParam = new SearchParameterExpression(DateSearchParam, greaterExpression); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenNonBinaryExpression_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var multiaryExpression = Expression.And( - Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate), - Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate)); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, multiaryExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, multiaryExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenExpressionsInReverseOrder_WhenVisitSqlRoot_ThenStillCombines() - { - // LessThan first, then GreaterThan - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, lessExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, greaterExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenDateTimeExpressionsWithConcatenationKind_WhenVisitSqlRoot_ThenCombinesExpressionsAndPreservesKind() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Concatenation); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Concatenation); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - } - - [Fact] - public void GivenDateTimeExpressionsWithNotExistsKind_WhenVisitSqlRoot_ThenCombinesExpressionsAndUsesNormalKind() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.NotExists); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.NotExists); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - } - - [Fact] - public void GivenDateTimeExpressionsWithChainLevel_WhenVisitSqlRoot_ThenCombinesAndUsesZeroChainLevel() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal, - chainLevel: 1); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal, - chainLevel: 1); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - Assert.Equal(0, result.SearchParamTableExpressions[0].ChainLevel); - } - - [Fact] - public void GivenDateTimeExpressionsWithDifferentChainLevels_WhenVisitSqlRoot_ThenCombinesRegardlessOfChainLevel() - { - // Note: The current implementation groups by SearchParameterInfo only, - // so expressions with different chainLevels are still combined. - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal, - chainLevel: 0); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal, - chainLevel: 1); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - // Implementation combines expressions even with different chainLevels - // since grouping is only by SearchParameterInfo - Assert.Single(result.SearchParamTableExpressions); - Assert.Equal(0, result.SearchParamTableExpressions[0].ChainLevel); - } - - [Fact] - public void GivenFourDateTimeExpressionsForTwoParameters_WhenVisitSqlRoot_ThenCombinesBothPairs() - { - var dateSearchParam1 = new SearchParameterInfo( - name: "issued", - code: "issued", - searchParamType: SearchParamType.Date, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-issued")); - - var dateSearchParam2 = new SearchParameterInfo( - name: "date", - code: "date", - searchParamType: SearchParamType.Date, - url: new Uri("http://hl7.org/fhir/SearchParameter/Observation-date")); - - var expression1 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var expression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - var expression3 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var expression4 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(dateSearchParam1, expression1); - var searchParam2 = new SearchParameterExpression(dateSearchParam1, expression2); - var searchParam3 = new SearchParameterExpression(dateSearchParam2, expression3); - var searchParam4 = new SearchParameterExpression(dateSearchParam2, expression4); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var tableExpression3 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam3, - SearchParamTableExpressionKind.Normal); - - var tableExpression4 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam4, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2, tableExpression3, tableExpression4 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - foreach (var tableExpression in result.SearchParamTableExpressions) - { - var searchParamExpression = (SearchParameterExpression)tableExpression.Predicate; - var multiaryExpression = (MultiaryExpression)searchParamExpression.Expression; - Assert.Equal(2, multiaryExpression.Expressions.Count); - } - } - - [Fact] - public void GivenDateTimeExpressionsWithComponentIndex_WhenVisitSqlRoot_ThenCombinesExpressionsCorrectly() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, 0, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, 0, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - - var combinedExpression = result.SearchParamTableExpressions[0]; - var searchParamExpression = (SearchParameterExpression)combinedExpression.Predicate; - var multiaryExpression = (MultiaryExpression)searchParamExpression.Expression; - - Assert.Equal(2, multiaryExpression.Expressions.Count); - Assert.Contains(greaterExpression, multiaryExpression.Expressions); - Assert.Contains(lessExpression, multiaryExpression.Expressions); - } - - [Fact] - public void GivenCombinedExpression_WhenVisitSqlRoot_ThenExpressionsInMultiaryAreExactlyOriginalExpressions() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - var combinedExpression = result.SearchParamTableExpressions[0]; - var searchParamExpression = (SearchParameterExpression)combinedExpression.Predicate; - var multiaryExpression = (MultiaryExpression)searchParamExpression.Expression; - - Assert.Equal(2, multiaryExpression.Expressions.Count); - Assert.True( - (ReferenceEquals(multiaryExpression.Expressions[0], greaterExpression) && ReferenceEquals(multiaryExpression.Expressions[1], lessExpression)) || - (ReferenceEquals(multiaryExpression.Expressions[0], lessExpression) && ReferenceEquals(multiaryExpression.Expressions[1], greaterExpression)), - "Combined multiary expression should contain exact references to original expressions"); - } - - [Fact] - public void GivenCombinedExpression_WhenVisitSqlRoot_ThenQueryGeneratorIsPreserved() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - var combinedExpression = result.SearchParamTableExpressions[0]; - Assert.Same(DateTimeQueryGenerator.Instance, combinedExpression.QueryGenerator); - } - - [Fact] - public void GivenMultipleTokenExpressionsWithSameParameter_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var tokenExpression1 = Expression.Equals(FieldName.TokenCode, null, "code1"); - var tokenExpression2 = Expression.Equals(FieldName.TokenCode, null, "code2"); - - var searchParam1 = new SearchParameterExpression(TokenSearchParam, tokenExpression1); - var searchParam2 = new SearchParameterExpression(TokenSearchParam, tokenExpression2); - - var tableExpression1 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - TokenQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Same(tableExpression1, result.SearchParamTableExpressions[0]); - Assert.Same(tableExpression2, result.SearchParamTableExpressions[1]); - } - - [Fact] - public void GivenBothGreaterThanOperators_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var expression1 = Expression.GreaterThan(FieldName.DateTimeEnd, null, TestStartDate); - var expression2 = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, expression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, expression2); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenBothLessThanOperators_WhenVisitSqlRoot_ThenDoesNotCombine() - { - var expression1 = Expression.LessThan(FieldName.DateTimeStart, null, TestStartDate); - var expression2 = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, expression1); - var searchParam2 = new SearchParameterExpression(DateSearchParam, expression2); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Normal); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Normal); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Equal(2, result.SearchParamTableExpressions.Count); - } - - [Fact] - public void GivenDateTimeExpressionsWithSort_WhenVisitSqlRoot_ThenCombinesExpressionsAndUsesNormalKind() - { - var greaterExpression = Expression.GreaterThanOrEqual(FieldName.DateTimeEnd, null, TestStartDate); - var lessExpression = Expression.LessThanOrEqual(FieldName.DateTimeStart, null, TestEndDate); - - var searchParam1 = new SearchParameterExpression(DateSearchParam, greaterExpression); - var searchParam2 = new SearchParameterExpression(DateSearchParam, lessExpression); - - var tableExpression1 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam1, - SearchParamTableExpressionKind.Sort); - - var tableExpression2 = new SearchParamTableExpression( - DateTimeQueryGenerator.Instance, - searchParam2, - SearchParamTableExpressionKind.Sort); - - var rootExpression = new SqlRootExpression( - new[] { tableExpression1, tableExpression2 }, - Array.Empty()); - - var result = (SqlRootExpression)DateTimeTableExpressionCombiner.Instance.VisitSqlRoot(rootExpression, null); - - Assert.Single(result.SearchParamTableExpressions); - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/LegacyStringOverflowRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/LegacyStringOverflowRewriterTests.cs deleted file mode 100644 index ad15c50033..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/LegacyStringOverflowRewriterTests.cs +++ /dev/null @@ -1,317 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors -{ - /// - /// Unit tests for LegacyStringOverflowRewriter. - /// Tests the rewriter's ability to transform string search expressions to handle text overflow - /// for legacy schema versions (pre-partitioned tables). - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class LegacyStringOverflowRewriterTests - { - private static readonly SearchParameterInfo StringSearchParam = new SearchParameterInfo( - name: "name", - code: "name", - searchParamType: SearchParamType.String, - url: new Uri("http://hl7.org/fhir/SearchParameter/Patient-name")); - - private static readonly SearchParameterInfo TokenSearchParam = new SearchParameterInfo( - name: "status", - code: "status", - searchParamType: SearchParamType.Token, - url: new Uri("http://hl7.org/fhir/SearchParameter/Patient-status")); - - private const int MaxTextLength = 256; // VLatest.StringSearchParam.Text.Metadata.MaxLength - - [Fact] - public void GivenEmptySearchParamTableExpressions_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - var sqlRoot = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - // Act - var result = LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenStringSearchParamWithShortValue_WhenVisited_ThenDoesNotAddConcatenation() - { - // Arrange - Short string that fits in Text column (equals operator, length <= 256) - var shortValue = "John"; - var stringExpression = Expression.StringEquals(FieldName.String, null, shortValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - No concatenation added for short strings with equals operator - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenStringSearchParamWithLongValue_WhenVisited_ThenAddsConcatenation() - { - // Arrange - Long string that exceeds Text column limit (257 chars) - // Note: The rewriter uses VLatest.StringSearchParam.Text.Metadata.MaxLength at runtime - var longValue = new string('a', 257); - var stringExpression = Expression.StringEquals(FieldName.String, null, longValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Long string with equals operator adds concatenation - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.Equals, concatenationString.StringOperator); - } - - [Fact] - public void GivenStringSearchParamWithBoundaryValue_WhenVisited_ThenDoesNotAddConcatenation() - { - // Arrange - String exactly at the limit (256 characters) - var boundaryValue = new string('b', MaxTextLength); - var stringExpression = Expression.StringEquals(FieldName.String, null, boundaryValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - At boundary, no concatenation needed for equals operator - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenStringSearchParamWithStartsWithOperator_WhenVisited_ThenAddsConcatenation() - { - // Arrange - StartsWith operator always checks overflow - var value = "start"; - var stringExpression = Expression.StartsWith(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - StartsWith adds concatenation regardless of length - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.StartsWith, concatenationString.StringOperator); - } - - [Fact] - public void GivenStringSearchParamWithContainsOperator_WhenVisited_ThenAddsConcatenation() - { - // Arrange - Contains operator always checks overflow - var value = "contains"; - var stringExpression = Expression.Contains(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Contains adds concatenation - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.Contains, concatenationString.StringOperator); - } - - [Fact] - public void GivenNonStringSearchParam_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - Token search parameter should not be rewritten - var tokenExpression = Expression.Equals(FieldName.TokenCode, null, "code"); - var searchParamExpression = new SearchParameterExpression(TokenSearchParam, tokenExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenChainExpression_WhenVisited_ThenDoesNotRewrite() - { - // Arrange - Chain expressions should be skipped even with long string values - var longValue = new string('c', 257); - var stringExpression = Expression.StringEquals(FieldName.String, null, longValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Chain)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Same(sqlRoot, result); - Assert.Single(result.SearchParamTableExpressions); - } - - [Fact] - public void GivenStringExpressionWithLongValue_WhenVisited_ThenPreservesValueInConcatenation() - { - // Arrange - String expression with long value to trigger concatenation - var longValue = new string('y', 257); - var stringExpression = Expression.StringEquals(FieldName.String, null, longValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Null(concatenationString.ComponentIndex); - Assert.Equal(longValue, concatenationString.Value); - } - - [Fact] - public void GivenIgnoreCaseTrue_WhenVisited_ThenPreservesIgnoreCase() - { - // Arrange - var longValue = new string('z', 257); - var stringExpression = new StringExpression(StringOperator.Equals, FieldName.String, null, longValue, ignoreCase: true); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.True(concatenationString.IgnoreCase); - } - - [Fact] - public void GivenStringSearchParamWithEndsWithOperator_WhenVisited_ThenAddsConcatenation() - { - // Arrange - EndsWith operator always checks overflow - var value = "end"; - var stringExpression = Expression.EndsWith(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - EndsWith adds concatenation regardless of length - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.EndsWith, concatenationString.StringOperator); - } - - [Fact] - public void GivenStringSearchParamWithNotStartsWithOperator_WhenVisited_ThenAddsConcatenation() - { - // Arrange - NotStartsWith operator always checks overflow - var value = "notstart"; - var stringExpression = Expression.NotStartsWith(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - NotStartsWith adds concatenation regardless of length - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.NotStartsWith, concatenationString.StringOperator); - } - - [Fact] - public void GivenStringSearchParamWithNotContainsOperator_WhenVisited_ThenAddsConcatenation() - { - // Arrange - NotContains operator always checks overflow - var value = "notcontains"; - var stringExpression = Expression.NotContains(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - NotContains adds concatenation regardless of length - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.NotContains, concatenationString.StringOperator); - } - - [Fact] - public void GivenStringSearchParamWithNotEndsWithOperator_WhenVisited_ThenAddsConcatenation() - { - // Arrange - NotEndsWith operator always checks overflow - var value = "notend"; - var stringExpression = Expression.NotEndsWith(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)LegacyStringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - NotEndsWith adds concatenation regardless of length - Assert.Equal(2, result.SearchParamTableExpressions.Count); - var concatenationSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - var concatenationString = (StringExpression)concatenationSearchParam.Expression; - Assert.Equal(SqlFieldName.TextOverflow, concatenationString.FieldName); - Assert.Equal(StringOperator.NotEndsWith, concatenationString.StringOperator); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/CompartmentQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/CompartmentQueryGeneratorTests.cs deleted file mode 100644 index 2826d89509..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/CompartmentQueryGeneratorTests.cs +++ /dev/null @@ -1,300 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Extensions; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for CompartmentQueryGenerator. - /// Tests the generator's ability to create SQL queries for compartment searches. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class CompartmentQueryGeneratorTests : IClassFixture - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public CompartmentQueryGeneratorTests(ModelInfoProviderFixture fixture) - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenCompartmentQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(CompartmentQueryGenerator.Instance); - } - - [Fact] - public void GivenCompartmentQueryGenerator_WhenTableAccessed_ThenReturnsCompartmentAssignmentTable() - { - var table = CompartmentQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.CompartmentAssignment.TableName, table.TableName); - } - - [Theory] - [InlineData("Patient", "123")] - [InlineData("Encounter", "abc-def")] - [InlineData("Device", "device-001")] - [InlineData("Practitioner", "pract-xyz")] - [InlineData("RelatedPerson", "rel-123-456")] - public void GivenCompartmentSearchExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(string compartmentType, string compartmentId) - { - byte compartmentTypeId = 1; - _model.GetCompartmentTypeId(compartmentType).Returns(compartmentTypeId); - - var expression = new CompartmentSearchExpression(compartmentType, compartmentId); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name, sql); - Assert.Matches($@"{VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name}\s*=\s*@\w+", sql); - Assert.Contains(VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name, sql); - Assert.Matches($@"{VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name}\s*=\s*@\w+", sql); - Assert.Contains("AND", sql); - - _model.Received(1).GetCompartmentTypeId(compartmentType); - } - - [Fact] - public void GivenCompartmentSearchExpression_WhenVisited_ThenAddsParametersCorrectly() - { - const string compartmentType = "Patient"; - const string compartmentId = "123"; - byte compartmentTypeId = 1; - - _model.GetCompartmentTypeId(compartmentType).Returns(compartmentTypeId); - - var expression = new CompartmentSearchExpression(compartmentType, compartmentId); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Matches($@"{VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name}\s*=\s*@\w+", sql); - Assert.Matches($@"{VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name}\s*=\s*@\w+", sql); - Assert.Contains("AND", sql); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenCompartmentSearchExpression_WhenVisited_ThenGeneratesCompleteQuery() - { - const string compartmentType = "Encounter"; - const string compartmentId = "enc-456"; - byte compartmentTypeId = 3; - - _model.GetCompartmentTypeId(compartmentType).Returns(compartmentTypeId); - - var expression = new CompartmentSearchExpression(compartmentType, compartmentId); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name, sql); - Assert.Contains(VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name, sql); - Assert.Contains("=", sql); - Assert.Contains("AND", sql); - Assert.NotEmpty(sql); - } - - [Theory] - [InlineData("ca")] - [InlineData("compartment")] - [InlineData("c1")] - public void GivenCompartmentSearchExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias(string tableAlias) - { - const string compartmentType = "Encounter"; - const string compartmentId = "enc-456"; - byte compartmentTypeId = 3; - - _model.GetCompartmentTypeId(compartmentType).Returns(compartmentTypeId); - - var expression = new CompartmentSearchExpression(compartmentType, compartmentId); - var context = CreateContext(tableAlias); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name}", sql); - Assert.Contains($"{tableAlias}.{VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name}", sql); - } - - [Theory] - [InlineData("Patient", 1)] - [InlineData("Encounter", 2)] - [InlineData("Device", 3)] - [InlineData("Practitioner", 4)] - [InlineData("RelatedPerson", 5)] - public void GivenDifferentCompartmentTypes_WhenVisited_ThenUsesCorrectCompartmentTypeId(string compartmentType, byte expectedId) - { - _model.GetCompartmentTypeId(compartmentType).Returns(expectedId); - - var expression = new CompartmentSearchExpression(compartmentType, "123"); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - _model.Received(1).GetCompartmentTypeId(compartmentType); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Theory] - [InlineData("Patient", 1)] - [InlineData("Encounter", 2)] - [InlineData("Device", 3)] - [InlineData("Practitioner", 4)] - [InlineData("RelatedPerson", 5)] - public void GivenDifferentCompartmentTypes_WhenVisited_ThenCallsModelCorrectly(string compartmentType, byte expectedId) - { - _model.GetCompartmentTypeId(compartmentType).Returns(expectedId); - - var expression = new CompartmentSearchExpression(compartmentType, "123"); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - _model.Received(1).GetCompartmentTypeId(compartmentType); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name, sql); - } - - [Fact] - public void GivenCompartmentSearchExpression_WhenVisited_ThenReturnsContext() - { - var expression = new CompartmentSearchExpression("Patient", "123"); - var context = CreateContext(); - _model.GetCompartmentTypeId("Patient").Returns((byte)1); - - var initialLength = context.StringBuilder.ToString().Length; - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var finalLength = context.StringBuilder.ToString().Length; - Assert.True(finalLength > initialLength, "StringBuilder should have content added"); - } - - [Fact] - public void GivenCompartmentSearchExpressionWithSpecialCharacters_WhenVisited_ThenHandlesCorrectly() - { - const string compartmentId = "patient-123/abc:xyz"; - _model.GetCompartmentTypeId("Patient").Returns((byte)1); - - var expression = new CompartmentSearchExpression("Patient", compartmentId); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name, sql); - Assert.Contains(VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name, sql); - - // Verify special characters are parameterized (not raw in SQL) - Assert.DoesNotContain(compartmentId, sql); - Assert.Matches($@"{VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name}\s*=\s*@\w+", sql); - Assert.Contains("AND", sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenMultipleCompartmentSearchExpressions_WhenVisited_ThenEachGeneratesSQL() - { - var expression1 = new CompartmentSearchExpression("Patient", "patient-1"); - var expression2 = new CompartmentSearchExpression("Encounter", "encounter-1"); - - _model.GetCompartmentTypeId("Patient").Returns((byte)1); - _model.GetCompartmentTypeId("Encounter").Returns((byte)2); - - var context1 = CreateContext(); - var context2 = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression1, context1); - CompartmentQueryGenerator.Instance.VisitCompartment(expression2, context2); - - var sql1 = context1.StringBuilder.ToString(); - var sql2 = context2.StringBuilder.ToString(); - - Assert.Contains(VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name, sql1); - Assert.Contains(VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name, sql1); - Assert.Contains("AND", sql1); - - Assert.Contains(VLatest.CompartmentAssignment.CompartmentTypeId.Metadata.Name, sql2); - Assert.Contains(VLatest.CompartmentAssignment.ReferenceResourceId.Metadata.Name, sql2); - Assert.Contains("AND", sql2); - - _model.Received(1).GetCompartmentTypeId("Patient"); - _model.Received(1).GetCompartmentTypeId("Encounter"); - - Assert.True(context1.Parameters.HasParametersToHash); - Assert.True(context2.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenCompartmentSearchExpression_WhenParametersAreHashable_ThenParametersIncludedInHash() - { - _model.GetCompartmentTypeId("Patient").Returns((byte)1); - - var expression = new CompartmentSearchExpression("Patient", "123"); - var context = CreateContext(); - - CompartmentQueryGenerator.Instance.VisitCompartment(expression, context); - - var hashingParams = context.Parameters; - Assert.True(hashingParams.HasParametersToHash); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/DateTimeQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/DateTimeQueryGeneratorTests.cs deleted file mode 100644 index 7a4a9b0643..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/DateTimeQueryGeneratorTests.cs +++ /dev/null @@ -1,251 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for DateTimeQueryGenerator. - /// Tests the generator's ability to create SQL queries for date/time searches. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class DateTimeQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public DateTimeQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenDateTimeQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(DateTimeQueryGenerator.Instance); - } - - [Fact] - public void GivenDateTimeQueryGenerator_WhenTableAccessed_ThenReturnsDateTimeSearchParamTable() - { - var table = DateTimeQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.DateTimeSearchParam.TableName, table.TableName); - } - - [Theory] - [InlineData(BinaryOperator.Equal)] - [InlineData(BinaryOperator.GreaterThan)] - [InlineData(BinaryOperator.GreaterThanOrEqual)] - [InlineData(BinaryOperator.LessThan)] - [InlineData(BinaryOperator.LessThanOrEqual)] - public void GivenDateTimeStartExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(BinaryOperator binaryOperator) - { - var dateTime = new DateTimeOffset(2023, 1, 15, 10, 30, 0, TimeSpan.Zero); - var expression = new BinaryExpression(binaryOperator, FieldName.DateTimeStart, null, dateTime); - var context = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.DateTimeSearchParam.StartDateTime.Metadata.Name, sql); - Assert.Matches($@"{VLatest.DateTimeSearchParam.StartDateTime.Metadata.Name}\s*{System.Text.RegularExpressions.Regex.Escape(GetOperatorString(binaryOperator))}\s*@\w+", sql); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Theory] - [InlineData(BinaryOperator.Equal)] - [InlineData(BinaryOperator.GreaterThan)] - [InlineData(BinaryOperator.GreaterThanOrEqual)] - [InlineData(BinaryOperator.LessThan)] - [InlineData(BinaryOperator.LessThanOrEqual)] - public void GivenDateTimeEndExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(BinaryOperator binaryOperator) - { - var dateTime = new DateTimeOffset(2023, 12, 31, 23, 59, 59, TimeSpan.Zero); - var expression = new BinaryExpression(binaryOperator, FieldName.DateTimeEnd, null, dateTime); - var context = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.DateTimeSearchParam.EndDateTime.Metadata.Name, sql); - Assert.Matches($@"{VLatest.DateTimeSearchParam.EndDateTime.Metadata.Name}\s*{System.Text.RegularExpressions.Regex.Escape(GetOperatorString(binaryOperator))}\s*@\w+", sql); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void GivenIsLongerThanADayExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(bool isLongerThanADay) - { - var expression = new BinaryExpression(BinaryOperator.Equal, SqlFieldName.DateTimeIsLongerThanADay, null, isLongerThanADay); - var context = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.DateTimeSearchParam.IsLongerThanADay.Metadata.Name, sql); - Assert.Matches($@"{VLatest.DateTimeSearchParam.IsLongerThanADay.Metadata.Name}\s*=\s*{(isLongerThanADay ? "1" : "0")}", sql); - Assert.NotEmpty(sql); - } - - [Fact] - public void GivenDateTimeExpressionWithComponentIndex_WhenVisited_ThenIncludesComponentIndex() - { - var dateTime = new DateTimeOffset(2023, 6, 15, 12, 0, 0, TimeSpan.Zero); - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.DateTimeStart, 0, dateTime); - var context = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.DateTimeSearchParam.StartDateTime.Metadata.Name + "1", sql); - } - - [Fact] - public void GivenDateTimeExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "dt"; - var dateTime = new DateTimeOffset(2023, 3, 20, 8, 0, 0, TimeSpan.Zero); - var expression = new BinaryExpression(BinaryOperator.GreaterThan, FieldName.DateTimeStart, null, dateTime); - var context = CreateContext(tableAlias); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.DateTimeSearchParam.StartDateTime.Metadata.Name}", sql); - } - - [Fact] - public void GivenMultipleDateTimeExpressions_WhenVisited_ThenEachGeneratesSQL() - { - var startDate = new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero); - var endDate = new DateTimeOffset(2023, 12, 31, 23, 59, 59, TimeSpan.Zero); - - var expression1 = new BinaryExpression(BinaryOperator.GreaterThanOrEqual, FieldName.DateTimeStart, null, startDate); - var expression2 = new BinaryExpression(BinaryOperator.LessThanOrEqual, FieldName.DateTimeEnd, null, endDate); - - var context1 = CreateContext(); - var context2 = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression1, context1); - DateTimeQueryGenerator.Instance.VisitBinary(expression2, context2); - - var sql1 = context1.StringBuilder.ToString(); - var sql2 = context2.StringBuilder.ToString(); - - Assert.Contains(VLatest.DateTimeSearchParam.StartDateTime.Metadata.Name, sql1); - Assert.Contains(">=", sql1); - - Assert.Contains(VLatest.DateTimeSearchParam.EndDateTime.Metadata.Name, sql2); - Assert.Contains("<=", sql2); - - Assert.True(context1.Parameters.HasParametersToHash); - Assert.True(context2.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenDateTimeExpression_WhenVisited_ThenConvertsToUtcDateTime() - { - var localDateTime = new DateTimeOffset(2023, 7, 4, 14, 30, 0, TimeSpan.FromHours(-5)); - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.DateTimeStart, null, localDateTime); - var context = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenInvalidFieldName_WhenVisited_ThenThrowsArgumentOutOfRangeException() - { - var dateTime = new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero); - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.TokenCode, null, dateTime); - var context = CreateContext(); - - Assert.Throws(() => - DateTimeQueryGenerator.Instance.VisitBinary(expression, context)); - } - - [Theory] - [InlineData("2020-01-01T00:00:00Z")] - [InlineData("2023-06-15T12:30:45Z")] - [InlineData("2025-12-31T23:59:59Z")] - public void GivenVariousDateTimes_WhenVisited_ThenGeneratesSQL(string dateTimeString) - { - var dateTime = DateTimeOffset.Parse(dateTimeString); - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.DateTimeStart, null, dateTime); - var context = CreateContext(); - - DateTimeQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.DateTimeSearchParam.StartDateTime.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - - private static string GetOperatorString(BinaryOperator binaryOperator) - { - return binaryOperator switch - { - BinaryOperator.Equal => "=", - BinaryOperator.GreaterThan => ">", - BinaryOperator.GreaterThanOrEqual => ">=", - BinaryOperator.LessThan => "<", - BinaryOperator.LessThanOrEqual => "<=", - BinaryOperator.NotEqual => "<>", - _ => throw new ArgumentOutOfRangeException(nameof(binaryOperator)), - }; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ModelInfoProviderFixture.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ModelInfoProviderFixture.cs deleted file mode 100644 index a32466e4fa..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ModelInfoProviderFixture.cs +++ /dev/null @@ -1,35 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Linq; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.Tests.Common; -using NSubstitute; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Fixture that initializes the static with a compartment-aware - /// provider. Shared by test classes that depend on the provider being set (for example - /// CompartmentQueryGeneratorTests and ScalarTemporalEqualityRewriterTests) so they do not have to - /// mutate the process-global provider inline and race other parallel test classes. - /// - public class ModelInfoProviderFixture - { - public ModelInfoProviderFixture() - { - var provider = MockModelInfoProviderBuilder - .Create(FhirSpecification.R4) - .AddKnownTypes("Encounter", "Device", "Practitioner", "RelatedPerson", "Claim", "Appointment", "Condition") - .Build(); - - // Manually override the compartment types to include all standard FHIR compartments - provider.GetCompartmentTypeNames().Returns(new[] { "Patient", "Practitioner", "Encounter", "Device", "RelatedPerson" }); - provider.IsKnownCompartmentType(Arg.Any()).Returns(x => new[] { "Patient", "Practitioner", "Encounter", "Device", "RelatedPerson" }.Contains((string)x[0])); - - ModelInfoProvider.SetProvider(provider); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/NumberQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/NumberQueryGeneratorTests.cs deleted file mode 100644 index 80099442cd..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/NumberQueryGeneratorTests.cs +++ /dev/null @@ -1,358 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for NumberQueryGenerator. - /// Tests the generator's ability to create SQL queries for number searches. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class NumberQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public NumberQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenNumberQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(NumberQueryGenerator.Instance); - } - - [Fact] - public void GivenNumberQueryGenerator_WhenTableAccessed_ThenReturnsNumberSearchParamTable() - { - var table = NumberQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.NumberSearchParam.TableName, table.TableName); - } - - [Theory] - [InlineData(BinaryOperator.Equal, 123.45)] - [InlineData(BinaryOperator.GreaterThan, 100.0)] - [InlineData(BinaryOperator.GreaterThanOrEqual, 50.5)] - [InlineData(BinaryOperator.LessThan, 200.0)] - [InlineData(BinaryOperator.LessThanOrEqual, 150.75)] - public void GivenNumberExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(BinaryOperator binaryOperator, decimal value) - { - var expression = new BinaryExpression(binaryOperator, FieldName.Number, null, value); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - Assert.Contains($"{VLatest.NumberSearchParam.SingleValue.Metadata.Name} IS NOT NULL", sql); - Assert.Matches($@"{VLatest.NumberSearchParam.SingleValue.Metadata.Name}\s*{System.Text.RegularExpressions.Regex.Escape(GetOperatorString(binaryOperator))}\s*@\w+", sql); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenNumberSingleValueExpression_WhenVisited_ThenChecksNotNull() - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, 123.45m); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - Assert.Contains("IS NOT NULL", sql); - Assert.Contains("AND", sql); - } - - [Theory] - [InlineData(BinaryOperator.Equal)] - [InlineData(BinaryOperator.GreaterThan)] - [InlineData(BinaryOperator.LessThan)] - public void GivenNumberLowExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(BinaryOperator binaryOperator) - { - var expression = new BinaryExpression(binaryOperator, SqlFieldName.NumberLow, null, 50.0m); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.LowValue.Metadata.Name, sql); - Assert.DoesNotContain("IS NOT NULL", sql); // LowValue is not nullable - Assert.Matches($@"{VLatest.NumberSearchParam.LowValue.Metadata.Name}\s*{System.Text.RegularExpressions.Regex.Escape(GetOperatorString(binaryOperator))}\s*@\w+", sql); - } - - [Theory] - [InlineData(BinaryOperator.Equal)] - [InlineData(BinaryOperator.GreaterThan)] - [InlineData(BinaryOperator.LessThan)] - public void GivenNumberHighExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(BinaryOperator binaryOperator) - { - var expression = new BinaryExpression(binaryOperator, SqlFieldName.NumberHigh, null, 100.0m); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.HighValue.Metadata.Name, sql); - Assert.DoesNotContain("IS NOT NULL", sql); // HighValue is not nullable - Assert.Matches($@"{VLatest.NumberSearchParam.HighValue.Metadata.Name}\s*{System.Text.RegularExpressions.Regex.Escape(GetOperatorString(binaryOperator))}\s*@\w+", sql); - } - - [Fact] - public void GivenNumberExpressionWithComponentIndex_WhenVisited_ThenIncludesComponentIndex() - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, 0, 123.45m); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name + "1", sql); - } - - [Fact] - public void GivenNumberExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "num"; - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, 123.45m); - var context = CreateContext(tableAlias); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.NumberSearchParam.SingleValue.Metadata.Name}", sql); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(-1)] - [InlineData(100)] - [InlineData(-100)] - public void GivenIntegerNumbers_WhenVisited_ThenGeneratesSQL(int value) - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, value); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Theory] - [InlineData(0.1)] - [InlineData(123.456)] - [InlineData(-456.789)] - [InlineData(999.999)] - public void GivenDecimalNumbers_WhenVisited_ThenGeneratesSQL(decimal value) - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, value); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenVeryLargeNumber_WhenVisited_ThenHandlesCorrectly() - { - var largeNumber = 999999999999999999.999999999999m; - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, largeNumber); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - } - - [Fact] - public void GivenVerySmallNumber_WhenVisited_ThenHandlesCorrectly() - { - var smallNumber = 0.000000000000000001m; - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, smallNumber); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - } - - [Fact] - public void GivenNegativeNumber_WhenVisited_ThenHandlesCorrectly() - { - var negativeNumber = -123.45m; - var expression = new BinaryExpression(BinaryOperator.LessThan, FieldName.Number, null, negativeNumber); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("<", sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - } - - [Fact] - public void GivenInvalidFieldName_WhenVisited_ThenThrowsArgumentOutOfRangeException() - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.TokenCode, null, 123); - var context = CreateContext(); - - Assert.Throws(() => - NumberQueryGenerator.Instance.VisitBinary(expression, context)); - } - - [Fact] - public void GivenMultipleNumberExpressions_WhenVisited_ThenEachGeneratesSQL() - { - var expression1 = new BinaryExpression(BinaryOperator.GreaterThan, FieldName.Number, null, 100m); - var expression2 = new BinaryExpression(BinaryOperator.LessThan, SqlFieldName.NumberHigh, null, 200m); - - var context1 = CreateContext(); - var context2 = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression1, context1); - NumberQueryGenerator.Instance.VisitBinary(expression2, context2); - - var sql1 = context1.StringBuilder.ToString(); - var sql2 = context2.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql1); - Assert.Contains(">", sql1); - - Assert.Contains(VLatest.NumberSearchParam.HighValue.Metadata.Name, sql2); - Assert.Contains("<", sql2); - - Assert.True(context1.Parameters.HasParametersToHash); - Assert.True(context2.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenNumberRangeQuery_WhenUsingLowAndHigh_ThenGeneratesCorrectSQL() - { - var lowExpression = new BinaryExpression(BinaryOperator.GreaterThanOrEqual, SqlFieldName.NumberLow, null, 50m); - var highExpression = new BinaryExpression(BinaryOperator.LessThanOrEqual, SqlFieldName.NumberHigh, null, 150m); - - var contextLow = CreateContext(); - var contextHigh = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(lowExpression, contextLow); - NumberQueryGenerator.Instance.VisitBinary(highExpression, contextHigh); - - var sqlLow = contextLow.StringBuilder.ToString(); - var sqlHigh = contextHigh.StringBuilder.ToString(); - - Assert.Contains(VLatest.NumberSearchParam.LowValue.Metadata.Name, sqlLow); - Assert.Contains(">=", sqlLow); - - Assert.Contains(VLatest.NumberSearchParam.HighValue.Metadata.Name, sqlHigh); - Assert.Contains("<=", sqlHigh); - } - - [Theory] - [InlineData(BinaryOperator.NotEqual)] - public void GivenNotEqualOperator_WhenVisited_ThenGeneratesCorrectSQL(BinaryOperator binaryOperator) - { - var expression = new BinaryExpression(binaryOperator, FieldName.Number, null, 100m); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("<>", sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - } - - [Fact] - public void GivenZeroValue_WhenVisited_ThenHandlesCorrectly() - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Number, null, 0m); - var context = CreateContext(); - - NumberQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.NumberSearchParam.SingleValue.Metadata.Name, sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - - private static string GetOperatorString(BinaryOperator binaryOperator) - { - return binaryOperator switch - { - BinaryOperator.Equal => "=", - BinaryOperator.GreaterThan => ">", - BinaryOperator.GreaterThanOrEqual => ">=", - BinaryOperator.LessThan => "<", - BinaryOperator.LessThanOrEqual => "<=", - BinaryOperator.NotEqual => "<>", - _ => throw new ArgumentOutOfRangeException(nameof(binaryOperator)), - }; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/PrimaryKeyRangeParameterQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/PrimaryKeyRangeParameterQueryGeneratorTests.cs deleted file mode 100644 index 5bf8fb3735..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/PrimaryKeyRangeParameterQueryGeneratorTests.cs +++ /dev/null @@ -1,195 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for PrimaryKeyRangeParameterQueryGenerator. - /// Tests the generator's ability to create SQL predicates for primary key range queries. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class PrimaryKeyRangeParameterQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public PrimaryKeyRangeParameterQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenSimplePrimaryKeyRange_WhenVisitBinary_ThenGeneratesCorrectSql() - { - // Arrange - var currentValue = new PrimaryKeyValue(1, 100); - var nextResourceTypeIds = new BitArray(10); - nextResourceTypeIds[2] = true; - nextResourceTypeIds[3] = true; - - var primaryKeyRange = new PrimaryKeyRange(currentValue, nextResourceTypeIds); - var expression = new BinaryExpression(BinaryOperator.GreaterThan, SqlFieldName.PrimaryKey, null, primaryKeyRange); - var context = CreateContext(); - - // Act - PrimaryKeyRangeParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - - // Verify full predicate shape: (ResourceTypeId = X AND ResourceSurrogateId > @...) OR ResourceTypeId IN (...) - Assert.Matches(@"ResourceTypeId\s*=\s*1", sql); // Current ResourceTypeId - Assert.Matches(@"ResourceSurrogateId\s*>\s*@\w+", sql); - Assert.Contains("OR", sql); - Assert.Matches(@"ResourceTypeId\s+IN\s*\(", sql); - - // Verify IN clause contains expected next type ids (2 and 3) - Assert.Contains("2", sql); - Assert.Contains("3", sql); - } - - [Fact] - public void GivenPrimaryKeyRangeWithMultipleNextTypes_WhenVisitBinary_ThenGeneratesInClauseWithMultipleIds() - { - // Arrange - var currentValue = new PrimaryKeyValue(1, 100); - var nextResourceTypeIds = new BitArray(10); - nextResourceTypeIds[2] = true; - nextResourceTypeIds[3] = true; - nextResourceTypeIds[7] = true; - - var primaryKeyRange = new PrimaryKeyRange(currentValue, nextResourceTypeIds); - var expression = new BinaryExpression(BinaryOperator.GreaterThan, SqlFieldName.PrimaryKey, null, primaryKeyRange); - var context = CreateContext(); - - // Act - PrimaryKeyRangeParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.Matches(@"ResourceTypeId\s+IN\s*\(", sql); - - // Verify IN clause contains the expected resource type ids (2, 3, 7) - Assert.Contains("2", sql); - Assert.Contains("3", sql); - Assert.Contains("7", sql); - - // Multiple values should be comma-separated - Assert.Contains(",", sql); - } - - [Fact] - public void GivenPrimaryKeyRange_WhenVisitBinary_ThenParametersNotIncludedInHash() - { - // Arrange - Primary key range parameters should not be hashed for query plan reuse - var currentValue = new PrimaryKeyValue(1, 100); - var nextResourceTypeIds = new BitArray(5); - nextResourceTypeIds[2] = true; - - var primaryKeyRange = new PrimaryKeyRange(currentValue, nextResourceTypeIds); - var expression = new BinaryExpression(BinaryOperator.GreaterThan, SqlFieldName.PrimaryKey, null, primaryKeyRange); - var context = CreateContext(); - - var initialHashedParams = context.Parameters.ParametersToHash.Count; - - // Act - PrimaryKeyRangeParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - Parameters added should not increase the hash count - Assert.Equal(initialHashedParams, context.Parameters.ParametersToHash.Count); - } - - [Fact] - public void GivenPrimaryKeyRangeWithLargeResourceTypeIdArray_WhenVisitBinary_ThenHandlesCorrectly() - { - // Arrange - var currentValue = new PrimaryKeyValue(1, 100); - var nextResourceTypeIds = new BitArray(100); - nextResourceTypeIds[10] = true; - nextResourceTypeIds[20] = true; - nextResourceTypeIds[50] = true; - nextResourceTypeIds[99] = true; - - var primaryKeyRange = new PrimaryKeyRange(currentValue, nextResourceTypeIds); - var expression = new BinaryExpression(BinaryOperator.GreaterThan, SqlFieldName.PrimaryKey, null, primaryKeyRange); - var context = CreateContext(); - - // Act - PrimaryKeyRangeParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.Contains("ResourceTypeId", sql); - Assert.Contains("IN", sql); - Assert.NotEmpty(sql); - } - - [Fact] - public void GivenPrimaryKeyRangeWithAllNextTypesSet_WhenVisitBinary_ThenGeneratesLargeInClause() - { - // Arrange - var currentValue = new PrimaryKeyValue(1, 100); - var nextResourceTypeIds = new BitArray(10); - for (int i = 0; i < 10; i++) - { - nextResourceTypeIds[i] = true; - } - - var primaryKeyRange = new PrimaryKeyRange(currentValue, nextResourceTypeIds); - var expression = new BinaryExpression(BinaryOperator.GreaterThan, SqlFieldName.PrimaryKey, null, primaryKeyRange); - var context = CreateContext(); - - // Act - PrimaryKeyRangeParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.Matches(@"ResourceTypeId\s+IN\s*\(", sql); - - // Verify IN clause contains all expected resource type ids (0-9) - for (int i = 0; i < 10; i++) - { - Assert.Matches($@"\b{i}\b", sql); - } - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/QuantityQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/QuantityQueryGeneratorTests.cs deleted file mode 100644 index e40978d834..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/QuantityQueryGeneratorTests.cs +++ /dev/null @@ -1,382 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for QuantityQueryGenerator. - /// Tests the generator's ability to create SQL queries for quantity searches. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class QuantityQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public QuantityQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenQuantityQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(QuantityQueryGenerator.Instance); - } - - [Fact] - public void GivenQuantityQueryGenerator_WhenTableAccessed_ThenReturnsQuantitySearchParamTable() - { - var table = QuantityQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.QuantitySearchParam.TableName, table.TableName); - } - - [Theory] - [InlineData(BinaryOperator.Equal, 5.4)] - [InlineData(BinaryOperator.GreaterThan, 10.0)] - [InlineData(BinaryOperator.LessThan, 20.0)] - public void GivenQuantitySingleValueExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(BinaryOperator binaryOperator, decimal value) - { - var expression = new BinaryExpression(binaryOperator, FieldName.Quantity, null, value); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.SingleValue.Metadata.Name, sql); - Assert.Contains($"{VLatest.QuantitySearchParam.SingleValue.Metadata.Name} IS NOT NULL", sql); - Assert.Matches($@"{VLatest.QuantitySearchParam.SingleValue.Metadata.Name}\s*{System.Text.RegularExpressions.Regex.Escape(GetOperatorString(binaryOperator))}\s*@\w+", sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenQuantityLowValueExpression_WhenVisited_ThenGeneratesCorrectSqlQuery() - { - var expression = new BinaryExpression(BinaryOperator.GreaterThanOrEqual, SqlFieldName.QuantityLow, null, 5.0m); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.LowValue.Metadata.Name, sql); - Assert.DoesNotContain("IS NOT NULL", sql); - Assert.Matches($@"{VLatest.QuantitySearchParam.LowValue.Metadata.Name}\s*>=\s*@\w+", sql); - } - - [Fact] - public void GivenQuantityHighValueExpression_WhenVisited_ThenGeneratesCorrectSqlQuery() - { - var expression = new BinaryExpression(BinaryOperator.LessThanOrEqual, SqlFieldName.QuantityHigh, null, 100.0m); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.HighValue.Metadata.Name, sql); - Assert.DoesNotContain("IS NOT NULL", sql); - Assert.Matches($@"{VLatest.QuantitySearchParam.HighValue.Metadata.Name}\s*<=\s*@\w+", sql); - } - - [Fact] - public void GivenQuantityCodeExpression_WhenCodeIdExists_ThenUsesDirectComparison() - { - const string codeValue = "mg"; - const int quantityCodeId = 1; - - _model.TryGetQuantityCodeId(codeValue, out Arg.Any()) - .Returns(x => - { - x[1] = quantityCodeId; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantityCode, null, codeValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.QuantityCodeId.Metadata.Name, sql); - Assert.Matches($@"{VLatest.QuantitySearchParam.QuantityCodeId.Metadata.Name}\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenQuantityCodeExpression_WhenCodeIdNotExists_ThenUsesScalarSubquery() - { - const string codeValue = "custom-unit"; - - _model.TryGetQuantityCodeId(codeValue, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantityCode, null, codeValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // dbo.QuantityCode has a primary key on Value, so there is always at most one matching - // QuantityCodeId — a scalar subquery with = is correct and avoids an unnecessary IN list. - Assert.Matches(@"QuantityCodeId\s*=\s*\(\s*SELECT", sql); - Assert.DoesNotContain(" IN ", sql); - Assert.Contains(VLatest.QuantityCode.TableName, sql); - } - - [Fact] - public void GivenQuantityCodeExpression_WhenCodeIdNotExists_ThenSubqueryFiltersOnValue() - { - const string codeValue = "custom-unit"; - - _model.TryGetQuantityCodeId(codeValue, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantityCode, null, codeValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // The subquery must select QuantityCodeId and filter on Value so the correct row is returned. - Assert.Contains(VLatest.QuantityCode.QuantityCodeId.Metadata.Name, sql); - Assert.Contains(VLatest.QuantityCode.Value.Metadata.Name, sql); - } - - [Fact] - public void GivenQuantitySystemExpression_WhenSystemIdExists_ThenUsesDirectComparison() - { - const string systemValue = "http://unitsofmeasure.org"; - const int systemId = 1; - - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(x => - { - x[1] = systemId; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantitySystem, null, systemValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.SystemId.Metadata.Name, sql); - Assert.Matches($@"{VLatest.QuantitySearchParam.SystemId.Metadata.Name}\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenQuantitySystemExpression_WhenSystemIdNotExists_ThenUsesScalarSubquery() - { - const string systemValue = "http://custom-system.org"; - - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantitySystem, null, systemValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // dbo.System has a primary key on Value, so there is always at most one matching - // SystemId — a scalar subquery with = is correct and avoids an unnecessary IN list. - Assert.Matches(@"SystemId\s*=\s*\(\s*SELECT", sql); - Assert.DoesNotContain(" IN ", sql); - Assert.Contains(VLatest.System.TableName, sql); - } - - [Fact] - public void GivenQuantitySystemExpression_WhenSystemIdNotExists_ThenSubqueryFiltersOnValue() - { - const string systemValue = "http://custom-system.org"; - - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantitySystem, null, systemValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // The subquery must select SystemId and filter on Value so the correct row is returned. - Assert.Contains(VLatest.System.SystemId.Metadata.Name, sql); - Assert.Contains(VLatest.System.Value.Metadata.Name, sql); - } - - [Fact] - public void GivenQuantityExpressionWithComponentIndex_WhenVisited_ThenIncludesComponentIndex() - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Quantity, 0, 5.4m); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.SingleValue.Metadata.Name + "1", sql); - } - - [Fact] - public void GivenQuantityExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "qty"; - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Quantity, null, 5.4m); - var context = CreateContext(tableAlias); - - QuantityQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.QuantitySearchParam.SingleValue.Metadata.Name}", sql); - } - - [Theory] - [InlineData("mg")] - [InlineData("kg")] - [InlineData("g")] - [InlineData("mmol/L")] - public void GivenVariousQuantityCodes_WhenVisited_ThenGeneratesSQL(string codeValue) - { - _model.TryGetQuantityCodeId(codeValue, out Arg.Any()) - .Returns(x => - { - x[1] = 1; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.QuantityCode, null, codeValue, true); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.QuantitySearchParam.QuantityCodeId.Metadata.Name, sql); - } - - [Fact] - public void GivenInvalidFieldNameForBinary_WhenVisited_ThenThrowsArgumentOutOfRangeException() - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.TokenCode, null, 5.4m); - var context = CreateContext(); - - Assert.Throws(() => - QuantityQueryGenerator.Instance.VisitBinary(expression, context)); - } - - [Fact] - public void GivenInvalidFieldNameForString_WhenVisited_ThenThrowsArgumentOutOfRangeException() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.String, null, "invalid", true); - var context = CreateContext(); - - Assert.Throws(() => - QuantityQueryGenerator.Instance.VisitString(expression, context)); - } - - [Fact] - public void GivenQuantityRangeQuery_WhenUsingLowAndHigh_ThenGeneratesCorrectSQL() - { - var lowExpression = new BinaryExpression(BinaryOperator.GreaterThanOrEqual, SqlFieldName.QuantityLow, null, 5.0m); - var highExpression = new BinaryExpression(BinaryOperator.LessThanOrEqual, SqlFieldName.QuantityHigh, null, 10.0m); - - var contextLow = CreateContext(); - var contextHigh = CreateContext(); - - QuantityQueryGenerator.Instance.VisitBinary(lowExpression, contextLow); - QuantityQueryGenerator.Instance.VisitBinary(highExpression, contextHigh); - - var sqlLow = contextLow.StringBuilder.ToString(); - var sqlHigh = contextHigh.StringBuilder.ToString(); - - Assert.Contains(VLatest.QuantitySearchParam.LowValue.Metadata.Name, sqlLow); - Assert.Contains(">=", sqlLow); - - Assert.Contains(VLatest.QuantitySearchParam.HighValue.Metadata.Name, sqlHigh); - Assert.Contains("<=", sqlHigh); - } - - [Theory] - [InlineData(0.001)] - [InlineData(5.4)] - [InlineData(100.0)] - [InlineData(999.999)] - public void GivenVariousQuantityValues_WhenVisited_ThenGeneratesSQL(decimal value) - { - var expression = new BinaryExpression(BinaryOperator.Equal, FieldName.Quantity, null, value); - var context = CreateContext(); - - QuantityQueryGenerator.Instance.VisitBinary(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.QuantitySearchParam.SingleValue.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - - private static string GetOperatorString(BinaryOperator binaryOperator) - { - return binaryOperator switch - { - BinaryOperator.Equal => "=", - BinaryOperator.GreaterThan => ">", - BinaryOperator.GreaterThanOrEqual => ">=", - BinaryOperator.LessThan => "<", - BinaryOperator.LessThanOrEqual => "<=", - BinaryOperator.NotEqual => "<>", - _ => throw new ArgumentOutOfRangeException(nameof(binaryOperator)), - }; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceQueryGeneratorTests.cs deleted file mode 100644 index 96f5f14c3e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceQueryGeneratorTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for ReferenceQueryGenerator. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ReferenceQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public ReferenceQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenReferenceQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(ReferenceQueryGenerator.Instance); - } - - [Fact] - public void GivenReferenceQueryGenerator_WhenTableAccessed_ThenReturnsReferenceSearchParamTable() - { - var table = ReferenceQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.ReferenceSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenReferenceResourceIdExpression_WhenVisited_ThenGeneratesCorrectSqlQuery() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.ReferenceResourceId, null, "patient-123", true); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.ReferenceSearchParam.ReferenceResourceId.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenReferenceResourceTypeExpression_WhenVisited_ThenUsesResourceTypeId() - { - const string resourceType = "Patient"; - const short resourceTypeId = 1; - - _model.TryGetResourceTypeId(resourceType, out Arg.Any()) - .Returns(x => - { - x[1] = resourceTypeId; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.ReferenceResourceType, null, resourceType, true); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{VLatest.ReferenceSearchParam.ReferenceResourceTypeId.Metadata.Name} = {resourceTypeId}", sql); - _model.Received(1).TryGetResourceTypeId(resourceType, out Arg.Any()); - } - - [Fact] - public void GivenReferenceBaseUriExpression_WhenVisited_ThenGeneratesCorrectSqlQuery() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.ReferenceBaseUri, null, "http://example.org", true); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.ReferenceSearchParam.BaseUri.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenMissingFieldExpression_WhenVisited_ThenChecksBaseUriIsNull() - { - var expression = new MissingFieldExpression(FieldName.ReferenceBaseUri, null); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitMissingField(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{VLatest.ReferenceSearchParam.BaseUri.Metadata.Name} IS NULL", sql); - } - - [Fact] - public void GivenMissingReferenceResourceTypeExpression_WhenVisited_ThenChecksReferenceResourceTypeIdIsNull() - { - var expression = new MissingFieldExpression(FieldName.ReferenceResourceType, null); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitMissingField(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{VLatest.ReferenceSearchParam.ReferenceResourceTypeId.Metadata.Name} IS NULL", sql); - } - - [Fact] - public void GivenInvalidFieldName_WhenVisited_ThenThrowsArgumentOutOfRangeException() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "invalid", true); - var context = CreateContext(); - - Assert.Throws(() => - ReferenceQueryGenerator.Instance.VisitString(expression, context)); - } - - [Fact] - public void GivenReferenceExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "ref"; - var expression = new StringExpression(StringOperator.Equals, FieldName.ReferenceResourceId, null, "patient-123", true); - var context = CreateContext(tableAlias); - - ReferenceQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.ReferenceSearchParam.ReferenceResourceId.Metadata.Name}", sql); - } - - [Theory] - [InlineData("Patient")] - [InlineData("Observation")] - [InlineData("Encounter")] - public void GivenVariousResourceTypes_WhenVisited_ThenCallsModelCorrectly(string resourceType) - { - _model.TryGetResourceTypeId(resourceType, out Arg.Any()) - .Returns(x => - { - x[1] = (short)1; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.ReferenceResourceType, null, resourceType, true); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitString(expression, context); - - _model.Received(1).TryGetResourceTypeId(resourceType, out Arg.Any()); - } - - [Fact] - public void GivenUnknownResourceType_WhenVisited_ThenGeneratesAlwaysFalsePredicate() - { - const string unknownType = "ActorDefinition"; - - _model.TryGetResourceTypeId(unknownType, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.ReferenceResourceType, null, unknownType, true); - var context = CreateContext(); - - ReferenceQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("0 = 1", sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceTokenCompositeQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceTokenCompositeQueryGeneratorTests.cs deleted file mode 100644 index c1cf0ebee0..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceTokenCompositeQueryGeneratorTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for ReferenceTokenCompositeQueryGenerator. - /// Tests the generator's ability to delegate to component generators and handle composite search parameters. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ReferenceTokenCompositeQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public ReferenceTokenCompositeQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenReferenceTokenCompositeQueryGenerator_WhenTableAccessed_ThenReturnsReferenceTokenCompositeSearchParamTable() - { - var table = ReferenceTokenCompositeQueryGenerator.Instance.Table; - - Assert.NotNull(table); - Assert.Equal(VLatest.ReferenceTokenCompositeSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenStringExpressionForReferenceResourceIdWithComponentIndex0_WhenVisitString_ThenDelegatesToReferenceQueryGenerator() - { - // Arrange - Component index 0 should delegate to ReferenceQueryGenerator - var expression = new StringExpression( - StringOperator.Equals, - FieldName.ReferenceResourceId, - componentIndex: 0, - value: "patient123", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ReferenceTokenCompositeQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // ReferenceQueryGenerator should generate SQL for reference resource ID with correct component column - Assert.Matches(@"ReferenceResourceId1\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenStringExpressionForTokenCodeWithComponentIndex1_WhenVisitString_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - Component index 1 should delegate to TokenQueryGenerator - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 1, - value: "active", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ReferenceTokenCompositeQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // TokenQueryGenerator should generate SQL for token code on component 2 - Assert.Matches(@"Code2\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenMissingFieldExpressionWithComponentIndex0_WhenVisitMissingField_ThenDelegatesToReferenceQueryGenerator() - { - // Arrange - var expression = new MissingFieldExpression(FieldName.ReferenceBaseUri, componentIndex: 0); - var context = CreateContext(); - - // Act - ReferenceTokenCompositeQueryGenerator.Instance.VisitMissingField(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // Should check for NULL base URI on component 1 - Assert.Contains("BaseUri1 IS NULL", sql); - } - - [Fact] - public void GivenMissingFieldExpressionWithComponentIndex1_WhenVisitMissingField_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - var expression = new MissingFieldExpression(FieldName.TokenSystem, componentIndex: 1); - var context = CreateContext(); - - // Act - ReferenceTokenCompositeQueryGenerator.Instance.VisitMissingField(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // Should check for NULL token system on component 2 - Assert.Contains("SystemId2 IS NULL", sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ResourceIdParameterQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ResourceIdParameterQueryGeneratorTests.cs deleted file mode 100644 index ff41b32398..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ResourceIdParameterQueryGeneratorTests.cs +++ /dev/null @@ -1,189 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for ResourceIdParameterQueryGenerator. - /// Tests the generator's ability to handle resource ID searches with various string operators. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ResourceIdParameterQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public ResourceIdParameterQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenStringExpressionWithEqualsOperator_WhenVisitString_ThenGeneratesSqlWithEquality() - { - // Arrange - Test exact match search (_id=123) - var expression = new StringExpression( - StringOperator.Equals, - FieldName.String, - componentIndex: null, - value: "patient123", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ResourceIdParameterQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"ResourceId\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenStringExpressionWithStartsWithOperator_WhenVisitString_ThenGeneratesSqlWithLikePattern() - { - // Arrange - Test prefix search (_id=pat*) - var expression = new StringExpression( - StringOperator.StartsWith, - FieldName.String, - componentIndex: null, - value: "pat", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ResourceIdParameterQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"ResourceId\s+LIKE\s+@\w+", sql); - - // Wildcard pattern should be in parameter, not directly in SQL - Assert.DoesNotContain("pat%", sql); - } - - [Fact] - public void GivenStringExpressionWithContainsOperator_WhenVisitString_ThenGeneratesSqlWithLikePattern() - { - // Arrange - Test substring search - var expression = new StringExpression( - StringOperator.Contains, - FieldName.String, - componentIndex: null, - value: "tient", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ResourceIdParameterQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"ResourceId\s+LIKE\s+@\w+", sql); - } - - [Fact] - public void GivenStringExpressionWithSpecialCharacters_WhenVisitString_ThenHandlesEscapingCorrectly() - { - // Arrange - Test resource ID with special SQL characters - var expression = new StringExpression( - StringOperator.Equals, - FieldName.String, - componentIndex: null, - value: "patient_123", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ResourceIdParameterQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Contains("ResourceId", sql); - - // Should have generated SQL with parameter - Assert.Contains("@", sql); - } - - [Fact] - public void GivenMultipleStringExpressions_WhenVisitString_ThenGeneratesMultipleSqlPredicates() - { - // Arrange - Test sequential calls for multiple conditions - var expression1 = new StringExpression( - StringOperator.Equals, - FieldName.String, - componentIndex: null, - value: "patient1", - ignoreCase: false); - - var expression2 = new StringExpression( - StringOperator.Equals, - FieldName.String, - componentIndex: null, - value: "patient2", - ignoreCase: false); - - var context = CreateContext(); - - // Act - ResourceIdParameterQueryGenerator.Instance.VisitString(expression1, context); - var sqlAfterFirst = context.StringBuilder.ToString(); - - ResourceIdParameterQueryGenerator.Instance.VisitString(expression2, context); - var sqlAfterSecond = context.StringBuilder.ToString(); - - // Assert - Assert.NotEmpty(sqlAfterFirst); - Assert.NotEmpty(sqlAfterSecond); - Assert.True(sqlAfterSecond.Length > sqlAfterFirst.Length, "Second call should append more SQL"); - - // Should have multiple parameter references - var paramCount = sqlAfterSecond.Split('@').Length - 1; - Assert.True(paramCount >= 2, "Expected at least 2 parameters"); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ResourceSurrogateIdParameterQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ResourceSurrogateIdParameterQueryGeneratorTests.cs deleted file mode 100644 index 7bd9737da6..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/ResourceSurrogateIdParameterQueryGeneratorTests.cs +++ /dev/null @@ -1,234 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for ResourceSurrogateIdParameterQueryGenerator. - /// Tests the generator's ability to handle surrogate ID searches with conditional parameter hashing. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class ResourceSurrogateIdParameterQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public ResourceSurrogateIdParameterQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenBinaryExpressionWithEqualOperator_WhenVisitBinary_ThenGeneratesSqlWithEquality() - { - // Arrange - Test exact match (_resourceSurrogateId=12345) - var expression = new BinaryExpression( - BinaryOperator.Equal, - FieldName.Number, - componentIndex: null, - value: 12345L); - - var context = CreateContext(isAsyncOperation: false); - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"ResourceSurrogateId\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenBinaryExpressionWithGreaterThanOperator_WhenVisitBinary_ThenGeneratesSqlWithGreaterThan() - { - // Arrange - Test range query (_resourceSurrogateId=gt12345) - var expression = new BinaryExpression( - BinaryOperator.GreaterThan, - FieldName.Number, - componentIndex: null, - value: 12345L); - - var context = CreateContext(isAsyncOperation: false); - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"ResourceSurrogateId\s*>\s*@\w+", sql); - Assert.DoesNotContain(">=", sql); - } - - [Fact] - public void GivenBinaryExpressionWithLessThanOperator_WhenVisitBinary_ThenGeneratesSqlWithLessThan() - { - // Arrange - Test range query (_resourceSurrogateId=lt12345) - var expression = new BinaryExpression( - BinaryOperator.LessThan, - FieldName.Number, - componentIndex: null, - value: 12345L); - - var context = CreateContext(isAsyncOperation: false); - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"ResourceSurrogateId\s*<\s*@\w+", sql); - Assert.DoesNotContain("<=", sql); - } - - [Fact] - public void GivenSyncOperation_WhenVisitBinary_ThenParameterNotIncludedInHash() - { - // Arrange - For synchronous operations, surrogate ID should NOT be hashed - var expression = new BinaryExpression( - BinaryOperator.Equal, - FieldName.Number, - componentIndex: null, - value: 12345L); - - var context = CreateContext(isAsyncOperation: false); - var initialHashCount = context.Parameters.ParametersToHash.Count; - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - Parameter should not be added to hash for sync operations - Assert.Equal(initialHashCount, context.Parameters.ParametersToHash.Count); - } - - [Fact] - public void GivenAsyncOperation_WhenVisitBinary_ThenParameterIncludedInHash() - { - // Arrange - For async operations, surrogate ID SHOULD be hashed for query plan reuse - var expression = new BinaryExpression( - BinaryOperator.Equal, - FieldName.Number, - componentIndex: null, - value: 12345L); - - var context = CreateContext(isAsyncOperation: true); - var initialHashCount = context.Parameters.ParametersToHash.Count; - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - Parameter SHOULD be added to hash for async operations - Assert.True( - context.Parameters.ParametersToHash.Count > initialHashCount, - "Async operations should include surrogate ID in parameter hash"); - } - - [Fact] - public void GivenMultipleBinaryExpressions_WhenVisitBinary_ThenGeneratesMultipleSqlPredicates() - { - // Arrange - Test sequential calls for range queries - var expression1 = new BinaryExpression( - BinaryOperator.GreaterThanOrEqual, - FieldName.Number, - componentIndex: null, - value: 1000L); - - var expression2 = new BinaryExpression( - BinaryOperator.LessThan, - FieldName.Number, - componentIndex: null, - value: 2000L); - - var context = CreateContext(isAsyncOperation: false); - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression1, context); - var sqlAfterFirst = context.StringBuilder.ToString(); - - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression2, context); - var sqlAfterSecond = context.StringBuilder.ToString(); - - // Assert - Assert.NotEmpty(sqlAfterFirst); - Assert.NotEmpty(sqlAfterSecond); - Assert.True(sqlAfterSecond.Length > sqlAfterFirst.Length, "Second call should append more SQL"); - - // Should have >= and < - Assert.Contains(">=", sqlAfterSecond); - Assert.Contains("<", sqlAfterSecond); - } - - [Fact] - public void GivenDifferentOperators_WhenVisitBinary_ThenGeneratesCorrectSqlOperators() - { - // Arrange - Test all comparison operators - var testCases = new[] - { - new { Operator = BinaryOperator.Equal, Expected = " = " }, - new { Operator = BinaryOperator.GreaterThan, Expected = " > " }, - new { Operator = BinaryOperator.GreaterThanOrEqual, Expected = " >= " }, - new { Operator = BinaryOperator.LessThan, Expected = " < " }, - new { Operator = BinaryOperator.LessThanOrEqual, Expected = " <= " }, - new { Operator = BinaryOperator.NotEqual, Expected = " <> " }, - }; - - foreach (var testCase in testCases) - { - // Arrange - var expression = new BinaryExpression( - testCase.Operator, - FieldName.Number, - componentIndex: null, - value: 12345L); - - var context = CreateContext(isAsyncOperation: false); - - // Act - ResourceSurrogateIdParameterQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.Contains(testCase.Expected, sql); - } - } - - private SearchParameterQueryGeneratorContext CreateContext(bool isAsyncOperation, string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/StringQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/StringQueryGeneratorTests.cs deleted file mode 100644 index 68ec7e8d52..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/StringQueryGeneratorTests.cs +++ /dev/null @@ -1,321 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for StringQueryGenerator. - /// Tests the generator's ability to create SQL queries for string searches. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class StringQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public StringQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenStringQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(StringQueryGenerator.Instance); - } - - [Fact] - public void GivenStringQueryGenerator_WhenTableAccessed_ThenReturnsStringSearchParamTable() - { - var table = StringQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.StringSearchParam.TableName, table.TableName); - } - - [Theory] - [InlineData(StringOperator.Equals, true, "test")] - [InlineData(StringOperator.Equals, false, "Test")] - [InlineData(StringOperator.Contains, true, "substring")] - [InlineData(StringOperator.StartsWith, true, "start")] - [InlineData(StringOperator.EndsWith, true, "end")] - public void GivenStringExpression_WhenVisited_ThenGeneratesCorrectSqlQuery(StringOperator stringOperator, bool ignoreCase, string value) - { - var expression = new StringExpression(stringOperator, FieldName.String, null, value, ignoreCase); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenStringEqualsExpression_WhenVisited_ThenUsesEqualsOperator() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.String, null, "test", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Matches($@"{VLatest.StringSearchParam.Text.Metadata.Name}\s*=\s*@\w+", sql); - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql); - } - - [Fact] - public void GivenStringContainsExpression_WhenVisited_ThenUsesLikeOperator() - { - var expression = new StringExpression(StringOperator.Contains, FieldName.String, null, "substring", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Matches($@"{VLatest.StringSearchParam.Text.Metadata.Name}\s+LIKE\s+@\w+", sql); - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql); - } - - [Fact] - public void GivenStringStartsWithExpression_WhenVisited_ThenUsesLikeOperator() - { - var expression = new StringExpression(StringOperator.StartsWith, FieldName.String, null, "start", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Matches($@"{VLatest.StringSearchParam.Text.Metadata.Name}\s+LIKE\s+@\w+", sql); - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql); - } - - [Fact] - public void GivenStringEndsWithExpression_WhenVisited_ThenUsesLikeOperator() - { - var expression = new StringExpression(StringOperator.EndsWith, FieldName.String, null, "end", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Matches($@"{VLatest.StringSearchParam.Text.Metadata.Name}\s+LIKE\s+@\w+", sql); - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql); - } - - [Fact] - public void GivenCaseSensitiveStringExpression_WhenVisited_ThenGeneratesCorrectCollation() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.String, null, "test", false); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("COLLATE", sql); - Assert.Contains("CS_AS", sql); - } - - [Fact] - public void GivenTextOverflowExpression_WhenVisited_ThenChecksForNotNull() - { - var expression = new StringExpression(StringOperator.Equals, SqlFieldName.TextOverflow, null, "overflow", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.StringSearchParam.TextOverflow.Metadata.Name, sql); - Assert.Contains("IS NOT NULL", sql); - Assert.Contains("AND", sql); - } - - [Fact] - public void GivenStringExpressionWithComponentIndex_WhenVisited_ThenIncludesComponentIndex() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.String, 0, "test", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name + "1", sql); - } - - [Fact] - public void GivenStringExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "str"; - var expression = new StringExpression(StringOperator.Equals, FieldName.String, null, "test", true); - var context = CreateContext(tableAlias); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.StringSearchParam.Text.Metadata.Name}", sql); - } - - [Theory] - [InlineData(StringOperator.NotContains, "NOT")] - [InlineData(StringOperator.NotStartsWith, "NOT")] - [InlineData(StringOperator.NotEndsWith, "NOT")] - public void GivenNegativeStringExpression_WhenVisited_ThenIncludesNotOperator(StringOperator stringOperator, string expectedOperator) - { - var expression = new StringExpression(stringOperator, FieldName.String, null, "test", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(expectedOperator, sql); - Assert.Contains("LIKE", sql); - } - - [Fact] - public void GivenStringWithSpecialCharacters_WhenVisited_ThenEscapesCorrectly() - { - var expression = new StringExpression(StringOperator.Contains, FieldName.String, null, "test%value", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("ESCAPE", sql); - Assert.Contains("!", sql); - } - - [Fact] - public void GivenMultipleStringExpressions_WhenVisited_ThenEachGeneratesSQL() - { - var expression1 = new StringExpression(StringOperator.Equals, FieldName.String, null, "test1", true); - var expression2 = new StringExpression(StringOperator.Contains, FieldName.String, null, "test2", true); - - var context1 = CreateContext(); - var context2 = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression1, context1); - StringQueryGenerator.Instance.VisitString(expression2, context2); - - var sql1 = context1.StringBuilder.ToString(); - var sql2 = context2.StringBuilder.ToString(); - - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql1); - Assert.Contains("=", sql1); - - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql2); - Assert.Contains("LIKE", sql2); - - Assert.True(context1.Parameters.HasParametersToHash); - Assert.True(context2.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenInvalidFieldName_WhenVisited_ThenThrowsArgumentOutOfRangeException() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "test", true); - var context = CreateContext(); - - Assert.Throws(() => - StringQueryGenerator.Instance.VisitString(expression, context)); - } - - [Theory] - [InlineData("")] - [InlineData("a")] - [InlineData("test")] - [InlineData("a very long string value for testing")] - [InlineData("unicode: ����")] - public void GivenVariousStringValues_WhenVisited_ThenGeneratesSQL(string value) - { - var expression = new StringExpression(StringOperator.Equals, FieldName.String, null, value, true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.StringSearchParam.Text.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenLeftSideStartsWithExpression_WhenVisited_ThenGeneratesReversedLikeQuery() - { - var expression = new StringExpression(StringOperator.LeftSideStartsWith, FieldName.String, null, "test", true); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("LIKE", sql); - Assert.Contains("+'%'", sql); - } - - [Fact] - public void GivenCaseSensitiveEqualsExpression_WhenVisited_ThenIncludesAdditionalInsensitivePredicate() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.String, null, "Test", false); - var context = CreateContext(); - - StringQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains("=", sql); - Assert.Contains("AND", sql); - Assert.Contains("COLLATE", sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenDateTimeCompositeQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenDateTimeCompositeQueryGeneratorTests.cs deleted file mode 100644 index 9d8f840c56..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenDateTimeCompositeQueryGeneratorTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for TokenDateTimeCompositeQueryGenerator. - /// Tests the generator's ability to delegate to Token and DateTime component generators. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TokenDateTimeCompositeQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public TokenDateTimeCompositeQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenTokenDateTimeCompositeQueryGenerator_WhenTableAccessed_ThenReturnsTokenDateTimeCompositeSearchParamTable() - { - var table = TokenDateTimeCompositeQueryGenerator.Instance.Table; - - Assert.NotNull(table); - Assert.Equal(VLatest.TokenDateTimeCompositeSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenStringExpressionForTokenWithComponentIndex0_WhenVisitString_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - Component index 0 should delegate to TokenQueryGenerator - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 1; - return true; - }); - - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 0, - value: "active", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenDateTimeCompositeQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // TokenQueryGenerator should generate SQL for token code on component 1 - Assert.Matches(@"Code1\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenBinaryExpressionForDateTimeWithComponentIndex1_WhenVisitBinary_ThenDelegatesToDateTimeQueryGenerator() - { - // Arrange - Component index 1 should delegate to DateTimeQueryGenerator - var dateValue = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero); - var expression = new BinaryExpression( - BinaryOperator.GreaterThanOrEqual, - FieldName.DateTimeStart, - componentIndex: 1, - value: dateValue); - - var context = CreateContext(); - - // Act - TokenDateTimeCompositeQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // DateTimeQueryGenerator should generate SQL for datetime comparison on component 2 - Assert.Matches(@"StartDateTime2\s*>=\s*@\w+", sql); - } - - [Fact] - public void GivenMissingFieldExpressionForTokenWithComponentIndex0_WhenVisitMissingField_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - var expression = new MissingFieldExpression(FieldName.TokenSystem, componentIndex: 0); - var context = CreateContext(); - - // Act - TokenDateTimeCompositeQueryGenerator.Instance.VisitMissingField(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // Should check for NULL token system - Assert.Contains("IS NULL", sql); - } - - [Fact] - public void GivenBothComponentExpressions_WhenVisited_ThenBothGenerateSql() - { - // Arrange - Test Token (index 0) + DateTime (index 1) combination - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 1; - return true; - }); - - var tokenExpression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 0, - value: "completed", - ignoreCase: false); - - var dateValue = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); - var dateTimeExpression = new BinaryExpression( - BinaryOperator.GreaterThan, - FieldName.DateTimeStart, - componentIndex: 1, - value: dateValue); - - var context = CreateContext(); - - // Act - TokenDateTimeCompositeQueryGenerator.Instance.VisitString(tokenExpression, context); - var sqlAfterToken = context.StringBuilder.ToString(); - - TokenDateTimeCompositeQueryGenerator.Instance.VisitBinary(dateTimeExpression, context); - var sqlAfterBoth = context.StringBuilder.ToString(); - - // Assert - Assert.NotEmpty(sqlAfterToken); - Assert.NotEmpty(sqlAfterBoth); - Assert.True(sqlAfterBoth.Length > sqlAfterToken.Length, "Both components should generate SQL"); - - // Should contain both token and datetime SQL elements - Assert.Contains("Code", sqlAfterBoth); - Assert.Contains("StartDateTime", sqlAfterBoth); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenNumberNumberQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenNumberNumberQueryGeneratorTests.cs deleted file mode 100644 index 94d102f8e6..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenNumberNumberQueryGeneratorTests.cs +++ /dev/null @@ -1,221 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for TokenNumberNumberQueryGenerator. - /// Tests the generator's ability to delegate to three component generators (Token + Number + Number). - /// This is the only composite generator with three components. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TokenNumberNumberQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public TokenNumberNumberQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenTokenNumberNumberQueryGenerator_WhenTableAccessed_ThenReturnsTokenNumberNumberCompositeSearchParamTable() - { - var table = TokenNumberNumberQueryGenerator.Instance.Table; - - Assert.NotNull(table); - Assert.Equal(VLatest.TokenNumberNumberCompositeSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenStringExpressionForTokenWithComponentIndex0_WhenVisitString_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - Component index 0 should delegate to TokenQueryGenerator - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 1; - return true; - }); - - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 0, - value: "mg", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenNumberNumberQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // TokenQueryGenerator should generate SQL for token code on component 1 - Assert.Matches(@"Code1\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenBinaryExpressionForFirstNumberWithComponentIndex1_WhenVisitBinary_ThenDelegatesToNumberQueryGenerator() - { - // Arrange - Component index 1 should delegate to NumberQueryGenerator (first number) - var expression = new BinaryExpression( - BinaryOperator.GreaterThanOrEqual, - FieldName.Number, - componentIndex: 1, - value: 10.5m); - - var context = CreateContext(); - - // Act - TokenNumberNumberQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // NumberQueryGenerator should generate SQL for number comparison with null-guard - Assert.Contains("SingleValue2 IS NOT NULL", sql); - Assert.Matches(@"SingleValue2\s*>=\s*@\w+", sql); - } - - [Fact] - public void GivenBinaryExpressionForSecondNumberWithComponentIndex2_WhenVisitBinary_ThenDelegatesToNumberQueryGenerator() - { - // Arrange - Component index 2 should delegate to NumberQueryGenerator (second number) - var expression = new BinaryExpression( - BinaryOperator.LessThan, - FieldName.Number, - componentIndex: 2, - value: 100.0m); - - var context = CreateContext(); - - // Act - TokenNumberNumberQueryGenerator.Instance.VisitBinary(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // NumberQueryGenerator should generate SQL for number comparison with null-guard - Assert.Contains("SingleValue3 IS NOT NULL", sql); - Assert.Matches(@"SingleValue3\s*<\s*@\w+", sql); - } - - [Fact] - public void GivenMissingFieldExpressionForTokenWithComponentIndex0_WhenVisitMissingField_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - var expression = new MissingFieldExpression(FieldName.TokenSystem, componentIndex: 0); - var context = CreateContext(); - - // Act - TokenNumberNumberQueryGenerator.Instance.VisitMissingField(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // Should check for NULL token system - Assert.Contains("IS NULL", sql); - } - - [Fact] - public void GivenAllThreeComponentExpressions_WhenVisited_ThenAllGenerateSql() - { - // Arrange - Test Token (index 0) + Number1 (index 1) + Number2 (index 2) combination - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 1; - return true; - }); - - var tokenExpression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 0, - value: "mg", - ignoreCase: false); - - var number1Expression = new BinaryExpression( - BinaryOperator.GreaterThanOrEqual, - FieldName.Number, - componentIndex: 1, - value: 10.0m); - - var number2Expression = new BinaryExpression( - BinaryOperator.LessThan, - FieldName.Number, - componentIndex: 2, - value: 100.0m); - - var context = CreateContext(); - - // Act - Visit all three components - TokenNumberNumberQueryGenerator.Instance.VisitString(tokenExpression, context); - var sqlAfterToken = context.StringBuilder.ToString(); - - TokenNumberNumberQueryGenerator.Instance.VisitBinary(number1Expression, context); - var sqlAfterNumber1 = context.StringBuilder.ToString(); - - TokenNumberNumberQueryGenerator.Instance.VisitBinary(number2Expression, context); - var sqlAfterAll = context.StringBuilder.ToString(); - - // Assert - Assert.NotEmpty(sqlAfterToken); - Assert.NotEmpty(sqlAfterNumber1); - Assert.NotEmpty(sqlAfterAll); - - Assert.True(sqlAfterNumber1.Length > sqlAfterToken.Length, "Second component should add SQL"); - Assert.True(sqlAfterAll.Length > sqlAfterNumber1.Length, "Third component should add SQL"); - - // Should contain all three component SQL elements - Assert.Contains("Code", sqlAfterAll); - - // Should have both number comparisons - Assert.Contains(">=", sqlAfterAll); - Assert.Contains("<", sqlAfterAll); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenQueryGeneratorTests.cs deleted file mode 100644 index 70cc1a1f6b..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenQueryGeneratorTests.cs +++ /dev/null @@ -1,393 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for TokenQueryGenerator. - /// Tests the generator's ability to create SQL queries for token searches. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TokenQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public TokenQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenTokenQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(TokenQueryGenerator.Instance); - } - - [Fact] - public void GivenTokenQueryGenerator_WhenTableAccessed_ThenReturnsTokenSearchParamTable() - { - var table = TokenQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.TokenSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenTokenCodeExpression_WhenVisited_ThenGeneratesCorrectSqlQuery() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "code123", true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - Assert.NotEmpty(sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenTokenSystemExpression_WhenSystemIdExists_ThenUsesDirectComparison() - { - const string systemValue = "http://loinc.org"; - const int systemId = 1; - - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(x => - { - x[1] = systemId; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenSystem, null, systemValue, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Matches(@"SystemId\s*=\s*@?\w+", sql); - } - - [Fact] - public void GivenTokenSystemExpression_WhenSystemIdNotExists_ThenUsesScalarSubquery() - { - const string systemValue = "http://custom-system.org"; - - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenSystem, null, systemValue, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // dbo.System has a primary key on Value, so there is always at most one matching - // SystemId — a scalar subquery with = is correct and avoids an unnecessary IN list. - Assert.Matches(@"SystemId\s*=\s*\(\s*SELECT", sql); - Assert.DoesNotContain(" IN ", sql); - Assert.Contains(VLatest.System.TableName, sql); - } - - [Fact] - public void GivenTokenSystemExpression_WhenSystemIdNotExists_ThenSubqueryFiltersOnValue() - { - const string systemValue = "http://custom-system.org"; - - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(false); - - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenSystem, null, systemValue, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // The subquery must filter on System.Value so the correct SystemId is returned. - Assert.Contains(VLatest.System.Value.Metadata.Name, sql); - Assert.Contains(VLatest.System.SystemId.Metadata.Name, sql); - } - - [Fact] - public void GivenShortTokenCode_WhenVisited_ThenOnlyUsesCodeColumn() - { - var shortCode = "ABC"; - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, shortCode, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - Assert.DoesNotContain(VLatest.TokenSearchParam.CodeOverflow.Metadata.Name, sql); - } - - [Fact] - public void GivenTokenCodeAtMaxLength_WhenVisited_ThenChecksCodeOverflowIsNull() - { - var maxLengthCode = new string('A', (int)VLatest.TokenSearchParam.Code.Metadata.MaxLength); - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, maxLengthCode, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - Assert.Contains(VLatest.TokenSearchParam.CodeOverflow.Metadata.Name, sql); - Assert.Contains("IS NULL", sql); - } - - [Fact] - public void GivenLongTokenCode_WhenVisited_ThenUsesCodeAndCodeOverflow() - { - var longCode = new string('A', (int)VLatest.TokenSearchParam.Code.Metadata.MaxLength + 50); - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, longCode, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - Assert.Contains(VLatest.TokenSearchParam.CodeOverflow.Metadata.Name, sql); - Assert.Contains("IS NOT NULL", sql); - } - - [Fact] - public void GivenVeryLongTokenCode_WhenVisited_ThenIncludesTruncation128LogicIsNotTriggered() - { - var veryLongCode = new string('A', 150); - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, veryLongCode, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - // Verify truncation-128 logic does not exist - // - Should not have nested structure with OR for handling truncated codes - // - Code = @... for the full value match - // - No OR branch with Code = @... for the 128-truncated value match - Assert.DoesNotContain("((", sql); - Assert.DoesNotContain("OR", sql); - Assert.Matches(@"Code\s*=\s*@\w+", sql); - - // Verify the SQL contains the OR branch for 128-truncation - // The structure should be ((Code = @p0 ...) OR (Code = @p1 ...)) - Assert.DoesNotContain("))", sql); - } - - [Fact] - public void GivenTokenExpressionWithComponentIndex_WhenVisited_ThenIncludesComponentIndex() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, 0, "code123", true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name + "1", sql); - } - - [Fact] - public void GivenTokenExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "tok"; - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "code123", true); - var context = CreateContext(tableAlias); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.TokenSearchParam.Code.Metadata.Name}", sql); - } - - [Fact] - public void GivenMissingFieldExpression_WhenVisited_ThenChecksSystemIdIsNull() - { - var expression = new MissingFieldExpression(FieldName.TokenSystem, null); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitMissingField(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.SystemId.Metadata.Name, sql); - Assert.Contains("IS NULL", sql); - } - - [Fact] - public void GivenInvalidFieldName_WhenVisited_ThenThrowsInvalidOperationException() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.DateTimeStart, null, "invalid", true); - var context = CreateContext(); - - Assert.Throws(() => - TokenQueryGenerator.Instance.VisitString(expression, context)); - } - - [Fact] - public void GivenMultipleTokenExpressions_WhenVisited_ThenEachGeneratesSQL() - { - var expression1 = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "code1", true); - var expression2 = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "code2", true); - - var context1 = CreateContext(); - var context2 = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression1, context1); - TokenQueryGenerator.Instance.VisitString(expression2, context2); - - var sql1 = context1.StringBuilder.ToString(); - var sql2 = context2.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql1); - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql2); - - Assert.True(context1.Parameters.HasParametersToHash); - Assert.True(context2.Parameters.HasParametersToHash); - } - - [Theory] - [InlineData("http://loinc.org")] - [InlineData("http://snomed.info/sct")] - [InlineData("http://hl7.org/fhir/sid/us-ssn")] - [InlineData("urn:oid:1.2.3.4.5")] - public void GivenVariousSystemValues_WhenVisited_ThenGeneratesSQL(string systemValue) - { - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(x => - { - x[1] = 1; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenSystem, null, systemValue, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.TokenSearchParam.SystemId.Metadata.Name, sql); - } - - [Theory] - [InlineData("M")] - [InlineData("F")] - [InlineData("active")] - [InlineData("12345")] - [InlineData("code-with-dashes")] - public void GivenVariousCodeValues_WhenVisited_ThenGeneratesSQL(string codeValue) - { - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, codeValue, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenTokenCodeWithSpecialCharacters_WhenVisited_ThenHandlesCorrectly() - { - const string codeWithSpecialChars = "code_with-special.chars"; - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, codeWithSpecialChars, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - } - - [Fact] - public void GivenTokenCodeExactly256Characters_WhenVisited_ThenHandlesMaxLengthCorrectly() - { - var code256 = new string('X', 256); - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, code256, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.Code.Metadata.Name, sql); - Assert.Contains(VLatest.TokenSearchParam.CodeOverflow.Metadata.Name, sql); - } - - [Fact] - public void GivenTokenSystemWithComponentIndex_WhenVisited_ThenIncludesComponentIndex() - { - const string systemValue = "http://example.org"; - _model.TryGetSystemId(systemValue, out Arg.Any()) - .Returns(x => - { - x[1] = 1; - return true; - }); - - var expression = new StringExpression(StringOperator.Equals, FieldName.TokenSystem, 0, systemValue, true); - var context = CreateContext(); - - TokenQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.TokenSearchParam.SystemId.Metadata.Name + "1", sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenStringCompositeQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenStringCompositeQueryGeneratorTests.cs deleted file mode 100644 index 6eb1390c9b..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenStringCompositeQueryGeneratorTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for TokenStringCompositeQueryGenerator. - /// Tests the generator's ability to delegate to component generators (TokenQueryGenerator for component 0, - /// StringQueryGenerator for component 1) and return the correct table reference. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TokenStringCompositeQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public TokenStringCompositeQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Theory] - [InlineData(0, FieldName.TokenCode, "active", @"Code1\s*=\s*@\w+")] - [InlineData(1, FieldName.String, "test-value", @"Text2\s*=\s*@\w+")] - public void GivenStringExpressionWithComponentIndex_WhenVisitString_ThenDelegatesToCorrectQueryGenerator( - int componentIndex, FieldName fieldName, string value, string expectedPattern) - { - // Arrange - Component index determines which query generator handles the expression - var expression = new StringExpression( - StringOperator.Equals, - fieldName, - componentIndex: componentIndex, - value: value, - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenStringCompositeQueryGenerator.Instance.VisitString(expression, context); - - // Assert - Correct query generator should generate SQL with appropriate column suffix - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(expectedPattern, sql); - } - - [Fact] - public void GivenMissingFieldExpressionWithComponentIndex0_WhenVisitMissingField_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - Component 0 should delegate to TokenQueryGenerator for missing field - var expression = new MissingFieldExpression(FieldName.TokenSystem, componentIndex: 0); - var context = CreateContext(); - - // Act - TokenStringCompositeQueryGenerator.Instance.VisitMissingField(expression, context); - - // Assert - Should check for NULL token system on component 1 - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Contains("SystemId1 IS NULL", sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenTextQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenTextQueryGeneratorTests.cs deleted file mode 100644 index 18915e7502..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenTextQueryGeneratorTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for TokenTextQueryGenerator. - /// Tests the generator's ability to handle token text searches with various string operators. - /// TokenText is used for searching token display text (e.g., code system display names). - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TokenTextQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public TokenTextQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenTokenTextQueryGenerator_WhenTableAccessed_ThenReturnsTokenTextTable() - { - var table = TokenTextQueryGenerator.Instance.Table; - - Assert.NotNull(table); - Assert.Equal(VLatest.TokenText.TableName, table.TableName); - } - - [Fact] - public void GivenStringExpressionWithEqualsOperator_WhenVisitString_ThenGeneratesSqlWithEquality() - { - // Arrange - Test exact match search (:text=Active) - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenText, - componentIndex: null, - value: "Active", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTextQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"Text\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenStringExpressionWithContainsOperator_WhenVisitString_ThenGeneratesSqlWithLikePattern() - { - // Arrange - Test substring search (:text=active*) - var expression = new StringExpression( - StringOperator.Contains, - FieldName.TokenText, - componentIndex: null, - value: "active", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTextQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"Text\s+LIKE\s+@\w+", sql); - } - - [Fact] - public void GivenStringExpressionWithStartsWithOperator_WhenVisitString_ThenGeneratesSqlWithLikePattern() - { - // Arrange - Test prefix search - var expression = new StringExpression( - StringOperator.StartsWith, - FieldName.TokenText, - componentIndex: null, - value: "Act", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTextQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Matches(@"Text\s+LIKE\s+@\w+", sql); - } - - [Fact] - public void GivenStringExpressionWithSpecialCharacters_WhenVisitString_ThenHandlesEscapingCorrectly() - { - // Arrange - Test text with SQL special characters - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenText, - componentIndex: null, - value: "Status_Active", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTextQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - Assert.Contains("Text", sql); - - // Should have generated SQL with parameter - Assert.Contains("@", sql); - } - - [Fact] - public void GivenMultipleStringExpressions_WhenVisitString_ThenGeneratesMultipleSqlPredicates() - { - // Arrange - Test sequential calls for multiple text conditions - var expression1 = new StringExpression( - StringOperator.StartsWith, - FieldName.TokenText, - componentIndex: null, - value: "Active", - ignoreCase: false); - - var expression2 = new StringExpression( - StringOperator.Contains, - FieldName.TokenText, - componentIndex: null, - value: "Status", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTextQueryGenerator.Instance.VisitString(expression1, context); - var sqlAfterFirst = context.StringBuilder.ToString(); - - TokenTextQueryGenerator.Instance.VisitString(expression2, context); - var sqlAfterSecond = context.StringBuilder.ToString(); - - // Assert - Assert.NotEmpty(sqlAfterFirst); - Assert.NotEmpty(sqlAfterSecond); - Assert.True(sqlAfterSecond.Length > sqlAfterFirst.Length, "Second call should append more SQL"); - - // Should have both LIKE patterns - var likeCount = sqlAfterSecond.Split("LIKE").Length - 1; - Assert.Equal(2, likeCount); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenTokenCompositeQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenTokenCompositeQueryGeneratorTests.cs deleted file mode 100644 index 39b14c0216..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/TokenTokenCompositeQueryGeneratorTests.cs +++ /dev/null @@ -1,189 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for TokenTokenCompositeQueryGenerator. - /// Tests the generator's ability to delegate to Token generators for both components. - /// Unique characteristic: Both components use the same generator type (TokenQueryGenerator). - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TokenTokenCompositeQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public TokenTokenCompositeQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenTokenTokenCompositeQueryGenerator_WhenTableAccessed_ThenReturnsTokenTokenCompositeSearchParamTable() - { - var table = TokenTokenCompositeQueryGenerator.Instance.Table; - - Assert.NotNull(table); - Assert.Equal(VLatest.TokenTokenCompositeSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenStringExpressionForFirstTokenWithComponentIndex0_WhenVisitString_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - Component index 0 should delegate to TokenQueryGenerator (first token) - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 1; - return true; - }); - - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 0, - value: "active", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTokenCompositeQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // TokenQueryGenerator should generate SQL for token code with component index suffix - Assert.Matches(@"Code1\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenStringExpressionForSecondTokenWithComponentIndex1_WhenVisitString_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - Component index 1 should delegate to TokenQueryGenerator (second token) - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 2; - return true; - }); - - var expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 1, - value: "completed", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTokenCompositeQueryGenerator.Instance.VisitString(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // TokenQueryGenerator should generate SQL for token code on component 2 - Assert.Matches(@"Code2\s*=\s*@\w+", sql); - } - - [Fact] - public void GivenMissingFieldExpressionForFirstTokenWithComponentIndex0_WhenVisitMissingField_ThenDelegatesToTokenQueryGenerator() - { - // Arrange - var expression = new MissingFieldExpression(FieldName.TokenSystem, componentIndex: 0); - var context = CreateContext(); - - // Act - TokenTokenCompositeQueryGenerator.Instance.VisitMissingField(expression, context); - - // Assert - var sql = context.StringBuilder.ToString(); - Assert.NotEmpty(sql); - - // Should check for NULL token system - Assert.Contains("IS NULL", sql); - } - - [Fact] - public void GivenBothTokenComponentExpressions_WhenVisited_ThenBothGenerateSql() - { - // Arrange - Test Token1 (index 0) + Token2 (index 1) combination - _model.TryGetSystemId(Arg.Any(), out Arg.Any()).Returns(x => - { - x[1] = 1; - return true; - }); - - var token1Expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 0, - value: "active", - ignoreCase: false); - - var token2Expression = new StringExpression( - StringOperator.Equals, - FieldName.TokenCode, - componentIndex: 1, - value: "completed", - ignoreCase: false); - - var context = CreateContext(); - - // Act - TokenTokenCompositeQueryGenerator.Instance.VisitString(token1Expression, context); - var sqlAfterToken1 = context.StringBuilder.ToString(); - - TokenTokenCompositeQueryGenerator.Instance.VisitString(token2Expression, context); - var sqlAfterBoth = context.StringBuilder.ToString(); - - // Assert - Assert.NotEmpty(sqlAfterToken1); - Assert.NotEmpty(sqlAfterBoth); - Assert.True(sqlAfterBoth.Length > sqlAfterToken1.Length, "Both components should generate SQL"); - - // Should contain both Code1 and Code2 with proper predicate structure for the two token components - Assert.Matches(@"Code1\s*=\s*@\w+", sqlAfterBoth); - Assert.Matches(@"Code2\s*=\s*@\w+", sqlAfterBoth); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/UriQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/UriQueryGeneratorTests.cs deleted file mode 100644 index 6dfe0fb4f7..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/QueryGenerators/UriQueryGeneratorTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Text; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Unit tests for UriQueryGenerator. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class UriQueryGeneratorTests - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - - public UriQueryGeneratorTests() - { - _model = Substitute.For(); - _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - _schemaInformation.Current = SchemaVersionConstants.Max; - } - - [Fact] - public void GivenUriQueryGenerator_WhenInstanceAccessed_ThenNotNull() - { - Assert.NotNull(UriQueryGenerator.Instance); - } - - [Fact] - public void GivenUriQueryGenerator_WhenTableAccessed_ThenReturnsUriSearchParamTable() - { - var table = UriQueryGenerator.Instance.Table; - - Assert.Equal(VLatest.UriSearchParam.TableName, table.TableName); - } - - [Fact] - public void GivenUriEqualsExpression_WhenVisited_ThenGeneratesCorrectSqlQuery() - { - var expression = new StringExpression(StringOperator.Equals, FieldName.Uri, null, "http://example.org", true); - var context = CreateContext(); - - UriQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains(VLatest.UriSearchParam.Uri.Metadata.Name, sql); - Assert.Matches($@"{VLatest.UriSearchParam.Uri.Metadata.Name}\s*=\s*@\w+", sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Theory] - [InlineData("http://example.org")] - [InlineData("https://hl7.org/fhir/ValueSet/example")] - [InlineData("urn:oid:1.2.3.4.5")] - public void GivenVariousUriValues_WhenVisited_ThenGeneratesSQL(string uriValue) - { - var expression = new StringExpression(StringOperator.Equals, FieldName.Uri, null, uriValue, true); - var context = CreateContext(); - - UriQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.NotEmpty(sql); - Assert.Contains(VLatest.UriSearchParam.Uri.Metadata.Name, sql); - Assert.True(context.Parameters.HasParametersToHash); - } - - [Fact] - public void GivenUriExpressionWithTableAlias_WhenVisited_ThenSqlContainsTableAlias() - { - const string tableAlias = "uri"; - var expression = new StringExpression(StringOperator.Equals, FieldName.Uri, null, "http://example.org", true); - var context = CreateContext(tableAlias); - - UriQueryGenerator.Instance.VisitString(expression, context); - - var sql = context.StringBuilder.ToString(); - - Assert.Contains($"{tableAlias}.{VLatest.UriSearchParam.Uri.Metadata.Name}", sql); - } - - private SearchParameterQueryGeneratorContext CreateContext(string tableAlias = null) - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - using var sqlCommand = new SqlCommand(); - var sqlParameterManager = new SqlQueryParameterManager(sqlCommand.Parameters); - var parameters = new HashingSqlQueryParameterManager(sqlParameterManager); - - return new SearchParameterQueryGeneratorContext( - stringBuilder, - parameters, - _model, - _schemaInformation, - isAsyncOperation: false, - tableAlias); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/SearchParamTableExpressionQueryGeneratorFactoryTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/SearchParamTableExpressionQueryGeneratorFactoryTests.cs deleted file mode 100644 index ee6ddd7c89..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/SearchParamTableExpressionQueryGeneratorFactoryTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors -{ - /// - /// Unit tests for SearchParamTableExpressionQueryGeneratorFactory. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class SearchParamTableExpressionQueryGeneratorFactoryTests - { - private readonly SearchParamTableExpressionQueryGeneratorFactory _factory; - - public SearchParamTableExpressionQueryGeneratorFactoryTests() - { - var searchParameterToSearchValueTypeMap = new SearchParameterToSearchValueTypeMap(); - _factory = new SearchParamTableExpressionQueryGeneratorFactory(searchParameterToSearchValueTypeMap); - } - - [Fact] - public void GivenMissingFieldExpressionWithReferenceResourceType_WhenVisited_ThenReturnsReferenceQueryGenerator() - { - var expression = new MissingFieldExpression(FieldName.ReferenceResourceType, null); - - var generator = _factory.VisitMissingField(expression, null); - - Assert.IsType(generator); - } - - [Fact] - public void GivenMissingFieldExpressionWithReferenceBaseUri_WhenVisited_ThenReturnsReferenceQueryGenerator() - { - var expression = new MissingFieldExpression(FieldName.ReferenceBaseUri, null); - - var generator = _factory.VisitMissingField(expression, null); - - Assert.IsType(generator); - } - - [Fact] - public void GivenMissingFieldExpressionWithTokenSystem_WhenVisited_ThenReturnsTokenQueryGenerator() - { - var expression = new MissingFieldExpression(FieldName.TokenSystem, null); - - var generator = _factory.VisitMissingField(expression, null); - - Assert.IsType(generator); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/StringOverflowRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/StringOverflowRewriterTests.cs deleted file mode 100644 index bb1dc610ca..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/StringOverflowRewriterTests.cs +++ /dev/null @@ -1,325 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors -{ - /// - /// Unit tests for StringOverflowRewriter. - /// Tests the rewriter's ability to transform string search expressions to handle text overflow - /// for modern schema versions (partitioned tables and above). - /// Unlike LegacyStringOverflowRewriter which uses concatenation, this rewriter uses AND/OR logic. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class StringOverflowRewriterTests - { - private static readonly SearchParameterInfo StringSearchParam = new SearchParameterInfo( - name: "name", - code: "name", - searchParamType: SearchParamType.String, - url: new Uri("http://hl7.org/fhir/SearchParameter/Patient-name")); - - private static readonly SearchParameterInfo TokenSearchParam = new SearchParameterInfo( - name: "status", - code: "status", - searchParamType: SearchParamType.Token, - url: new Uri("http://hl7.org/fhir/SearchParameter/Patient-status")); - - private const int MaxTextLength = 256; // VLatest.StringSearchParam.Text.Metadata.MaxLength - - [Fact] - public void GivenEmptySearchParamTableExpressions_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - var sqlRoot = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - // Act - var result = StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenStringSearchParamWithShortValue_WhenEqualsOperator_ThenReturnsUnchanged() - { - // Arrange - Short string that fits in Text column - var shortValue = "John"; - var stringExpression = Expression.StringEquals(FieldName.String, null, shortValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - No rewrite for short strings with equals operator - Assert.Same(sqlRoot, result); - } - - [Theory] - [InlineData(false, null)] // Basic case - no ignoreCase, no componentIndex - [InlineData(true, null)] // IgnoreCase preserved - [InlineData(false, 2)] // ComponentIndex preserved - public void GivenStringSearchParamWithLongValue_WhenEqualsOperator_ThenCreatesAndExpressionPreservingProperties(bool ignoreCase, int? componentIndex) - { - // Arrange - Long string that exceeds Text column limit - var longValue = new string('a', 257); - var stringExpression = new StringExpression(StringOperator.Equals, FieldName.String, componentIndex, longValue, ignoreCase); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Should create AND expression with prefix check and overflow check - var rewrittenSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[0].Predicate; - var andExpression = rewrittenSearchParam.Expression as MultiaryExpression; - - Assert.NotNull(andExpression); - Assert.Equal(MultiaryOperator.And, andExpression!.MultiaryOperation); - Assert.Equal(2, andExpression!.Expressions.Count); - - // First expression should check Text column with prefix - var prefixExpression = andExpression!.Expressions[0] as StringExpression; - Assert.NotNull(prefixExpression); - Assert.Equal(FieldName.String, prefixExpression!.FieldName); - Assert.Equal(MaxTextLength, prefixExpression!.Value.Length); - Assert.Equal(ignoreCase, prefixExpression!.IgnoreCase); - Assert.Equal(componentIndex, prefixExpression!.ComponentIndex); - - // Second expression should check TextOverflow column with full value - var overflowExpression = andExpression!.Expressions[1] as StringExpression; - Assert.NotNull(overflowExpression); - Assert.Equal(SqlFieldName.TextOverflow, overflowExpression!.FieldName); - Assert.Equal(longValue, overflowExpression!.Value); - Assert.Equal(ignoreCase, overflowExpression!.IgnoreCase); - Assert.Equal(componentIndex, overflowExpression!.ComponentIndex); - } - - [Fact] - public void GivenStringSearchParamWithBoundaryValue_WhenEqualsOperator_ThenReturnsUnchanged() - { - // Arrange - String exactly at the limit - var boundaryValue = new string('b', MaxTextLength); - var stringExpression = Expression.StringEquals(FieldName.String, null, boundaryValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - At boundary, no rewrite needed - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenStringSearchParamWithLongValue_WhenStartsWithOperator_ThenCreatesAndExpression() - { - // Arrange - Long string with StartsWith operator - var longValue = new string('s', 257); - var stringExpression = Expression.StartsWith(FieldName.String, null, longValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Should create AND expression - var rewrittenSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[0].Predicate; - var andExpression = rewrittenSearchParam.Expression as MultiaryExpression; - - Assert.NotNull(andExpression); - Assert.Equal(MultiaryOperator.And, andExpression!.MultiaryOperation); - Assert.Equal(2, andExpression!.Expressions.Count); - - // Verify both expressions use StartsWith operator - var prefixExpression = andExpression!.Expressions[0] as StringExpression; - Assert.NotNull(prefixExpression); - Assert.Equal(StringOperator.StartsWith, prefixExpression!.StringOperator); - - var overflowExpression = andExpression!.Expressions[1] as StringExpression; - Assert.NotNull(overflowExpression); - Assert.Equal(StringOperator.StartsWith, overflowExpression!.StringOperator); - } - - [Fact] - public void GivenStringSearchParamWithShortValue_WhenStartsWithOperator_ThenReturnsUnchanged() - { - // Arrange - Short string with StartsWith operator - var shortValue = "start"; - var stringExpression = Expression.StartsWith(FieldName.String, null, shortValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Short StartsWith doesn't need overflow handling - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenStringSearchParamWithContainsOperator_ThenCreatesOrExpression() - { - // Arrange - Contains operator should always check overflow - var value = "contains"; - var stringExpression = Expression.Contains(FieldName.String, null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Should create OR expression - var rewrittenSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[0].Predicate; - var orExpression = rewrittenSearchParam.Expression as MultiaryExpression; - - Assert.NotNull(orExpression); - Assert.Equal(MultiaryOperator.Or, orExpression!.MultiaryOperation); - Assert.Equal(2, orExpression!.Expressions.Count); - - // First expression should check Text column - var textExpression = orExpression!.Expressions[0] as StringExpression; - Assert.NotNull(textExpression); - Assert.Equal(FieldName.String, textExpression!.FieldName); - - // Second expression should check TextOverflow column - var overflowExpression = orExpression!.Expressions[1] as StringExpression; - Assert.NotNull(overflowExpression); - Assert.Equal(SqlFieldName.TextOverflow, overflowExpression!.FieldName); - } - - [Fact] - public void GivenNonStringSearchParam_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - Token search parameter should not be rewritten - var tokenExpression = Expression.Equals(FieldName.TokenCode, null, "code"); - var searchParamExpression = new SearchParameterExpression(TokenSearchParam, tokenExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenTokenCodeFieldName_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - TokenCode field should not be rewritten even with string search param - var stringExpression = new StringExpression(StringOperator.Equals, FieldName.TokenCode, null, "code", ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Same(sqlRoot, result); - } - - [Fact] - public void GivenMultipleSearchParamTableExpressions_WhenVisited_ThenRewritesOnlyStringParams() - { - // Arrange - Mix of string and token search parameters - var shortStringExpression = Expression.StringEquals(FieldName.String, null, "short", ignoreCase: false); - var longStringExpression = Expression.StringEquals(FieldName.String, null, new string('x', 257), ignoreCase: false); - var tokenExpression = Expression.Equals(FieldName.TokenCode, null, "code"); - - var stringSearchParam1 = new SearchParameterExpression(StringSearchParam, shortStringExpression); - var stringSearchParam2 = new SearchParameterExpression(StringSearchParam, longStringExpression); - var tokenSearchParam = new SearchParameterExpression(TokenSearchParam, tokenExpression); - - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, stringSearchParam1, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, stringSearchParam2, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, tokenSearchParam, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Assert.Equal(3, result.SearchParamTableExpressions.Count); - - // First should be unchanged (short string) - var first = (SearchParameterExpression)result.SearchParamTableExpressions[0].Predicate; - Assert.IsType(first.Expression); - - // Second should be rewritten (long string) - var second = (SearchParameterExpression)result.SearchParamTableExpressions[1].Predicate; - Assert.IsType(second.Expression); - - // Third should be unchanged (token) - var third = (SearchParameterExpression)result.SearchParamTableExpressions[2].Predicate; - Assert.IsType(third.Expression); - } - - [Theory] - [InlineData(StringOperator.EndsWith, "end")] - [InlineData(StringOperator.NotStartsWith, "notstart")] - [InlineData(StringOperator.NotContains, "notcontains")] - [InlineData(StringOperator.NotEndsWith, "notend")] - public void GivenUnsupportedOperator_WhenVisited_ThenThrowsInvalidOperationException(StringOperator unsupportedOperator, string value) - { - // Arrange - These operators are not supported by StringOverflowRewriter - var stringExpression = new StringExpression(unsupportedOperator, FieldName.String, componentIndex: null, value, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act & Assert - Assert.Throws(() => StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null)); - } - - [Fact] - public void GivenContainsWithLongValue_WhenVisited_ThenBothExpressionsHaveSameValue() - { - // Arrange - Contains with long value - var longValue = new string('l', 300); - var stringExpression = Expression.Contains(FieldName.String, null, longValue, ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(StringSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act - var result = (SqlRootExpression)StringOverflowRewriter.Instance.VisitSqlRoot(sqlRoot, null); - - // Assert - Both OR branches should have the same full value - var rewrittenSearchParam = (SearchParameterExpression)result.SearchParamTableExpressions[0].Predicate; - var orExpression = rewrittenSearchParam.Expression as MultiaryExpression; - Assert.NotNull(orExpression); - - var textExpression = orExpression!.Expressions[0] as StringExpression; - Assert.NotNull(textExpression); - Assert.Equal(longValue, textExpression!.Value); - - var overflowExpression = orExpression!.Expressions[1] as StringExpression; - Assert.NotNull(overflowExpression); - Assert.Equal(longValue, overflowExpression!.Value); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/TopRewriterTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/TopRewriterTests.cs deleted file mode 100644 index 706d7bb283..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/Visitors/TopRewriterTests.cs +++ /dev/null @@ -1,189 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.Test.Utilities; -using Xunit; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors -{ - /// - /// Unit tests for TopRewriter. - /// Tests the rewriter's ability to add TOP expression to SQL queries. - /// The TOP expression is added to limit the number of results returned by the query. - /// - [Trait(Traits.OwningTeam, OwningTeam.Fhir)] - [Trait(Traits.Category, Categories.Search)] - public class TopRewriterTests - { - private static readonly SearchParameterInfo TestSearchParam = new SearchParameterInfo( - name: "name", - code: "name", - searchParamType: SearchParamType.String, - url: new Uri("http://hl7.org/fhir/SearchParameter/Patient-name")); - - [Fact] - public void GivenCountOnlyQuery_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - var stringExpression = Expression.StringEquals(FieldName.String, null, "test", ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(TestSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - var searchOptions = new SearchOptions { CountOnly = true }; - - // Act - var result = TopRewriter.Instance.VisitSqlRoot(sqlRoot, searchOptions); - - // Assert - Should return unchanged for count-only queries - Assert.Same(sqlRoot, result); - Assert.Single(((SqlRootExpression)result).SearchParamTableExpressions); - } - - [Fact] - public void GivenEmptySearchParamTableExpressions_WhenVisited_ThenReturnsUnchanged() - { - // Arrange - var sqlRoot = new SqlRootExpression( - Array.Empty(), - Array.Empty()); - - var searchOptions = new SearchOptions { CountOnly = false }; - - // Act - var result = TopRewriter.Instance.VisitSqlRoot(sqlRoot, searchOptions); - - // Assert - Should return unchanged when no search param table expressions exist - Assert.Same(sqlRoot, result); - Assert.Empty(((SqlRootExpression)result).SearchParamTableExpressions); - } - - [Fact] - public void GivenNormalQuery_WhenVisited_ThenAddsTopExpression() - { - // Arrange - var stringExpression = Expression.StringEquals(FieldName.String, null, "test", ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(TestSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - var searchOptions = new SearchOptions { CountOnly = false }; - - // Act - var result = (SqlRootExpression)TopRewriter.Instance.VisitSqlRoot(sqlRoot, searchOptions); - - // Assert - Should add TOP expression - Assert.NotSame(sqlRoot, result); - Assert.Equal(2, result.SearchParamTableExpressions.Count); - - // First expression should be the original - Assert.Same(searchParamExpression, result.SearchParamTableExpressions[0].Predicate); - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - - // Second expression should be TOP - Assert.Equal(SearchParamTableExpressionKind.Top, result.SearchParamTableExpressions[1].Kind); - Assert.Null(result.SearchParamTableExpressions[1].Predicate); - } - - [Fact] - public void GivenQueryWithMultipleExpressions_WhenVisited_ThenAddsTopExpressionAtEnd() - { - // Arrange - var expression1 = Expression.StringEquals(FieldName.String, null, "test1", ignoreCase: false); - var expression2 = Expression.StringEquals(FieldName.String, null, "test2", ignoreCase: false); - var searchParam1 = new SearchParameterExpression(TestSearchParam, expression1); - var searchParam2 = new SearchParameterExpression(TestSearchParam, expression2); - - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParam1, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, searchParam2, SearchParamTableExpressionKind.Normal)); - - var searchOptions = new SearchOptions { CountOnly = false }; - - // Act - var result = (SqlRootExpression)TopRewriter.Instance.VisitSqlRoot(sqlRoot, searchOptions); - - // Assert - Should have 3 expressions (2 original + 1 TOP) - Assert.Equal(3, result.SearchParamTableExpressions.Count); - - // First two should be unchanged - Assert.Same(searchParam1, result.SearchParamTableExpressions[0].Predicate); - Assert.Same(searchParam2, result.SearchParamTableExpressions[1].Predicate); - - // Last should be TOP - Assert.Equal(SearchParamTableExpressionKind.Top, result.SearchParamTableExpressions[2].Kind); - Assert.Null(result.SearchParamTableExpressions[2].Predicate); - } - - [Fact] - public void GivenQueryWithResourceTableExpressions_WhenVisited_ThenPreservesResourceTableExpressions() - { - // Arrange - var stringExpression = Expression.StringEquals(FieldName.String, null, "test", ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(TestSearchParam, stringExpression); - var resourceTableExpression = new SearchParameterExpression( - SearchParameterInfo.ResourceTypeSearchParameter, - Expression.StringEquals(FieldName.String, null, "Patient", ignoreCase: false)); - - var sqlRoot = new SqlRootExpression( - new[] { new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal) }, - new SearchParameterExpressionBase[] { resourceTableExpression }); - - var searchOptions = new SearchOptions { CountOnly = false }; - - // Act - var result = (SqlRootExpression)TopRewriter.Instance.VisitSqlRoot(sqlRoot, searchOptions); - - // Assert - Resource table expressions should be preserved - Assert.Equal(2, result.SearchParamTableExpressions.Count); - Assert.Single(result.ResourceTableExpressions); - Assert.Same(resourceTableExpression, result.ResourceTableExpressions[0]); - } - - [Fact] - public void GivenNullSearchOptions_WhenVisited_ThenThrowsArgumentNullException() - { - // Arrange - var stringExpression = Expression.StringEquals(FieldName.String, null, "test", ignoreCase: false); - var searchParamExpression = new SearchParameterExpression(TestSearchParam, stringExpression); - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParamExpression, SearchParamTableExpressionKind.Normal)); - - // Act & Assert - Assert.Throws(() => TopRewriter.Instance.VisitSqlRoot(sqlRoot, null)); - } - - [Fact] - public void GivenQueryWithDifferentExpressionKinds_WhenVisited_ThenAddsTopExpression() - { - // Arrange - Mix of different expression kinds - var normalExpression = Expression.StringEquals(FieldName.String, null, "test", ignoreCase: false); - var searchParam = new SearchParameterExpression(TestSearchParam, normalExpression); - - var sqlRoot = SqlRootExpression.WithSearchParamTableExpressions( - new SearchParamTableExpression(null, searchParam, SearchParamTableExpressionKind.Normal), - new SearchParamTableExpression(null, searchParam, SearchParamTableExpressionKind.Sort)); - - var searchOptions = new SearchOptions { CountOnly = false }; - - // Act - var result = (SqlRootExpression)TopRewriter.Instance.VisitSqlRoot(sqlRoot, searchOptions); - - // Assert - Should add TOP expression at the end - Assert.Equal(3, result.SearchParamTableExpressions.Count); - Assert.Equal(SearchParamTableExpressionKind.Normal, result.SearchParamTableExpressions[0].Kind); - Assert.Equal(SearchParamTableExpressionKind.Sort, result.SearchParamTableExpressions[1].Kind); - Assert.Equal(SearchParamTableExpressionKind.Top, result.SearchParamTableExpressions[2].Kind); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/QueryPlanReuseCheckerTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/QueryPlanReuseCheckerTests.cs index 3f39e9b69e..edc04e1fcd 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/QueryPlanReuseCheckerTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/QueryPlanReuseCheckerTests.cs @@ -348,7 +348,7 @@ private HashSet CreateSkewedParameterGroups(string uri) /// /// Creates a SearchOptions instance with the specified search parameters. /// - private SearchOptions CreateSearchOptions(IReadOnlyList searchParameters) + private SearchOptions CreateSearchOptions(IList searchParameters) { return new SearchOptions { diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs deleted file mode 100644 index 81a9e9206c..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs +++ /dev/null @@ -1,695 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; -using Microsoft.Health.Fhir.Core.Configs; -using Microsoft.Health.Fhir.Core.Features.Definition; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.Tests.Common; -using Microsoft.Health.Fhir.ValueSets; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Storage; -using Microsoft.Health.Test.Utilities; -using NSubstitute; -using Xunit; -using Xunit.Sdk; - -namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search; - -[Trait(Traits.OwningTeam, OwningTeam.Fhir)] -[Trait(Traits.Category, Categories.Search)] -public class SqlQueryGeneratorTests : IClassFixture -{ - private readonly ISqlServerFhirModel _fhirModel; - private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; - private readonly SchemaInformation _schemaInformation = new(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - private readonly IndentedStringBuilder _strBuilder = new(new StringBuilder()); - private readonly SqlQueryGenerator _queryGenerator; - - public SqlQueryGeneratorTests(ModelInfoProviderFixture modelInfoProviderFixture) - { - _ = modelInfoProviderFixture; - _fhirModel = Substitute.For(); - - // Create real instances instead of mocking since factory is internal - var searchParameterToSearchValueTypeMap = new SearchParameterToSearchValueTypeMap(); - _queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(searchParameterToSearchValueTypeMap); - - _schemaInformation.Current = SchemaVersionConstants.Max; - - using Data.SqlClient.SqlCommand command = new(); - HashingSqlQueryParameterManager parameters = new(new SqlQueryParameterManager(command.Parameters)); - - _queryGenerator = new( - _strBuilder, - parameters, - _fhirModel, - _schemaInformation, - _queryGeneratorFactory, - false, - false); - } - - [Fact] - public void GivenASearchTypeLatestResources_WhenSqlGenerated_ThenSqlFiltersForLatestOnly() - { - Expression predicate = Expression.And([new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false))]); - SqlRootExpression sqlExpression = new([new(null, predicate, SearchParamTableExpressionKind.All)], new List()); - SearchOptions searchOptions = new() - { - Sort = [], - ResourceVersionTypes = ResourceVersionType.Latest, - }; - - var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - Assert.Contains("IsHistory = 0", _strBuilder.ToString()); - Assert.Contains("IsDeleted = 0", _strBuilder.ToString()); - } - - [Fact] - public void GivenASearchTypeForSoftDeletedOnly_WhenSqlGenerated_ThenFilterForSoftDeletedInSql() - { - Expression predicate = Expression.And([new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false))]); - SqlRootExpression sqlExpression = new([new(null, predicate, SearchParamTableExpressionKind.All)], new List()); - SearchOptions searchOptions = new() - { - Sort = [], - ResourceVersionTypes = ResourceVersionType.SoftDeleted, - }; - - var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - Assert.Contains("IsDeleted = 1", _strBuilder.ToString()); - } - - [Fact] - public void GivenASearchTypeForHistoryOnly_WhenSqlGenerated_ThenFilterForHistoryInSql() - { - Expression predicate = Expression.And([new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false))]); - SqlRootExpression sqlExpression = new([new(null, predicate, SearchParamTableExpressionKind.All)], new List()); - SearchOptions searchOptions = new() - { - Sort = [], - ResourceVersionTypes = ResourceVersionType.History, - }; - - var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - Assert.Contains("History = 1", _strBuilder.ToString()); - } - - [Fact] - public void GivenASearchTypeForLatestHistorySoftDeleted_WhenSqlGenerated_ThenFiltersArentInSql() - { - Expression predicate = Expression.And([new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false))]); - SqlRootExpression sqlExpression = new([new(null, predicate, SearchParamTableExpressionKind.All)], new List()); - SearchOptions searchOptions = new() - { - Sort = [], - ResourceVersionTypes = ResourceVersionType.Latest | ResourceVersionType.History | ResourceVersionType.SoftDeleted, - }; - - var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - Assert.DoesNotContain("IsHistory =", _strBuilder.ToString()); - Assert.DoesNotContain("IsDeleted =", _strBuilder.ToString()); - } - - [Fact] - public void GivenASearchTypeForHistorySoftDeleted_WhenSqlGenerated_ThenSqlFiltersOutLatest() - { - Expression predicate = Expression.And([new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Patient", false))]); - SqlRootExpression sqlExpression = new([new(null, predicate, SearchParamTableExpressionKind.All)], new List()); - SearchOptions searchOptions = new() - { - Sort = [], - ResourceVersionTypes = ResourceVersionType.History | ResourceVersionType.SoftDeleted, - }; - - var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - Assert.Contains("IsHistory = 1", _strBuilder.ToString()); - Assert.Contains("IsDeleted = 1", _strBuilder.ToString()); - } - - [Fact] - public void GivenReferenceSearchParameterWithMultipleTargetTypes_WhenSqlGenerated_ThenSqlIncludesOrClauseForReferenceResourceTypeId() - { - // Setup mock to return resource type IDs - _fhirModel.TryGetResourceTypeId("Patient", out Arg.Any()) - .Returns(x => - { - x[1] = (short)1; - return true; - }); - _fhirModel.TryGetResourceTypeId("Practitioner", out Arg.Any()) - .Returns(x => - { - x[1] = (short)2; - return true; - }); - _fhirModel.GetSearchParamId(Arg.Any()).Returns((short)100); - - // Create a reference search parameter with multiple target types (like Observation.patient) - var referenceParam = new SearchParameterInfo( - "patient", - "patient", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Observation-patient"), - null, - "Observation.subject", - new[] { "Patient", "Practitioner" }); - - // Create expression with OR of multiple target types + IS NULL (simulating UntypedReferenceRewriter output) - Expression predicate = Expression.SearchParameter( - referenceParam, - Expression.And( - Expression.StringEquals(FieldName.ReferenceResourceId, null, "test-id", false), - Expression.Or( - Expression.StringEquals(FieldName.ReferenceResourceType, null, "Patient", false), - Expression.StringEquals(FieldName.ReferenceResourceType, null, "Practitioner", false), - Expression.Missing(FieldName.ReferenceResourceType, null)))); - - var queryGenerator = predicate.AcceptVisitor(_queryGeneratorFactory, null); - SqlRootExpression sqlExpression = new([new(queryGenerator, predicate, SearchParamTableExpressionKind.Normal)], new List()); - SearchOptions searchOptions = new() - { - Sort = [], - ResourceVersionTypes = ResourceVersionType.Latest, - }; - - _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - string generatedSql = _strBuilder.ToString(); - - // Verify the SQL contains ReferenceResourceTypeId with OR clause and IS NULL - // This confirms that the VisitMultiary method in SearchParameterQueryGenerator - // correctly handles the OR expression generated by UntypedReferenceRewriter - Assert.Contains("ReferenceResourceTypeId", generatedSql); - Assert.Contains(" OR ", generatedSql); - Assert.Contains("IS NULL", generatedSql); - - // Verify both target resource type IDs appear as separate equality checks - // Patient=1 and Practitioner=2, each generating "ReferenceResourceTypeId = @pN" - int typeIdOccurrences = generatedSql.Split("ReferenceResourceTypeId").Length - 1; - Assert.True(typeIdOccurrences >= 3, $"Expected ReferenceResourceTypeId to appear at least 3 times (2 type equality checks + 1 IS NULL), but found {typeIdOccurrences} in: {generatedSql}"); - - // Verify both type IDs were passed as parameters by checking the mock was called - _fhirModel.Received(1).TryGetResourceTypeId("Patient", out Arg.Any()); - _fhirModel.Received(1).TryGetResourceTypeId("Practitioner", out Arg.Any()); - } - - [Theory] - [InlineData(false, "refTarget")] - [InlineData(true, "refSource")] - public void GivenSmartCompartmentInclude_WhenSqlGenerated_ThenCandidateMembershipIsCheckedBeforeBranchLimit( - bool reversed, - string candidateAlias) - { - // Arrange - var includeParameterUrl = new Uri("http://hl7.org/fhir/SearchParameter/Observation-subject"); - var membershipParameterUrl = new Uri("http://hl7.org/fhir/SearchParameter/DiagnosticReport-subject"); - var includeParameter = new SearchParameterInfo( - "subject", - "subject", - SearchParamType.Reference, - includeParameterUrl, - null, - "Observation.subject", - ["Patient"]); - var includeExpression = new IncludeExpression( - ["Observation"], - includeParameter, - "Observation", - "Patient", - null, - false, - reversed, - false); - var membership = new SmartCompartmentMembershipContext( - "Patient", - "patient-a", - ["Practitioner"], - [new SmartCompartmentMembershipRule("DiagnosticReport", [membershipParameterUrl])]); - SqlRootExpression sqlExpression = new( - [ - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeExpression, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeLimit), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeUnionAll), - ], - [], - membership); - SearchOptions searchOptions = new() - { - IncludeCount = 1, - MaxItemCount = 10, - Sort = [], - ResourceVersionTypes = ResourceVersionType.Latest, - }; - - ConfigureResourceTypeIds(); - _fhirModel.GetSearchParamId(includeParameterUrl).Returns((short)40); - _fhirModel.GetSearchParamId(membershipParameterUrl).Returns((short)41); - - // Act - _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - // Assert - string generatedSql = _strBuilder.ToString(); - Assert.Contains("smartCompartmentMembership", generatedSql); - Assert.Contains($"smartCompartmentMembership.ResourceTypeId = {candidateAlias}.ResourceTypeId", generatedSql); - Assert.Contains($"smartCompartmentMembership.ResourceSurrogateId = {candidateAlias}.ResourceSurrogateId", generatedSql); - Assert.Contains("smartCompartmentMembership.BaseUri IS NULL", generatedSql); - Assert.DoesNotContain("OPTION (RECOMPILE)", generatedSql); - _fhirModel.Received(1).GetSearchParamId(membershipParameterUrl); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void GivenIncludeWithoutSmartCompartmentMembership_WhenSqlGenerated_ThenNoCandidatePredicateIsEmitted(bool reversed) - { - // Arrange - same include shape as the SMART compartment test, but no membership attached - // (non-SMART request). The candidate authorization predicate must not appear. - var includeParameterUrl = new Uri("http://hl7.org/fhir/SearchParameter/Observation-subject"); - var includeParameter = new SearchParameterInfo( - "subject", - "subject", - SearchParamType.Reference, - includeParameterUrl, - null, - "Observation.subject", - ["Patient"]); - var includeExpression = new IncludeExpression( - ["Observation"], - includeParameter, - "Observation", - "Patient", - null, - false, - reversed, - false); - SqlRootExpression sqlExpression = new( - [ - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeExpression, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeLimit), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeUnionAll), - ], - []); - SearchOptions searchOptions = new() - { - IncludeCount = 1, - MaxItemCount = 10, - Sort = [], - ResourceVersionTypes = ResourceVersionType.Latest, - }; - - ConfigureResourceTypeIds(); - _fhirModel.GetSearchParamId(includeParameterUrl).Returns((short)40); - - // Act - _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - // Assert - string generatedSql = _strBuilder.ToString(); - Assert.DoesNotContain("smartCompartmentMembership", generatedSql); - Assert.DoesNotContain("smartCompartmentRoot", generatedSql); - } - - [Fact] - public void GivenObservationCompartmentDefinition_WhenMembershipCreated_ThenFocusIsNotAMembershipParameter() - { - // Arrange - var subject = new SearchParameterInfo( - "subject", - "subject", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Observation-subject"), - null, - "Observation.subject", - ["Patient"]); - var focus = new SearchParameterInfo( - "focus", - "focus", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Observation-focus"), - null, - "Observation.focus", - ["Patient"]); - SqlCompartmentSearchRewriter rewriter = CreateCompartmentRewriter( - "Observation", - ["subject"], - new Dictionary - { - ["subject"] = subject, - ["focus"] = focus, - }); - - // Act - SmartCompartmentMembershipContext membership = SmartCompartmentMembershipContextFactory.Create( - Expression.SmartCompartmentSearch("Patient", "patient-a", "Observation"), - rewriter, - CreateSmartRewriter()); - - // Assert - SmartCompartmentMembershipRule rule = Assert.Single(membership.MembershipRules); - Assert.Equal("Observation", rule.ResourceType); - Assert.Equal(subject.Url, Assert.Single(rule.SearchParameterUrls)); - Assert.DoesNotContain(focus.Url, rule.SearchParameterUrls); - } - - [Fact] - public void GivenACompartmentParameterWithASiblingReferenceParameter_WhenMembershipCreated_ThenOnlyTheFormalParameterIsUsed() - { - // Arrange - Condition-subject is a supported reference parameter targeting Patient, but the Patient - // CompartmentDefinition nominates only `patient` for Condition. Membership must follow the formal - // definition and must not absorb sibling parameters, which would widen the compartment. - var clinicalPatient = new SearchParameterInfo( - "patient", - "patient", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/clinical-patient"), - null, - "Condition.subject.where(resolve() is Patient)", - ["Patient"]); - var subject = new SearchParameterInfo( - "subject", - "subject", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Condition-subject"), - null, - "Condition.subject", - ["Patient"]); - SqlCompartmentSearchRewriter rewriter = CreateCompartmentRewriter( - "Condition", - ["patient"], - new Dictionary - { - ["patient"] = clinicalPatient, - ["subject"] = subject, - }); - - // Act - SmartCompartmentMembershipContext membership = SmartCompartmentMembershipContextFactory.Create( - Expression.SmartCompartmentSearch("Patient", "patient-a", "Condition"), - rewriter, - CreateSmartRewriter()); - - // Assert - SmartCompartmentMembershipRule rule = Assert.Single(membership.MembershipRules); - Assert.Equal(clinicalPatient.Url, Assert.Single(rule.SearchParameterUrls)); - Assert.DoesNotContain(subject.Url, rule.SearchParameterUrls); - } - - [Fact] - public void GivenClinicalPatientParameter_WhenMembershipCreated_ThenFormalParameterIsUsed() - { - // Arrange - var clinicalPatient = new SearchParameterInfo( - "patient", - "patient", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/clinical-patient"), - null, - "AllergyIntolerance.patient", - ["Patient"]); - SqlCompartmentSearchRewriter rewriter = CreateCompartmentRewriter( - "AllergyIntolerance", - ["patient"], - new Dictionary - { - ["patient"] = clinicalPatient, - }); - - // Act - SmartCompartmentMembershipContext membership = SmartCompartmentMembershipContextFactory.Create( - Expression.SmartCompartmentSearch("Patient", "patient-a", "AllergyIntolerance"), - rewriter, - CreateSmartRewriter()); - - // Assert - SmartCompartmentMembershipRule rule = Assert.Single(membership.MembershipRules); - Assert.Equal(clinicalPatient.Url, Assert.Single(rule.SearchParameterUrls)); - } - - [Fact] - public void GivenPractitionerCompartmentEncounter_WhenMembershipCreated_ThenOnlyTheFormalParameterIsUsed() - { - // Arrange - Encounter-participant is a supported reference parameter targeting Practitioner, but the - // Practitioner CompartmentDefinition nominates only `practitioner` for Encounter. Membership must - // follow the formal definition; absorbing `participant` would also admit RelatedPerson-keyed rows. - var practitioner = new SearchParameterInfo( - "practitioner", - "practitioner", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Encounter-practitioner"), - null, - "Encounter.participant.individual.where(resolve() is Practitioner)", - ["Practitioner"]); - var participant = new SearchParameterInfo( - "participant", - "participant", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/Encounter-participant"), - null, - "Encounter.participant.individual", - ["Practitioner", "RelatedPerson"]); - SqlCompartmentSearchRewriter rewriter = CreateCompartmentRewriter( - "Encounter", - ["practitioner"], - new Dictionary - { - ["practitioner"] = practitioner, - ["participant"] = participant, - }, - CompartmentType.Practitioner); - - // Act - SmartCompartmentMembershipContext membership = SmartCompartmentMembershipContextFactory.Create( - Expression.SmartCompartmentSearch("Practitioner", "practitioner-a", "Encounter"), - rewriter, - CreateSmartRewriter()); - - // Assert - SmartCompartmentMembershipRule rule = Assert.Single(membership.MembershipRules); - Assert.Equal("Encounter", rule.ResourceType); - Assert.Equal(practitioner.Url, Assert.Single(rule.SearchParameterUrls)); - Assert.DoesNotContain(participant.Url, rule.SearchParameterUrls); - } - - [Fact] - public void GivenEpisodeOfCareCareManager_WhenPractitionerMembershipCreated_ThenFormalParameterIsUsed() - { - // Arrange - EpisodeOfCare-care-manager is the sole Practitioner compartment parameter for - // EpisodeOfCare. It is resolve()-based, which the indexer evaluates via - // LightweightReferenceToElementResolver, so it is materialized like any other reference parameter. - var careManager = new SearchParameterInfo( - "care-manager", - "care-manager", - SearchParamType.Reference, - new Uri("http://hl7.org/fhir/SearchParameter/EpisodeOfCare-care-manager"), - null, - "EpisodeOfCare.careManager.where(resolve() is Practitioner)", - ["Practitioner"]); - SqlCompartmentSearchRewriter rewriter = CreateCompartmentRewriter( - "EpisodeOfCare", - ["care-manager"], - new Dictionary - { - ["care-manager"] = careManager, - }, - CompartmentType.Practitioner); - - // Act - SmartCompartmentMembershipContext membership = SmartCompartmentMembershipContextFactory.Create( - Expression.SmartCompartmentSearch("Practitioner", "practitioner-a", "EpisodeOfCare"), - rewriter, - CreateSmartRewriter()); - - // Assert - SmartCompartmentMembershipRule rule = Assert.Single(membership.MembershipRules); - Assert.Equal(careManager.Url, Assert.Single(rule.SearchParameterUrls)); - } - - [Fact] - public void GivenSmartCompartmentExpressionInsideAndTree_WhenMembershipCreatedAndSqlGenerated_ThenIncludeCandidatePredicateIsEmitted() - { - // Canary for the SMART include enforcement chain. SqlServerSearchService derives the membership - // context by locating the SmartCompartmentSearchExpression inside the core expression tree and - // attaching the result to the SqlRootExpression; the query generator emits the candidate - // authorization predicate only when that context is present. If any link breaks — the compartment - // expression gets wrapped in a node the factory cannot traverse, a rewriter drops the attached - // context, or the generator stops honoring it — include CTEs silently revert to the pre-fix - // cross-compartment leak. This test exercises the factory -> attach -> generate chain end to end - // with the same tree shape SearchOptionsFactory produces (smart node as a child of the top-level And). - var includeParameterUrl = new Uri("http://hl7.org/fhir/SearchParameter/Observation-subject"); - var membershipParameterUrl = new Uri("http://hl7.org/fhir/SearchParameter/DiagnosticReport-subject"); - var membershipParameter = new SearchParameterInfo( - "subject", - "subject", - SearchParamType.Reference, - membershipParameterUrl, - null, - "DiagnosticReport.subject", - ["Patient"]); - SqlCompartmentSearchRewriter rewriter = CreateCompartmentRewriter( - "DiagnosticReport", - ["subject"], - new Dictionary - { - ["subject"] = membershipParameter, - }); - - Expression coreExpression = Expression.And( - new SearchParameterExpression(new SearchParameterInfo("_type", "_type"), new StringExpression(StringOperator.Equals, FieldName.String, null, "Observation", false)), - Expression.SmartCompartmentSearch("Patient", "patient-a", "Observation")); - - SmartCompartmentMembershipContext membership = SmartCompartmentMembershipContextFactory.Create(coreExpression, rewriter, CreateSmartRewriter()); - - Assert.NotNull(membership); - Assert.Equal("Patient", membership.CompartmentResourceType); - Assert.Equal("patient-a", membership.CompartmentResourceId); - SmartCompartmentMembershipRule rule = Assert.Single(membership.MembershipRules); - Assert.Equal("DiagnosticReport", rule.ResourceType); - Assert.Equal(membershipParameterUrl, Assert.Single(rule.SearchParameterUrls)); - - var includeParameter = new SearchParameterInfo( - "subject", - "subject", - SearchParamType.Reference, - includeParameterUrl, - null, - "Observation.subject", - ["Patient"]); - var includeExpression = new IncludeExpression( - ["Observation"], - includeParameter, - "Observation", - "Patient", - null, - false, - false, - false); - SqlRootExpression sqlExpression = new SqlRootExpression( - [ - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.All), - new SearchParamTableExpression(IncludeQueryGenerator.Instance, includeExpression, SearchParamTableExpressionKind.Include), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeLimit), - new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeUnionAll), - ], - []).WithSmartCompartmentMembership(membership); - SearchOptions searchOptions = new() - { - IncludeCount = 1, - MaxItemCount = 10, - Sort = [], - ResourceVersionTypes = ResourceVersionType.Latest, - }; - - ConfigureResourceTypeIds(); - _fhirModel.GetSearchParamId(includeParameterUrl).Returns((short)40); - _fhirModel.GetSearchParamId(membershipParameterUrl).Returns((short)41); - - _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); - - string generatedSql = _strBuilder.ToString(); - Assert.Contains("smartCompartmentMembership.ResourceTypeId = refTarget.ResourceTypeId", generatedSql); - Assert.Contains("smartCompartmentMembership.ResourceSurrogateId = refTarget.ResourceSurrogateId", generatedSql); - Assert.Contains("smartCompartmentMembership.BaseUri IS NULL", generatedSql); - _fhirModel.Received(1).GetSearchParamId(membershipParameterUrl); - } - - private void ConfigureResourceTypeIds() - { - var resourceTypeIds = new Dictionary(StringComparer.Ordinal) - { - ["Patient"] = 1, - ["Practitioner"] = 2, - ["Observation"] = 3, - ["DiagnosticReport"] = 4, - }; - - _fhirModel.TryGetResourceTypeId(Arg.Any(), out Arg.Any()) - .Returns(call => - { - call[1] = resourceTypeIds[(string)call[0]]; - return true; - }); - } - - private static SqlCompartmentSearchRewriter CreateCompartmentRewriter( - string resourceType, - HashSet compartmentParameterCodes, - IReadOnlyDictionary searchParameters, - CompartmentType compartmentType = CompartmentType.Patient) - { - ICompartmentDefinitionManager compartmentDefinitionManager = Substitute.For(); - compartmentDefinitionManager.TryGetResourceTypes(compartmentType, out Arg.Any>()) - .Returns(call => - { - call[1] = new HashSet(StringComparer.Ordinal) { resourceType }; - return true; - }); - compartmentDefinitionManager.TryGetSearchParams(resourceType, compartmentType, out Arg.Any>()) - .Returns(call => - { - call[2] = compartmentParameterCodes; - return true; - }); - - ISearchParameterDefinitionManager searchParameterDefinitionManager = Substitute.For(); - searchParameterDefinitionManager.TryGetSearchParameter(resourceType, Arg.Any(), out Arg.Any()) - .Returns(call => - { - bool found = searchParameters.TryGetValue((string)call[1], out SearchParameterInfo parameter); - call[2] = parameter; - return found; - }); - - return new SqlCompartmentSearchRewriter( - new Lazy(() => compartmentDefinitionManager), - new Lazy(() => searchParameterDefinitionManager)); - } - - // Production always supplies a SmartCompartmentSearchRewriter to the factory, so the parameter is - // required. The SMART Device conditional-visibility rules are exercised end to end in - // SmartCompartmentSearchRewriterTests; these membership tests assert only formal compartment - // membership parameters, so the Device restriction is disabled here to keep the conditional-rule - // set empty (preserving each test's original configuration and expectations). - private static SmartCompartmentSearchRewriter CreateSmartRewriter() - { - ISearchParameterDefinitionManager searchParameterDefinitionManager = Substitute.For(); - ICompartmentDefinitionManager compartmentDefinitionManager = Substitute.For(); - - var compartmentRewriter = new SqlCompartmentSearchRewriter( - new Lazy(() => compartmentDefinitionManager), - new Lazy(() => searchParameterDefinitionManager)); - - return new SmartCompartmentSearchRewriter( - compartmentRewriter, - new Lazy(() => searchParameterDefinitionManager), - Options.Create(new CoreFeatureConfiguration { EnableSmartCompartmentDeviceRestriction = false })); - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/DateTimeSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/DateTimeSqlParserTests.cs new file mode 100644 index 0000000000..fbfdbc4db3 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/DateTimeSqlParserTests.cs @@ -0,0 +1,157 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class DateTimeSqlParserTests + { + private readonly DateTimeSqlParser _parser; + + public DateTimeSqlParserTests() + { + _parser = new DateTimeSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + } + + [Fact] + public void GivenExactDate_WhenBuildWhereClause_ThenUsesEqModifier() + { + // Arrange / Act + var result = _parser.BuildWhereClause("2024-01-15", string.Empty); + + // Assert — default is eq, so produces range overlap check + Assert.Contains("t.EndDateTime", result); + Assert.Contains("t.StartDateTime", result); + Assert.Contains("2024-01-15", result); + } + + [Fact] + public void GivenYearOnly_WhenBuildWhereClause_ThenProducesRangeForWholeYear() + { + // Arrange / Act + var result = _parser.BuildWhereClause("2024", string.Empty); + + // Assert + Assert.Contains("2024", result); + Assert.Contains("t.EndDateTime", result); + Assert.Contains("t.StartDateTime", result); + } + + [Fact] + public void GivenGtPrefix_WhenBuildWhereClause_ThenUsesEndDateTimeGreaterThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt2024-01-15", string.Empty); + + // Assert + Assert.Contains("t.EndDateTime", result); + Assert.Contains(">", result); + Assert.DoesNotContain("<=", result); + } + + [Fact] + public void GivenLtPrefix_WhenBuildWhereClause_ThenUsesStartDateTimeLessThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("lt2024-01-15", string.Empty); + + // Assert + Assert.Contains("t.StartDateTime", result); + Assert.Contains("<", result); + } + + [Fact] + public void GivenGePrefix_WhenBuildWhereClause_ThenUsesEndDateTimeGreaterOrEqual() + { + // Arrange / Act + var result = _parser.BuildWhereClause("ge2024-01-15", string.Empty); + + // Assert + Assert.Contains("t.EndDateTime", result); + Assert.Contains(">=", result); + } + + [Fact] + public void GivenLePrefix_WhenBuildWhereClause_ThenUsesStartDateTimeLessOrEqual() + { + // Arrange / Act + var result = _parser.BuildWhereClause("le2024-01-15", string.Empty); + + // Assert + Assert.Contains("t.StartDateTime", result); + Assert.Contains("<=", result); + } + + [Fact] + public void GivenNePrefix_WhenBuildWhereClause_ThenUsesOrCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("ne2024-01-15", string.Empty); + + // Assert + Assert.Contains("OR", result); + Assert.Contains("t.EndDateTime", result); + Assert.Contains("t.StartDateTime", result); + } + + [Fact] + public void GivenSaPrefix_WhenBuildWhereClause_ThenUsesStartDateTimeGreaterThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("sa2024-01-15", string.Empty); + + // Assert + Assert.Contains("t.StartDateTime", result); + Assert.Contains(">", result); + } + + [Fact] + public void GivenEbPrefix_WhenBuildWhereClause_ThenUsesEndDateTimeLessThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("eb2024-01-15", string.Empty); + + // Assert + Assert.Contains("t.EndDateTime", result); + Assert.Contains("<", result); + } + + [Fact] + public void GivenDateWithTime_WhenBuildWhereClause_ThenIncludesTimeInCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("2024-01-15T10:30:00Z", string.Empty); + + // Assert + Assert.Contains("2024-01-15T10:30:00", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffix() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt2024-01-15", string.Empty, columnSuffix: 2); + + // Assert + Assert.Contains("t.EndDateTime2", result); + } + + [Fact] + public void GivenCustomTableName_WhenBuildWhereClause_ThenUsesCustomTableName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt2024-01-15", string.Empty, tableName: "dt"); + + // Assert + Assert.Contains("dt.EndDateTime", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/NumberSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/NumberSqlParserTests.cs new file mode 100644 index 0000000000..33f614cdc5 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/NumberSqlParserTests.cs @@ -0,0 +1,118 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class NumberSqlParserTests + { + private readonly NumberSqlParser _parser; + + public NumberSqlParserTests() + { + _parser = new NumberSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + } + + [Fact] + public void GivenSimpleNumber_WhenBuildWhereClause_ThenGeneratesEqCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("42", string.Empty); + + // Assert — eq: HighValue >= val AND LowValue <= val + Assert.Contains("t.HighValue", result); + Assert.Contains("t.LowValue", result); + Assert.Contains("42", result); + } + + [Fact] + public void GivenGtPrefix_WhenBuildWhereClause_ThenUsesHighValueGreaterThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt10", string.Empty); + + // Assert + Assert.Contains("t.HighValue > 10", result); + } + + [Fact] + public void GivenLtPrefix_WhenBuildWhereClause_ThenUsesLowValueLessThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("lt10", string.Empty); + + // Assert + Assert.Contains("t.LowValue < 10", result); + } + + [Fact] + public void GivenGePrefix_WhenBuildWhereClause_ThenUsesHighValueGreaterOrEqual() + { + // Arrange / Act + var result = _parser.BuildWhereClause("ge10", string.Empty); + + // Assert + Assert.Contains("t.HighValue >= 10", result); + } + + [Fact] + public void GivenLePrefix_WhenBuildWhereClause_ThenUsesLowValueLessOrEqual() + { + // Arrange / Act + var result = _parser.BuildWhereClause("le10", string.Empty); + + // Assert + Assert.Contains("t.LowValue <= 10", result); + } + + [Fact] + public void GivenNePrefix_WhenBuildWhereClause_ThenUsesOrCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("ne10", string.Empty); + + // Assert + Assert.Contains("OR", result); + Assert.Contains("t.HighValue", result); + Assert.Contains("t.LowValue", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffixToColumnNames() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt5", string.Empty, columnSuffix: 1); + + // Assert + Assert.Contains("t.HighValue1 > 5", result); + } + + [Fact] + public void GivenCustomTableName_WhenBuildWhereClause_ThenUsesCustomTableName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt5", string.Empty, tableName: "n"); + + // Assert + Assert.Contains("n.HighValue > 5", result); + } + + [Fact] + public void GivenDecimalNumber_WhenBuildWhereClause_ThenHandlesDecimalCorrectly() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt3.14", string.Empty); + + // Assert + Assert.Contains("3.14", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/QuantitySqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/QuantitySqlParserTests.cs new file mode 100644 index 0000000000..f98cab90a8 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/QuantitySqlParserTests.cs @@ -0,0 +1,129 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.BaseParsers; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class QuantitySqlParserTests + { + private readonly QuantitySqlParser _parser; + + public QuantitySqlParserTests() + { + _parser = new QuantitySqlParser(ParserTestHelper.CreateMockDefinitionManager()); + } + + [Fact] + public void GivenEmptyValue_WhenBuildWhereClause_ThenReturnsAlwaysTrue() + { + // Arrange / Act + var result = _parser.BuildWhereClause(string.Empty, string.Empty); + + // Assert + Assert.Equal("1=1", result); + } + + [Fact] + public void GivenValueOnly_WhenBuildWhereClause_ThenGeneratesNumericConditionOnly() + { + // Arrange / Act + var result = _parser.BuildWhereClause("5.4", string.Empty); + + // Assert — eq is default + Assert.Contains("t.HighValue", result); + Assert.Contains("t.LowValue", result); + Assert.Contains("5.4", result); + Assert.DoesNotContain("SystemId", result); + Assert.DoesNotContain("QuantityCodeId", result); + } + + [Fact] + public void GivenValueWithSystemAndCode_WhenBuildWhereClause_ThenGeneratesAllThreeConditions() + { + // Arrange / Act + var result = _parser.BuildWhereClause("5.4|http://unitsofmeasure.org|mg", string.Empty); + + // Assert + Assert.Contains("t.HighValue", result); + Assert.Contains("t.LowValue", result); + Assert.Contains("t.SystemId = (SELECT SystemId FROM dbo.System WHERE Value = 'http://unitsofmeasure.org')", result); + Assert.Contains("t.QuantityCodeId = (SELECT QuantityCodeId FROM dbo.QuantityCode WHERE Value = 'mg')", result); + } + + [Fact] + public void GivenValueWithCodeOnly_WhenBuildWhereClause_ThenGeneratesValueAndCodeConditions() + { + // Arrange / Act + var result = _parser.BuildWhereClause("5.4||mg", string.Empty); + + // Assert + Assert.Contains("t.HighValue", result); + Assert.Contains("t.QuantityCodeId = (SELECT QuantityCodeId FROM dbo.QuantityCode WHERE Value = 'mg')", result); + Assert.DoesNotContain("SystemId", result); + } + + [Fact] + public void GivenGtPrefix_WhenBuildWhereClause_ThenUsesHighValueGreaterThan() + { + // Arrange / Act + var result = _parser.BuildWhereClause("gt50|http://unitsofmeasure.org|kg", string.Empty); + + // Assert + Assert.Contains("t.HighValue > 50", result); + Assert.Contains("t.SystemId", result); + Assert.Contains("QuantityCodeId", result); + } + + [Fact] + public void GivenLePrefix_WhenBuildWhereClause_ThenUsesLowValueLessOrEqual() + { + // Arrange / Act + var result = _parser.BuildWhereClause("le100.0", string.Empty); + + // Assert + Assert.Contains("t.LowValue <= 100", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffixToColumnNames() + { + // Arrange / Act + var result = _parser.BuildWhereClause("5.4", string.Empty, columnSuffix: 2); + + // Assert + Assert.Contains("t.HighValue2", result); + Assert.Contains("t.LowValue2", result); + } + + [Fact] + public void GivenCustomTableName_WhenBuildWhereClause_ThenUsesCustomTableName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("5.4", string.Empty, tableName: "q"); + + // Assert + Assert.Contains("q.HighValue", result); + } + + [Fact] + public void GivenApPrefix_WhenBuildWhereClause_ThenGeneratesApproximateCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("ap100", string.Empty); + + // Assert + Assert.Contains("0.9", result); + Assert.Contains("1.1", result); + Assert.Contains("100", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/ReferenceSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/ReferenceSqlParserTests.cs new file mode 100644 index 0000000000..ecd69a33e0 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/ReferenceSqlParserTests.cs @@ -0,0 +1,151 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class ReferenceSqlParserTests + { + private readonly ISqlServerFhirModel _fhirModel; + private readonly ReferenceSqlParser _parser; + + public ReferenceSqlParserTests() + { + _fhirModel = Substitute.For(); + _parser = new ReferenceSqlParser(ParserTestHelper.CreateMockDefinitionManager(), _fhirModel); + } + + [Fact] + public void GivenEmptyValue_WhenBuildWhereClause_ThenReturnsAlwaysTrue() + { + // Arrange / Act + var result = _parser.BuildWhereClause(string.Empty, string.Empty); + + // Assert + Assert.Equal("1=1", result); + } + + [Fact] + public void GivenIdOnly_WhenBuildWhereClause_ThenGeneratesReferenceIdConditionOnly() + { + // Arrange / Act + var result = _parser.BuildWhereClause("123", string.Empty); + + // Assert + Assert.Equal("t.ReferenceResourceId = '123'", result); + } + + [Fact] + public void GivenRelativeReference_WhenBuildWhereClause_ThenGeneratesIdAndTypeConditions() + { + // Arrange + short patientTypeId = 42; + _fhirModel.TryGetResourceTypeId("Patient", out Arg.Any()) + .Returns(x => + { + x[1] = patientTypeId; + return true; + }); + + // Act + var result = _parser.BuildWhereClause("Patient/123", string.Empty); + + // Assert + Assert.Contains("t.ReferenceResourceId = '123'", result); + Assert.Contains("t.ReferenceResourceTypeId = 42", result); + } + + [Fact] + public void GivenAbsoluteUrl_WhenBuildWhereClause_ThenGeneratesIdTypeAndBaseUriConditions() + { + // Arrange + short patientTypeId = 5; + _fhirModel.TryGetResourceTypeId("Patient", out Arg.Any()) + .Returns(x => + { + x[1] = patientTypeId; + return true; + }); + + // Act + var result = _parser.BuildWhereClause("http://server/Patient/123", string.Empty); + + // Assert + Assert.Contains("t.ReferenceResourceId = '123'", result); + Assert.Contains("t.ReferenceResourceTypeId = 5", result); + Assert.Contains("t.BaseUri = 'http://server'", result); + } + + [Fact] + public void GivenTypeModifier_WhenBuildWhereClause_ThenUsesModifierAsResourceType() + { + // Arrange + short practitionerTypeId = 99; + _fhirModel.TryGetResourceTypeId("Practitioner", out Arg.Any()) + .Returns(x => + { + x[1] = practitionerTypeId; + return true; + }); + + // Act + var result = _parser.BuildWhereClause("123", "Practitioner"); + + // Assert + Assert.Contains("t.ReferenceResourceId = '123'", result); + Assert.Contains("t.ReferenceResourceTypeId = 99", result); + } + + [Fact] + public void GivenUnknownResourceType_WhenBuildWhereClause_ThenReturnsNeverTrue() + { + // Arrange + _fhirModel.TryGetResourceTypeId("UnknownType", out Arg.Any()).Returns(false); + + // Act + var result = _parser.BuildWhereClause("UnknownType/123", string.Empty); + + // Assert + Assert.Equal("1=0", result); + } + + [Fact] + public void GivenRelativeReferenceWithSingleQuote_WhenBuildWhereClause_ThenEscapesId() + { + // Arrange + short patientTypeId = 10; + _fhirModel.TryGetResourceTypeId("Patient", out Arg.Any()) + .Returns(x => + { + x[1] = patientTypeId; + return true; + }); + + // Act + var result = _parser.BuildWhereClause("Patient/O'Brien", string.Empty); + + // Assert + Assert.Contains("O''Brien", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffixToColumnNames() + { + // Arrange / Act + var result = _parser.BuildWhereClause("123", string.Empty, columnSuffix: 1); + + // Assert + Assert.Contains("t.ReferenceResourceId1 = '123'", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/StringSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/StringSqlParserTests.cs new file mode 100644 index 0000000000..9b49a56771 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/StringSqlParserTests.cs @@ -0,0 +1,111 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class StringSqlParserTests + { + private readonly StringSqlParser _parser; + + public StringSqlParserTests() + { + _parser = new StringSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + } + + [Fact] + public void GivenDefaultModifier_WhenBuildWhereClause_ThenGeneratesStartsWithCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("Smith", string.Empty); + + // Assert + Assert.Equal("(t.Text like N'Smith%')", result); + } + + [Fact] + public void GivenExactModifier_WhenBuildWhereClause_ThenGeneratesExactMatchWithCollation() + { + // Arrange / Act + var result = _parser.BuildWhereClause("Smith", "exact"); + + // Assert + Assert.Equal("t.Text = N'Smith' COLLATE Latin1_General_100_CS_AS", result); + } + + [Fact] + public void GivenContainsModifier_WhenBuildWhereClause_ThenGeneratesContainsCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("mit", "contains"); + + // Assert + Assert.Equal("(t.Text like N'%mit%')", result); + } + + [Fact] + public void GivenLongValueWithDefaultModifier_WhenBuildWhereClause_ThenUsesTextOverflowColumn() + { + // Arrange + var longValue = new string('a', 257); + + // Act + var result = _parser.BuildWhereClause(longValue, string.Empty); + + // Assert + Assert.Contains("t.TextOverflow", result); + } + + [Fact] + public void GivenLongValueWithExactModifier_WhenBuildWhereClause_ThenUsesTextOverflowColumn() + { + // Arrange + var longValue = new string('a', 257); + + // Act + var result = _parser.BuildWhereClause(longValue, "exact"); + + // Assert + Assert.Contains("t.TextOverflow", result); + Assert.Contains("COLLATE Latin1_General_100_CS_AS", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffixToColumnName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("Smith", string.Empty, columnSuffix: 3); + + // Assert + Assert.Contains("t.Text3", result); + } + + [Fact] + public void GivenValueWithSingleQuote_WhenBuildWhereClause_ThenEscapesQuote() + { + // Arrange / Act + var result = _parser.BuildWhereClause("O'Brien", string.Empty); + + // Assert + Assert.Contains("O''Brien", result); + } + + [Fact] + public void GivenCustomTableName_WhenBuildWhereClause_ThenUsesCustomTableName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("Smith", string.Empty, tableName: "sp"); + + // Assert + Assert.Contains("sp.Text", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/TokenSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/TokenSqlParserTests.cs new file mode 100644 index 0000000000..afaf415a39 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/TokenSqlParserTests.cs @@ -0,0 +1,145 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class TokenSqlParserTests + { + private readonly TokenSqlParser _parser; + + public TokenSqlParserTests() + { + _parser = new TokenSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + } + + [Fact] + public void GivenEmptyValue_WhenBuildWhereClause_ThenReturnsAlwaysTrue() + { + // Arrange / Act + var result = _parser.BuildWhereClause(string.Empty, string.Empty); + + // Assert + Assert.Equal("1=1", result); + } + + [Fact] + public void GivenCodeOnly_WhenBuildWhereClause_ThenGeneratesCodeCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("active", string.Empty); + + // Assert + Assert.Equal("t.Code = 'active'", result); + } + + [Fact] + public void GivenSystemAndCode_WhenBuildWhereClause_ThenGeneratesSystemAndCodeConditions() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://sys|active", string.Empty); + + // Assert + Assert.Contains("t.SystemId = (SELECT SystemId FROM dbo.System WHERE Value = 'http://sys')", result); + Assert.Contains("t.Code = 'active'", result); + Assert.Contains(" AND ", result); + } + + [Fact] + public void GivenEmptySystem_WhenBuildWhereClause_ThenGeneratesNullOrEmptySystemCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("|active", string.Empty); + + // Assert + Assert.Contains("SystemId", result); + Assert.Contains("IS NULL", result); + Assert.Contains("t.Code = 'active'", result); + } + + [Fact] + public void GivenSystemOnly_WhenBuildWhereClause_ThenGeneratesSystemConditionWithoutCode() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://sys|", string.Empty); + + // Assert + Assert.Contains("t.SystemId = (SELECT SystemId FROM dbo.System WHERE Value = 'http://sys')", result); + Assert.DoesNotContain("Code", result); + } + + [Fact] + public void GivenTextModifier_WhenBuildWhereClause_ThenGeneratesTextLikeCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("active", "text"); + + // Assert + Assert.Equal("(t.Text LIKE N'active%')", result); + } + + [Fact] + public void GivenLongCode_WhenBuildWhereClause_ThenUsesCodeAndCodeOverflow() + { + // Arrange + var longCode = new string('x', 300); + var expectedPrefix = longCode.Substring(0, 256); + var expectedOverflow = longCode.Substring(256); + + // Act + var result = _parser.BuildWhereClause(longCode, string.Empty); + + // Assert + Assert.Contains($"t.Code = '{expectedPrefix}'", result); + Assert.Contains($"t.CodeOverflow = '{expectedOverflow}'", result); + } + + [Fact] + public void GivenValueWithSingleQuote_WhenBuildWhereClause_ThenEscapesQuote() + { + // Arrange / Act + var result = _parser.BuildWhereClause("o'brian", string.Empty); + + // Assert + Assert.Contains("o''brian", result); + } + + [Fact] + public void GivenTextModifierWithSingleQuote_WhenBuildWhereClause_ThenEscapesQuote() + { + // Arrange / Act + var result = _parser.BuildWhereClause("o'test", "text"); + + // Assert + Assert.Contains("o''test", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffixToColumnNames() + { + // Arrange / Act + var result = _parser.BuildWhereClause("active", string.Empty, columnSuffix: 2); + + // Assert + Assert.Contains("t.Code2 = 'active'", result); + } + + [Fact] + public void GivenCustomTableName_WhenBuildWhereClause_ThenUsesCustomTableName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("active", string.Empty, tableName: "sp"); + + // Assert + Assert.Contains("sp.Code = 'active'", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/UriSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/UriSqlParserTests.cs new file mode 100644 index 0000000000..311cbf1533 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/BaseParsers/UriSqlParserTests.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.BaseParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class UriSqlParserTests + { + private readonly UriSqlParser _parser; + + public UriSqlParserTests() + { + _parser = new UriSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + } + + [Fact] + public void GivenEmptyValue_WhenBuildWhereClause_ThenReturnsAlwaysTrue() + { + // Arrange / Act + var result = _parser.BuildWhereClause(string.Empty, string.Empty); + + // Assert + Assert.Equal("1=1", result); + } + + [Fact] + public void GivenSimpleUri_WhenBuildWhereClause_ThenGeneratesExactMatchCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/profile", string.Empty); + + // Assert + Assert.Equal("t.Uri = 'http://example.org/profile'", result); + } + + [Fact] + public void GivenAboveModifier_WhenBuildWhereClause_ThenGeneratesAncestorCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/a/b", "above"); + + // Assert + Assert.Contains("LIKE t.Uri", result); + Assert.Contains("NOT LIKE 'urn:%'", result); + } + + [Fact] + public void GivenBelowModifier_WhenBuildWhereClause_ThenGeneratesDescendantCondition() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/a", "below"); + + // Assert + Assert.Contains("t.Uri", result); + Assert.Contains("LIKE 'http://example.org/a'", result); + Assert.Contains("NOT LIKE 'urn:%'", result); + } + + [Fact] + public void GivenUnknownModifier_WhenBuildWhereClause_ThenFallsBackToExactMatch() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/profile", "unknown"); + + // Assert + Assert.Equal("t.Uri = 'http://example.org/profile'", result); + } + + [Fact] + public void GivenUriWithSingleQuote_WhenBuildWhereClause_ThenEscapesQuote() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/a'b", string.Empty); + + // Assert + Assert.Contains("a''b", result); + } + + [Fact] + public void GivenColumnSuffix_WhenBuildWhereClause_ThenAppendsSuffixToColumnName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/profile", string.Empty, columnSuffix: 2); + + // Assert + Assert.Contains("t.Uri2 = 'http://example.org/profile'", result); + } + + [Fact] + public void GivenCustomTableName_WhenBuildWhereClause_ThenUsesCustomTableName() + { + // Arrange / Act + var result = _parser.BuildWhereClause("http://example.org/profile", string.Empty, tableName: "u"); + + // Assert + Assert.Contains("u.Uri = 'http://example.org/profile'", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ChainSearchGroupTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ChainSearchGroupTests.cs new file mode 100644 index 0000000000..7203cb7b0c --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ChainSearchGroupTests.cs @@ -0,0 +1,179 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class ChainSearchGroupTests + { + [Fact] + public void GivenEmptyDictionary_WhenGroupChainedParameters_ThenReturnsEmptyList() + { + var input = new Dictionary>(); + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Empty(result); + } + + [Fact] + public void GivenSingleChain_WhenGroupChainedParameters_ThenReturnsSingleGroup() + { + var input = new Dictionary> + { + { "subject.name", new List { "John" } }, + }; + + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Single(result); + Assert.Equal("subject", result[0].GroupKey); + Assert.False(result[0].IsReverseChain); + Assert.Single(result[0].Entries); + Assert.Equal("name", result[0].Entries[0].RemainingChain); + Assert.Equal("John", result[0].Entries[0].Value); + } + + [Fact] + public void GivenMultipleChainsWithSameRef_WhenGroupChainedParameters_ThenGroupsTogether() + { + var input = new Dictionary> + { + { "subject.name", new List { "John" } }, + { "subject.birthdate", new List { "2000-01-01" } }, + }; + + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Single(result); + Assert.Equal(2, result[0].Entries.Count); + } + + [Fact] + public void GivenChainsWithDifferentRefs_WhenGroupChainedParameters_ThenCreatesSeparateGroups() + { + var input = new Dictionary> + { + { "subject.name", new List { "John" } }, + { "performer.name", new List { "Dr Smith" } }, + }; + + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Equal(2, result.Count); + } + + [Fact] + public void GivenEntryWithoutDot_WhenGroupChainedParameters_ThenSkipsIt() + { + var input = new Dictionary> + { + { "nodot", new List { "value" } }, + }; + + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Empty(result); + } + + [Fact] + public void GivenMultipleValuesForSameParam_WhenGroupChainedParameters_ThenCreatesEntryPerValue() + { + var input = new Dictionary> + { + { "subject.name", new List { "John", "Jane" } }, + }; + + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Single(result); + Assert.Equal(2, result[0].Entries.Count); + Assert.Equal("John", result[0].Entries[0].Value); + Assert.Equal("Jane", result[0].Entries[1].Value); + } + + [Fact] + public void GivenTypedRefChain_WhenGroupChainedParameters_ThenPreservesTypeInGroupKey() + { + var input = new Dictionary> + { + { "subject:Patient.name", new List { "John" } }, + }; + + var result = ChainSearchGroup.GroupChainedParameters(input); + Assert.Single(result); + Assert.Equal("subject:Patient", result[0].GroupKey); + Assert.Equal("subject:Patient", result[0].Entries[0].ReferenceParamCode); + } + + // Reverse chain grouping tests + + [Fact] + public void GivenEmptyDictionary_WhenGroupReversedChainedParameters_ThenReturnsEmptyList() + { + var input = new Dictionary>(); + var result = ChainSearchGroup.GroupReversedChainedParameters(input); + Assert.Empty(result); + } + + [Fact] + public void GivenSingleReverseChain_WhenGroupReversedChainedParameters_ThenReturnsSingleGroup() + { + var input = new Dictionary> + { + { "_has:Coverage:beneficiary:identifier", new List { "12345" } }, + }; + + var result = ChainSearchGroup.GroupReversedChainedParameters(input); + Assert.Single(result); + Assert.Equal("Coverage:beneficiary", result[0].GroupKey); + Assert.True(result[0].IsReverseChain); + Assert.Single(result[0].Entries); + Assert.Equal("identifier", result[0].Entries[0].RemainingChain); + Assert.Equal("12345", result[0].Entries[0].Value); + Assert.Equal("Coverage", result[0].Entries[0].SourceResourceType); + } + + [Fact] + public void GivenMultipleReverseChainsSameGroup_WhenGroupReversedChainedParameters_ThenGroupsTogether() + { + var input = new Dictionary> + { + { "_has:Coverage:beneficiary:identifier", new List { "12345" } }, + { "_has:Coverage:beneficiary:status", new List { "active" } }, + }; + + var result = ChainSearchGroup.GroupReversedChainedParameters(input); + Assert.Single(result); + Assert.Equal(2, result[0].Entries.Count); + } + + [Fact] + public void GivenReverseChainsDifferentResourceTypes_WhenGroupReversedChainedParameters_ThenCreatesSeparateGroups() + { + var input = new Dictionary> + { + { "_has:Coverage:beneficiary:identifier", new List { "12345" } }, + { "_has:Observation:subject:code", new List { "vital" } }, + }; + + var result = ChainSearchGroup.GroupReversedChainedParameters(input); + Assert.Equal(2, result.Count); + } + + [Fact] + public void GivenEntryWithTooFewParts_WhenGroupReversedChainedParameters_ThenSkipsIt() + { + var input = new Dictionary> + { + { "_has:Coverage:beneficiary", new List { "value" } }, + }; + + var result = ChainSearchGroup.GroupReversedChainedParameters(input); + Assert.Empty(result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/CompositeParsers/CompositeParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/CompositeParsers/CompositeParserTests.cs new file mode 100644 index 0000000000..2aeede47f1 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/CompositeParsers/CompositeParserTests.cs @@ -0,0 +1,106 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.CompositeParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class CompositeParserTests + { + private static readonly SqlSearchParameterDefinitionManager MockDefManager = + ParserTestHelper.CreateMockDefinitionManager(); + + [Fact] + public void GivenTokenStringComposite_WhenBuildWhereClause_ThenCombinesTokenAndStringConditions() + { + var parser = new TokenStringCompositeSqlParser(MockDefManager); + var result = parser.BuildWhereClause("http://sys|code$stringval", string.Empty); + + Assert.Contains("Code1", result); + Assert.Contains("SystemId1", result); + Assert.Contains("Text2", result); + Assert.Contains("AND", result); + } + + [Fact] + public void GivenTokenTokenComposite_WhenBuildWhereClause_ThenCombinesTwoTokenConditions() + { + var parser = new TokenTokenCompositeSqlParser(MockDefManager); + var result = parser.BuildWhereClause("code1$code2", string.Empty); + + Assert.Contains("Code1", result); + Assert.Contains("Code2", result); + Assert.Contains("AND", result); + } + + [Fact] + public void GivenTokenDateTimeComposite_WhenBuildWhereClause_ThenCombinesTokenAndDateConditions() + { + var parser = new TokenDateTimeCompositeSqlParser(MockDefManager); + var result = parser.BuildWhereClause("code$2024-01-15", string.Empty); + + Assert.Contains("Code1", result); + Assert.Contains("DateTime2", result); + Assert.Contains("AND", result); + } + + [Fact] + public void GivenTokenQuantityComposite_WhenBuildWhereClause_ThenCombinesTokenAndQuantityConditions() + { + var parser = new TokenQuantityCompositeSqlParser(MockDefManager); + var result = parser.BuildWhereClause("code$100", string.Empty); + + Assert.Contains("Code1", result); + Assert.Contains("Value2", result); + Assert.Contains("AND", result); + } + + [Fact] + public void GivenTokenNumberNumberComposite_WhenBuildWhereClause_ThenCombinesThreeComponents() + { + var parser = new TokenNumberNumberCompositeSqlParser(MockDefManager); + var result = parser.BuildWhereClause("code$100$200", string.Empty); + + Assert.Contains("Code1", result); + Assert.Contains("Value2", result); + Assert.Contains("Value3", result); + } + + [Fact] + public void GivenTwoComponentComposite_WhenValueHasNoDollarSign_ThenThrows() + { + var parser = new TokenStringCompositeSqlParser(MockDefManager); + Assert.Throws(() => + parser.BuildWhereClause("nodollarsign", string.Empty)); + } + + [Fact] + public void GivenThreeComponentComposite_WhenValueHasOnlyOneDollarSign_ThenThrows() + { + var parser = new TokenNumberNumberCompositeSqlParser(MockDefManager); + Assert.Throws(() => + parser.BuildWhereClause("code$100", string.Empty)); + } + + [Fact] + public void GivenReferenceTokenComposite_WhenBuildWhereClause_ThenCombinesReferenceAndTokenConditions() + { + var fhirModel = ParserTestHelper.CreateMockFhirModel(("Patient", 1)); + var parser = new ReferenceTokenCompositeSqlParser(MockDefManager, fhirModel); + var result = parser.BuildWhereClause("Patient/123$active", string.Empty); + + Assert.Contains("ReferenceResourceId1", result); + Assert.Contains("Code2", result); + Assert.Contains("AND", result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ParserTestHelper.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ParserTestHelper.cs new file mode 100644 index 0000000000..9e64fce38f --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ParserTestHelper.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Runtime.CompilerServices; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using NSubstitute; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser +{ + /// + /// Helper for creating parser instances with mocked dependencies in unit tests. + /// + internal static class ParserTestHelper + { + /// + /// Creates a SqlSearchParameterDefinitionManager without invoking its constructor. + /// The returned instance cannot resolve actual search parameters, but is sufficient + /// for testing BuildWhereClause methods that don't access the parameter collection. + /// + public static SqlSearchParameterDefinitionManager CreateMockDefinitionManager() + { + return (SqlSearchParameterDefinitionManager)RuntimeHelpers.GetUninitializedObject( + typeof(SqlSearchParameterDefinitionManager)); + } + + /// + /// Creates a mocked ISqlServerFhirModel where TryGetResourceTypeId returns true + /// for the specified resource types with their assigned IDs. + /// + public static ISqlServerFhirModel CreateMockFhirModel(params (string resourceType, short id)[] mappings) + { + var model = Substitute.For(); + + foreach (var (resourceType, id) in mappings) + { + model.TryGetResourceTypeId(resourceType, out Arg.Any()) + .Returns(x => + { + x[1] = id; + return true; + }); + } + + return model; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ParserUtilTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ParserUtilTests.cs new file mode 100644 index 0000000000..aa90bce8cd --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/ParserUtilTests.cs @@ -0,0 +1,188 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class ParserUtilTests + { + [Fact] + public void GivenDefaultOptions_WhenAddFirstCteFilters_ThenAddsHistoryAndDeletedChecks() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + var options = new ParserOptions + { + ResourceVersionType = ResourceVersionType.Latest, + }; + + ParserUtil.AddFirstCteFilters(builder, options, "r"); + var sql = builder.ToString(); + Assert.Contains("r.IsHistory = 0", sql); + Assert.Contains("r.IsDeleted = 0", sql); + } + + [Fact] + public void GivenHistoryOptions_WhenAddFirstCteFilters_ThenSkipsHistoryCheck() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + var options = new ParserOptions + { + ResourceVersionType = ResourceVersionType.Latest | ResourceVersionType.History, + }; + + ParserUtil.AddFirstCteFilters(builder, options, "r"); + var sql = builder.ToString(); + Assert.DoesNotContain("r.IsHistory = 0", sql); + Assert.Contains("r.IsDeleted = 0", sql); + } + + [Fact] + public void GivenSoftDeletedOptions_WhenAddFirstCteFilters_ThenSkipsDeletedCheck() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + var options = new ParserOptions + { + ResourceVersionType = ResourceVersionType.Latest | ResourceVersionType.SoftDeleted, + }; + + ParserUtil.AddFirstCteFilters(builder, options, "r"); + var sql = builder.ToString(); + Assert.Contains("r.IsHistory = 0", sql); + Assert.DoesNotContain("r.IsDeleted = 0", sql); + } + + [Fact] + public void GivenResourceTypes_WhenAddFirstCteFilters_ThenAddsResourceTypeInClause() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + var options = new ParserOptions + { + ResourceTypes = new List { 10, 20 }, + }; + + ParserUtil.AddFirstCteFilters(builder, options, "r"); + var sql = builder.ToString(); + Assert.Contains("r.ResourceTypeId IN (10, 20)", sql); + } + + [Fact] + public void GivenExcludedResourceTypes_WhenAddFirstCteFilters_ThenAddsNotInClause() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + var options = new ParserOptions + { + ExcludedResourceTypes = new List { 5 }, + }; + + ParserUtil.AddFirstCteFilters(builder, options, "r"); + var sql = builder.ToString(); + Assert.Contains("r.ResourceTypeId NOT IN (5)", sql); + } + + [Fact] + public void GivenLastCteName_WhenAddFirstCteFilters_ThenSkipsAllBaseFilters() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + var options = new ParserOptions + { + LastCteName = "cte0", + ResourceTypes = new List { 10 }, + }; + + ParserUtil.AddFirstCteFilters(builder, options, "r"); + var sql = builder.ToString(); + Assert.DoesNotContain("IsHistory", sql); + Assert.DoesNotContain("IsDeleted", sql); + Assert.DoesNotContain("ResourceTypeId IN", sql); + } + + [Fact] + public void GivenSingleCte_WhenAddUnionCte_ThenSelectsFromSingleCte() + { + var builder = new SqlQueryBuilder(); + + // Need at least one CTE to exist so _isFirstCte is false + builder.BeginCte("cte0"); + builder.Select("1"); + builder.EndCte(); + builder.AppendLine(); + + ParserUtil.AddUnionCte(builder, "unionCte", new List { "cte0" }); + var sql = builder.ToString(); + Assert.Contains("SELECT * FROM cte0", sql); + Assert.DoesNotContain("UNION ALL", sql); + } + + [Fact] + public void GivenMultipleCtes_WhenAddUnionCte_ThenProducesUnionAll() + { + var builder = new SqlQueryBuilder(); + builder.BeginCte("cte0"); + builder.Select("1"); + builder.EndCte(); + builder.AppendLine(); + + ParserUtil.AddUnionCte(builder, "unionCte", new List { "cte0", "include0" }); + var sql = builder.ToString(); + Assert.Contains("SELECT * FROM cte0", sql); + Assert.Contains("UNION ALL", sql); + Assert.Contains("include0", sql); + Assert.Contains("NOT EXISTS", sql); + } + + [Fact] + public void GivenIncludeSort_WhenAddUnionCte_ThenAddsSortValueNull() + { + var builder = new SqlQueryBuilder(); + builder.BeginCte("cte0"); + builder.Select("1"); + builder.EndCte(); + builder.AppendLine(); + + ParserUtil.AddUnionCte(builder, "unionCte", new List { "cte0", "inc0" }, includeSort: true); + var sql = builder.ToString(); + Assert.Contains("SortValue = NULL", sql); + } + + [Fact] + public void GivenHistoryAndDeletedCheck_WhenBothIncluded_ThenNoChecksAdded() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.Where("1=1"); + + ParserUtil.AddHistoryAndDeletedCheck(builder, "r", includeHistory: true, includeDeleted: true); + var sql = builder.ToString(); + Assert.DoesNotContain("IsHistory", sql); + Assert.DoesNotContain("IsDeleted", sql); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/QueryStringParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/QueryStringParserTests.cs new file mode 100644 index 0000000000..f6478fdcb0 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/QueryStringParserTests.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class QueryStringParserTests + { + [Fact] + public void GivenNullInput_WhenParse_ThenReturnsEmptyDictionary() + { + var result = QueryStringParser.Parse(null); + Assert.Empty(result); + } + + [Fact] + public void GivenEmptyString_WhenParse_ThenReturnsEmptyDictionary() + { + var result = QueryStringParser.Parse(string.Empty); + Assert.Empty(result); + } + + [Fact] + public void GivenNoQuestionMark_WhenParse_ThenReturnsEmptyDictionary() + { + var result = QueryStringParser.Parse("name=value"); + Assert.Empty(result); + } + + [Fact] + public void GivenSingleParam_WhenParse_ThenReturnsSingleEntry() + { + var result = QueryStringParser.Parse("http://host?name=value"); + Assert.Single(result); + Assert.Equal("value", result["name"][0]); + } + + [Fact] + public void GivenMultipleParams_WhenParse_ThenReturnsAllEntries() + { + var result = QueryStringParser.Parse("http://host?a=1&b=2&c=3"); + Assert.Equal(3, result.Count); + Assert.Equal("1", result["a"][0]); + Assert.Equal("2", result["b"][0]); + Assert.Equal("3", result["c"][0]); + } + + [Fact] + public void GivenDuplicateKeys_WhenParse_ThenReturnsListWithMultipleValues() + { + var result = QueryStringParser.Parse("http://host?a=1&a=2&a=3"); + Assert.Single(result); + Assert.Equal(3, result["a"].Count); + Assert.Equal("1", result["a"][0]); + Assert.Equal("2", result["a"][1]); + Assert.Equal("3", result["a"][2]); + } + + [Fact] + public void GivenUrlEncodedValue_WhenParse_ThenDecodesValue() + { + var result = QueryStringParser.Parse("http://host?name=hello%20world"); + Assert.Equal("hello world", result["name"][0]); + } + + [Fact] + public void GivenParamWithoutValue_WhenParse_ThenReturnsEmptyStringValue() + { + var result = QueryStringParser.Parse("http://host?flag"); + Assert.Equal(string.Empty, result["flag"][0]); + } + + [Fact] + public void GivenParamWithEmptyValue_WhenParse_ThenReturnsEmptyStringValue() + { + var result = QueryStringParser.Parse("http://host?name="); + Assert.Equal(string.Empty, result["name"][0]); + } + + [Fact] + public void GivenCaseInsensitiveKeys_WhenParse_ThenGroupsTogether() + { + var result = QueryStringParser.Parse("http://host?Name=a&name=b"); + Assert.Single(result); + Assert.Equal(2, result["name"].Count); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/IdSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/IdSqlParserTests.cs new file mode 100644 index 0000000000..5b9c61a8e3 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/IdSqlParserTests.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.SpecialParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class IdSqlParserTests + { + private readonly IdSqlParser _parser = new IdSqlParser(); + + private ParserOptions CreateOptions(int cteNumber = 0) + { + return new ParserOptions + { + CteNumber = cteNumber, + SqlQueryBuilder = new SqlQueryBuilder(), + ResourceTypes = new List { 1 }, + }; + } + + [Fact] + public void GivenSingleId_WhenParse_ThenProducesEqualsCondition() + { + var options = CreateOptions(); + _parser.Parse("_id", "123", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceId = '123'", sql); + Assert.Contains("cte0", sql); + } + + [Fact] + public void GivenMultipleIds_WhenParse_ThenProducesInClause() + { + var options = CreateOptions(); + _parser.Parse("_id", "123,456,789", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceId IN ('123', '456', '789')", sql); + } + + [Fact] + public void GivenNotModifier_WhenParse_ThenProducesNotEqualsCondition() + { + var options = CreateOptions(); + _parser.Parse("_id:not", "123", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceId <> '123'", sql); + } + + [Fact] + public void GivenNotModifierWithMultipleIds_WhenParse_ThenProducesNotInClause() + { + var options = CreateOptions(); + _parser.Parse("_id:not", "123,456", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceId NOT IN ('123', '456')", sql); + } + + [Fact] + public void GivenEmptyValue_WhenParse_ThenThrows() + { + var options = CreateOptions(); + Assert.Throws(() => _parser.Parse("_id", string.Empty, options)); + } + + [Fact] + public void GivenIdWithSingleQuote_WhenParse_ThenEscapesValue() + { + var options = CreateOptions(); + _parser.Parse("_id", "ab'cd", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("ab''cd", sql); + } + + [Fact] + public void GivenOptions_WhenParse_ThenSetsResultCteName() + { + var options = CreateOptions(); + _parser.Parse("_id", "123", options); + + Assert.Equal("cte0", options.ResultCteName); + } + + [Fact] + public void GivenChainLevel_WhenParse_ThenUsesChainCteName() + { + var options = CreateOptions(); + options.ChainLevel = 1; + _parser.Parse("_id", "123", options); + + Assert.Equal("cte0chain1", options.ResultCteName); + } + + [Fact] + public void GivenParse_WhenCalled_ThenSelectsFromResource() + { + var options = CreateOptions(); + _parser.Parse("_id", "123", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("FROM dbo.Resource", sql); + Assert.Contains("SELECT DISTINCT", sql); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/LastUpdatedSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/LastUpdatedSqlParserTests.cs new file mode 100644 index 0000000000..142ab279a3 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/LastUpdatedSqlParserTests.cs @@ -0,0 +1,101 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.SpecialParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class LastUpdatedSqlParserTests + { + private readonly LastUpdatedSqlParser _parser = new LastUpdatedSqlParser(); + + private ParserOptions CreateOptions(int cteNumber = 0) + { + return new ParserOptions + { + CteNumber = cteNumber, + SqlQueryBuilder = new SqlQueryBuilder(), + ResourceTypes = new List { 1 }, + }; + } + + [Fact] + public void GivenDateValue_WhenParse_ThenProducesSurrogateIdRangeCondition() + { + var options = CreateOptions(); + _parser.Parse("_lastUpdated", "2024-01-15", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceSurrogateId >=", sql); + Assert.Contains("r.ResourceSurrogateId <", sql); + } + + [Fact] + public void GivenGtPrefix_WhenParse_ThenProducesGreaterThanOrEqualCondition() + { + var options = CreateOptions(); + _parser.Parse("_lastUpdated", "gt2024-01-15", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceSurrogateId >=", sql); + } + + [Fact] + public void GivenLtPrefix_WhenParse_ThenProducesLessThanCondition() + { + var options = CreateOptions(); + _parser.Parse("_lastUpdated", "lt2024-01-15", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceSurrogateId <", sql); + } + + [Fact] + public void GivenNePrefix_WhenParse_ThenProducesNotEqualCondition() + { + var options = CreateOptions(); + _parser.Parse("_lastUpdated", "ne2024-01-15", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceSurrogateId >=", sql); + Assert.Contains("OR", sql); + } + + [Fact] + public void GivenEmptyValue_WhenParse_ThenThrows() + { + var options = CreateOptions(); + Assert.Throws(() => _parser.Parse("_lastUpdated", string.Empty, options)); + } + + [Fact] + public void GivenParse_WhenCalled_ThenSelectsFromResource() + { + var options = CreateOptions(); + _parser.Parse("_lastUpdated", "2024-01-15", options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("FROM dbo.Resource", sql); + Assert.Contains("cte0", sql); + } + + [Fact] + public void GivenParse_WhenCalled_ThenSetsResultCteName() + { + var options = CreateOptions(); + _parser.Parse("_lastUpdated", "2024-01-15", options); + + Assert.Equal("cte0", options.ResultCteName); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/SortSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/SortSqlParserTests.cs new file mode 100644 index 0000000000..e6aac44ab7 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/SortSqlParserTests.cs @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.SpecialParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class SortSqlParserTests + { + [Fact] + public void GivenNoSortValue_WhenCreateOrderByClause_ThenReturnsDefaultOrder() + { + var result = SortSqlParser.CreateOrderByClause(sortDescending: false, hasSortValue: false); + + Assert.Contains("t.IsMatch DESC", result); + Assert.Contains("t.ResourceTypeId ASC", result); + Assert.Contains("t.ResourceSurrogateId ASC", result); + } + + [Fact] + public void GivenAscendingSort_WhenCreateOrderByClause_ThenReturnsAscendingSortWithNullsLast() + { + var result = SortSqlParser.CreateOrderByClause(sortDescending: false, hasSortValue: true); + + Assert.Contains("t.IsMatch DESC", result); + Assert.Contains("CASE WHEN t.SortValue IS NULL THEN 1 ELSE 0 END ASC", result); + Assert.Contains("t.SortValue ASC", result); + Assert.Contains("t.ResourceTypeId ASC", result); + Assert.Contains("t.ResourceSurrogateId ASC", result); + } + + [Fact] + public void GivenDescendingSort_WhenCreateOrderByClause_ThenReturnsDescendingSortWithNullsLast() + { + var result = SortSqlParser.CreateOrderByClause(sortDescending: true, hasSortValue: true); + + Assert.Contains("t.SortValue DESC", result); + Assert.Contains("CASE WHEN t.SortValue IS NULL THEN 1 ELSE 0 END ASC", result); + } + + [Fact] + public void GivenNullSortParameterName_WhenCreateSortCte_ThenReturnsNull() + { + var parser = new SortSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + var result = parser.CreateSortCte(null, false, "cte0", "sortCte", 1); + + Assert.Null(result); + } + + [Fact] + public void GivenEmptySourceCteName_WhenCreateSortCte_ThenReturnsNull() + { + var parser = new SortSqlParser(ParserTestHelper.CreateMockDefinitionManager()); + var result = parser.CreateSortCte("date", false, string.Empty, "sortCte", 1); + + Assert.Null(result); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/SystemSqlParserTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/SystemSqlParserTests.cs new file mode 100644 index 0000000000..bdda24485b --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SpecialParsers/SystemSqlParserTests.cs @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser.SpecialParsers +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class SystemSqlParserTests + { + private readonly SystemSqlParser _parser = new SystemSqlParser(); + + [Fact] + public void GivenNoResourceTypes_WhenParse_ThenProducesBasicQuery() + { + var options = new ParserOptions + { + CteNumber = 0, + SqlQueryBuilder = new SqlQueryBuilder(), + }; + + _parser.Parse(string.Empty, string.Empty, options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("SELECT r.ResourceTypeId, r.ResourceSurrogateId", sql); + Assert.Contains("FROM dbo.Resource", sql); + Assert.Contains("r.IsHistory = 0", sql); + Assert.Contains("r.IsDeleted = 0", sql); + } + + [Fact] + public void GivenResourceTypes_WhenParse_ThenAddsResourceTypeFilter() + { + var options = new ParserOptions + { + CteNumber = 0, + SqlQueryBuilder = new SqlQueryBuilder(), + ResourceTypes = new List { 10, 20 }, + }; + + _parser.Parse(string.Empty, string.Empty, options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("r.ResourceTypeId IN (10, 20)", sql); + } + + [Fact] + public void GivenParse_WhenCalled_ThenCreatesCte() + { + var options = new ParserOptions + { + CteNumber = 5, + SqlQueryBuilder = new SqlQueryBuilder(), + }; + + _parser.Parse(string.Empty, string.Empty, options); + var sql = options.SqlQueryBuilder.ToString(); + + Assert.Contains("cte5 AS (", sql); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SqlQueryBuilderTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SqlQueryBuilderTests.cs new file mode 100644 index 0000000000..a29d3993fd --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SqlQueryBuilderTests.cs @@ -0,0 +1,238 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class SqlQueryBuilderTests + { + [Fact] + public void GivenNewBuilder_WhenToString_ThenReturnsEmpty() + { + var builder = new SqlQueryBuilder(); + Assert.Equal(string.Empty, builder.ToString()); + Assert.Equal(0, builder.Length); + } + + [Fact] + public void GivenBuilder_WhenAppendLine_ThenAppendsTextWithNewline() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("hello"); + Assert.Contains("hello", builder.ToString()); + Assert.EndsWith(Environment.NewLine, builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenSelectSingleColumn_ThenProducesSelectLine() + { + var builder = new SqlQueryBuilder(); + builder.Select("col1"); + Assert.Contains("SELECT col1", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenSelectMultipleColumns_ThenProducesCommaSeparated() + { + var builder = new SqlQueryBuilder(); + builder.Select("col1", "col2", "col3"); + var sql = builder.ToString(); + Assert.Contains("SELECT col1, col2, col3", sql); + } + + [Fact] + public void GivenBuilder_WhenSelectWithModifier_ThenIncludesModifier() + { + var builder = new SqlQueryBuilder(); + builder.SelectWithModifier("DISTINCT", "col1", "col2"); + var sql = builder.ToString(); + Assert.Contains("SELECT DISTINCT col1, col2", sql); + } + + [Fact] + public void GivenBuilder_WhenSelectWithTopModifier_ThenIncludesTop() + { + var builder = new SqlQueryBuilder(); + builder.SelectWithModifier("TOP 100", "col1"); + Assert.Contains("SELECT TOP 100 col1", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenFromWithAlias_ThenProducesFromAs() + { + var builder = new SqlQueryBuilder(); + builder.From("dbo.Resource", "r"); + Assert.Contains("FROM dbo.Resource AS r", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenFromWithoutAlias_ThenProducesFrom() + { + var builder = new SqlQueryBuilder(); + builder.From("dbo.Resource"); + Assert.Contains("FROM dbo.Resource", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenInnerJoin_ThenProducesInnerJoinOn() + { + var builder = new SqlQueryBuilder(); + builder.InnerJoin("dbo.TokenSearchParam", "t", "t.ResourceSurrogateId = r.ResourceSurrogateId"); + var sql = builder.ToString(); + Assert.Contains("INNER JOIN dbo.TokenSearchParam AS t ON t.ResourceSurrogateId = r.ResourceSurrogateId", sql); + } + + [Fact] + public void GivenBuilder_WhenLeftJoin_ThenProducesLeftJoinOn() + { + var builder = new SqlQueryBuilder(); + builder.LeftJoin("dbo.Resource", "r", "r.Id = t.Id"); + Assert.Contains("LEFT JOIN dbo.Resource AS r ON r.Id = t.Id", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenWhereAndOr_ThenProducesCorrectClauses() + { + var builder = new SqlQueryBuilder(); + builder.Where("col1 = 1"); + builder.And("col2 = 2"); + builder.Or("col3 = 3"); + var sql = builder.ToString(); + Assert.Contains("WHERE col1 = 1", sql); + Assert.Contains("AND col2 = 2", sql); + Assert.Contains("OR col3 = 3", sql); + } + + [Fact] + public void GivenBuilder_WhenOrderBy_ThenProducesOrderBy() + { + var builder = new SqlQueryBuilder(); + builder.OrderBy("col1 ASC"); + Assert.Contains("ORDER BY col1 ASC", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenBeginAndEndCte_ThenProducesWithAs() + { + var builder = new SqlQueryBuilder(); + builder.BeginCte("cte0"); + builder.Select("1"); + builder.EndCte(); + var sql = builder.ToString(); + Assert.Contains(";WITH", sql); + Assert.Contains("cte0 AS (", sql); + Assert.Contains(")", sql); + } + + [Fact] + public void GivenBuilder_WhenMultipleCtes_ThenSecondUsesComma() + { + var builder = new SqlQueryBuilder(); + builder.BeginCte("cte0"); + builder.Select("1"); + builder.EndCte(); + builder.AppendLine(); + builder.BeginCte("cte1"); + builder.Select("2"); + builder.EndCte(); + var sql = builder.ToString(); + Assert.Contains(";WITH", sql); + Assert.Contains("cte0 AS (", sql); + Assert.Contains(",", sql); + Assert.Contains("cte1 AS (", sql); + } + + [Fact] + public void GivenBuilder_WhenEndCteWithoutBegin_ThenThrows() + { + var builder = new SqlQueryBuilder(); + Assert.Throws(() => builder.EndCte()); + } + + [Fact] + public void GivenBuilder_WhenIncreaseAndDecreaseIndent_ThenIndentLevelChanges() + { + var builder = new SqlQueryBuilder(); + Assert.Equal(0, builder.IndentLevel); + + builder.IncreaseIndent(); + Assert.Equal(1, builder.IndentLevel); + + builder.IncreaseIndent(2); + Assert.Equal(3, builder.IndentLevel); + + builder.DecreaseIndent(2); + Assert.Equal(1, builder.IndentLevel); + + builder.DecreaseIndent(5); + Assert.Equal(0, builder.IndentLevel); + } + + [Fact] + public void GivenBuilder_WhenClear_ThenResetsState() + { + var builder = new SqlQueryBuilder(); + builder.AppendLine("SELECT 1"); + builder.IncreaseIndent(); + builder.Clear(); + Assert.Equal(string.Empty, builder.ToString()); + Assert.Equal(0, builder.Length); + Assert.Equal(0, builder.IndentLevel); + } + + [Fact] + public void GivenBuilder_WhenJoinMultiLine_ThenProducesMultiLineJoin() + { + var builder = new SqlQueryBuilder(); + builder.JoinMultiLine("INNER", "dbo.Table", "t", "t.Col1 = r.Col1", "t.Col2 = r.Col2"); + var sql = builder.ToString(); + Assert.Contains("INNER JOIN dbo.Table AS t", sql); + Assert.Contains("ON t.Col1 = r.Col1", sql); + Assert.Contains("AND t.Col2 = r.Col2", sql); + } + + [Fact] + public void GivenBuilder_WhenGroupBy_ThenProducesGroupBy() + { + var builder = new SqlQueryBuilder(); + builder.GroupBy("col1, col2"); + Assert.Contains("GROUP BY col1, col2", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenHaving_ThenProducesHaving() + { + var builder = new SqlQueryBuilder(); + builder.Having("COUNT(*) > 1"); + Assert.Contains("HAVING COUNT(*) > 1", builder.ToString()); + } + + [Fact] + public void GivenBuilder_WhenAppend_ThenAppendsWithoutNewline() + { + var builder = new SqlQueryBuilder(); + builder.Append("hello "); + builder.Append("world"); + var sql = builder.ToString(); + Assert.Contains("hello world", sql); + Assert.DoesNotContain(Environment.NewLine + "world", sql); + } + + [Fact] + public void GivenBuilder_WhenSelectNoColumns_ThenProducesSelectOnly() + { + var builder = new SqlQueryBuilder(); + builder.Select(); + Assert.Contains("SELECT", builder.ToString()); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlServerSearchServiceTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlServerSearchServiceTests.cs index 8464541d15..427fc5e24f 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlServerSearchServiceTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlServerSearchServiceTests.cs @@ -25,9 +25,7 @@ using Microsoft.Health.Fhir.SqlServer.Features.Schema; using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; using Microsoft.Health.Fhir.SqlServer.Features.Storage; using Microsoft.Health.Fhir.SqlServer.Registration; using Microsoft.Health.Fhir.Tests.Common; @@ -52,7 +50,6 @@ public class SqlServerSearchServiceTests private readonly ISearchOptionsFactory _searchOptionsFactory; private readonly IFhirDataStore _fhirDataStore; private readonly ISqlServerFhirModel _model; - private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; private readonly ISqlRetryService _sqlRetryService; private readonly SchemaInformation _schemaInformation; private readonly ICompressedRawResourceConverter _compressedRawResourceConverter; @@ -66,7 +63,6 @@ public SqlServerSearchServiceTests() _searchOptionsFactory = Substitute.For(); _fhirDataStore = Substitute.For(); _model = Substitute.For(); - _queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(new SearchParameterToSearchValueTypeMap()); _sqlRetryService = Substitute.For(); _compressedRawResourceConverter = Substitute.For(); _requestContextAccessor = Substitute.For>(); @@ -83,10 +79,6 @@ public SqlServerSearchServiceTests() _schemaInformation = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); // Create concrete instances of rewriters with required dependencies - var sqlRootExpressionRewriter = new SqlRootExpressionRewriter(_queryGeneratorFactory); - var chainFlatteningRewriter = new ChainFlatteningRewriter(_queryGeneratorFactory); - var sortRewriter = new SortRewriter(_queryGeneratorFactory); - var partitionEliminationRewriter = new PartitionEliminationRewriter(_model, _schemaInformation, () => Substitute.For()); var compartmentDefinitionManager = Substitute.For(); var searchParameterDefinitionManager = Substitute.For(); var compartmentSearchRewriter = new SqlCompartmentSearchRewriter( @@ -96,18 +88,14 @@ public SqlServerSearchServiceTests() compartmentSearchRewriter, new Lazy(() => searchParameterDefinitionManager), Options.Create(new CoreFeatureConfiguration())); + var searchParameterSqlParser = Substitute.For(); _searchService = new SqlServerSearchService( _searchOptionsFactory, _fhirDataStore, _model, - sqlRootExpressionRewriter, - chainFlatteningRewriter, - sortRewriter, - partitionEliminationRewriter, compartmentSearchRewriter, smartCompartmentSearchRewriter, - _queryGeneratorFactory, _sqlRetryService, Options.Create(config), fhirConfig, @@ -116,6 +104,7 @@ public SqlServerSearchServiceTests() _compressedRawResourceConverter, _queryHashCalculator, _queryPlanReuseChecker, + searchParameterSqlParser, NullLogger.Instance); } @@ -123,13 +112,8 @@ public SqlServerSearchServiceTests() public void Constructor_WithNullSearchOptionsFactory_ThrowsArgumentNullException() { // Arrange - var queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(new SearchParameterToSearchValueTypeMap()); - var sqlRootExpressionRewriter = new SqlRootExpressionRewriter(queryGeneratorFactory); - var chainFlatteningRewriter = new ChainFlatteningRewriter(queryGeneratorFactory); - var sortRewriter = new SortRewriter(queryGeneratorFactory); var model = Substitute.For(); var schemaInfo = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - var partitionEliminationRewriter = new PartitionEliminationRewriter(model, schemaInfo, () => Substitute.For()); var compartmentDefinitionManager = Substitute.For(); var searchParameterDefinitionManager = Substitute.For(); var compartmentSearchRewriter = new SqlCompartmentSearchRewriter( @@ -139,6 +123,7 @@ public void Constructor_WithNullSearchOptionsFactory_ThrowsArgumentNullException compartmentSearchRewriter, new Lazy(() => searchParameterDefinitionManager), Options.Create(new CoreFeatureConfiguration())); + var searchParameterSqlParser = Substitute.For(); // Act & Assert var ex = Assert.Throws(() => @@ -147,13 +132,8 @@ public void Constructor_WithNullSearchOptionsFactory_ThrowsArgumentNullException null, _fhirDataStore, model, - sqlRootExpressionRewriter, - chainFlatteningRewriter, - sortRewriter, - partitionEliminationRewriter, compartmentSearchRewriter, smartCompartmentSearchRewriter, - queryGeneratorFactory, _sqlRetryService, Options.Create(new SqlServerDataStoreConfiguration()), new FhirSqlServerConfiguration(), @@ -162,6 +142,7 @@ public void Constructor_WithNullSearchOptionsFactory_ThrowsArgumentNullException _compressedRawResourceConverter, _queryHashCalculator, _queryPlanReuseChecker, + searchParameterSqlParser, NullLogger.Instance); }); @@ -172,13 +153,8 @@ public void Constructor_WithNullSearchOptionsFactory_ThrowsArgumentNullException public void Constructor_WithNullSqlRetryService_ThrowsArgumentNullException() { // Arrange - var queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(new SearchParameterToSearchValueTypeMap()); - var sqlRootExpressionRewriter = new SqlRootExpressionRewriter(queryGeneratorFactory); - var chainFlatteningRewriter = new ChainFlatteningRewriter(queryGeneratorFactory); - var sortRewriter = new SortRewriter(queryGeneratorFactory); var model = Substitute.For(); var schemaInfo = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - var partitionEliminationRewriter = new PartitionEliminationRewriter(model, schemaInfo, () => Substitute.For()); var compartmentDefinitionManager = Substitute.For(); var searchParameterDefinitionManager = Substitute.For(); var compartmentSearchRewriter = new SqlCompartmentSearchRewriter( @@ -188,6 +164,7 @@ public void Constructor_WithNullSqlRetryService_ThrowsArgumentNullException() compartmentSearchRewriter, new Lazy(() => searchParameterDefinitionManager), Options.Create(new CoreFeatureConfiguration())); + var searchParameterSqlParser = Substitute.For(); // Act & Assert var ex = Assert.Throws(() => @@ -196,13 +173,8 @@ public void Constructor_WithNullSqlRetryService_ThrowsArgumentNullException() _searchOptionsFactory, _fhirDataStore, model, - sqlRootExpressionRewriter, - chainFlatteningRewriter, - sortRewriter, - partitionEliminationRewriter, compartmentSearchRewriter, smartCompartmentSearchRewriter, - queryGeneratorFactory, null, Options.Create(new SqlServerDataStoreConfiguration()), new FhirSqlServerConfiguration(), @@ -211,6 +183,7 @@ public void Constructor_WithNullSqlRetryService_ThrowsArgumentNullException() _compressedRawResourceConverter, _queryHashCalculator, _queryPlanReuseChecker, + searchParameterSqlParser, NullLogger.Instance); }); @@ -221,13 +194,8 @@ public void Constructor_WithNullSqlRetryService_ThrowsArgumentNullException() public void Constructor_WithNullSchemaInformation_ThrowsArgumentNullException() { // Arrange - var queryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(new SearchParameterToSearchValueTypeMap()); - var sqlRootExpressionRewriter = new SqlRootExpressionRewriter(queryGeneratorFactory); - var chainFlatteningRewriter = new ChainFlatteningRewriter(queryGeneratorFactory); - var sortRewriter = new SortRewriter(queryGeneratorFactory); var model = Substitute.For(); var schemaInfo = new SchemaInformation(SchemaVersionConstants.Min, SchemaVersionConstants.Max); - var partitionEliminationRewriter = new PartitionEliminationRewriter(model, schemaInfo, () => Substitute.For()); var compartmentDefinitionManager = Substitute.For(); var searchParameterDefinitionManager = Substitute.For(); var compartmentSearchRewriter = new SqlCompartmentSearchRewriter( @@ -237,6 +205,7 @@ public void Constructor_WithNullSchemaInformation_ThrowsArgumentNullException() compartmentSearchRewriter, new Lazy(() => searchParameterDefinitionManager), Options.Create(new CoreFeatureConfiguration())); + var searchParameterSqlParser = Substitute.For(); // Act & Assert var ex = Assert.Throws(() => @@ -245,13 +214,8 @@ public void Constructor_WithNullSchemaInformation_ThrowsArgumentNullException() _searchOptionsFactory, _fhirDataStore, model, - sqlRootExpressionRewriter, - chainFlatteningRewriter, - sortRewriter, - partitionEliminationRewriter, compartmentSearchRewriter, smartCompartmentSearchRewriter, - queryGeneratorFactory, _sqlRetryService, Options.Create(new SqlServerDataStoreConfiguration()), new FhirSqlServerConfiguration(), @@ -260,6 +224,7 @@ public void Constructor_WithNullSchemaInformation_ThrowsArgumentNullException() _compressedRawResourceConverter, _queryHashCalculator, _queryPlanReuseChecker, + searchParameterSqlParser, NullLogger.Instance); }); @@ -356,6 +321,8 @@ public void GetKeyColumns_ForUnknownOrNullTable_ReturnsEmptySet(string tableName Assert.Empty(columns); } + /* + [Fact] public void CollectNotExistsLeaves_WithResourceSurrogateId_DetectsSurrogateIdAndMissingParam() { @@ -515,6 +482,8 @@ public void DateEqualityPipeline_AcrossFlagMatrix_AppliesExpectedSemanticsAndNev Assert.False(ContainsPredicate(result, e => e is UnionExpression), "No date-equality flag combination may emit a temporal UNION."); } + */ + private static SearchParameterExpression BuildExactDayBirthdateEquality() { var dateParam = new SearchParameterInfo( diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Microsoft.Health.Fhir.SqlServer.UnitTests.csproj b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Microsoft.Health.Fhir.SqlServer.UnitTests.csproj index ed90df8d67..83893d74d3 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Microsoft.Health.Fhir.SqlServer.UnitTests.csproj +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Microsoft.Health.Fhir.SqlServer.UnitTests.csproj @@ -1,5 +1,6 @@  + @@ -12,4 +13,7 @@ + + + diff --git a/src/Microsoft.Health.Fhir.SqlServer/AssemblyInfo.cs b/src/Microsoft.Health.Fhir.SqlServer/AssemblyInfo.cs index 7122f10988..4a27b76d48 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/AssemblyInfo.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/AssemblyInfo.cs @@ -18,4 +18,5 @@ [assembly: InternalsVisibleTo("Microsoft.Health.Internal.Fhir.EventsReader")] [assembly: InternalsVisibleTo("Microsoft.Health.Internal.Fhir.PerfTester")] [assembly: InternalsVisibleTo("Microsoft.Health.Internal.Fhir.Exporter")] +[assembly: InternalsVisibleTo("SqlSearchDebugger")] [assembly: NeutralResourcesLanguage("en-us")] diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpression.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpression.cs deleted file mode 100644 index 9bd12a546f..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpression.cs +++ /dev/null @@ -1,85 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions -{ - /// - /// An expression over a search param or compartment table. - /// - internal class SearchParamTableExpression : Expression - { - /// - /// Creates a new instance of the class. - /// - /// The search parameter query generator - /// The search expression over a columns belonging exclusively to a search parameter table. - /// Applies to the chain target if a chained expression. - /// The table expression kind. - /// The nesting chain nesting level of the current expression. 0 if not a chain expression. - public SearchParamTableExpression( - SearchParamTableExpressionQueryGenerator queryGenerator, - Expression predicate, - SearchParamTableExpressionKind kind, - int chainLevel = 0) - { - QueryGenerator = queryGenerator; - Predicate = predicate; - Kind = kind; - ChainLevel = chainLevel; - } - - public SearchParamTableExpressionKind Kind { get; } - - /// - /// The nesting chain nesting level of the current expression. 0 if not a chain expression. - /// - public int ChainLevel { get; } - - public SearchParamTableExpressionQueryGenerator QueryGenerator { get; } - - /// - /// The search expression over columns of the corresponding search parameter table. - /// - public Expression Predicate { get; } - - public override TOutput AcceptVisitor(IExpressionVisitor visitor, TContext context) - { - return AcceptVisitor((ISqlExpressionVisitor)visitor, context); - } - - public TOutput AcceptVisitor(ISqlExpressionVisitor visitor, TContext context) - { - return visitor.VisitTable(this, context); - } - - public override string ToString() - { - return $"(Table {Kind} {(ChainLevel == 0 ? null : $"ChainLevel:{ChainLevel} ")}{QueryGenerator?.Table} Predicate:{Predicate})"; - } - - public override void AddValueInsensitiveHashCode(ref HashCode hashCode) - { - hashCode.Add(typeof(SearchParamTableExpression)); - hashCode.Add(Kind); - hashCode.Add(ChainLevel); - hashCode.Add(QueryGenerator); - Predicate?.AddValueInsensitiveHashCode(ref hashCode); - } - - public override bool ValueInsensitiveEquals(Expression other) - { - return other is SearchParamTableExpression tableExpression && - tableExpression.Kind == Kind && - tableExpression.ChainLevel == ChainLevel && - tableExpression.QueryGenerator.Equals(QueryGenerator) && - (tableExpression.Predicate?.ValueInsensitiveEquals(Predicate) ?? Predicate == null); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs deleted file mode 100644 index a735c01488..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs +++ /dev/null @@ -1,152 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions -{ - /// - /// extensions. - /// - internal static class SearchParamTableExpressionExtensions - { - /// - /// Identifies if a contains a . - /// - /// Instance of under evaluation. - public static bool HasUnionAllExpression(this SearchParamTableExpression expression) - { - IExpressionsContainer expressionContainer = expression.Predicate as IExpressionsContainer; - return expressionContainer?.Expressions.Any(e => e is UnionExpression) ?? false; - } - - /// - /// Split the inner expressions from a into two groups: an existing and the other expressions. - /// - /// Instance of under evaluation. - /// Instance of . - /// Other exception different than . - /// Returns TRUE if the contains a . - public static bool SplitExpressions(this SearchParamTableExpression expression, out UnionExpression unionExpression, out SearchParamTableExpression allOtherRemainingExpressions) - { - unionExpression = null; - allOtherRemainingExpressions = null; - - IExpressionsContainer expressionContainer = expression.Predicate as IExpressionsContainer; - - if (expressionContainer != null) - { - UnionExpression tempUnionAllExpression = expressionContainer.Expressions.SingleOrDefault(e => e is UnionExpression) as UnionExpression; - - if (tempUnionAllExpression != null) - { - IReadOnlyList allOtherExpression = expressionContainer.Expressions.Where(e => e != tempUnionAllExpression).ToList(); - - if (allOtherExpression.Any()) - { - allOtherRemainingExpressions = new SearchParamTableExpression( - expression.QueryGenerator, - new MultiaryExpression(MultiaryOperator.And, allOtherExpression), - SearchParamTableExpressionKind.Normal, - chainLevel: expression.ChainLevel + 1); - } - - unionExpression = tempUnionAllExpression; - - return true; - } - } - - return false; - } - - /// - /// Identifies if a contains a with SmartV2 flag. - /// - /// Instance of under evaluation. - public static bool HasSmartV2UnionExpression(this SearchParamTableExpression expression) - { - return ContainsSmartV2UnionFlag(expression.Predicate); - } - - /// - /// Sort expression by query composition logic. always is the first expression to be processed - /// with SmartV2 union expressions appearing at the end of all union expressions, followed by other expressions. - /// - /// Instance of under evaluation. - public static IReadOnlyList SortExpressionsByQueryLogic(this IReadOnlyList expressions) - { - var regularUnions = new List(); - var smartV2Unions = new List(); - var nonUnions = new List(); - - foreach (SearchParamTableExpression tableExpression in expressions) - { - if (tableExpression.HasUnionAllExpression()) - { - if (tableExpression.HasSmartV2UnionExpression()) - { - smartV2Unions.Add(tableExpression); - } - else - { - regularUnions.Add(tableExpression); - } - } - else - { - nonUnions.Add(tableExpression); - } - } - - // Combine in the desired order: regular unions first, then SmartV2 unions, then non-unions - var result = new List(capacity: expressions.Count); - result.AddRange(regularUnions); - result.AddRange(smartV2Unions); - result.AddRange(nonUnions); - - return result; - } - - /// - /// Get Count of all Union All Expressions. - /// - /// Instance of under evaluation. - public static int GetCountOfUnionAllExpressions(this IReadOnlyList expressions) - { - return expressions.Count(tableExpression => tableExpression.HasUnionAllExpression()); - } - - /// - /// Recursively checks whether the given expression or any of its descendant expressions - /// has the flag set to true. - /// - /// The root expression to search. - /// True if any expression in the tree has the flag; otherwise, false. - private static bool ContainsSmartV2UnionFlag(Expression expression) - { - if (expression == null) - { - return false; - } - - // If this expression has the flag, return true. - if (expression.IsSmartV2UnionExpressionForScopesSearchParameters) - { - return true; - } - - // Check if expression can contain child expressions. - if (expression is IExpressionsContainer container) - { - return container.Expressions.Any(ContainsSmartV2UnionFlag); - } - - return false; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionKind.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionKind.cs deleted file mode 100644 index 1f0f96aac5..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionKind.cs +++ /dev/null @@ -1,78 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions -{ - /// - /// The different kinds of s. - /// - internal enum SearchParamTableExpressionKind - { - /// - /// Represents a table expression that applies a filter, producing a set of candidate resource IDs. - /// This set is intersected with its preceding table expression, if any. - /// - Normal, - - /// - /// Represents a table expression that applies a filter, producing a set of candidate resource IDs. - /// This set is appended to the set produced by its preceding table expression. - /// - Concatenation, - - /// - /// Represents a table expression that excludes items, produced by applying a filter, from its - /// preceding table expression. - /// - NotExists, - - /// - /// Represents a table expression that yields all possible resource IDs. - /// - All, - - /// - /// Represents a table expression that applies a TOP operator over its predecessor. - /// - Top, - - /// - /// Represents a table expression that serves as the JOIN between a resource and target reference. - /// in a chained search. - /// - Chain, - - /// - /// Represents a table expression that is used to include multiple resource types in the query. - /// - Include, - - /// - /// Represents a table expression that is used to UNION results from multiple queries together. - /// - Union, - - /// - /// Represents a table expression that is used to union all of the includes with the base search query. - /// - IncludeUnionAll, - - /// - /// Represents a table expression that is used to sort result of the base search query. - /// - Sort, - - /// - /// Represents a table expression that is used to limit the number of included items. - /// - IncludeLimit, - - /// - /// Represents a table expression that is used to sort the result of the base query where - /// the sort parameter is also present as a query parameter in the base search query. - /// - SortWithFilter, - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlChainLinkExpression.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlChainLinkExpression.cs deleted file mode 100644 index 21a5c19ca1..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlChainLinkExpression.cs +++ /dev/null @@ -1,132 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions -{ - /// - /// An expression type used to represent a non-leaf chaining expression. Used by the SQL layer, created from . - /// - internal class SqlChainLinkExpression : Expression - { - public SqlChainLinkExpression( - string[] resourceTypes, - SearchParameterInfo referenceSearchParameter, - string[] targetResourceTypes, - bool reversed, - Expression expressionOnSource = null, - Expression expressionOnTarget = null) - { - EnsureArg.IsNotNull(resourceTypes, nameof(resourceTypes)); - EnsureArg.IsNotNull(referenceSearchParameter, nameof(referenceSearchParameter)); - EnsureArg.IsNotNull(targetResourceTypes, nameof(targetResourceTypes)); - - ResourceTypes = resourceTypes; - ReferenceSearchParameter = referenceSearchParameter; - TargetResourceTypes = targetResourceTypes; - Reversed = reversed; - ExpressionOnSource = expressionOnSource; - ExpressionOnTarget = expressionOnTarget; - } - - /// - /// Gets the resource types which are being searched. - /// - public string[] ResourceTypes { get; } - - /// - /// Gets the parameter name. - /// - public SearchParameterInfo ReferenceSearchParameter { get; } - - /// - /// Gets the target resource types. - /// - public string[] TargetResourceTypes { get; } - - /// - /// Get if the expression is reversed. - /// - public bool Reversed { get; } - - /// - /// The expression on the chain target. For example, for Observation?subject:Patient._lastUpdated=2020, this would be _lastUpdated=2020 - /// - public Expression ExpressionOnTarget { get; } - - /// - /// The expression on the chain source. For example, for Observation?subject:Patient._lastUpdated=2020, this would be type=Observation - /// - public Expression ExpressionOnSource { get; } - - public override TOutput AcceptVisitor(IExpressionVisitor visitor, TContext context) - { - return ((ISqlExpressionVisitor)visitor).VisitSqlChainLink(this, context); - } - - public override string ToString() - { - return $"({(Reversed ? "Reverse " : string.Empty)}SqlChainLink {ReferenceSearchParameter.Code}:{string.Join(", ", TargetResourceTypes)} {(ExpressionOnSource == null ? string.Empty : $" Source:{ExpressionOnSource}")}{(ExpressionOnTarget == null ? string.Empty : $" Target:{ExpressionOnTarget}")})"; - } - - public override void AddValueInsensitiveHashCode(ref HashCode hashCode) - { - hashCode.Add(typeof(SqlChainLinkExpression)); - foreach (string resourceType in ResourceTypes) - { - hashCode.Add(resourceType); - } - - foreach (string targetResourceType in TargetResourceTypes) - { - hashCode.Add(targetResourceType); - } - - hashCode.Add(ReferenceSearchParameter); - - hashCode.Add(Reversed); - - ExpressionOnSource?.AddValueInsensitiveHashCode(ref hashCode); - ExpressionOnTarget?.AddValueInsensitiveHashCode(ref hashCode); - } - - public override bool ValueInsensitiveEquals(Expression other) - { - if (other is not SqlChainLinkExpression chainLink || - chainLink.ResourceTypes.Length != ResourceTypes.Length || - chainLink.TargetResourceTypes.Length != TargetResourceTypes.Length || - !chainLink.ReferenceSearchParameter.Equals(ReferenceSearchParameter) || - chainLink.Reversed != Reversed || - !(chainLink.ExpressionOnSource?.ValueInsensitiveEquals(ExpressionOnSource) ?? ExpressionOnSource == null) || - !(chainLink.ExpressionOnTarget?.ValueInsensitiveEquals(ExpressionOnTarget) ?? ExpressionOnTarget == null)) - { - return false; - } - - for (var i = 0; i < ResourceTypes.Length; i++) - { - if (chainLink.ResourceTypes[i] != ResourceTypes[i]) - { - return false; - } - } - - for (var i = 0; i < TargetResourceTypes.Length; i++) - { - if (chainLink.TargetResourceTypes[i] != TargetResourceTypes[i]) - { - return false; - } - } - - return true; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlFieldName.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlFieldName.cs deleted file mode 100644 index c493f2747a..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlFieldName.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions -{ - public static class SqlFieldName - { - public const FieldName ResourceSurrogateId = (FieldName)100; - public const FieldName LastUpdated = (FieldName)101; - public const FieldName TextOverflow = (FieldName)102; - public const FieldName NumberLow = (FieldName)103; - public const FieldName NumberHigh = (FieldName)104; - public const FieldName QuantityLow = (FieldName)105; - public const FieldName QuantityHigh = (FieldName)106; - public const FieldName DateTimeIsLongerThanADay = (FieldName)107; - public const FieldName PrimaryKey = (FieldName)108; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlRootExpression.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlRootExpression.cs deleted file mode 100644 index dda39bb66f..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SqlRootExpression.cs +++ /dev/null @@ -1,133 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions -{ - /// - /// The root of a search expression tree that will be translated to a SQL batch. - /// It is organized as a set of expressions that are over search parameter tables () and a set - /// of expressions over the columns on the Resource table (). - /// - internal class SqlRootExpression : Expression - { - public SqlRootExpression( - IReadOnlyList searchParamTableExpressions, - IReadOnlyList resourceTableExpressions, - SmartCompartmentMembershipContext smartCompartmentMembership = null) - { - EnsureArg.IsNotNull(searchParamTableExpressions, nameof(searchParamTableExpressions)); - EnsureArg.IsNotNull(resourceTableExpressions, nameof(resourceTableExpressions)); - - SearchParamTableExpressions = searchParamTableExpressions; - ResourceTableExpressions = resourceTableExpressions; - SmartCompartmentMembership = smartCompartmentMembership; - } - - /// - /// Expressions applied to various search parameter tables (e.g. TokenSearchParam, NumberSearchParam, etc.) or the CompartmentAssignment table. - /// - public IReadOnlyList SearchParamTableExpressions { get; } - - /// - /// Expressions applied to directly to the Resource table. - /// - public IReadOnlyList ResourceTableExpressions { get; } - - /// - /// Gets candidate-driven SMART compartment membership rules for include authorization. - /// - public SmartCompartmentMembershipContext SmartCompartmentMembership { get; } - - public SqlRootExpression WithSmartCompartmentMembership(SmartCompartmentMembershipContext membership) - { - return new SqlRootExpression(SearchParamTableExpressions, ResourceTableExpressions, membership); - } - - public static SqlRootExpression WithSearchParamTableExpressions(params SearchParamTableExpression[] expressions) - { - return new SqlRootExpression(expressions, Array.Empty()); - } - - public static SqlRootExpression WithResourceTableExpressions(params SearchParameterExpressionBase[] expressions) - { - return new SqlRootExpression(Array.Empty(), expressions); - } - - public static SqlRootExpression WithSearchParamTableExpressions(IReadOnlyList expressions) - { - return new SqlRootExpression(expressions, Array.Empty()); - } - - public static SqlRootExpression WithResourceTableExpressions(IReadOnlyList expressions) - { - return new SqlRootExpression(Array.Empty(), expressions); - } - - public override TOutput AcceptVisitor(IExpressionVisitor visitor, TContext context) - { - return AcceptVisitor((ISqlExpressionVisitor)visitor, context); - } - - public TOutput AcceptVisitor(ISqlExpressionVisitor visitor, TContext context) - { - EnsureArg.IsNotNull(visitor, nameof(visitor)); - return visitor.VisitSqlRoot(this, context); - } - - public override string ToString() - { - return $"(SqlRoot (SearchParamTables:{(SearchParamTableExpressions.Any() ? " " + string.Join(" ", SearchParamTableExpressions) : null)}) (ResourceTable:{(ResourceTableExpressions.Any() ? " " + string.Join(" ", ResourceTableExpressions) : null)}))"; - } - - public override void AddValueInsensitiveHashCode(ref HashCode hashCode) - { - hashCode.Add(typeof(SqlRootExpression)); - foreach (SearchParamTableExpression searchParamTableExpression in SearchParamTableExpressions) - { - hashCode.Add(searchParamTableExpression); - } - - foreach (SearchParameterExpressionBase resourceTableExpression in ResourceTableExpressions) - { - hashCode.Add(resourceTableExpression); - } - } - - public override bool ValueInsensitiveEquals(Expression other) - { - if (other is not SqlRootExpression sqlRoot || - sqlRoot.ResourceTableExpressions.Count != ResourceTableExpressions.Count || - sqlRoot.SearchParamTableExpressions.Count != SearchParamTableExpressions.Count) - { - return false; - } - - for (var i = 0; i < ResourceTableExpressions.Count; i++) - { - if (!sqlRoot.ResourceTableExpressions[i].ValueInsensitiveEquals(ResourceTableExpressions[i])) - { - return false; - } - } - - for (var i = 0; i < SearchParamTableExpressions.Count; i++) - { - if (!sqlRoot.SearchParamTableExpressions[i].ValueInsensitiveEquals(SearchParamTableExpressions[i])) - { - return false; - } - } - - return true; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ChainFlatteningRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ChainFlatteningRewriter.cs deleted file mode 100644 index 52f0ba2342..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ChainFlatteningRewriter.cs +++ /dev/null @@ -1,133 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Flattens chained expressions into 's list. - /// The expression within a chained expression is promoted to a top-level table expression, but we keep track of the height - /// via the . - /// - internal class ChainFlatteningRewriter : SqlExpressionRewriterWithInitialContext - { - private readonly SearchParamTableExpressionQueryGeneratorFactory _searchParamTableExpressionQueryGeneratorFactory; - - public ChainFlatteningRewriter(SearchParamTableExpressionQueryGeneratorFactory searchParamTableExpressionQueryGeneratorFactory) - { - EnsureArg.IsNotNull(searchParamTableExpressionQueryGeneratorFactory, nameof(searchParamTableExpressionQueryGeneratorFactory)); - _searchParamTableExpressionQueryGeneratorFactory = searchParamTableExpressionQueryGeneratorFactory; - } - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - List newTableExpressions = null; - for (var i = 0; i < expression.SearchParamTableExpressions.Count; i++) - { - SearchParamTableExpression searchParamTableExpression = expression.SearchParamTableExpressions[i]; - if (searchParamTableExpression.Kind != SearchParamTableExpressionKind.Chain) - { - newTableExpressions?.Add(searchParamTableExpression); - continue; - } - - EnsureAllocatedAndPopulated(ref newTableExpressions, expression.SearchParamTableExpressions, i); - - ProcessChainedExpression((ChainedExpression)searchParamTableExpression.Predicate, newTableExpressions, 1); - } - - if (newTableExpressions == null) - { - return expression; - } - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - /// - /// Chained expression can be created either by search service or member-match service. In first case chain expression would contain only one nested expression - /// because this is how '_has' works. `_has:Observation:patient:code=1234-5` as example. In that case we carry on, visit expression, and created proper table expression. - /// For member-match service we can pass multiple restrain expression to chained expression which we put behind 'And' expression. - /// 'And' visitor unfortunetelly can pick up only one queryGenerator, so it will pick first one in 'And' list and if expression inside 'And' are for different tables, - /// that would lead to incorrect table expressions. So we handle that case separatly and create table generator for each expression in `And` expression. - /// - private void ProcessChainedExpression(ChainedExpression chainedExpression, List tableExpressions, int chainLevel) - { - if (chainedExpression.Expression is MultiaryExpression multiaryExpression && multiaryExpression.MultiaryOperation == MultiaryOperator.And) - { - HandleAndExpression(chainedExpression, tableExpressions, chainLevel, multiaryExpression); - } - else - { - SearchParamTableExpressionQueryGenerator queryGenerator = chainedExpression.Expression.AcceptVisitor(_searchParamTableExpressionQueryGeneratorFactory, null); - - Expression expressionOnTarget = queryGenerator == null ? chainedExpression.Expression : null; - - var sqlChainLinkExpression = new SqlChainLinkExpression( - chainedExpression.ResourceTypes, - chainedExpression.ReferenceSearchParameter, - chainedExpression.TargetResourceTypes, - chainedExpression.Reversed, - expressionOnTarget: expressionOnTarget); - - tableExpressions.Add( - new SearchParamTableExpression( - ChainLinkQueryGenerator.Instance, - sqlChainLinkExpression, - SearchParamTableExpressionKind.Chain, - chainLevel)); - - if (chainedExpression.Expression is ChainedExpression nestedChainedExpression) - { - ProcessChainedExpression(nestedChainedExpression, tableExpressions, chainLevel + 1); - } - else if (queryGenerator != null) - { - tableExpressions.Add( - new SearchParamTableExpression( - queryGenerator, - chainedExpression.Expression, - SearchParamTableExpressionKind.Normal, - chainLevel)); - } - } - } - - /// - /// And expression inside chained expression wouldn't be handled properly because we can have different types of paramaters to filter on. - /// But acceptVisitor method returns only one table generator (first we encounter). - /// This method takes table generator for each subexpression and create table expression for it. - /// - private void HandleAndExpression(ChainedExpression chainedExpression, List tableExpressions, int chainLevel, MultiaryExpression multiaryExpression) - { - var chainLinkExpression = new SqlChainLinkExpression( - chainedExpression.ResourceTypes, - chainedExpression.ReferenceSearchParameter, - chainedExpression.TargetResourceTypes, - chainedExpression.Reversed); - - tableExpressions.Add(new SearchParamTableExpression( - ChainLinkQueryGenerator.Instance, - chainLinkExpression, - SearchParamTableExpressionKind.Chain, - chainLevel)); - - foreach (var expression in multiaryExpression.Expressions) - { - var handler = expression.AcceptVisitor(_searchParamTableExpressionQueryGeneratorFactory, null); - tableExpressions.Add( - new SearchParamTableExpression( - handler, - expression, - SearchParamTableExpressionKind.Normal, - chainLevel)); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ConcatenationRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ConcatenationRewriter.cs deleted file mode 100644 index 3b34dc78d7..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ConcatenationRewriter.cs +++ /dev/null @@ -1,108 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Diagnostics; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// An abstract rewriter to rewrite a table expression into a concatenation of two table expressions. - /// - internal abstract class ConcatenationRewriter : SqlExpressionRewriterWithInitialContext - { - private readonly IExpressionVisitor _booleanScout; - private readonly ExpressionRewriter _rewritingScout; - - protected ConcatenationRewriter(IExpressionVisitor scout) - { - EnsureArg.IsNotNull(scout, nameof(scout)); - _booleanScout = scout; - } - - protected ConcatenationRewriter(ExpressionRewriter scout) - { - EnsureArg.IsNotNull(scout, nameof(scout)); - _rewritingScout = scout; - } - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count == 0) - { - return expression; - } - - List newTableExpressions = null; - for (var i = 0; i < expression.SearchParamTableExpressions.Count; i++) - { - SearchParamTableExpression searchParamTableExpression = expression.SearchParamTableExpressions[i]; - bool found = false; - - switch (searchParamTableExpression.Kind) - { - case SearchParamTableExpressionKind.Chain: - case SearchParamTableExpressionKind.Include: - case SearchParamTableExpressionKind.Sort: - case SearchParamTableExpressionKind.All: - // The expressions contained within a ChainExpression, IncludeExpression, SortExpression, AllExpression - // have been promoted to SearchParamTableExpressions in this list and are not considered. - break; - - default: - if (_rewritingScout != null) - { - var newPredicate = searchParamTableExpression.Predicate.AcceptVisitor(_rewritingScout, null); - if (!ReferenceEquals(newPredicate, searchParamTableExpression.Predicate)) - { - found = true; - searchParamTableExpression = new SearchParamTableExpression(searchParamTableExpression.QueryGenerator, newPredicate, searchParamTableExpression.Kind, searchParamTableExpression.ChainLevel); - } - } - else - { - found = searchParamTableExpression.Predicate.AcceptVisitor(_booleanScout, null); - } - - break; - } - - if (found) - { - EnsureAllocatedAndPopulated(ref newTableExpressions, expression.SearchParamTableExpressions, i); - - newTableExpressions.Add(searchParamTableExpression); - newTableExpressions.Add((SearchParamTableExpression)searchParamTableExpression.AcceptVisitor(this, context)); - } - else - { - newTableExpressions?.Add(searchParamTableExpression); - } - } - - if (newTableExpressions == null) - { - return expression; - } - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - public override Expression VisitTable(SearchParamTableExpression searchParamTableExpression, object context) - { - var predicate = searchParamTableExpression.Predicate.AcceptVisitor(this, context); - - Debug.Assert(predicate != searchParamTableExpression.Predicate, "expecting table expression to have been rewritten for concatenation"); - - return new SearchParamTableExpression( - searchParamTableExpression.QueryGenerator, - predicate, - SearchParamTableExpressionKind.Concatenation, - searchParamTableExpression.ChainLevel); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs deleted file mode 100644 index c40cdb1cc2..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeBoundedRangeRewriter.cs +++ /dev/null @@ -1,96 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.ValueSets; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Looks for the pattern (And (FieldGreaterThan DateTimeEnd X) (FieldLessThan DateTimeStart Y)) - /// This is produced when the search expression ?date=gtX&date=ltY after the - /// visitor has run. The problem with this expression is that it can be very expensive because of the unbound ranges - /// that have no relation to one another. One way we can optimize this is to notice that the vast majority of datetime ranges in - /// the database will be less than a day. This means that for most of the data, we can apply a fixed range on DateTimeStart, knowing - /// that it will greater than (X - 1 day) in addition to always being less than Y. We can create a filtered nonclustered - /// index where the range is greater than one day. Over this index, we'll do the original query, but it will be on a much smaller - /// set of row. - /// - internal class DateTimeBoundedRangeRewriter : ConcatenationRewriter - { - internal static readonly DateTimeBoundedRangeRewriter Instance = new DateTimeBoundedRangeRewriter(); - - public DateTimeBoundedRangeRewriter() - : base(new Scout()) - { - } - - public override Expression VisitMultiary(MultiaryExpression expression, object context) - { - if (expression.MultiaryOperation == MultiaryOperator.And && - expression.Expressions.Count == 3 && - expression.Expressions[0] is BinaryExpression isLong && - isLong.FieldName == SqlFieldName.DateTimeIsLongerThanADay) - { - var left = (BinaryExpression)expression.Expressions[1]; - var right = (BinaryExpression)expression.Expressions[2]; - - return Expression.And( - Expression.Equals(SqlFieldName.DateTimeIsLongerThanADay, left.ComponentIndex, false), - new BinaryExpression(left.BinaryOperator, FieldName.DateTimeEnd, left.ComponentIndex, left.Value), - new BinaryExpression(left.BinaryOperator, FieldName.DateTimeStart, left.ComponentIndex, ((DateTimeOffset)left.Value).AddTicks(-TimeSpan.TicksPerDay)), - new BinaryExpression(right.BinaryOperator, FieldName.DateTimeStart, right.ComponentIndex, right.Value)); - } - - return expression; - } - - private class Scout : SqlExpressionRewriterWithInitialContext - { - public override Expression VisitSearchParameter(SearchParameterExpression expression, object context) - { - // for now, we don't apply this optimization to composite parameters - - if (expression.Parameter.Type == SearchParamType.Date && - expression.Expression is MultiaryExpression) - { - return base.VisitSearchParameter(expression, context); - } - - return expression; - } - - public override Expression VisitMultiary(MultiaryExpression expression, object context) - { - if (expression.MultiaryOperation == MultiaryOperator.And && - expression.Expressions.Count == 2 && - expression.Expressions[0] is BinaryExpression left && - expression.Expressions[1] is BinaryExpression right) - { - if (left.BinaryOperator > right.BinaryOperator) - { - var tmp = left; - left = right; - right = tmp; - } - - if (left.FieldName == FieldName.DateTimeEnd && - right.FieldName == FieldName.DateTimeStart && - (left.BinaryOperator == BinaryOperator.GreaterThanOrEqual || left.BinaryOperator == BinaryOperator.GreaterThan) && - (right.BinaryOperator == BinaryOperator.LessThanOrEqual || right.BinaryOperator == BinaryOperator.LessThan)) - { - return Expression.And( - Expression.Equals(SqlFieldName.DateTimeIsLongerThanADay, left.ComponentIndex, true), - new BinaryExpression(left.BinaryOperator, left.FieldName, left.ComponentIndex, left.Value), - new BinaryExpression(right.BinaryOperator, right.FieldName, right.ComponentIndex, right.Value)); - } - } - - return expression; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeTableExpressionCombiner.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeTableExpressionCombiner.cs deleted file mode 100644 index c67525c137..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DateTimeTableExpressionCombiner.cs +++ /dev/null @@ -1,92 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Combines s that are over the same DateTime search parameter. - /// For example, without this class, ?issued=ge2024-04-22T00:00:00&issued=lt2024-04-23T00:00:00 will end up as separate table expressions, - /// but the query will be much more efficient if they are combined. - /// - internal class DateTimeTableExpressionCombiner : SqlExpressionRewriterWithInitialContext - { - internal static readonly DateTimeTableExpressionCombiner Instance = new DateTimeTableExpressionCombiner(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - // This rewriter is a little heavier, so we bail out early if we can. - if (expression.SearchParamTableExpressions.Count <= 1) - { - return expression; - } - - // now we look for pairs of expressions that are over the same search parameter - // that are also DateTime expressions - var groupedSearchParams = expression.SearchParamTableExpressions.GroupBy( - p => (p.Predicate as SearchParameterExpression)?.Parameter, - p => p); - - var newSearchParamExpressions = new List(expression.SearchParamTableExpressions); - - foreach (var group in groupedSearchParams) - { - // This is a targeted change, so we are carefully limiting the - // expressions that we will apply this change to. - // For now, only DateTime expressions are supported, where - // there are exactly 2 expressions over the same search parameter. - if ((group.Key?.Type != ValueSets.SearchParamType.Date) || (group.Count() != 2)) - { - continue; - } - - // one of the expression.Predicate must be GreaterThanOrEqual, the other LessThanOrEqual - if (group.Any(p => - { - var searchParameterExpression = p.Predicate as SearchParameterExpression; - var binaryExpression = searchParameterExpression?.Expression as BinaryExpression; - return (binaryExpression?.BinaryOperator == BinaryOperator.GreaterThanOrEqual || binaryExpression?.BinaryOperator == BinaryOperator.GreaterThan) - && binaryExpression?.FieldName == FieldName.DateTimeEnd; - }) && group.Any(p => - { - var searchParameterExpression = p.Predicate as SearchParameterExpression; - var binaryExpression = searchParameterExpression?.Expression as BinaryExpression; - return (binaryExpression?.BinaryOperator == BinaryOperator.LessThanOrEqual || binaryExpression?.BinaryOperator == BinaryOperator.LessThan) - && binaryExpression?.FieldName == FieldName.DateTimeStart; - })) - { - // Now we want to create a new multiary expression that combines the two DateTime expressions - var multiaryExpression = new MultiaryExpression( - MultiaryOperator.And, - group.Select(p => (p.Predicate as SearchParameterExpression).Expression).ToArray()); - - var combineSearchParamExpression = new SearchParameterExpression( - group.Key, - multiaryExpression); - - var combinedExpression = new SearchParamTableExpression( - group.First().QueryGenerator, - combineSearchParamExpression, - SearchParamTableExpressionKind.Normal); - - // now remove the original expressions in this group from expression.SearchParamTableExpressions - // and add the new combined expression - foreach (var searchParamExpression in group) - { - newSearchParamExpressions.Remove(searchParamExpression); - } - - newSearchParamExpressions.Add(combinedExpression); - } - } - - return new SqlRootExpression(newSearchParamExpressions, expression.ResourceTableExpressions); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DefaultSqlExpressionVisitor.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DefaultSqlExpressionVisitor.cs deleted file mode 100644 index f8d0b87341..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/DefaultSqlExpressionVisitor.cs +++ /dev/null @@ -1,28 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - internal abstract class DefaultSqlExpressionVisitor : DefaultExpressionVisitor, ISqlExpressionVisitor - { - protected DefaultSqlExpressionVisitor() - { - } - - protected DefaultSqlExpressionVisitor(Func outputAggregator) - : base(outputAggregator) - { - } - - public virtual TOutput VisitSqlRoot(SqlRootExpression expression, TContext context) => default; - - public virtual TOutput VisitTable(SearchParamTableExpression searchParamTableExpression, TContext context) => default; - - public virtual TOutput VisitSqlChainLink(SqlChainLinkExpression sqlChainLinkExpression, TContext context) => default; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/FlatteningRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/FlatteningRewriter.cs deleted file mode 100644 index 16ef3816a9..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/FlatteningRewriter.cs +++ /dev/null @@ -1,64 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Flattens multiary expressions when possible. - /// (And (And a b) (And c d)) -> (And a b c d) - /// (And a) -> a - /// - internal class FlatteningRewriter : ExpressionRewriterWithInitialContext - { - public static readonly FlatteningRewriter Instance = new FlatteningRewriter(); - - private FlatteningRewriter() - { - } - - public override Expression VisitMultiary(MultiaryExpression expression, object context) - { - // Flattens multiary expressions: (And (And a b) (And c d)) -> (And a b c d) - // Without checking if a and b are of different type of search parameters like token and string. - // It works fine for regular requests but in case of smart v2 scopes requests with search parameters, we have a special union expression where we want to avoid flattening - if (expression.IsSmartV2UnionExpressionForScopesSearchParameters) - { - return expression; - } - - expression = (MultiaryExpression)base.VisitMultiary(expression, context); - if (expression.Expressions.Count == 1) - { - return expression.Expressions[0]; - } - - List newExpressions = null; - - for (var i = 0; i < expression.Expressions.Count; i++) - { - Expression childExpression = expression.Expressions[i]; - if (childExpression is MultiaryExpression childMultiary && childMultiary.MultiaryOperation == expression.MultiaryOperation) - { - EnsureAllocatedAndPopulated(ref newExpressions, expression.Expressions, i); - newExpressions.AddRange(childMultiary.Expressions); - } - else - { - newExpressions?.Add(childExpression); - } - } - - if (newExpressions == null) - { - return expression; - } - - return new MultiaryExpression(expression.MultiaryOperation, newExpressions); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ISqlExpressionVisitor.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ISqlExpressionVisitor.cs deleted file mode 100644 index c791ae710c..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ISqlExpressionVisitor.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - internal interface ISqlExpressionVisitor : IExpressionVisitor - { - TOutput VisitSqlRoot(SqlRootExpression expression, TContext context); - - TOutput VisitTable(SearchParamTableExpression searchParamTableExpression, TContext context); - - TOutput VisitSqlChainLink(SqlChainLinkExpression sqlChainLinkExpression, TContext context); - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludeMatchSeedRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludeMatchSeedRewriter.cs deleted file mode 100644 index 9f5709c7c7..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludeMatchSeedRewriter.cs +++ /dev/null @@ -1,46 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Rewriter used to add an All SearchParamTableExpression to serve as a seed for match results when the SearchParamTableExpressions in a SqlRootExpression - /// consists solely of Include expressions. - /// Suppose you have an include query like Observation?code=abc&_include=Observation:subject. The include will be based on the CTE that gets the IDs of the first p observations where the code is abc. - /// Now suppose you have simply Observation?_include=Observation:subject. Here there is no CTE to base the include on, so we inject one that yields the first p Observations. - /// - internal class IncludeMatchSeedRewriter : SqlExpressionRewriterWithInitialContext - { - public static readonly IncludeMatchSeedRewriter Instance = new IncludeMatchSeedRewriter(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count == 0) - { - return expression; - } - - if (!expression.SearchParamTableExpressions.All(te => te.Kind == SearchParamTableExpressionKind.Include)) - { - return expression; - } - - var newTableExpressions = new List(expression.SearchParamTableExpressions.Count + 1); - - Expression resourceExpression = Expression.And(expression.ResourceTableExpressions); - var allExpression = new SearchParamTableExpression(null, resourceExpression, SearchParamTableExpressionKind.All); - - newTableExpressions.Add(allExpression); - newTableExpressions.AddRange(expression.SearchParamTableExpressions); - - return new SqlRootExpression(newTableExpressions, Array.Empty()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludeRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludeRewriter.cs deleted file mode 100644 index 5c5b3004d7..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludeRewriter.cs +++ /dev/null @@ -1,169 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Rewriter used to put the include expressions at the end of the list of table expressions. - /// - internal class IncludeRewriter : SqlExpressionRewriterWithInitialContext - { - internal static readonly IncludeRewriter Instance = new IncludeRewriter(); - - protected static readonly SearchParamTableExpression IncludeUnionAllExpression = new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeUnionAll); - protected static readonly SearchParamTableExpression IncludeLimitExpression = new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.IncludeLimit); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count == 1 || expression.SearchParamTableExpressions.All(e => e.Kind != SearchParamTableExpressionKind.Include)) - { - return expression; - } - - // SearchParamTableExpressions contains at least one Include expression - var nonIncludeExpressions = expression.SearchParamTableExpressions.Where(e => e.Kind != SearchParamTableExpressionKind.Include).ToList(); - var includeExpressions = expression.SearchParamTableExpressions.Where(e => e.Kind == SearchParamTableExpressionKind.Include).ToList(); - - // Sort include expressions if there is an include iterate expression - // Order so that include iterate expression appear after the expressions they select from - IEnumerable sortedIncludeExpressions = includeExpressions; - if (includeExpressions.Any(e => ((IncludeExpression)e.Predicate).Iterate)) - { - IEnumerable nonIncludeIterateExpressions = includeExpressions.Where(e => !((IncludeExpression)e.Predicate).Iterate); - List includeIterateExpressions = includeExpressions.Where(e => ((IncludeExpression)e.Predicate).Iterate).ToList(); - sortedIncludeExpressions = nonIncludeIterateExpressions.Concat(SortIncludeIterateExpressions(includeIterateExpressions)); - } - - // Add sorted include expressions after all other expressions - var reorderedExpressions = nonIncludeExpressions.Concat(sortedIncludeExpressions).ToList(); - - // We are adding an extra CTE after each include cte, so we traverse the ordered - // list from the end and add a limit expression after each include expression - for (var i = reorderedExpressions.Count - 1; i >= 0; i--) - { - switch (reorderedExpressions[i].QueryGenerator) - { - case IncludeQueryGenerator _: - reorderedExpressions.Insert(i + 1, IncludeLimitExpression); - break; - default: - break; - } - } - - reorderedExpressions.Add(IncludeUnionAllExpression); - return new SqlRootExpression(reorderedExpressions, expression.ResourceTableExpressions); - } - - protected static List SortIncludeIterateExpressions(List expressions) - { - // Based on Khan's algorithm. See https://en.wikipedia.org/wiki/Topological_sorting. - // The search queries are acyclic. - if (expressions.Count == 1) - { - return expressions; - } - - var graph = new IncludeIterateExpressionDependencyGraph(expressions); - var sortedExpressions = new List(); - - while (graph.NodesWithoutIncomingEdges.Any()) - { - // Remove a node without incoming edges and add to the sorted list - var v = graph.NodesWithoutIncomingEdges.First(); - sortedExpressions.Add(v); - - graph.RemoveNodeAndAllOutgoingEdges(v); - } - - // If there are edges, then the graph contains a cycle - if (graph.OutgoingEdges.Any()) - { - throw new SearchOperationNotSupportedException(Resources.CyclicIncludeIterateNotSupported); - } - - return sortedExpressions; - } - - // Dependency graph of parameters so that parameter b depends on a means that b includes from a, therefore a should appear before b. - private class IncludeIterateExpressionDependencyGraph - { - // private static readonly IncludeExpressionComparer Comparer = new IncludeExpressionComparer(); - public IncludeIterateExpressionDependencyGraph(IEnumerable includeIterateExpressions) - { - OutgoingEdges = new Dictionary>(); - IncomingEdgesCount = new Dictionary(); - includeIterateExpressions = includeIterateExpressions.ToList(); - - // Add graph nodes (parameters) and edges (dependencies) - foreach (var v in includeIterateExpressions) - { - OutgoingEdges.Add(v, new List()); - IncomingEdgesCount.TryAdd(v, 0); - - foreach (var u in includeIterateExpressions) - { - if (v != u && IsDependencyEdge(v, u)) - { - IncomingEdgesCount.TryAdd(u, 0); - OutgoingEdges[v].Add(u); - IncomingEdgesCount[u]++; - } - } - } - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "This is a public signature.")] - public IDictionary> OutgoingEdges { get; private set; } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "This is a public signature.")] - public IDictionary IncomingEdgesCount { get; private set; } - - public IEnumerable NodesWithoutIncomingEdges - { - get { return IncomingEdgesCount.Where(e => e.Value == 0).Select(e => e.Key); } - } - - // Remove v and all v's edges and update incoming edge count accordingly - public void RemoveNodeAndAllOutgoingEdges(SearchParamTableExpression v) - { - if (OutgoingEdges.ContainsKey(v)) - { - // Remove all edges - IList edges; - if (OutgoingEdges.TryGetValue(v, out edges)) - { - while (edges.Any()) - { - var u = edges[0]; - edges.RemoveAt(0); - IncomingEdgesCount[u]--; - } - } - - // Remove node - OutgoingEdges.Remove(v); - IncomingEdgesCount.Remove(v); - } - } - - // (x, y) is a graph edge if x needs to appear before y in the sorted query. That is, y has dependency on x. - private static bool IsDependencyEdge(SearchParamTableExpression x, SearchParamTableExpression y) - { - // Assumes both expressions are include iterate expressions - var xInclude = (IncludeExpression)x.Predicate; - var yInclude = (IncludeExpression)y.Predicate; - - return xInclude.Produces.Intersect(yInclude.Requires).Any(); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludesOperationRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludesOperationRewriter.cs deleted file mode 100644 index 4845cfee63..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/IncludesOperationRewriter.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - internal class IncludesOperationRewriter : IncludeRewriter - { - internal static new readonly IncludesOperationRewriter Instance = new IncludesOperationRewriter(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression == null - || expression.SearchParamTableExpressions.Count == 1 - || expression.SearchParamTableExpressions.All(e => e.Kind != SearchParamTableExpressionKind.Include)) - { - return expression; - } - - // SearchParamTableExpressions contains at least one Include expression - var nonIncludeExpressions = expression.SearchParamTableExpressions.Where(e => e.Kind != SearchParamTableExpressionKind.Include).ToList(); - var includeExpressions = expression.SearchParamTableExpressions.Where(e => e.Kind == SearchParamTableExpressionKind.Include).ToList(); - - // Sort include expressions if there is an include iterate expression - // Order so that include iterate expression appear after the expressions they select from - IEnumerable sortedIncludeExpressions = includeExpressions; - if (includeExpressions.Any(e => ((IncludeExpression)e.Predicate).Iterate)) - { - IEnumerable nonIncludeIterateExpressions = includeExpressions.Where(e => !((IncludeExpression)e.Predicate).Iterate); - List includeIterateExpressions = includeExpressions.Where(e => ((IncludeExpression)e.Predicate).Iterate).ToList(); - sortedIncludeExpressions = nonIncludeIterateExpressions.Concat(SortIncludeIterateExpressions(includeIterateExpressions)); - } - - // Add sorted include expressions after all other expressions - var reorderedExpressions = nonIncludeExpressions.Concat(sortedIncludeExpressions).Concat(new[] { IncludeUnionAllExpression, IncludeLimitExpression }).ToList(); - return new SqlRootExpression(reorderedExpressions, expression.ResourceTableExpressions); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs deleted file mode 100644 index 551437a1ae..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LastUpdatedToResourceSurrogateIdRewriter.cs +++ /dev/null @@ -1,92 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Core.Extensions; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Turns predicates over _lastUpdated to be over ResourceSurrogateId - /// - internal class LastUpdatedToResourceSurrogateIdRewriter : SqlExpressionRewriterWithInitialContext - { - internal static readonly LastUpdatedToResourceSurrogateIdRewriter Instance = new LastUpdatedToResourceSurrogateIdRewriter(); - - public override Expression VisitMissingSearchParameter(MissingSearchParameterExpression expression, object context) - { - if (expression.Parameter.Code == SearchParameterNames.LastUpdated) - { - return Expression.MissingSearchParameter(SqlSearchParameters.ResourceSurrogateIdParameter, expression.IsMissing); - } - - return expression; - } - - public override Expression VisitSearchParameter(SearchParameterExpression expression, object context) - { - if (expression.Parameter.Code == SearchParameterNames.LastUpdated) - { - return Expression.SearchParameter(SqlSearchParameters.ResourceSurrogateIdParameter, expression.Expression.AcceptVisitor(this, context)); - } - - return expression; - } - - public override Expression VisitBinary(BinaryExpression expression, object context) - { - if (expression.FieldName != FieldName.DateTimeStart && expression.FieldName != FieldName.DateTimeEnd) - { - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - - // ResourceSurrogateId has millisecond datetime precision, with lower bits added in to make the value unique. - - DateTime original = ((DateTimeOffset)expression.Value).UtcDateTime; - DateTime truncated = original.TruncateToMillisecond(); - - switch (expression.BinaryOperator) - { - case BinaryOperator.GreaterThan: - return Expression.GreaterThanOrEqual( - SqlFieldName.ResourceSurrogateId, - null, - new DateTimeOffset(truncated.AddTicks(TimeSpan.TicksPerMillisecond)).ToSurrogateId()); - case BinaryOperator.GreaterThanOrEqual: - if (original == truncated) - { - return Expression.GreaterThanOrEqual( - SqlFieldName.ResourceSurrogateId, - null, - new DateTimeOffset(truncated).ToSurrogateId()); - } - - goto case BinaryOperator.GreaterThan; - case BinaryOperator.LessThan: - if (original == truncated) - { - return Expression.LessThan( - SqlFieldName.ResourceSurrogateId, - null, - new DateTimeOffset(truncated).ToSurrogateId()); - } - - goto case BinaryOperator.LessThanOrEqual; - case BinaryOperator.LessThanOrEqual: - return Expression.LessThan( - SqlFieldName.ResourceSurrogateId, - null, - new DateTimeOffset(truncated.AddTicks(TimeSpan.TicksPerMillisecond)).ToSurrogateId()); - case BinaryOperator.NotEqual: - case BinaryOperator.Equal: // expecting eq to have been rewritten as a range - default: - throw new ArgumentOutOfRangeException(expression.BinaryOperator.ToString()); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LegacyStringOverflowRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LegacyStringOverflowRewriter.cs deleted file mode 100644 index 3ee519d498..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/LegacyStringOverflowRewriter.cs +++ /dev/null @@ -1,87 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.ValueSets; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// To be used with schema versions less than . - /// Rewrites expressions over string search parameters to account for entries where the TextOverflow - /// column is not null. - /// - internal class LegacyStringOverflowRewriter : ConcatenationRewriter - { - public static readonly LegacyStringOverflowRewriter Instance = new LegacyStringOverflowRewriter(); - - public LegacyStringOverflowRewriter() - : base(new Scout()) - { - } - - public override Expression VisitSearchParameter(SearchParameterExpression expression, object context) - { - if (expression.Parameter.Type == SearchParamType.String || - expression.Parameter.Type == SearchParamType.Composite) - { - return base.VisitSearchParameter(expression, expression.Parameter); - } - - return expression; - } - - public override Expression VisitString(StringExpression expression, object context) - { - var searchParameterInfo = (SearchParameterInfo)context; - if ((expression.ComponentIndex == null ? searchParameterInfo.Type : searchParameterInfo.Component[expression.ComponentIndex.Value].ResolvedSearchParameter.Type) != SearchParamType.String) - { - return expression; - } - - return new StringExpression(expression.StringOperator, SqlFieldName.TextOverflow, expression.ComponentIndex, expression.Value, expression.IgnoreCase); - } - - private class Scout : DefaultSqlExpressionVisitor - { - internal Scout() - : base((accumulated, current) => accumulated || current) - { - } - - public override bool VisitSearchParameter(SearchParameterExpression expression, object context) - { - if (expression.Parameter.Type == SearchParamType.String || - expression.Parameter.Type == SearchParamType.Composite) - { - return expression.Expression.AcceptVisitor(this, expression.Parameter); - } - - return false; - } - - public override bool VisitString(StringExpression expression, object context) - { - var searchParameterInfo = (SearchParameterInfo)context; - - if ((expression.ComponentIndex == null ? searchParameterInfo.Type : searchParameterInfo.Component[expression.ComponentIndex.Value].ResolvedSearchParameter.Type) != SearchParamType.String) - { - return false; - } - - if (expression.StringOperator == StringOperator.Equals && expression.Value.Length <= VLatest.StringSearchParam.Text.Metadata.MaxLength) - { - // in these cases, we will know for sure that we do not need to consider the overflow column - return false; - } - - return true; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/MissingSearchParamVisitor.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/MissingSearchParamVisitor.cs deleted file mode 100644 index 7fe2835040..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/MissingSearchParamVisitor.cs +++ /dev/null @@ -1,98 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Turns an expression with a :missing=true search parameter expression and turns it into a - /// table expression with the condition negated - /// - internal class MissingSearchParamVisitor : SqlExpressionRewriterWithInitialContext - { - internal static readonly MissingSearchParamVisitor Instance = new MissingSearchParamVisitor(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count == 0) - { - return expression; - } - - List newTableExpressions = null; - for (var i = 0; i < expression.SearchParamTableExpressions.Count; i++) - { - SearchParamTableExpression searchParamTableExpression = expression.SearchParamTableExpressions[i]; - - // Ignore Sort as it has its own visitor. - if (searchParamTableExpression.Kind != SearchParamTableExpressionKind.Sort && searchParamTableExpression.Predicate?.AcceptVisitor(Scout.Instance, null) == true) - { - EnsureAllocatedAndPopulated(ref newTableExpressions, expression.SearchParamTableExpressions, i); - - // If this is the first expression, we need to add another expression before it - if (i == 0) - { - // seed with all resources so that we have something to restrict - newTableExpressions.Add( - new SearchParamTableExpression( - searchParamTableExpression.QueryGenerator, - null, - SearchParamTableExpressionKind.All)); - } - - newTableExpressions.Add((SearchParamTableExpression)searchParamTableExpression.AcceptVisitor(this, context)); - } - else - { - newTableExpressions?.Add(searchParamTableExpression); - } - } - - if (newTableExpressions == null) - { - return expression; - } - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - public override Expression VisitTable(SearchParamTableExpression searchParamTableExpression, object context) - { - var predicate = searchParamTableExpression.Predicate.AcceptVisitor(this, context); - - return new SearchParamTableExpression( - searchParamTableExpression.QueryGenerator, - predicate, - SearchParamTableExpressionKind.NotExists); - } - - public override Expression VisitMissingSearchParameter(MissingSearchParameterExpression expression, object context) - { - if (expression.IsMissing) - { - return Expression.MissingSearchParameter(expression.Parameter, false); - } - - return expression; - } - - private class Scout : DefaultSqlExpressionVisitor - { - internal static readonly Scout Instance = new Scout(); - - private Scout() - : base((accumulated, current) => accumulated || current) - { - } - - public override bool VisitMissingSearchParameter(MissingSearchParameterExpression expression, object context) - { - return expression.IsMissing; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/NotExpressionRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/NotExpressionRewriter.cs deleted file mode 100644 index 3516f4de55..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/NotExpressionRewriter.cs +++ /dev/null @@ -1,93 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Turns an expression with a :not modifier into a - /// table expression with the condition negated - /// - internal class NotExpressionRewriter : SqlExpressionRewriterWithInitialContext - { - internal static readonly NotExpressionRewriter Instance = new NotExpressionRewriter(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count == 0) - { - return expression; - } - - List newTableExpressions = null; - for (var i = 0; i < expression.SearchParamTableExpressions.Count; i++) - { - SearchParamTableExpression tableExpression = expression.SearchParamTableExpressions[i]; - - // process only normalized predicates. Ignore Sort as it has its own visitor. - if (tableExpression.Kind != SearchParamTableExpressionKind.Chain && tableExpression.Kind != SearchParamTableExpressionKind.Sort && tableExpression.Predicate?.AcceptVisitor(Scout.Instance, context) == true) - { - EnsureAllocatedAndPopulated(ref newTableExpressions, expression.SearchParamTableExpressions, i); - - // If this is the first expression, we need to add another expression before it - if (i == 0) - { - // seed with all resources so that we have something to restrict - newTableExpressions.Add( - new SearchParamTableExpression( - tableExpression.QueryGenerator, - null, - SearchParamTableExpressionKind.Normal)); - } - - newTableExpressions.Add((SearchParamTableExpression)tableExpression.AcceptVisitor(this, context)); - } - else - { - newTableExpressions?.Add(tableExpression); - } - } - - if (newTableExpressions == null) - { - return expression; - } - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - public override Expression VisitTable(SearchParamTableExpression tableExpression, object context) - { - var visitedPredicate = tableExpression.Predicate.AcceptVisitor(this, context); - - return new SearchParamTableExpression( - tableExpression.QueryGenerator, - visitedPredicate, - SearchParamTableExpressionKind.NotExists); - } - - public override Expression VisitNotExpression(NotExpression expression, object context) - { - return expression.Expression; - } - - private class Scout : DefaultExpressionVisitor - { - internal static readonly Scout Instance = new Scout(); - - private Scout() - : base((accumulated, current) => current || accumulated) - { - } - - public override bool VisitNotExpression(NotExpression expression, object context) - { - return true; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/NumericRangeRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/NumericRangeRewriter.cs deleted file mode 100644 index e4edb03f80..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/NumericRangeRewriter.cs +++ /dev/null @@ -1,73 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Rewrites expressions over Quantity and Number values to take ranges into account. - /// They will combine the results of the original expression with the results of the - /// expressions over the low and high fields where the entry is not a single value. - /// - internal class NumericRangeRewriter : ConcatenationRewriter - { - internal static readonly NumericRangeRewriter Instance = new NumericRangeRewriter(); - - private NumericRangeRewriter() - : base(new Scout()) - { - } - - public override Expression VisitBinary(BinaryExpression expression, object context) - { - FieldName highField; - FieldName lowField; - switch (expression.FieldName) - { - case FieldName.Quantity: - highField = SqlFieldName.QuantityHigh; - lowField = SqlFieldName.QuantityLow; - break; - case FieldName.Number: - highField = SqlFieldName.NumberHigh; - lowField = SqlFieldName.NumberLow; - break; - default: - return expression; - } - - switch (expression.BinaryOperator) - { - case BinaryOperator.GreaterThan: - return Expression.GreaterThan(highField, expression.ComponentIndex, expression.Value); - case BinaryOperator.GreaterThanOrEqual: - return Expression.GreaterThanOrEqual(highField, expression.ComponentIndex, expression.Value); - case BinaryOperator.LessThan: - return Expression.LessThan(lowField, expression.ComponentIndex, expression.Value); - case BinaryOperator.LessThanOrEqual: - return Expression.LessThanOrEqual(lowField, expression.ComponentIndex, expression.Value); - case BinaryOperator.Equal: - case BinaryOperator.NotEqual: - default: - throw new ArgumentOutOfRangeException(expression.BinaryOperator.ToString()); - } - } - - private class Scout : DefaultSqlExpressionVisitor - { - internal Scout() - : base((accumulated, current) => accumulated || current) - { - } - - public override bool VisitBinary(BinaryExpression expression, object context) - { - return expression.FieldName == FieldName.Quantity || expression.FieldName == FieldName.Number; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/PartitionEliminationRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/PartitionEliminationRewriter.cs deleted file mode 100644 index 1d6bdf65ad..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/PartitionEliminationRewriter.cs +++ /dev/null @@ -1,213 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections; -using System.Collections.Generic; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Definition; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.SqlServer.Features.Schema; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// The Resource and search parameter tables are partitioned by ResourceTypeId. This rewriter does two things: - /// 1. Ensures that in the case of a system-wide search (/), we enumerate all types that the resources can be, - /// rather that leaving the search. (The SQL optimizer does not always employ partition elimination otherwise) - /// 2. It expands out a primary key continuation token () into a , - /// which includes the primary key but enumerates the subsequent resource types (depending on the sort order). - /// - internal class PartitionEliminationRewriter : SqlExpressionRewriterWithInitialContext - { - private readonly ISqlServerFhirModel _model; - private readonly SchemaInformation _schemaInformation; - private readonly ISearchParameterDefinitionManager.SearchableSearchParameterDefinitionManagerResolver _searchParameterDefinitionManagerResolver; - private SearchParameterInfo _resourceTypeSearchParameter; - private SearchParameterExpression _allTypesExpression; - - public PartitionEliminationRewriter( - ISqlServerFhirModel model, - SchemaInformation schemaInformation, - ISearchParameterDefinitionManager.SearchableSearchParameterDefinitionManagerResolver searchParameterDefinitionManagerResolver) - { - EnsureArg.IsNotNull(model, nameof(model)); - EnsureArg.IsNotNull(schemaInformation, nameof(schemaInformation)); - EnsureArg.IsNotNull(searchParameterDefinitionManagerResolver, nameof(searchParameterDefinitionManagerResolver)); - - _model = model; - _schemaInformation = schemaInformation; - _searchParameterDefinitionManagerResolver = searchParameterDefinitionManagerResolver; - } - - private SearchParameterInfo ResourceTypeSearchParameter - { - get - { - if (_resourceTypeSearchParameter == null) - { - _resourceTypeSearchParameter = _searchParameterDefinitionManagerResolver.Invoke().GetSearchParameter(KnownResourceTypes.Resource, SearchParameterNames.ResourceType); - } - - return _resourceTypeSearchParameter; - } - } - - private SearchParameterExpression GetAllTypesExpression() - { - if (_allTypesExpression != null) - { - return _allTypesExpression; - } - - string[] resourceTypes = new string[_model.ResourceTypeIdRange.highestId - _model.ResourceTypeIdRange.lowestId + 1]; - for (short i = 0, typeId = _model.ResourceTypeIdRange.lowestId; typeId <= _model.ResourceTypeIdRange.highestId; typeId++, i++) - { - resourceTypes[i] = _model.GetResourceTypeName(typeId); - } - - _allTypesExpression = Expression.SearchParameter(ResourceTypeSearchParameter, Expression.In(FieldName.TokenCode, null, resourceTypes)); - - return _allTypesExpression; - } - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (_schemaInformation.Current < SchemaVersionConstants.PartitionedTables) - { - return expression; - } - - // Look for primary key continuation token (PrimaryKeyParameter) or _type parameters - - int primaryKeyValueIndex = -1; - bool hasTypeRestriction = false; - bool needTypeRestriction = false; - for (var i = 0; i < expression.ResourceTableExpressions.Count; i++) - { - SearchParameterInfo parameter = expression.ResourceTableExpressions[i].Parameter; - - if (ReferenceEquals(parameter, SqlSearchParameters.PrimaryKeyParameter)) - { - primaryKeyValueIndex = i; - } - else if (ReferenceEquals(parameter, ResourceTypeSearchParameter)) - { - hasTypeRestriction = true; - } - } - - // We still need this resource expansion for Smart requests that does system wide search and returns only resources that are part of same - // compartment along with universal resources. - // Refer to this test case GivenFhirUserClaimPractitioner_WhenAllResourcesRequested_ResourcesInTheSameComparementAndUniversalResourcesAlsoReturned - if (!hasTypeRestriction) - { - for (var i = 0; i < expression.SearchParamTableExpressions.Count; i++) - { - if (expression.SearchParamTableExpressions[i].ToString().Contains(ResourceTypeSearchParameter.Code, StringComparison.Ordinal)) - { - needTypeRestriction = true; - } - } - } - - if (primaryKeyValueIndex < 0) - { - // no continuation token - - if (hasTypeRestriction) - { - // This is already constrained to be one or more resource types. - return expression; - } - else if (needTypeRestriction) - { - // Explicitly allow all resource types. SQL tends to create far better query plans than when there is no filter on ResourceTypeId. - - var updatedResourceTableExpressions = new List(expression.ResourceTableExpressions.Count + 1); - updatedResourceTableExpressions.AddRange(expression.ResourceTableExpressions); - updatedResourceTableExpressions.Add(GetAllTypesExpression()); - - return new SqlRootExpression(expression.SearchParamTableExpressions, updatedResourceTableExpressions); - } - - return expression; - } - - // There is a primary key continuation token. - // Now look at the _type restrictions to construct a PrimaryKeyRange - // that has only the allowed types. - - var primaryKeyParameter = (SearchParameterExpression)expression.ResourceTableExpressions[primaryKeyValueIndex]; - - (short? singleAllowedResourceTypeId, BitArray allowedTypes) = TypeConstraintVisitor.Instance.Visit(expression, _model); - - var existingPrimaryKeyBinaryExpression = (BinaryExpression)primaryKeyParameter.Expression; - var existingPrimaryKeyValue = (PrimaryKeyValue)existingPrimaryKeyBinaryExpression.Value; - - SearchParameterExpression newSearchParameterExpression; - if (singleAllowedResourceTypeId != null || allowedTypes == null) - { - // we'll keep the existing _type parameter and just need to add a ResourceSurrogateId expression - newSearchParameterExpression = Expression.SearchParameter( - SqlSearchParameters.ResourceSurrogateIdParameter, - new BinaryExpression(existingPrimaryKeyBinaryExpression.BinaryOperator, SqlFieldName.ResourceSurrogateId, null, existingPrimaryKeyValue.ResourceSurrogateId)); - } - else - { - // Intersect allowed types with the direction of primary key parameter - // e.g. if >, then eliminate all types that are <= - switch (existingPrimaryKeyBinaryExpression.BinaryOperator) - { - case BinaryOperator.GreaterThan: - for (int i = existingPrimaryKeyValue.ResourceTypeId; i >= 0; i--) - { - allowedTypes[i] = false; - } - - break; - case BinaryOperator.LessThan: - for (int i = existingPrimaryKeyValue.ResourceTypeId; i < allowedTypes.Length; i++) - { - allowedTypes[i] = false; - } - - break; - default: - throw new InvalidOperationException($"Unexpected operator {existingPrimaryKeyBinaryExpression.BinaryOperator}"); - } - - newSearchParameterExpression = Expression.SearchParameter( - primaryKeyParameter.Parameter, - new BinaryExpression( - existingPrimaryKeyBinaryExpression.BinaryOperator, - existingPrimaryKeyBinaryExpression.FieldName, - null, - new PrimaryKeyRange(existingPrimaryKeyValue, allowedTypes))); - } - - var newResourceTableExpressions = new List(); - for (var i = 0; i < expression.ResourceTableExpressions.Count; i++) - { - if (i == primaryKeyValueIndex || // eliminate the existing primaryKey expression - (singleAllowedResourceTypeId == null && // if there are many possible types, the PrimaryKeyRange expression will already be constrained to those types - expression.ResourceTableExpressions[i] is SearchParameterExpression searchParameterExpression && - searchParameterExpression.Parameter.Name == SearchParameterNames.ResourceType)) - { - continue; - } - - newResourceTableExpressions.Add(expression.ResourceTableExpressions[i]); - } - - newResourceTableExpressions.Add(newSearchParameterExpression); - return new SqlRootExpression(expression.SearchParamTableExpressions, newResourceTableExpressions); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ChainLinkQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ChainLinkQueryGenerator.cs deleted file mode 100644 index f86be02a12..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ChainLinkQueryGenerator.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class ChainLinkQueryGenerator : SearchParamTableExpressionQueryGenerator - { - internal static readonly ChainLinkQueryGenerator Instance = new ChainLinkQueryGenerator(); - - public override Table Table => null; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/CompartmentQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/CompartmentQueryGenerator.cs deleted file mode 100644 index e99a84b423..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/CompartmentQueryGenerator.cs +++ /dev/null @@ -1,35 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class CompartmentQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly CompartmentQueryGenerator Instance = new CompartmentQueryGenerator(); - - public override Table Table => VLatest.CompartmentAssignment; - - public override SearchParameterQueryGeneratorContext VisitCompartment(CompartmentSearchExpression expression, SearchParameterQueryGeneratorContext context) - { - byte compartmentTypeId = context.Model.GetCompartmentTypeId(expression.CompartmentType); - - context.StringBuilder - .Append(VLatest.CompartmentAssignment.CompartmentTypeId, context.TableAlias) - .Append(" = ") - .Append(context.Parameters.AddParameter(VLatest.CompartmentAssignment.CompartmentTypeId, compartmentTypeId, true)) - .AppendLine() - .Append("AND ") - .Append(VLatest.CompartmentAssignment.ReferenceResourceId, context.TableAlias) - .Append(" = ") - .Append(context.Parameters.AddParameter(VLatest.CompartmentAssignment.ReferenceResourceId, expression.CompartmentId, true)); - - return context; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/CompositeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/CompositeQueryGenerator.cs deleted file mode 100644 index 32216c6f6f..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/CompositeQueryGenerator.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal abstract class CompositeQueryGenerator : SearchParamTableExpressionQueryGenerator - { - private readonly SearchParamTableExpressionQueryGenerator[] _componentHandlers; - - protected CompositeQueryGenerator(params SearchParamTableExpressionQueryGenerator[] componentHandlers) - { - EnsureArg.IsNotNull(componentHandlers, nameof(componentHandlers)); - _componentHandlers = componentHandlers; - } - - public override SearchParameterQueryGeneratorContext VisitBinary(BinaryExpression expression, SearchParameterQueryGeneratorContext context) - { - return expression.AcceptVisitor(_componentHandlers[(int)expression.ComponentIndex], context); - } - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - return expression.AcceptVisitor(_componentHandlers[(int)expression.ComponentIndex], context); - } - - public override SearchParameterQueryGeneratorContext VisitMissingField(MissingFieldExpression expression, SearchParameterQueryGeneratorContext context) - { - return expression.AcceptVisitor(_componentHandlers[(int)expression.ComponentIndex], context); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/DateTimeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/DateTimeQueryGenerator.cs deleted file mode 100644 index c3447358b8..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/DateTimeQueryGenerator.cs +++ /dev/null @@ -1,41 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class DateTimeQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly DateTimeQueryGenerator Instance = new DateTimeQueryGenerator(); - - public override Table Table => VLatest.DateTimeSearchParam; - - public override SearchParameterQueryGeneratorContext VisitBinary(BinaryExpression expression, SearchParameterQueryGeneratorContext context) - { - DateTime2Column column; - switch (expression.FieldName) - { - case FieldName.DateTimeStart: - column = VLatest.DateTimeSearchParam.StartDateTime; - break; - case FieldName.DateTimeEnd: - column = VLatest.DateTimeSearchParam.EndDateTime; - break; - case SqlFieldName.DateTimeIsLongerThanADay: - // we don't want to use a parameter here because we want the query plan to use the filtered index based on this field - AppendColumnName(context, VLatest.DateTimeSearchParam.IsLongerThanADay, expression).Append(" = ").Append((bool)expression.Value ? '1' : '0'); - return context; - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - - return VisitSimpleBinary(expression.BinaryOperator, context, column, expression.ComponentIndex, ((DateTimeOffset)expression.Value).UtcDateTime); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/InQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/InQueryGenerator.cs deleted file mode 100644 index 68c4129c95..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/InQueryGenerator.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class InQueryGenerator : SearchParamTableExpressionQueryGenerator - { - internal static readonly InQueryGenerator Instance = new InQueryGenerator(); - - public override Table Table => null; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/IncludeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/IncludeQueryGenerator.cs deleted file mode 100644 index 5474e30df1..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/IncludeQueryGenerator.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class IncludeQueryGenerator : SearchParamTableExpressionQueryGenerator - { - internal static readonly IncludeQueryGenerator Instance = new IncludeQueryGenerator(); - - public override Table Table => null; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/NotReferencedQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/NotReferencedQueryGenerator.cs deleted file mode 100644 index c1bb82c63b..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/NotReferencedQueryGenerator.cs +++ /dev/null @@ -1,17 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class NotReferencedQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly NotReferencedQueryGenerator Instance = new NotReferencedQueryGenerator(); - - public override Table Table => VLatest.Resource; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/NumberQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/NumberQueryGenerator.cs deleted file mode 100644 index 312e5bc528..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/NumberQueryGenerator.cs +++ /dev/null @@ -1,48 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class NumberQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly NumberQueryGenerator Instance = new NumberQueryGenerator(); - - public override Table Table => VLatest.NumberSearchParam; - - public override SearchParameterQueryGeneratorContext VisitBinary(BinaryExpression expression, SearchParameterQueryGeneratorContext context) - { - NullableDecimalColumn valueColumn = null; - DecimalColumn notNullableValueColumn = null; - - switch (expression.FieldName) - { - case FieldName.Number: - valueColumn = VLatest.NumberSearchParam.SingleValue; - break; - case SqlFieldName.NumberLow: - notNullableValueColumn = VLatest.NumberSearchParam.LowValue; - break; - case SqlFieldName.NumberHigh: - notNullableValueColumn = VLatest.NumberSearchParam.HighValue; - break; - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - - if (valueColumn != null) - { - AppendColumnName(context, valueColumn, expression).Append(" IS NOT NULL AND "); - return VisitSimpleBinary(expression.BinaryOperator, context, valueColumn, expression.ComponentIndex, expression.Value); - } - - return VisitSimpleBinary(expression.BinaryOperator, context, notNullableValueColumn, expression.ComponentIndex, expression.Value); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/PrimaryKeyRangeParameterQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/PrimaryKeyRangeParameterQueryGenerator.cs deleted file mode 100644 index 859c8fe83c..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/PrimaryKeyRangeParameterQueryGenerator.cs +++ /dev/null @@ -1,63 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// Generates predicates for . - /// These take the from of - /// ResourceTypeId = currentTypeId AND ResourceSurrogateId > currentSurrogateId OR ResourceTypeId IN (subsequentIDs) - /// - internal class PrimaryKeyRangeParameterQueryGenerator : ResourceTableSearchParameterQueryGenerator - { - public static new readonly PrimaryKeyRangeParameterQueryGenerator Instance = new(); - - public override SearchParameterQueryGeneratorContext VisitBinary(BinaryExpression expression, SearchParameterQueryGeneratorContext context) - { - var primaryKeyRange = (PrimaryKeyRange)expression.Value; - - context.StringBuilder.AppendLine("("); - using (context.StringBuilder.Indent()) - { - VisitSimpleBinary(BinaryOperator.Equal, context, VLatest.Resource.ResourceTypeId, null, primaryKeyRange.CurrentValue.ResourceTypeId, includeInParameterHash: false); - context.StringBuilder.Append(" AND "); - VisitSimpleBinary(expression.BinaryOperator, context, VLatest.Resource.ResourceSurrogateId, null, primaryKeyRange.CurrentValue.ResourceSurrogateId, includeInParameterHash: false); - - bool first = true; - for (short i = 0; i < primaryKeyRange.NextResourceTypeIds.Count; i++) - { - if (primaryKeyRange.NextResourceTypeIds[i]) - { - if (first) - { - context.StringBuilder.AppendLine(); - context.StringBuilder.Append("OR "); - AppendColumnName(context, VLatest.Resource.ResourceTypeId, (int?)null).Append(" IN ("); - first = false; - } - else - { - context.StringBuilder.Append(", "); - } - - context.StringBuilder.Append(context.Parameters.AddParameter(VLatest.Resource.ResourceTypeId, i, includeInHash: false)); - } - } - - if (!first) - { - context.StringBuilder.AppendLine(")"); - } - } - - context.StringBuilder.AppendLine(")"); - - return context; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/QuantityQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/QuantityQueryGenerator.cs deleted file mode 100644 index 6ab7857470..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/QuantityQueryGenerator.cs +++ /dev/null @@ -1,93 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class QuantityQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly QuantityQueryGenerator Instance = new QuantityQueryGenerator(); - - public override Table Table => VLatest.QuantitySearchParam; - - public override SearchParameterQueryGeneratorContext VisitBinary(BinaryExpression expression, SearchParameterQueryGeneratorContext context) - { - NullableDecimalColumn valueColumn = null; - DecimalColumn notNullableValueColumn = null; - - switch (expression.FieldName) - { - case FieldName.Quantity: - valueColumn = VLatest.QuantitySearchParam.SingleValue; - break; - case SqlFieldName.QuantityLow: - notNullableValueColumn = VLatest.QuantitySearchParam.LowValue; - break; - case SqlFieldName.QuantityHigh: - notNullableValueColumn = VLatest.QuantitySearchParam.HighValue; - break; - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - - if (valueColumn != null) - { - AppendColumnName(context, valueColumn, expression).Append(" IS NOT NULL AND "); - return VisitSimpleBinary(expression.BinaryOperator, context, valueColumn, expression.ComponentIndex, expression.Value); - } - - return VisitSimpleBinary(expression.BinaryOperator, context, notNullableValueColumn, expression.ComponentIndex, expression.Value); - } - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - switch (expression.FieldName) - { - case FieldName.QuantityCode: - if (context.Model.TryGetQuantityCodeId(expression.Value, out var quantityCodeId)) - { - return VisitSimpleBinary(BinaryOperator.Equal, context, VLatest.QuantitySearchParam.QuantityCodeId, expression.ComponentIndex, quantityCodeId); - } - - AppendColumnName(context, VLatest.QuantitySearchParam.QuantityCodeId, expression) - .Append(" = (SELECT ") - .Append(VLatest.QuantityCode.QuantityCodeId, null) - .Append(" FROM ").Append(VLatest.QuantityCode) - .Append(" WHERE ") - .Append(VLatest.QuantityCode.Value, null) - .Append(" = ") - .Append(context.Parameters.AddParameter(VLatest.QuantityCode.Value, expression.Value, true)) - .Append(")"); - - return context; - - case FieldName.QuantitySystem: - if (context.Model.TryGetSystemId(expression.Value, out var systemId)) - { - return VisitSimpleBinary(BinaryOperator.Equal, context, VLatest.QuantitySearchParam.SystemId, expression.ComponentIndex, systemId); - } - - AppendColumnName(context, VLatest.QuantitySearchParam.SystemId, expression) - .Append(" = (SELECT ") - .Append(VLatest.System.SystemId, null) - .Append(" FROM ").Append(VLatest.System) - .Append(" WHERE ") - .Append(VLatest.System.Value, null) - .Append(" = ") - .Append(context.Parameters.AddParameter(VLatest.System.Value, expression.Value, true)) - .Append(")"); - - return context; - - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceQueryGenerator.cs deleted file mode 100644 index 0d8e071a8a..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceQueryGenerator.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class ReferenceQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly ReferenceQueryGenerator Instance = new ReferenceQueryGenerator(); - - public override Table Table => VLatest.ReferenceSearchParam; - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - switch (expression.FieldName) - { - case FieldName.ReferenceBaseUri: - return VisitSimpleString(expression, context, VLatest.ReferenceSearchParam.BaseUri, expression.Value); - case FieldName.ReferenceResourceType: - if (context.Model.TryGetResourceTypeId(expression.Value, out short resourceTypeId)) - { - return VisitSimpleBinary(BinaryOperator.Equal, context, VLatest.ReferenceSearchParam.ReferenceResourceTypeId, expression.ComponentIndex, resourceTypeId); - } - - // Resource type not in model info provider (e.g., Citation is a search param target in R5 but excluded from supported resources). - // essentially a bug in R5, some search parameters reference target types which are not in the model info provider. - context.StringBuilder.Append("0 = 1"); - return context; - case FieldName.ReferenceResourceId: - return VisitSimpleString(expression, context, VLatest.ReferenceSearchParam.ReferenceResourceId, expression.Value); - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - } - - public override SearchParameterQueryGeneratorContext VisitMissingField(MissingFieldExpression expression, SearchParameterQueryGeneratorContext context) - { - switch (expression.FieldName) - { - case FieldName.ReferenceBaseUri: - return VisitMissingFieldImpl(expression, context, FieldName.ReferenceBaseUri, VLatest.ReferenceSearchParam.BaseUri); - case FieldName.ReferenceResourceType: - return VisitMissingFieldImpl(expression, context, FieldName.ReferenceResourceType, VLatest.ReferenceSearchParam.ReferenceResourceTypeId); - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceTokenCompositeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceTokenCompositeQueryGenerator.cs deleted file mode 100644 index 231ab4d99e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ReferenceTokenCompositeQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class ReferenceTokenCompositeQueryGenerator : CompositeQueryGenerator - { - public static readonly ReferenceTokenCompositeQueryGenerator Instance = new ReferenceTokenCompositeQueryGenerator(); - - public ReferenceTokenCompositeQueryGenerator() - : base(ReferenceQueryGenerator.Instance, TokenQueryGenerator.Instance) - { - } - - public override Table Table => VLatest.ReferenceTokenCompositeSearchParam; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceIdParameterQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceIdParameterQueryGenerator.cs deleted file mode 100644 index acdfadbbb6..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceIdParameterQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class ResourceIdParameterQueryGenerator : ResourceTableSearchParameterQueryGenerator - { - public static new readonly ResourceIdParameterQueryGenerator Instance = new ResourceIdParameterQueryGenerator(); - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - VisitSimpleString(expression, context, VLatest.Resource.ResourceId, expression.Value); - - return context; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceSurrogateIdParameterQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceSurrogateIdParameterQueryGenerator.cs deleted file mode 100644 index eda732609e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceSurrogateIdParameterQueryGenerator.cs +++ /dev/null @@ -1,21 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class ResourceSurrogateIdParameterQueryGenerator : ResourceTableSearchParameterQueryGenerator - { - public static new readonly ResourceSurrogateIdParameterQueryGenerator Instance = new ResourceSurrogateIdParameterQueryGenerator(); - - public override SearchParameterQueryGeneratorContext VisitBinary(BinaryExpression expression, SearchParameterQueryGeneratorContext context) - { - VisitSimpleBinary(expression.BinaryOperator, context, VLatest.Resource.ResourceSurrogateId, expression.ComponentIndex, expression.Value, includeInParameterHash: context.IsAsyncOperation); - return context; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceTableSearchParameterQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceTableSearchParameterQueryGenerator.cs deleted file mode 100644 index da29f1980f..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceTableSearchParameterQueryGenerator.cs +++ /dev/null @@ -1,40 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - /// - /// A base class for s that are for search parameters on the Resource table. - /// - internal class ResourceTableSearchParameterQueryGenerator : SearchParameterQueryGenerator - { - /// - /// This instance is intended to be used for expressions that exclusively over search parameters on the Resource table or the Resource table and Search parameter tables - /// - public static readonly ResourceTableSearchParameterQueryGenerator Instance = new ResourceTableSearchParameterQueryGenerator(); - - public override SearchParameterQueryGeneratorContext VisitSearchParameter(SearchParameterExpression expression, SearchParameterQueryGeneratorContext context) - { - return expression.Expression.AcceptVisitor(GetSearchParameterQueryGenerator(expression), context); - } - - public override SearchParameterQueryGeneratorContext VisitMissingSearchParameter(MissingSearchParameterExpression expression, SearchParameterQueryGeneratorContext context) - { - // Call this method but discard the result to ensure the search parameter is one we are expecting. - GetSearchParameterQueryGenerator(expression); - - context.StringBuilder.Append(expression.IsMissing ? " 1 = 0 " : " 1 = 1 "); - return context; - } - - private static SearchParameterQueryGenerator GetSearchParameterQueryGenerator(SearchParameterExpressionBase searchParameter) - { - return GetSearchParameterQueryGeneratorIfResourceColumnSearchParameter(searchParameter) ?? throw new InvalidOperationException($"Unexpected search parameter {searchParameter.Parameter.Code}"); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceTypeIdParameterQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceTypeIdParameterQueryGenerator.cs deleted file mode 100644 index 93527ebe1b..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/ResourceTypeIdParameterQueryGenerator.cs +++ /dev/null @@ -1,47 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class ResourceTypeIdParameterQueryGenerator : ResourceTableSearchParameterQueryGenerator - { - public static new readonly ResourceTypeIdParameterQueryGenerator Instance = new ResourceTypeIdParameterQueryGenerator(); - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - if (!context.Model.TryGetResourceTypeId(expression.Value, out var resourceTypeId)) - { - context.StringBuilder.Append("0 = 1"); - return context; - } - - return VisitSimpleBinary(BinaryOperator.Equal, context, VLatest.Resource.ResourceTypeId, expression.ComponentIndex, resourceTypeId); - } - - public override SearchParameterQueryGeneratorContext VisitIn(InExpression expression, SearchParameterQueryGeneratorContext context) - { - List resolvedResourceTypeIds = new List(capacity: expression.Values.Count); - foreach (T resourceType in expression.Values) - { - string resourceTypeName = resourceType.ToString(); - if (context.Model.TryGetResourceTypeId(resourceTypeName, out short resourceTypeId)) - { - resolvedResourceTypeIds.Add(resourceTypeId); - } - else - { - throw new InvalidOperationException(string.Format(Resources.InvalidResourceTypeValue, resourceType)); - } - } - - return VisitSimpleIn(context, VLatest.Resource.ResourceTypeId, resolvedResourceTypeIds); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParamTableExpressionQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParamTableExpressionQueryGenerator.cs deleted file mode 100644 index d749a20ecf..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParamTableExpressionQueryGenerator.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal abstract class SearchParamTableExpressionQueryGenerator : SearchParameterQueryGenerator - { - public abstract Table Table { get; } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParameterQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParameterQueryGenerator.cs deleted file mode 100644 index b23f80c494..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParameterQueryGenerator.cs +++ /dev/null @@ -1,382 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text; -using System.Text.RegularExpressions; -using EnsureThat; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal abstract class SearchParameterQueryGenerator : DefaultExpressionVisitor - { - private const string DefaultCaseInsensitiveCollation = "Latin1_General_100_CI_AI_SC"; - private const string DefaultCaseSensitiveCollation = "Latin1_General_100_CS_AS"; - - private static readonly Regex LikeEscapingRegex = new Regex("[%!\\[\\]_]", RegexOptions.Compiled); - - public override SearchParameterQueryGeneratorContext VisitSearchParameter(SearchParameterExpression expression, SearchParameterQueryGeneratorContext context) - { - SearchParameterQueryGenerator delegatedGenerator = GetSearchParameterQueryGeneratorIfResourceColumnSearchParameter(expression); - if (delegatedGenerator != null) - { - // This is a search parameter over a column that exists on the Resource table or both the Resource table and search parameter tables. - // Delegate to the visitor specific to it. - return expression.Expression.AcceptVisitor(delegatedGenerator, context); - } - - short searchParamId = context.Model.GetSearchParamId(expression.Parameter.Url); - SmallIntColumn searchParamIdColumn = VLatest.SearchParam.SearchParamId; - - context.StringBuilder - .Append(searchParamIdColumn, context.TableAlias) - .Append(" = ") - .Append(context.Parameters.AddParameter(searchParamIdColumn, searchParamId, true)).AppendLine() - .Append("AND "); - - return expression.Expression.AcceptVisitor(this, context); - } - - public override SearchParameterQueryGeneratorContext VisitSortParameter(SortExpression expression, SearchParameterQueryGeneratorContext context) - { - short searchParamId = context.Model.GetSearchParamId(expression.Parameter.Url); - var searchParamIdColumn = VLatest.SearchParam.SearchParamId; - - context.StringBuilder - .Append(searchParamIdColumn, context.TableAlias) - .Append(" = ") - .Append(context.Parameters.AddParameter(searchParamIdColumn, searchParamId, true)) - .Append(" "); - - return context; - } - - public override SearchParameterQueryGeneratorContext VisitMissingSearchParameter(MissingSearchParameterExpression expression, SearchParameterQueryGeneratorContext context) - { - SearchParameterQueryGenerator delegatedGenerator = GetSearchParameterQueryGeneratorIfResourceColumnSearchParameter(expression); - if (delegatedGenerator != null) - { - // This is a search parameter over a column that exists on the Resource table or both the Resource table and search parameter tables. - // Delegate to the visitor specific to it. - return expression.AcceptVisitor(delegatedGenerator, context); - } - - Debug.Assert(!expression.IsMissing, "IsMissing=true expressions should have been rewritten"); - - short searchParamId = context.Model.GetSearchParamId(expression.Parameter.Url); - SmallIntColumn searchParamIdColumn = VLatest.SearchParam.SearchParamId; - - context.StringBuilder - .Append(searchParamIdColumn, context.TableAlias) - .Append(" = ") - .Append(context.Parameters.AddParameter(searchParamIdColumn, searchParamId, true)) - .Append(" "); - - return context; - } - - public override SearchParameterQueryGeneratorContext VisitNotExpression(NotExpression expression, SearchParameterQueryGeneratorContext context) - { - context.StringBuilder.Append("NOT "); - return base.VisitNotExpression(expression, context); - } - - public override SearchParameterQueryGeneratorContext VisitMultiary(MultiaryExpression expression, SearchParameterQueryGeneratorContext context) - { - bool isOrMultinaryOperator = expression.MultiaryOperation == MultiaryOperator.Or; - - if (isOrMultinaryOperator) - { - context.StringBuilder.Append('('); - } - - context.StringBuilder.AppendDelimited( - sb => sb.AppendLine().Append(expression.MultiaryOperation == MultiaryOperator.And ? "AND " : "OR "), - expression.Expressions, - (sb, childExpr) => - { - if (isOrMultinaryOperator) - { - context.StringBuilder.Append('('); - } - - childExpr.AcceptVisitor(this, context); - - if (isOrMultinaryOperator) - { - context.StringBuilder.Append(')'); - } - }); - - if (isOrMultinaryOperator) - { - context.StringBuilder.Append(')'); - } - - context.StringBuilder.Append(" "); // Replaced CR by space keeping code "protection". - - return context; - } - - public override SearchParameterQueryGeneratorContext VisitNotReferenced(NotReferencedExpression expression, SearchParameterQueryGeneratorContext context) - { - context.StringBuilder.AppendLine($"{VLatest.Resource.IsHistory} = 0"); - context.StringBuilder.AppendLine($"AND {VLatest.Resource.IsDeleted} = 0"); - context.StringBuilder.AppendLine("AND NOT EXISTS ").AppendLine("("); - using (context.StringBuilder.Indent()) - { - context.StringBuilder.AppendLine($"SELECT *"); - context.StringBuilder.Append($"FROM (SELECT SourceResourceTypeId = {VLatest.ReferenceSearchParam.ResourceTypeId}, {VLatest.ReferenceSearchParam.SearchParamId}, {VLatest.ReferenceSearchParam.ReferenceResourceId}, {VLatest.ReferenceSearchParam.ReferenceResourceTypeId} FROM ").Append(VLatest.ReferenceSearchParam).AppendLine(") R"); - - using (var nestedDelimited = context.StringBuilder.BeginDelimitedWhereClause()) - { - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"{VLatest.ReferenceSearchParam.ReferenceResourceId} = {VLatest.Resource.ResourceId}"); - - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"{VLatest.ReferenceSearchParam.ReferenceResourceTypeId} = {VLatest.Resource.ResourceTypeId}"); - - if (expression.SourceResourceType != null) - { - var resourceTypeId = context.Model.GetResourceTypeId(expression.SourceResourceType); - - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"SourceResourceTypeId = {resourceTypeId}"); - - if (expression.ReferenceSearchParameter != null) - { - var searchParamId = context.Model.GetSearchParamId(expression.ReferenceSearchParameter.Url); - - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"{VLatest.ReferenceSearchParam.SearchParamId} = {searchParamId}"); - } - } - } - } - - context.StringBuilder.AppendLine(")"); - - return base.VisitNotReferenced(expression, context); - } - - public override SearchParameterQueryGeneratorContext VisitNotReferencing(NotReferencingExpression expression, SearchParameterQueryGeneratorContext context) - { - short sourceResourceTypeId = context.Model.GetResourceTypeId(expression.SourceResourceType); - short referenceSearchParamId = context.Model.GetSearchParamId(expression.ReferenceSearchParameter.Url); - - context.StringBuilder.AppendLine($"{VLatest.Resource.ResourceTypeId} = {sourceResourceTypeId}"); - context.StringBuilder.AppendLine("AND NOT EXISTS ").AppendLine("("); - using (context.StringBuilder.Indent()) - { - context.StringBuilder.AppendLine("SELECT *"); - context.StringBuilder.Append($"FROM (SELECT RefResourceTypeId = {VLatest.ReferenceSearchParam.ResourceTypeId}, RefResourceSurrogateId = {VLatest.ReferenceSearchParam.ResourceSurrogateId}, {VLatest.ReferenceSearchParam.SearchParamId} FROM ").Append(VLatest.ReferenceSearchParam).AppendLine(") R"); - - using (var nestedDelimited = context.StringBuilder.BeginDelimitedWhereClause()) - { - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"RefResourceTypeId = {VLatest.Resource.ResourceTypeId}"); - - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"RefResourceSurrogateId = {VLatest.Resource.ResourceSurrogateId}"); - - nestedDelimited.BeginDelimitedElement(); - context.StringBuilder.Append($"{VLatest.ReferenceSearchParam.SearchParamId} = {referenceSearchParamId}"); - } - } - - context.StringBuilder.AppendLine(")"); - - return context; - } - - protected static SearchParameterQueryGenerator GetSearchParameterQueryGeneratorIfResourceColumnSearchParameter(SearchParameterExpressionBase searchParameter) - { - switch (searchParameter.Parameter.Code) - { - case SearchParameterNames.Id: - return ResourceIdParameterQueryGenerator.Instance; - case SearchParameterNames.ResourceType: - return ResourceTypeIdParameterQueryGenerator.Instance; - case SqlSearchParameters.ResourceSurrogateIdParameterName: - return ResourceSurrogateIdParameterQueryGenerator.Instance; - case SqlSearchParameters.PrimaryKeyParameterName: - return PrimaryKeyRangeParameterQueryGenerator.Instance; -#if DEBUG - case SearchParameterNames.LastUpdated: - throw new InvalidOperationException($"Expression with {SearchParameterNames.LastUpdated} parameter should have been rewritten to use {SqlSearchParameters.ResourceSurrogateIdParameterName}."); -#endif - default: - return null; - } - } - - private static bool TryEscapeValueForLike(ref string value) - { - var escapedValue = LikeEscapingRegex.Replace(value, "!$0"); - if (escapedValue != value) - { - value = escapedValue; - return true; - } - - return false; - } - - protected static SearchParameterQueryGeneratorContext VisitSimpleBinary(BinaryOperator binaryOperator, SearchParameterQueryGeneratorContext context, Column column, int? componentIndex, object value, bool includeInParameterHash = true) - { - AppendColumnName(context, column, componentIndex); - - switch (binaryOperator) - { - case BinaryOperator.Equal: - context.StringBuilder.Append(" = "); - break; - case BinaryOperator.GreaterThan: - context.StringBuilder.Append(" > "); - break; - case BinaryOperator.GreaterThanOrEqual: - context.StringBuilder.Append(" >= "); - break; - case BinaryOperator.LessThan: - context.StringBuilder.Append(" < "); - break; - case BinaryOperator.LessThanOrEqual: - context.StringBuilder.Append(" <= "); - break; - case BinaryOperator.NotEqual: - context.StringBuilder.Append(" <> "); - break; - default: - throw new ArgumentOutOfRangeException(binaryOperator.ToString()); - } - - context.StringBuilder.Append(context.Parameters.AddParameter(column, value, includeInParameterHash)); - - return context; - } - - protected static SearchParameterQueryGeneratorContext VisitSimpleString(StringExpression expression, SearchParameterQueryGeneratorContext context, StringColumn column, string value) - { - if (expression.StringOperator != StringOperator.LeftSideStartsWith) - { - AppendColumnName(context, column, expression); - } - - bool needsEscaping = false; - switch (expression.StringOperator) - { - case StringOperator.Contains: - needsEscaping = TryEscapeValueForLike(ref value); - context.StringBuilder.Append(" LIKE ").Append(context.Parameters.AddParameter(column, $"%{value}%", true)); - break; - case StringOperator.EndsWith: - needsEscaping = TryEscapeValueForLike(ref value); - context.StringBuilder.Append(" LIKE ").Append(context.Parameters.AddParameter(column, $"%{value}", true)); - break; - case StringOperator.Equals: - context.StringBuilder.Append(" = ").Append(context.Parameters.AddParameter(column, value, true)); - break; - case StringOperator.NotContains: - context.StringBuilder.Append(" NOT "); - goto case StringOperator.Contains; - case StringOperator.NotEndsWith: - context.StringBuilder.Append(" NOT "); - goto case StringOperator.EndsWith; - case StringOperator.NotStartsWith: - context.StringBuilder.Append(" NOT "); - goto case StringOperator.StartsWith; - case StringOperator.StartsWith: - needsEscaping = TryEscapeValueForLike(ref value); - context.StringBuilder.Append(" LIKE ").Append(context.Parameters.AddParameter(column, $"{value}%", true)); - break; - case StringOperator.LeftSideStartsWith: - needsEscaping = TryEscapeValueForLike(ref value); - context.StringBuilder.Append(context.Parameters.AddParameter(column, $"{value}", true)).Append(" LIKE "); - AppendColumnName(context, column, expression); - context.StringBuilder.Append("+'%'"); - break; - - default: - throw new ArgumentOutOfRangeException(expression.StringOperator.ToString()); - } - - if (needsEscaping) - { - context.StringBuilder.Append(" ESCAPE '!'"); - } - - if (column.IsAcentSensitive == null || column.IsCaseSensitive == null || - column.IsAcentSensitive == expression.IgnoreCase || - column.IsCaseSensitive == expression.IgnoreCase) - { - if (!expression.IgnoreCase && expression.StringOperator == StringOperator.Equals && column.IsAcentSensitive != null && column.IsCaseSensitive != null) - { - // We are doing a case/accent sensitive query over a column that is case/accent insensitive. - // We can improve efficiency of the query by including an accent/case insensitive predicate - // in addition to the sensitive one. This allows the optimizer choose an index seek. - - context.StringBuilder.Append(" AND "); - AppendColumnName(context, column, expression); - context.StringBuilder.Append(" = ").Append(context.Parameters.AddParameter(column, value, true)); - } - - context.StringBuilder.Append(" COLLATE ").Append(expression.IgnoreCase ? DefaultCaseInsensitiveCollation : DefaultCaseSensitiveCollation); - } - - return context; - } - - protected static SearchParameterQueryGeneratorContext VisitSimpleIn(SearchParameterQueryGeneratorContext context, Column column, IReadOnlyList values) - { - context.StringBuilder.Append(column, context.TableAlias); - context.StringBuilder.Append(" IN ("); - - for (int index = 0; index < values.Count; index++) - { - T item = values[index]; - - context.StringBuilder.Append(context.Parameters.AddParameter(column, item, true)); - - if (index < values.Count - 1) - { - context.StringBuilder.Append(","); - } - } - - context.StringBuilder.Append(") "); // Replaced CR by space keeping code "protection". - - return context; - } - - protected static SearchParameterQueryGeneratorContext VisitMissingFieldImpl(MissingFieldExpression expression, SearchParameterQueryGeneratorContext context, FieldName expectedFieldName, Column column) - { - if (expression.FieldName != expectedFieldName) - { - throw new InvalidOperationException($"Unexpected missing field {expression.FieldName}"); - } - - AppendColumnName(context, column, expression).Append(" IS NULL"); - return context; - } - - protected static IndentedStringBuilder AppendColumnName(SearchParameterQueryGeneratorContext context, Column column, IFieldExpression expression) - { - return AppendColumnName(context, column, expression.ComponentIndex); - } - - protected static IndentedStringBuilder AppendColumnName(SearchParameterQueryGeneratorContext context, Column column, int? componentIndex) - { - return context.StringBuilder.Append(column, context.TableAlias).Append(componentIndex + 1); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParameterQueryGeneratorContext.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParameterQueryGeneratorContext.cs deleted file mode 100644 index aeb51cd5b5..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SearchParameterQueryGeneratorContext.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using EnsureThat; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal readonly struct SearchParameterQueryGeneratorContext - { - internal SearchParameterQueryGeneratorContext(IndentedStringBuilder stringBuilder, HashingSqlQueryParameterManager parameters, ISqlServerFhirModel model, SchemaInformation schemaInformation, bool isAsyncOperation, string tableAlias = null) - { - EnsureArg.IsNotNull(stringBuilder, nameof(stringBuilder)); - EnsureArg.IsNotNull(parameters, nameof(parameters)); - EnsureArg.IsNotNull(model, nameof(model)); - EnsureArg.IsNotNull(schemaInformation, nameof(schemaInformation)); - - StringBuilder = stringBuilder; - Parameters = parameters; - Model = model; - SchemaInformation = schemaInformation; - TableAlias = tableAlias; - IsAsyncOperation = isAsyncOperation; - } - - public IndentedStringBuilder StringBuilder { get; } - - public HashingSqlQueryParameterManager Parameters { get; } - - public ISqlServerFhirModel Model { get; } - - public SchemaInformation SchemaInformation { get; } - - /// - /// Flag for async operations. - /// - public bool IsAsyncOperation { get; } - - public string TableAlias { get; } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs deleted file mode 100644 index fa8247e193..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs +++ /dev/null @@ -1,2158 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using EnsureThat; -using Microsoft.Data.SqlClient; -using Microsoft.Health.Fhir.Api.Features.Filters; -using Microsoft.Health.Fhir.Core.Exceptions; -using Microsoft.Health.Fhir.Core.Features; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.SqlServer; -using Microsoft.Health.SqlServer.Features.Schema; -using Microsoft.Health.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Storage; -using Expression = Microsoft.Health.Fhir.Core.Features.Search.Expressions.Expression; -using SortOrder = Microsoft.Health.Fhir.Core.Features.Search.SortOrder; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class SqlQueryGenerator : DefaultSqlExpressionVisitor - { - // In the case of input search parameter being too complex, there is a possibility of a stack overflow. - // Stack overflow exceptions cannot be caught in .NET and will abort the process. For that reason, we enforce this stack depth limit. - private const int _stackOverflowLimiter = 100; - private int _stackDepth = 0; - - private const string _joinShift = " "; - internal const string ParametersHashStart = "/* HASH "; - internal const string ParametersHashEnd = " */"; - - private string _cteMainSelect; // This is represents the CTE that is the main selector for use with includes - private List _includeCteIds; - private Dictionary> _includeLimitCtesByResourceType; // ctes of each include value, by their resource type - - // Include:iterate may be applied on results from multiple ctes - private List _includeFromCteIds; - - private int _tableExpressionCounter = -1; - private int _smartv2ScopeUnionCTE = -1; - private SqlRootExpression _rootExpression; - private readonly SchemaInformation _schemaInfo; - private bool _sortVisited = false; - private bool _unionVisited = false; - private bool _smartV2UnionVisited = false; - private int _unionAggregateCTEIndex = -1; // the index of the CTE that aggregates all union results - private bool _firstChainAfterUnionVisited = false; - private HashSet _cteToLimit = new HashSet(); - private bool _hasIdentifier = false; - private int _searchParamCount = 0; - private bool previousSqlQueryGeneratorFailure = false; - private int maxTableExpressionCountLimitForExists = 5; - private bool _reuseQueryPlans; - private bool _isAsyncOperation; - private readonly HashSet _searchParamIds = new(); - private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; - - public SqlQueryGenerator( - IndentedStringBuilder sb, - HashingSqlQueryParameterManager parameters, - ISqlServerFhirModel model, - SchemaInformation schemaInfo, - SearchParamTableExpressionQueryGeneratorFactory queryGeneratorFactory, - bool reuseQueryPlans, - bool isAsyncOperation, - SqlException sqlException = null) - { - EnsureArg.IsNotNull(sb, nameof(sb)); - EnsureArg.IsNotNull(parameters, nameof(parameters)); - EnsureArg.IsNotNull(model, nameof(model)); - EnsureArg.IsNotNull(schemaInfo, nameof(schemaInfo)); - EnsureArg.IsNotNull(queryGeneratorFactory, nameof(queryGeneratorFactory)); - - StringBuilder = sb; - Parameters = parameters; - Model = model; - _schemaInfo = schemaInfo; - _queryGeneratorFactory = queryGeneratorFactory; - _reuseQueryPlans = reuseQueryPlans; - _isAsyncOperation = isAsyncOperation; - - if (sqlException?.Number == SqlErrorCodes.QueryProcessorNoQueryPlan) - { - previousSqlQueryGeneratorFailure = true; - } - } - - public HashSet SearchParamIds => _searchParamIds; - - public IndentedStringBuilder StringBuilder { get; } - - public HashingSqlQueryParameterManager Parameters { get; } - - public ISqlServerFhirModel Model { get; } - - public override object VisitSqlRoot(SqlRootExpression expression, SearchOptions context) - { - if (!(context is SearchOptions searchOptions)) - { - throw new ArgumentException($"Argument should be of type {nameof(SearchOptions)}", nameof(context)); - } - - _rootExpression = expression; - - // Fail-closed invariant: when a SMART compartment membership context was attached for this search - // (see SqlServerSearchService.AttachSmartCompartmentMembership), it must still be present on the - // root expression that reaches SQL generation. SmartCompartmentMembership is carried outside the - // visitable expression tree, so a rewrite step that reconstructs SqlRootExpression after the attach - // would silently drop it — and the include CTEs would be generated without compartment - // authorization. Refuse to generate that SQL. This cannot affect non-SMART or system-scope - // searches: IsSmartCompartmentSearch is only set when a membership context was actually attached. - if (context is SqlSearchOptions { IsSmartCompartmentSearch: true } - && expression.SmartCompartmentMembership == null - && expression.SearchParamTableExpressions.Any(t => t.Kind == SearchParamTableExpressionKind.Include)) - { - throw new InvalidOperationException( - "SMART compartment membership context was dropped before SQL generation; refusing to generate _include/_revinclude SQL without compartment authorization."); - } - - var visitedInclude = false; - if (expression.SearchParamTableExpressions.Count > 0) - { - if (expression.ResourceTableExpressions.Count > 0) - { - throw new InvalidOperationException("Expected no predicates on the Resource table because of the presence of TableExpressions"); - } - - // Union expressions must be executed first than all other expressions. The overral idea is that Union All expressions will - // filter the highest group of records, and the following expressions will be executed on top of this group of records. - // If include, split SQL into 2 parts: 1st filter and preserve data in filtered data table variable, and 2nd - use persisted data - StringBuilder.Append("DECLARE @FilteredData AS TABLE (T1 smallint, Sid1 bigint, IsMatch bit, IsPartial bit, Row int"); - var isSortValueNeeded = IsSortValueNeeded(context); - if (isSortValueNeeded) - { - var sortContext = GetSortRelatedDetails(context); - var dbType = sortContext.SortColumnName.Metadata.SqlDbType; - var typeStr = dbType.ToString().ToLowerInvariant(); - StringBuilder.Append($", SortValue {typeStr}"); - if (dbType != System.Data.SqlDbType.DateTime2 && dbType != System.Data.SqlDbType.DateTime) // we support only date time and short string - { - StringBuilder.Append($"({sortContext.SortColumnName.Metadata.MaxLength})"); - } - } - - StringBuilder.AppendLine(")"); - bool hasIncludeExpressions = expression.SearchParamTableExpressions.Any(t => t.Kind == SearchParamTableExpressionKind.Include); - bool hasSmartV2UnionExpressionInTheSet = expression.SearchParamTableExpressions.Any(t => t.HasSmartV2UnionExpression()); - - // Find number of union expressions - int numberOfUnionExpressions = expression.SearchParamTableExpressions.GetCountOfUnionAllExpressions(); - int smartV2TableCounter = 0; - UnionExpression smartV2UnionExpression = null; - SearchParamTableExpressionQueryGenerator smartV2QueryGenerator = null; - StringBuilder.AppendLine(";WITH"); - StringBuilder.AppendDelimited($"{Environment.NewLine},", expression.SearchParamTableExpressions.SortExpressionsByQueryLogic(), (sb, tableExpression) => - { - if (tableExpression.SplitExpressions(out UnionExpression unionExpression, out SearchParamTableExpression allOtherRemainingExpressions)) - { - numberOfUnionExpressions--; - if (tableExpression.HasSmartV2UnionExpression()) - { - // Union expressions for smart v2 scopes with search parameters needs to be handled differently - smartV2TableCounter = _tableExpressionCounter; - smartV2UnionExpression = unionExpression; - smartV2QueryGenerator = tableExpression.QueryGenerator; - - var parametersBeforeSmartScopesAreApplied = Parameters.ParametersToHash; - AppendSmartNewSetOfUnionAllTableExpressions(context, unionExpression, tableExpression.QueryGenerator, false); - - if (hasIncludeExpressions) - { - // For include and revinclude searches we need to mark the parameters added during smart scope union as smart scope parameters - // As we are going to use these parameters to generate a hash for the include filtered data table - MarkNewParametersAsSmartScopeParameter(parametersBeforeSmartScopesAreApplied.ToHashSet()); - } - } - else - { - AppendNewSetOfUnionAllTableExpressions(context, unionExpression, tableExpression.QueryGenerator); - } - - // Keep building the sql the old way when there are other remaining expressions after the union all without smart v2 scopes with search parameters - if ((!hasSmartV2UnionExpressionInTheSet && allOtherRemainingExpressions != null) || (hasSmartV2UnionExpressionInTheSet && allOtherRemainingExpressions != null && numberOfUnionExpressions == 0)) - { - StringBuilder.AppendLine(", "); - AppendNewTableExpression(sb, allOtherRemainingExpressions, ++_tableExpressionCounter, context); - _unionAggregateCTEIndex = _tableExpressionCounter; - } - } - else - { - // Look for include kind. Before going to include itself, add filtered data persistence. - if (!visitedInclude && tableExpression.Kind == SearchParamTableExpressionKind.Include) - { - sb.Remove(sb.Length - 1, 1); // remove last comma - AddParametersHash(); // hash is required in upper SQL - sb.AppendLine($"INSERT INTO @FilteredData SELECT T1, Sid1, IsMatch, IsPartial, Row{(isSortValueNeeded ? ", SortValue " : " ")}FROM cte{_tableExpressionCounter}"); - AddOptionClause(); - - if (_smartV2UnionVisited) - { - // If we have smart v2 scopes with search parameters we need to re-generate the scope - // restricted data set for the include, because the - // include CTEs are emitted in a new ;WITH statement that cannot reference the CTEs above. - sb.AppendLine("OPTION (RECOMPILE)"); - sb.AppendLine($";WITH"); - int saveTableExpressionCounter = _tableExpressionCounter; - _tableExpressionCounter = smartV2TableCounter; - AppendSmartNewSetOfUnionAllTableExpressions(context, smartV2UnionExpression, smartV2QueryGenerator, true); - _tableExpressionCounter = saveTableExpressionCounter; - sb.AppendLine(); - sb.AppendLine($",cte{_tableExpressionCounter} AS (SELECT * FROM @FilteredData)"); - sb.Append(","); // add comma back - } - else - { - sb.AppendLine($";WITH cte{_tableExpressionCounter} AS (SELECT * FROM @FilteredData)"); - sb.Append(","); // add comma back - } - - visitedInclude = true; - } - - AppendNewTableExpression(sb, tableExpression, ++_tableExpressionCounter, context); - } - }); - - StringBuilder.AppendLine(); - } - - if (!visitedInclude) - { - AddParametersHash(); // for include and rev-include we already added hash for all filtering conditions to the filter query - } - else if (visitedInclude && _smartV2UnionVisited) - { - AddParametersHash(true); // for include and rev-include with smart v2 scopes with search parameters add the hash - } - - string resourceTableAlias = "r"; - bool selectingFromResourceTable; - - if (searchOptions.CountOnly) - { - if (expression.SearchParamTableExpressions.Count > 0) - { - // The last CTE has all the surrogate IDs that match the results. - // We just need to count those and don't need to join with the Resource table - selectingFromResourceTable = false; - StringBuilder.AppendLine("SELECT count_big(DISTINCT Sid1)"); - } - else - { - // We will be counting over the Resource table. - selectingFromResourceTable = true; - StringBuilder.AppendLine("SELECT count_big(*)"); - } - } - else - { - selectingFromResourceTable = true; - - // When there are no SearchParamTableExpressions, we need TOP on the outer SELECT (after ORDER BY) - // to ensure pagination works correctly. Previously TOP was in the inner subquery without ORDER BY, - // causing SQL Server to return arbitrary rows before the outer ORDER BY reordered them. - // Fix for pagination bug introduced in commit 6dd540c7d. - if (expression.SearchParamTableExpressions.Count == 0) - { - StringBuilder.Append("SELECT TOP (").Append(Parameters.AddParameter(context.MaxItemCount + 1, includeInHash: false)).Append(") * FROM ("); - } - else - { - StringBuilder.Append("SELECT * FROM ("); - } - - // DISTINCT is used since different ctes may return the same resources due to _include and _include:iterate search parameters - StringBuilder.Append("SELECT DISTINCT "); - - StringBuilder.Append(VLatest.Resource.ResourceTypeId, resourceTableAlias).Append(", ") - .Append(VLatest.Resource.ResourceId, resourceTableAlias).Append(", ") - .Append(VLatest.Resource.Version, resourceTableAlias).Append(", ") - .Append(VLatest.Resource.IsDeleted, resourceTableAlias).Append(", ") - .Append(VLatest.Resource.ResourceSurrogateId, resourceTableAlias).Append(", ") - .Append(VLatest.Resource.RequestMethod, resourceTableAlias).Append(", "); - - // If there's a table expression, use the previously selected bit, otherwise everything in the select is considered a match - StringBuilder.Append(expression.SearchParamTableExpressions.Count > 0 ? "CAST(IsMatch AS bit) AS IsMatch, " : "CAST(1 AS bit) AS IsMatch, "); - StringBuilder.Append(expression.SearchParamTableExpressions.Count > 0 ? "CAST(IsPartial AS bit) AS IsPartial, " : "CAST(0 AS bit) AS IsPartial, "); - - StringBuilder.Append(VLatest.Resource.IsRawResourceMetaSet, resourceTableAlias).Append(", "); - - if (_schemaInfo.Current >= SchemaVersionConstants.SearchParameterHashSchemaVersion) - { - StringBuilder.Append(VLatest.Resource.SearchParamHash, resourceTableAlias).Append(", "); - } - - StringBuilder.Append(VLatest.Resource.RawResource, resourceTableAlias); - - if (IsSortValueNeeded(context) && !context.IsIncludesOperation) - { - StringBuilder.Append(", ").Append(TableExpressionName(_tableExpressionCounter)).Append(".SortValue"); - } - - StringBuilder.AppendLine(); - } - - if (selectingFromResourceTable) - { - if (expression.SearchParamTableExpressions.Count == 0 && - !context.ResourceVersionTypes.HasFlag(ResourceVersionType.History) && - !context.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted) && - expression.ResourceTableExpressions.Any(e => e.AcceptVisitor(ExpressionContainsParameterVisitor.Instance, SearchParameterNames.ResourceType)) && - !expression.ResourceTableExpressions.Any(e => e.AcceptVisitor(ExpressionContainsParameterVisitor.Instance, SearchParameterNames.Id))) - { - StringBuilder.Append("FROM ").Append(VLatest.Resource).Append(" ").Append(resourceTableAlias); - - // If this is a simple search over a resource type (like GET /Observation) - // make sure the optimizer does not decide to do a scan on the clustered index, since we have an index specifically for this common case - StringBuilder.Append(" WITH (INDEX(").Append(VLatest.Resource.IX_Resource_ResourceTypeId_ResourceSurrgateId).AppendLine("))"); - } - else - { - StringBuilder.Append("FROM ").Append(VLatest.Resource).Append(" ").AppendLine(resourceTableAlias); - } - - if (expression.SearchParamTableExpressions.Count > 0) - { - StringBuilder.Append(_joinShift).Append("JOIN ").Append(TableExpressionName(_tableExpressionCounter)); - StringBuilder.Append(" ON ") - .Append(VLatest.Resource.ResourceTypeId, resourceTableAlias).Append(" = ").Append(TableExpressionName(_tableExpressionCounter)).Append(".T1 AND ") - .Append(VLatest.Resource.ResourceSurrogateId, resourceTableAlias).Append(" = ").Append(TableExpressionName(_tableExpressionCounter)).AppendLine(".Sid1"); - } - - using (var delimitedClause = StringBuilder.BeginDelimitedWhereClause()) - { - foreach (var denormalizedPredicate in expression.ResourceTableExpressions) - { - delimitedClause.BeginDelimitedElement(); - denormalizedPredicate.AcceptVisitor(ResourceTableSearchParameterQueryGenerator.Instance, GetContext()); - } - - AppendHistoryClause(delimitedClause, context.ResourceVersionTypes); - - AppendDeletedClause(delimitedClause, context.ResourceVersionTypes); - } - - if (!searchOptions.CountOnly) - { - var orderTableAlias = "t"; - StringBuilder.Append(") AS ").Append(orderTableAlias).Append(" ORDER BY "); - - var hasIncludes = _rootExpression.SearchParamTableExpressions.Any(t => t.Kind == SearchParamTableExpressionKind.Include); - - if (hasIncludes) - { - // ensure the matches appear before includes - StringBuilder.Append("IsMatch DESC, "); - } - - if (IsPrimaryKeySort(searchOptions)) - { - StringBuilder.AppendDelimited(", ", searchOptions.Sort, (sb, sort) => - { - Column column = sort.searchParameterInfo.Name switch - { - SearchParameterNames.ResourceType => VLatest.Resource.ResourceTypeId, - SearchParameterNames.LastUpdated => VLatest.Resource.ResourceSurrogateId, - _ => throw new InvalidOperationException($"Unexpected sort parameter {sort.searchParameterInfo.Name}"), - }; - - if (hasIncludes) - { - // when includes are present, we want to ensure that only matches sorted by the sort field - sb.Append("(CASE WHEN IsMatch = 1 THEN "); - sb.Append(column, orderTableAlias); - sb.Append(" ELSE NULL END) "); - } - else - { - sb.Append(column, orderTableAlias).Append(" "); - } - - sb.Append(sort.sortOrder == SortOrder.Ascending ? "ASC" : "DESC"); - }); - - if (hasIncludes) - { - StringBuilder.Append(", (CASE WHEN IsMatch = 0 THEN ").Append(VLatest.Resource.ResourceTypeId, orderTableAlias).Append(" ELSE NULL END) ASC, "); - StringBuilder.Append("(CASE WHEN IsMatch = 0 THEN ").Append(VLatest.Resource.ResourceSurrogateId, orderTableAlias).Append(" ELSE NULL END) ASC "); - } - - StringBuilder.AppendLine(); - } - else if (IsSortValueNeeded(searchOptions) && !context.IsIncludesOperation) - { - if (hasIncludes) - { - StringBuilder - .Append("(CASE WHEN IsMatch = 1 THEN ") - .Append(orderTableAlias) - .Append(".SortValue ELSE NULL END) "); - } - else - { - StringBuilder - .Append(orderTableAlias) - .Append(".SortValue "); - } - - StringBuilder - .Append(searchOptions.Sort[0].sortOrder == SortOrder.Ascending ? "ASC" : "DESC").Append(", ") - .Append(VLatest.Resource.ResourceTypeId, orderTableAlias).Append(" ASC, ") - .Append(VLatest.Resource.ResourceSurrogateId, orderTableAlias).AppendLine(" ASC "); - } - else - { - StringBuilder - .Append(VLatest.Resource.ResourceTypeId, orderTableAlias).Append(" ASC, ") - .Append(VLatest.Resource.ResourceSurrogateId, orderTableAlias).AppendLine(" ASC "); - } - - AddOptionClause(); - } - } - else - { - // this is selecting only from the last CTE (for a count) - StringBuilder.Append("FROM ").AppendLine(TableExpressionName(_tableExpressionCounter)); - } - - return null; - } - - // TODO: Remove when code starts using TokenSearchParamHighCard table - private void AddOptionClause() - { - // if we have a complex query more than one SearchParemter, one of the parameters is "identifier", and we have an include - // then we will tell SQL to ignore the parameter values and base the query plan one the - // statistics only. We have seen SQL make poor choices in this instance, so we are making a special case here - if (AddOptimizeForUnknownClause()) - { - StringBuilder.AppendLine("OPTION (OPTIMIZE FOR UNKNOWN)"); - } - } - - private void AddParametersHash(bool forSmartV2Include = false) - { - foreach (var searchParamId in Parameters.SearchParamIds) - { - _searchParamIds.Add(searchParamId); - } - - if (Parameters.HasParametersToHash && !_reuseQueryPlans) // hash cannot be last comment as it will not be stored in query store - { - // Add a hash of (most of the) parameter values as a comment. - // We do this to avoid re-using query plans unless two queries have - // the same parameter values. We currently exclude from the hash parameters - // that are related to TOP clauses or continuation tokens. - // We can exclude more in the future. - - StringBuilder.Append(ParametersHashStart); - if (forSmartV2Include) - { - // Only add the hash for smart scope parameters - Parameters.AppendSmartScopeHash(StringBuilder); - Parameters.AppendSmartScopeParameterNames(StringBuilder); - } - else - { - Parameters.AppendHash(StringBuilder); - Parameters.AppendHashedParameterNames(StringBuilder); - } - - StringBuilder.Append(ParametersHashEnd); - } - - StringBuilder.AppendLine(); // do not include EOL into parameters hash line to get same behavior on Windows and Linux - } - - /// - /// Marks parameters that were added after a specific point in time as SMART scope parameters. - /// - /// The set of parameters that existed before the operation. - /// List of new parameters that were added and marked as SMART scope parameters. - private List MarkNewParametersAsSmartScopeParameter(HashSet parametersBefore) - { - var parametersAfter = new HashSet(Parameters.ParametersToHash); - var newParameters = parametersAfter.Except(parametersBefore).ToList(); - - if (newParameters.Any()) - { - foreach (var param in newParameters) - { - Parameters.MarkAsSmartScopeParameter(param); - } - } - - return newParameters; - } - - private static string TableExpressionName(int id) => "cte" + id; - - private bool IsInSortMode(SearchOptions context) => context.Sort != null && context.Sort.Count > 0 && _sortVisited; - - public override object VisitTable(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - try - { - _stackDepth++; - if (_stackDepth > _stackOverflowLimiter) - { - throw new SearchParameterTooComplexException(); - } - - const string referenceSourceTableAlias = "refSource"; - const string referenceTargetResourceTableAlias = "refTarget"; - - switch (searchParamTableExpression.Kind) - { - case SearchParamTableExpressionKind.Normal: - HandleTableKindNormal(searchParamTableExpression, context); - break; - - case SearchParamTableExpressionKind.Concatenation: - StringBuilder.Append("SELECT * FROM ").AppendLine(TableExpressionName(_tableExpressionCounter - 1)); - StringBuilder.AppendLine("UNION ALL"); - - goto case SearchParamTableExpressionKind.Normal; - - case SearchParamTableExpressionKind.All: - HandleTableKindAll(searchParamTableExpression, context); - break; - - case SearchParamTableExpressionKind.NotExists: - HandleTableKindNotExists(searchParamTableExpression, context); - break; - - case SearchParamTableExpressionKind.Top: - HandleTableKindTop(context); - break; - - case SearchParamTableExpressionKind.Chain: - HandleTableKindChain(searchParamTableExpression, context, referenceSourceTableAlias, referenceTargetResourceTableAlias); - break; - - case SearchParamTableExpressionKind.Include: - HandleTableKindInclude(searchParamTableExpression, context, referenceSourceTableAlias, referenceTargetResourceTableAlias); - break; - - case SearchParamTableExpressionKind.IncludeLimit: - HandleTableKindIncludeLimit(context); - break; - - case SearchParamTableExpressionKind.IncludeUnionAll: - HandleTableKindIncludeUnionAll(context); - break; - - case SearchParamTableExpressionKind.Sort: - HandleTableKindSort(searchParamTableExpression, context); - break; - - case SearchParamTableExpressionKind.SortWithFilter: - HandleTableKindSortWithFilter(searchParamTableExpression, context); - break; - - case SearchParamTableExpressionKind.Union: - HandleParamTableUnion(searchParamTableExpression, context); - break; - - default: - throw new ArgumentOutOfRangeException(searchParamTableExpression.Kind.ToString()); - } - } - finally - { - _stackDepth--; - } - - return null; - } - - private void HandleParamTableUnion(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - var specialCaseTableName = searchParamTableExpression.QueryGenerator.Table; - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); - - using (StringBuilder.Indent()) - { - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid1"); - - var searchParameterExpressionPredicate = searchParamTableExpression.Predicate as SearchParameterExpression; - - // handle special case where we want to Union a specific resource to the results - if (searchParameterExpressionPredicate != null && - searchParameterExpressionPredicate.Parameter.ColumnLocation().HasFlag(SearchParameterColumnLocation.ResourceTable)) - { - specialCaseTableName = VLatest.Resource; - StringBuilder.Append("FROM ").AppendLine(specialCaseTableName); - } - else - { - // For Smart union expression, searchParamTableExpression.Predicate could be a multiary expression and not SearchParameterExpression - // To retrieve the main compartment resource we are building the Multiary expression with ResourceTypeId AND ResourceId (SearchParameterExpression) - // Check if its a Multiary expression, if yes then check the internal expressions are SearchParameterExpression of parameter _type and _id - // If yes then we can set the specialCaseTableName to Resource table and not to searchParamTableExpression.QueryGenerator.Table which will mostly be a ReferenceSearchParamTable - if (searchParamTableExpression.Predicate is MultiaryExpression multiaryExpression) - { - bool allAreResourceTypeOrId = multiaryExpression.Expressions.All(e => - e is SearchParameterExpression spe && - (spe.Parameter.Name == SearchParameterNames.ResourceType || spe.Parameter.Name == SearchParameterNames.Id)); - - if (allAreResourceTypeOrId) - { - specialCaseTableName = VLatest.Resource; - } - } - - StringBuilder.Append("FROM ").AppendLine(specialCaseTableName); - } - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - // Apply History and Delete clause when querying from Resource table in case of compartment unions - AppendHistoryClause(delimited, context.ResourceVersionTypes, searchParamTableExpression, null, specialCaseTableName); - - if (specialCaseTableName.Equals(VLatest.Resource)) - { - AppendDeletedClause(delimited, context.ResourceVersionTypes); - } - - if (searchParamTableExpression.Predicate != null && !(searchParamTableExpression.Predicate is CompartmentSearchExpression)) - { - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(searchParamTableExpression.QueryGenerator, GetContext()); - } - } - } - - StringBuilder.AppendLine("),"); - } - - private void HandleTableKindNormal(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - var specialCaseTableName = searchParamTableExpression.QueryGenerator.Table; - - if (searchParamTableExpression.ChainLevel == 0) - { - int predecessorIndex = FindRestrictingPredecessorTableExpressionIndex(); - - // if this is not sort mode or if it is the first cte - if (!IsInSortMode(context) || predecessorIndex < 0) - { - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid1") - .Append("FROM ").AppendLine(searchParamTableExpression.QueryGenerator.Table); - } - else - { - // we are in sort mode and we need to join with previous cte to propagate the SortValue - var cte = TableExpressionName(predecessorIndex); - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).Append(" AS Sid1, ") - .Append(cte).AppendLine(".SortValue") - .Append("FROM ").AppendLine(searchParamTableExpression.QueryGenerator.Table) - .Append(_joinShift).Append("JOIN ").Append(cte) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, null).Append(" = ").Append(cte).Append(".T1") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, null).Append(" = ").Append(cte).AppendLine(".Sid1"); - } - } - else if (searchParamTableExpression.ChainLevel == 1 && _unionVisited) - { - // handle special case where we want to Union a specific resource to the results - var searchParameterExpressionPredicate = CheckExpressionOrFirstChildIsSearchParam(searchParamTableExpression.Predicate); - if (searchParameterExpressionPredicate != null && - searchParameterExpressionPredicate.Parameter.ColumnLocation().HasFlag(SearchParameterColumnLocation.ResourceTable)) - { - specialCaseTableName = new VLatest.ResourceTable(); - } - - StringBuilder.Append("SELECT T1, Sid1, ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T2, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid2") - .Append("FROM ").AppendLine(specialCaseTableName) - .Append(_joinShift).Append("JOIN ").Append(TableExpressionName(FindRestrictingPredecessorTableExpressionIndex())) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, null).Append(" = ").Append(_firstChainAfterUnionVisited ? "T2" : "T1") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, null).Append(" = ").AppendLine(_firstChainAfterUnionVisited ? "Sid2" : "Sid1"); - - // once we have visited a table after the union all, the remained of the inner joins - // should be on T1 and Sid1 - _firstChainAfterUnionVisited = true; - } - else - { - StringBuilder.Append("SELECT T1, Sid1, ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T2, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid2") - .Append("FROM ").AppendLine(searchParamTableExpression.QueryGenerator.Table) - .Append(_joinShift).Append("JOIN ").Append(TableExpressionName(FindRestrictingPredecessorTableExpressionIndex())) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, null).Append(" = ").Append("T2") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, null).Append(" = ").AppendLine("Sid2"); - } - - if (UseAppendWithJoin() - && searchParamTableExpression.ChainLevel == 0 && !IsInSortMode(context) && !context.SkipAppendIntersectionWithPredecessor) - { - AppendIntersectionWithPredecessorUsingInnerJoin(StringBuilder, searchParamTableExpression); - } - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - AppendHistoryClause(delimited, context.ResourceVersionTypes, searchParamTableExpression, null, specialCaseTableName); - - // For smart request when we have union of all scopes ANDed with their respective search parameters - // Like (ResourceType = x and searchParam1 = foo) Intersect (ResourceType = x and searchParam2 = doo) UNION (ResourceType = y and searchParam3 = goo) Intersect (ResourceType = y and searchParam4 = woo) - // To get the intersection we need to AppendIntersectionWithPredecessor - if (searchParamTableExpression.ChainLevel == 0 && !IsInSortMode(context) && !UseAppendWithJoin()) - { - if (!context.SkipAppendIntersectionWithPredecessor) - { - // if chainLevel > 0 or if in sort mode or if we need to simplify the query, the intersection is already handled in a JOIN - AppendIntersectionWithPredecessor(delimited, searchParamTableExpression); - } - } - - if (searchParamTableExpression.Predicate != null) - { - delimited.BeginDelimitedElement(); - CheckForIdentifierSearchParams(searchParamTableExpression.Predicate); - searchParamTableExpression.Predicate.AcceptVisitor(searchParamTableExpression.QueryGenerator, GetContext()); - } - } - } - - private void HandleTableKindAll(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - int predecessorIndex = FindRestrictingPredecessorTableExpressionIndex(); - - // In the case the query contains a UNION operator, the following CTE must join the latest Union CTE - // where all data is aggregated. - if (_unionVisited && predecessorIndex > 0 && searchParamTableExpression.ChainLevel == 0) - { - var cte = TableExpressionName(predecessorIndex); - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid1") // SELECT and FROM can be on same line only for singe line statements - .Append("FROM ").AppendLine(VLatest.Resource) - .Append(_joinShift).Append("JOIN ").Append(cte) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, null).Append(" = ").Append(cte).Append(".T1") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, null).Append(" = ").Append(cte).AppendLine(".Sid1"); - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - AppendHistoryClause(delimited, context.ResourceVersionTypes); - AppendDeletedClause(delimited, context.ResourceVersionTypes); - if (searchParamTableExpression.Predicate != null) - { - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(ResourceTableSearchParameterQueryGenerator.Instance, GetContext()); - } - } - } - else - { - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid1") - .Append("FROM ").AppendLine(VLatest.Resource); - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - AppendHistoryClause(delimited, context.ResourceVersionTypes); - AppendDeletedClause(delimited, context.ResourceVersionTypes); - if (searchParamTableExpression.Predicate != null) - { - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(ResourceTableSearchParameterQueryGenerator.Instance, GetContext()); - } - } - } - } - - private void HandleTableKindNotExists(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - StringBuilder.Append("SELECT T1, Sid1"); - StringBuilder.AppendLine(IsInSortMode(context) ? ", SortValue" : string.Empty); - StringBuilder.Append("FROM ").AppendLine(TableExpressionName(_tableExpressionCounter - 1)); - StringBuilder.AppendLine("WHERE Sid1 NOT IN").AppendLine("("); - - using (StringBuilder.Indent()) - { - StringBuilder.Append("SELECT ").AppendLine(VLatest.Resource.ResourceSurrogateId, null) - .Append("FROM ").AppendLine(searchParamTableExpression.QueryGenerator.Table); - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - AppendHistoryClause(delimited, context.ResourceVersionTypes, searchParamTableExpression); - - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(searchParamTableExpression.QueryGenerator, GetContext()); - } - } - - StringBuilder.AppendLine(")"); - } - - private void HandleTableKindTop(SearchOptions context) - { - var tableExpressionName = TableExpressionName(_tableExpressionCounter - 1); - var sortExpression = IsSortValueNeeded(context) ? $"{tableExpressionName}.SortValue" : null; - - bool hasIncludeExpression = _rootExpression.SearchParamTableExpressions.Any(t => t.Kind == SearchParamTableExpressionKind.Include); - - IndentedStringBuilder.IndentedScope indentedScope = default; - if (hasIncludeExpression) - { - // a subsequent _include will need to join with the top context.MaxItemCount of this resultset, so we include a Row column - StringBuilder.Append("SELECT row_number() OVER ("); - AppendOrderBy(); - StringBuilder.AppendLine(") AS Row, *") - .AppendLine("FROM") - .AppendLine("("); - - indentedScope = StringBuilder.Indent(); - } - - // Everything in the top expression is considered a match - const string selectStatement = "SELECT DISTINCT"; - StringBuilder.Append(selectStatement).Append(" TOP (").Append(Parameters.AddParameter(context.MaxItemCount + 1, includeInHash: false)).Append(") T1, Sid1, 1 AS IsMatch, 0 AS IsPartial ") - .AppendLine(sortExpression == null ? string.Empty : $", {sortExpression}") - .Append("FROM ").AppendLine(tableExpressionName); - - AppendOrderBy(); - StringBuilder.AppendLine(); - - if (hasIncludeExpression) - { - indentedScope.Dispose(); - StringBuilder.AppendLine(") t"); - } - - // For any includes, the source of the resource surrogate ids to join on is saved - _cteMainSelect = TableExpressionName(_tableExpressionCounter); - - void AppendOrderBy() - { - StringBuilder.Append("ORDER BY "); - if (IsPrimaryKeySort(context)) - { - StringBuilder.AppendDelimited(", ", context.Sort, (sb, sort) => - { - string column = sort.searchParameterInfo.Name switch - { - SearchParameterNames.ResourceType => "T1", - SearchParameterNames.LastUpdated => "Sid1", - _ => throw new InvalidOperationException($"Unexpected sort parameter {sort.searchParameterInfo.Name}"), - }; - sb.Append(column).Append(" ").Append(sort.sortOrder == SortOrder.Ascending ? "ASC" : "DESC"); - }); - } - else if (IsSortValueNeeded(context)) - { - StringBuilder.Append("SortValue ").Append(" ").Append(context.Sort[0].sortOrder == SortOrder.Ascending ? "ASC" : "DESC").Append(", Sid1 ASC"); - } - else - { - StringBuilder.Append("Sid1 ASC"); - } - } - } - - private void HandleTableKindChain( - SearchParamTableExpression searchParamTableExpression, - SearchOptions context, - string referenceSourceTableAlias, - string referenceTargetResourceTableAlias) - { - var chainedExpression = (SqlChainLinkExpression)searchParamTableExpression.Predicate; - StringBuilder.Append("SELECT "); - if (searchParamTableExpression.ChainLevel == 1) - { - StringBuilder.Append(VLatest.ReferenceSearchParam.ResourceTypeId, referenceSourceTableAlias).Append(" AS ").Append(chainedExpression.Reversed ? "T2" : "T1").Append(", "); - StringBuilder.Append(VLatest.ReferenceSearchParam.ResourceSurrogateId, referenceSourceTableAlias).Append(" AS ").Append(chainedExpression.Reversed ? "Sid2" : "Sid1").Append(", "); - } - else - { - StringBuilder.Append("T1, Sid1, "); - } - - StringBuilder - .Append(VLatest.Resource.ResourceTypeId, chainedExpression.Reversed && searchParamTableExpression.ChainLevel > 1 ? referenceSourceTableAlias : referenceTargetResourceTableAlias).Append(" AS ").Append(chainedExpression.Reversed && searchParamTableExpression.ChainLevel == 1 ? "T1, " : "T2, ") - .Append(VLatest.Resource.ResourceSurrogateId, chainedExpression.Reversed && searchParamTableExpression.ChainLevel > 1 ? referenceSourceTableAlias : referenceTargetResourceTableAlias).Append(" AS ").AppendLine(chainedExpression.Reversed && searchParamTableExpression.ChainLevel == 1 ? "Sid1 " : "Sid2 ") - .Append("FROM ").Append(VLatest.ReferenceSearchParam).Append(' ').AppendLine(referenceSourceTableAlias) - .Append(_joinShift).Append("JOIN ").Append(VLatest.Resource).Append(' ').Append(referenceTargetResourceTableAlias) - .Append(" ON ").Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias).Append(" = ").Append(VLatest.Resource.ResourceTypeId, referenceTargetResourceTableAlias) - .Append(" AND ").Append(VLatest.ReferenceSearchParam.ReferenceResourceId, referenceSourceTableAlias).Append(" = ").AppendLine(VLatest.Resource.ResourceId, referenceTargetResourceTableAlias); - - // For reverse chaining, if there is a parameter on the _id search parameter, we need another join to get the resource ID of the reference source (all we have is the surrogate ID at this point) - bool expressionOnTargetHandledBySecondJoin = chainedExpression.ExpressionOnTarget != null && chainedExpression.Reversed && chainedExpression.ExpressionOnTarget.AcceptVisitor(ExpressionContainsParameterVisitor.Instance, SearchParameterNames.Id); - if (expressionOnTargetHandledBySecondJoin) - { - const string referenceSourceResourceTableAlias = "refSourceResource"; - StringBuilder.Append(_joinShift).Append("JOIN ").Append(VLatest.Resource).Append(' ').Append(referenceSourceResourceTableAlias) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, referenceSourceTableAlias).Append(" = ").Append(VLatest.Resource.ResourceTypeId, referenceSourceResourceTableAlias) - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, referenceSourceTableAlias).Append(" = ").Append(VLatest.Resource.ResourceSurrogateId, referenceSourceResourceTableAlias) - .Append(" AND "); - chainedExpression.ExpressionOnTarget.AcceptVisitor(ResourceTableSearchParameterQueryGenerator.Instance, GetContext(referenceSourceResourceTableAlias)); - StringBuilder.AppendLine(); - } - - if (searchParamTableExpression.ChainLevel > 1) - { - StringBuilder.Append(_joinShift).Append("JOIN ").Append(TableExpressionName(FindRestrictingPredecessorTableExpressionIndex())) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, chainedExpression.Reversed ? referenceTargetResourceTableAlias : referenceSourceTableAlias).Append(" = ").Append("T2") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, chainedExpression.Reversed ? referenceTargetResourceTableAlias : referenceSourceTableAlias).Append(" = ").AppendLine("Sid2"); - } - - // since we are in chain table expression, we know the Table is the ReferenceSearchParam table - else if (UseAppendWithJoin()) - { - AppendIntersectionWithPredecessorUsingInnerJoin(StringBuilder, searchParamTableExpression, chainedExpression.Reversed ? referenceTargetResourceTableAlias : referenceSourceTableAlias); - } - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.SearchParamId, referenceSourceTableAlias) - .Append(" = ").Append(Parameters.AddParameter(VLatest.ReferenceSearchParam.SearchParamId, Model.GetSearchParamId(chainedExpression.ReferenceSearchParameter.Url), true)); - - // We should remove IsHistory from ReferenceSearchParam (Source) only but keep on Resource (Target) - AppendHistoryClause(delimited, context.ResourceVersionTypes, null, referenceTargetResourceTableAlias); - AppendDeletedClause(delimited, context.ResourceVersionTypes, referenceTargetResourceTableAlias); - - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ResourceTypeId, referenceSourceTableAlias) - .Append(" IN (") - .Append(string.Join(", ", chainedExpression.ResourceTypes.Select(x => Parameters.AddParameter(VLatest.ReferenceSearchParam.ResourceTypeId, Model.GetResourceTypeId(x), true)))) - .Append(")"); - - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias) - .Append(" IN (") - .Append(string.Join(", ", chainedExpression.TargetResourceTypes.Select(x => Parameters.AddParameter(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, Model.GetResourceTypeId(x), true)))) - .Append(")"); - - if (searchParamTableExpression.ChainLevel == 1 && !UseAppendWithJoin()) - { - // if > 1, the intersection is handled by the JOIN - AppendIntersectionWithPredecessor(delimited, searchParamTableExpression, chainedExpression.Reversed ? referenceTargetResourceTableAlias : referenceSourceTableAlias); - } - - if (chainedExpression.ExpressionOnTarget != null && !expressionOnTargetHandledBySecondJoin) - { - delimited.BeginDelimitedElement(); - chainedExpression.ExpressionOnTarget.AcceptVisitor(ResourceTableSearchParameterQueryGenerator.Instance, GetContext(chainedExpression.Reversed ? referenceSourceTableAlias : referenceTargetResourceTableAlias)); - } - - if (chainedExpression.ExpressionOnSource != null) - { - delimited.BeginDelimitedElement(); - chainedExpression.ExpressionOnSource.AcceptVisitor(ResourceTableSearchParameterQueryGenerator.Instance, GetContext(chainedExpression.Reversed ? referenceTargetResourceTableAlias : referenceSourceTableAlias)); - } - } - } - - private void HandleTableKindInclude( - SearchParamTableExpression searchParamTableExpression, - SearchOptions context, - string referenceSourceTableAlias, - string referenceTargetResourceTableAlias) - { - var includeExpression = (IncludeExpression)searchParamTableExpression.Predicate; - _includeCteIds = _includeCteIds ?? new List(); - _includeLimitCtesByResourceType = _includeLimitCtesByResourceType ?? new Dictionary>(); - _includeFromCteIds = _includeFromCteIds ?? new List(); - - StringBuilder.Append("SELECT DISTINCT "); - - // Adding 1 to the include count for detecting a case of truncated "include" resources. - StringBuilder.Append("TOP (").Append(Parameters.AddParameter(context.IncludeCount + 1, includeInHash: false)).Append(") "); - - var table = !includeExpression.Reversed ? referenceTargetResourceTableAlias : referenceSourceTableAlias; - - StringBuilder.Append(VLatest.Resource.ResourceTypeId, table).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, table); - - // Always project IsPartial to maintain consistent column count across UNION branches - StringBuilder.AppendLine(" AS Sid1, 0 AS IsMatch, 0 AS IsPartial "); - - StringBuilder.Append("FROM ").Append(VLatest.ReferenceSearchParam).Append(' ').AppendLine(referenceSourceTableAlias) - .Append(_joinShift).Append("JOIN ").Append(VLatest.Resource).Append(' ').Append(referenceTargetResourceTableAlias) - .Append(" ON ").Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias).Append(" = ").Append(VLatest.Resource.ResourceTypeId, referenceTargetResourceTableAlias) - .Append(" AND ").Append(VLatest.ReferenceSearchParam.ReferenceResourceId, referenceSourceTableAlias).Append(" = ").AppendLine(VLatest.Resource.ResourceId, referenceTargetResourceTableAlias); - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - // Smart V2 with SearchParam has a special handling for references resources - if (!_smartV2UnionVisited) - { - if (!includeExpression.WildCard) - { - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.SearchParamId, referenceSourceTableAlias) - .Append(" = ").Append(Parameters.AddParameter(VLatest.ReferenceSearchParam.SearchParamId, Model.GetSearchParamId(includeExpression.ReferenceSearchParameter.Url), true)); - - if (includeExpression.TargetResourceType != null) - { - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias) - .Append(" = ").Append(Parameters.AddParameter(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, Model.GetResourceTypeId(includeExpression.TargetResourceType), true)); - } - else if (includeExpression.AllowedResourceTypesByScope != null && - !includeExpression.AllowedResourceTypesByScope.Contains(KnownResourceTypes.All)) - { - // AllowedResourceTypesByScope - types allowed by SMART scopes on this request - // If the list contains "All", then we don't add a filter - // Restrict the reference resource types that are returned to the allowed types by scope - // For revinclude that would be ReferenceSearchParam.ResourceTypeId (Resource type that referes the target) - // For include that would be ReferenceSearchParam.ReferenceResourceTypeId (Resource type that is refered by the source) - // Smart V2 with SP has a special handling for references resources - if (!includeExpression.Reversed) - { - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias) - .Append(" IN (") - .Append(string.Join(", ", includeExpression.AllowedResourceTypesByScope.Select(x => Parameters.AddParameter(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, Model.GetResourceTypeId(x), true)))) - .Append(")"); - } - else - { - // For _revinclude we need to filter on ResourceTypeId (the resource type that contains the reference) - // Example: /Patient?_revinclude=*:* and scope Patient/Patient and Patient/Encounter - // In this case, we need to filter the resources referring Patient by the allowed types by scope - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ResourceTypeId, referenceSourceTableAlias) - .Append(" IN (") - .Append(string.Join(", ", includeExpression.AllowedResourceTypesByScope.Select(x => Parameters.AddParameter(VLatest.ReferenceSearchParam.ResourceTypeId, Model.GetResourceTypeId(x), true)))) - .Append(")"); - } - } - } - else if (includeExpression.WildCard && includeExpression.AllowedResourceTypesByScope != null && - !includeExpression.AllowedResourceTypesByScope.Contains(KnownResourceTypes.All)) - { - // AllowedResourceTypesByScope - types allowed by SMART scopes on this request - // If the list contains "All", then we don't add a filter - // Restrict the reference resource types that are returned to the allowed types by scope - // For revinclude that would be ReferenceSearchParam.ResourceTypeId (Resource type that referes the target) - // For include that would be ReferenceSearchParam.ReferenceResourceTypeId (Resource type that is refered by the source) - if (!includeExpression.Reversed) - { - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias) - .Append(" IN (") - .Append(string.Join(", ", includeExpression.AllowedResourceTypesByScope.Select(x => Parameters.AddParameter(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, Model.GetResourceTypeId(x), true)))) - .Append(")"); - } - else - { - // For _revinclude we need to filter on ResourceTypeId (the resource type that contains the reference) - // Example: /Patient?_revinclude=*:* and scope Patient/Patient and Patient/Encounter - // In this case, we need to filter the resources referring Patient by the allowed types by scope - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ResourceTypeId, referenceSourceTableAlias) - .Append(" IN (") - .Append(string.Join(", ", includeExpression.AllowedResourceTypesByScope.Select(x => Parameters.AddParameter(VLatest.ReferenceSearchParam.ResourceTypeId, Model.GetResourceTypeId(x), true)))) - .Append(")"); - } - } - } - - // We should remove IsHistory from ReferenceSearchParam (Source) only but keep on Resource (Target) - AppendHistoryClause(delimited, context.ResourceVersionTypes, null, referenceTargetResourceTableAlias); - - AppendDeletedClause(delimited, context.ResourceVersionTypes, referenceTargetResourceTableAlias); - - table = !includeExpression.Reversed ? referenceSourceTableAlias : referenceTargetResourceTableAlias; - - // For RevIncludeIterate we expect to have a TargetType specified if the target reference can be of multiple types - var resourceTypeIds = includeExpression.ResourceTypes.Select(x => Model.GetResourceTypeId(x)).ToArray(); - if (includeExpression.Reversed && includeExpression.Iterate) - { - if (includeExpression.TargetResourceType != null) - { - resourceTypeIds = new[] { Model.GetResourceTypeId(includeExpression.TargetResourceType) }; - } - else if (includeExpression.ReferenceSearchParameter?.TargetResourceTypes?.Count > 0) - { - resourceTypeIds = new[] { Model.GetResourceTypeId(includeExpression.ReferenceSearchParameter.TargetResourceTypes.ToList().First()) }; - } - } - - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ResourceTypeId, table) - .Append(" IN (") - .Append(string.Join(", ", resourceTypeIds)) - .Append(")"); - - // Get FROM ctes - List fromCte = new List(); - fromCte.Add(_cteMainSelect); - - if (includeExpression.Iterate) - { - // Include Iterate - if (!includeExpression.Reversed) - { - // _include:iterate may appear without a preceding _include, in case of circular reference - // On that case, the fromCte is _cteMainSelect - if (TryGetIncludeCtes(includeExpression.SourceResourceType, out _includeFromCteIds)) - { - fromCte = _includeFromCteIds; - } - } - - // RevInclude Iterate - else - { - if (includeExpression.TargetResourceType != null) - { - if (TryGetIncludeCtes(includeExpression.TargetResourceType, out _includeFromCteIds)) - { - fromCte = _includeFromCteIds; - } - } - else if (includeExpression.ReferenceSearchParameter?.TargetResourceTypes != null) - { - // Assumes TargetResourceTypes is of length 1. Otherwise, a BadRequest would have been thrown earlier for _revinclude:iterate - List fromCtes; - var targetType = includeExpression.ReferenceSearchParameter.TargetResourceTypes[0]; - - if (TryGetIncludeCtes(targetType, out fromCtes)) - { - _includeFromCteIds.AddRange(fromCtes); - } - - _includeFromCteIds = _includeFromCteIds.Distinct().ToList(); - fromCte = _includeFromCteIds.Count > 0 ? _includeFromCteIds : fromCte; - } - } - } - - var includesContinuationToken = IncludesContinuationToken.FromString(context.IncludesContinuationToken); - if (!context.IsIncludesOperation || includesContinuationToken?.IncludeResourceTypeId == null || includesContinuationToken?.IncludeResourceSurrogateId == null) - { - if (includeExpression.Reversed && includeExpression.SourceResourceType != "*") - { - delimited.BeginDelimitedElement().Append(VLatest.ReferenceSearchParam.ResourceTypeId, referenceSourceTableAlias) - .Append(" = ").Append(Parameters.AddParameter(VLatest.ReferenceSearchParam.ResourceTypeId, Model.GetResourceTypeId(includeExpression.SourceResourceType), true)); - } - } - else - { - var tableAlias = includeExpression.Reversed ? referenceSourceTableAlias : referenceTargetResourceTableAlias; - delimited.BeginDelimitedElement() - .Append("(") - .Append(VLatest.Resource.ResourceTypeId, tableAlias) - .Append(" > ") - .Append(includesContinuationToken.IncludeResourceTypeId) - .Append(" OR (") - .Append(VLatest.Resource.ResourceTypeId, tableAlias) - .Append(" = ") - .Append(includesContinuationToken.IncludeResourceTypeId) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ResourceSurrogateId, tableAlias) - .Append(" > ") - .Append(includesContinuationToken.IncludeResourceSurrogateId) - .Append("))"); - } - - var scope = delimited.BeginDelimitedElement(); - scope.Append("EXISTS ("); - for (var index = 0; index < fromCte.Count; index++) - { - var cte = fromCte[index]; - scope.Append("SELECT * FROM ").Append(cte) - .Append(" WHERE ").Append(VLatest.Resource.ResourceTypeId, table).Append(" = T1 AND ") - .Append(VLatest.Resource.ResourceSurrogateId, table).Append(" = Sid1"); - - if (!includeExpression.Iterate && !context.IsIncludesOperation) - { - // Limit the join to the main select CTE. - // The main select will have max+1 items in the result set to account for paging, so we only want to join using the max amount. - - scope.Append(" AND Row < ").Append(Parameters.AddParameter(context.MaxItemCount + 1, true)); - } - - if (index < fromCte.Count - 1) - { - scope.AppendLine(" UNION ALL "); - } - } - - scope.Append(")"); - - if (includeExpression.AllowedResourceTypesByScope != null && !includeExpression.AllowedResourceTypesByScope.Contains(KnownResourceTypes.All) && _smartV2UnionVisited) - { - if (!includeExpression.Reversed) - { - var scopeForSmartV2 = delimited.BeginDelimitedElement(); - scopeForSmartV2.Append("EXISTS ("); - scopeForSmartV2.Append("SELECT * FROM "); - scopeForSmartV2.Append(TableExpressionName(_smartv2ScopeUnionCTE)) - .Append(" WHERE ").Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, referenceSourceTableAlias).Append(" = T1 AND ") - .Append(VLatest.Resource.ResourceSurrogateId, referenceTargetResourceTableAlias).Append(" = Sid1)"); - } - else - { - var scopeForSmartV2 = delimited.BeginDelimitedElement(); - scopeForSmartV2.Append("EXISTS ("); - scopeForSmartV2.Append("SELECT * FROM "); - scopeForSmartV2.Append(TableExpressionName(_smartv2ScopeUnionCTE)) - .Append(" WHERE ").Append(VLatest.ReferenceSearchParam.ResourceTypeId, referenceSourceTableAlias).Append(" = T1 AND ") - .Append(VLatest.ReferenceSearchParam.ResourceSurrogateId, referenceSourceTableAlias).Append(" = Sid1)"); - } - } - - if (_rootExpression.SmartCompartmentMembership != null) - { - AppendSmartCompartmentCandidatePredicate( - delimited.BeginDelimitedElement(), - includeExpression.Reversed ? referenceSourceTableAlias : referenceTargetResourceTableAlias, - candidateIsResourceTable: !includeExpression.Reversed); - } - } - - if (context.IsIncludesOperation) - { - StringBuilder.AppendLine("ORDER BY T1 ASC, Sid1 ASC"); - _includeCteIds.Add(TableExpressionName(_tableExpressionCounter)); - } - - if (includeExpression.Reversed) - { - // mark that this cte is a reverse one, meaning we need to add another items limitation - // cte on top of it - _cteToLimit.Add(_tableExpressionCounter); - } - - // Update target reference cte dictionary - var curLimitCte = TableExpressionName(_tableExpressionCounter + 1); - - // Take the count before AddIncludeLimitCte because _includeFromCteIds?.Count will be incremented differently depending on the resource type. - int count = _includeFromCteIds?.Count ?? 0; - - // Add current cte limit to the dictionary - if (includeExpression.Reversed) - { - AddIncludeLimitCte(includeExpression.SourceResourceType, curLimitCte); - } - else - { - // Not reversed and a specific target type is provided as the 3rd part of include value - if (includeExpression.TargetResourceType != null) - { - AddIncludeLimitCte(includeExpression.TargetResourceType, curLimitCte); - } - else if (includeExpression.ReferenceSearchParameter != null) - { - includeExpression.ReferenceSearchParameter.TargetResourceTypes?.ToList().ForEach(t => AddIncludeLimitCte(t, curLimitCte)); - } - } - - if (includeExpression.WildCard) - { - includeExpression.ReferencedTypes?.ToList().ForEach(t => AddIncludeLimitCte(t, curLimitCte)); - } - } - - private void AppendSmartCompartmentCandidatePredicate( - IndentedStringBuilder scope, - string candidateTableAlias, - bool candidateIsResourceTable) - { - const string membershipAlias = "smartCompartmentMembership"; - const string rootAlias = "smartCompartmentRoot"; - - SmartCompartmentMembershipContext membership = _rootExpression.SmartCompartmentMembership; - var candidateResourceTypeId = candidateIsResourceTable - ? VLatest.Resource.ResourceTypeId - : VLatest.ReferenceSearchParam.ResourceTypeId; - var candidateResourceSurrogateId = candidateIsResourceTable - ? VLatest.Resource.ResourceSurrogateId - : VLatest.ReferenceSearchParam.ResourceSurrogateId; - - object compartmentResourceTypeId = Parameters.AddParameter( - VLatest.Resource.ResourceTypeId, - Model.GetResourceTypeId(membership.CompartmentResourceType), - true); - object compartmentResourceId = Parameters.AddParameter( - VLatest.Resource.ResourceId, - membership.CompartmentResourceId, - true); - - scope.Append("(") - .Append("(") - .Append(candidateResourceTypeId, candidateTableAlias) - .Append(" = ") - .Append(compartmentResourceTypeId) - .Append(" AND "); - - if (candidateIsResourceTable) - { - scope.Append(VLatest.Resource.ResourceId, candidateTableAlias) - .Append(" = ") - .Append(compartmentResourceId); - } - else - { - scope.Append("EXISTS (SELECT 1 FROM ") - .Append(VLatest.Resource) - .Append(' ') - .Append(rootAlias) - .Append(" WHERE ") - .Append(VLatest.Resource.ResourceTypeId, rootAlias) - .Append(" = ") - .Append(candidateResourceTypeId, candidateTableAlias) - .Append(" AND ") - .Append(VLatest.Resource.ResourceSurrogateId, rootAlias) - .Append(" = ") - .Append(candidateResourceSurrogateId, candidateTableAlias) - .Append(" AND ") - .Append(VLatest.Resource.ResourceId, rootAlias) - .Append(" = ") - .Append(compartmentResourceId) - .Append(")"); - } - - scope.Append(")"); - - if (!membership.SharedResourceTypes.IsDefaultOrEmpty) - { - scope.Append(" OR ") - .Append(candidateResourceTypeId, candidateTableAlias) - .Append(" IN (") - .Append(string.Join( - ", ", - membership.SharedResourceTypes.Select(resourceType => Parameters.AddParameter( - VLatest.Resource.ResourceTypeId, - Model.GetResourceTypeId(resourceType), - true)))) - .Append(")"); - } - - if (!membership.MembershipRules.IsDefaultOrEmpty) - { - scope.Append(" OR EXISTS (SELECT 1 FROM ") - .Append(VLatest.ReferenceSearchParam) - .Append(' ') - .Append(membershipAlias) - .Append(" WHERE ") - .Append(VLatest.ReferenceSearchParam.ResourceTypeId, membershipAlias) - .Append(" = ") - .Append(candidateResourceTypeId, candidateTableAlias) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ResourceSurrogateId, membershipAlias) - .Append(" = ") - .Append(candidateResourceSurrogateId, candidateTableAlias) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, membershipAlias) - .Append(" = ") - .Append(compartmentResourceTypeId) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ReferenceResourceId, membershipAlias) - .Append(" = ") - .Append(compartmentResourceId) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.BaseUri, membershipAlias) - .Append(" IS NULL AND ("); - - for (int ruleIndex = 0; ruleIndex < membership.MembershipRules.Length; ruleIndex++) - { - SmartCompartmentMembershipRule rule = membership.MembershipRules[ruleIndex]; - if (ruleIndex > 0) - { - scope.Append(" OR "); - } - - scope.Append("(") - .Append(VLatest.ReferenceSearchParam.ResourceTypeId, membershipAlias) - .Append(" = ") - .Append(Parameters.AddParameter( - VLatest.ReferenceSearchParam.ResourceTypeId, - Model.GetResourceTypeId(rule.ResourceType), - true)) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.SearchParamId, membershipAlias) - .Append(" IN (") - .Append(string.Join( - ", ", - rule.SearchParameterUrls.Select(url => Parameters.AddParameter( - VLatest.ReferenceSearchParam.SearchParamId, - Model.GetSearchParamId(url), - true)))) - .Append("))"); - } - - scope.Append("))"); - } - - if (!membership.ConditionalRules.IsDefaultOrEmpty) - { - // Conditional-visibility legs (for example the SMART Device limits). Each rule authorizes a candidate - // of a given resource type that either references the compartment root (own device) or has no - // reference at all (unassigned device). Candidates that satisfy neither (for example a device - // assigned to a different patient) are excluded, closing the _include/_revinclude leak. This loop is - // generic: the rules are data supplied by SmartCompartmentSearchRewriter.GetConditionalCompartmentRules, - // so no resource-type-specific logic lives here. - for (int conditionalIndex = 0; conditionalIndex < membership.ConditionalRules.Length; conditionalIndex++) - { - SmartCompartmentConditionalMembershipRule rule = membership.ConditionalRules[conditionalIndex]; - string conditionalAlias = "smartCompartmentConditional" + conditionalIndex.ToString(CultureInfo.InvariantCulture); - - object ruleResourceTypeId = Parameters.AddParameter( - VLatest.Resource.ResourceTypeId, - Model.GetResourceTypeId(rule.ResourceType), - true); - object ruleSearchParamId = Parameters.AddParameter( - VLatest.ReferenceSearchParam.SearchParamId, - Model.GetSearchParamId(new Uri(rule.ReferenceSearchParameterUrl)), - true); - - scope.Append(" OR (") - .Append(candidateResourceTypeId, candidateTableAlias) - .Append(" = ") - .Append(ruleResourceTypeId) - .Append(" AND "); - - scope.Append(rule.Visibility == SmartCompartmentConditionalVisibility.HasNoReference ? "NOT EXISTS" : "EXISTS") - .Append(" (SELECT 1 FROM ") - .Append(VLatest.ReferenceSearchParam) - .Append(' ') - .Append(conditionalAlias) - .Append(" WHERE ") - .Append(VLatest.ReferenceSearchParam.ResourceTypeId, conditionalAlias) - .Append(" = ") - .Append(candidateResourceTypeId, candidateTableAlias) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ResourceSurrogateId, conditionalAlias) - .Append(" = ") - .Append(candidateResourceSurrogateId, candidateTableAlias) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.SearchParamId, conditionalAlias) - .Append(" = ") - .Append(ruleSearchParamId); - - if (rule.Visibility == SmartCompartmentConditionalVisibility.ReferencesCompartmentRoot) - { - scope.Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ReferenceResourceTypeId, conditionalAlias) - .Append(" = ") - .Append(compartmentResourceTypeId) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.ReferenceResourceId, conditionalAlias) - .Append(" = ") - .Append(compartmentResourceId) - .Append(" AND ") - .Append(VLatest.ReferenceSearchParam.BaseUri, conditionalAlias) - .Append(" IS NULL"); - } - - scope.Append("))"); - } - } - - scope.Append(")"); - } - - private void HandleTableKindIncludeLimit(SearchOptions context) - { - StringBuilder.Append("SELECT DISTINCT TOP (") - .Append(Parameters.AddParameter(context.IncludeCount + 1, includeInHash: false)) - .Append(") T1, Sid1, IsMatch, "); - - StringBuilder.Append("CASE WHEN count_big(*) over() > ") - .Append(Parameters.AddParameter(context.IncludeCount, true)) - .AppendLine(" THEN 1 ELSE 0 END AS IsPartial "); - - StringBuilder.Append("FROM ").AppendLine(TableExpressionName(_tableExpressionCounter - 1)); - if (!context.IsIncludesOperation) - { - // the 'original' include cte is not in the union, but this new layer is instead - _includeCteIds.Add(TableExpressionName(_tableExpressionCounter)); - } - else - { - StringBuilder.AppendLine("ORDER BY T1 ASC, Sid1 ASC"); - } - } - - private void HandleTableKindIncludeUnionAll(SearchOptions context) - { - StringBuilder.Append("SELECT T1, Sid1, IsMatch, IsPartial "); - - bool sortValueNeeded = IsSortValueNeeded(context); - - // The includes operation does not contain matched resources, so no sort value is needed. - if (sortValueNeeded && !context.IsIncludesOperation) - { - StringBuilder.AppendLine(", SortValue"); - } - else - { - StringBuilder.AppendLine(); - } - - // Excluding a cte for matched resources for $includes operation. - var rootCte = _cteMainSelect; - var skip = 0; - if (context.IsIncludesOperation) - { - rootCte = _includeCteIds.FirstOrDefault(); - skip = rootCte == null ? 0 : 1; - } - - StringBuilder.Append("FROM ").AppendLine(rootCte); - - foreach (var includeCte in _includeCteIds.Skip(skip)) - { - StringBuilder.AppendLine("UNION ALL"); - StringBuilder.Append("SELECT T1, Sid1, IsMatch, IsPartial"); - if (sortValueNeeded && !context.IsIncludesOperation) - { - StringBuilder.AppendLine(", NULL as SortValue "); - } - else - { - StringBuilder.AppendLine(); - } - - // Matched results should be excluded from included CTEs - StringBuilder.Append("FROM ").Append(includeCte) - .Append(" WHERE NOT EXISTS (SELECT * FROM ").Append(_cteMainSelect) - .Append(" WHERE ").Append(_cteMainSelect).Append(".Sid1 = ").Append(includeCte).Append(".Sid1") - .Append(" AND ").Append(_cteMainSelect).Append(".T1 = ").Append(includeCte).AppendLine(".T1)"); - } - } - - private void HandleTableKindSort(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - if (searchParamTableExpression.ChainLevel != 0) - { - throw new InvalidOperationException("Multiple chain level is not possible."); - } - - SortContext sortContext = GetSortRelatedDetails(context); - - if (!string.IsNullOrEmpty(sortContext.SortColumnName) && searchParamTableExpression.QueryGenerator != null) - { - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).Append(" AS Sid1, ") - .Append(sortContext.SortColumnName, null).AppendLine(" AS SortValue") - .Append("FROM ").AppendLine(searchParamTableExpression.QueryGenerator.Table); - - if (UseAppendWithJoin()) - { - AppendIntersectionWithPredecessorUsingInnerJoin(StringBuilder, searchParamTableExpression); - } - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - AppendHistoryClause(delimited, context.ResourceVersionTypes, searchParamTableExpression); - AppendMinOrMax(delimited, context); - - if (searchParamTableExpression.Predicate != null) - { - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(searchParamTableExpression.QueryGenerator, GetContext()); - } - - // if continuation token exists, add it to the query - if (sortContext.ContinuationToken != null) - { - var sortOperand = sortContext.SortOrder == SortOrder.Ascending ? ">" : "<"; - - delimited.BeginDelimitedElement(); - StringBuilder.Append("((").Append(sortContext.SortColumnName, null).Append(" = ").Append(Parameters.AddParameter(sortContext.SortColumnName, sortContext.SortValue, includeInHash: false)); - StringBuilder.Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, null).Append(" > ").Append(Parameters.AddParameter(VLatest.Resource.ResourceSurrogateId, sortContext.ContinuationToken.ResourceSurrogateId, includeInHash: false)).Append(")"); - StringBuilder.Append(" OR ").Append(sortContext.SortColumnName, null).Append(" ").Append(sortOperand).Append(" ").Append(Parameters.AddParameter(sortContext.SortColumnName, sortContext.SortValue, includeInHash: false)).AppendLine(")"); - } - - if (!UseAppendWithJoin()) - { - AppendIntersectionWithPredecessor(delimited, searchParamTableExpression); - } - } - } - - _sortVisited = true; - } - - private void HandleTableKindSortWithFilter(SearchParamTableExpression searchParamTableExpression, SearchOptions context) - { - SortContext sortContext = GetSortRelatedDetails(context); - - if (!string.IsNullOrEmpty(sortContext.SortColumnName) && searchParamTableExpression.QueryGenerator != null) - { - StringBuilder.Append("SELECT ") - .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") - .Append(VLatest.Resource.ResourceSurrogateId, null).Append(" AS Sid1, ") - .Append(sortContext.SortColumnName, null).AppendLine(" AS SortValue") - .Append("FROM ").AppendLine(searchParamTableExpression.QueryGenerator.Table); - - if (UseAppendWithJoin()) - { - AppendIntersectionWithPredecessorUsingInnerJoin(StringBuilder, searchParamTableExpression); - } - - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) - { - AppendHistoryClause(delimited, context.ResourceVersionTypes, searchParamTableExpression); - AppendMinOrMax(delimited, context); - - if (searchParamTableExpression.Predicate != null) - { - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(searchParamTableExpression.QueryGenerator, GetContext()); - } - - // if continuation token exists, add it to the query - if (sortContext.ContinuationToken != null) - { - var sortOperand = sortContext.SortOrder == SortOrder.Ascending ? ">" : "<"; - - delimited.BeginDelimitedElement(); - StringBuilder.Append("((").Append(sortContext.SortColumnName, null).Append(" = ").Append(Parameters.AddParameter(sortContext.SortColumnName, sortContext.SortValue, includeInHash: false)); - StringBuilder.Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, null).Append(" > ").Append(Parameters.AddParameter(VLatest.Resource.ResourceSurrogateId, sortContext.ContinuationToken.ResourceSurrogateId, includeInHash: false)).Append(")"); - StringBuilder.Append(" OR ").Append(sortContext.SortColumnName, null).Append(" ").Append(sortOperand).Append(" ").Append(Parameters.AddParameter(sortContext.SortColumnName, sortContext.SortValue, includeInHash: false)).AppendLine(")"); - } - - if (!UseAppendWithJoin()) - { - AppendIntersectionWithPredecessor(delimited, searchParamTableExpression); - } - } - } - - _sortVisited = true; - } - - private SearchParameterQueryGeneratorContext GetContext(string tableAlias = null) - { - return new SearchParameterQueryGeneratorContext(StringBuilder, Parameters, Model, _schemaInfo, isAsyncOperation: _isAsyncOperation, tableAlias); - } - - private void AppendNewSetOfUnionAllTableExpressions(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator) - { - if (unionExpression.Operator != UnionOperator.All) - { - throw new ArgumentOutOfRangeException(unionExpression.Operator.ToString()); - } - - // Iterate through all expressions and create a unique CTE for each one. - int firstInclusiveTableExpressionId = _tableExpressionCounter + 1; - foreach (Expression innerExpression in unionExpression.Expressions) - { - // Determine the appropriate query generator for this specific inner expression - var queryGenerator = DetermineQueryGeneratorForExpression(innerExpression, defaultQueryGenerator); - - var searchParamExpression = new SearchParamTableExpression( - queryGenerator, - innerExpression, - SearchParamTableExpressionKind.Union); - - searchParamExpression.AcceptVisitor(this, context); - } - - int lastInclusiveTableExpressionId = _tableExpressionCounter; - - // Create a final CTE aggregating results from all previous CTEs. - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); - for (int tableExpressionId = firstInclusiveTableExpressionId; tableExpressionId <= lastInclusiveTableExpressionId; tableExpressionId++) - { - using (StringBuilder.Indent()) - { - StringBuilder.Append("SELECT * FROM ").Append(TableExpressionName(tableExpressionId)); - - if (tableExpressionId < lastInclusiveTableExpressionId) - { - StringBuilder.AppendLine(); - StringBuilder.Append("UNION ALL "); - } - } - } - - StringBuilder.AppendLine(); - StringBuilder.Append(")"); - - // check for a previous union all, and if so, join the new union all with the previous one - if (_unionAggregateCTEIndex > -1) - { - var prevUnionAggregateTableName = TableExpressionName(_unionAggregateCTEIndex); - var currentUnionAggregateTableName = TableExpressionName(_tableExpressionCounter); - - StringBuilder.Append(", "); - StringBuilder.AppendLine(); - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); - - using (StringBuilder.Indent()) - { - StringBuilder.Append("SELECT ").Append(prevUnionAggregateTableName + ".T1, ").Append(prevUnionAggregateTableName + ".Sid1") - .AppendLine() - .Append("FROM ").Append(prevUnionAggregateTableName) - .AppendLine() - .Append(_joinShift).Append("JOIN ").Append(currentUnionAggregateTableName) - .Append(" ON ").Append(prevUnionAggregateTableName + ".T1").Append(" = ").Append(currentUnionAggregateTableName + ".T1") - .Append(" AND ").Append(prevUnionAggregateTableName + ".Sid1").Append(" = ").Append(currentUnionAggregateTableName + ".Sid1") - .AppendLine(); - } - - StringBuilder.Append(")"); - } - - _unionAggregateCTEIndex = _tableExpressionCounter; - - _unionVisited = true; - _firstChainAfterUnionVisited = false; - } - - private void AppendSmartNewSetOfUnionAllTableExpressions(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator, bool skipJoinFromPreviousUnions) - { - if (unionExpression.Operator != UnionOperator.All) - { - throw new ArgumentOutOfRangeException(unionExpression.Operator.ToString()); - } - - List lastAndedCTEs = new List(); - - // Iterate through all expressions and create a unique CTE for each one. - foreach (Expression innerExpression in unionExpression.Expressions) - { - context.SkipAppendIntersectionWithPredecessor = false; - if (innerExpression is MultiaryExpression innerMultiaryExpression) - { - bool firstQueryParamExpression = true; - foreach (Expression childExpression in innerMultiaryExpression.Expressions) - { - // Determine the appropriate query generator for this specific inner expression - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); - var childQueryGenerator = DetermineQueryGeneratorForExpression(childExpression, defaultQueryGenerator); - - var childSearchParamExpression = new SearchParamTableExpression( - childQueryGenerator, - childExpression, - SearchParamTableExpressionKind.Normal); - - context.SkipAppendIntersectionWithPredecessor = firstQueryParamExpression; - firstQueryParamExpression = false; - using (StringBuilder.Indent()) - { - childSearchParamExpression.AcceptVisitor(this, context); - } - - StringBuilder.AppendLine("),"); - } - - lastAndedCTEs.Add(_tableExpressionCounter); - } - else - { - // Determine the appropriate query generator for this specific inner expression - var queryGenerator = DetermineQueryGeneratorForExpression(innerExpression, defaultQueryGenerator); - - var searchParamExpression = new SearchParamTableExpression( - queryGenerator, - innerExpression, - SearchParamTableExpressionKind.Union); - - searchParamExpression.AcceptVisitor(this, context); - lastAndedCTEs.Add(_tableExpressionCounter); - } - } - - context.SkipAppendIntersectionWithPredecessor = false; - int lastInclusiveTableExpressionId = _tableExpressionCounter; - - // Create a final CTE aggregating results from all previous CTEs. - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); - _smartv2ScopeUnionCTE = _tableExpressionCounter; - foreach (int tableExpressionId in lastAndedCTEs) - { - using (StringBuilder.Indent()) - { - StringBuilder.Append("SELECT * FROM ").Append(TableExpressionName(tableExpressionId)); - - if (tableExpressionId < lastInclusiveTableExpressionId) - { - StringBuilder.AppendLine(); - StringBuilder.Append("UNION ALL "); - } - } - } - - StringBuilder.AppendLine(); - StringBuilder.Append(")"); - - // check for a previous union all, and if so, join the new union all with the previous one - if (!skipJoinFromPreviousUnions && _unionAggregateCTEIndex > -1) - { - var prevUnionAggregateTableName = TableExpressionName(_unionAggregateCTEIndex); - var currentUnionAggregateTableName = TableExpressionName(_tableExpressionCounter); - - StringBuilder.Append(", "); - StringBuilder.AppendLine(); - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); - - using (StringBuilder.Indent()) - { - StringBuilder.Append("SELECT ").Append(prevUnionAggregateTableName + ".T1, ").Append(prevUnionAggregateTableName + ".Sid1") - .AppendLine() - .Append("FROM ").Append(prevUnionAggregateTableName) - .AppendLine() - .Append(_joinShift).Append("JOIN ").Append(currentUnionAggregateTableName) - .Append(" ON ").Append(prevUnionAggregateTableName + ".T1").Append(" = ").Append(currentUnionAggregateTableName + ".T1") - .Append(" AND ").Append(prevUnionAggregateTableName + ".Sid1").Append(" = ").Append(currentUnionAggregateTableName + ".Sid1") - .AppendLine(); - } - - StringBuilder.Append(")"); - } - - _unionVisited = true; - _smartV2UnionVisited = true; - _firstChainAfterUnionVisited = false; - } - - private void AppendNewTableExpression(IndentedStringBuilder sb, SearchParamTableExpression tableExpression, int cteId, SearchOptions context) - { - sb.Append(TableExpressionName(cteId)).AppendLine(" AS").AppendLine("("); - - using (sb.Indent()) - { - tableExpression.AcceptVisitor(this, context); - } - - sb.Append(")"); - } - - /// - /// Determines the appropriate query generator for a specific expression within a UNION. - /// This allows different expressions in a UNION to use different underlying SQL tables. - /// - private SearchParamTableExpressionQueryGenerator DetermineQueryGeneratorForExpression(Expression expression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator) - { - // Use the factory to determine the appropriate query generator for this expression - var specificGenerator = expression.AcceptVisitor(_queryGeneratorFactory, _queryGeneratorFactory.InitialContext); - return specificGenerator ?? defaultQueryGenerator; - } - - private bool UseAppendWithJoin() - { - // if either: - // 1. the number of table expressions is greater than the limit indicating a complex query - // 2. the previous query generator failed to generate a query - // then we will NOT use the EXISTS clause instead of the inner join - if (_rootExpression.SearchParamTableExpressions.Count > maxTableExpressionCountLimitForExists || - previousSqlQueryGeneratorFailure) - { - return true; - } - else - { - return false; - } - } - - private void AppendIntersectionWithPredecessor(IndentedStringBuilder.DelimitedScope delimited, SearchParamTableExpression searchParamTableExpression, string tableAlias = null) - { - int predecessorIndex = FindRestrictingPredecessorTableExpressionIndex(); - - if (predecessorIndex >= 0) - { - delimited.BeginDelimitedElement(); - - bool intersectWithFirst = (searchParamTableExpression.Kind == SearchParamTableExpressionKind.Chain ? searchParamTableExpression.ChainLevel - 1 : searchParamTableExpression.ChainLevel) == 0; - - StringBuilder.Append("EXISTS (SELECT * FROM ").Append(TableExpressionName(predecessorIndex)) - .Append(" WHERE ").Append(VLatest.Resource.ResourceTypeId, tableAlias).Append(" = ").Append(intersectWithFirst ? "T1" : "T2") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, tableAlias).Append(" = ").Append(intersectWithFirst ? "Sid1" : "Sid2") - .Append(')'); - } - } - - private void AppendIntersectionWithPredecessorUsingInnerJoin(IndentedStringBuilder sb, SearchParamTableExpression searchParamTableExpression, string tableAlias = null) - { - int predecessorIndex = FindRestrictingPredecessorTableExpressionIndex(); - - if (predecessorIndex >= 0) - { - bool intersectWithFirst = (searchParamTableExpression.Kind == SearchParamTableExpressionKind.Chain ? searchParamTableExpression.ChainLevel - 1 : searchParamTableExpression.ChainLevel) == 0; - - // To simplify query plan generation, if we are intersecting with the Reference search param table, we will use an inner join - // rather than an EXISTS clause. We have see that this significanlty reduces the query plan generation time for - // complex queries - sb.Append(_joinShift).Append("JOIN " + TableExpressionName(predecessorIndex - 0)) - .Append(" ON ").Append(VLatest.Resource.ResourceTypeId, tableAlias).Append(" = ").Append(intersectWithFirst ? "T1" : "T2") - .Append(" AND ").Append(VLatest.Resource.ResourceSurrogateId, tableAlias).Append(" = ").Append(intersectWithFirst ? "Sid1" : "Sid2") - .AppendLine(); - } - } - - private int FindRestrictingPredecessorTableExpressionIndex() - { - int FindImpl(int currentIndex) - { - // Due to the UnionAll expressions, the number of the current index used to create new CTEs can be greater than - // the number of expressions in '_rootExpression.SearchParamTableExpressions'. - if (currentIndex >= _rootExpression.SearchParamTableExpressions.Count) - { - return currentIndex - 1; - } - - SearchParamTableExpression currentSearchParamTableExpression = _rootExpression.SearchParamTableExpressions[currentIndex]; - - // Include all the required SearchParamTableExpressionKind here - switch (currentSearchParamTableExpression.Kind) - { - case SearchParamTableExpressionKind.NotExists: - case SearchParamTableExpressionKind.Normal: - case SearchParamTableExpressionKind.Chain: - case SearchParamTableExpressionKind.Top: - return currentIndex - 1; - case SearchParamTableExpressionKind.Concatenation: - return FindImpl(currentIndex - 1); - case SearchParamTableExpressionKind.Sort: - case SearchParamTableExpressionKind.SortWithFilter: - return currentIndex - 1; - case SearchParamTableExpressionKind.All: - return currentIndex - 1; - case SearchParamTableExpressionKind.Include: - case SearchParamTableExpressionKind.IncludeLimit: - case SearchParamTableExpressionKind.Union: - case SearchParamTableExpressionKind.IncludeUnionAll: - return currentIndex - 1; - default: - throw new ArgumentOutOfRangeException(currentSearchParamTableExpression.Kind.ToString()); - } - } - - return FindImpl(_tableExpressionCounter); - } - - private void AppendDeletedClause(in IndentedStringBuilder.DelimitedScope delimited, ResourceVersionType resourceVersionType, string tableAlias = null) - { - if (resourceVersionType.HasFlag(ResourceVersionType.Latest) && !resourceVersionType.HasFlag(ResourceVersionType.SoftDeleted)) - { - delimited.BeginDelimitedElement(); - StringBuilder.Append(VLatest.Resource.IsDeleted, tableAlias).Append(" = 0 "); - } - else if (resourceVersionType.HasFlag(ResourceVersionType.SoftDeleted) && !resourceVersionType.HasFlag(ResourceVersionType.Latest)) - { - delimited.BeginDelimitedElement(); - StringBuilder.Append(VLatest.Resource.IsDeleted, tableAlias).Append(" = 1 "); - } - } - - private void AppendHistoryClause(in IndentedStringBuilder.DelimitedScope delimited, ResourceVersionType resourceVersionType, SearchParamTableExpression expression = null, string tableAlias = null, string specialCaseTableName = null) - { - if (expression != null && - expression.QueryGenerator.Table.TableName.EndsWith("SearchParam", StringComparison.OrdinalIgnoreCase) && - (string.IsNullOrEmpty(specialCaseTableName) || - expression.QueryGenerator.Table.TableName.Equals(specialCaseTableName, StringComparison.OrdinalIgnoreCase))) - { - // History clause is not applicable for search param tables except for the special case table like Resource in case of Compartment search - return; - } - - if (resourceVersionType.HasFlag(ResourceVersionType.Latest) && !resourceVersionType.HasFlag(ResourceVersionType.History)) - { - delimited.BeginDelimitedElement(); - StringBuilder.Append(VLatest.Resource.IsHistory, tableAlias).Append(" = 0 "); - } - else if (resourceVersionType.HasFlag(ResourceVersionType.History) && !resourceVersionType.HasFlag(ResourceVersionType.Latest)) - { - delimited.BeginDelimitedElement(); - StringBuilder.Append(VLatest.Resource.IsHistory, tableAlias).Append(" = 1 "); - } - } - - private void AppendMinOrMax(in IndentedStringBuilder.DelimitedScope delimited, SearchOptions context) - { - if (_schemaInfo.Current < SchemaVersionConstants.AddMinMaxForDateAndStringSearchParamVersion) - { - return; - } - - delimited.BeginDelimitedElement(); - if (context.Sort[0].sortOrder == SortOrder.Ascending) - { - StringBuilder.Append(VLatest.StringSearchParam.IsMin, tableAlias: null).Append(" = 1"); - } - else if (context.Sort[0].sortOrder == SortOrder.Descending) - { - StringBuilder.Append(VLatest.StringSearchParam.IsMax, tableAlias: null).Append(" = 1"); - } - } - - private void AddIncludeLimitCte(string resourceType, string cte) - { - _includeLimitCtesByResourceType ??= new Dictionary>(); - List ctes; - if (!_includeLimitCtesByResourceType.TryGetValue(resourceType, out ctes)) - { - ctes = new List(); - _includeLimitCtesByResourceType.Add(resourceType, ctes); - } - - if (!ctes.Contains(cte)) - { - _includeLimitCtesByResourceType[resourceType].Add(cte); - } - } - - private bool TryGetIncludeCtes(string resourceType, out List ctes) - { - if (_includeLimitCtesByResourceType == null) - { - ctes = null; - return false; - } - - return _includeLimitCtesByResourceType.TryGetValue(resourceType, out ctes); - } - - private static bool IsPrimaryKeySort(SearchOptions searchOptions) - { - return searchOptions.Sort.All(s => s.searchParameterInfo.Name is SearchParameterNames.ResourceType or SearchParameterNames.LastUpdated); - } - - internal bool IsSortValueNeeded(SearchOptions context) - { - if (context.Sort.Count == 0) - { - return false; - } - - if (IsPrimaryKeySort(context)) - { - return false; - } - - foreach (var searchParamTableExpression in _rootExpression.SearchParamTableExpressions) - { - if (searchParamTableExpression.Kind == SearchParamTableExpressionKind.Sort || - searchParamTableExpression.Kind == SearchParamTableExpressionKind.SortWithFilter) - { - return true; - } - } - - return false; - } - - /// - /// We are looking for 3 conditions to add the OptimizeForUnknownClause: - /// 1. Has an include expression - /// 2. Has an identifier search - /// 3. Has at least one more search parameter - /// - /// True if all condition are met - private bool AddOptimizeForUnknownClause() - { - var hasInclude = _rootExpression.SearchParamTableExpressions.Any(t => t.Kind == SearchParamTableExpressionKind.Include); - - return hasInclude && _hasIdentifier && (_searchParamCount >= 2); - } - - private void CheckForIdentifierSearchParams(Expression predicate) - { - var searchParameterExpressionPredicate = predicate as SearchParameterExpression; - if (searchParameterExpressionPredicate != null) - { - _searchParamCount++; - if (searchParameterExpressionPredicate.Parameter.Name == KnownQueryParameterNames.Identifier) - { - _hasIdentifier = true; - } - } - } - - private static SortContext GetSortRelatedDetails(SearchOptions context) - { - SortContext sortContext = new SortContext(); - SearchParameterInfo searchParamInfo = default; - if (context.Sort?.Count > 0) - { - (searchParamInfo, sortContext.SortOrder) = context.Sort[0]; - } - - sortContext.ContinuationToken = ContinuationToken.FromString(context.ContinuationToken); - - switch (searchParamInfo.Type) - { - case ValueSets.SearchParamType.Date: - sortContext.SortColumnName = VLatest.DateTimeSearchParam.StartDateTime; - if (sortContext.ContinuationToken != null) - { - DateTime dateSortValue; - if (DateTime.TryParseExact(sortContext.ContinuationToken.SortValue, "o", null, DateTimeStyles.None, out dateSortValue)) - { - sortContext.SortValue = dateSortValue; - } - } - - break; - case ValueSets.SearchParamType.String: - sortContext.SortColumnName = VLatest.StringSearchParam.Text; - if (sortContext.ContinuationToken != null) - { - sortContext.SortValue = sortContext.ContinuationToken.SortValue; - } - - break; - } - - return sortContext; - } - - private static SearchParameterExpression CheckExpressionOrFirstChildIsSearchParam(Expression expression) - { - while (expression is MultiaryExpression) - { - expression = ((MultiaryExpression)expression).Expressions[0]; - } - - return expression as SearchParameterExpression; - } - - /// - /// A visitor to determine if there are any references to a search parameter in an expression. - /// - private class ExpressionContainsParameterVisitor : DefaultExpressionVisitor - { - public static readonly ExpressionContainsParameterVisitor Instance = new ExpressionContainsParameterVisitor(); - - private ExpressionContainsParameterVisitor() - : base((acc, curr) => acc || curr) - { - } - - public override bool VisitSearchParameter(SearchParameterExpression expression, string context) => string.Equals(expression.Parameter.Code, context, StringComparison.Ordinal); - } - - internal class SortContext - { - public SortOrder SortOrder { get; set; } - - public ContinuationToken ContinuationToken { get; set; } - - public object SortValue { get; set; } - - public Column SortColumnName { get; set; } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/StringQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/StringQueryGenerator.cs deleted file mode 100644 index 3033da0e87..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/StringQueryGenerator.cs +++ /dev/null @@ -1,40 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class StringQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly StringQueryGenerator Instance = new StringQueryGenerator(); - - public override Table Table => VLatest.StringSearchParam; - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - StringColumn column; - switch (expression.FieldName) - { - case FieldName.String: - column = VLatest.StringSearchParam.Text; - break; - case SqlFieldName.TextOverflow: - column = VLatest.StringSearchParam.TextOverflow; - AppendColumnName(context, column, expression); - context.StringBuilder.Append(" IS NOT NULL AND "); - break; - default: - throw new ArgumentOutOfRangeException(expression.FieldName.ToString()); - } - - return VisitSimpleString(expression, context, column, expression.Value); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenDateTimeCompositeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenDateTimeCompositeQueryGenerator.cs deleted file mode 100644 index f5006ce603..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenDateTimeCompositeQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenDateTimeCompositeQueryGenerator : CompositeQueryGenerator - { - public static readonly TokenDateTimeCompositeQueryGenerator Instance = new TokenDateTimeCompositeQueryGenerator(); - - public TokenDateTimeCompositeQueryGenerator() - : base(TokenQueryGenerator.Instance, DateTimeQueryGenerator.Instance) - { - } - - public override Table Table => VLatest.TokenDateTimeCompositeSearchParam; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenNumberNumberQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenNumberNumberQueryGenerator.cs deleted file mode 100644 index 2b96080ad4..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenNumberNumberQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenNumberNumberQueryGenerator : CompositeQueryGenerator - { - public static readonly TokenNumberNumberQueryGenerator Instance = new TokenNumberNumberQueryGenerator(); - - public TokenNumberNumberQueryGenerator() - : base(TokenQueryGenerator.Instance, NumberQueryGenerator.Instance, NumberQueryGenerator.Instance) - { - } - - public override Table Table => VLatest.TokenNumberNumberCompositeSearchParam; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenQuantityCompositeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenQuantityCompositeQueryGenerator.cs deleted file mode 100644 index 6a0ca26cd3..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenQuantityCompositeQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenQuantityCompositeQueryGenerator : CompositeQueryGenerator - { - public static readonly TokenQuantityCompositeQueryGenerator Instance = new TokenQuantityCompositeQueryGenerator(); - - public TokenQuantityCompositeQueryGenerator() - : base(TokenQueryGenerator.Instance, QuantityQueryGenerator.Instance) - { - } - - public override Table Table => VLatest.TokenQuantityCompositeSearchParam; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenQueryGenerator.cs deleted file mode 100644 index 9c37962226..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenQueryGenerator.cs +++ /dev/null @@ -1,86 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Diagnostics; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly TokenQueryGenerator Instance = new TokenQueryGenerator(); - - public override Table Table => VLatest.TokenSearchParam; - - public override SearchParameterQueryGeneratorContext VisitMissingField(MissingFieldExpression expression, SearchParameterQueryGeneratorContext context) - { - return VisitMissingFieldImpl(expression, context, FieldName.TokenSystem, VLatest.TokenSearchParam.SystemId); - } - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - Debug.Assert(expression.StringOperator == StringOperator.Equals, "Only equals is supported"); - - switch (expression.FieldName) - { - case FieldName.TokenSystem: - if (context.Model.TryGetSystemId(expression.Value, out var systemId)) - { - return VisitSimpleBinary(BinaryOperator.Equal, context, VLatest.TokenSearchParam.SystemId, expression.ComponentIndex, systemId); - } - - AppendColumnName(context, VLatest.TokenSearchParam.SystemId, expression) - .Append(" = (SELECT ") - .Append(VLatest.System.SystemId, null) - .Append(" FROM ").Append(VLatest.System) - .Append(" WHERE ") - .Append(VLatest.System.Value, null) - .Append(" = ") - .Append(context.Parameters.AddParameter(VLatest.System.Value, expression.Value, true)) - .Append(")"); - - return context; - - case FieldName.TokenCode: - if (expression.Value.Length < VLatest.TokenSearchParam.Code.Metadata.MaxLength) - { - // In this case CodeOverflow in the DB table is always NULL, no need to test. There are SQL constraints in each table to enforce this. - VisitSimpleString(expression, context, VLatest.TokenSearchParam.Code, expression.Value); - } - else if (expression.Value.Length == VLatest.TokenSearchParam.Code.Metadata.MaxLength) - { - VisitSimpleString(expression, context, VLatest.TokenSearchParam.Code, expression.Value); - context.StringBuilder.Append(" AND "); - AppendColumnName(context, VLatest.TokenSearchParam.CodeOverflow, expression); - context.StringBuilder.Append(" IS NULL"); - } - else - { - int codeLength; - checked - { - codeLength = (int)VLatest.TokenSearchParam.Code.Metadata.MaxLength; // Throw overflow if code max lenght is ever too big to fit into int. - } - - VisitSimpleString(expression, context, VLatest.TokenSearchParam.Code, expression.Value[..codeLength]); - context.StringBuilder.Append(" AND "); - AppendColumnName(context, VLatest.TokenSearchParam.CodeOverflow, expression); - context.StringBuilder.Append(" IS NOT NULL AND "); - VisitSimpleString(expression, context, VLatest.TokenSearchParam.CodeOverflow, expression.Value[codeLength..]); - } - - break; - default: - throw new InvalidOperationException(); - } - - return context; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenStringCompositeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenStringCompositeQueryGenerator.cs deleted file mode 100644 index f126d68403..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenStringCompositeQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenStringCompositeQueryGenerator : CompositeQueryGenerator - { - public static readonly TokenStringCompositeQueryGenerator Instance = new TokenStringCompositeQueryGenerator(); - - public TokenStringCompositeQueryGenerator() - : base(TokenQueryGenerator.Instance, StringQueryGenerator.Instance) - { - } - - public override Table Table => VLatest.TokenStringCompositeSearchParam; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenTextQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenTextQueryGenerator.cs deleted file mode 100644 index fa128c6ee4..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenTextQueryGenerator.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenTextQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly TokenTextQueryGenerator Instance = new TokenTextQueryGenerator(); - - public override Table Table => VLatest.TokenText; - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - return VisitSimpleString(expression, context, VLatest.TokenText.Text, expression.Value); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenTokenCompositeQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenTokenCompositeQueryGenerator.cs deleted file mode 100644 index 76a3a9d925..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/TokenTokenCompositeQueryGenerator.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class TokenTokenCompositeQueryGenerator : CompositeQueryGenerator - { - public static readonly TokenTokenCompositeQueryGenerator Instance = new TokenTokenCompositeQueryGenerator(); - - public TokenTokenCompositeQueryGenerator() - : base(TokenQueryGenerator.Instance, TokenQueryGenerator.Instance) - { - } - - public override Table Table => VLatest.TokenTokenCompositeSearchParam; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/UriQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/UriQueryGenerator.cs deleted file mode 100644 index d13d905c32..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/UriQueryGenerator.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.SqlServer.Features.Schema.Model; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators -{ - internal class UriQueryGenerator : SearchParamTableExpressionQueryGenerator - { - public static readonly UriQueryGenerator Instance = new UriQueryGenerator(); - - public override Table Table => VLatest.UriSearchParam; - - public override SearchParameterQueryGeneratorContext VisitString(StringExpression expression, SearchParameterQueryGeneratorContext context) - { - return VisitSimpleString(expression, context, VLatest.UriSearchParam.Uri, expression.Value); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/RemoveIncludesRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/RemoveIncludesRewriter.cs deleted file mode 100644 index 16c93931ae..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/RemoveIncludesRewriter.cs +++ /dev/null @@ -1,54 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// A rewriter that removes s from an expression tree. - /// - internal class RemoveIncludesRewriter : ExpressionRewriterWithInitialContext - { - public static readonly RemoveIncludesRewriter Instance = new RemoveIncludesRewriter(); - - public override Expression VisitInclude(IncludeExpression expression, object context) - { - return null; - } - - public override Expression VisitMultiary(MultiaryExpression expression, object context) - { - List newChildExpressions = null; - for (int i = 0; i < expression.Expressions.Count; i++) - { - Expression childExpression = expression.Expressions[i]; - if (childExpression is IncludeExpression) - { - if (i == 0 && expression.Expressions.All(e => e is IncludeExpression)) - { - return null; - } - - EnsureAllocatedAndPopulated(ref newChildExpressions, expression.Expressions, i); - } - else - { - newChildExpressions?.Add(childExpression); - } - } - - return newChildExpressions switch - { - null => expression, - { Count: 0 } => null, - { Count: 1 } => newChildExpressions[0], - _ => new MultiaryExpression(expression.MultiaryOperation, newChildExpressions), - }; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ResourceColumnPredicatePushdownRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ResourceColumnPredicatePushdownRewriter.cs deleted file mode 100644 index 7aa64ff3ea..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ResourceColumnPredicatePushdownRewriter.cs +++ /dev/null @@ -1,100 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Promotes predicates applied directly in on the Resource table to the search parameter tables. - /// These are predicates on the ResourceSurrogateId and ResourceType columns. The idea is to make these - /// queries as selective as possible. - /// - internal class ResourceColumnPredicatePushdownRewriter : SqlExpressionRewriterWithInitialContext - { - public static readonly ResourceColumnPredicatePushdownRewriter Instance = new ResourceColumnPredicatePushdownRewriter(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count == 0 || expression.ResourceTableExpressions.Count == 0 || - expression.SearchParamTableExpressions.All(e => e.Kind == SearchParamTableExpressionKind.Include)) - { - // if only Include expressions, the case is handled in IncludeMatchSeedRewriter - return expression; - } - - Expression extractedCommonResourceExpressions = null; - bool containsResourceExpressionFoundOnlyOnResourceTable = false; - - for (int i = 0; i < expression.ResourceTableExpressions.Count; i++) - { - SearchParameterExpressionBase currentExpression = expression.ResourceTableExpressions[i]; - - if (currentExpression is SearchParameterExpression searchParameterExpression) - { - if (searchParameterExpression.Parameter.ColumnLocation().HasFlag(SearchParameterColumnLocation.SearchParamTable)) - { - extractedCommonResourceExpressions = extractedCommonResourceExpressions == null ? currentExpression : Expression.And(extractedCommonResourceExpressions, currentExpression); - } - else - { - containsResourceExpressionFoundOnlyOnResourceTable = true; - } - } - } - - var newTableExpressions = new List(expression.SearchParamTableExpressions.Count); - - if (containsResourceExpressionFoundOnlyOnResourceTable) - { - // There is a predicate over _id, which is on the Resource table but not on the search parameter tables. - // So the first table expression should be an "All" expression, where we restrict the resultset to resources with that ID. - newTableExpressions.Add(new SearchParamTableExpression(null, Expression.And(expression.ResourceTableExpressions), SearchParamTableExpressionKind.All)); - } - - foreach (var tableExpression in expression.SearchParamTableExpressions) - { - if (tableExpression.Kind == SearchParamTableExpressionKind.Include || - (tableExpression.Kind == SearchParamTableExpressionKind.Normal && tableExpression.ChainLevel > 0) || - (tableExpression.Kind == SearchParamTableExpressionKind.Chain && tableExpression.ChainLevel > 1)) - { - // these predicates do not apply to referenced resources - - newTableExpressions.Add(tableExpression); - } - else if (tableExpression.Kind == SearchParamTableExpressionKind.Chain) - { - var sqlChainLinkExpression = (SqlChainLinkExpression)tableExpression.Predicate; - - Debug.Assert(sqlChainLinkExpression.ExpressionOnSource == null); - - var newChainLinkExpression = new SqlChainLinkExpression( - sqlChainLinkExpression.ResourceTypes, - sqlChainLinkExpression.ReferenceSearchParameter, - sqlChainLinkExpression.TargetResourceTypes, - sqlChainLinkExpression.Reversed, - extractedCommonResourceExpressions, - sqlChainLinkExpression.ExpressionOnTarget); - - newTableExpressions.Add(new SearchParamTableExpression(tableExpression.QueryGenerator, newChainLinkExpression, tableExpression.Kind, chainLevel: tableExpression.ChainLevel)); - } - else - { - Expression predicate = tableExpression.Predicate == null - ? extractedCommonResourceExpressions - : Expression.And(tableExpression.Predicate, extractedCommonResourceExpressions); - - newTableExpressions.Add(new SearchParamTableExpression(tableExpression.QueryGenerator, predicate, tableExpression.Kind, tableExpression.ChainLevel)); - } - } - - return new SqlRootExpression(newTableExpressions, Array.Empty()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs deleted file mode 100644 index 90f985ef5c..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs +++ /dev/null @@ -1,168 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.ValueSets; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// For allow-listed scalar date parameters (currently birthdate), collapses Core's exact-day - /// equality containment (DateTimeStart >= lo AND DateTimeEnd <= hi) into a single - /// DateTimeEnd predicate with IsLongerThanADay = false — an index optimization. A stored - /// period longer than one day can never be contained in a one-day window, so no - /// / day-split is needed. Other precisions, range/ap operators, and - /// composite or non-allow-listed parameters pass through unchanged. Runs before the Core->SQL - /// conversion, and only when both the scalar-temporal and FHIR date containment flags are enabled. - /// - internal class ScalarTemporalEqualityRewriter : SqlExpressionRewriterWithInitialContext - { - internal static readonly ScalarTemporalEqualityRewriter Instance = new ScalarTemporalEqualityRewriter(); - - // NOTE: This list is only for parameters that are the same across all FHIR versions. Before adding parameters - // this approach may need to be revisited to support version-specific allow lists. - private static readonly HashSet _allowList = new HashSet(StringComparer.Ordinal) - { - "http://hl7.org/fhir/SearchParameter/individual-birthdate", - }; - - private enum Precision - { - NotRewritable, - ExactDay, - } - - public override Expression VisitSearchParameter(SearchParameterExpression expression, bool context) - { - // 1. Only allow-listed scalar date parameters are eligible. - if (!IsActivatedScalarTemporalParameter(expression)) - { - return expression; - } - - // 2. The inner expression must be the two-predicate equality pattern Core emits. - if (!TryMatchEqualityPattern(expression.Expression, out BinaryExpression startPredicate, out BinaryExpression endPredicate)) - { - return expression; - } - - // 3. The predicate operands must be concrete DateTimeOffset values so we can inspect - // the start/end boundaries and decide whether they cover exactly one calendar day. - // Anything else (null, string, partial date) is not something we can classify, so pass through. - if (startPredicate.Value is not DateTimeOffset startValue || - endPredicate.Value is not DateTimeOffset endValue) - { - return expression; - } - - // 4. Classify precision and build the matching rewrite, or pass through. - // Day-precision collapses to a single DateTimeEnd predicate (no UNION). - return ClassifyPrecision(startValue, endValue) switch - { - Precision.ExactDay => BuildEndOnlyPredicate(expression.Parameter, endPredicate), - _ => expression, - }; - } - - internal static bool IsActivatedScalarTemporalParameter(SearchParameterExpression expression) - { - var p = expression?.Parameter; - if (p == null) - { - return false; - } - - bool isScalarDate = p.Type == SearchParamType.Date && (p.Component == null || p.Component.Count == 0); - bool isAllowListed = p.Url != null && _allowList.Contains(p.Url.OriginalString); - - return isScalarDate && isAllowListed; - } - - /// - /// Matches the shape DateTimeStart >= X AND DateTimeEnd <= Y (either operand order). - /// On success, returns the two predicates normalized so is always the - /// DateTimeStart >= side and is always the DateTimeEnd <= side. - /// - internal static bool TryMatchEqualityPattern( - Expression expr, - out BinaryExpression startGe, - out BinaryExpression endLe) - { - startGe = null; - endLe = null; - - if (expr is not MultiaryExpression multiary || - multiary.MultiaryOperation != MultiaryOperator.And || - multiary.Expressions.Count != 2 || - multiary.Expressions[0] is not BinaryExpression a || - multiary.Expressions[1] is not BinaryExpression b || - a.ComponentIndex != b.ComponentIndex) - { - return false; - } - - // Operands may appear in either order; pick the one that is the start-ge predicate. - if (IsStartGe(a) && IsEndLe(b)) - { - startGe = a; - endLe = b; - return true; - } - - if (IsStartGe(b) && IsEndLe(a)) - { - startGe = b; - endLe = a; - return true; - } - - return false; - } - - private static Precision ClassifyPrecision(DateTimeOffset start, DateTimeOffset end) - { - // Both endpoints must be UTC, and start must sit on a UTC midnight, before any precision applies. - if (!IsUtcMidnight(start) || !IsUtc(end)) - { - return Precision.NotRewritable; - } - - if (end == start.AddDays(1).AddTicks(-1)) - { - return Precision.ExactDay; - } - - return Precision.NotRewritable; - } - - // Birthdate rows are stored as exactly one full UTC day, so containment collapses to a single - // DateTimeEnd equality with IsLongerThanADay = false. A period longer than one day can never be - // contained in a one-day window, so those rows are excluded by construction (no UNION ALL). - private static SearchParameterExpression BuildEndOnlyPredicate( - SearchParameterInfo parameter, - BinaryExpression endPredicate) - { - return new SearchParameterExpression( - parameter, - Expression.And( - Expression.Equals(SqlFieldName.DateTimeIsLongerThanADay, endPredicate.ComponentIndex, false), - new BinaryExpression(BinaryOperator.Equal, FieldName.DateTimeEnd, endPredicate.ComponentIndex, endPredicate.Value))); - } - - private static bool IsStartGe(BinaryExpression be) => - be.FieldName == FieldName.DateTimeStart && be.BinaryOperator == BinaryOperator.GreaterThanOrEqual; - - private static bool IsEndLe(BinaryExpression be) => - be.FieldName == FieldName.DateTimeEnd && be.BinaryOperator == BinaryOperator.LessThanOrEqual; - - private static bool IsUtc(DateTimeOffset value) => value.Offset == TimeSpan.Zero; - - private static bool IsUtcMidnight(DateTimeOffset value) => IsUtc(value) && value.TimeOfDay == TimeSpan.Zero; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SearchParamTableExpressionQueryGeneratorFactory.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SearchParamTableExpressionQueryGeneratorFactory.cs deleted file mode 100644 index 0dbafe1b7f..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SearchParamTableExpressionQueryGeneratorFactory.cs +++ /dev/null @@ -1,226 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Concurrent; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; -using Microsoft.Health.Fhir.ValueSets; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Returns the for an expression. - /// - internal class SearchParamTableExpressionQueryGeneratorFactory : IExpressionVisitorWithInitialContext - { - private readonly SearchParameterToSearchValueTypeMap _searchParameterToSearchValueTypeMap; - private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); - - public SearchParamTableExpressionQueryGeneratorFactory(SearchParameterToSearchValueTypeMap searchParameterToSearchValueTypeMap) - { - EnsureArg.IsNotNull(searchParameterToSearchValueTypeMap, nameof(searchParameterToSearchValueTypeMap)); - _searchParameterToSearchValueTypeMap = searchParameterToSearchValueTypeMap; - } - - public object InitialContext => null; - - public SearchParamTableExpressionQueryGenerator VisitSearchParameter(SearchParameterExpression expression, object context) - { - return VisitSearchParameterExpressionBase(expression.Parameter, expression.Expression, context); - } - - public SearchParamTableExpressionQueryGenerator VisitMissingSearchParameter(MissingSearchParameterExpression expression, object context) - { - return VisitSearchParameterExpressionBase(expression.Parameter, null, context); - } - - public SearchParamTableExpressionQueryGenerator GetGenerator(SearchParameterInfo param) - { - switch (param.Type) - { - case SearchParamType.Token: - return TokenQueryGenerator.Instance; - case SearchParamType.Date: - return DateTimeQueryGenerator.Instance; - case SearchParamType.Number: - return NumberQueryGenerator.Instance; - case SearchParamType.Quantity: - return QuantityQueryGenerator.Instance; - case SearchParamType.Reference: - return ReferenceQueryGenerator.Instance; - case SearchParamType.String: - return StringQueryGenerator.Instance; - case SearchParamType.Uri: - return UriQueryGenerator.Instance; - case SearchParamType.Composite: - Type searchValueType = _searchParameterToSearchValueTypeMap.GetSearchValueType(param); - if (searchValueType == typeof(ValueTuple)) - { - return TokenQuantityCompositeQueryGenerator.Instance; - } - - if (searchValueType == typeof(ValueTuple)) - { - return ReferenceTokenCompositeQueryGenerator.Instance; - } - - if (searchValueType == typeof(ValueTuple)) - { - return TokenTokenCompositeQueryGenerator.Instance; - } - - if (searchValueType == typeof(ValueTuple)) - { - return TokenDateTimeCompositeQueryGenerator.Instance; - } - - if (searchValueType == typeof(ValueTuple)) - { - return TokenStringCompositeQueryGenerator.Instance; - } - - if (searchValueType == typeof(ValueTuple)) - { - return TokenNumberNumberQueryGenerator.Instance; - } - - throw new InvalidOperationException($"Unexpected composite search parameter {param.Url}"); - - default: - throw new InvalidOperationException($"Unexpected search parameter type {param.Type}"); - } - } - - private SearchParamTableExpressionQueryGenerator VisitSearchParameterExpressionBase(SearchParameterInfo searchParameterInfo, Expression childExpression, object context) - { - if (searchParameterInfo.ColumnLocation().HasFlag(SearchParameterColumnLocation.ResourceTable)) - { - return null; - } - - if (childExpression != null) - { - if (searchParameterInfo.Type == SearchParamType.Token) - { - // could be Token or TokenText - return childExpression.AcceptVisitor(this, context); - } - } - - if (!_cache.TryGetValue(searchParameterInfo.Url, out var generator)) - { - generator = GetGenerator(searchParameterInfo); - _cache.TryAdd(searchParameterInfo.Url, generator); - } - - return generator; - } - - public SearchParamTableExpressionQueryGenerator GetSearchParamTableExpressionQueryGenerator(SearchParameterInfo searchParameterInfo) - { - return VisitSearchParameterExpressionBase(searchParameterInfo, null, null); - } - - public SearchParamTableExpressionQueryGenerator VisitBinary(BinaryExpression expression, object context) - { - throw new InvalidOperationException("Not expecting a BinaryExpression under a Token search param."); - } - - public SearchParamTableExpressionQueryGenerator VisitChained(ChainedExpression expression, object context) - { - return ChainLinkQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitMissingField(MissingFieldExpression expression, object context) - { - return expression.FieldName switch - { - FieldName.ReferenceResourceType or FieldName.ReferenceBaseUri => ReferenceQueryGenerator.Instance, - _ => TokenQueryGenerator.Instance, - }; - } - - public SearchParamTableExpressionQueryGenerator VisitNotExpression(NotExpression expression, object context) - { - return expression.Expression.AcceptVisitor(this, context); - } - - public SearchParamTableExpressionQueryGenerator VisitMultiary(MultiaryExpression expression, object context) - { - return VisitExpressionsContainer(expression, context); - } - - public SearchParamTableExpressionQueryGenerator VisitUnion(UnionExpression expression, object context) - { - return VisitExpressionsContainer(expression, context); - } - - public SearchParamTableExpressionQueryGenerator VisitString(StringExpression expression, object context) - { - if (expression.FieldName == FieldName.TokenText) - { - return TokenTextQueryGenerator.Instance; - } - - return TokenQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitCompartment(CompartmentSearchExpression expression, object context) - { - return CompartmentQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitSmartCompartment(SmartCompartmentSearchExpression expression, object context) - { - return CompartmentQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitInclude(IncludeExpression expression, object context) - { - return IncludeQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitSortParameter(SortExpression expression, object context) - { - return GetSearchParamTableExpressionQueryGenerator(expression.Parameter); - } - - public SearchParamTableExpressionQueryGenerator VisitIn(InExpression expression, object context) - { - return InQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitNotReferenced(NotReferencedExpression expression, object context) - { - return NotReferencedQueryGenerator.Instance; - } - - public SearchParamTableExpressionQueryGenerator VisitNotReferencing(NotReferencingExpression expression, object context) - { - // NotReferencedQueryGenerator's Table is dbo.Resource, which is what we want: - // the union CTE selects from Resource and filters with a NOT EXISTS anti-join. - return NotReferencedQueryGenerator.Instance; - } - - private SearchParamTableExpressionQueryGenerator VisitExpressionsContainer(IExpressionsContainer expression, object context) - { - foreach (var childExpression in expression.Expressions) - { - var handler = childExpression.AcceptVisitor(this, context); - if (handler != null) - { - return handler; - } - } - - return null; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SearchParamTableExpressionReorderer.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SearchParamTableExpressionReorderer.cs deleted file mode 100644 index 68e17757bc..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SearchParamTableExpressionReorderer.cs +++ /dev/null @@ -1,76 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Reorders table expressions by expected selectivity. Most selective are moved to the front. - /// - internal class SearchParamTableExpressionReorderer : SqlExpressionRewriterWithInitialContext - { - public static readonly SearchParamTableExpressionReorderer Instance = new SearchParamTableExpressionReorderer(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - if (expression.SearchParamTableExpressions.Count <= 1) - { - return expression; - } - - List reorderedExpressions = expression.SearchParamTableExpressions.OrderByDescending(t => - { - if (t.Kind == SearchParamTableExpressionKind.All) - { - return 20; - } - - if (t.Predicate is MissingSearchParameterExpression) - { - return -10; - } - - var order = t.Predicate?.AcceptVisitor(Scout.Instance, context); - if (order != 0) - { - return order; - } - - switch (t.QueryGenerator) - { - case ReferenceQueryGenerator _: - return 10; - case CompartmentQueryGenerator _: - return 10; - case IncludeQueryGenerator _: - return -20; - default: - return 0; - } - }).ToList(); - - return new SqlRootExpression(reorderedExpressions, expression.ResourceTableExpressions); - } - - private class Scout : DefaultExpressionVisitor - { - internal static readonly Scout Instance = new Scout(); - - private Scout() - : base((accumulated, current) => current != 0 ? current : accumulated) - { - } - - public override int VisitNotExpression(NotExpression expression, object context) - { - return -15; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SortRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SortRewriter.cs deleted file mode 100644 index 658f49c84e..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SortRewriter.cs +++ /dev/null @@ -1,174 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// It creates the correct generator and populates the predicates for sort parameters. - /// - internal class SortRewriter : SqlExpressionRewriter - { - private readonly SearchParamTableExpressionQueryGeneratorFactory _searchParamTableExpressionQueryGeneratorFactory; - - public SortRewriter(SearchParamTableExpressionQueryGeneratorFactory searchParamTableExpressionQueryGeneratorFactory) - { - _searchParamTableExpressionQueryGeneratorFactory = searchParamTableExpressionQueryGeneratorFactory; - } - - public override Expression VisitSqlRoot(SqlRootExpression expression, SqlSearchOptions context) - { - // If we only need the count, we don't want to execute any sort specific queries. - if (context.CountOnly) - { - return expression; - } - - // Proceed if no sort params were requested. - if (context.Sort.Count == 0) - { - return expression; - } - - // _type and _lastUpdated sort params are handled differently than others, because they can be - // inferred directly from the resource table itself. - if (context.Sort.All(s => s.searchParameterInfo.Name is SearchParameterNames.ResourceType or SearchParameterNames.LastUpdated)) - { - return expression; - } - - // Check if the parameter being sorted on is also part of another parameter for the search. - // If the parameter being sorted on is part of a filter then we don't need to run the seperate search for resources that are missing a value for the field being sorted on. - // If the parameter being sorted on is not part of a filter we need to run a seperate search to get resources that don't have a value for the field being sorted on. - bool matchFound = false; - bool sortHasMissingModifier = false; - for (int i = 0; i < expression.SearchParamTableExpressions.Count; i++) - { - Expression updatedExpression = expression.SearchParamTableExpressions[i].Predicate.AcceptVisitor(this, context); - - if (expression.SearchParamTableExpressions[i].Predicate is MissingSearchParameterExpression && !sortHasMissingModifier) - { - MissingSearchParameterExpression misingSPExpression = (MissingSearchParameterExpression)expression.SearchParamTableExpressions[i].Predicate; - sortHasMissingModifier = !misingSPExpression.IsMissing && context.Sort[0].searchParameterInfo.Name.Equals(misingSPExpression.Parameter.Name, System.StringComparison.OrdinalIgnoreCase); - } - - if (updatedExpression == null) - { - matchFound = true; - break; - } - } - - context.SortHasMissingModifier |= sortHasMissingModifier; - var newTableExpressions = new List(); - newTableExpressions.AddRange(expression.SearchParamTableExpressions); - var continuationToken = ContinuationToken.FromString(context.ContinuationToken); - - if (!matchFound && !sortHasMissingModifier) - { - // We are running a sort query where the parameter by which we are sorting - // is not present as part of other search parameters in the query. - - // Check whether we have to execute the second phase of the search for a sort query. - // This can occur when SearchService decides to run a second search while processing the current query. - // Or it could be a query from the client with a hardcoded "special" continuation token. - if (context.SortQuerySecondPhase || - (continuationToken != null && - continuationToken.ResourceSurrogateId == 0 && - continuationToken.SortValue == SqlSearchConstants.SortSentinelValueForCt)) - { - context.ContinuationToken = null; - if (context.Sort[0].sortOrder == SortOrder.Descending) - { - // For descending order, the second phase of the sort query deals with searching - // for resources that do not have a value for the _sort parameter. - var missingExpression = Expression.MissingSearchParameter(context.Sort[0].searchParameterInfo, isMissing: true); - var queryGenForMissing = _searchParamTableExpressionQueryGeneratorFactory.GetSearchParamTableExpressionQueryGenerator(context.Sort[0].searchParameterInfo); - var notExistsExpression = new SearchParamTableExpression( - queryGenForMissing, - missingExpression, - SearchParamTableExpressionKind.NotExists); - - newTableExpressions.Add(notExistsExpression); - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - // For ascending, the second phase of the sort query deals with searching - // for resources that have a value for the _sort parameter. So we will generate - // the appropriate Sort expression below. - } - else if (continuationToken != null && continuationToken.SortValue == null) - { - // We have a ct for resourceid but not for the sort value. - // This means we are paging through resources that do not have values for the _sort parameter. - var missingExpression = Expression.MissingSearchParameter(context.Sort[0].searchParameterInfo, isMissing: true); - var queryGenForMissing = _searchParamTableExpressionQueryGeneratorFactory.GetSearchParamTableExpressionQueryGenerator(context.Sort[0].searchParameterInfo); - var notExistsExpression = new SearchParamTableExpression( - queryGenForMissing, - missingExpression, - SearchParamTableExpressionKind.NotExists); - - newTableExpressions.Add(notExistsExpression); - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - else if (continuationToken == null) - { - // This means we are in the first "phase" of searching for resources for the _sort query. - // For ascending order, we will search for resources that do not have a value for the - // corresponding _sort parameter. - if (context.Sort[0].sortOrder == SortOrder.Ascending) - { - var missingExpression = Expression.MissingSearchParameter(context.Sort[0].searchParameterInfo, isMissing: true); - var queryGenForMissing = _searchParamTableExpressionQueryGeneratorFactory.GetSearchParamTableExpressionQueryGenerator(context.Sort[0].searchParameterInfo); - var notExistsExpression = new SearchParamTableExpression( - queryGenForMissing, - missingExpression, - SearchParamTableExpressionKind.NotExists); - - newTableExpressions.Add(notExistsExpression); - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - // For descending order, we will search for resources that have a value for the - // corresponding _sort parameter. We will generate the appropriate Sort expression below. - } - } - - SearchParamTableExpressionKind sortKind = matchFound ? SearchParamTableExpressionKind.SortWithFilter : SearchParamTableExpressionKind.Sort; - if (sortKind == SearchParamTableExpressionKind.SortWithFilter) - { - context.IsSortWithFilter = true; - } - - var queryGenerator = _searchParamTableExpressionQueryGeneratorFactory.GetSearchParamTableExpressionQueryGenerator(context.Sort[0].searchParameterInfo); - - newTableExpressions.Add(new SearchParamTableExpression(queryGenerator, new SortExpression(context.Sort[0].searchParameterInfo), sortKind)); - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - - public override Expression VisitSearchParameter(SearchParameterExpression expression, SqlSearchOptions context) - { - if (context.Sort.Count > 0) - { - if (expression.Parameter.Equals(context.Sort[0].searchParameterInfo)) - { - // We are returning null here to notify that we have found a SearchParameterExpression - // for the same search parameter used for sort. - return null; - } - } - - return expression; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlExpressionRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlExpressionRewriter.cs deleted file mode 100644 index c642367bff..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlExpressionRewriter.cs +++ /dev/null @@ -1,65 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - internal abstract class SqlExpressionRewriter : ExpressionRewriter, ISqlExpressionVisitor - { - public virtual Expression VisitSqlRoot(SqlRootExpression expression, TContext context) - { - IReadOnlyList visitedResourceExpressions = VisitArray(expression.ResourceTableExpressions, context); - IReadOnlyList visitedTableExpressions = VisitArray(expression.SearchParamTableExpressions, context); - - if (ReferenceEquals(visitedTableExpressions, expression.SearchParamTableExpressions) && - ReferenceEquals(visitedResourceExpressions, expression.ResourceTableExpressions)) - { - return expression; - } - - return new SqlRootExpression(visitedTableExpressions, visitedResourceExpressions); - } - - public virtual Expression VisitTable(SearchParamTableExpression searchParamTableExpression, TContext context) - { - Expression rewrittenPredicate = searchParamTableExpression.Predicate?.AcceptVisitor(this, context); - - if (ReferenceEquals(rewrittenPredicate, searchParamTableExpression.Predicate)) - { - return searchParamTableExpression; - } - - int chainLevel = 0; - if (((rewrittenPredicate as SearchParameterExpression)?.Expression as MultiaryExpression)?.MultiaryOperation == MultiaryOperator.Or) - { - chainLevel = 1; - } - - return new SearchParamTableExpression(searchParamTableExpression.QueryGenerator, rewrittenPredicate, searchParamTableExpression.Kind, chainLevel); - } - - public virtual Expression VisitSqlChainLink(SqlChainLinkExpression sqlChainLinkExpression, TContext context) - { - Expression visitedExpressionOnSource = sqlChainLinkExpression.ExpressionOnSource?.AcceptVisitor(this, context); - Expression visitedExpressionOnTarget = sqlChainLinkExpression.ExpressionOnTarget?.AcceptVisitor(this, context); - - if (ReferenceEquals(visitedExpressionOnSource, sqlChainLinkExpression.ExpressionOnSource) && - ReferenceEquals(visitedExpressionOnTarget, sqlChainLinkExpression.ExpressionOnTarget)) - { - return sqlChainLinkExpression; - } - - return new SqlChainLinkExpression( - sqlChainLinkExpression.ResourceTypes, - sqlChainLinkExpression.ReferenceSearchParameter, - sqlChainLinkExpression.TargetResourceTypes, - sqlChainLinkExpression.Reversed, - visitedExpressionOnSource, - visitedExpressionOnTarget); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlExpressionRewriterWithInitialContext.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlExpressionRewriterWithInitialContext.cs deleted file mode 100644 index d2591c5d63..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlExpressionRewriterWithInitialContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - internal abstract class SqlExpressionRewriterWithInitialContext : SqlExpressionRewriter, IExpressionVisitorWithInitialContext - { - public virtual TContext InitialContext => default; - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlRootExpressionRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlRootExpressionRewriter.cs deleted file mode 100644 index ea38a1cf7c..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SqlRootExpressionRewriter.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Constructs a by partitioning an expression into expressions over search parameter tables and expressions over the Resource table - /// - internal class SqlRootExpressionRewriter : ExpressionRewriterWithInitialContext - { - private readonly SearchParamTableExpressionQueryGeneratorFactory _searchParamTableExpressionQueryGeneratorFactory; - - public SqlRootExpressionRewriter(SearchParamTableExpressionQueryGeneratorFactory searchParamTableExpressionQueryGeneratorFactory) - { - EnsureArg.IsNotNull(searchParamTableExpressionQueryGeneratorFactory, nameof(searchParamTableExpressionQueryGeneratorFactory)); - _searchParamTableExpressionQueryGeneratorFactory = searchParamTableExpressionQueryGeneratorFactory; - } - - public override Expression VisitMultiary(MultiaryExpression expression, int context) - { - if (expression.MultiaryOperation != MultiaryOperator.And) - { - throw new InvalidOperationException("Or is not supported as a top-level expression"); - } - - List resourceExpressions = null; - List tableExpressions = null; - - for (var i = 0; i < expression.Expressions.Count; i++) - { - Expression childExpression = expression.Expressions[i]; - - if (TryGetSearchParamTableExpressionQueryGenerator(childExpression, out SearchParamTableExpressionQueryGenerator tableExpressionGenerator, out SearchParamTableExpressionKind tableExpressionKind)) - { - EnsureAllocatedAndPopulatedChangeType(ref resourceExpressions, expression.Expressions, i); - EnsureAllocatedAndPopulated(ref tableExpressions, Array.Empty(), 0); - - tableExpressions.Add(new SearchParamTableExpression(tableExpressionGenerator, childExpression, tableExpressionKind, tableExpressionKind == SearchParamTableExpressionKind.Chain ? 1 : 0)); - } - else - { - resourceExpressions?.Add((SearchParameterExpressionBase)childExpression); - } - } - - if (tableExpressions == null) - { - SearchParameterExpressionBase[] castedResourceExpressions = new SearchParameterExpressionBase[expression.Expressions.Count]; - - for (var i = 0; i < expression.Expressions.Count; i++) - { - castedResourceExpressions[i] = (SearchParameterExpressionBase)expression.Expressions[i]; - } - - return SqlRootExpression.WithResourceTableExpressions(castedResourceExpressions); - } - - if (resourceExpressions == null) - { - return SqlRootExpression.WithSearchParamTableExpressions(tableExpressions); - } - - return new SqlRootExpression(tableExpressions, resourceExpressions); - } - - public override Expression VisitUnion(UnionExpression expression, int context) => ConvertNonMultiary(expression); - - public override Expression VisitSearchParameter(SearchParameterExpression expression, int context) => ConvertNonMultiary(expression); - - public override Expression VisitCompartment(CompartmentSearchExpression expression, int context) => ConvertNonMultiary(expression); - - public override Expression VisitMissingSearchParameter(MissingSearchParameterExpression expression, int context) => ConvertNonMultiary(expression); - - public override Expression VisitChained(ChainedExpression expression, int context) => ConvertNonMultiary(expression); - - private SqlRootExpression ConvertNonMultiary(Expression expression) - { - if (TryGetSearchParamTableExpressionQueryGenerator(expression, out var generator, out var kind)) - { - return SqlRootExpression.WithSearchParamTableExpressions(new SearchParamTableExpression(generator, predicate: expression, kind, chainLevel: kind == SearchParamTableExpressionKind.Chain ? 1 : 0)); - } - else - { - return SqlRootExpression.WithResourceTableExpressions((SearchParameterExpressionBase)expression); - } - } - - private bool TryGetSearchParamTableExpressionQueryGenerator(Expression expression, out SearchParamTableExpressionQueryGenerator searchParamTableExpressionGenerator, out SearchParamTableExpressionKind kind) - { - searchParamTableExpressionGenerator = expression.AcceptVisitor(_searchParamTableExpressionQueryGeneratorFactory); - switch (searchParamTableExpressionGenerator) - { - case ChainLinkQueryGenerator _: - kind = SearchParamTableExpressionKind.Chain; - break; - case IncludeQueryGenerator _: - kind = SearchParamTableExpressionKind.Include; - break; - default: - kind = SearchParamTableExpressionKind.Normal; - break; - } - - return searchParamTableExpressionGenerator != null; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/StringOverflowRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/StringOverflowRewriter.cs deleted file mode 100644 index 4efe7c1ef8..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/StringOverflowRewriter.cs +++ /dev/null @@ -1,84 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Schema; -using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.ValueSets; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// To be used with schema versions greater than or equal to . - /// Rewrites expressions over string search parameters to account for long entries that require use of the TextOverflow column. - /// - internal class StringOverflowRewriter : SqlExpressionRewriterWithInitialContext - { - public static readonly StringOverflowRewriter Instance = new(); - - public override Expression VisitSqlRoot(SqlRootExpression expression, object context) - { - IReadOnlyList visitedTableExpressions = VisitArray(expression.SearchParamTableExpressions, context); - - if (ReferenceEquals(visitedTableExpressions, expression.SearchParamTableExpressions)) - { - return expression; - } - - return new SqlRootExpression(visitedTableExpressions, expression.ResourceTableExpressions); - } - - public override Expression VisitSearchParameter(SearchParameterExpression expression, object context) - { - if (expression.Parameter.Type == SearchParamType.String || - (expression.Parameter.Type == SearchParamType.Composite && - expression.Parameter.Component.Any(c => c.ResolvedSearchParameter.Type == SearchParamType.String))) - { - return base.VisitSearchParameter(expression, expression.Parameter); - } - - return expression; - } - - public override Expression VisitString(StringExpression expression, object context) - { - if (expression.FieldName == FieldName.TokenCode) - { - return expression; - } - - // TODO: We decided to do token differently and then go back and do string same way as token. No need for TokenOverflowRewritter.cs. - - switch (expression.StringOperator) - { - case StringOperator.Equals: - case StringOperator.StartsWith: - if (expression.Value.Length <= VLatest.StringSearchParam.Text.Metadata.MaxLength) - { - // checking the Text column will be sufficient - return expression; - } - - // We need to check the TextOverflow column. But we also check the Text column to allow an index seek - string prefix = expression.Value.Substring(0, (int)VLatest.StringSearchParam.Text.Metadata.MaxLength); - - return Expression.And( - new StringExpression(expression.StringOperator, expression.FieldName, expression.ComponentIndex, prefix, expression.IgnoreCase), - new StringExpression(expression.StringOperator, SqlFieldName.TextOverflow, expression.ComponentIndex, expression.Value, expression.IgnoreCase)); - - case StringOperator.Contains: - // We need to consider the entire string, so we need to check TextOverflow if it is populated. - return Expression.Or( - expression, - new StringExpression(expression.StringOperator, SqlFieldName.TextOverflow, expression.ComponentIndex, expression.Value, expression.IgnoreCase)); - default: - throw new InvalidOperationException($"Unexpected operator '{expression.StringOperator}' for string search parameter."); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/TopRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/TopRewriter.cs deleted file mode 100644 index 7212adc790..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/TopRewriter.cs +++ /dev/null @@ -1,33 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - internal class TopRewriter : SqlExpressionRewriter - { - public static readonly TopRewriter Instance = new TopRewriter(); - - private static readonly SearchParamTableExpression _topSearchParamTableExpression = new SearchParamTableExpression(null, null, SearchParamTableExpressionKind.Top); - - public override Expression VisitSqlRoot(SqlRootExpression expression, SearchOptions context) - { - if (context.CountOnly || expression.SearchParamTableExpressions.Count == 0) - { - return expression; - } - - var newTableExpressions = new List(expression.SearchParamTableExpressions.Count + 1); - newTableExpressions.AddRange(expression.SearchParamTableExpressions); - - newTableExpressions.Add(_topSearchParamTableExpression); - - return new SqlRootExpression(newTableExpressions, expression.ResourceTableExpressions); - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/TypeConstraintVisitor.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/TypeConstraintVisitor.cs deleted file mode 100644 index 3f6342100a..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/TypeConstraintVisitor.cs +++ /dev/null @@ -1,138 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections; -using System.Collections.Generic; -using EnsureThat; -using Microsoft.Health.Fhir.Core.Features.Search; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Storage; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// Looks for _type parameters to determine the set of ResourceTypeIds allowed in an expression. - /// Assumes that has not run yet on the expression. - /// - internal class TypeConstraintVisitor : DefaultSqlExpressionVisitor<(BitArray allowedTypes, ISqlServerFhirModel model), short?> - { - private const short NoTypes = -1; - - internal static readonly TypeConstraintVisitor Instance = new(); - - /// - /// Determines which resource types are allowed in an expression. - /// - /// The expression to visit. - /// The model instance. - /// A tuple with the single allowed resource type id if there is exactly one (otherwise null) and - /// a with bits set for each resource type that is allowed, or null if no types are allowed. - public (short? singleAllowedResourceTypeId, BitArray allAllowedTypes) Visit(Expression expression, ISqlServerFhirModel model) - { - var allowedTypes = new BitArray(model.ResourceTypeIdRange.highestId + 1, true); - for (int i = 0; i < model.ResourceTypeIdRange.lowestId; i++) - { - allowedTypes[i] = false; - } - - short? singleResourceTypeId = expression?.AcceptVisitor(this, (allowedTypes, model)); - return singleResourceTypeId == NoTypes ? (null, null) : (singleResourceTypeId, allowedTypes); - } - - public override short? VisitSearchParameter(SearchParameterExpression expression, (BitArray allowedTypes, ISqlServerFhirModel model) context) - { - if (expression is { Parameter: { Name: SearchParameterNames.ResourceType } }) - { - return base.VisitSearchParameter(expression, context); - } - - return null; - } - - public override short? VisitSqlRoot(SqlRootExpression expression, (BitArray allowedTypes, ISqlServerFhirModel model) context) - { - EnsureArg.IsNotNull(context.allowedTypes, nameof(context.allowedTypes)); - EnsureArg.IsNotNull(context.model, nameof(context.model)); - - return HandleAndedExpressions(expression.ResourceTableExpressions, context); - } - - public override short? VisitMultiary(MultiaryExpression expression, (BitArray allowedTypes, ISqlServerFhirModel model) context) - { - EnsureArg.IsNotNull(context.allowedTypes, nameof(context.allowedTypes)); - EnsureArg.IsNotNull(context.model, nameof(context.model)); - - if (expression.MultiaryOperation == MultiaryOperator.And) - { - return HandleAndedExpressions(expression.Expressions, context); - } - - // assuming this OR to be within a _type SearchParameterExpression - - var orArray = new BitArray(context.model.ResourceTypeIdRange.highestId + 1, false); - foreach (Expression childExpression in expression.Expressions) - { - // pass null as the bitarray to save on allocations and rely on the return parameter. - short? single = childExpression.AcceptVisitor(this, (null, context.model)); - if (single is > 0) - { - orArray[single.Value] = true; - } - } - - context.allowedTypes.And(orArray); - - int allowedTypesCount = 0; - short lastAllowedType = 0; - for (short i = 0; i < context.allowedTypes.Count; i++) - { - if (context.allowedTypes[i]) - { - allowedTypesCount++; - lastAllowedType = i; - } - } - - return allowedTypesCount switch { 0 => NoTypes, 1 => lastAllowedType, _ => null}; - } - - private short? HandleAndedExpressions(IReadOnlyList expressions, (BitArray allowedTypes, ISqlServerFhirModel model) context) - { - short? overallResult = null; - foreach (Expression childExpression in expressions) - { - short? result = childExpression.AcceptVisitor(this, context); - if (result != null) - { - overallResult = result; - } - } - - return overallResult; - } - - public override short? VisitString(StringExpression expression, (BitArray allowedTypes, ISqlServerFhirModel model) context) - { - EnsureArg.IsNotNull(context.model, nameof(context.model)); - - short resourceTypeId = context.model.GetResourceTypeId(expression.Value); - - if (context.allowedTypes != null) - { - bool isTypeCurrentlyAllowed = context.allowedTypes[resourceTypeId]; - context.allowedTypes.SetAll(false); - if (isTypeCurrentlyAllowed) - { - context.allowedTypes[resourceTypeId] = true; - return resourceTypeId; - } - - return NoTypes; - } - - return resourceTypeId; - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/UntypedReferenceRewriter.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/UntypedReferenceRewriter.cs deleted file mode 100644 index d34e50f855..0000000000 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/UntypedReferenceRewriter.cs +++ /dev/null @@ -1,192 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Health.Fhir.Core.Features.Search.Expressions; -using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.ValueSets; - -namespace Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors -{ - /// - /// For expressions over a reference search parameter where the type is not specified - /// (e.g. "abc" instead of "Patient/123"), rewrites the expression to specify the target type(s). - /// If the search parameter has one target type, adds an equality filter. - /// If the search parameter has multiple target types, adds an OR expression with all possible types. - /// - internal class UntypedReferenceRewriter : ExpressionRewriterWithInitialContext - { - public static readonly UntypedReferenceRewriter Instance = new UntypedReferenceRewriter(); - - public override Expression VisitSearchParameter(SearchParameterExpression expression, object context) - { - // Handle reference search parameters as well as composite search parameters with one or more reference components. - // We first create a bitmask with bits set representing the component indexes that are candidates for this rule. - // Bit 0 is for reference, non-composite parameters where the reference has one or more target resource types - // Bits 1 and up represent the component indexes (plus one) where the component is a reference search parameter with one or more target types - - int componentCandidates = expression.Parameter.Type switch - { - SearchParamType.Reference when expression.Parameter.TargetResourceTypes?.Count >= 1 => 1, - SearchParamType.Composite => - expression.Parameter.Component.Aggregate( - (index: 1, flags: 0), - (acc, c) => (index: acc.index + 1, flags: acc.flags | (c.ResolvedSearchParameter.Type == SearchParamType.Reference && c.ResolvedSearchParameter.TargetResourceTypes?.Count >= 1 ? 1 << acc.index : 0))) - .flags, - _ => 0, - }; - - if (componentCandidates == 0) - { - // nothing to do for this expression - return expression; - } - - if (expression.Expression is MultiaryExpression multiaryExpression && multiaryExpression.MultiaryOperation == MultiaryOperator.Or) - { - Expression[] rewrittenExpressions = null; - for (var i = 0; i < multiaryExpression.Expressions.Count; i++) - { - Expression subexpression = multiaryExpression.Expressions[i]; - Expression rewrittenSubexpression = RewriteSubexpression(expression.Parameter, subexpression, componentCandidates); - - if (!ReferenceEquals(rewrittenSubexpression, subexpression)) - { - EnsureAllocatedAndPopulated(ref rewrittenExpressions, multiaryExpression.Expressions, i); - } - - if (rewrittenExpressions != null) - { - rewrittenExpressions[i] = rewrittenSubexpression; - } - } - - if (rewrittenExpressions == null) - { - return expression; - } - - return Expression.SearchParameter(expression.Parameter, Expression.Or(rewrittenExpressions)); - } - - // a single expression (possibly ANDs), not multiple expressions ORed together - - Expression rewrittenExpression = RewriteSubexpression(expression.Parameter, expression.Expression, componentCandidates); - - if (ReferenceEquals(rewrittenExpression, expression.Expression)) - { - return expression; - } - - return Expression.SearchParameter(expression.Parameter, rewrittenExpression); - } - - /// - /// Attempts to rewrite a expression adding in a reference type predicate where missing and possible. - /// Can be called for individual operands of an OR expression. - /// - /// The context search parameter - /// The expression to rewrite - /// - /// A bitset with bits set at indexes where components are reference search parameters that have one or more target types. - /// Bit index 0 is used for non-composite search parameters. Bits 1 and up are the one-based indexes of the components of a composite search parameter. - /// A rewritten expression or the same instance if no changes made. - private static Expression RewriteSubexpression(SearchParameterInfo searchParameter, Expression expression, int componentCandidates) - { - // now see which components have a expression on the reference type - int componentsPresent = expression.AcceptVisitor(ParameterPredicateVisitor.Instance, null); - - // Now determine which components should get a type expression added to it. - // componentCandidates will have bits set for each component that we could provide a known type expression for. - // componentsPresent will have bits set for each component that actually has a type expression. - // So the components that we need to provide a type expression for can be obtained by a set difference, - // which we can do with a bitwise complement (~) and bitwise intersection (&). - - int componentsToFill = componentCandidates & ~componentsPresent; - - if (componentsToFill == 0) - { - return expression; - } - - List newExpressionsToBeAnded; - if (expression is MultiaryExpression me && me.MultiaryOperation == MultiaryOperator.And) - { - newExpressionsToBeAnded = new List(me.Expressions.Count + 1); - newExpressionsToBeAnded.AddRange(me.Expressions); - } - else - { - newExpressionsToBeAnded = new List(1) { expression }; - } - - // Now go through each bit set on componentsToFill. - // For each of those, add in a type expression - - for (int i = 0, x = componentsToFill; x != 0; i++, x >>= 1) - { - if ((x & 1) == 0) - { - continue; - } - - int? actualComponentIndex = i == 0 ? null : (i - 1); - - IReadOnlyList targetResourceTypes = actualComponentIndex == null - ? searchParameter.TargetResourceTypes - : searchParameter.Component[actualComponentIndex.Value].ResolvedSearchParameter.TargetResourceTypes; - - if (targetResourceTypes.Count == 1) - { - // Single target type - add simple equality expression - newExpressionsToBeAnded.Add(Expression.StringEquals(FieldName.ReferenceResourceType, actualComponentIndex, targetResourceTypes[0], false)); - } - else - { - // Multiple target types - add OR expression with IS NULL to include untyped string references - var typeExpressions = new Expression[targetResourceTypes.Count + 1]; - for (int t = 0; t < targetResourceTypes.Count; t++) - { - typeExpressions[t] = Expression.StringEquals(FieldName.ReferenceResourceType, actualComponentIndex, targetResourceTypes[t], false); - } - - typeExpressions[targetResourceTypes.Count] = Expression.Missing(FieldName.ReferenceResourceType, actualComponentIndex); - newExpressionsToBeAnded.Add(Expression.Or(typeExpressions)); - } - } - - return Expression.And(newExpressionsToBeAnded); - } - - private class ParameterPredicateVisitor : DefaultExpressionVisitor - { - internal static readonly ParameterPredicateVisitor Instance = new ParameterPredicateVisitor(); - - private ParameterPredicateVisitor() - : base((acc, curr) => acc | curr) - { - } - - public override int VisitMultiary(MultiaryExpression expression, object context) - { - if (expression.MultiaryOperation != MultiaryOperator.And) - { - throw new InvalidOperationException($"Unexpected {nameof(MultiaryExpression)}.{expression.MultiaryOperation}"); - } - - return base.VisitMultiary(expression, context); - } - - public override int VisitString(StringExpression expression, object context) - { - return expression.FieldName == FieldName.ReferenceResourceType - ? 1 << (expression.ComponentIndex + 1 ?? 0) - : 0; - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/QueryPlanReuseChecker.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/QueryPlanReuseChecker.cs index e8cff24c14..a422a59ca2 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/QueryPlanReuseChecker.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/QueryPlanReuseChecker.cs @@ -19,7 +19,6 @@ using Microsoft.Extensions.Options; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Messages.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; using Microsoft.Health.Fhir.SqlServer.Features.Storage; using Microsoft.Health.Fhir.SqlServer.Features.Watchdogs; using Microsoft.Health.Fhir.SqlServer.Registration; diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlQueryHashCalculator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlQueryHashCalculator.cs index 1361dff840..58a3b0e179 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlQueryHashCalculator.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlQueryHashCalculator.cs @@ -5,7 +5,7 @@ using System; using Microsoft.Health.Core.Extensions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; +using Microsoft.Health.Fhir.Core.Features.Persistence; namespace Microsoft.Health.Fhir.SqlServer.Features.Search { @@ -19,14 +19,19 @@ public string CalculateHash(string query) // This method negates effect of the AddParametersHash(). This is done this way to keep current SQL generator logic. internal static string RemoveParametersHash(string query) { - var hashStartIndex = query.IndexOf(SqlQueryGenerator.ParametersHashStart, StringComparison.OrdinalIgnoreCase); + if (query == null) + { + throw new BadRequestException(nameof(query)); + } + + var hashStartIndex = query.IndexOf(SqlSearchConstants.ParametersHashStart, StringComparison.OrdinalIgnoreCase); if (hashStartIndex < 0) // no parameters hash { return query; } - var hashEndIndex = query[hashStartIndex..].IndexOf(SqlQueryGenerator.ParametersHashEnd, StringComparison.OrdinalIgnoreCase); - var hashLine = query[hashStartIndex..(hashStartIndex + hashEndIndex + SqlQueryGenerator.ParametersHashStart.Length)]; + var hashEndIndex = query[hashStartIndex..].IndexOf(SqlSearchConstants.ParametersHashEnd, StringComparison.OrdinalIgnoreCase); + var hashLine = query[hashStartIndex..(hashStartIndex + hashEndIndex + SqlSearchConstants.ParametersHashEnd.Length)]; return query.Replace(hashLine, string.Empty, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchConstants.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchConstants.cs index b3282c7537..2b871bfe71 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchConstants.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchConstants.cs @@ -8,5 +8,7 @@ namespace Microsoft.Health.Fhir.SqlServer.Features.Search internal static class SqlSearchConstants { public const string SortSentinelValueForCt = "sentinelSortValue"; + public const string ParametersHashStart = "/* HASH "; + public const string ParametersHashEnd = " */"; } } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchOptions.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchOptions.cs index 5ea3620684..96be3266ea 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchOptions.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchOptions.cs @@ -3,6 +3,7 @@ // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- +using System.Linq; using Microsoft.Health.Fhir.Core.Features.Search; namespace Microsoft.Health.Fhir.SqlServer.Features.Search @@ -15,6 +16,13 @@ public SqlSearchOptions(SearchOptions searchOptions) { } + public SqlSearchOptions(SqlSearchOptions sqlSearchOptions) + : base(sqlSearchOptions) + { + SortQuerySecondPhase = sqlSearchOptions.SortQuerySecondPhase; + DidWeSearchForSortValue = sqlSearchOptions.DidWeSearchForSortValue; + } + /// /// Marks whether we need to execute the second set of queries for (certain types of) sort. /// @@ -23,7 +31,18 @@ public SqlSearchOptions(SearchOptions searchOptions) /// /// Sets whether this search query is of type sort with filter. /// - public bool IsSortWithFilter { get; internal set; } = false; + public bool IsSortWithFilter + { + get + { + if (Sort.Count == 0) + { + return false; + } + + return QueryParams.ContainsKey(Sort[0].searchParameterInfo.Code); + } + } /// /// Keeps track of whether we searched for sort values as part of the current SQL query. @@ -33,7 +52,18 @@ public SqlSearchOptions(SearchOptions searchOptions) /// /// Keeps track of whether missing modifier is specified for search parameter used in sort. /// - public bool SortHasMissingModifier { get; internal set; } + public bool SortHasMissingModifier + { + get + { + if (Sort.Count == 0) + { + return false; + } + + return QueryParams.ContainsKey(Sort[0].searchParameterInfo.Code + ":missing"); + } + } /// /// Set when a SMART compartment membership context was attached to the root expression for this diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/DateTimeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/DateTimeSqlParser.cs new file mode 100644 index 0000000000..a0ea8e708f --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/DateTimeSqlParser.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Text; +using Microsoft.Health.Fhir.Core.Features.Search.SearchValues; +using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class DateTimeSqlParser : BaseSqlParser + { + private readonly string _dateTimeFormat = "yyyy-MM-ddTHH:mm:ss.fffffff"; + + public DateTimeSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection) + { + SetTableName("DateTimeSearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + var parsedValue = ParseValue(value, out var valueModifier); + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + + return valueModifier switch + { + "gt" => $"{tableName}.EndDateTime{suffix} > '{parsedValue.End.ToString(_dateTimeFormat)}'", + "ge" => $"{tableName}.EndDateTime{suffix} >= '{parsedValue.Start.ToString(_dateTimeFormat)}'", + "lt" => $"{tableName}.StartDateTime{suffix} < '{parsedValue.Start.ToString(_dateTimeFormat)}'", + "le" => $"{tableName}.StartDateTime{suffix} <= '{parsedValue.End.ToString(_dateTimeFormat)}'", + "sa" => $"{tableName}.StartDateTime{suffix} > '{parsedValue.End.ToString(_dateTimeFormat)}'", + "eb" => $"{tableName}.EndDateTime{suffix} < '{parsedValue.Start.ToString(_dateTimeFormat)}'", + "ne" => $"({tableName}.EndDateTime{suffix} > '{parsedValue.End.ToString(_dateTimeFormat)}' OR {tableName}.StartDateTime{suffix} < '{parsedValue.Start.ToString(_dateTimeFormat)}')", + "eq" => $"{tableName}.EndDateTime{suffix} >= '{parsedValue.Start.ToString(_dateTimeFormat)}' AND {tableName}.StartDateTime{suffix} <= '{parsedValue.End.ToString(_dateTimeFormat)}'", + _ => throw new InvalidOperationException($"Unsupported modifier: {valueModifier}"), + }; + } + + public static DateTimeSearchValue ParseValue(string value, out string modifier) + { + modifier = "eq"; + + if (string.IsNullOrEmpty(value)) + { + return null; + } + + // Check for comparison prefixes + string actualValue = value; + + if (value.StartsWith("ge", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("le", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("gt", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("lt", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("eq", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("ne", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("sa", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("eb", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("ap", StringComparison.OrdinalIgnoreCase)) + { + modifier = value.Substring(0, 2); + actualValue = value.Substring(2); + } + + // Escape single quotes by doubling them + var parsed = DateTimeSearchValue.Parse(actualValue); + + return parsed; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/NumberSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/NumberSqlParser.cs new file mode 100644 index 0000000000..e2b840c265 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/NumberSqlParser.cs @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Runtime.Serialization; +using System.Text; +using Microsoft.SqlServer.Management.XEvent; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class NumberSqlParser : BaseSqlParser + { + public NumberSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection) + { + SetTableName("NumberSearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + var parsedValue = ParseValue(value, out var valueModifier); + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + + return valueModifier switch + { + "gt" => $"{tableName}.HighValue{suffix} > {parsedValue}", + "ge" => $"{tableName}.HighValue{suffix} >= {parsedValue}", + "lt" => $"{tableName}.LowValue{suffix} < {parsedValue}", + "le" => $"{tableName}.LowValue{suffix} <= {parsedValue}", + "sa" => $"{tableName}.LowValue{suffix} > {parsedValue}", + "eb" => $"{tableName}.HighValue{suffix} < {parsedValue}", + "ne" => $"({tableName}.HighValue{suffix} > {parsedValue} OR {tableName}.LowValue{suffix} < {parsedValue})", + "eq" => $"{tableName}.HighValue{suffix} >= {parsedValue} AND {tableName}.LowValue{suffix} <= {parsedValue}", + _ => throw new InvalidOperationException($"Unsupported modifier: {valueModifier}"), + }; + } + + public static string ParseValue(string value, out string modifier) + { + modifier = "eq"; + + if (string.IsNullOrEmpty(value)) + { + return "''"; + } + + // Check for comparison prefixes + string actualValue = value; + + if (value.StartsWith("ge", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("le", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("gt", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("lt", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("eq", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("ne", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("sa", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("eb", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("ap", StringComparison.OrdinalIgnoreCase)) + { + modifier = value.Substring(0, 2); + actualValue = value.Substring(2); + } + + var parsedValue = double.TryParse(actualValue, out var numericValue) ? numericValue : throw new SerializationException($"Invalid number value: {actualValue}"); + return parsedValue.ToString(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/QuantitySqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/QuantitySqlParser.cs new file mode 100644 index 0000000000..cfd9f011b5 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/QuantitySqlParser.cs @@ -0,0 +1,121 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.BaseParsers +{ + /// + /// Parser for quantity search parameters (e.g., value-quantity, component-value-quantity). + /// Quantity parameters combine numeric values with optional system and code. + /// Format: [prefix]number|system|code or [prefix]number||code or [prefix]number + /// Examples: + /// - "5.4|http://unitsofmeasure.org|mg" - value 5.4 with system and code + /// - "5.4||mg" - value 5.4 with code only (any system) + /// - "le100.0" - less than or equal to 100.0 + /// - "gt50|http://unitsofmeasure.org|kg" - greater than 50 kg + /// + public class QuantitySqlParser : BaseSqlParser + { + public QuantitySqlParser(SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection) + { + SetTableName("QuantitySearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + if (string.IsNullOrWhiteSpace(value)) + { + return "1=1"; + } + + // Parse quantity value - format can be: + // - "[prefix]number" (just value) + // - "[prefix]number|system|code" (value with system and code) + // - "[prefix]number||code" (value with code, any system) + // - "[prefix]number|system|" (value with system, any code) + + var parts = value.Split('|', 3); + var numberPart = parts[0]; + string? system = parts.Length > 1 ? parts[1] : null; + string? code = parts.Length > 2 ? parts[2] : null; + + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + var conditions = new StringBuilder(); + + // Parse and add the numeric comparison + var numericCondition = BuildNumericCondition(numberPart, suffix, tableName); + conditions.Append(numericCondition); + + // Add system condition if specified + if (!string.IsNullOrEmpty(system)) + { + var escapedSystem = EscapeSqlValue(system); + conditions.Append($" AND {tableName}.SystemId{suffix} = (SELECT SystemId FROM dbo.System WHERE Value = {escapedSystem})"); + } + + // Add code condition if specified + if (!string.IsNullOrEmpty(code)) + { + var escapedCode = EscapeSqlValue(code); + conditions.Append($" AND {tableName}.QuantityCodeId{suffix} = (SELECT QuantityCodeId FROM dbo.QuantityCode WHERE Value = {escapedCode})"); + } + + return conditions.ToString(); + } + + /// + /// Builds the numeric portion of the WHERE clause using the value parser from NumberSqlParser. + /// + /// The numeric part of the quantity value (may include prefix like "gt", "le", etc.). + /// Optional numeric suffix for column names in composite tables. + /// The name of the table to use in the SQL condition. + /// The SQL condition for the numeric comparison. + private static string BuildNumericCondition(string numberPart, string suffix, string tableName) + { + // Reuse the NumberSqlParser's ParseValue to extract the modifier and value + var parsedValue = NumberSqlParser.ParseValue(numberPart, out var valueModifier); + + // Build the condition using the same logic as NumberSqlParser + return valueModifier switch + { + "gt" => $"{tableName}.HighValue{suffix} > {parsedValue}", + "ge" => $"{tableName}.HighValue{suffix} >= {parsedValue}", + "lt" => $"{tableName}.LowValue{suffix} < {parsedValue}", + "le" => $"{tableName}.LowValue{suffix} <= {parsedValue}", + "sa" => $"{tableName}.LowValue{suffix} > {parsedValue}", // starts after + "eb" => $"{tableName}.HighValue{suffix} < {parsedValue}", // ends before + "ne" => $"({tableName}.HighValue{suffix} > {parsedValue} OR {tableName}.LowValue{suffix} < {parsedValue})", + "eq" => $"{tableName}.HighValue{suffix} >= {parsedValue} AND {tableName}.LowValue{suffix} <= {parsedValue}", + "ap" => BuildApproximateCondition(parsedValue, suffix, tableName), // approximately + _ => throw new InvalidOperationException($"Unsupported modifier: {valueModifier}"), + }; + } + + /// + /// Builds the condition for approximate matching (ap modifier). + /// Approximate means within 10% of the specified value. + /// + /// The escaped numeric value. + /// Optional numeric suffix for column names. + /// The name of the table to use in the SQL condition. + /// The SQL condition for approximate matching. + private static string BuildApproximateCondition(string escapedValue, string suffix, string tableName) + { + // Remove the quotes added by ParseValue to do math + var numericValue = escapedValue.Trim('\''); + + // For approximate, we check if the ranges overlap when considering 10% tolerance + // The stored range is [LowValue, HighValue] + // The approximate range is [value * 0.9, value * 1.1] + // They overlap if: HighValue >= value * 0.9 AND LowValue <= value * 1.1 + return $"({tableName}.HighValue{suffix} >= {numericValue} * 0.9 AND {tableName}.LowValue{suffix} <= {numericValue} * 1.1)"; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/ReferenceSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/ReferenceSqlParser.cs new file mode 100644 index 0000000000..f2226e54ab --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/ReferenceSqlParser.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Text; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// Parser for reference search parameters (e.g., subject, patient, performer). + /// Reference parameters can be in various formats: + /// - "Patient/123" (resource type and id) + /// - "123" (just id, any resource type) + /// - "http://example.org/Patient/123" (absolute URL) + /// + public class ReferenceSqlParser : BaseSqlParser + { + private readonly ISqlServerFhirModel _fhirModel; + + public ReferenceSqlParser(SqlSearchParameterDefinitionManager parameterCollection, ISqlServerFhirModel fhirModel) + : base(parameterCollection) + { + ArgumentNullException.ThrowIfNull(fhirModel); + _fhirModel = fhirModel; + SetTableName("ReferenceSearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + if (string.IsNullOrWhiteSpace(value)) + { + return "1=1"; + } + + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + + // Parse the reference value + // Formats: + // - "ResourceType/id" - relative reference + // - "id" - just the id + // - "http://base/ResourceType/id" - absolute reference + + string? resourceType = null; + string? resourceId = null; + string? baseUri = null; + + // Check if it's an absolute URL + if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + // Parse absolute URL: http://example.com/fhir/Patient/123 + var uri = new Uri(value); + baseUri = $"{uri.Scheme}://{uri.Authority}"; + + var pathParts = uri.AbsolutePath.TrimStart('/').Split('/'); + if (pathParts.Length >= 2) + { + resourceType = pathParts[pathParts.Length - 2]; + resourceId = pathParts[pathParts.Length - 1]; + } + else if (pathParts.Length == 1) + { + resourceId = pathParts[0]; + } + } + else if (value.Contains('/', StringComparison.Ordinal)) + { + // Relative reference: ResourceType/id + var parts = value.Split('/', 2); + resourceType = parts[0]; + resourceId = parts[1]; + } + else + { + // Just the resource ID + resourceId = value; + } + + // If a type modifier is provided (e.g., :Practitioner), use it as the reference type filter. + // This takes precedence over any type parsed from the value itself. + if (!string.IsNullOrEmpty(modifier) && resourceType == null) + { + resourceType = modifier; + } + + var conditions = new StringBuilder(); + + // Build WHERE conditions + if (!string.IsNullOrEmpty(resourceId)) + { + var escapedId = EscapeSqlValue(resourceId); + conditions.Append($"{tableName}.ReferenceResourceId{suffix} = {escapedId}"); + } + + if (!string.IsNullOrEmpty(resourceType)) + { + // Look up the resource type ID using the model + if (_fhirModel.TryGetResourceTypeId(resourceType, out short resourceTypeId)) + { + if (conditions.Length > 0) + { + conditions.Append(" AND "); + } + + conditions.Append($"{tableName}.ReferenceResourceTypeId{suffix} = {resourceTypeId}"); + } + else + { + // If the resource type is not found, the search should return no results + // This is handled by returning a condition that will never match + return "1=0"; + } + } + + if (!string.IsNullOrEmpty(baseUri)) + { + var escapedBaseUri = EscapeSqlValue(baseUri); + if (conditions.Length > 0) + { + conditions.Append(" AND "); + } + + conditions.Append($"{tableName}.BaseUri{suffix} = {escapedBaseUri}"); + } + + return conditions.Length > 0 ? conditions.ToString() : "1=1"; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/StringSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/StringSqlParser.cs new file mode 100644 index 0000000000..4e7c58ec9d --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/StringSqlParser.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Text; +using Microsoft.Health.Extensions.DependencyInjection; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class StringSqlParser : BaseSqlParser + { + public StringSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection) + { + SetTableName("StringSearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + var escapedValue = value.Replace("'", "''", StringComparison.Ordinal); + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + + return modifier switch + { + "exact" => $"{tableName}.Text{(escapedValue.Length > 256 ? "Overflow" : string.Empty)}{suffix} = N'{escapedValue}' COLLATE Latin1_General_100_CS_AS", + "contains" => $"({tableName}.Text{(escapedValue.Length > 256 ? "Overflow" : string.Empty)}{suffix} like N'%{escapedValue}%')", + _ => $"({tableName}.Text{(escapedValue.Length > 256 ? "Overflow" : string.Empty)}{suffix} like N'{escapedValue}%')", + }; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/TokenSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/TokenSqlParser.cs new file mode 100644 index 0000000000..7a4d082a69 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/TokenSqlParser.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// Parser for token search parameters (e.g., code, identifier, status). + /// Token parameters can have system|code format or just code. + /// + public class TokenSqlParser : BaseSqlParser + { + public TokenSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection) + { + SetTableName("TokenSearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + if (string.IsNullOrWhiteSpace(value)) + { + return "1=1"; + } + + if (modifier.Equals("text", StringComparison.OrdinalIgnoreCase)) + { + return $"({tableName}.Text LIKE N'{value.Replace("'", "''", StringComparison.Ordinal)}%')"; + } + + // Parse token value - format can be: + // - "code" (just code) + // - "|code" (empty system with this code) + // - "system|code" (specific system and code) + // - "system|" (any code in this system) + + var parts = value.Split('|', 2); + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + var conditions = new StringBuilder(); + + if (parts.Length == 1) + { + // Just code, no system specified + conditions.Append(BuildCodeCondition(parts[0], modifier, suffix, tableName)); + } + else + { + var system = parts[0]; + var code = parts[1]; + + // System is specified + if (string.IsNullOrEmpty(system)) + { + conditions.Append($"({tableName}.SystemId{suffix} = (SELECT SystemId FROM dbo.System WHERE Value = '') OR {tableName}.SystemId{suffix} IS NULL)"); + } + else + { + var escapedSystem = EscapeSqlValue(system); + conditions.Append($"{tableName}.SystemId{suffix} = (SELECT SystemId FROM dbo.System WHERE Value = {escapedSystem})"); + } + + if (!string.IsNullOrEmpty(code)) + { + conditions.Append(" AND "); + conditions.Append(BuildCodeCondition(code, modifier, suffix, tableName)); + } + } + + return conditions.ToString(); + } + + protected override string GetTableName(string modifier) + { + if (modifier.Equals("text", StringComparison.OrdinalIgnoreCase)) + { + return "TokenText"; + } + + return "TokenSearchParam"; + } + + private static string BuildCodeCondition(string code, string modifier, string suffix, string tableName) + { + const int MaxCodeLength = 256; + + if (code.Length <= MaxCodeLength) + { + // Code fits in the Code column + var escapedCode = EscapeSqlValue(code); + return $"{tableName}.Code{suffix} = {escapedCode}"; + } + else + { + // Code is longer than 256 characters + // The first 256 characters are in Code, the rest in CodeOverflow + var codePrefix = code.Substring(0, MaxCodeLength); + var codeOverflow = code.Substring(MaxCodeLength); + + var escapedPrefix = EscapeSqlValue(codePrefix); + var escapedOverflow = EscapeSqlValue(codeOverflow); + + return $"({tableName}.Code{suffix} = {escapedPrefix} AND {tableName}.CodeOverflow{suffix} = {escapedOverflow})"; + } + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/UriSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/UriSqlParser.cs new file mode 100644 index 0000000000..00b105d8ac --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseParsers/UriSqlParser.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// Parser for URI search parameters (e.g., url, profile, identifier system). + /// URI parameters support exact match and hierarchical searches using :above and :below modifiers. + /// + public class UriSqlParser : BaseSqlParser + { + public UriSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection) + { + SetTableName("UriSearchParam"); + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + if (string.IsNullOrWhiteSpace(value)) + { + return "1=1"; + } + + var escapedUri = EscapeSqlValue(value); + var suffix = columnSuffix.HasValue ? columnSuffix.Value.ToString() : string.Empty; + + if (string.IsNullOrEmpty(modifier)) + { + // Exact match (case-sensitive) + return $"{tableName}.Uri{suffix} = {escapedUri}"; + } + + if (modifier.Equals("above", StringComparison.OrdinalIgnoreCase)) + { + // :above modifier - matches URIs that are hierarchical ancestors + // e.g., searching for :above http://example.com/a/b matches http://example.com/a + // URN schemes are excluded from hierarchical matching + return $"({escapedUri} LIKE {tableName}.Uri{suffix} + '%' AND {tableName}.Uri{suffix} NOT LIKE 'urn:%')"; + } + + if (modifier.Equals("below", StringComparison.OrdinalIgnoreCase)) + { + // :below modifier - matches URIs that are hierarchical descendants + // e.g., searching for :below http://example.com/a matches http://example.com/a/b + // URN schemes are excluded from hierarchical matching + return $"({tableName}.Uri{suffix} LIKE {escapedUri} + '%' AND {tableName}.Uri{suffix} NOT LIKE 'urn:%')"; + } + + // Unknown modifier - treat as exact match + return $"{tableName}.Uri{suffix} = {escapedUri}"; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseSqlParser.cs new file mode 100644 index 0000000000..5452881337 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/BaseSqlParser.cs @@ -0,0 +1,261 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Linq; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public abstract class BaseSqlParser : ISqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private string _tableName = string.Empty; + + protected BaseSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + _parameterCollection = parameterCollection; + } + + protected virtual string GetTableName(string modifier) + { + return _tableName; + } + + protected void SetTableName(string value) + { + _tableName = value; + } + + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + var modifier = string.Empty; + if (name.Contains(':', StringComparison.Ordinal)) + { + var parts = name.Split(':', 2); + name = parts[0]; + modifier = parts[1]; + } + + var parameter = _parameterCollection.GetByCode(name, options.ResourceTypes.FirstOrDefault()); + if (parameter == null) + { + throw new ArgumentException($"Search Parameter '{name}' not found for resource type '{options.ResourceTypes.FirstOrDefault()}'"); + } + + var builder = options.SqlQueryBuilder; + + var surrogateIdColumn = (options.ChainLevel == 0 || options.LastCteName == null) ? "ResourceSurrogateId" : "RefResourceSurrogateId"; + var typeIdColumn = (options.ChainLevel == 0 || options.LastCteName == null) ? "ResourceTypeId" : "RefResourceTypeId"; + + // Start the subquery with opening parenthesis and SELECT + var cteName = options.ChainLevel == 0 ? $"cte{options.CteNumber}" : $"cte{options.CteNumber}chain{options.ChainLevel}"; + options.ResultCteName = cteName; + builder.BeginCte(cteName); + builder.IncreaseIndent(); + + // When in a chain, select the target resource columns (what we're searching against) + builder.SelectWithModifier("DISTINCT", $"r.{typeIdColumn}", $"r.{surrogateIdColumn}"); + + if (modifier.Equals("missing", StringComparison.OrdinalIgnoreCase)) + { + builder.From(options.LastCteName ?? "dbo.Resource", "r"); + + var existsPrefix = bool.Parse(value) ? "NOT " : string.Empty; + builder.Where($"{existsPrefix}EXISTS ("); + builder.IncreaseIndent(); + builder.Select("1") + .From(GetTableName(modifier), "t") + .Where($"t.ResourceSurrogateId = r.{surrogateIdColumn}") + .And($"t.ResourceTypeId = r.{typeIdColumn}") + .And($"t.SearchParamId = {parameter.Id}"); + builder.DecreaseIndent(); + builder.AppendLine(")"); + } + else + { + builder.From(GetTableName(modifier), "t"); + + // Join on Resource table or previous CTE + builder.InnerJoin( + options.LastCteName ?? "dbo.Resource", + "r", + $"t.ResourceSurrogateId = r.{surrogateIdColumn} AND t.ResourceTypeId = r.{typeIdColumn}"); + + var tableName = "t"; + + if (modifier.Equals("not", StringComparison.OrdinalIgnoreCase)) + { + builder.Where($"NOT EXISTS ("); + builder.IncreaseIndent(); + builder.Select("1") + .From(GetTableName(modifier), "t2"); + tableName = "t2"; + builder.Where($"{tableName}.SearchParamId = {parameter.Id}"); + } + else + { + builder.Where($"{tableName}.SearchParamId = {parameter.Id}"); + } + + var values = SplitWithEscapeChar(value, ',', '\\'); + + builder.And("("); + builder.IncreaseIndent(); + + bool firstClause = true; + foreach (var v in values) + { + var whereClause = BuildWhereClause(v, modifier, columnSuffix: null, tableName: tableName); + + if (!firstClause) + { + builder.Or(whereClause); + } + else + { + builder.IncreaseIndent(2); + builder.AppendLine(whereClause); + builder.DecreaseIndent(2); + } + + firstClause = false; + } + + builder.IncreaseIndent(); + builder.AppendLine(")"); + builder.DecreaseIndent(2); + + if (modifier.Equals("not", StringComparison.OrdinalIgnoreCase)) + { + builder.And($"{tableName}.ResourceSurrogateId = t.ResourceSurrogateId"); + builder.And($"{tableName}.ResourceTypeId = t.ResourceTypeId"); + builder.DecreaseIndent(); + builder.AppendLine(")"); + } + } + + // Add base filters only on the first CTE + ParserUtil.AddFirstCteFilters(builder, options, "r"); + + builder.DecreaseIndent(); + builder.EndCte(); + } + + /// + /// Builds the WHERE clause for the search parameter. + /// + /// The search value. + /// The search modifier (if any). + /// Optional numeric suffix for column names in composite tables (e.g., 2 for "Text2"). Null for non-composite tables. + /// The table name or alias to use in the WHERE clause. + /// The SQL WHERE clause. + public abstract string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t"); + + /// + /// Returns the search table name, search param ID, and WHERE clause for use in a combined CTE. + /// This avoids creating a separate CTE for each search condition when multiple conditions + /// target the same resource type (e.g., in reverse chain groups). + /// + public (string tableName, int searchParamId, string whereClause)? GetSearchJoinInfo( + string name, + string value, + short resourceTypeId) + { + var modifier = string.Empty; + if (name.Contains(':', StringComparison.Ordinal)) + { + var parts = name.Split(':', 2); + name = parts[0]; + modifier = parts[1]; + } + + // :missing and :not modifiers need full CTE — can't be combined + if (modifier.Equals("missing", StringComparison.OrdinalIgnoreCase) || + modifier.Equals("not", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var parameter = _parameterCollection.GetByCode(name, resourceTypeId); + if (parameter == null) + { + return null; + } + + var tableName = GetTableName(modifier); + var values = SplitWithEscapeChar(value, ',', '\\'); + + var whereParts = new StringBuilder(); + bool first = true; + foreach (var v in values) + { + var clause = BuildWhereClause(v, modifier, columnSuffix: null, tableName: "t_placeholder"); + if (!first) + { + whereParts.Append(" OR "); + } + + whereParts.Append(clause); + first = false; + } + + var whereClause = values.Length > 1 ? $"({whereParts})" : whereParts.ToString(); + + return (tableName, parameter.Id, whereClause); + } + + protected static string EscapeSqlValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "''"; + } + + // Escape single quotes by doubling them + var escaped = value.Replace("'", "''", StringComparison.Ordinal); + return $"'{escaped}'"; + } + + private static string[] SplitWithEscapeChar(string value, char separator, char escapeChar) + { + var result = new System.Collections.Generic.List(); + var current = new StringBuilder(); + bool isEscaped = false; + foreach (var c in value) + { + if (isEscaped) + { + current.Append(c); + isEscaped = false; + } + else if (c == escapeChar) + { + isEscaped = true; + } + else if (c == separator) + { + result.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(c); + } + } + + result.Add(current.ToString()); + return result.ToArray(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ChainSearchEntry.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ChainSearchEntry.cs new file mode 100644 index 0000000000..86bed5a701 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ChainSearchEntry.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// A single search entry within a chain group. Represents one leaf search parameter + /// and its value to be applied to the referenced resources. + /// + public class ChainSearchEntry + { + public ChainSearchEntry(string fullParameterName, string remainingChain, string value, string referenceParamCode, string? sourceResourceType = null) + { + FullParameterName = fullParameterName; + RemainingChain = remainingChain; + Value = value; + ReferenceParamCode = referenceParamCode; + SourceResourceType = sourceResourceType; + } + + /// + /// Gets the full original parameter name (e.g., "subject:Patient.name" or "_has:Coverage:beneficiary:identifier"). + /// + public string FullParameterName { get; } + + /// + /// Gets the remaining chain after the first reference lookup (e.g., "name" or "organization:Organization.name"). + /// + public string RemainingChain { get; } + + /// + /// Gets the search value. + /// + public string Value { get; } + + /// + /// Gets the first-level reference parameter code (e.g., "subject" or "beneficiary"). + /// + public string ReferenceParamCode { get; } + + /// + /// Gets the source resource type for reverse chains (e.g., "Coverage"). Null for forward chains. + /// + public string? SourceResourceType { get; } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ChainSearchGroup.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ChainSearchGroup.cs new file mode 100644 index 0000000000..2a97c0cdba --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ChainSearchGroup.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// Represents a group of chained or reverse-chained search parameters that share the same + /// reference search parameter at the first level. By grouping them, the expensive reference + /// CTE (ReferenceSearchParam JOIN Resource) is generated only once and reused for all + /// leaf searches in the group. + /// + public class ChainSearchGroup + { + public ChainSearchGroup(string groupKey, bool isReverseChain) + { + GroupKey = groupKey; + IsReverseChain = isReverseChain; + } + + /// + /// Gets the grouping key that identifies the shared reference lookup. + /// For forward chains: the reference parameter code (e.g., "subject" or "subject:Patient"). + /// For reverse chains: "resourceType:referenceParam" (e.g., "Coverage:beneficiary"). + /// + public string GroupKey { get; } + + /// + /// Gets whether this is a reverse chain (_has) group. + /// + public bool IsReverseChain { get; } + + /// + /// Gets the individual search entries in this group. Each entry represents a leaf + /// search parameter and its value that filters the referenced resources. + /// + public IList Entries { get; } = new List(); + + /// + /// Groups chained search parameters by their first-level reference parameter. + /// Parameters that share the same reference lookup will be in the same group. + /// + public static IList GroupChainedParameters( + IDictionary> chainedParameters) + { + var groups = new Dictionary(); + + foreach (var kvp in chainedParameters) + { + var parts = kvp.Key.Split('.', 2); + if (parts.Length < 2) + { + continue; + } + + // The first part is the reference param (e.g., "subject" or "subject:Patient") + var refParam = parts[0]; + var remainingChain = parts[1]; + + // Group key is the reference param (normalized) + var groupKey = refParam.ToLowerInvariant(); + + if (!groups.TryGetValue(groupKey, out var group)) + { + group = new ChainSearchGroup(refParam, isReverseChain: false); + groups[groupKey] = group; + } + + foreach (var value in kvp.Value) + { + group.Entries.Add(new ChainSearchEntry( + fullParameterName: kvp.Key, + remainingChain: remainingChain, + value: value, + referenceParamCode: refParam)); + } + } + + return groups.Values.ToList(); + } + + /// + /// Groups reverse-chained (_has) search parameters by their resource type and reference parameter. + /// Parameters that share the same reference lookup will be in the same group. + /// + public static IList GroupReversedChainedParameters( + IDictionary> reversedChainedParameters) + { + var groups = new Dictionary(); + + foreach (var kvp in reversedChainedParameters) + { + // Format: _has::: + var parts = kvp.Key.Split(':', 4); + if (parts.Length < 4) + { + continue; + } + + var resourceType = parts[1]; + var referenceParam = parts[2]; + var searchParam = parts[3]; + + // Group key is "resourceType:referenceParam" + var groupKey = $"{resourceType}:{referenceParam}".ToLowerInvariant(); + + if (!groups.TryGetValue(groupKey, out var group)) + { + group = new ChainSearchGroup($"{resourceType}:{referenceParam}", isReverseChain: true); + groups[groupKey] = group; + } + + foreach (var value in kvp.Value) + { + group.Entries.Add(new ChainSearchEntry( + fullParameterName: kvp.Key, + remainingChain: searchParam, + value: value, + referenceParamCode: referenceParam, + sourceResourceType: resourceType)); + } + } + + return groups.Values.ToList(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/BaseCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/BaseCompositeSqlParser.cs new file mode 100644 index 0000000000..19f67b44be --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/BaseCompositeSqlParser.cs @@ -0,0 +1,210 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Linq; +using System.Text; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Base parser for composite search parameters that combine multiple component search parameters. + /// Composite parameters use the '$' separator to join component values (e.g., "code$value" or "code$low$high"). + /// Supports both two-component and three-component composite parameters. + /// + public abstract class BaseCompositeSqlParser : BaseSqlParser + { + private readonly BaseSqlParser _firstComponentParser; + private readonly BaseSqlParser _secondComponentParser; + private readonly BaseSqlParser? _thirdComponentParser; + + protected BaseCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection, + BaseSqlParser firstComponentParser, + BaseSqlParser secondComponentParser, + BaseSqlParser? thirdComponentParser = null) + : base(parameterCollection) + { + ArgumentNullException.ThrowIfNull(firstComponentParser); + ArgumentNullException.ThrowIfNull(secondComponentParser); + + _firstComponentParser = firstComponentParser; + _secondComponentParser = secondComponentParser; + _thirdComponentParser = thirdComponentParser; + } + + /// + /// Gets the first component parser. + /// + protected ISqlParser FirstComponentParser => _firstComponentParser; + + /// + /// Gets the second component parser. + /// + protected ISqlParser SecondComponentParser => _secondComponentParser; + + /// + /// Gets the third component parser (null for two-component composites). + /// + protected ISqlParser? ThirdComponentParser => _thirdComponentParser; + + /// + /// Gets whether this is a three-component composite. + /// + protected bool IsThreeComponent => _thirdComponentParser != null; + + /// + /// Determines the composite type from a SearchParameterInfo by examining its component definitions. + /// + /// The search parameter to analyze. + /// The search parameter collection to resolve component search parameters. + /// The determined composite type. + public static CompositeType DetermineCompositeType( + SearchParameterInfo searchParameter, + SqlSearchParameterDefinitionManager searchParameterCollection) + { + ArgumentNullException.ThrowIfNull(searchParameter); + ArgumentNullException.ThrowIfNull(searchParameterCollection); + + if (searchParameter.Type != SearchParamType.Composite) + { + return CompositeType.Unknown; + } + + if (searchParameter.Component == null || (searchParameter.Component.Count != 2 && searchParameter.Component.Count != 3)) + { + // Composite parameters must have exactly 2 or 3 components + return CompositeType.Unknown; + } + + var firstComponent = searchParameterCollection.GetByUrl(searchParameter.Component[0].DefinitionUrl); + var secondComponent = searchParameterCollection.GetByUrl(searchParameter.Component[1].DefinitionUrl); + + if (firstComponent == null || secondComponent == null) + { + return CompositeType.Unknown; + } + + // Check for three-component composite + if (searchParameter.Component.Count == 3) + { + var thirdComponent = searchParameterCollection.GetByUrl(searchParameter.Component[2].DefinitionUrl); + if (thirdComponent == null) + { + return CompositeType.Unknown; + } + + // Currently only Token-Number-Number is supported + return (firstComponent.SearchParameterInfo.Type, secondComponent.SearchParameterInfo.Type, thirdComponent.SearchParameterInfo.Type) switch + { + (SearchParamType.Token, SearchParamType.Number, SearchParamType.Number) => CompositeType.TokenNumberNumber, + _ => CompositeType.Unknown, + }; + } + + // Determine composite type based on component types (two-component) + return (firstComponent.SearchParameterInfo.Type, secondComponent.SearchParameterInfo.Type) switch + { + (SearchParamType.Token, SearchParamType.Token) => CompositeType.TokenToken, + (SearchParamType.Token, SearchParamType.Quantity) => CompositeType.TokenQuantity, + (SearchParamType.Token, SearchParamType.String) => CompositeType.TokenString, + (SearchParamType.Token, SearchParamType.Date) => CompositeType.TokenDate, + (SearchParamType.Reference, SearchParamType.Token) => CompositeType.TokenReference, + _ => CompositeType.Unknown, + }; + } + + public override string BuildWhereClause(string value, string modifier, int? columnSuffix = null, string tableName = "t") + { + // Composite parameters use '$' as separator between component values + // Two-component example: "http://loinc.org|1234-5$gt100" for a token$number composite + // Three-component example: "http://loinc.org|1234-5$gt100$lt200" for a token$number$number composite + var components = value.Split('$'); + + if (_thirdComponentParser != null) + { + // Three-component composite + if (components.Length != 3) + { + throw new InvalidOperationException( + $"Three-component composite search parameter value must contain exactly two '$' separators. Got: {value}"); + } + + var firstValue = components[0]; + var secondValue = components[1]; + var thirdValue = components[2]; + + // Build WHERE clause that combines all three component conditions + return BuildThreeComponentWhereClause(firstValue, secondValue, thirdValue, modifier, tableName); + } + else + { + // Two-component composite + if (components.Length != 2) + { + throw new InvalidOperationException( + $"Two-component composite search parameter value must contain exactly one '$' separator. Got: {value}"); + } + + var firstValue = components[0]; + var secondValue = components[1]; + + // Build WHERE clause that combines both component conditions + return BuildCompositeWhereClause(firstValue, secondValue, modifier, tableName); + } + } + + /// + /// Builds the WHERE clause for a two-component composite search parameter by combining both component conditions. + /// + /// The value for the first component. + /// The value for the second component. + /// The modifier applied to the search parameter (if any). + /// The table name to use in the SQL query. + /// The SQL WHERE clause combining both components. + protected string BuildCompositeWhereClause(string firstValue, string secondValue, string modifier, string tableName) + { + if (_thirdComponentParser != null) + { + throw new NotSupportedException("Two-component composite search parameters are not supported by this parser."); + } + + // Pass column suffix 1 for first component, 2 for second component + var firstWhereClause = _firstComponentParser.BuildWhereClause(firstValue, modifier, columnSuffix: 1, tableName: tableName); + var secondWhereClause = _secondComponentParser.BuildWhereClause(secondValue, modifier, columnSuffix: 2, tableName: tableName); + + return $"({firstWhereClause}) AND ({secondWhereClause})"; + } + + /// + /// Builds the WHERE clause for a three-component composite search parameter by combining all three component conditions. + /// Override this method in derived classes that support three-component composites. + /// + /// The value for the first component. + /// The value for the second component. + /// The value for the third component. + /// The modifier applied to the search parameter (if any). + /// The table name to use in the SQL query. + /// The SQL WHERE clause combining all three components. + protected string BuildThreeComponentWhereClause(string firstValue, string secondValue, string thirdValue, string modifier, string tableName) + { + if (_thirdComponentParser == null) + { + throw new NotSupportedException("Three-component composite search parameters are not supported by this parser."); + } + + // Pass column suffix 1, 2, 3 for each component respectively + var firstWhereClause = _firstComponentParser.BuildWhereClause(firstValue, modifier, columnSuffix: 1, tableName: tableName); + var secondWhereClause = _secondComponentParser.BuildWhereClause(secondValue, modifier, columnSuffix: 2, tableName: tableName); + var thirdWhereClause = _thirdComponentParser.BuildWhereClause(thirdValue, modifier, columnSuffix: 3, tableName: tableName); + + return $"({firstWhereClause}) AND ({secondWhereClause}) AND ({thirdWhereClause})"; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/CompositeType.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/CompositeType.cs new file mode 100644 index 0000000000..12fe520b47 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/CompositeType.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Defines the types of composite search parameters based on the combination of their component types. + /// + public enum CompositeType + { + /// + /// Composite of Token and Token parameters. + /// + TokenToken, + + /// + /// Composite of Token and Quantity parameters. + /// + TokenQuantity, + + /// + /// Composite of Token and String parameters. + /// + TokenString, + + /// + /// Composite of Token and Number parameters. + /// + TokenNumber, + + /// + /// Composite of Token and Date parameters. + /// + TokenDate, + + /// + /// Composite of Token and Reference parameters. + /// + TokenReference, + + /// + /// Composite of Token, Number, and Number parameters. + /// Used for range-based searches like Observation.component-code-value-quantity. + /// + TokenNumberNumber, + + /// + /// Unknown or unsupported composite type. + /// + Unknown, + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/ReferenceTokenCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/ReferenceTokenCompositeSqlParser.cs new file mode 100644 index 0000000000..17a476ecc0 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/ReferenceTokenCompositeSqlParser.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Parser for composite search parameters that combine a Token parameter with a Reference parameter. + /// Example: Observation.code-subject combining code (token) and subject (reference). + /// Format: "code|system$Patient/123" where the first part is a token and the second is a reference. + /// Reference format: [ResourceType]/[id] or just [id] (e.g., "Patient/123" or "123"). + /// + public class ReferenceTokenCompositeSqlParser : BaseCompositeSqlParser + { + public ReferenceTokenCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection, + ISqlServerFhirModel fhirModel) + : base(parameterCollection, new ReferenceSqlParser(parameterCollection, fhirModel), new TokenSqlParser(parameterCollection)) + { + SetTableName("ReferenceTokenCompositeSearchParam"); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenDateTimeCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenDateTimeCompositeSqlParser.cs new file mode 100644 index 0000000000..cc0f30de02 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenDateTimeCompositeSqlParser.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Parser for composite search parameters that combine a Token parameter with a Date parameter. + /// Example: Condition.code-onset-date combining code (token) and onsetDateTime (date). + /// Format: "code|system$ge2020-01-01" where the first part is a token and the second is a date with optional prefix. + /// Date format: [prefix]YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (e.g., "2020-01-01" or "ge2020-01-01"). + /// + public class TokenDateTimeCompositeSqlParser : BaseCompositeSqlParser + { + public TokenDateTimeCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection, new TokenSqlParser(parameterCollection), new DateTimeSqlParser(parameterCollection)) + { + SetTableName("TokenDateTimeCompositeSearchParam"); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenNumberNumberCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenNumberNumberCompositeSqlParser.cs new file mode 100644 index 0000000000..fd738fa929 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenNumberNumberCompositeSqlParser.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Parser for three-component composite search parameters that combine a Token parameter with two Number parameters. + /// This is used for range-based searches where a code identifies the measurement type and two numbers define a range. + /// Example: Observation.component-code-value-quantity combining component.code (token), + /// component.valueQuantity.value low (number), and component.valueQuantity.value high (number). + /// Format: "code|system$gt100$lt200" where the first part is a token and the second and third are numbers with optional prefixes. + /// + public class TokenNumberNumberCompositeSqlParser : BaseCompositeSqlParser + { + public TokenNumberNumberCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection, new TokenSqlParser(parameterCollection), new NumberSqlParser(parameterCollection), new NumberSqlParser(parameterCollection)) + { + SetTableName("TokenNumberNumberCompositeSearchParam"); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenQuantityCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenQuantityCompositeSqlParser.cs new file mode 100644 index 0000000000..f90f9cf6b9 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenQuantityCompositeSqlParser.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.BaseParsers; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Parser for composite search parameters that combine a Token parameter with a Quantity parameter. + /// Example: Observation.code-value-quantity combining code (token) and valueQuantity (quantity). + /// Format: "code|system$gt100||mg" where the first part is a token and the second is a quantity with optional prefix. + /// Quantity format: [prefix]value[|system|code] (e.g., "5.4|http://unitsofmeasure.org|mg" or "gt5.4"). + /// + public class TokenQuantityCompositeSqlParser : BaseCompositeSqlParser + { + public TokenQuantityCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection, new TokenSqlParser(parameterCollection), new QuantitySqlParser(parameterCollection)) + { + SetTableName("TokenQuantityCompositeSearchParam"); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenStringCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenStringCompositeSqlParser.cs new file mode 100644 index 0000000000..7d9e5f44b8 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenStringCompositeSqlParser.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Parser for composite search parameters that combine a Token parameter with a String parameter. + /// Example: Patient.identifier combining identifier.system (token) and identifier.value (string). + /// Format: "system|code$stringvalue" where the first part is a token and the second is a string. + /// + public class TokenStringCompositeSqlParser : BaseCompositeSqlParser + { + public TokenStringCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection, new TokenSqlParser(parameterCollection), new StringSqlParser(parameterCollection)) + { + SetTableName("TokenStringCompositeSearchParam"); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenTokenCompositeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenTokenCompositeSqlParser.cs new file mode 100644 index 0000000000..ddf0a9f7bc --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/CompositeParsers/TokenTokenCompositeSqlParser.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers +{ + /// + /// Parser for composite search parameters that combine two Token parameters. + /// Example: Observation.code-value-concept combining code (token) and valueCodeableConcept (token). + /// Format: "code1|system1$code2|system2" where both parts are token parameters. + /// + public class TokenTokenCompositeSqlParser : BaseCompositeSqlParser + { + public TokenTokenCompositeSqlParser( + SqlSearchParameterDefinitionManager parameterCollection) + : base(parameterCollection, new TokenSqlParser(parameterCollection), new TokenSqlParser(parameterCollection)) + { + SetTableName("TokenTokenCompositeSearchParam"); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ISqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ISqlParser.cs new file mode 100644 index 0000000000..0f81592f02 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ISqlParser.cs @@ -0,0 +1,14 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public interface ISqlParser + { + void Parse(string name, string value, ParserOptions options); + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ParserOptions.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ParserOptions.cs new file mode 100644 index 0000000000..62029a586e --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ParserOptions.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System.Collections.Generic; +using Microsoft.Health.Fhir.Core.Features.Search; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class ParserOptions + { + public ContinuationToken? ContinuationToken { get; set; } + + public IncludesContinuationToken? IncludesContinuationToken { get; set; } + + public int CteNumber { get; set; } = 0; + + public string? LastCteName { get; set; } + + public int ChainLevel { get; set; } = 0; + + public bool IsLastInChainGroup { get; set; } = false; + + public bool ParentIsForwardChain { get; set; } = false; + + public bool Sort { get; set; } + + public int Count { get; set; } = 10; + + public int IncludeCount { get; set; } = 10; + + public IList ResourceTypes { get; init; } = new List(); + + public IList ExcludedResourceTypes { get; init; } = new List(); + + public bool GetTotalCount { get; set; } + + public string? SortParameterName { get; set; } + + public bool SortDescending { get; set; } + + public bool SortIsSpecialParameter { get; set; } + + public bool SortQuerySecondPhase { get; set; } + + public string SortContinuationToken { get; set; } = string.Empty; + + public long? SortContinuationResourceSurrogateId { get; set; } + + public bool IsIterateInclude { get; set; } + + /// + /// Gets or sets the name of the result CTE produced by the parser. + /// Set by chain/reverse-chain parsers so callers know which CTE to reference. + /// + public string? ResultCteName { get; set; } + + public ResourceVersionType ResourceVersionType { get; set; } = ResourceVersionType.Latest; + + public SqlQueryBuilder SqlQueryBuilder { get; set; } = new SqlQueryBuilder(); + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ParserUtil.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ParserUtil.cs new file mode 100644 index 0000000000..090bcd9f09 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/ParserUtil.cs @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.Core.Features.Search; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + internal class ParserUtil + { + public static void AddHistoryAndDeletedCheck(SqlQueryBuilder builder, string tableAlias, bool includeHistory = false, bool includeDeleted = false) + { + if (!includeHistory) + { + builder.And($"{tableAlias}.IsHistory = 0"); + } + + if (!includeDeleted) + { + builder.And($"{tableAlias}.IsDeleted = 0"); + } + } + + public static void AddFirstCteFilters(SqlQueryBuilder builder, ParserOptions options, string tableAlias) + { + // Add base filters only on the first CTE + if (options.LastCteName == null) + { + AddHistoryAndDeletedCheck(builder, tableAlias, options.ResourceVersionType.HasFlag(ResourceVersionType.History), options.ResourceVersionType.HasFlag(ResourceVersionType.SoftDeleted)); + if (options.ResourceTypes != null && options.ResourceTypes.Count > 0) + { + var resourceTypeIds = string.Join(", ", options.ResourceTypes); + builder.And($"{tableAlias}.ResourceTypeId IN ({resourceTypeIds})"); + } + + if (options.ExcludedResourceTypes != null && options.ExcludedResourceTypes.Count > 0) + { + var excludedResourceTypeIds = string.Join(", ", options.ExcludedResourceTypes); + builder.And($"{tableAlias}.ResourceTypeId NOT IN ({excludedResourceTypeIds})"); + } + + if (options.ContinuationToken != null && options.IncludesContinuationToken == null) + { + var sortOperator = options.SortDescending ? "<" : ">"; + + if (options.SortParameterName != null && options.SortParameterName.Equals(KnownQueryParameterNames.LastUpdated, StringComparison.OrdinalIgnoreCase)) + { + builder.And($"{tableAlias}.ResourceSurrogateId {sortOperator} {options.ContinuationToken.ResourceSurrogateId}"); + } + else + { + builder.And("("); + builder.IncreaseIndent(3); + builder.AppendLine($"({tableAlias}.ResourceSurrogateId {sortOperator} {options.ContinuationToken.ResourceSurrogateId} AND {tableAlias}.ResourceTypeId = {options.ContinuationToken.ResourceTypeId})"); + builder.DecreaseIndent(); + builder.Or($"{tableAlias}.ResourceTypeId {sortOperator} {options.ContinuationToken.ResourceTypeId}"); + builder.DecreaseIndent(2); + builder.AppendLine(")"); + } + } + else if (options.IncludesContinuationToken != null) + { + builder.And($"{tableAlias}.ResourceSurrogateId >= {options.IncludesContinuationToken.MatchResourceSurrogateIdMin}") + .And($"{tableAlias}.ResourceSurrogateId <= {options.IncludesContinuationToken.MatchResourceSurrogateIdMax}") + .And($"{tableAlias}.ResourceTypeId = {options.IncludesContinuationToken.MatchResourceTypeId}"); + } + } + } + + public static void AddUnionCte(SqlQueryBuilder builder, string cteName, IList targetCtes, bool includeSort = false, bool includeRow = true) + { + builder.BeginCte(cteName); + + // When sort CTE exists, the count CTE column order is: + // ResourceTypeId, ResourceSurrogateId, SortValue, IsMatch, IsPartial, Row + // Include CTEs have: ResourceTypeId, ResourceSurrogateId, IsMatch, IsPartial + // We must use explicit columns to match the count CTE's column order + var rowExpr = includeRow ? ", Row = 0" : string.Empty; + var sortExpr = includeSort ? ", SortValue = NULL" : string.Empty; + var columns = $"ResourceTypeId, ResourceSurrogateId{sortExpr}, IsMatch, IsPartial{rowExpr}"; + + if (includeRow) + { + builder.AppendLine($"SELECT * FROM {targetCtes[0]}"); + } + else + { + builder.AppendLine($"SELECT {columns} FROM {targetCtes[0]}"); + } + + foreach (var includeCteName in targetCtes.Skip(1)) + { + builder.AppendLine("UNION ALL"); + builder.IncreaseIndent(); + builder.AppendLine($"SELECT {columns} FROM {includeCteName}"); + builder.Where($"NOT EXISTS (SELECT * FROM {targetCtes[0]} base WHERE base.ResourceSurrogateId = {includeCteName}.ResourceSurrogateId)"); + builder.DecreaseIndent(); + } + + builder.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/QueryStringParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/QueryStringParser.cs new file mode 100644 index 0000000000..a8a3a45f89 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/QueryStringParser.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public static class QueryStringParser + { + public static Dictionary> Parse(string queryString) + { + var parameters = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(queryString)) + { + return parameters; + } + + // Extract the query part after the '?' + var queryIndex = queryString.IndexOf('?', StringComparison.Ordinal); + if (queryIndex == -1) + { + return parameters; + } + + var queryPart = queryString.Substring(queryIndex + 1); + + // Split by '&' to get individual parameters + var paramPairs = queryPart.Split('&', StringSplitOptions.RemoveEmptyEntries); + + foreach (var pair in paramPairs) + { + var equalIndex = pair.IndexOf('=', StringComparison.Ordinal); + + string key, value; + if (equalIndex == -1) + { + // Parameter without value + key = Uri.UnescapeDataString(pair); + value = string.Empty; + } + else + { + key = Uri.UnescapeDataString(pair[..equalIndex]); + value = Uri.UnescapeDataString(pair[(equalIndex + 1)..]); + } + + // Add to dictionary, handling multiple values for the same key + if (!parameters.TryGetValue(key, out var list)) + { + list = new List(); + parameters[key] = list; + } + + list.Add(value); + } + + return parameters; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SearchParameterIdWrapper.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SearchParameterIdWrapper.cs new file mode 100644 index 0000000000..5921cd7530 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SearchParameterIdWrapper.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class SearchParameterIdWrapper + { + public required SearchParameterInfo SearchParameterInfo { get; init; } + + public required int Id { get; init; } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SearchParameterSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SearchParameterSqlParser.cs new file mode 100644 index 0000000000..48f1a779fb --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SearchParameterSqlParser.cs @@ -0,0 +1,1319 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.CompositeParsers; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class SearchParameterSqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly Dictionary _sqlParsers; + private readonly Dictionary _compositeSqlParsers; + private readonly SystemSqlParser _systemSqlParser; + private readonly IdSqlParser _idSqlParser; + private readonly ISqlServerFhirModel _sqlServerFhirModel; + private readonly IncludeSqlParser _includeSqlParser; + private readonly RevIncludeSqlParser _revIncludeSqlParser; + private readonly ChainedSqlParser _chainedSqlParser; + private readonly ReversedChainSqlParser _reversedChainSqlParser; + private readonly LastUpdatedSqlParser _lastUpdatedSqlParser; + private readonly SortSqlParser _sortSqlParser; + private readonly NotReferencedSqlParser _notReferencedSqlParser; + private readonly CompartmentSqlParser _compartmentSqlParser; + private readonly SmartCompartmentSqlParser _smartCompartmentSqlParser; + private readonly ILogger _logger; + + public SearchParameterSqlParser(SqlSearchParameterDefinitionManager parameterCollection, ISqlServerFhirModel fhirModel, ICompartmentDefinitionManager compartmentDefinitionManager, ILogger logger) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(fhirModel); + ArgumentNullException.ThrowIfNull(compartmentDefinitionManager); + ArgumentNullException.ThrowIfNull(logger); + + _parameterCollection = parameterCollection; + _logger = logger; + _sqlServerFhirModel = fhirModel; + _systemSqlParser = new SystemSqlParser(); + _idSqlParser = new IdSqlParser(); + _lastUpdatedSqlParser = new LastUpdatedSqlParser(); + _sqlParsers = new Dictionary() + { + { SearchParamType.Number, new NumberSqlParser(parameterCollection) }, + { SearchParamType.Date, new DateTimeSqlParser(parameterCollection) }, + { SearchParamType.String, new StringSqlParser(parameterCollection) }, + { SearchParamType.Token, new TokenSqlParser(parameterCollection) }, + { SearchParamType.Reference, new ReferenceSqlParser(parameterCollection, fhirModel) }, + { SearchParamType.Uri, new UriSqlParser(parameterCollection) }, + { SearchParamType.Quantity, new BaseParsers.QuantitySqlParser(parameterCollection) }, + }; + _compositeSqlParsers = new Dictionary() + { + { CompositeType.TokenString, new TokenStringCompositeSqlParser(parameterCollection) }, + { CompositeType.TokenToken, new TokenTokenCompositeSqlParser(parameterCollection) }, + { CompositeType.TokenQuantity, new TokenQuantityCompositeSqlParser(parameterCollection) }, + { CompositeType.TokenReference, new ReferenceTokenCompositeSqlParser(parameterCollection, fhirModel) }, + { CompositeType.TokenDate, new TokenDateTimeCompositeSqlParser(parameterCollection) }, + { CompositeType.TokenNumberNumber, new TokenNumberNumberCompositeSqlParser(parameterCollection) }, + }; + + _includeSqlParser = new IncludeSqlParser(parameterCollection, fhirModel); + _revIncludeSqlParser = new RevIncludeSqlParser(parameterCollection, fhirModel); + _chainedSqlParser = new ChainedSqlParser(parameterCollection, this, fhirModel); + _reversedChainSqlParser = new ReversedChainSqlParser(parameterCollection, this, fhirModel); + _sortSqlParser = new SortSqlParser(parameterCollection); + _notReferencedSqlParser = new NotReferencedSqlParser(parameterCollection, fhirModel); + _compartmentSqlParser = new CompartmentSqlParser(fhirModel, parameterCollection, compartmentDefinitionManager); + _smartCompartmentSqlParser = new SmartCompartmentSqlParser(fhirModel, parameterCollection, compartmentDefinitionManager); + } + + public string? ParseMultiple(IDictionary> parameters, SqlSearchOptions sqlSearchOptions, ContinuationToken? continuationToken = null, IncludesContinuationToken? includesContinuationToken = null) + { + var parametersCopy = DeepCopyParameters(parameters); + + // Enforce parameter count limit (matches SQL Server's 2100 parameter limit from the old parameterized query approach) + const int maxParameterValues = 2048; + int totalValues = 0; + foreach (var kvp in parametersCopy) + { + foreach (var v in kvp.Value) + { + totalValues += v.Split(',').Length; + } + } + + if (totalValues > maxParameterValues) + { + throw new Microsoft.Health.Fhir.Core.Exceptions.RequestNotValidException(Core.Resources.TooManyParameters); + } + + var cteIndex = 0; + string? lastCteName = null; + Dictionary> includeParameters = new(); + Dictionary> chainedParameters = new(); + Dictionary> reversedChainedParameters = new(); + Dictionary> notReferencedParameters = new(); + var parserOptions = new ParserOptions() + { + ContinuationToken = continuationToken, + IncludesContinuationToken = includesContinuationToken, + Count = sqlSearchOptions.MaxItemCount, + IncludeCount = sqlSearchOptions.IncludeCount, + GetTotalCount = sqlSearchOptions.CountOnly, + ResourceVersionType = sqlSearchOptions.ResourceVersionTypes, + }; + var sqlBuilder = parserOptions.SqlQueryBuilder; + + if (continuationToken != null) + { + _logger.LogInformation("Parsing continuation token {ContinuationToken}", continuationToken); + } + + // Extract and process _sort parameter + string? sortParameterName = null; + bool sortDescending = false; + bool sortIsSpecialParameter = false; + + if (parametersCopy.TryGetValue("_sort", out var sortValues) && sortValues.Count > 0 && !parserOptions.GetTotalCount) + { + var sortValue = sortValues[0]; // Use first sort parameter + sortDescending = sortValue.StartsWith('-'); + sortParameterName = sortDescending ? sortValue[1..] : sortValue; + + // Check if this is a special parameter (_lastUpdated or _type) + sortIsSpecialParameter = sortParameterName.Equals(SearchParameterNames.LastUpdated, StringComparison.OrdinalIgnoreCase) || + sortParameterName.Equals(SearchParameterNames.ResourceType, StringComparison.OrdinalIgnoreCase); + + if ((sqlSearchOptions.SortQuerySecondPhase && sortDescending) + || (!sqlSearchOptions.SortQuerySecondPhase && !sortDescending && !sortIsSpecialParameter && !sqlSearchOptions.IsSortWithFilter && !sqlSearchOptions.SortHasMissingModifier)) + { + if (!parametersCopy.TryAdd(sortParameterName + ":missing", new List { "true" })) + { + parametersCopy[sortParameterName + ":missing"].Add("true"); + } + } + else + { + parserOptions.SortParameterName = sortParameterName; + parserOptions.SortDescending = sortDescending; + parserOptions.SortIsSpecialParameter = sortIsSpecialParameter; + + if (parserOptions.ContinuationToken != null && !sortIsSpecialParameter) + { + // For non-special sort, extract sort value and use it in the sort CTE + parserOptions.SortContinuationToken = parserOptions.ContinuationToken.SortValue; + parserOptions.SortContinuationResourceSurrogateId = parserOptions.ContinuationToken.ResourceSurrogateId; + parserOptions.ContinuationToken = null; + } + } + } + + parametersCopy.Remove("_sort"); + + // Check for _summary=accurate parameter + if (parametersCopy.TryGetValue("_summary", out var summaryValues)) + { + if (summaryValues.Any(v => v.Equals("count", StringComparison.OrdinalIgnoreCase))) + { + parserOptions.GetTotalCount = true; + } + + parametersCopy.Remove("_summary"); + } + + if (parametersCopy.TryGetValue("_type", out var typeValues)) + { + foreach (var typeValue in typeValues.SelectMany(types => types.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Select(_sqlServerFhirModel.GetResourceTypeId)) + { + parserOptions.ResourceTypes.Add(typeValue); + } + + parametersCopy.Remove("_type"); + } + + if (parametersCopy.TryGetValue("_type:not", out var excludedTypeValues)) + { + foreach (var typeValue in excludedTypeValues.SelectMany(types => types.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Select(_sqlServerFhirModel.GetResourceTypeId)) + { + parserOptions.ExcludedResourceTypes.Add(typeValue); + } + + parametersCopy.Remove("_type:not"); + } + + parametersCopy.Remove("_elements"); + parametersCopy.Remove("_count"); + parametersCopy.Remove("_total"); + parametersCopy.Remove("ct"); + parametersCopy.Remove(KnownQueryParameterNames.IncludesContinuationToken); + parametersCopy.Remove(KnownQueryParameterNames.IncludesCount); + + // Extract compartment parameters + string? compartmentType = null; + string? compartmentId = null; + if (parametersCopy.TryGetValue("_compartmentType", out var compartmentTypeValues)) + { + compartmentType = compartmentTypeValues.FirstOrDefault(); + parametersCopy.Remove("_compartmentType"); + } + + if (parametersCopy.TryGetValue("_compartmentId", out var compartmentIdValues)) + { + compartmentId = compartmentIdValues.FirstOrDefault(); + parametersCopy.Remove("_compartmentId"); + } + + // Extract SMART compartment parameters (patient-level scope restriction) + string? smartCompartmentType = null; + string? smartCompartmentId = null; + if (parametersCopy.TryGetValue("_smartCompartmentType", out var smartCompartmentTypeValues)) + { + smartCompartmentType = smartCompartmentTypeValues.FirstOrDefault(); + parametersCopy.Remove("_smartCompartmentType"); + } + + if (parametersCopy.TryGetValue("_smartCompartmentId", out var smartCompartmentIdValues)) + { + smartCompartmentId = smartCompartmentIdValues.FirstOrDefault(); + parametersCopy.Remove("_smartCompartmentId"); + } + + // Extract fine-grained access control allowed resource types + List? allowedResourceTypes = null; + if (parametersCopy.TryGetValue("_fhirScopeAllowedTypes", out var allowedTypeValues)) + { + allowedResourceTypes = allowedTypeValues.ToList(); + parametersCopy.Remove("_fhirScopeAllowedTypes"); + + // Apply allowed types as resource type filter (intersect with existing _type if any) + if (allowedResourceTypes.Count == 1 && allowedResourceTypes[0] == "none") + { + // No types allowed — add a resource type that doesn't exist to block results + parserOptions.ResourceTypes.Clear(); + parserOptions.ResourceTypes.Add(-1); + } + else if (parserOptions.ResourceTypes.Count > 0) + { + // Intersect with already-specified _type + var allowedTypeIds = allowedResourceTypes + .Select(t => + { + try + { + return _sqlServerFhirModel.GetResourceTypeId(t); + } + catch + { + return (short)-1; + } + }) + .Where(id => id >= 0) + .ToHashSet(); + var intersection = parserOptions.ResourceTypes.Where(rt => allowedTypeIds.Contains(rt)).ToList(); + parserOptions.ResourceTypes.Clear(); + foreach (var rt in intersection) + { + parserOptions.ResourceTypes.Add(rt); + } + + if (parserOptions.ResourceTypes.Count == 0) + { + parserOptions.ResourceTypes.Add(-1); // No valid types — block results + } + } + else + { + // No _type specified, use allowed types as the filter + foreach (var t in allowedResourceTypes) + { + try + { + parserOptions.ResourceTypes.Add(_sqlServerFhirModel.GetResourceTypeId(t)); + } + catch + { + // Skip unknown types + } + } + } + } + + // *********************************************************************** Basic Search Parameters *********************************************************************** + + // If compartment search is specified, use it as the base CTE + if (!string.IsNullOrEmpty(compartmentType) && !string.IsNullOrEmpty(compartmentId)) + { + parserOptions.CteNumber = cteIndex; + _compartmentSqlParser.Parse(compartmentType, compartmentId, parserOptions); + lastCteName = $"cte{cteIndex}"; + parserOptions.LastCteName = lastCteName; + cteIndex++; + } + + // If SMART compartment is specified, use it as the base CTE (restricts to user's compartment + own resource + universal resources) + if (!string.IsNullOrEmpty(smartCompartmentType) && !string.IsNullOrEmpty(smartCompartmentId)) + { + parserOptions.CteNumber = cteIndex; + _smartCompartmentSqlParser.Parse(smartCompartmentType, smartCompartmentId, parserOptions); + lastCteName = $"cte{cteIndex}"; + parserOptions.LastCteName = lastCteName; + cteIndex++; + } + + // If no search parameters, use SystemSqlParser for basic resource retrieval (only if no compartment was already set) + if (parametersCopy.Count == 0 && lastCteName == null) + { + parserOptions.CteNumber = cteIndex; + _systemSqlParser.Parse(string.Empty, string.Empty, parserOptions); + lastCteName = $"cte{cteIndex}"; + cteIndex++; + } + else if (parametersCopy.Count > 0) + { + foreach (var kvp in parametersCopy) + { + if (kvp.Key.StartsWith("_include", StringComparison.OrdinalIgnoreCase) || kvp.Key.StartsWith("_revinclude", StringComparison.OrdinalIgnoreCase)) + { + includeParameters.Add(kvp.Key, kvp.Value); + continue; + } + + if (kvp.Key.StartsWith("_has:", StringComparison.OrdinalIgnoreCase)) + { + reversedChainedParameters.Add(kvp.Key, kvp.Value); + continue; + } + + if (string.Equals(kvp.Key, KnownQueryParameterNames.NotReferenced, StringComparison.OrdinalIgnoreCase)) + { + notReferencedParameters.Add(kvp.Key, kvp.Value); + continue; + } + + if (kvp.Key.Contains('.', StringComparison.OrdinalIgnoreCase)) + { + chainedParameters.Add(kvp.Key, kvp.Value); + continue; + } + + foreach (var value in kvp.Value) + { + var parameter = _parameterCollection.GetByCode(kvp.Key, parserOptions.ResourceTypes.FirstOrDefault()); + if (parameter == null) + { + continue; + } + + var cteName = $"cte{cteIndex}"; + parserOptions.CteNumber = cteIndex; + + Parse(kvp.Key, value, parserOptions); + + lastCteName = cteName; + parserOptions.LastCteName = lastCteName; + + cteIndex++; + } + } + } + + // *********************************************************************** Chained Search Parameters *********************************************************************** + if (chainedParameters.Count > 0) + { + var chainGroups = ChainSearchGroup.GroupChainedParameters(chainedParameters); + + foreach (var group in chainGroups) + { + if (group.Entries.Count > 1) + { + // Multiple entries share the same reference lookup — generate the ref CTE once + var sharedRefCteName = $"cte{cteIndex}chain0_ref"; + var firstEntry = group.Entries[0]; + var walkBackCteNames = new List(); + + // Generate the shared ref CTE by parsing the first entry without a shared ref + parserOptions.CteNumber = cteIndex; + _chainedSqlParser.Parse(firstEntry.FullParameterName, firstEntry.Value, parserOptions, sharedRefCteName: null); + walkBackCteNames.Add($"cte{cteIndex}"); + cteIndex++; + + // For remaining entries, reuse the shared ref CTE + for (int i = 1; i < group.Entries.Count; i++) + { + var entry = group.Entries[i]; + parserOptions.CteNumber = cteIndex; + _chainedSqlParser.Parse(entry.FullParameterName, entry.Value, parserOptions, sharedRefCteName: sharedRefCteName); + walkBackCteNames.Add($"cte{cteIndex}"); + cteIndex++; + } + + // Intersect all walk-back results so ALL chain conditions must be satisfied + var intersectCteName = $"cte{cteIndex}"; + parserOptions.SqlQueryBuilder.BeginCte(intersectCteName); + parserOptions.SqlQueryBuilder.SelectWithModifier("DISTINCT", "t0.ResourceTypeId", "t0.ResourceSurrogateId"); + parserOptions.SqlQueryBuilder.From(walkBackCteNames[0], "t0"); + for (int i = 1; i < walkBackCteNames.Count; i++) + { + parserOptions.SqlQueryBuilder.InnerJoin( + walkBackCteNames[i], + $"t{i}", + $"t{i}.ResourceSurrogateId = t0.ResourceSurrogateId AND t{i}.ResourceTypeId = t0.ResourceTypeId"); + } + + parserOptions.SqlQueryBuilder.EndCte(); + lastCteName = intersectCteName; + parserOptions.LastCteName = lastCteName; + cteIndex++; + } + else + { + // Single entry — use existing behavior + var entry = group.Entries[0]; + parserOptions.CteNumber = cteIndex; + _chainedSqlParser.Parse(entry.FullParameterName, entry.Value, parserOptions); + lastCteName = $"cte{cteIndex}"; + parserOptions.LastCteName = lastCteName; + cteIndex++; + } + } + } + + // *********************************************************************** Reversed Chained Search Parameters *********************************************************************** + if (reversedChainedParameters.Count > 0) + { + var reverseChainGroups = ChainSearchGroup.GroupReversedChainedParameters(reversedChainedParameters); + + foreach (var group in reverseChainGroups) + { + if (group.Entries.Count > 1) + { + // Try combined approach: build one CTE with multiple JOINs instead of N sequential CTEs + bool usedCombined = TryBuildCombinedReverseChain(group, parserOptions, ref cteIndex); + + if (usedCombined) + { + lastCteName = parserOptions.LastCteName; + } + else + { + // Fallback to sequential chaining for entries that can't be combined + parserOptions.IsLastInChainGroup = false; + + var sharedRefCteName = $"cte{cteIndex}chain0_ref"; + var firstEntry = group.Entries[0]; + + parserOptions.CteNumber = cteIndex; + _reversedChainSqlParser.Parse(firstEntry.FullParameterName, firstEntry.Value, parserOptions, sharedRefCteName: null, firstRefCteName: null); + cteIndex++; + + for (int i = 1; i < group.Entries.Count; i++) + { + var entry = group.Entries[i]; + parserOptions.CteNumber = cteIndex; + parserOptions.IsLastInChainGroup = i == group.Entries.Count - 1; + _reversedChainSqlParser.Parse(entry.FullParameterName, entry.Value, parserOptions, sharedRefCteName: parserOptions.ResultCteName, firstRefCteName: sharedRefCteName); + lastCteName = $"cte{cteIndex}"; + cteIndex++; + } + + parserOptions.LastCteName = lastCteName; + } + } + else + { + // Single entry — use existing behavior + parserOptions.IsLastInChainGroup = true; + var entry = group.Entries[0]; + parserOptions.CteNumber = cteIndex; + _reversedChainSqlParser.Parse(entry.FullParameterName, entry.Value, parserOptions); + lastCteName = $"cte{cteIndex}"; + parserOptions.LastCteName = lastCteName; + cteIndex++; + } + } + } + + if (lastCteName == null) + { + // No search CTEs generated (e.g., only _include/_revinclude/_not-referenced/chained params) - generate base system CTE + if (includeParameters.Count > 0 || reversedChainedParameters.Count > 0 || notReferencedParameters.Count > 0 || chainedParameters.Count > 0) + { + parserOptions.CteNumber = cteIndex; + _systemSqlParser.Parse(string.Empty, string.Empty, parserOptions); + lastCteName = $"cte{cteIndex}"; + cteIndex++; + } + else + { + return null; + } + } + + // *********************************************************************** Not Referenced Parameters *********************************************************************** + if (notReferencedParameters.Count > 0) + { + foreach (var kvp in notReferencedParameters) + { + foreach (var value in kvp.Value) + { + // Skip invalid values (no colon separator) + if (!value.Contains(':', StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var cteName = $"cte{cteIndex}"; + parserOptions.CteNumber = cteIndex; + parserOptions.LastCteName = lastCteName; + + _notReferencedSqlParser.Parse(kvp.Key, value, parserOptions); + + lastCteName = cteName; + parserOptions.LastCteName = lastCteName; + cteIndex++; + } + } + } + + // *********************************************************************** Apply Sort (if needed) *********************************************************************** + // Apply sorting AFTER getting initial results but BEFORE includes + bool hasSortCte = false; + if (!string.IsNullOrEmpty(parserOptions.SortParameterName) && !parserOptions.SortIsSpecialParameter && !parserOptions.GetTotalCount) + { + var sortCteName = $"cte{cteIndex}"; + cteIndex++; + + var sortCte = _sortSqlParser.CreateSortCte( + parserOptions.SortParameterName, + parserOptions.SortDescending, + lastCteName!, + sortCteName, + parserOptions.ResourceTypes.FirstOrDefault(), + parserOptions.SortContinuationToken, + parserOptions.SortContinuationResourceSurrogateId); + + if (sortCte != null) + { + sqlBuilder.AppendLine($",{sortCte}"); + lastCteName = sortCteName; + parserOptions.LastCteName = lastCteName; + hasSortCte = true; + } + } + + // *********************************************************************** Apply Count *********************************************************************** + if (!parserOptions.GetTotalCount) + { + var cteName = $"cte{cteIndex}"; + parserOptions.CteNumber = cteIndex; + cteIndex++; + + // When a sort CTE exists, use SortValue for ordering; otherwise use ResourceTypeId/ResourceSurrogateId + string sortDir = parserOptions.SortDescending ? "DESC" : "ASC"; + string rowOrderBy; + string outerOrderBy; + if (hasSortCte) + { + rowOrderBy = $"CASE WHEN r.SortValue IS NULL THEN 1 ELSE 0 END ASC, r.SortValue {sortDir}, r.ResourceTypeId ASC, r.ResourceSurrogateId ASC"; + outerOrderBy = $"CASE WHEN r.SortValue IS NULL THEN 1 ELSE 0 END ASC, r.SortValue {sortDir}, r.ResourceTypeId ASC, r.ResourceSurrogateId ASC"; + } + else + { + rowOrderBy = $"r.ResourceTypeId {sortDir}, r.ResourceSurrogateId {sortDir}"; + outerOrderBy = $"r.ResourceTypeId {sortDir}, r.ResourceSurrogateId {sortDir}"; + } + + sqlBuilder.BeginCte(cteName) + .SelectWithModifier($"TOP {parserOptions.Count + 1}", "*", "IsMatch = 1", "IsPartial = 0", $"Row = ROW_NUMBER() OVER (ORDER BY {rowOrderBy})") + .From(lastCteName, "r") + .OrderBy(outerOrderBy) + .EndCte(); + + lastCteName = cteName; + parserOptions.LastCteName = lastCteName; + } + + // *********************************************************************** Include Parameters *********************************************************************** + if (includeParameters.Count > 0 && !parserOptions.GetTotalCount) + { + var baseCteName = lastCteName; + + parserOptions.LastCteName = baseCteName; + + var includeCteNames = new List { baseCteName }; + + // Order include parameters so iterate includes come after their dependencies + var orderedIncludes = OrderIncludeParameters(includeParameters); + + // Process each ordered include + for (int i = 0; i < orderedIncludes.Count; i++) + { + var orderedInclude = orderedIncludes[i]; + + // Determine the LastCteName for this include + string includeLastCteName; + + if (orderedInclude.IsIterate && orderedInclude.DependsOnIndices.Count > 0) + { + // Create a union CTE of all dependency CTEs + var unionCteName = $"cte{cteIndex}"; + cteIndex++; + + var dependencyCteNames = new List(); + foreach (var depIndex in orderedInclude.DependsOnIndices) + { + // Find the dependency in the ordered list by searching for the index + var dependency = orderedIncludes.FirstOrDefault(inc => inc.OriginalIndex == depIndex); + + if (dependency != null && dependency.CteNames.Count > 0) + { + dependencyCteNames.AddRange(dependency.CteNames); + } + } + + if (dependencyCteNames.Count > 0) + { + // Create union CTE for iterate dependencies (no Row column since these are include CTEs) + ParserUtil.AddUnionCte(sqlBuilder, unionCteName, dependencyCteNames, includeRow: false); + includeLastCteName = unionCteName; + } + else + { + // No dependencies found, fall back to base CTE + includeLastCteName = baseCteName; + } + } + else + { + // Regular include uses the base CTE + includeLastCteName = baseCteName; + } + + // Process each value in this include + var includeCteName = $"cte{cteIndex}"; + parserOptions.CteNumber = cteIndex; + parserOptions.LastCteName = includeLastCteName; + parserOptions.IsIterateInclude = orderedInclude.IsIterate; + cteIndex++; + + // Choose the appropriate parser based on whether this is _include or _revinclude + ISqlParser parser = orderedInclude.ParameterName.StartsWith("_revinclude", StringComparison.OrdinalIgnoreCase) + ? _revIncludeSqlParser + : _includeSqlParser; + + parser.Parse(orderedInclude.ParameterName, orderedInclude.Value, parserOptions); + + includeCteNames.Add(includeCteName); + orderedInclude.CteNames.Add(includeCteName); + } + + sqlBuilder.AppendLine(); + + var unionCte = $"cte{cteIndex}"; + cteIndex++; + + // If there is an includes continuation token, we don't include the matched resources in the final result set. So we don't need to union the include CTEs with the base CTE + bool includeRow = true; + if (parserOptions.IncludesContinuationToken != null) + { + includeCteNames.RemoveAt(0); + includeRow = false; + } + + ParserUtil.AddUnionCte(sqlBuilder, unionCte, includeCteNames, includeSort: hasSortCte, includeRow: includeRow); + + lastCteName = unionCte; + cteIndex++; + } + + sqlBuilder.AppendLine(); + + // *********************************************************************** Get Resources *********************************************************************** + // If this is a count query, return count instead of full results + if (parserOptions.GetTotalCount) + { + sqlBuilder.Select($"COUNT_BIG(*) AS Total") + .From(lastCteName); + } + else + { + // Build the ORDER BY clause based on sort parameters + string orderByClause; + bool hasSortValue = false; + + if (!string.IsNullOrEmpty(parserOptions.SortParameterName) && parserOptions.IncludesContinuationToken == null) + { + if (parserOptions.SortIsSpecialParameter) + { + // Special parameters map directly to Resource table columns + if (parserOptions.SortParameterName.Equals(SearchParameterNames.LastUpdated, StringComparison.OrdinalIgnoreCase)) + { + // _lastUpdated maps to ResourceSurrogateId (which encodes timestamp) + orderByClause = parserOptions.SortDescending + ? "t.IsMatch DESC, t.ResourceSurrogateId DESC" + : "t.IsMatch DESC, t.ResourceSurrogateId ASC"; + } + else if (parserOptions.SortParameterName.Equals(SearchParameterNames.ResourceType, StringComparison.OrdinalIgnoreCase)) + { + // _type maps to ResourceTypeId + orderByClause = parserOptions.SortDescending + ? "t.IsMatch DESC, t.ResourceTypeId DESC, t.ResourceSurrogateId DESC" + : "t.IsMatch DESC, t.ResourceTypeId ASC, t.ResourceSurrogateId ASC"; + } + else + { + // Fallback to default ordering + orderByClause = "t.IsMatch DESC, (CASE WHEN t.IsMatch = 1 THEN t.ResourceTypeId ELSE NULL END) ASC, (CASE WHEN t.IsMatch = 1 THEN t.ResourceSurrogateId ELSE NULL END) ASC, (CASE WHEN t.IsMatch = 0 THEN t.ResourceTypeId ELSE NULL END) ASC, (CASE WHEN t.IsMatch = 0 THEN t.ResourceSurrogateId ELSE NULL END) ASC"; + } + } + else + { + // Regular search parameters - use SortSqlParser + hasSortValue = hasSortCte; + + orderByClause = SortSqlParser.CreateOrderByClause(parserOptions.SortDescending, hasSortValue); + } + } + else + { + // No sort parameter - use default ordering + orderByClause = "t.IsMatch DESC, (CASE WHEN t.IsMatch = 1 THEN t.ResourceTypeId ELSE NULL END) ASC, (CASE WHEN t.IsMatch = 1 THEN t.ResourceSurrogateId ELSE NULL END) ASC, (CASE WHEN t.IsMatch = 0 THEN t.ResourceTypeId ELSE NULL END) ASC, (CASE WHEN t.IsMatch = 0 THEN t.ResourceSurrogateId ELSE NULL END) ASC"; + } + + // Build the SELECT statement - include SortValue if it exists + var selectColumns = "r.ResourceTypeId, r.ResourceId, r.Version, r.IsDeleted, r.ResourceSurrogateId, r.RequestMethod, CAST(IsMatch AS bit) AS IsMatch, CAST(IsPartial AS bit) AS IsPartial, r.IsRawResourceMetaSet, r.SearchParamHash, r.RawResource"; + if (hasSortValue) + { + selectColumns = $"r.ResourceTypeId, r.ResourceId, r.Version, r.IsDeleted, r.ResourceSurrogateId, r.RequestMethod, CAST(f.IsMatch AS bit) AS IsMatch, CAST(f.IsPartial AS bit) AS IsPartial, r.IsRawResourceMetaSet, r.SearchParamHash, r.RawResource, f.SortValue"; + } + + sqlBuilder.Select($"*") + .From("(") + .IncreaseIndent() + .SelectWithModifier("DISTINCT", selectColumns) + .From("dbo.Resource", "r") + .JoinMultiLine("INNER", lastCteName, "f", "r.ResourceSurrogateId = f.ResourceSurrogateId", "r.ResourceTypeId = f.ResourceTypeId") + .Where("1 = 1"); + + ParserUtil.AddHistoryAndDeletedCheck(sqlBuilder, "r", parserOptions.ResourceVersionType.HasFlag(ResourceVersionType.History), parserOptions.ResourceVersionType.HasFlag(ResourceVersionType.SoftDeleted)); + + sqlBuilder.AppendLine(") AS t") + .OrderBy(orderByClause); + } + + return sqlBuilder.ToString(); + } + + public ISqlParser GetParser(string name, short resourceTypeId) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (name.StartsWith(KnownQueryParameterNames.Id, StringComparison.OrdinalIgnoreCase)) + { + return _idSqlParser; + } + + if (name.StartsWith(KnownQueryParameterNames.LastUpdated, StringComparison.OrdinalIgnoreCase)) + { + return _lastUpdatedSqlParser; + } + + if (name.Contains(KnownQueryParameterNames.ReverseChain, StringComparison.OrdinalIgnoreCase)) + { + return _reversedChainSqlParser; + } + + if (name.Contains('.', StringComparison.OrdinalIgnoreCase)) + { + return _chainedSqlParser; + } + + var parameter = _parameterCollection.GetByCode(name, resourceTypeId); + if (parameter == null) + { + throw new ArgumentException($"Search parameter '{name}' is not supported for resource type '{resourceTypeId}'."); + } + + ISqlParser? parser = null; + if (parameter.SearchParameterInfo.Type == SearchParamType.Composite) + { + var compositeType = BaseCompositeSqlParser.DetermineCompositeType(parameter.SearchParameterInfo, _parameterCollection); + if (!_compositeSqlParsers.TryGetValue(compositeType, out parser)) + { + throw new ArgumentException($"Parser not found for composite type '{compositeType}'."); + } + } + else if (!_sqlParsers.TryGetValue(parameter.SearchParameterInfo.Type, out parser)) + { + throw new ArgumentException($"Parser not found for search parameter type '{parameter.SearchParameterInfo.Type}'."); + } + + return parser; + } + + /// + /// Attempts to build a single combined CTE for a reverse chain group instead of N sequential CTEs. + /// This is much more efficient because SQL Server can optimize one CTE with multiple JOINs + /// better than N sequential CTE chains. + /// + /// True if the combined approach was used; false if fallback is needed. + private bool TryBuildCombinedReverseChain(ChainSearchGroup group, ParserOptions parserOptions, ref int cteIndex) + { + // Parse the group key to get source resource type and reference param + var groupParts = group.GroupKey.Split(':'); + if (groupParts.Length < 2) + { + return false; + } + + var sourceResourceType = groupParts[0]; + var referenceParamCode = groupParts[1]; + + short sourceResourceTypeId; + try + { + sourceResourceTypeId = _sqlServerFhirModel.GetResourceTypeId(sourceResourceType); + } + catch + { + return false; + } + + var referenceParameter = _parameterCollection.GetByCode(referenceParamCode, sourceResourceTypeId); + if (referenceParameter == null) + { + return false; + } + + // Try to get join info for all entries + var joinInfos = new List<(string tableName, int searchParamId, string whereClause, string alias)>(); + int aliasIndex = 0; + + foreach (var entry in group.Entries) + { + // Parse the search param code from the _has: format + var parts = entry.FullParameterName.Split(':', 4); + if (parts.Length < 4) + { + return false; + } + + var searchParamCode = parts[3]; + + // Special params like _type can't use the combined approach + if (searchParamCode.Equals(KnownQueryParameterNames.Type, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var parser = GetParser(searchParamCode, sourceResourceTypeId); + if (parser is not BaseSqlParser baseParser) + { + return false; + } + + var joinInfo = baseParser.GetSearchJoinInfo(searchParamCode, entry.Value, sourceResourceTypeId); + if (joinInfo == null) + { + return false; // :missing or :not modifier — fallback + } + + var alias = $"t{aliasIndex}"; + var (tableName, searchParamId, whereClause) = joinInfo.Value; + + // Replace the placeholder alias with the actual alias + whereClause = whereClause.Replace("t_placeholder", alias, StringComparison.Ordinal); + + joinInfos.Add((tableName, searchParamId, whereClause, alias)); + aliasIndex++; + } + + var builder = parserOptions.SqlQueryBuilder; + var refCteName = $"cte{cteIndex}chain0_ref"; + + // Step 1: Build the reference CTE (same as ReversedChainSqlParser) + builder.BeginCte(refCteName); + builder.Select( + "refSource.ResourceTypeId AS RefResourceTypeId", + "refSource.ResourceSurrogateId AS RefResourceSurrogateId", + "refTarget.ResourceTypeId AS ResourceTypeId", + "refTarget.ResourceSurrogateId AS ResourceSurrogateId"); + builder.From("dbo.ReferenceSearchParam", "refSource"); + builder.InnerJoin("dbo.Resource", "refTarget", "refSource.ReferenceResourceTypeId = refTarget.ResourceTypeId AND refSource.ReferenceResourceId = refTarget.ResourceId"); + + if (parserOptions.LastCteName != null) + { + builder.InnerJoin( + parserOptions.LastCteName, + "prev", + "prev.ResourceSurrogateId = refTarget.ResourceSurrogateId AND prev.ResourceTypeId = refTarget.ResourceTypeId"); + } + + builder.Where($"refSource.SearchParamId = {referenceParameter.Id}"); + builder.And($"refSource.ResourceTypeId = {sourceResourceTypeId}"); + ParserUtil.AddFirstCteFilters(builder, parserOptions, "refTarget"); + + if (parserOptions.LastCteName == null) + { + if (parserOptions.ResourceTypes != null && parserOptions.ResourceTypes.Count > 0) + { + var targetResourceTypeIds = string.Join(", ", parserOptions.ResourceTypes); + builder.And($"refTarget.ResourceTypeId IN ({targetResourceTypeIds})"); + } + + if (parserOptions.ContinuationToken != null) + { + var surrogateOperator = parserOptions.SortDescending ? "<" : ">"; + builder.And($"refTarget.ResourceSurrogateId {surrogateOperator} {parserOptions.ContinuationToken.ResourceSurrogateId}"); + + if (parserOptions.ContinuationToken.ResourceTypeId != null) + { + var typeOperator = parserOptions.SortDescending ? "<" : ">"; + builder.And($"refTarget.ResourceTypeId {typeOperator}= {parserOptions.ContinuationToken.ResourceTypeId}"); + } + } + } + + builder.EndCte(); + + // Step 2: Build ONE combined search CTE with multiple JOINs + var searchCteName = $"cte{cteIndex}chain1"; + builder.BeginCte(searchCteName); + builder.SelectWithModifier("DISTINCT", "r.RefResourceTypeId", "r.RefResourceSurrogateId"); + builder.From(refCteName, "r"); + + foreach (var (tableName, searchParamId, whereClause, alias) in joinInfos) + { + builder.InnerJoin( + $"dbo.{tableName}", + alias, + $"{alias}.ResourceSurrogateId = r.RefResourceSurrogateId AND {alias}.ResourceTypeId = r.RefResourceTypeId"); + } + + // Add WHERE clauses + bool firstWhere = true; + foreach (var (tableName, searchParamId, whereClause, alias) in joinInfos) + { + if (firstWhere) + { + builder.Where($"{alias}.SearchParamId = {searchParamId}"); + firstWhere = false; + } + else + { + builder.And($"{alias}.SearchParamId = {searchParamId}"); + } + + builder.And(whereClause); + } + + builder.EndCte(); + + // Step 3: Walk back to target resources (Patient) + var resultCteName = $"cte{cteIndex}"; + builder.BeginCte(resultCteName); + builder.SelectWithModifier("DISTINCT", "ref_cte.ResourceTypeId", "ref_cte.ResourceSurrogateId"); + builder.From(searchCteName, "search"); + builder.InnerJoin( + refCteName, + "ref_cte", + "ref_cte.RefResourceSurrogateId = search.RefResourceSurrogateId AND ref_cte.RefResourceTypeId = search.RefResourceTypeId"); + builder.EndCte(); + + parserOptions.LastCteName = resultCteName; + parserOptions.ResultCteName = resultCteName; + cteIndex++; + + return true; + } + + /// + /// Builds the SQL query for the given search parameters and options. + /// + /// The name of the search parameter. + /// The value of the search parameter. + /// The parser options. + /// If the name is null or whitespace. + /// If the search parameter is not supported or the parser is not found. + private void Parse(string name, string value, ParserOptions options) + { + var parser = GetParser(name, options.ResourceTypes.FirstOrDefault()); + if (parser == null) + { + throw new ArgumentException($"Parser not found for search parameter '{name}'."); + } + + parser.Parse(name, value, options); + } + + /// + /// Orders include parameters so that _include:iterate parameters are processed after + /// all _include parameters that produce the resources they depend on. + /// Also handles _revinclude:iterate which has reversed dependency logic. + /// + /// Examples: + /// - _include:iterate requires the SOURCE type to exist: + /// Patient?_include=Observation:subject&_include:iterate=Patient:organization + /// → Observation must be produced first, then we can follow Patient.organization + /// + /// - _revinclude:iterate requires the TARGET type to exist: + /// Patient?_include=Patient:organization&_revinclude:iterate=Observation:subject:Organization + /// → Organization must be produced first, then we can find Observations that reference it + /// + /// Dictionary of include parameter names to their values. + /// An ordered list of include parameters with their dependency information. + private List OrderIncludeParameters(Dictionary> includeParameters) + { + var allIncludes = new List(); + + // Convert all includes to OrderedInclude objects + int index = 0; + foreach (var kvp in includeParameters) + { + foreach (var value in kvp.Value) + { + var orderedInclude = new OrderedInclude + { + OriginalIndex = index, + ParameterName = kvp.Key, + Value = value, + IsIterate = kvp.Key.Contains(":iterate", StringComparison.OrdinalIgnoreCase) || kvp.Key.Contains(":recurse", StringComparison.OrdinalIgnoreCase), + IsRevInclude = kvp.Key.StartsWith("_revinclude", StringComparison.OrdinalIgnoreCase), + }; + allIncludes.Add(orderedInclude); + index++; + } + } + + // Build dependency graph + for (int i = 0; i < allIncludes.Count; i++) + { + if (!allIncludes[i].IsIterate) + { + continue; // Regular includes have no dependencies + } + + // For each iterate include, find all includes it depends on + var iterateInclude = allIncludes[i]; + var requiredResourceTypes = new HashSet(); + + // For _include:iterate, we need the SOURCE types to be present (to follow their references) + // For _revinclude:iterate, we need the TARGET types to be present (to find what references them) + if (iterateInclude.IsRevInclude) + { + // For revinclude:iterate, we need the target resource types from the parameter + var targetTypes = GetIncludeTargetResourceTypes(iterateInclude.Value); + foreach (var targetType in targetTypes) + { + requiredResourceTypes.Add(targetType); + } + } + else + { + // For include:iterate, we need the source resource type + var parts = iterateInclude.Value.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) + { + try + { + var sourceResourceTypeId = _sqlServerFhirModel.GetResourceTypeId(parts[0]); + requiredResourceTypes.Add(sourceResourceTypeId); + } + catch + { + // Invalid resource type, skip + } + } + } + + // Check all other includes to see if they produce any of the required resource types + for (int j = 0; j < allIncludes.Count; j++) + { + if (i == j) + { + continue; // Don't depend on self + } + + var potentialDependency = allIncludes[j]; + var producedTypes = new HashSet(); + + // Get all resource types this include could produce + // _include produces target types, _revinclude produces source types + var typesProduced = GetProducedResourceTypes(potentialDependency.Value, potentialDependency.IsRevInclude); + foreach (var typeId in typesProduced) + { + producedTypes.Add(typeId); + } + + // If this include produces any of the required resource types, add it as a dependency + if (requiredResourceTypes.Overlaps(producedTypes)) + { + iterateInclude.DependsOnIndices.Add(j); + } + } + } + + // Topological sort to order includes respecting dependencies + var result = new List(); + var processed = new HashSet(); + var processing = new HashSet(); + + bool TopologicalSort(int index) + { + if (processed.Contains(index)) + { + return true; // Already processed + } + + if (processing.Contains(index)) + { + // Circular dependency detected - this shouldn't happen with proper iterate semantics + // but handle gracefully by breaking the cycle + return false; + } + + processing.Add(index); + + // Process all dependencies first + foreach (var dependencyIndex in allIncludes[index].DependsOnIndices) + { + if (!TopologicalSort(dependencyIndex)) + { + // Circular dependency, skip this dependency + continue; + } + } + + processing.Remove(index); + processed.Add(index); + + // Add this include to result + result.Add(allIncludes[index]); + + return true; + } + + // Process all includes + for (int i = 0; i < allIncludes.Count; i++) + { + TopologicalSort(i); + } + + return result; + } + + /// + /// Extracts the target resource types from an include parameter value. + /// + /// The include parameter value (e.g., "Patient:organization" or "Observation:subject:Patient"). + /// A list of target resource type IDs. + private List GetIncludeTargetResourceTypes(string includeValue) + { + var targetTypes = new List(); + + // Parse the include value: ResourceType:searchParam or ResourceType:searchParam:targetType + var parts = includeValue.Split(':', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 0) + { + return targetTypes; + } + + var sourceResourceType = parts[0]; + short sourceResourceTypeId; + + try + { + sourceResourceTypeId = _sqlServerFhirModel.GetResourceTypeId(sourceResourceType); + } + catch + { + return targetTypes; + } + + // If explicit target type is specified (3 parts) + if (parts.Length >= 3) + { + try + { + var targetTypeId = _sqlServerFhirModel.GetResourceTypeId(parts[2]); + targetTypes.Add(targetTypeId); + return targetTypes; + } + catch + { + // Invalid target type, continue to infer from search parameter + } + } + + if (parts.Length >= 2) + { + IList parameters = new List(); + + if (parts[1] == "*") + { + parameters = _parameterCollection.GetByResourceType(parts[0]); + } + else + { + parameters.Add(_parameterCollection.GetByCode(parts[1], sourceResourceTypeId)); + } + + foreach (var parameter in parameters) + { + if (parameter != null && parameter.SearchParameterInfo.Type == SearchParamType.Reference) + { + foreach (var targetResourceType in parameter.SearchParameterInfo.TargetResourceTypes) + { + try + { + var targetTypeId = _sqlServerFhirModel.GetResourceTypeId(targetResourceType); + targetTypes.Add(targetTypeId); + } + catch + { + // Skip invalid resource types + } + } + } + } + } + + return targetTypes; + } + + /// + /// Gets the resource types produced by an include or revinclude operation. + /// For _include, this returns the target types (what's referenced). + /// For _revinclude, this returns the source types (what's doing the referencing). + /// + /// The include parameter value (e.g., "Patient:organization" or "Observation:subject:Patient"). + /// True if this is a _revinclude operation, false for _include. + /// A list of resource type IDs that this operation produces. + private List GetProducedResourceTypes(string includeValue, bool isRevInclude) + { + var parts = includeValue.Split(':', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 0) + { + return new List(); + } + + if (isRevInclude) + { + // For _revinclude, we produce the SOURCE resource types (what's doing the referencing) + // This is the first part of the value + var producedTypes = new List(); + try + { + var sourceTypeId = _sqlServerFhirModel.GetResourceTypeId(parts[0]); + producedTypes.Add(sourceTypeId); + } + catch + { + // Invalid resource type + } + + return producedTypes; + } + else + { + // For _include, we produce the TARGET resource types (what's referenced) + // Use the existing method for this + return GetIncludeTargetResourceTypes(includeValue); + } + } + + private static Dictionary> DeepCopyParameters(IDictionary> original) + { + var copy = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in original) + { + copy[kvp.Key] = new List(kvp.Value); + } + + return copy; + } + + /// + /// Represents an ordered include parameter with its dependencies. + /// + private class OrderedInclude + { + public int OriginalIndex { get; set; } + + public string ParameterName { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public bool IsIterate { get; set; } + + public bool IsRevInclude { get; set; } + + public HashSet DependsOnIndices { get; set; } = new HashSet(); + + public List CteNames { get; set; } = new List(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/ChainedSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/ChainedSqlParser.cs new file mode 100644 index 0000000000..ba8bf0fb53 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/ChainedSqlParser.cs @@ -0,0 +1,241 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class ChainedSqlParser : ISqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly SearchParameterSqlParser _parserSource; + private readonly ISqlServerFhirModel _model; + + public ChainedSqlParser(SqlSearchParameterDefinitionManager parameterCollection, SearchParameterSqlParser parserSource, ISqlServerFhirModel model) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(parserSource); + ArgumentNullException.ThrowIfNull(model); + + _parameterCollection = parameterCollection; + _parserSource = parserSource; + _model = model; + } + + public void Parse(string name, string value, ParserOptions options) + { + Parse(name, value, options, sharedRefCteName: null); + } + + /// + /// Parses a forward-chained search parameter. If is provided, + /// the ref CTE is assumed to already exist and will be reused instead of being generated again. + /// + public void Parse(string name, string value, ParserOptions options, string? sharedRefCteName) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + // Split the chained parameter name + var parts = name.Split('.', 2); + if (parts.Length < 2) + { + throw new ArgumentException("Invalid chained parameter format.", nameof(name)); + } + + var firstCode = parts[0]; + var remainingChain = parts[1]; + + // Look up the first parameter (should be a reference parameter) + var parameter = _parameterCollection.GetByCode(firstCode, options.ResourceTypes.FirstOrDefault()); + if (parameter == null) + { + throw new ArgumentException($"Search parameter '{firstCode}' is not supported for resource type '{options.ResourceTypes.FirstOrDefault()}'.", nameof(name)); + } + + var resourceTypeIds = parameter.SearchParameterInfo.TargetResourceTypes.Select(t => _model.GetResourceTypeId(t)).ToList(); + + var builder = options.SqlQueryBuilder; + + string chainCteName; + + if (sharedRefCteName != null) + { + // Reuse the already-generated ref CTE + chainCteName = sharedRefCteName; + + if (firstCode.Contains(':', StringComparison.OrdinalIgnoreCase)) + { + var resourceType = firstCode.Split(':')[1]; + resourceTypeIds = new List { _model.GetResourceTypeId(resourceType) }; + } + } + else + { + // Generate the ref CTE + chainCteName = $"cte{options.CteNumber}chain{options.ChainLevel}_ref"; + + builder.BeginCte(chainCteName); + builder.Select( + "refSource.ReferenceResourceTypeId AS RefResourceTypeId", + "refTarget.ResourceSurrogateId AS RefResourceSurrogateId", + "refSource.ResourceTypeId AS ResourceTypeId", + "refSource.ResourceSurrogateId AS ResourceSurrogateId"); + + builder.From("dbo.ReferenceSearchParam", "refSource"); + builder.InnerJoin("dbo.Resource", "refTarget", "refSource.ReferenceResourceTypeId = refTarget.ResourceTypeId AND refSource.ReferenceResourceId = refTarget.ResourceId"); + builder.InnerJoin( + options.LastCteName ?? "dbo.Resource", + "source", + $"source.{(options.ChainLevel > 0 ? "RefResource" : "Resource")}SurrogateId = refSource.ResourceSurrogateId AND source.{(options.ChainLevel > 0 ? "RefResource" : "Resource")}TypeId = refSource.ResourceTypeId"); + + builder.Where($"refSource.SearchParamId = {parameter.Id}"); + ParserUtil.AddHistoryAndDeletedCheck(builder, "refTarget"); + + if (firstCode.Contains(':', StringComparison.OrdinalIgnoreCase)) + { + var resourceType = firstCode.Split(':')[1]; + resourceTypeIds = new List { _model.GetResourceTypeId(resourceType) }; + builder.And($"refSource.ReferenceResourceTypeId = {resourceTypeIds[0]}"); + } + + // Add base filters only on the first CTE + ParserUtil.AddFirstCteFilters(builder, options, "source"); + + if (remainingChain.Equals(KnownQueryParameterNames.Type, StringComparison.OrdinalIgnoreCase)) + { + // _type is the terminal — add type filter inside ref CTE and close it + var valueTypeIds = value.Split(',').Select(v => _model.GetResourceTypeId(v)).ToList(); + builder.And($"refTarget.ResourceTypeId IN ({string.Join(",", valueTypeIds)})"); + } + + builder.EndCte(); + } + + if (!remainingChain.Equals(KnownQueryParameterNames.Type, StringComparison.OrdinalIgnoreCase)) + { + // Recursively parse the remaining chain + var remainingParameterParser = _parserSource.GetParser(remainingChain, resourceTypeIds[0]); + + var searchChainLevel = options.ChainLevel + 1; + var innerOptions = new ParserOptions + { + CteNumber = options.CteNumber, + LastCteName = chainCteName, + ChainLevel = searchChainLevel, + ResourceTypes = resourceTypeIds, + ParentIsForwardChain = true, + SqlQueryBuilder = builder, + }; + remainingParameterParser.Parse(remainingChain, value, innerOptions); + + chainCteName = innerOptions.ResultCteName ?? $"cte{options.CteNumber}chain{searchChainLevel}"; + } + else if (sharedRefCteName != null) + { + // _type terminal with a shared ref CTE — create a filter CTE + var typeFilterCteName = $"cte{options.CteNumber}chain{options.ChainLevel}_typefilter"; + var valueTypeIds = value.Split(',').Select(v => _model.GetResourceTypeId(v)).ToList(); + builder.BeginCte(typeFilterCteName); + builder.SelectWithModifier("DISTINCT", "RefResourceTypeId", "RefResourceSurrogateId", "ResourceTypeId", "ResourceSurrogateId"); + builder.From(chainCteName); + builder.Where($"RefResourceTypeId IN ({string.Join(",", valueTypeIds)})"); + builder.EndCte(); + chainCteName = typeFilterCteName; + } + + var baseCteName = $"cte{options.CteNumber}"; + var refCteName = sharedRefCteName ?? $"cte{options.CteNumber}chain{options.ChainLevel}_ref"; + string resultCteName; + + // When _type was the terminal (chainCteName == refCteName), the ref CTE already + // contains the filtered results — just select source resources directly from it. + bool typeWasTerminal = chainCteName == refCteName; + + if (options.ChainLevel == 0) + { + resultCteName = baseCteName; + builder.BeginCte(resultCteName); + if (typeWasTerminal) + { + // Source resources are directly in the ref CTE + builder.SelectWithModifier( + "DISTINCT", + "ResourceTypeId", + "ResourceSurrogateId"); + builder.From(refCteName); + } + else + { + // Join the search result (matching targets) back to the ref CTE to get source resources + builder.SelectWithModifier( + "DISTINCT", + "ref_cte.ResourceTypeId", + "ref_cte.ResourceSurrogateId"); + builder.From(chainCteName, "search"); + builder.InnerJoin( + refCteName, + "ref_cte", + "ref_cte.RefResourceSurrogateId = search.RefResourceSurrogateId AND ref_cte.RefResourceTypeId = search.RefResourceTypeId"); + } + + builder.EndCte(); + } + else + { + var parentCteName = $"{baseCteName}chain{options.ChainLevel - 1}"; + resultCteName = $"{parentCteName}_search"; + + if (typeWasTerminal) + { + // Source resources are directly in the ref CTE + builder.BeginCte(resultCteName); + builder.SelectWithModifier( + "DISTINCT", + "ResourceTypeId AS RefResourceTypeId", + "ResourceSurrogateId AS RefResourceSurrogateId"); + builder.From(refCteName); + builder.EndCte(); + } + else if (options.ParentIsForwardChain) + { + builder.BeginCte(resultCteName); + builder.SelectWithModifier( + "DISTINCT", + "parent.ResourceTypeId AS RefResourceTypeId", + "parent.ResourceSurrogateId AS RefResourceSurrogateId"); + builder.From(chainCteName, "child"); + builder.InnerJoin( + refCteName, + "parent", + "parent.RefResourceTypeId = child.RefResourceTypeId AND parent.RefResourceSurrogateId = child.RefResourceSurrogateId"); + builder.EndCte(); + } + else + { + builder.BeginCte(resultCteName); + builder.SelectWithModifier( + "DISTINCT", + "ResourceTypeId AS RefResourceTypeId", + "ResourceSurrogateId AS RefResourceSurrogateId"); + builder.From(chainCteName); + builder.EndCte(); + } + } + + options.ResultCteName = resultCteName; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/CompartmentSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/CompartmentSqlParser.cs new file mode 100644 index 0000000000..4a786b980a --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/CompartmentSqlParser.cs @@ -0,0 +1,168 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + /// + /// Handles compartment searches by querying the ReferenceSearchParam table. + /// A compartment search (e.g., Patient/123/Observation) finds resources that have a reference + /// search parameter pointing to the compartment owner. The compartment definition defines which + /// resource types and search parameters are relevant for each compartment type. + /// + public class CompartmentSqlParser : ISqlParser + { + private readonly ISqlServerFhirModel _model; + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly ICompartmentDefinitionManager _compartmentDefinitionManager; + + public CompartmentSqlParser( + ISqlServerFhirModel model, + SqlSearchParameterDefinitionManager parameterCollection, + ICompartmentDefinitionManager compartmentDefinitionManager) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(compartmentDefinitionManager); + _model = model; + _parameterCollection = parameterCollection; + _compartmentDefinitionManager = compartmentDefinitionManager; + } + + /// + /// Generates a CTE that filters resources by compartment membership using reference search params. + /// + /// The compartment type (e.g., "Patient", "Device"). + /// The compartment owner's resource ID (e.g., "123"). + /// Parser options containing CTE info and resource type filters. + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentNullException(nameof(value)); + } + + if (!Enum.TryParse(name, out var compartmentType)) + { + throw new InvalidOperationException($"Invalid compartment type: {name}"); + } + + // Get resource types that belong to this compartment + if (!_compartmentDefinitionManager.TryGetResourceTypes(compartmentType, out HashSet allResourceTypes)) + { + throw new InvalidOperationException($"No resource types found for compartment type: {name}"); + } + + // Filter to only the requested resource types if specified + var resourceTypesToSearch = allResourceTypes; + if (options.ResourceTypes != null && options.ResourceTypes.Count > 0) + { + var requestedTypeNames = options.ResourceTypes + .Select(id => _model.GetResourceTypeName(id)) + .Where(n => n != null) + .ToHashSet(); + resourceTypesToSearch = allResourceTypes.Where(rt => requestedTypeNames.Contains(rt)).ToHashSet(); + } + + // Build a mapping of search param ID -> applicable resource type IDs + var searchParamToResourceTypes = new Dictionary>(); + + foreach (var resourceType2 in resourceTypesToSearch) + { + if (_compartmentDefinitionManager.TryGetSearchParams(resourceType2, compartmentType, out HashSet searchParamNames)) + { + foreach (var searchParamName in searchParamNames) + { + try + { + var paramWrapper = _parameterCollection.GetByCode(searchParamName, resourceType2); + if (paramWrapper != null) + { + short paramId = (short)paramWrapper.Id; + if (!searchParamToResourceTypes.TryGetValue(paramId, out var rtSet)) + { + rtSet = new HashSet(); + searchParamToResourceTypes[paramId] = rtSet; + } + + short rtId = _model.GetResourceTypeId(resourceType2); + rtSet.Add(rtId); + } + } + catch + { + // Skip search params that can't be resolved + } + } + } + } + + if (searchParamToResourceTypes.Count == 0) + { + // No valid search params found - generate a CTE that returns nothing + var sqlBuilder = options.SqlQueryBuilder; + sqlBuilder.BeginCte($"cte{options.CteNumber}"); + sqlBuilder.Select("r.ResourceTypeId", "r.ResourceSurrogateId"); + sqlBuilder.From("dbo.Resource", "r"); + sqlBuilder.Where("1 = 0"); + sqlBuilder.EndCte(); + return; + } + + // Generate the CTE using ReferenceSearchParam table + var sql = options.SqlQueryBuilder; + var escapedValue = value.Replace("'", "''", StringComparison.Ordinal); + short compartmentResourceTypeId = _model.GetResourceTypeId(name); + + sql.BeginCte($"cte{options.CteNumber}"); + sql.Select("r.ResourceTypeId", "r.ResourceSurrogateId"); + sql.From("dbo.Resource", "r"); + sql.Join("INNER", "dbo.ReferenceSearchParam", "ref1", "r.ResourceTypeId = ref1.ResourceTypeId AND r.ResourceSurrogateId = ref1.ResourceSurrogateId"); + + sql.Where("r.IsHistory = 0") + .And("r.IsDeleted = 0") + .And($"ref1.ReferenceResourceTypeId = {compartmentResourceTypeId}") + .And($"ref1.ReferenceResourceId = '{escapedValue}'"); + + // Build the OR condition: (SearchParamId = X AND ResourceTypeId IN (...)) OR ... + var orConditions = new List(); + foreach (var kvp in searchParamToResourceTypes) + { + short searchParamId = kvp.Key; + var resourceTypeIds = kvp.Value; + var rtIdsStr = string.Join(", ", resourceTypeIds.OrderBy(x => x)); + + if (resourceTypeIds.Count == 1) + { + orConditions.Add($"(ref1.SearchParamId = {searchParamId} AND r.ResourceTypeId = {rtIdsStr})"); + } + else + { + orConditions.Add($"(ref1.SearchParamId = {searchParamId} AND r.ResourceTypeId IN ({rtIdsStr}))"); + } + } + + sql.And($"({string.Join("\n OR ", orConditions)})"); + + // Apply continuation token for pagination + ParserUtil.AddFirstCteFilters(sql, options, "r"); + + sql.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/IdSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/IdSqlParser.cs new file mode 100644 index 0000000000..3da5e06dad --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/IdSqlParser.cs @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Linq; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + /// + /// Parser for the _id search parameter. + /// Searches directly on the Resource table's ResourceId column. + /// + public class IdSqlParser : ISqlParser + { + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(value)); + } + + var parameterParts = name.Split(':'); + var modifier = parameterParts.Length > 1 ? parameterParts[1] : string.Empty; + + // Handle comma-separated list of IDs (e.g., _id=123,456,789) + var ids = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (ids.Length == 0) + { + throw new ArgumentException("No valid IDs provided.", nameof(value)); + } + + var sqlBuilder = options.SqlQueryBuilder; + var cteName = options.ChainLevel == 0 ? $"cte{options.CteNumber}" : $"cte{options.CteNumber}chain{options.ChainLevel}"; + options.ResultCteName = cteName; + + var surrogateIdColumn = (options.ChainLevel == 0 || options.LastCteName == null) ? "ResourceSurrogateId" : "RefResourceSurrogateId"; + var typeIdColumn = (options.ChainLevel == 0 || options.LastCteName == null) ? "ResourceTypeId" : "RefResourceTypeId"; + + sqlBuilder.BeginCte(cteName); + sqlBuilder.SelectWithModifier("DISTINCT", $"r.ResourceTypeId AS {typeIdColumn}", $"r.ResourceSurrogateId AS {surrogateIdColumn}"); + sqlBuilder.From("dbo.Resource", "r"); + + if (options.LastCteName != null) + { + sqlBuilder.JoinMultiLine("INNER", options.LastCteName, "lcte", $"r.ResourceSurrogateId = lcte.{surrogateIdColumn}", $"r.ResourceTypeId = lcte.{typeIdColumn}"); + } + + // Build WHERE clause for ResourceId matching + if (ids.Length == 1) + { + var escapedId = EscapeSqlValue(ids[0]); + sqlBuilder.Where($"r.ResourceId {(modifier.Equals("not", StringComparison.OrdinalIgnoreCase) ? "<>" : "=")} {escapedId}"); + } + else + { + // Multiple IDs - use IN clause + var escapedIds = string.Join(", ", ids.Select(EscapeSqlValue)); + sqlBuilder.Where($"r.ResourceId {(modifier.Equals("not", StringComparison.OrdinalIgnoreCase) ? "NOT IN" : "IN")} ({escapedIds})"); + } + + // Add base filters only on the first CTE + ParserUtil.AddFirstCteFilters(sqlBuilder, options, "r"); + + sqlBuilder.EndCte(); + } + + private static string EscapeSqlValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "''"; + } + + // Escape single quotes by doubling them + var escaped = value.Replace("'", "''", StringComparison.Ordinal); + return $"'{escaped}'"; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/IncludeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/IncludeSqlParser.cs new file mode 100644 index 0000000000..56602b3780 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/IncludeSqlParser.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class IncludeSqlParser : ISqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly ISqlServerFhirModel _model; + + public IncludeSqlParser(SqlSearchParameterDefinitionManager parameterCollection, ISqlServerFhirModel model) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(model); + _parameterCollection = parameterCollection; + _model = model; + } + + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + var parts = value.Split(':'); + SearchParameterIdWrapper? parameter = null; + var wildcardResourceType = false; + var wildcardSearchParameter = false; + short resourceTypeId = 0; + var targetResourceTypeIds = new List(); + + if (!string.Equals(parts[0], "*", StringComparison.OrdinalIgnoreCase)) + { + resourceTypeId = _model.GetResourceTypeId(parts[0]); + } + else + { + wildcardResourceType = true; + } + + if (parts.Length > 1 && !string.Equals(parts[1], "*", StringComparison.OrdinalIgnoreCase)) + { + parameter = _parameterCollection.GetByCode(parts[1], resourceTypeId); + } + else + { + wildcardSearchParameter = true; + } + + if (parts.Length > 2) + { + var targetResourceTypes = parts[2].Split(','); + targetResourceTypeIds = targetResourceTypes.Select(x => _model.GetResourceTypeId(x)).ToList(); + } + + if (parameter == null && !wildcardSearchParameter) + { + throw new ArgumentException("No search parameter found for the given resource type and code.", nameof(value)); + } + + if (string.IsNullOrWhiteSpace(options.LastCteName)) + { + throw new ArgumentException("LastCteName cannot be null or whitespace."); + } + + var sqlBuilder = options.SqlQueryBuilder; + sqlBuilder.BeginCte("cte" + options.CteNumber); + sqlBuilder.SelectWithModifier($"DISTINCT TOP {options.IncludeCount + 1}", "refTarget.ResourceTypeId", "refTarget.ResourceSurrogateId", "0 AS IsMatch", $"CASE WHEN count_big(*) over() > {options.IncludeCount} THEN 1 ELSE 0 END AS IsPartial"); + sqlBuilder.From("dbo.ReferenceSearchParam", "refSource"); + sqlBuilder.InnerJoin("dbo.Resource", "refTarget", "refSource.ReferenceResourceTypeId = refTarget.ResourceTypeId AND refSource.ReferenceResourceId = refTarget.ResourceId"); + + if (options.IsIterateInclude) + { + sqlBuilder.Where($"EXISTS (SELECT * FROM {options.LastCteName} lcte WHERE refSource.ResourceTypeId = lcte.ResourceTypeId AND refSource.ResourceSurrogateId = lcte.ResourceSurrogateId)"); + } + else + { + sqlBuilder.Where($"EXISTS (SELECT * FROM {options.LastCteName} lcte WHERE refSource.ResourceTypeId = lcte.ResourceTypeId AND refSource.ResourceSurrogateId = lcte.ResourceSurrogateId AND lcte.Row <= {options.Count})"); + } + + if (!wildcardResourceType) + { + sqlBuilder.And($"refSource.ResourceTypeId = {resourceTypeId}"); + } + + if (!wildcardSearchParameter) + { + sqlBuilder.And($"refSource.SearchParamId = {parameter?.Id}"); + } + + if (targetResourceTypeIds.Count > 0) + { + sqlBuilder.And($"refTarget.ResourceTypeId IN ({string.Join(",", targetResourceTypeIds)})"); + } + + ParserUtil.AddHistoryAndDeletedCheck(sqlBuilder, "refTarget"); + + if (options.IncludesContinuationToken != null && options.IncludesContinuationToken.IncludeResourceTypeId.HasValue && options.IncludesContinuationToken.IncludeResourceSurrogateId.HasValue) + { + sqlBuilder.And($"(refTarget.ResourceTypeId > {options.IncludesContinuationToken.IncludeResourceTypeId} OR (refTarget.ResourceTypeId = {options.IncludesContinuationToken.IncludeResourceTypeId} AND refTarget.ResourceSurrogateId > {options.IncludesContinuationToken.IncludeResourceSurrogateId}))"); + } + + sqlBuilder.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/LastUpdatedSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/LastUpdatedSqlParser.cs new file mode 100644 index 0000000000..b5d1a69433 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/LastUpdatedSqlParser.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.SqlServer.Management.XEvent; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + public class LastUpdatedSqlParser : ISqlParser + { + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentNullException(nameof(value)); + } + + var sqlBuilder = options.SqlQueryBuilder; + var cteName = options.ChainLevel == 0 ? $"cte{options.CteNumber}" : $"cte{options.CteNumber}chain{options.ChainLevel}"; + options.ResultCteName = cteName; + + var surrogateIdColumn = (options.ChainLevel == 0 || options.LastCteName == null) ? "ResourceSurrogateId" : "RefResourceSurrogateId"; + var typeIdColumn = (options.ChainLevel == 0 || options.LastCteName == null) ? "ResourceTypeId" : "RefResourceTypeId"; + + sqlBuilder.BeginCte(cteName); + sqlBuilder.SelectWithModifier("DISTINCT", $"r.{typeIdColumn}", $"r.{surrogateIdColumn}"); + sqlBuilder.From(options.LastCteName ?? "dbo.Resource", "r"); + + var dateTime = DateTimeSqlParser.ParseValue(value, out var modifier); + var minSurrogateId = ResourceSurrogateIdHelper.ToSurrogateId(dateTime.Start); + var maxSurrogateId = ResourceSurrogateIdHelper.ToSurrogateId(dateTime.End.AddMilliseconds(1)); + + // Because surrogate id is a range for the same datetime, different operators need to be handled accordingly. + var whereClause = modifier switch + { + "gt" => $"r.ResourceSurrogateId >= {maxSurrogateId}", // greater than means the start of the next millisecond, so max surrogate id is included + "ge" => $"r.ResourceSurrogateId >= {minSurrogateId}", + "lt" => $"r.ResourceSurrogateId < {minSurrogateId}", + "le" => $"r.ResourceSurrogateId < {maxSurrogateId}", + "sa" => $"r.ResourceSurrogateId > {maxSurrogateId}", + "eb" => $"r.ResourceSurrogateId < {minSurrogateId}", + "ne" => $"(r.ResourceSurrogateId >= {maxSurrogateId} OR r.ResourceSurrogateId < {minSurrogateId})", + "eq" => $"r.ResourceSurrogateId >= {minSurrogateId} AND r.ResourceSurrogateId < {maxSurrogateId}", + _ => throw new ArgumentException($"Invalid operator '{modifier}' for lastUpdated search parameter."), + }; + + sqlBuilder.Where(whereClause); + + // Add base filters only on the first CTE + ParserUtil.AddFirstCteFilters(sqlBuilder, options, "r"); + + sqlBuilder.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/NotReferencedSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/NotReferencedSqlParser.cs new file mode 100644 index 0000000000..c1b7c733a9 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/NotReferencedSqlParser.cs @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// Parses _not-referenced search parameters to find resources that are NOT referenced by other resources. + /// Syntax: <sourceResourceType>:<referenceSearchParameter> where either or both can be wildcards (*). + /// + public class NotReferencedSqlParser : ISqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly ISqlServerFhirModel _model; + + public NotReferencedSqlParser(SqlSearchParameterDefinitionManager parameterCollection, ISqlServerFhirModel model) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(model); + _parameterCollection = parameterCollection; + _model = model; + } + + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(options.LastCteName)) + { + throw new ArgumentException("LastCteName must be provided in ParserOptions."); + } + + var parts = value.Split(':'); + if (parts.Length < 2) + { + // Invalid format - no separator. This case is handled at the SearchOptionsFactory level + // which produces a warning. If it reaches here, just skip. + return; + } + + var sourceType = parts[0]; + var searchParam = parts[1]; + + bool wildcardSourceType = string.Equals(sourceType, "*", StringComparison.OrdinalIgnoreCase); + bool wildcardSearchParam = string.Equals(searchParam, "*", StringComparison.OrdinalIgnoreCase); + + short sourceResourceTypeId = 0; + SearchParameterIdWrapper? parameter = null; + + if (!wildcardSourceType) + { + sourceResourceTypeId = _model.GetResourceTypeId(sourceType); + } + + if (!wildcardSearchParam && !wildcardSourceType) + { + parameter = _parameterCollection.GetByCode(searchParam, sourceResourceTypeId); + } + + var sqlBuilder = options.SqlQueryBuilder; + sqlBuilder.BeginCte($"cte{options.CteNumber}"); + sqlBuilder.Select("r.ResourceTypeId", "r.ResourceSurrogateId"); + sqlBuilder.From(options.LastCteName, "r"); + sqlBuilder.InnerJoin("dbo.Resource", "res", "r.ResourceTypeId = res.ResourceTypeId AND r.ResourceSurrogateId = res.ResourceSurrogateId"); + + // Build NOT EXISTS subquery + var notExistsConditions = new List + { + "ref.ReferenceResourceTypeId = res.ResourceTypeId", + "ref.ReferenceResourceId = res.ResourceId", + }; + + if (!wildcardSourceType) + { + notExistsConditions.Add($"ref.ResourceTypeId = {sourceResourceTypeId}"); + } + + if (!wildcardSearchParam && parameter != null) + { + notExistsConditions.Add($"ref.SearchParamId = {parameter.Id}"); + } + + var notExistsClause = string.Join(" AND ", notExistsConditions); + sqlBuilder.Where($"NOT EXISTS (SELECT 1 FROM dbo.ReferenceSearchParam ref WHERE {notExistsClause})"); + + ParserUtil.AddFirstCteFilters(sqlBuilder, options, "r"); + sqlBuilder.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/RevIncludeSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/RevIncludeSqlParser.cs new file mode 100644 index 0000000000..127e80167b --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/RevIncludeSqlParser.cs @@ -0,0 +1,127 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// Parses _revinclude search parameters to find resources that reference the matched resources. + /// This is the reverse of _include which finds resources referenced by the matched resources. + /// + public class RevIncludeSqlParser : ISqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly ISqlServerFhirModel _model; + + public RevIncludeSqlParser(SqlSearchParameterDefinitionManager parameterCollection, ISqlServerFhirModel model) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(model); + _parameterCollection = parameterCollection; + _model = model; + } + + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + var parts = value.Split(':'); + SearchParameterIdWrapper? parameter = null; + var wildcardResourceType = false; + var wildcardSearchParameter = false; + short resourceTypeId = 0; + var targetResourceTypeIds = new List(); + + // Parse the resource type that will reference our matched resources + if (!string.Equals(parts[0], "*", StringComparison.OrdinalIgnoreCase)) + { + resourceTypeId = _model.GetResourceTypeId(parts[0]); + } + else + { + wildcardResourceType = true; + } + + // Parse the search parameter on the referencing resource + if (parts.Length > 1 && !string.Equals(parts[1], "*", StringComparison.OrdinalIgnoreCase)) + { + parameter = _parameterCollection.GetByCode(parts[1], resourceTypeId); + } + else + { + wildcardSearchParameter = true; + } + + // Parse target resource types (the matched resources) + if (parts.Length > 2) + { + var targetResourceTypes = parts[2].Split(','); + targetResourceTypeIds = targetResourceTypes.Select(x => _model.GetResourceTypeId(x)).ToList(); + } + + if (parameter == null && !wildcardSearchParameter) + { + throw new ArgumentException($"Search parameter '{parts[1]}' not found for resource type '{parts[0]}'."); + } + + if (string.IsNullOrWhiteSpace(options.LastCteName)) + { + throw new ArgumentException("LastCteName must be provided in ParserOptions."); + } + + var sqlBuilder = options.SqlQueryBuilder; + sqlBuilder.BeginCte($"cte{options.CteNumber}"); + sqlBuilder.SelectWithModifier($"DISTINCT TOP {options.IncludeCount + 1}", "refSource.ResourceTypeId", "refSource.ResourceSurrogateId", "0 AS IsMatch", $"CASE WHEN count_big(*) over() > {options.IncludeCount} THEN 1 ELSE 0 END AS IsPartial"); + sqlBuilder.From("dbo.ReferenceSearchParam", "refSource"); + sqlBuilder.InnerJoin("dbo.Resource", "refTarget", "refSource.ReferenceResourceTypeId = refTarget.ResourceTypeId AND refSource.ReferenceResourceId = refTarget.ResourceId"); + + if (options.IsIterateInclude) + { + sqlBuilder.Where($"EXISTS (SELECT * FROM {options.LastCteName} lcte WHERE refTarget.ResourceTypeId = lcte.ResourceTypeId AND refTarget.ResourceSurrogateId = lcte.ResourceSurrogateId)"); + } + else + { + sqlBuilder.Where($"EXISTS (SELECT * FROM {options.LastCteName} lcte WHERE refTarget.ResourceTypeId = lcte.ResourceTypeId AND refTarget.ResourceSurrogateId = lcte.ResourceSurrogateId AND lcte.Row <= {options.Count})"); + } + + // For revinclude, we want resources (refSource) that reference the matched resources (refTarget) + // So we filter on refSource's ResourceTypeId (the referencing resource type) + if (!wildcardResourceType) + { + sqlBuilder.And($"refSource.ResourceTypeId = {resourceTypeId}"); + } + + if (!wildcardSearchParameter) + { + sqlBuilder.And($"refSource.SearchParamId = {parameter?.Id}"); + } + + // Filter on the target resource type if specified (the matched resources that are being referenced) + if (targetResourceTypeIds.Count > 0) + { + sqlBuilder.And($"refTarget.ResourceTypeId IN ({string.Join(",", targetResourceTypeIds)})"); + } + + ParserUtil.AddHistoryAndDeletedCheck(sqlBuilder, "refTarget"); + + if (options.IncludesContinuationToken != null && options.IncludesContinuationToken.IncludeResourceTypeId.HasValue && options.IncludesContinuationToken.IncludeResourceSurrogateId.HasValue) + { + sqlBuilder.And($"(refSource.ResourceTypeId > {options.IncludesContinuationToken.IncludeResourceTypeId} OR (refSource.ResourceTypeId = {options.IncludesContinuationToken.IncludeResourceTypeId} AND refSource.ResourceSurrogateId > {options.IncludesContinuationToken.IncludeResourceSurrogateId}))"); + } + + sqlBuilder.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/ReversedChainSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/ReversedChainSqlParser.cs new file mode 100644 index 0000000000..22636817dd --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/ReversedChainSqlParser.cs @@ -0,0 +1,233 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + /// + /// Parser for reversed chained search using the _has parameter. + /// Example: Patient?_has:Observation:subject:code=1234-5 + /// Finds resources that are referenced BY other resources with particular values. + /// + public class ReversedChainSqlParser : ISqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly SearchParameterSqlParser _parserSource; + private readonly ISqlServerFhirModel _model; + + public ReversedChainSqlParser(SqlSearchParameterDefinitionManager parameterCollection, SearchParameterSqlParser parserSource, ISqlServerFhirModel model) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(parserSource); + ArgumentNullException.ThrowIfNull(model); + + _parameterCollection = parameterCollection; + _parserSource = parserSource; + _model = model; + } + + public void Parse(string name, string value, ParserOptions options) + { + Parse(name, value, options, sharedRefCteName: null, firstRefCteName: null); + } + + /// + /// Parses a reverse-chained (_has) search parameter. If is provided, + /// the ref CTE is assumed to already exist and will be reused instead of being generated again. + /// + public void Parse(string name, string value, ParserOptions options, string? sharedRefCteName, string? firstRefCteName) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + // Parse the _has parameter format: _has::: + if (!name.StartsWith("_has:", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid _has parameter format. Expected format: _has:::", nameof(name)); + } + + var parts = name.Split(':', 4); + if (parts.Length < 4) + { + throw new ArgumentException("Invalid _has parameter format. Expected format: _has:::", nameof(name)); + } + + var sourceResourceType = parts[1]; // The resource type that references the target (e.g., Observation) + var referenceParamCode = parts[2]; // The reference parameter on the source resource (e.g., patient) + var searchParamCode = parts[3]; // The search parameter on the source resource to filter by (e.g., code) + + // Get the resource type ID for the source resource + short sourceResourceTypeId; + try + { + sourceResourceTypeId = _model.GetResourceTypeId(sourceResourceType); + } + catch + { + throw new ArgumentException($"Unknown resource type '{sourceResourceType}' in _has parameter.", nameof(name)); + } + + // Look up the reference parameter on the source resource type + var referenceParameter = _parameterCollection.GetByCode(referenceParamCode, sourceResourceTypeId); + if (referenceParameter == null) + { + throw new ArgumentException($"Reference parameter '{referenceParamCode}' is not supported for resource type '{sourceResourceType}'.", nameof(name)); + } + + var cteName = $"cte{options.CteNumber}"; + var builder = options.SqlQueryBuilder; + string refChainCteName; + + if (sharedRefCteName != null) + { + // Reuse the already-generated ref CTE + refChainCteName = sharedRefCteName; + } + else + { + refChainCteName = $"{cteName}chain{options.ChainLevel}_ref"; + + // Step 1: Create the _ref CTE that finds reference links from source to target. + // For reverse chain, we flip the column naming: + // - RefResourceTypeId/RefResourceSurrogateId = SOURCE (Observation) - what we'll search + // - ResourceTypeId/ResourceSurrogateId = TARGET (Patient) - what we'll output + builder.BeginCte(refChainCteName); + builder.Select( + "refSource.ResourceTypeId AS RefResourceTypeId", + "refSource.ResourceSurrogateId AS RefResourceSurrogateId", + "refTarget.ResourceTypeId AS ResourceTypeId", + "refTarget.ResourceSurrogateId AS ResourceSurrogateId"); + + builder.From("dbo.ReferenceSearchParam", "refSource"); + builder.InnerJoin("dbo.Resource", "refTarget", "refSource.ReferenceResourceTypeId = refTarget.ResourceTypeId AND refSource.ReferenceResourceId = refTarget.ResourceId"); + + // If we have a previous CTE, join to it (for nested _has or combined with other params) + if (options.LastCteName != null) + { + // When nested in a chain (ChainLevel > 0), the previous CTE is a _ref CTE + // whose RefResource columns represent the SOURCE resources we want to constrain against + var prevSurrogateCol = options.ChainLevel > 0 ? "RefResourceSurrogateId" : "ResourceSurrogateId"; + var prevTypeCol = options.ChainLevel > 0 ? "RefResourceTypeId" : "ResourceTypeId"; + builder.InnerJoin( + options.LastCteName, + "prev", + $"prev.{prevSurrogateCol} = refTarget.ResourceSurrogateId AND prev.{prevTypeCol} = refTarget.ResourceTypeId"); + } + + builder.Where($"refSource.SearchParamId = {referenceParameter.Id}"); + builder.And($"refSource.ResourceTypeId = {sourceResourceTypeId}"); + ParserUtil.AddFirstCteFilters(builder, options, "refTarget"); + + // Add base filters on the first level + if (options.LastCteName == null) + { + if (options.ResourceTypes != null && options.ResourceTypes.Count > 0) + { + var targetResourceTypeIds = string.Join(", ", options.ResourceTypes); + builder.And($"refTarget.ResourceTypeId IN ({targetResourceTypeIds})"); + } + + if (options.ContinuationToken != null) + { + var surrogateOperator = options.SortDescending ? "<" : ">"; + builder.And($"refTarget.ResourceSurrogateId {surrogateOperator} {options.ContinuationToken.ResourceSurrogateId}"); + + if (options.ContinuationToken.ResourceTypeId != null) + { + var typeOperator = options.SortDescending ? "<" : ">"; + builder.And($"refTarget.ResourceTypeId {typeOperator}= {options.ContinuationToken.ResourceTypeId}"); + } + } + } + + builder.EndCte(); + } + + // Step 2: Create the search filter on source resources + string searchChainCteName; + + if (!searchParamCode.Equals(KnownQueryParameterNames.Type, StringComparison.OrdinalIgnoreCase)) + { + // Parse the search parameter to filter source resources + var searchParser = _parserSource.GetParser(searchParamCode, sourceResourceTypeId); + var innerOptions = new ParserOptions + { + CteNumber = options.CteNumber, + ResourceTypes = new List { sourceResourceTypeId }, + ChainLevel = options.ChainLevel + 1, + LastCteName = refChainCteName, + SqlQueryBuilder = builder, + }; + searchParser.Parse(searchParamCode, value, innerOptions); + + // Use the result CTE name from the inner parser (handles nested chains) + searchChainCteName = innerOptions.ResultCteName ?? $"{cteName}chain{options.ChainLevel + 1}"; + } + else + { + // Special handling for _type parameter - filter by source resource type + searchChainCteName = $"{cteName}chain{options.ChainLevel + 1}"; + var sourceTypeIds = value.Split(',').Select(v => _model.GetResourceTypeId(v.Trim())).ToList(); + builder.BeginCte(searchChainCteName); + builder.SelectWithModifier("DISTINCT", "r.RefResourceSurrogateId", "r.RefResourceTypeId"); + builder.From(refChainCteName, "r"); + builder.Where($"r.RefResourceTypeId IN ({string.Join(",", sourceTypeIds)})"); + builder.EndCte(); + } + + // Step 3: Create the final CTE that maps matching sources back to targets + string resultCteName; + string firstChainRefCteName = firstRefCteName ?? refChainCteName; + if (options.ChainLevel == 0 && options.IsLastInChainGroup) + { + // Final level - output the target resources (Patients) + resultCteName = cteName; + builder.BeginCte(resultCteName); + builder.SelectWithModifier( + "DISTINCT", + "ref_cte.ResourceTypeId", + "ref_cte.ResourceSurrogateId"); + builder.From(searchChainCteName, "search"); + builder.InnerJoin( + firstChainRefCteName, + "ref_cte", + "ref_cte.RefResourceSurrogateId = search.RefResourceSurrogateId AND ref_cte.RefResourceTypeId = search.RefResourceTypeId"); + builder.EndCte(); + } + else if (options.ChainLevel == 0) + { + resultCteName = searchChainCteName; + } + else + { + // Nested level - output target resources for the parent chain to use + var parentCteName = $"{cteName}chain{options.ChainLevel - 1}"; + resultCteName = $"{parentCteName}_search"; + builder.BeginCte(resultCteName); + builder.SelectWithModifier( + "DISTINCT", + "ref_cte.ResourceTypeId AS RefResourceTypeId", + "ref_cte.ResourceSurrogateId AS RefResourceSurrogateId"); + builder.From(searchChainCteName, "search"); + builder.InnerJoin( + firstChainRefCteName, + "ref_cte", + "ref_cte.RefResourceSurrogateId = search.RefResourceSurrogateId AND ref_cte.RefResourceTypeId = search.RefResourceTypeId"); + builder.EndCte(); + } + + options.ResultCteName = resultCteName; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SmartCompartmentSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SmartCompartmentSqlParser.cs new file mode 100644 index 0000000000..0cace7f1c6 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SmartCompartmentSqlParser.cs @@ -0,0 +1,226 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + /// + /// Handles SMART compartment searches. A SMART compartment search provides access to: + /// 1. Resources in the patient's compartment (via reference search params) + /// 2. The patient's own resource + /// 3. Universal resources (Location, Organization, Practitioner, Medication, Device) + /// + public class SmartCompartmentSqlParser : ISqlParser + { + private readonly ISqlServerFhirModel _model; + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + private readonly ICompartmentDefinitionManager _compartmentDefinitionManager; + + private static readonly string[] UniversalResourceTypes = new[] + { + KnownResourceTypes.Location, + KnownResourceTypes.Organization, + KnownResourceTypes.Practitioner, + KnownResourceTypes.Medication, + KnownCompartmentTypes.Device, + }; + + public SmartCompartmentSqlParser( + ISqlServerFhirModel model, + SqlSearchParameterDefinitionManager parameterCollection, + ICompartmentDefinitionManager compartmentDefinitionManager) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(parameterCollection); + ArgumentNullException.ThrowIfNull(compartmentDefinitionManager); + _model = model; + _parameterCollection = parameterCollection; + _compartmentDefinitionManager = compartmentDefinitionManager; + } + + /// + /// Generates a CTE that filters resources by SMART compartment rules using a UNION of: + /// 1. Compartment resources (reference search) + /// 2. The compartment owner's own resource + /// 3. Universal resources + /// + /// The compartment type (e.g., "Patient"). + /// The compartment owner's resource ID (e.g., "smart-patient-A"). + /// Parser options containing CTE info and resource type filters. + public void Parse(string name, string value, ParserOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentNullException(nameof(value)); + } + + if (!Enum.TryParse(name, out var compartmentType)) + { + throw new InvalidOperationException($"Invalid compartment type: {name}"); + } + + var sql = options.SqlQueryBuilder; + var escapedValue = value.Replace("'", "''", StringComparison.Ordinal); + short compartmentResourceTypeId = _model.GetResourceTypeId(name); + + // Determine which universal resource types are relevant + var universalTypeIds = new List(); + foreach (var universalType in UniversalResourceTypes) + { + try + { + var typeId = _model.GetResourceTypeId(universalType); + + // If resource types are filtered, only include universal types that are in the filter + if (options.ResourceTypes == null || options.ResourceTypes.Count == 0 || options.ResourceTypes.Contains(typeId)) + { + universalTypeIds.Add(typeId); + } + } + catch + { + // Skip unknown types + } + } + + // Get compartment resource types and their search params + _compartmentDefinitionManager.TryGetResourceTypes(compartmentType, out HashSet allResourceTypes); + var searchParamToResourceTypes = new Dictionary>(); + + if (allResourceTypes != null) + { + var resourceTypesToSearch = allResourceTypes; + if (options.ResourceTypes != null && options.ResourceTypes.Count > 0) + { + var requestedTypeNames = options.ResourceTypes + .Select(id => _model.GetResourceTypeName(id)) + .Where(n => n != null) + .ToHashSet(); + resourceTypesToSearch = allResourceTypes.Where(rt => requestedTypeNames.Contains(rt)).ToHashSet(); + } + + foreach (var resourceType in resourceTypesToSearch) + { + if (_compartmentDefinitionManager.TryGetSearchParams(resourceType, compartmentType, out HashSet searchParamNames)) + { + foreach (var searchParamName in searchParamNames) + { + try + { + var paramWrapper = _parameterCollection.GetByCode(searchParamName, resourceType); + if (paramWrapper != null) + { + short paramId = (short)paramWrapper.Id; + if (!searchParamToResourceTypes.TryGetValue(paramId, out var rtSet)) + { + rtSet = new HashSet(); + searchParamToResourceTypes[paramId] = rtSet; + } + + short rtId = _model.GetResourceTypeId(resourceType); + rtSet.Add(rtId); + } + } + catch + { + // Skip search params that can't be resolved + } + } + } + } + } + + // Generate the SMART compartment CTE using UNION ALL in a subquery + sql.BeginCte($"cte{options.CteNumber}"); + sql.AppendLine("SELECT ResourceTypeId, ResourceSurrogateId FROM ("); + sql.IncreaseIndent(); + + // Part 1: Resources in the compartment (via ReferenceSearchParam) + if (searchParamToResourceTypes.Count > 0) + { + sql.AppendLine("SELECT r.ResourceTypeId, r.ResourceSurrogateId"); + sql.IncreaseIndent(); + sql.AppendLine("FROM dbo.Resource AS r"); + sql.AppendLine("INNER JOIN dbo.ReferenceSearchParam AS ref1 ON r.ResourceTypeId = ref1.ResourceTypeId AND r.ResourceSurrogateId = ref1.ResourceSurrogateId"); + sql.AppendLine($"WHERE r.IsHistory = 0 AND r.IsDeleted = 0"); + sql.AppendLine($"AND ref1.ReferenceResourceTypeId = {compartmentResourceTypeId}"); + sql.AppendLine($"AND ref1.ReferenceResourceId = '{escapedValue}'"); + + // Build OR condition for search params + var orConditions = new List(); + foreach (var kvp in searchParamToResourceTypes) + { + short searchParamId = kvp.Key; + var resourceTypeIds = kvp.Value; + var rtIdsStr = string.Join(", ", resourceTypeIds.OrderBy(x => x)); + if (resourceTypeIds.Count == 1) + { + orConditions.Add($"(ref1.SearchParamId = {searchParamId} AND r.ResourceTypeId = {rtIdsStr})"); + } + else + { + orConditions.Add($"(ref1.SearchParamId = {searchParamId} AND r.ResourceTypeId IN ({rtIdsStr}))"); + } + } + + sql.AppendLine($"AND ({string.Join(" OR ", orConditions)})"); + sql.DecreaseIndent(); + } + else + { + // No compartment search params - return empty set for compartment part + sql.AppendLine("SELECT r.ResourceTypeId, r.ResourceSurrogateId"); + sql.IncreaseIndent(); + sql.AppendLine("FROM dbo.Resource AS r"); + sql.AppendLine("WHERE 1 = 0"); + sql.DecreaseIndent(); + } + + // Part 2: The owner's own resource + bool ownerInResourceTypes = options.ResourceTypes == null || options.ResourceTypes.Count == 0 || options.ResourceTypes.Contains(compartmentResourceTypeId); + if (ownerInResourceTypes) + { + sql.AppendLine("UNION ALL"); + sql.AppendLine("SELECT r.ResourceTypeId, r.ResourceSurrogateId"); + sql.IncreaseIndent(); + sql.AppendLine("FROM dbo.Resource AS r"); + sql.AppendLine($"WHERE r.ResourceTypeId = {compartmentResourceTypeId}"); + sql.AppendLine($"AND r.ResourceId = '{escapedValue}'"); + sql.AppendLine("AND r.IsHistory = 0 AND r.IsDeleted = 0"); + sql.DecreaseIndent(); + } + + // Part 3: Universal resources + if (universalTypeIds.Count > 0) + { + sql.AppendLine("UNION ALL"); + sql.AppendLine("SELECT r.ResourceTypeId, r.ResourceSurrogateId"); + sql.IncreaseIndent(); + sql.AppendLine("FROM dbo.Resource AS r"); + sql.AppendLine($"WHERE r.ResourceTypeId IN ({string.Join(", ", universalTypeIds.OrderBy(x => x))})"); + sql.AppendLine("AND r.IsHistory = 0 AND r.IsDeleted = 0"); + sql.DecreaseIndent(); + } + + sql.DecreaseIndent(); + sql.AppendLine(") AS smart_union"); + sql.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SortSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SortSqlParser.cs new file mode 100644 index 0000000000..093f0d6e0c --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SortSqlParser.cs @@ -0,0 +1,160 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Text; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + /// + /// Parses sort parameters to create SQL that joins with DateTimeSearchParam or StringSearchParam + /// tables and uses IsMin/IsMax columns for efficient sorting. + /// + public class SortSqlParser + { + private readonly SqlSearchParameterDefinitionManager _parameterCollection; + + public SortSqlParser(SqlSearchParameterDefinitionManager parameterCollection) + { + ArgumentNullException.ThrowIfNull(parameterCollection); + _parameterCollection = parameterCollection; + } + + /// + /// Creates a CTE that joins the main result set with the appropriate search parameter table + /// to enable sorting by that parameter's values. + /// + /// The name of the parameter to sort by. + /// True for descending sort, false for ascending. + /// The name of the CTE containing the resources to sort. + /// The name to give the resulting sorted CTE. + /// The resource type ID to filter on, or 0 for all types. + /// The continuation point to use for paging, or null for no continuation. + /// The ResourceSurrogateId tiebreaker for paging, or null. + /// SQL string for the sort CTE, or null if the parameter is not sortable. + public string? CreateSortCte( + string sortParameterName, + bool sortDescending, + string sourceCteName, + string targetCteName, + short resourceTypeId, + string? continuationPoint = null, + long? continuationResourceSurrogateId = null) + { + if (string.IsNullOrWhiteSpace(sortParameterName) || string.IsNullOrWhiteSpace(sourceCteName)) + { + return null; + } + + // Get the search parameter definition + var parameter = _parameterCollection.GetByCode(sortParameterName, resourceTypeId); + if (parameter == null) + { + return null; + } + + // Only DateTime and String parameters support sorting with IsMin/IsMax + if (parameter.SearchParameterInfo.Type != SearchParamType.Date && + parameter.SearchParameterInfo.Type != SearchParamType.String) + { + return null; + } + + var sqlBuilder = new StringBuilder(); + sqlBuilder.AppendLine($"{targetCteName} AS ("); + sqlBuilder.AppendLine(" SELECT"); + sqlBuilder.AppendLine(" r.ResourceTypeId,"); + sqlBuilder.AppendLine(" r.ResourceSurrogateId"); + + // Determine which table and column to use + string tableName; + string sortColumn; + string isMinMaxColumn = sortDescending ? "IsMax" : "IsMin"; + + if (parameter.SearchParameterInfo.Type == SearchParamType.Date) + { + tableName = "dbo.DateTimeSearchParam"; + + // For DateTime, we sort by StartDateTime (the beginning of the range) + sortColumn = "sp.StartDateTime"; + } + else // String + { + tableName = "dbo.StringSearchParam"; + + // For String, we use the Text column + sortColumn = "sp.Text"; + } + + sqlBuilder.AppendLine($" ,{sortColumn} AS SortValue"); + sqlBuilder.AppendLine($" FROM {sourceCteName} r"); + + // Inner join to only include resources that have the search parameter + sqlBuilder.AppendLine($" JOIN {tableName} sp ON"); + sqlBuilder.AppendLine(" sp.ResourceTypeId = r.ResourceTypeId"); + sqlBuilder.AppendLine(" AND sp.ResourceSurrogateId = r.ResourceSurrogateId"); + sqlBuilder.AppendLine($" AND sp.SearchParamId = {parameter.Id}"); + sqlBuilder.AppendLine($" AND sp.{isMinMaxColumn} = 1"); + + if (!string.IsNullOrEmpty(continuationPoint)) + { + string op = sortDescending ? "<" : ">"; + if (continuationResourceSurrogateId.HasValue) + { + // Use composite continuation: skip past the exact row we left off at + sqlBuilder.AppendLine($" WHERE ({sortColumn} {op} '{continuationPoint}'"); + sqlBuilder.AppendLine($" OR ({sortColumn} = '{continuationPoint}' AND r.ResourceSurrogateId > {continuationResourceSurrogateId.Value}))"); + } + else + { + sqlBuilder.AppendLine($" WHERE {sortColumn} {op}= '{continuationPoint}'"); + } + } + + sqlBuilder.Append(')'); + + return sqlBuilder.ToString(); + } + + /// + /// Creates the ORDER BY clause for a sorted query. + /// + /// True for descending sort, false for ascending. + /// True if the query joined with a sort parameter table. + /// The ORDER BY clause SQL string. + public static string CreateOrderByClause(bool sortDescending, bool hasSortValue) + { + if (!hasSortValue) + { + // No sort parameter - use default ordering + return "t.IsMatch DESC, t.ResourceTypeId ASC, t.ResourceSurrogateId ASC"; + } + + var sqlBuilder = new StringBuilder("t.IsMatch DESC"); + + if (sortDescending) + { + // Descending: NULLs last, then sort values descending + // NULLS are resources without the parameter + sqlBuilder.Append(", CASE WHEN t.SortValue IS NULL THEN 1 ELSE 0 END ASC, t.SortValue DESC"); + } + else + { + // Ascending: NULLs last, then sort values ascending + sqlBuilder.Append(", CASE WHEN t.SortValue IS NULL THEN 1 ELSE 0 END ASC, t.SortValue ASC"); + } + + // Add ResourceTypeId and ResourceSurrogateId as tie-breakers for stable sorting + sqlBuilder.Append(", t.ResourceTypeId ASC, t.ResourceSurrogateId ASC"); + + return sqlBuilder.ToString(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SystemSqlParser.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SystemSqlParser.cs new file mode 100644 index 0000000000..91e77e25c4 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SpecialParsers/SystemSqlParser.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser.SpecialParsers +{ + /// + /// Handles basic system-level searches when no search parameters are provided. + /// Used for queries like GET [base]/Patient or GET [base]?_type=Patient + /// + public class SystemSqlParser : ISqlParser + { + public void Parse(string name, string value, ParserOptions options) + { + // SystemSqlParser doesn't use name/value parameters + // It generates a basic query based on options only + var sqlBuilder = options.SqlQueryBuilder; + + sqlBuilder.BeginCte($"cte{options.CteNumber}"); + + // Build the SELECT clause with TOP or without based on whether we're counting + sqlBuilder.Select("r.ResourceTypeId", "r.ResourceSurrogateId"); + + // FROM clause - always from dbo.Resource for system queries + sqlBuilder.From("dbo.Resource", "r"); + + // WHERE clause - base filters + sqlBuilder.Where("r.IsHistory = 0") + .And("r.IsDeleted = 0"); + + // Add resource type filter if specified + if (options.ResourceTypes != null && options.ResourceTypes.Count > 0) + { + var resourceTypeIds = string.Join(", ", options.ResourceTypes); + sqlBuilder.And($"r.ResourceTypeId IN ({resourceTypeIds})"); + } + + // Add continuation token support + ParserUtil.AddFirstCteFilters(sqlBuilder, options, "r"); + + sqlBuilder.EndCte(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SqlQueryBuilder.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SqlQueryBuilder.cs new file mode 100644 index 0000000000..cd1d599782 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SqlQueryBuilder.cs @@ -0,0 +1,454 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + /// + /// A specialized string builder for constructing SQL queries with proper indentation + /// and support for CTEs, SELECT, FROM, JOIN, WHERE, and AND clauses. + /// + public class SqlQueryBuilder + { + private readonly StringBuilder _builder = new(); + private int _indentLevel = 0; + private readonly string _indentString = " "; // 2 spaces per indent level + private bool _needsIndent = true; + private bool _isFirstCte = true; + private readonly Stack _cteStack = new(); + + /// + /// Gets the current indentation level. + /// + public int IndentLevel => _indentLevel; + + /// + /// Gets the length of the current query string. + /// + public int Length => _builder.Length; + + /// + /// Increases the indentation level by one. + /// + /// The number of levels to increase the indentation by. + /// This builder for chaining. + public SqlQueryBuilder IncreaseIndent(int count = 1) + { + _indentLevel += count; + return this; + } + + /// + /// Decreases the indentation level by one. + /// + /// The number of levels to decrease the indentation by. + /// This builder for chaining. + public SqlQueryBuilder DecreaseIndent(int count = 1) + { + if (_indentLevel > 0) + { + _indentLevel -= count; + if (_indentLevel < 0) + { + _indentLevel = 0; + } + } + + return this; + } + + /// + /// Appends a line with the current indentation. + /// + /// The text to append. + /// This builder for chaining. + public SqlQueryBuilder AppendLine(string? text = null) + { + if (_needsIndent && !string.IsNullOrEmpty(text)) + { + _builder.Append(GetIndent()); + } + + if (!string.IsNullOrEmpty(text)) + { + _builder.Append(text); + } + + _builder.AppendLine(); + _needsIndent = true; + return this; + } + + /// + /// Appends text without a line break. + /// + /// The text to append. + /// This builder for chaining. + public SqlQueryBuilder Append(string text) + { + if (_needsIndent) + { + _builder.Append(GetIndent()); + _needsIndent = false; + } + + _builder.Append(text); + return this; + } + + /// + /// Begins a CTE (Common Table Expression) definition. + /// + /// The name of the CTE. + /// True if this is the first CTE in a WITH clause. + /// This builder for chaining. + public SqlQueryBuilder BeginCte(string cteName, bool? isFirstCte = null) + { + var context = new CteContext(cteName, _indentLevel); + _cteStack.Push(context); + + if (isFirstCte ?? _isFirstCte) + { + AppendLine(";WITH"); + _isFirstCte = false; + } + else + { + AppendLine(","); + } + + AppendLine($"{cteName} AS ("); + IncreaseIndent(); + + return this; + } + + /// + /// Ends the current CTE definition. + /// + /// This builder for chaining. + public SqlQueryBuilder EndCte() + { + if (_cteStack.Count == 0) + { + throw new InvalidOperationException("No CTE to end. Call BeginCte first."); + } + + DecreaseIndent(); + _cteStack.Pop(); + Append(")"); + + // Don't add newline here - caller can decide if they want one + return this; + } + + /// + /// Appends a SELECT clause. + /// + /// The columns to select. If null or empty, appends just "SELECT". + /// This builder for chaining. + public SqlQueryBuilder Select(params string[] columns) + { + if (columns == null || columns.Length == 0) + { + AppendLine("SELECT"); + } + else if (columns.Length == 1) + { + AppendLine($"SELECT {columns[0]}"); + } + else + { + Append("SELECT "); + for (int i = 0; i < columns.Length; i++) + { + if (i < columns.Length - 1) + { + Append($"{columns[i]}, "); + } + else + { + AppendLine(columns[i]); + } + } + } + + return this; + } + + /// + /// Appends a SELECT with modifiers (like TOP, DISTINCT). + /// + /// The modifier (e.g., "TOP 100", "DISTINCT"). + /// The columns to select. + /// This builder for chaining. + public SqlQueryBuilder SelectWithModifier(string modifier, params string[] columns) + { + if (columns == null || columns.Length == 0) + { + AppendLine($"SELECT {modifier}"); + } + else if (columns.Length == 1) + { + AppendLine($"SELECT {modifier} {columns[0]}"); + } + else + { + Append($"SELECT {modifier} "); + for (int i = 0; i < columns.Length; i++) + { + if (i < columns.Length - 1) + { + Append($"{columns[i]}, "); + } + else + { + AppendLine(columns[i]); + } + } + } + + return this; + } + + /// + /// Appends a FROM clause. + /// + /// The table or CTE name. + /// Optional alias for the table. + /// This builder for chaining. + public SqlQueryBuilder From(string table, string? alias = null) + { + IncreaseIndent(); + + if (string.IsNullOrWhiteSpace(alias)) + { + AppendLine($"FROM {table}"); + } + else + { + AppendLine($"FROM {table} AS {alias}"); + } + + DecreaseIndent(); + + return this; + } + + /// + /// Appends a JOIN clause. + /// + /// The type of join (e.g., "INNER", "LEFT", "RIGHT"). + /// The table to join. + /// Optional alias for the table. + /// The ON condition for the join. + /// This builder for chaining. + public SqlQueryBuilder Join(string joinType, string table, string? alias, string? condition = null) + { + IncreaseIndent(2); + + if (string.IsNullOrWhiteSpace(alias)) + { + Append($"{joinType} JOIN {table}"); + } + else + { + Append($"{joinType} JOIN {table} AS {alias}"); + } + + if (!string.IsNullOrWhiteSpace(condition)) + { + AppendLine($" ON {condition}"); + } + else + { + AppendLine(); + } + + DecreaseIndent(2); + + return this; + } + + /// + /// Appends an INNER JOIN clause. + /// + /// The table to join. + /// Optional alias for the table. + /// The ON condition for the join. + /// This builder for chaining. + public SqlQueryBuilder InnerJoin(string table, string? alias = null, string? condition = null) + { + return Join("INNER", table, alias, condition); + } + + /// + /// Appends a LEFT JOIN clause. + /// + /// The table to join. + /// Optional alias for the table. + /// The ON condition for the join. + /// This builder for chaining. + public SqlQueryBuilder LeftJoin(string table, string? alias = null, string? condition = null) + { + return Join("LEFT", table, alias, condition); + } + + /// + /// Appends a multi-line JOIN with ON conditions on separate lines. + /// + /// The type of join. + /// The table to join. + /// Optional alias for the table. + /// Multiple ON/AND conditions. + /// This builder for chaining. + public SqlQueryBuilder JoinMultiLine(string joinType, string table, string? alias, params string[] conditions) + { + IncreaseIndent(2); + + if (string.IsNullOrWhiteSpace(alias)) + { + AppendLine($"{joinType} JOIN {table}"); + } + else + { + AppendLine($"{joinType} JOIN {table} AS {alias}"); + } + + if (conditions != null && conditions.Length > 0) + { + IncreaseIndent(); + AppendLine($"ON {conditions[0]}"); + + for (int i = 1; i < conditions.Length; i++) + { + AppendLine($"AND {conditions[i]}"); + } + + DecreaseIndent(); + } + + DecreaseIndent(2); + + return this; + } + + /// + /// Appends a WHERE clause. + /// + /// The WHERE condition. + /// This builder for chaining. + public SqlQueryBuilder Where(string condition) + { + IncreaseIndent(); + AppendLine($"WHERE {condition}"); + DecreaseIndent(); + return this; + } + + /// + /// Appends an AND condition (typically used after WHERE). + /// + /// The AND condition. + /// This builder for chaining. + public SqlQueryBuilder And(string condition) + { + IncreaseIndent(2); + AppendLine($"AND {condition}"); + DecreaseIndent(2); + return this; + } + + /// + /// Appends an OR condition. + /// + /// The OR condition. + /// This builder for chaining. + public SqlQueryBuilder Or(string condition) + { + IncreaseIndent(2); + AppendLine($"OR {condition}"); + DecreaseIndent(2); + return this; + } + + /// + /// Appends an ORDER BY clause. + /// + /// The ORDER BY expression. + /// This builder for chaining. + public SqlQueryBuilder OrderBy(string orderBy) + { + AppendLine($"ORDER BY {orderBy}"); + return this; + } + + /// + /// Appends a GROUP BY clause. + /// + /// The GROUP BY expression. + /// This builder for chaining. + public SqlQueryBuilder GroupBy(string groupBy) + { + AppendLine($"GROUP BY {groupBy}"); + return this; + } + + /// + /// Appends a HAVING clause. + /// + /// The HAVING condition. + /// This builder for chaining. + public SqlQueryBuilder Having(string condition) + { + AppendLine($"HAVING {condition}"); + return this; + } + + /// + /// Clears the builder. + /// + public void Clear() + { + _builder.Clear(); + _indentLevel = 0; + _needsIndent = true; + _cteStack.Clear(); + } + + /// + /// Returns the SQL query as a string. + /// + /// The complete SQL query. + public override string ToString() + { + return _builder.ToString(); + } + + private string GetIndent() + { + return string.Concat(Enumerable.Repeat(_indentString, _indentLevel)); + } + + private class CteContext + { + public CteContext(string name, int indentLevel) + { + Name = name; + IndentLevel = indentLevel; + } + + public string Name { get; } + + public int IndentLevel { get; } + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SqlSearchParameterDefinitionManager.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SqlSearchParameterDefinitionManager.cs new file mode 100644 index 0000000000..652dfed67e --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlSearchParser/SqlSearchParameterDefinitionManager.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.SqlServer.Features.Storage; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser +{ + public class SqlSearchParameterDefinitionManager + { + private readonly SearchParameterDefinitionManager _searchParameterDefinitionManager; + private readonly ISqlServerFhirModel _sqlServerFhirModel; + + public SqlSearchParameterDefinitionManager(SearchParameterDefinitionManager searchParameterDefinitionManager, ISqlServerFhirModel sqlServerFhirModel) + { + ArgumentNullException.ThrowIfNull(searchParameterDefinitionManager); + ArgumentNullException.ThrowIfNull(sqlServerFhirModel); + + _searchParameterDefinitionManager = searchParameterDefinitionManager; + _sqlServerFhirModel = sqlServerFhirModel; + } + + public SearchParameterIdWrapper GetByCode(string code, short resourceType) + { + if (resourceType == 0) + { + // If the resource type is 0, we are searching for a search parameter that is applicable to all resource types. + resourceType = 1; + } + + return GetByCode(code, _sqlServerFhirModel.GetResourceTypeName(resourceType)); + } + + public SearchParameterIdWrapper GetByCode(string code, string resourceType) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Code cannot be null or whitespace.", nameof(code)); + } + + if (code.Contains(':', StringComparison.OrdinalIgnoreCase)) + { + code = code.Split(':', 2, StringSplitOptions.None)[0]; + } + + var searchParameterInfo = _searchParameterDefinitionManager.GetSearchParameter(resourceType, code); + return new SearchParameterIdWrapper() + { + SearchParameterInfo = searchParameterInfo, + Id = _sqlServerFhirModel.GetSearchParamId(searchParameterInfo?.Url), + }; + } + + public SearchParameterIdWrapper GetByUrl(Uri url) + { + ArgumentNullException.ThrowIfNull(url); + + var searchParameterInfo = _searchParameterDefinitionManager.GetSearchParameter(url.OriginalString); + return new SearchParameterIdWrapper() + { + SearchParameterInfo = searchParameterInfo, + Id = _sqlServerFhirModel.GetSearchParamId(searchParameterInfo?.Url), + }; + } + + public IList GetByResourceType(short resourceType) + { + if (resourceType == 0) + { + // If the resource type is 0, we are searching for a search parameter that is applicable to all resource types. + resourceType = 1; + } + + return GetByResourceType(_sqlServerFhirModel.GetResourceTypeName(resourceType)); + } + + public IList GetByResourceType(string resourceType) + { + var parameters = _searchParameterDefinitionManager.GetSearchParameters(resourceType); + return parameters.Select(x => new SearchParameterIdWrapper() { SearchParameterInfo = x, Id = _sqlServerFhirModel.GetSearchParamId(x.Url) }).ToList(); + } + + public SearchParamType? GetParameterType(string code, short resourceType) + { + if (string.IsNullOrWhiteSpace(code)) + { + return null; + } + + var parameter = GetByCode(code, resourceType); + return parameter?.SearchParameterInfo.Type; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchParameterValidator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchParameterValidator.cs index 4e0cc3c8ac..31154b03db 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchParameterValidator.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchParameterValidator.cs @@ -6,7 +6,6 @@ using EnsureThat; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Models; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; using Microsoft.Health.Fhir.SqlServer.Features.Storage; namespace Microsoft.Health.Fhir.SqlServer.Features.Search @@ -24,21 +23,8 @@ public SqlServerSearchParameterValidator(SearchParameterToSearchValueTypeMap sea public bool ValidateSearchParameter(SearchParameterInfo searchParameter, out string errorMessage) { - EnsureArg.IsNotNull(searchParameter, nameof(searchParameter)); - errorMessage = null; - - var factory = new SearchParamTableExpressionQueryGeneratorFactory(_searchParameterToSearchValueTypeMap); - - try - { - factory.GetGenerator(searchParameter); - return true; - } - catch - { - errorMessage = string.Format(Resources.SearchParameterTypeNotSupportedBySQLServer, searchParameter.Type); - return false; - } + errorMessage = string.Empty; + return true; } } } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs index 66ceedb31f..e4e374be8b 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/SqlServerSearchService.cs @@ -34,9 +34,7 @@ using Microsoft.Health.Fhir.Core.Models; using Microsoft.Health.Fhir.SqlServer.Features.Schema; using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors.QueryGenerators; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; using Microsoft.Health.Fhir.SqlServer.Features.Storage; using Microsoft.Health.Fhir.SqlServer.Features.Storage.TvpRowGeneration; using Microsoft.Health.Fhir.SqlServer.Features.Storage.TvpRowGeneration.Merge; @@ -71,13 +69,8 @@ internal class SqlServerSearchService : SearchService private const string SortValueColumnName = "SortValue"; private readonly ISqlServerFhirModel _model; - private readonly SqlRootExpressionRewriter _sqlRootExpressionRewriter; - private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; - private readonly SortRewriter _sortRewriter; - private readonly PartitionEliminationRewriter _partitionEliminationRewriter; private readonly CompartmentSearchRewriter _compartmentSearchRewriter; private readonly SmartCompartmentSearchRewriter _smartCompartmentSearchRewriter; - private readonly ChainFlatteningRewriter _chainFlatteningRewriter; private readonly ILogger _logger; private readonly BitColumn _isMatch = new BitColumn("IsMatch"); private readonly BitColumn _isPartial = new BitColumn("IsPartial"); @@ -87,14 +80,13 @@ internal class SqlServerSearchService : SearchService private readonly SchemaInformation _schemaInformation; private readonly ICompressedRawResourceConverter _compressedRawResourceConverter; private readonly RequestContextAccessor _requestContextAccessor; - private readonly SearchParameterInfo _fakeLastUpdate = new SearchParameterInfo(SearchParameterNames.LastUpdated, SearchParameterNames.LastUpdated); private readonly ISqlQueryHashCalculator _queryHashCalculator; private readonly IFhirDataStore _fhirDataStore; private readonly IQueryPlanReuseChecker _queryPlanReuseChecker; + private readonly SearchParameterSqlParser _searchParameterSqlParser; private static readonly string[] NewLineSeparators = ["\r\n", "\n"]; private static readonly Regex WhitespacePattern = new Regex(@"\s+", RegexOptions.Compiled); - private static ResourceSearchParamStats _resourceSearchParamStats; private static object _locker = new object(); /// @@ -148,13 +140,8 @@ public SqlServerSearchService( ISearchOptionsFactory searchOptionsFactory, IFhirDataStore fhirDataStore, ISqlServerFhirModel model, - SqlRootExpressionRewriter sqlRootExpressionRewriter, - ChainFlatteningRewriter chainFlatteningRewriter, - SortRewriter sortRewriter, - PartitionEliminationRewriter partitionEliminationRewriter, CompartmentSearchRewriter compartmentSearchRewriter, SmartCompartmentSearchRewriter smartCompartmentSearchRewriter, - SearchParamTableExpressionQueryGeneratorFactory queryGeneratorFactory, ISqlRetryService sqlRetryService, IOptions sqlServerDataStoreConfiguration, FhirSqlServerConfiguration fhirSqlServerConfiguration, @@ -163,35 +150,29 @@ public SqlServerSearchService( ICompressedRawResourceConverter compressedRawResourceConverter, ISqlQueryHashCalculator queryHashCalculator, IQueryPlanReuseChecker queryPlanReuseChecker, + SearchParameterSqlParser searchParameterSqlParser, ILogger logger) : base(searchOptionsFactory, fhirDataStore, logger) { - EnsureArg.IsNotNull(sqlRootExpressionRewriter, nameof(sqlRootExpressionRewriter)); - EnsureArg.IsNotNull(chainFlatteningRewriter, nameof(chainFlatteningRewriter)); EnsureArg.IsNotNull(sqlRetryService, nameof(sqlRetryService)); EnsureArg.IsNotNull(schemaInformation, nameof(schemaInformation)); - EnsureArg.IsNotNull(partitionEliminationRewriter, nameof(partitionEliminationRewriter)); EnsureArg.IsNotNull(compartmentSearchRewriter, nameof(compartmentSearchRewriter)); EnsureArg.IsNotNull(smartCompartmentSearchRewriter, nameof(smartCompartmentSearchRewriter)); - EnsureArg.IsNotNull(queryGeneratorFactory, nameof(queryGeneratorFactory)); EnsureArg.IsNotNull(requestContextAccessor, nameof(requestContextAccessor)); EnsureArg.IsNotNull(queryPlanReuseChecker, nameof(queryPlanReuseChecker)); + EnsureArg.IsNotNull(searchParameterSqlParser, nameof(searchParameterSqlParser)); EnsureArg.IsNotNull(logger, nameof(logger)); _sqlServerDataStoreConfiguration = EnsureArg.IsNotNull(sqlServerDataStoreConfiguration?.Value, nameof(sqlServerDataStoreConfiguration)); _fhirSqlServerConfiguration = EnsureArg.IsNotNull(fhirSqlServerConfiguration, nameof(fhirSqlServerConfiguration)); _fhirDataStore = fhirDataStore; _model = model; - _sqlRootExpressionRewriter = sqlRootExpressionRewriter; - _sortRewriter = sortRewriter; - _queryGeneratorFactory = queryGeneratorFactory; - _partitionEliminationRewriter = partitionEliminationRewriter; _compartmentSearchRewriter = compartmentSearchRewriter; _smartCompartmentSearchRewriter = smartCompartmentSearchRewriter; - _chainFlatteningRewriter = chainFlatteningRewriter; _sqlRetryService = sqlRetryService; _queryHashCalculator = queryHashCalculator; _queryPlanReuseChecker = queryPlanReuseChecker; + _searchParameterSqlParser = searchParameterSqlParser; _logger = logger; _schemaInformation = schemaInformation; @@ -314,6 +295,8 @@ public override async Task SearchAsync(SearchOptions searchOptions int resultCount = searchResult.Results.Count(r => r.SearchEntryMode == SearchEntryMode.Match); if (!sqlSearchOptions.IsSortWithFilter && + !sqlSearchOptions.SortHasMissingModifier && + !sqlSearchOptions.SortQuerySecondPhase && searchResult.ContinuationToken == null && resultCount <= sqlSearchOptions.MaxItemCount && sqlSearchOptions.Sort != null && @@ -322,8 +305,9 @@ public override async Task SearchAsync(SearchOptions searchOptions { // We seem to have run a sort which has returned less results than what max we can return. // Let's determine whether we need to execute another query or not. - if ((sqlSearchOptions.Sort[0].sortOrder == SortOrder.Ascending && sqlSearchOptions.DidWeSearchForSortValue.HasValue && !sqlSearchOptions.DidWeSearchForSortValue.Value) || - (sqlSearchOptions.Sort[0].sortOrder == SortOrder.Descending && sqlSearchOptions.DidWeSearchForSortValue.HasValue && sqlSearchOptions.DidWeSearchForSortValue.Value && !sqlSearchOptions.SortHasMissingModifier) || (sqlSearchOptions.Sort[0].sortOrder == SortOrder.Descending && resultCount == 0 && !sqlSearchOptions.CountOnly)) + if ((sqlSearchOptions.Sort[0].sortOrder == SortOrder.Ascending) + || (sqlSearchOptions.Sort[0].sortOrder == SortOrder.Descending) + || (sqlSearchOptions.Sort[0].sortOrder == SortOrder.Descending && resultCount == 0 && !sqlSearchOptions.CountOnly)) { if (sqlSearchOptions.MaxItemCount - resultCount == 0) { @@ -373,16 +357,43 @@ public override async Task SearchAsync(SearchOptions searchOptions var includesContinuationToken = searchResult.IncludesContinuationToken; - if (secondSearchResult.IncludesContinuationToken != null) + // If phase 2 didn't produce an includes continuation token but we know + // there are includes to fetch (IncludeContinuationTokenSearch was set because + // phase 1 exhausted the include budget), create one from phase 2's matched results. + var secondPhaseIncludesCt = secondSearchResult.IncludesContinuationToken; + if (secondPhaseIncludesCt == null + && sqlSearchOptions.IncludeContinuationTokenSearch + && sqlSearchOptions.IncludesOperationSupported + && secondSearchResult.Results.Any(r => r.SearchEntryMode == SearchEntryMode.Match)) + { + var phase2Matches = secondSearchResult.Results + .Where(r => r.SearchEntryMode == SearchEntryMode.Match) + .ToList(); + var firstMatch = phase2Matches.First().Resource; + var lastMatch = phase2Matches.Last().Resource; + var resourceTypeId = _model.GetResourceTypeId(firstMatch.ResourceTypeName); + + secondPhaseIncludesCt = new IncludesContinuationToken(new object[] + { + resourceTypeId, + firstMatch.ResourceSurrogateId, + lastMatch.ResourceSurrogateId, + null, + null, + true, // SortQuerySecondPhase + }).ToJson(); + } + + if (secondPhaseIncludesCt != null) { if (includesContinuationToken == null) { - includesContinuationToken = secondSearchResult.IncludesContinuationToken; + includesContinuationToken = secondPhaseIncludesCt; } else { var firstToken = IncludesContinuationToken.FromString(includesContinuationToken); - var secondToken = IncludesContinuationToken.FromString(secondSearchResult.IncludesContinuationToken); + var secondToken = IncludesContinuationToken.FromString(secondPhaseIncludesCt); includesContinuationToken = new IncludesContinuationToken(new object[] { firstToken.MatchResourceTypeId, @@ -490,86 +501,48 @@ private async Task SearchImpl(SqlSearchOptions sqlSearchOptions, b } Stopwatch stopwatch = Stopwatch.StartNew(); - Expression searchExpression = sqlSearchOptions.Expression; // AND in the continuation token + ContinuationToken continuationToken = null; if (!string.IsNullOrWhiteSpace(sqlSearchOptions.ContinuationToken) && !sqlSearchOptions.CountOnly) { - var continuationToken = ContinuationToken.FromString(sqlSearchOptions.ContinuationToken); - if (continuationToken != null) + continuationToken = ContinuationToken.FromString(sqlSearchOptions.ContinuationToken); + if (continuationToken == null) { - if (string.IsNullOrEmpty(continuationToken.SortValue)) - { - // Check whether it's a _lastUpdated or (_type,_lastUpdated) sort optimization - bool optimize = true; - (SearchParameterInfo searchParamInfo, SortOrder sortOrder) = sqlSearchOptions.Sort.Count == 0 ? default : sqlSearchOptions.Sort[0]; - if (sqlSearchOptions.Sort.Count > 0) - { - if (!(searchParamInfo.Name == SearchParameterNames.LastUpdated || searchParamInfo.Name == SearchParameterNames.ResourceType)) - { - optimize = false; - } - } - - FieldName fieldName; - object keyValue; - SearchParameterInfo parameter; - if (continuationToken.ResourceTypeId == null || _schemaInformation.Current < SchemaVersionConstants.PartitionedTables) - { - // backwards compat - parameter = SqlSearchParameters.ResourceSurrogateIdParameter; - fieldName = SqlFieldName.ResourceSurrogateId; - keyValue = continuationToken.ResourceSurrogateId; - } - else - { - parameter = SqlSearchParameters.PrimaryKeyParameter; - fieldName = SqlFieldName.PrimaryKey; - keyValue = new PrimaryKeyValue(continuationToken.ResourceTypeId.Value, continuationToken.ResourceSurrogateId); - } - - Expression lastUpdatedExpression = !optimize - ? Expression.GreaterThan(fieldName, null, keyValue) - : sortOrder == SortOrder.Ascending - ? Expression.GreaterThan(fieldName, null, keyValue) - : Expression.LessThan(fieldName, null, keyValue); + _logger.LogWarning("Bad Request (InvalidContinuationToken)"); + throw new BadRequestException(Resources.InvalidContinuationToken); + } - var tokenExpression = Expression.SearchParameter(parameter, lastUpdatedExpression); - searchExpression = searchExpression == null ? tokenExpression : Expression.And(tokenExpression, searchExpression); - } + if (continuationToken.SortValue != null && continuationToken.SortValue.Equals(SqlSearchConstants.SortSentinelValueForCt, StringComparison.OrdinalIgnoreCase)) + { + continuationToken = null; + sqlSearchOptions.SortQuerySecondPhase = true; } - else + else if (continuationToken.SortValue != null + && sqlSearchOptions.Sort?.Count > 0 + && sqlSearchOptions.Sort[0].sortOrder == SortOrder.Ascending + && sqlSearchOptions.Sort[0].searchParameterInfo.Code != KnownQueryParameterNames.LastUpdated) { - _logger.LogWarning("Bad Request (InvalidContinuationToken)"); - throw new BadRequestException(Resources.InvalidContinuationToken); + // For ascending sort, having a SortValue in the continuation token means we're + // paginating within phase 2 (resources WITH the sort parameter). Set SortQuerySecondPhase + // so the parser generates a sort CTE rather than a missing query. + sqlSearchOptions.SortQuerySecondPhase = true; } } var originalSort = new List<(SearchParameterInfo, SortOrder)>(sqlSearchOptions.Sort); - var clonedSearchOptions = UpdateSort(sqlSearchOptions, searchExpression); + var clonedSearchOptions = new SqlSearchOptions(sqlSearchOptions); - if (clonedSearchOptions.CountOnly) + if (clonedSearchOptions.CountOnly && !clonedSearchOptions.QueryParams.Any(kvp => kvp.Key == KnownQueryParameterNames.Summary)) { - // if we're only returning a count, discard any _include parameters since included resources are not counted. - searchExpression = searchExpression?.AcceptVisitor(RemoveIncludesRewriter.Instance); +#pragma warning disable IDE0300 // Simplify collection initialization +#pragma warning disable CA1861 // Avoid constant arrays as arguments + clonedSearchOptions.QueryParams.Add(KnownQueryParameterNames.Summary, new string[] { "count" }); +#pragma warning restore CA1861 // Avoid constant arrays as arguments +#pragma warning restore IDE0300 // Simplify collection initialization } - // ! - Trace - SqlRootExpression expression = (SqlRootExpression)CreateDefaultSearchExpression(searchExpression, clonedSearchOptions) - ?.AcceptVisitor(IncludeRewriter.Instance) - ?? SqlRootExpression.WithResourceTableExpressions(); - expression = AttachSmartCompartmentMembership(expression, searchExpression, clonedSearchOptions); - - await CreateStats(expression, cancellationToken); - - // Reads by resource ids is handled directly via GetAsync(). - // Search result is set only on success, otherwise it is null. - // SqlServerFhirDataStore uses the same retry class, so it is not needed to call this inside _sqlRetryService.ExecuteSql down below. - if (await GetResourcesByIdsAsync(expression, clonedSearchOptions, _fhirDataStore, cancellationToken) is SearchResult result) - { - _logger.LogInformation("Get resources by ids was handled via GetAsync()"); - return result; - } + // Old expression tree pipeline removed - SQL generation is now handled by SearchParameterSqlParser.ParseMultiple SearchResult searchResult = null; await _sqlRetryService.ExecuteSql( @@ -585,17 +558,17 @@ await _sqlRetryService.ExecuteSql( { PopulateSqlCommandFromQueryHints(clonedSearchOptions, sqlCommand); sqlCommand.CommandTimeout = 1200; // set to 20 minutes, as dataset is usually large - } + }/* else if (TryExtractGetResourcesByTokensParams(expression, clonedSearchOptions, (SqlServerFhirModel)_model, out var resourceTypeId, out var searchParamId, out var tokens, out var top)) { PopulateGetResourcesByTokensCommand(sqlCommand, resourceTypeId, searchParamId, tokens, top); - } + }*/ else { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - - EnableTimeAndIoMessageLogging(stringBuilder, connection); + // var stringBuilder = new IndentedStringBuilder(new StringBuilder()); + // EnableTimeAndIoMessageLogging(stringBuilder, connection); + /* var queryGenerator = new SqlQueryGenerator( stringBuilder, new HashingSqlQueryParameterManager(new SqlQueryParameterManager(sqlCommand.Parameters)), @@ -610,11 +583,13 @@ await _sqlRetryService.ExecuteSql( isSortValueNeeded = queryGenerator.IsSortValueNeeded(clonedSearchOptions); SqlCommandSimplifier.RemoveRedundantParameters(stringBuilder, sqlCommand.Parameters, _logger); + */ - var queryText = stringBuilder.ToString(); + var queryText = _searchParameterSqlParser.ParseMultiple(clonedSearchOptions.QueryParams, sqlSearchOptions, continuationToken); var queryHash = _queryHashCalculator.CalculateHash(queryText); _logger.LogInformation("SQL Search Service query hash: {QueryHash}", queryHash); var customQuery = CustomQueries.CheckQueryHash(connection, queryHash, _logger); + isSortValueNeeded = queryText.Contains("SortValue", StringComparison.OrdinalIgnoreCase); if (!string.IsNullOrEmpty(customQuery)) { @@ -628,7 +603,7 @@ await _sqlRetryService.ExecuteSql( sqlCommand.CommandText = queryText; #pragma warning restore CA2100 // Review SQL queries for security vulnerabilities - _logger.LogInformation($"Query.SearchParamIds={string.Join(",", queryGenerator.SearchParamIds)}"); + // _logger.LogInformation($"Query.SearchParamIds={string.Join(",", queryGenerator.SearchParamIds)}"); } LogSqlCommand(sqlCommand); @@ -803,19 +778,14 @@ await _sqlRetryService.ExecuteSql( await reader.NextResultAsync(cancellationToken); ContinuationToken continuationToken = moreResults - ? new ContinuationToken( - clonedSearchOptions.Sort.Select(s => - s.searchParameterInfo.Name switch - { - SearchParameterNames.ResourceType => (object)newContinuationType, - SearchParameterNames.LastUpdated => newContinuationId, - _ => sortValue, - }).ToArray()) + ? new ContinuationToken(new object[] { sortValue, newContinuationType, newContinuationId }) : null; + string includesContinuationTokenString = null; if (clonedSearchOptions.IncludesOperationSupported - && clonedSearchOptions.Expression is MultiaryExpression - && ((MultiaryExpression)clonedSearchOptions.Expression).Expressions.Any(x => x is IncludeExpression) + && clonedSearchOptions.QueryParams.Any(kvp => + kvp.Key.StartsWith("_include", StringComparison.OrdinalIgnoreCase) + || kvp.Key.StartsWith("_revinclude", StringComparison.OrdinalIgnoreCase)) && newContinuationType.HasValue && newContinuationId.HasValue && matchedResourceSurrogateIdStart.HasValue @@ -859,18 +829,6 @@ await _sqlRetryService.ExecuteSql( sqlSearchOptions.DidWeSearchForSortValue = isSortValueNeeded; } - // This value is set inside the SortRewriter. If it is set, we need to pass - // this value back to the caller. - if (clonedSearchOptions.IsSortWithFilter) - { - sqlSearchOptions.IsSortWithFilter = true; - } - - if (clonedSearchOptions.SortHasMissingModifier) - { - sqlSearchOptions.SortHasMissingModifier = true; - } - _logger.LogInformation("Continuation token is {ContinuationTokenPresent}returned. {MaxSurrogateId}", continuationToken != null ? string.Empty : "not ", newContinuationId); _logger.LogInformation("Includes continuation token is {ContinuationTokenPresent}returned", includesContinuationTokenString != null ? string.Empty : "not "); @@ -1238,14 +1196,14 @@ internal static string ExtractParameterHash(string queryText) // ParametersHashStart/End are always emitted in fixed uppercase by SqlQueryGenerator, // so use Ordinal (not OrdinalIgnoreCase) to avoid matching arbitrary user-authored // lowercase comments such as "/* hash ... */". - int hashStart = queryText.IndexOf(Expressions.Visitors.QueryGenerators.SqlQueryGenerator.ParametersHashStart, StringComparison.Ordinal); + int hashStart = queryText.IndexOf(SqlSearchConstants.ParametersHashStart, StringComparison.Ordinal); if (hashStart < 0) { return null; } - int valueStart = hashStart + Expressions.Visitors.QueryGenerators.SqlQueryGenerator.ParametersHashStart.Length; - int hashEnd = queryText.IndexOf(Expressions.Visitors.QueryGenerators.SqlQueryGenerator.ParametersHashEnd, valueStart, StringComparison.Ordinal); + int valueStart = hashStart + SqlSearchConstants.ParametersHashStart.Length; + int hashEnd = queryText.IndexOf(SqlSearchConstants.ParametersHashEnd, valueStart, StringComparison.Ordinal); if (hashEnd < 0) { return null; @@ -1583,7 +1541,7 @@ AND replace(replace(replace(replace(replace(replace(qt.query_sql_text, char(9), // Fast path: search by the embedded parameter hash string. cmd.CommandText = HashLookupSql; cmd.Parameters.Clear(); - string hashFilter = Expressions.Visitors.QueryGenerators.SqlQueryGenerator.ParametersHashStart + fragmentHash; + string hashFilter = SqlSearchConstants.ParametersHashStart + fragmentHash; cmd.Parameters.AddWithValue("@HashFilter", hashFilter); using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); @@ -1721,101 +1679,6 @@ public override async Task> GetUsedResourceTypes(Cancellat return await sqlCommand.ExecuteReaderAsync(_sqlRetryService, ReaderGetUsedResourceTypes, _logger, cancellationToken); } - /// - /// If no sorting fields are specified, sets the sorting fields to the primary key. (This is either ResourceSurrogateId or ResourceTypeId, ResourceSurrogateId). - /// If sorting only by ResourceTypeId, adds in ResourceSurrogateId as the second sort column. - /// If sorting by ResourceSurrogateId and using partitioned tables and searching over a single type, sets the sort to ResourceTypeId, ResourceSurrogateId - /// - /// The input SearchOptions - /// The searchExpression - /// If the sort needs to be updated, a new instance, otherwise, the same instance as - private SqlSearchOptions UpdateSort(SqlSearchOptions searchOptions, Expression searchExpression) - { - SqlSearchOptions newSearchOptions = searchOptions; - if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History) && searchOptions.Sort.Any()) - { - // history is always sorted by _lastUpdated (except for export). - newSearchOptions = searchOptions.CloneSqlSearchOptions(); - - return newSearchOptions; - } - - if (searchOptions.Sort.Count == 0) - { - newSearchOptions = searchOptions.CloneSqlSearchOptions(); - - if (_schemaInformation.Current < SchemaVersionConstants.PartitionedTables) - { - newSearchOptions.Sort = new (SearchParameterInfo searchParameterInfo, SortOrder sortOrder)[] - { - (_fakeLastUpdate, SortOrder.Ascending), - }; - } - else - { - newSearchOptions.Sort = new (SearchParameterInfo searchParameterInfo, SortOrder sortOrder)[] - { - (SearchParameterInfo.ResourceTypeSearchParameter, SortOrder.Ascending), - (_fakeLastUpdate, SortOrder.Ascending), - }; - } - - return newSearchOptions; - } - - if (searchOptions.Sort.Count == 1 && searchOptions.Sort[0].searchParameterInfo.Name == SearchParameterNames.ResourceType) - { - // We will not get here unless the schema version is at least SchemaVersionConstants.PartitionedTables. - - // Add _lastUpdated to the sort list so that there is a deterministic key to sort on - - newSearchOptions = searchOptions.CloneSqlSearchOptions(); - - newSearchOptions.Sort = new (SearchParameterInfo searchParameterInfo, SortOrder sortOrder)[] - { - (SearchParameterInfo.ResourceTypeSearchParameter, searchOptions.Sort[0].sortOrder), - (_fakeLastUpdate, searchOptions.Sort[0].sortOrder), - }; - - return newSearchOptions; - } - - if (searchOptions.Sort.Count == 1 && searchOptions.Sort[0].searchParameterInfo.Name == SearchParameterNames.LastUpdated && _schemaInformation.Current >= SchemaVersionConstants.PartitionedTables) - { - (short? singleAllowedTypeId, BitArray allowedTypes) = TypeConstraintVisitor.Instance.Visit(searchExpression, _model); - - if (singleAllowedTypeId != null && allowedTypes != null) - { - // this means that this search is over a single type. - newSearchOptions = searchOptions.CloneSqlSearchOptions(); - - newSearchOptions.Sort = new (SearchParameterInfo searchParameterInfo, SortOrder sortOrder)[] - { - (SearchParameterInfo.ResourceTypeSearchParameter, searchOptions.Sort[0].sortOrder), - (_fakeLastUpdate, searchOptions.Sort[0].sortOrder), - }; - } - - return newSearchOptions; - } - - if (searchOptions.Sort[^1].searchParameterInfo.Name != SearchParameterNames.LastUpdated) - { - // Make sure custom sort has _lastUpdated as the last sort parameter. - - newSearchOptions = searchOptions.CloneSqlSearchOptions(); - - newSearchOptions.Sort = new List<(SearchParameterInfo searchParameterInfo, SortOrder sortOrder)>(searchOptions.Sort) - { - (_fakeLastUpdate, SortOrder.Ascending), - }; - - return newSearchOptions; - } - - return newSearchOptions; - } - private void ReadWrapper( SqlDataReader reader, bool readIsHistory, @@ -2175,23 +2038,24 @@ private static string GetForceReindexResourceType(SearchOptions searchOptions) return resourceType; } + /* private async Task CreateStats(SqlRootExpression expression, CancellationToken cancel) { if (_resourceSearchParamStats == null) { lock (_locker) { - _resourceSearchParamStats ??= new ResourceSearchParamStats(_sqlRetryService, _logger, _queryGeneratorFactory, cancel); + _resourceSearchParamStats ??= new ResourceSearchParamStats(_sqlRetryService, _logger, cancel); } } await _resourceSearchParamStats.Create(expression, _sqlRetryService, _logger, (SqlServerFhirModel)_model, cancel); } + */ internal static ICollection<(string TableName, string ColumnName, short ResourceTypeId, short SearchParamId, short? ReferenceResourceTypeId)> GetStatsFromCache() { - return _resourceSearchParamStats?.GetStatsFromCache() - ?? Array.Empty<(string TableName, string ColumnName, short ResourceTypeId, short SearchParamId, short? ReferenceResourceTypeId)>(); + return Array.Empty<(string TableName, string ColumnName, short ResourceTypeId, short SearchParamId, short? ReferenceResourceTypeId)>(); } /// @@ -2239,88 +2103,51 @@ private async Task SearchIncludeImpl(SqlSearchOptions sqlSearchOpt throw new BadRequestException(Resources.InvalidIncludesContinuationToken); } - var gteExpression = Expression.GreaterThanOrEqual( - SqlFieldName.ResourceSurrogateId, - null, - includesContinuationToken.MatchResourceSurrogateIdMin); - var lteExpression = Expression.LessThanOrEqual( - SqlFieldName.ResourceSurrogateId, - null, - includesContinuationToken.MatchResourceSurrogateIdMax); - var tokenExpression = Expression.And( - Expression.SearchParameter(SqlSearchParameters.ResourceSurrogateIdParameter, gteExpression), - Expression.SearchParameter(SqlSearchParameters.ResourceSurrogateIdParameter, lteExpression)); - Expression searchExpression = sqlSearchOptions.Expression == null ? tokenExpression : Expression.And(tokenExpression, sqlSearchOptions.Expression); - var originalSort = new List<(SearchParameterInfo, SortOrder)>(sqlSearchOptions.Sort); - var clonedSearchOptions = UpdateSort(sqlSearchOptions, searchExpression); + var continuationToken = ContinuationToken.FromString(sqlSearchOptions.ContinuationToken); - if (clonedSearchOptions.CountOnly) - { - // if we're only returning a count, discard any _include parameters since included resources are not counted. - searchExpression = searchExpression?.AcceptVisitor(RemoveIncludesRewriter.Instance); - } - - // ! - Trace - SqlRootExpression expression = (SqlRootExpression)CreateDefaultSearchExpression(searchExpression, clonedSearchOptions) - ?.AcceptVisitor(IncludesOperationRewriter.Instance) - ?? SqlRootExpression.WithResourceTableExpressions(); - expression = AttachSmartCompartmentMembership(expression, searchExpression, clonedSearchOptions); + var originalSort = new List<(SearchParameterInfo, SortOrder)>(sqlSearchOptions.Sort); - await CreateStats(expression, cancellationToken); + // Old expression tree pipeline removed - SQL generation is now handled by SearchParameterSqlParser.ParseMultiple SearchResult searchResult = null; await _sqlRetryService.ExecuteSql( async (connection, cancellationToken, sqlException) => { - using (SqlCommand sqlCommand = connection.CreateCommand()) // WARNING, this code will not set sqlCommand.Transaction. Sql transactions via C#/.NET are not supported in this method. + using (SqlCommand sqlCommand = connection.CreateCommand()) { sqlCommand.CommandTimeout = (int)_sqlServerDataStoreConfiguration.CommandTimeout.TotalSeconds; - var exportTimeTravel = clonedSearchOptions.QueryHints != null && ContainsGlobalEndSurrogateId(clonedSearchOptions); - if (exportTimeTravel) + var queryText = _searchParameterSqlParser.ParseMultiple( + sqlSearchOptions.QueryParams, + sqlSearchOptions, + continuationToken: continuationToken, + includesContinuationToken: includesContinuationToken); + + if (string.IsNullOrEmpty(queryText)) { - PopulateSqlCommandFromQueryHints(clonedSearchOptions, sqlCommand); - sqlCommand.CommandTimeout = 1200; // set to 20 minutes, as dataset is usually large + searchResult = new SearchResult( + Enumerable.Empty().ToList(), + null, + originalSort, + sqlSearchOptions.UnsupportedSearchParams); + return; } - else - { - var stringBuilder = new IndentedStringBuilder(new StringBuilder()); - - EnableTimeAndIoMessageLogging(stringBuilder, connection); - var queryGenerator = new SqlQueryGenerator( - stringBuilder, - new HashingSqlQueryParameterManager(new SqlQueryParameterManager(sqlCommand.Parameters)), - _model, - _schemaInformation, - _queryGeneratorFactory, - _fhirSqlServerConfiguration.ReuseQueryPlans && _queryPlanReuseChecker.CanReuseQueryPlan(clonedSearchOptions), - sqlSearchOptions.IsAsyncOperation, - sqlException); - - expression.AcceptVisitor(queryGenerator, clonedSearchOptions); - - SqlCommandSimplifier.RemoveRedundantParameters(stringBuilder, sqlCommand.Parameters, _logger); - - var queryText = stringBuilder.ToString(); - var queryHash = _queryHashCalculator.CalculateHash(queryText); - _logger.LogInformation("SQL Search Service query hash: {QueryHash}", queryHash); - var customQuery = CustomQueries.CheckQueryHash(connection, queryHash, _logger); - - if (!string.IsNullOrEmpty(customQuery)) - { - _logger.LogInformation("SQl Search Service, custom Query identified by hash {QueryHash}, {CustomQuery}", queryHash, customQuery); - queryText = customQuery; - sqlCommand.CommandType = CommandType.StoredProcedure; - } + var queryHash = _queryHashCalculator.CalculateHash(queryText); + _logger.LogInformation("SQL Search Service includes query hash: {QueryHash}", queryHash); + var customQuery = CustomQueries.CheckQueryHash(connection, queryHash, _logger); - // Command text contains no direct user input. -#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities - sqlCommand.CommandText = queryText; -#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities + if (!string.IsNullOrEmpty(customQuery)) + { + queryText = customQuery; + sqlCommand.CommandType = CommandType.StoredProcedure; } +#pragma warning disable CA2100 + sqlCommand.CommandText = queryText; +#pragma warning restore CA2100 + LogSqlCommand(sqlCommand); var executionStopwatch = Stopwatch.StartNew(); @@ -2329,30 +2156,6 @@ await _sqlRetryService.ExecuteSql( { using (var reader = await sqlCommand.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken)) { - if (clonedSearchOptions.CountOnly) - { - await reader.ReadAsync(cancellationToken); - long count = reader.GetInt64(0); - if (count > int.MaxValue) - { - _requestContextAccessor.RequestContext.BundleIssues.Add( - new OperationOutcomeIssue( - OperationOutcomeConstants.IssueSeverity.Error, - OperationOutcomeConstants.IssueType.NotSupported, - string.Format(Core.Resources.SearchCountResultsExceedLimit, count, int.MaxValue))); - - _logger.LogWarning("Invalid Search Operation (SearchCountResultsExceedLimit)"); - throw new InvalidSearchOperationException(string.Format(Core.Resources.SearchCountResultsExceedLimit, count, int.MaxValue)); - } - - searchResult = new SearchResult((int)count, clonedSearchOptions.UnsupportedSearchParams); - - // call NextResultAsync to get the info messages - await reader.NextResultAsync(cancellationToken); - - return; - } - var moreResults = false; var moreResultsSurrogateIdCutOff = 0L; var moreResultsResourceTypeId = 0; @@ -2362,7 +2165,7 @@ await _sqlRetryService.ExecuteSql( { ReadWrapper( reader, - exportTimeTravel, + false, out short resourceTypeId, out string resourceId, out int version, @@ -2382,15 +2185,13 @@ await _sqlRetryService.ExecuteSql( continue; } - if (resources.Count < clonedSearchOptions.IncludeCount) + if (resources.Count < sqlSearchOptions.IncludeCount) { var rawResource = new Lazy(() => { using var rawResourceStream = new MemoryStream(rawResourceBytes); var decompressedResource = _compressedRawResourceConverter.ReadCompressedRawResource(rawResourceStream); - _logger.LogDebug("{NameOfResourceSurrogateId}: {ResourceSurrogateId}; {NameOfResourceTypeId}: {ResourceTypeId}; Decompressed length: {RawResourceLength}", nameof(resourceSurrogateId), resourceSurrogateId, nameof(resourceTypeId), resourceTypeId, decompressedResource.Length); - if (string.IsNullOrEmpty(decompressedResource)) { decompressedResource = MissingResourceFactory.CreateJson(resourceId, _model.GetResourceTypeName(resourceTypeId), "warning", "incomplete"); @@ -2405,7 +2206,7 @@ await _sqlRetryService.ExecuteSql( resourceId, version.ToString(CultureInfo.InvariantCulture), _model.GetResourceTypeName(resourceTypeId), - clonedSearchOptions.OnlyIds ? null : new RawResource(rawResource, FhirResourceFormat.Json, isMetaSet: isRawResourceMetaSet), + new RawResource(rawResource, FhirResourceFormat.Json, isMetaSet: isRawResourceMetaSet), new ResourceRequest(requestMethod), resourceSurrogateId.ToLastUpdated(), isDeleted, @@ -2424,10 +2225,10 @@ await _sqlRetryService.ExecuteSql( moreResultsResourceTypeId = resourceTypeId; moreResultsSurrogateIdCutOff = resourceSurrogateId - 1; moreResults = true; + break; } } - // call NextResultAsync to get the info messages await reader.NextResultAsync(cancellationToken); IncludesContinuationToken nextIncludesContinuationToken = null; @@ -2437,12 +2238,12 @@ await _sqlRetryService.ExecuteSql( nextIncludesContinuationToken = new IncludesContinuationToken( new object[] { - includesContinuationToken.MatchResourceTypeId, - includesContinuationToken.MatchResourceSurrogateIdMin, - includesContinuationToken.MatchResourceSurrogateIdMax, - moreResultsResourceTypeId, - moreResultsSurrogateIdCutOff, - includesContinuationToken.SortQuerySecondPhase, + includesContinuationToken.MatchResourceTypeId, + includesContinuationToken.MatchResourceSurrogateIdMin, + includesContinuationToken.MatchResourceSurrogateIdMax, + moreResultsResourceTypeId, + moreResultsSurrogateIdCutOff, + includesContinuationToken.SortQuerySecondPhase, }); } @@ -2450,7 +2251,7 @@ await _sqlRetryService.ExecuteSql( resources, null, originalSort, - clonedSearchOptions.UnsupportedSearchParams, + sqlSearchOptions.UnsupportedSearchParams, null, nextIncludesContinuationToken?.ToJson()); } @@ -2461,7 +2262,6 @@ await _sqlRetryService.ExecuteSql( if (executionStopwatch.ElapsedMilliseconds > _longRunningThreshold.GetValue(_sqlRetryService) && _longRunningQueryDetails.IsEnabled(_sqlRetryService)) { - // Capture query text and command type BEFORE the connection closes string queryTextSnapshot = sqlCommand.CommandText; bool isStoredProcSnapshot = sqlCommand.CommandType == CommandType.StoredProcedure; long executionTimeSnapshot = executionStopwatch.ElapsedMilliseconds; @@ -2475,11 +2275,12 @@ await _sqlRetryService.ExecuteSql( }, _logger, cancellationToken, - true); // this enables reads from replicas + true); return searchResult; } + /* private SqlRootExpression CreateDefaultSearchExpression(Expression rootExpression, SqlSearchOptions searchOptions) { Expression afterSmartCompartment = rootExpression @@ -2784,6 +2585,7 @@ private static bool TryExtractResourceKeys(SqlRootExpression expression, SearchO return resourceKeys.Count > 0 && resourceKeys.Count <= searchOptions.MaxItemCount; // second condition guarantees absence of continuation token } + */ private static void PopulateGetResourceSurrogateIdRangesCommand(SqlCommand cmd, short resourceTypeId, long startId, long endId, int rangeSize, int? numberOfRanges, bool up, bool activeOnly) { @@ -2801,16 +2603,13 @@ private static void PopulateGetResourceSurrogateIdRangesCommand(SqlCommand cmd, internal class ResourceSearchParamStats { private readonly ConcurrentDictionary<(string TableName, string ColumnName, short ResourceTypeId, short SearchParamId, short? ReferenceResourceTypeId), bool> _stats; - private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; public ResourceSearchParamStats( ISqlRetryService sqlRetryService, ILogger logger, - SearchParamTableExpressionQueryGeneratorFactory queryGeneratorFactory, CancellationToken cancel) { _stats = new ConcurrentDictionary<(string TableName, string ColumnName, short ResourceTypeId, short SearchParamId, short? ReferenceResourceTypeId), bool>(); - _queryGeneratorFactory = queryGeneratorFactory; Init(sqlRetryService, logger, cancel).Wait(cancel); } @@ -2819,6 +2618,7 @@ public ResourceSearchParamStats( return _stats.Keys; } + /* public async Task Create( SqlRootExpression expression, ISqlRetryService sqlRetryService, @@ -3191,6 +2991,7 @@ private static void CollectReferenceResourceTypes(Expression expression, SqlServ break; } } + */ private static void CollectResourceTypesFromExpression(Expression expression, SqlServerFhirModel model, HashSet resourceTypeIds) { @@ -3274,6 +3075,7 @@ private async Task Init(ISqlRetryService sqlRetryService, ILogger GenerateRows(IList tokens) } } } + */ } } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs index ec135989d8..2cdb3cda2a 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Storage/SqlServerFhirModel.cs @@ -114,6 +114,11 @@ public short GetResourceTypeId(string resourceTypeName) return resourceTypeId; } + if (resourceTypeName == KnownResourceTypes.DomainResource || resourceTypeName == KnownResourceTypes.Resource) + { + return 0; + } + throw new ResourceNotFoundException($"Resource type '{resourceTypeName}' is not a known resource type."); } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs b/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs index 12fe9334b3..f92ebaa8fa 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs @@ -32,8 +32,7 @@ using Microsoft.Health.Fhir.SqlServer.Features.Operations.Import; using Microsoft.Health.Fhir.SqlServer.Features.Schema; using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; using Microsoft.Health.Fhir.SqlServer.Features.Storage; using Microsoft.Health.Fhir.SqlServer.Features.Storage.Registry; using Microsoft.Health.Fhir.SqlServer.Features.Watchdogs; @@ -118,25 +117,15 @@ public static IFhirServerBuilder AddSqlServer(this IFhirServerBuilder fhirServer AddSqlServerTableRowParameterGenerators(services); - services.Add() + // Add new sql search parser here + services.Add() .Singleton() .AsSelf(); - services.Add() + services.Add() .Singleton() - .AsSelf(); - - services.Add() - .Singleton() - .AsSelf(); - - services.Add() - .Singleton() - .AsSelf(); - - services.Add() - .Singleton() - .AsSelf(); + .AsSelf() + .AsImplementedInterfaces(); services.Add() .Singleton() diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CompartmentTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CompartmentTests.cs index dd50b8cb3d..be5c11569a 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CompartmentTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CompartmentTests.cs @@ -202,6 +202,7 @@ public async Task GivenMoreSearchResultsThanCount_WhenSearchingAPatientCompartme } results.Entry.AddRange(bundle.Entry); + loop++; } ValidateBundle(results, Fixture.Observation, Fixture.Encounter, Fixture.Condition); diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/InProcTestFhirServer.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/InProcTestFhirServer.cs index 235aff5f8b..1f8cda699e 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/InProcTestFhirServer.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/InProcTestFhirServer.cs @@ -44,6 +44,11 @@ public InProcTestFhirServer(DataStore dataStore, Type startupType) var projectDir = GetProjectPath("src", startupType); var testConfigPath = Path.GetFullPath("testconfiguration.json"); + if (!testConfigPath.Contains("\\bin\\Debug\\net")) + { + testConfigPath = Path.Combine(testConfigPath.Substring(0, testConfigPath.IndexOf("testconfiguration.json")), "bin\\Debug\\net9.0\\testconfiguration.json"); + } + var launchSettings = JObject.Parse(File.ReadAllText(Path.Combine(projectDir, "Properties", "launchSettings.json"))); var configuration = launchSettings["profiles"][dataStore.ToString()]["environmentVariables"].Cast().ToDictionary(p => p.Name, p => p.Value.ToString()); @@ -86,7 +91,7 @@ public InProcTestFhirServer(DataStore dataStore, Type startupType) configuration["FhirServer:CoreFeatures:SearchParameterCacheRefreshIntervalSeconds"] = "1"; configuration["FhirServer:CoreFeatures:SystemConformanceProviderRefreshIntervalSeconds"] = "5"; configuration["FhirServer:CoreFeatures:SystemConformanceProviderRebuildIntervalSeconds"] = "120"; - configuration["FhirServer:CoreFeatures:MaxIncludeCountPerSearch"] = "10"; + configuration["FhirServer:CoreFeatures:MaxIncludeCountPerSearch"] = "20"; configuration["FhirServer:CoreFeatures:DefaultIncludeCountPerSearch"] = "10"; if (startupType.IsDefined(typeof(RequiresIsolatedDatabaseAttribute))) diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/IncludeSearchTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/IncludeSearchTests.cs index 542beb98fe..2cdcea5f2c 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/IncludeSearchTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/IncludeSearchTests.cs @@ -230,18 +230,23 @@ public async Task GivenAnIncludeSearchExpressionWithMultipleResourceTableParamet string lastUpdated = HttpUtility.UrlEncode($"{Fixture.PatientGroup.Meta.LastUpdated:o}"); string query = $"_tag={Fixture.Tag}&_include=DiagnosticReport:patient:Patient&_include=DiagnosticReport:result:Observation&code=429858000&_lastUpdated=lt{lastUpdated}"; - await SearchAndValidateBundleAsync( - ResourceType.DiagnosticReport, - query, - Fixture.SmithSnomedDiagnosticReport, - Fixture.SmithPatient, - Fixture.SmithSnomedObservation, - Fixture.TrumanSnomedDiagnosticReport, - Fixture.TrumanPatient, - Fixture.TrumanSnomedObservation); - - // delete the extra entry added - await Fixture.TestFhirClient.DeleteAsync(newDiagnosticReportResponse.Resource); + try + { + await SearchAndValidateBundleAsync( + ResourceType.DiagnosticReport, + query, + Fixture.SmithSnomedDiagnosticReport, + Fixture.SmithPatient, + Fixture.SmithSnomedObservation, + Fixture.TrumanSnomedDiagnosticReport, + Fixture.TrumanPatient, + Fixture.TrumanSnomedObservation); + } + finally + { + // delete the extra entry added + await Fixture.TestFhirClient.DeleteAsync(newDiagnosticReportResponse.Resource); + } } [Fact] @@ -285,7 +290,7 @@ await SearchAndValidateBundleAsync( } [Fact] - public async Task GivenAnIncludeSearchExpression_WhenSearched_DoesnotIncludeDeletedOrUpdatedResources() + public async Task GivenAnIncludeSearchExpression_WhenSearched_DoesNotIncludeDeletedOrUpdatedResources() { string query = $"_tag={Fixture.Tag}&_include=Patient:organization"; @@ -511,17 +516,22 @@ public async Task GivenARevIncludeSearchExpressionWithMultipleResourceTableParam string lastUpdated = HttpUtility.UrlEncode($"{Fixture.PatientGroup.Meta.LastUpdated:o}"); string query = $"_tag={Fixture.Tag}&_revinclude=DiagnosticReport:result&code=429858000&_lastUpdated=lt{lastUpdated}"; - Bundle bundle = await SearchAndValidateBundleAsync( - ResourceType.Observation, - query, - Fixture.SmithSnomedDiagnosticReport, - Fixture.SmithSnomedObservation, - Fixture.TrumanSnomedDiagnosticReport, - Fixture.TrumanSnomedObservation, - newDiagnosticReportResponse.Resource); - - // delete the extra entry added - await Fixture.TestFhirClient.DeleteAsync(newDiagnosticReportResponse.Resource); + try + { + Bundle bundle = await SearchAndValidateBundleAsync( + ResourceType.Observation, + query, + Fixture.SmithSnomedDiagnosticReport, + Fixture.SmithSnomedObservation, + Fixture.TrumanSnomedDiagnosticReport, + Fixture.TrumanSnomedObservation, + newDiagnosticReportResponse.Resource); + } + finally + { + // delete the extra entry added + await Fixture.TestFhirClient.DeleteAsync(newDiagnosticReportResponse.Resource); + } } [Fact] diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/QueryPlanReuseCheckerTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/QueryPlanReuseCheckerTests.cs index e823969e27..506bf3d0d1 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/QueryPlanReuseCheckerTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/QueryPlanReuseCheckerTests.cs @@ -418,7 +418,7 @@ IF EXISTS (SELECT * FROM sys.stats WHERE name = '{statName}' AND object_id = OBJ /// /// Creates a SearchOptions instance with the specified search parameters. /// - private SearchOptions CreateSearchOptions(IReadOnlyList searchParameters) + private SearchOptions CreateSearchOptions(IList searchParameters) { return new SearchOptions { diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/SqlServerFhirStorageTestsFixture.cs b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/SqlServerFhirStorageTestsFixture.cs index 5f7e486557..27b2466e70 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/SqlServerFhirStorageTestsFixture.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.Integration/Persistence/SqlServerFhirStorageTestsFixture.cs @@ -35,7 +35,7 @@ using Microsoft.Health.Fhir.SqlServer.Features.Schema; using Microsoft.Health.Fhir.SqlServer.Features.Schema.Model; using Microsoft.Health.Fhir.SqlServer.Features.Search; -using Microsoft.Health.Fhir.SqlServer.Features.Search.Expressions.Visitors; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; using Microsoft.Health.Fhir.SqlServer.Features.Storage; using Microsoft.Health.Fhir.SqlServer.Features.Storage.Registry; using Microsoft.Health.Fhir.SqlServer.Registration; @@ -285,11 +285,6 @@ public async Task InitializeAsync() new ExpressionAccessControl(_fhirRequestContextAccessor), NullLogger.Instance); - var searchParamTableExpressionQueryGeneratorFactory = new SearchParamTableExpressionQueryGeneratorFactory(searchParameterToSearchValueTypeMap); - var sqlRootExpressionRewriter = new SqlRootExpressionRewriter(searchParamTableExpressionQueryGeneratorFactory); - var chainFlatteningRewriter = new ChainFlatteningRewriter(searchParamTableExpressionQueryGeneratorFactory); - var sortRewriter = new SortRewriter(searchParamTableExpressionQueryGeneratorFactory); - var partitionEliminationRewriter = new PartitionEliminationRewriter(sqlServerFhirModel, SchemaInformation, () => searchableSearchParameterDefinitionManager); var compartmentDefinitionManager = new CompartmentDefinitionManager(ModelInfoProvider.Instance); compartmentDefinitionManager.StartAsync(CancellationToken.None).Wait(); var compartmentSearchRewriter = new SqlCompartmentSearchRewriter(new Lazy(() => compartmentDefinitionManager), new Lazy(() => _searchParameterDefinitionManager)); @@ -300,17 +295,14 @@ public async Task InitializeAsync() SqlQueryHashCalculator = new TestSqlHashCalculator(); + var searchParameterSqlParser = Substitute.For(); + _searchService = new SqlServerSearchService( searchOptionsFactory, _fhirDataStore, sqlServerFhirModel, - sqlRootExpressionRewriter, - chainFlatteningRewriter, - sortRewriter, - partitionEliminationRewriter, compartmentSearchRewriter, smartCompartmentSearchRewriter, - searchParamTableExpressionQueryGeneratorFactory, SqlRetryService, SqlServerDataStoreConfiguration, _fhirSqlConfiguration, @@ -319,6 +311,7 @@ public async Task InitializeAsync() new CompressedRawResourceConverter(), SqlQueryHashCalculator, queryPlanReuseChecker, + searchParameterSqlParser, NullLogger.Instance); ISearchParameterSupportResolver searchParameterSupportResolver = Substitute.For(); diff --git a/test/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SqlQueryBuilderTests.cs b/test/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SqlQueryBuilderTests.cs new file mode 100644 index 0000000000..34a6de548b --- /dev/null +++ b/test/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlSearchParser/SqlQueryBuilderTests.cs @@ -0,0 +1,336 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +#nullable enable + +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search.SqlSearchParser +{ + /// + /// Tests for SqlQueryBuilder - demonstrating usage patterns. + /// + public class SqlQueryBuilderTests + { + private readonly ITestOutputHelper _output; + + public SqlQueryBuilderTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void GivenSimpleQuery_WhenBuilt_ThenProperlyFormatted() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.Select("ResourceId", "ResourceTypeId", "Version") + .From("dbo.Resource", "r") + .Where("r.IsDeleted = 0") + .And("r.IsHistory = 0"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains("SELECT", sql); + Assert.Contains("FROM dbo.Resource AS r", sql); + Assert.Contains("WHERE r.IsDeleted = 0", sql); + Assert.Contains("AND r.IsHistory = 0", sql); + } + + [Fact] + public void GivenQueryWithJoins_WhenBuilt_ThenProperlyIndented() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.Select("r.ResourceId", "t.Code") + .From("dbo.Resource", "r") + .InnerJoin("dbo.TokenSearchParam", "t", "t.ResourceSurrogateId = r.ResourceSurrogateId") + .Where("r.ResourceTypeId = 1") + .OrderBy("r.ResourceId ASC"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains("INNER JOIN dbo.TokenSearchParam AS t ON t.ResourceSurrogateId = r.ResourceSurrogateId", sql); + } + + [Fact] + public void GivenMultiLineJoin_WhenBuilt_ThenConditionsOnSeparateLines() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.Select("*") + .From("dbo.Resource", "r") + .JoinMultiLine( + "LEFT", + "dbo.DateTimeSearchParam", + "dt", + "dt.ResourceTypeId = r.ResourceTypeId", + "dt.ResourceSurrogateId = r.ResourceSurrogateId", + "dt.SearchParamId = 5"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains("LEFT JOIN dbo.DateTimeSearchParam AS dt", sql); + Assert.Contains("ON dt.ResourceTypeId = r.ResourceTypeId", sql); + Assert.Contains("AND dt.ResourceSurrogateId = r.ResourceSurrogateId", sql); + Assert.Contains("AND dt.SearchParamId = 5", sql); + } + + [Fact] + public void GivenCte_WhenBuilt_ThenProperlyFormatted() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.BeginCte("FilteredPatients", isFirstCte: true) + .Select("ResourceId", "ResourceSurrogateId") + .From("dbo.Resource", "r") + .Where("r.ResourceTypeId = 1") + .EndCte(); + + builder.AppendLine(); + builder.Select("*") + .From("FilteredPatients"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains(";WITH", sql); + Assert.Contains("FilteredPatients AS (", sql); + Assert.Contains(")", sql); + } + + [Fact] + public void GivenMultipleCtes_WhenBuilt_ThenProperlyChained() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.BeginCte("cte1", isFirstCte: true) + .Select("ResourceId") + .From("dbo.Resource") + .Where("ResourceTypeId = 1") + .EndCte(); + + builder.AppendLine(); + + builder.BeginCte("cte2") + .Select("ResourceId") + .From("cte1") + .Where("ResourceId > 100") + .EndCte(); + + builder.AppendLine(); + builder.Select("*") + .From("cte2"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains(";WITH", sql); + Assert.Contains("cte1 AS (", sql); + Assert.Contains(",", sql); // Comma between CTEs + Assert.Contains("cte2 AS (", sql); + } + + [Fact] + public void GivenComplexQuery_WhenBuilt_ThenProperlyIndented() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.BeginCte("SearchResults", isFirstCte: true) + .IncreaseIndent() + .SelectWithModifier("DISTINCT TOP 100", "r.ResourceTypeId", "r.ResourceSurrogateId", "1 AS IsMatch") + .From("dbo.Resource", "r") + .InnerJoin("dbo.TokenSearchParam", "t", "t.ResourceSurrogateId = r.ResourceSurrogateId") + .Where("r.ResourceTypeId = 1") + .And("r.IsDeleted = 0") + .And("t.Code = 'active'") + .OrderBy("r.ResourceSurrogateId DESC") + .DecreaseIndent() + .EndCte(); + + builder.AppendLine(); + + builder.Select("r.ResourceId", "r.Version", "r.RawResource") + .From("dbo.Resource", "r") + .InnerJoin("SearchResults", "s", "s.ResourceSurrogateId = r.ResourceSurrogateId") + .Where("r.IsHistory = 0"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains(";WITH", sql); + Assert.Contains("SearchResults AS (", sql); + Assert.Contains("DISTINCT TOP 100", sql); + } + + [Fact] + public void GivenNestedCtes_WhenBuilt_ThenIndentationPreserved() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.BeginCte("OuterCte", isFirstCte: true) + .Select("*") + .From("dbo.Resource") + .EndCte(); + + builder.AppendLine(); + + builder.BeginCte("InnerCte") + .Select("ResourceId", "ResourceTypeId") + .From("OuterCte") + .Where("ResourceTypeId IN (1, 2, 3)") + .EndCte(); + + builder.AppendLine(); + + builder.Select("COUNT(*) AS Total") + .From("InnerCte"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains("OuterCte AS (", sql); + Assert.Contains("InnerCte AS (", sql); + + // Check indentation is consistent + var lines = sql.Split('\n'); + var hasProperIndentation = true; + foreach (var line in lines) + { + _output.WriteLine($"Line: '{line.TrimEnd()}'"); + } + + Assert.True(hasProperIndentation); + } + + [Fact] + public void GivenManualIndentation_WhenUsed_ThenProperlyApplied() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.AppendLine("SELECT"); + builder.IncreaseIndent(); + builder.AppendLine("ResourceId,"); + builder.AppendLine("ResourceTypeId,"); + builder.AppendLine("Version"); + builder.DecreaseIndent(); + builder.AppendLine("FROM dbo.Resource"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + var lines = sql.Split('\n'); + + // First line should have no indent + Assert.StartsWith("SELECT", lines[0]); + + // Column lines should be indented + Assert.StartsWith(" ", lines[1]); // ResourceId with 2-space indent + } + + [Fact] + public void GivenQueryWithGroupBy_WhenBuilt_ThenProperlyFormatted() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act + builder.Select("ResourceTypeId", "COUNT(*) AS ResourceCount") + .From("dbo.Resource", "r") + .Where("r.IsDeleted = 0") + .GroupBy("ResourceTypeId") + .Having("COUNT(*) > 10") + .OrderBy("ResourceCount DESC"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + Assert.Contains("GROUP BY ResourceTypeId", sql); + Assert.Contains("HAVING COUNT(*) > 10", sql); + } + + [Fact] + public void GivenRealWorldSearchQuery_WhenBuilt_ThenMatchesExpectedFormat() + { + // Arrange + var builder = new SqlQueryBuilder(); + + // Act - Build a realistic FHIR search query + builder.BeginCte("cte0", isFirstCte: true) + .SelectWithModifier("DISTINCT", + "r.ResourceTypeId", + "r.ResourceSurrogateId", + "1 AS IsMatch", + "0 AS IsPartial", + "row_number() OVER (ORDER BY r.ResourceTypeId ASC, r.ResourceSurrogateId ASC) AS Row") + .From("dbo.Resource", "r") + .InnerJoin("dbo.TokenSearchParam", "t", "t.ResourceSurrogateId = r.ResourceSurrogateId") + .Where("r.ResourceTypeId = 1") + .And("r.IsDeleted = 0") + .And("r.IsHistory = 0") + .And("t.SearchParamId = 5") + .And("t.Code = 'active'") + .EndCte(); + + builder.AppendLine(); + + builder.BeginCte("cte1") + .AppendLine("SELECT TOP 11 * FROM cte0") + .AppendLine("ORDER BY ResourceTypeId ASC, ResourceSurrogateId ASC") + .EndCte(); + + builder.AppendLine(); + + builder.Select("r.ResourceId", "r.Version", "r.ResourceTypeId", "r.RawResource") + .From("dbo.Resource", "r") + .InnerJoin("cte1", "f", "f.ResourceSurrogateId = r.ResourceSurrogateId") + .Where("r.IsHistory = 0") + .OrderBy("r.ResourceTypeId ASC, r.ResourceSurrogateId ASC"); + + var sql = builder.ToString(); + + // Assert + _output.WriteLine(sql); + _output.WriteLine("\n--- Formatted SQL ---"); + _output.WriteLine(sql); + + Assert.Contains(";WITH", sql); + Assert.Contains("cte0 AS (", sql); + Assert.Contains("cte1 AS (", sql); + Assert.Contains("DISTINCT", sql); + Assert.Contains("row_number() OVER", sql); + } + } +} diff --git a/tools/ABTestRunner/Invoke-ABTest.ps1 b/tools/ABTestRunner/Invoke-ABTest.ps1 index f88e1d9ba0..ff1d21a14c 100644 --- a/tools/ABTestRunner/Invoke-ABTest.ps1 +++ b/tools/ABTestRunner/Invoke-ABTest.ps1 @@ -418,13 +418,14 @@ Write-Host "└───────────────────── # Build E2E tests if no DLL path provided if (-not $TestDllPath) { $testProject = Join-Path $repoRoot "test/Microsoft.Health.Fhir.$FhirVersion.Tests.E2E/Microsoft.Health.Fhir.$FhirVersion.Tests.E2E.csproj" - $testOutputDir = Join-Path $outputDir "testbin" Write-Host "`n► Building E2E test project..." - dotnet build $testProject -c Release -o $testOutputDir --nologo -v q + dotnet build $testProject -c Release --nologo -v q if ($LASTEXITCODE -ne 0) { throw "Failed to build E2E test project" } - $TestDllPath = Join-Path $testOutputDir "Microsoft.Health.Fhir.$FhirVersion.Tests.E2E.dll" + # Find the DLL in the standard bin output (avoids -o flat directory lock conflicts) + $testProjectDir = Split-Path $testProject + $TestDllPath = Join-Path $testProjectDir "bin/Release" | Get-ChildItem -Recurse -Filter "Microsoft.Health.Fhir.$FhirVersion.Tests.E2E.dll" | Select-Object -First 1 -ExpandProperty FullName } if (-not (Test-Path $TestDllPath)) { @@ -519,8 +520,16 @@ $testJob = { $baselineTrxDir = Join-Path $outputDir "baseline-results" $branchTrxDir = Join-Path $outputDir "branch-results" -$baselineJob = Start-Job -ScriptBlock $testJob -ArgumentList $TestDllPath, $baselineUrl, "baseline", $baselineTrxDir, $baselineTrx, $testFilter, $FhirVersion, $Iterations -$branchJob = Start-Job -ScriptBlock $testJob -ArgumentList $TestDllPath, $branchUrl, "branch", $branchTrxDir, $branchTrx, $testFilter, $FhirVersion, $Iterations +# Each parallel job needs its own copy of the test binaries to avoid file locks +$baselineTestBin = Join-Path $outputDir "testbin-baseline" +$branchTestBin = Join-Path $outputDir "testbin-branch" +Copy-Item -Path (Split-Path $TestDllPath) -Destination $baselineTestBin -Recurse -Force +Copy-Item -Path (Split-Path $TestDllPath) -Destination $branchTestBin -Recurse -Force +$baselineDll = Join-Path $baselineTestBin (Split-Path $TestDllPath -Leaf) +$branchDll = Join-Path $branchTestBin (Split-Path $TestDllPath -Leaf) + +$baselineJob = Start-Job -ScriptBlock $testJob -ArgumentList $baselineDll, $baselineUrl, "baseline", $baselineTrxDir, $baselineTrx, $testFilter, $FhirVersion, $Iterations +$branchJob = Start-Job -ScriptBlock $testJob -ArgumentList $branchDll, $branchUrl, "branch", $branchTrxDir, $branchTrx, $testFilter, $FhirVersion, $Iterations # Monitor both jobs with progress updates $startTime = Get-Date diff --git a/tools/SqlSearchDebugger/Mocks/FakeCompartmentDefinitionManager.cs b/tools/SqlSearchDebugger/Mocks/FakeCompartmentDefinitionManager.cs new file mode 100644 index 0000000000..af43a2b782 --- /dev/null +++ b/tools/SqlSearchDebugger/Mocks/FakeCompartmentDefinitionManager.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.ValueSets; + +namespace SqlSearchDebugger.Mocks; + +/// +/// Fake compartment definition manager for debugging. Provides basic Patient compartment definitions. +/// +public class FakeCompartmentDefinitionManager : ICompartmentDefinitionManager +{ + private static readonly Dictionary> _compartmentResourceTypes = new() + { + { + CompartmentType.Patient, new HashSet + { + "Observation", "Encounter", "Condition", "Procedure", "MedicationRequest", + "DiagnosticReport", "AllergyIntolerance", "CarePlan", "Immunization", + "DocumentReference", "Claim", "Coverage", "Device", + } + }, + { + CompartmentType.Practitioner, new HashSet + { + "Observation", "Encounter", "Procedure", "DiagnosticReport", "Appointment", + } + }, + }; + + private static readonly Dictionary<(string ResourceType, CompartmentType), HashSet> _compartmentSearchParams = new() + { + { ("Observation", CompartmentType.Patient), new HashSet { "subject", "performer" } }, + { ("Encounter", CompartmentType.Patient), new HashSet { "patient" } }, + { ("Condition", CompartmentType.Patient), new HashSet { "patient", "asserter" } }, + { ("Procedure", CompartmentType.Patient), new HashSet { "patient", "performer" } }, + { ("MedicationRequest", CompartmentType.Patient), new HashSet { "subject" } }, + { ("DiagnosticReport", CompartmentType.Patient), new HashSet { "subject" } }, + { ("AllergyIntolerance", CompartmentType.Patient), new HashSet { "patient" } }, + { ("CarePlan", CompartmentType.Patient), new HashSet { "patient" } }, + { ("Immunization", CompartmentType.Patient), new HashSet { "patient" } }, + { ("DocumentReference", CompartmentType.Patient), new HashSet { "subject" } }, + { ("Claim", CompartmentType.Patient), new HashSet { "patient" } }, + { ("Coverage", CompartmentType.Patient), new HashSet { "patient", "beneficiary" } }, + { ("Device", CompartmentType.Patient), new HashSet { "patient" } }, + { ("Observation", CompartmentType.Practitioner), new HashSet { "performer" } }, + { ("Encounter", CompartmentType.Practitioner), new HashSet { "practitioner" } }, + { ("Procedure", CompartmentType.Practitioner), new HashSet { "performer" } }, + { ("DiagnosticReport", CompartmentType.Practitioner), new HashSet { "performer" } }, + { ("Appointment", CompartmentType.Practitioner), new HashSet { "actor" } }, + }; + + public bool TryGetResourceTypes(CompartmentType compartmentType, out HashSet resourceTypes) + { + return _compartmentResourceTypes.TryGetValue(compartmentType, out resourceTypes!); + } + + public bool TryGetSearchParams(string resourceType, CompartmentType compartmentType, out HashSet searchParams) + { + return _compartmentSearchParams.TryGetValue((resourceType, compartmentType), out searchParams!); + } +} diff --git a/tools/SqlSearchDebugger/Mocks/FakeMediator.cs b/tools/SqlSearchDebugger/Mocks/FakeMediator.cs new file mode 100644 index 0000000000..166b9b0a6a --- /dev/null +++ b/tools/SqlSearchDebugger/Mocks/FakeMediator.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using MediatR; + +namespace SqlSearchDebugger.Mocks; + +class FakeMediator : IMediator +{ + public Task Send(IRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(default(TResponse)!); + + public Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest + => Task.CompletedTask; + + public Task Send(object request, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task Publish(object notification, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification + => Task.CompletedTask; + + public IAsyncEnumerable CreateStream(IStreamRequest request, CancellationToken cancellationToken = default) + => EmptyAsyncEnumerable(); + + public IAsyncEnumerable CreateStream(object request, CancellationToken cancellationToken = default) + => EmptyAsyncEnumerable(); + + private static async IAsyncEnumerable EmptyAsyncEnumerable() + { + await Task.CompletedTask; + yield break; + } +} diff --git a/tools/SqlSearchDebugger/Mocks/FakeServiceProviders.cs b/tools/SqlSearchDebugger/Mocks/FakeServiceProviders.cs new file mode 100644 index 0000000000..dfc03759fe --- /dev/null +++ b/tools/SqlSearchDebugger/Mocks/FakeServiceProviders.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Extensions.DependencyInjection; +using Microsoft.Health.Fhir.Core.Features.Operations; +using Microsoft.Health.Fhir.Core.Features.Search.Parameters; +using Microsoft.Health.Fhir.Core.Models; + +namespace SqlSearchDebugger.Mocks; + +class FakeScopeProvider : IScopeProvider where T : class +{ + private readonly T _instance; + + public FakeScopeProvider(T instance) => _instance = instance; + + public IScoped Invoke() => new FakeScoped(_instance); +} + +class FakeScoped : IScoped where T : class +{ + public FakeScoped(T value) => Value = value; + + public T Value { get; } + + public void Dispose() { } +} + +class FakeSearchParameterComparer : ISearchParameterComparer +{ + public int Compare(SearchParameterInfo? x, SearchParameterInfo? y) + { + if (x == null && y == null) return 0; + if (x == null) return -1; + if (y == null) return 1; + return string.Compare(x.Url?.ToString(), y.Url?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + public int CompareBase(IEnumerable x, IEnumerable y) => 0; + + public int CompareComponent(IEnumerable<(string definition, string expression)> x, IEnumerable<(string definition, string expression)> y) => 0; + + public int CompareExpression(string x, string y, bool isQuantity) => string.Compare(x, y, StringComparison.OrdinalIgnoreCase); +} diff --git a/tools/SqlSearchDebugger/Mocks/FakeSqlServerFhirModel.cs b/tools/SqlSearchDebugger/Mocks/FakeSqlServerFhirModel.cs new file mode 100644 index 0000000000..ee739263e4 --- /dev/null +++ b/tools/SqlSearchDebugger/Mocks/FakeSqlServerFhirModel.cs @@ -0,0 +1,138 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.SqlServer.Features.Storage; + +namespace SqlSearchDebugger.Mocks; + +class FakeSqlServerFhirModel : ISqlServerFhirModel +{ + private readonly Dictionary _resourceTypeNameToId = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _resourceTypeIdToName = new(); + private readonly Dictionary _searchParamUriToId = new(); + private readonly Dictionary _systemToId = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _quantityCodeToId = new(StringComparer.OrdinalIgnoreCase); + private short _nextResourceTypeId = 1; + private short _nextSearchParamId = 1; + private int _nextSystemId = 1; + private int _nextQuantityCodeId = 1; + + public int ResourceTypeCount => _resourceTypeNameToId.Count; + public int SearchParamCount => _searchParamUriToId.Count; + + public (short lowestId, short highestId) ResourceTypeIdRange => + _resourceTypeNameToId.Count > 0 + ? ((short)1, (short)(_nextResourceTypeId - 1)) + : ((short)0, (short)0); + + public short GetResourceTypeId(string resourceTypeName) + { + if (_resourceTypeNameToId.TryGetValue(resourceTypeName, out var id)) + { + return id; + } + + id = _nextResourceTypeId++; + _resourceTypeNameToId[resourceTypeName] = id; + _resourceTypeIdToName[id] = resourceTypeName; + return id; + } + + public bool TryGetResourceTypeId(string resourceTypeName, out short id) + { + if (_resourceTypeNameToId.TryGetValue(resourceTypeName, out id)) + { + return true; + } + + id = GetResourceTypeId(resourceTypeName); + return true; + } + + public string GetResourceTypeName(short resourceTypeId) + { + if (_resourceTypeIdToName.TryGetValue(resourceTypeId, out var name)) + { + return name; + } + + return $"UnknownType_{resourceTypeId}"; + } + + public byte GetClaimTypeId(string claimTypeName) => 1; + + public short GetSearchParamId(Uri searchParamUri) + { + if (searchParamUri == null) + { + return 0; + } + + var key = searchParamUri.OriginalString; + if (_searchParamUriToId.TryGetValue(key, out var id)) + { + return id; + } + + id = _nextSearchParamId++; + _searchParamUriToId[key] = id; + return id; + } + + public void TryAddSearchParamIdToUriMapping(string searchParamUri, short searchParamId) + { + _searchParamUriToId[searchParamUri] = searchParamId; + } + + public void RemoveSearchParamIdToUriMapping(string searchParamUri) + { + _searchParamUriToId.Remove(searchParamUri); + } + + public byte GetCompartmentTypeId(string compartmentType) => 1; + + public bool TryGetSystemId(string system, out int systemId) + { + if (_systemToId.TryGetValue(system, out systemId)) + { + return true; + } + + systemId = _nextSystemId++; + _systemToId[system] = systemId; + return true; + } + + public int GetSystemId(string system) + { + TryGetSystemId(system, out var id); + return id; + } + + public int GetQuantityCodeId(string code) + { + TryGetQuantityCodeId(code, out var id); + return id; + } + + public bool TryGetQuantityCodeId(string code, out int quantityCodeId) + { + if (_quantityCodeToId.TryGetValue(code, out quantityCodeId)) + { + return true; + } + + quantityCodeId = _nextQuantityCodeId++; + _quantityCodeToId[code] = quantityCodeId; + return true; + } + + public List GetAllResourceTypes() + { + return _resourceTypeNameToId.Select(kvp => (object)new { name = kvp.Key, id = kvp.Value }) + .OrderBy(x => ((dynamic)x).id) + .ToList(); + } +} diff --git a/tools/SqlSearchDebugger/ParserHelpers.cs b/tools/SqlSearchDebugger/ParserHelpers.cs new file mode 100644 index 0000000000..179496bdf0 --- /dev/null +++ b/tools/SqlSearchDebugger/ParserHelpers.cs @@ -0,0 +1,169 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.Core.Features.Definition; +using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Search.Registry; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.SqlServer.Features.Search; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using SqlSearchDebugger.Mocks; + +namespace SqlSearchDebugger; + +static class ParserHelpers +{ + public static SearchParameterDefinitionManager InitializeSearchParameterDefinitionManager(IModelInfoProvider modelInfoProvider) + { + var mediator = new FakeMediator(); + var scopeSearchService = new FakeScopeProvider(null!); + var scopeStatusStore = new FakeScopeProvider(null!); + var scopeDataStore = new FakeScopeProvider(null!); + var comparer = new FakeSearchParameterComparer(); + var logger = LoggerFactory.Create(b => b.AddConsole()).CreateLogger(); + + var manager = new SearchParameterDefinitionManager( + modelInfoProvider, + mediator, + scopeSearchService, + comparer, + scopeStatusStore, + scopeDataStore, + logger); + + return manager; + } + + public static object ParseFhirUrl(string url, string? continuationToken, SearchParameterSqlParser parser, FakeSqlServerFhirModel fhirModel) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("URL cannot be empty"); + } + + // Strip leading slash + url = url.TrimStart('/'); + + string resourceType; + string queryString = string.Empty; + + var questionIdx = url.IndexOf('?'); + if (questionIdx >= 0) + { + resourceType = url[..questionIdx]; + queryString = url[(questionIdx + 1)..]; + } + else + { + resourceType = url; + } + + // Handle paths like "Patient/$includes" + var slashIdx = resourceType.IndexOf('/'); + if (slashIdx >= 0) + { + resourceType = resourceType[..slashIdx]; + } + + short resourceTypeId = fhirModel.GetResourceTypeId(resourceType); + + // Parse query string into parameters + var parameters = new Dictionary>(); + parameters["_type"] = new List { resourceType }; + + if (!string.IsNullOrEmpty(queryString)) + { + foreach (var param in queryString.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var eqIdx = param.IndexOf('='); + string key, value; + if (eqIdx >= 0) + { + key = Uri.UnescapeDataString(param[..eqIdx]); + value = Uri.UnescapeDataString(param[(eqIdx + 1)..]); + } + else + { + key = Uri.UnescapeDataString(param); + value = string.Empty; + } + + if (parameters.TryGetValue(key, out var existing)) + { + existing.Add(value); + } + else + { + parameters[key] = new List { value }; + } + } + } + + // Extract _count for MaxItemCount + int maxItemCount = 10; + if (parameters.TryGetValue("_count", out var countValues) && countValues.Count > 0) + { + if (int.TryParse(countValues[0], out var count) && count > 0) + { + maxItemCount = count; + } + } + + // Extract _sort for SearchOptions.Sort + var sortList = new List<(SearchParameterInfo searchParameterInfo, SortOrder sortOrder)>(); + if (parameters.TryGetValue("_sort", out var sortValues) && sortValues.Count > 0) + { + var sortValue = sortValues[0]; + var sortDescending = sortValue.StartsWith('-'); + var sortParamName = sortDescending ? sortValue[1..] : sortValue; + var sortOrder = sortDescending ? SortOrder.Descending : SortOrder.Ascending; + sortList.Add((new SearchParameterInfo(sortParamName, sortParamName), sortOrder)); + } + + // Build SqlSearchOptions using internal constructors (via InternalsVisibleTo) + var searchOptions = new SearchOptions(); + searchOptions.MaxItemCount = maxItemCount; + searchOptions.IncludeCount = 1000; + searchOptions.Sort = sortList; + searchOptions.QueryParams = parameters; + searchOptions.SearchParameters = new List(); + searchOptions.UnsupportedSearchParams = new List>(); + + var sqlSearchOptions = new SqlSearchOptions(searchOptions); + + // Parse continuation token if provided + ContinuationToken? ct = null; + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + ct = ContinuationToken.FromString(continuationToken); + } + + // Generate SQL + var sql = parser.ParseMultiple(parameters, sqlSearchOptions, ct); + + return new + { + resourceType, + resourceTypeId, + queryParameters = parameters, + continuationTokenParsed = ct?.ToString(), + generatedSql = sql, + formattedSql = FormatSql(sql), + }; + } + + public static string? FormatSql(string? sql) + { + if (string.IsNullOrWhiteSpace(sql)) + { + return null; + } + + return sql; + } +} + +record ParseRequest(string Url, string? ContinuationToken); diff --git a/tools/SqlSearchDebugger/Program.cs b/tools/SqlSearchDebugger/Program.cs new file mode 100644 index 0000000000..3aaf2ef9fb --- /dev/null +++ b/tools/SqlSearchDebugger/Program.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.Core; +using Microsoft.Health.Fhir.Core.Models; +using Microsoft.Health.Fhir.SqlServer.Features.Search.SqlSearchParser; +using SqlSearchDebugger; +using SqlSearchDebugger.Mocks; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +// Initialize the FHIR model and parser +var modelInfoProvider = new VersionSpecificModelInfoProvider(); +ModelInfoProvider.SetProvider(modelInfoProvider); + +var fhirModel = new FakeSqlServerFhirModel(); +var searchParamDefManager = ParserHelpers.InitializeSearchParameterDefinitionManager(modelInfoProvider); +var sqlSearchParamDefManager = new SqlSearchParameterDefinitionManager(searchParamDefManager, fhirModel); +var compartmentDefManager = new FakeCompartmentDefinitionManager(); +var logger = LoggerFactory.Create(b => b.AddConsole()).CreateLogger(); +var parser = new SearchParameterSqlParser(sqlSearchParamDefManager, fhirModel, compartmentDefManager, logger); + +Console.WriteLine("SQL Search Debugger initialized with {0} resource types and {1} search parameters", + fhirModel.ResourceTypeCount, fhirModel.SearchParamCount); +Console.WriteLine("Open http://localhost:5200 in your browser"); + +// Serve static files from wwwroot +app.UseStaticFiles(); + +// Serve index.html at root +app.MapGet("/", () => Results.File( + Path.Combine(app.Environment.WebRootPath, "index.html"), "text/html")); + +// API endpoint to parse a FHIR URL into SQL +app.MapPost("/api/parse", (ParseRequest request) => +{ + try + { + var result = ParserHelpers.ParseFhirUrl(request.Url, request.ContinuationToken, parser, fhirModel); + return Results.Json(result); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 200); + } +}); + +// API endpoint to list known resource types +app.MapGet("/api/resource-types", () => Results.Json(fhirModel.GetAllResourceTypes())); + +// API endpoint to list search params for a resource type +app.MapGet("/api/search-params/{resourceType}", (string resourceType) => +{ + try + { + var typeId = fhirModel.GetResourceTypeId(resourceType); + var parameters = sqlSearchParamDefManager.GetByResourceType(typeId); + return Results.Json(parameters.Select(p => new + { + code = p.SearchParameterInfo.Code, + type = p.SearchParameterInfo.Type.ToString(), + url = p.SearchParameterInfo.Url?.ToString(), + id = p.Id, + description = p.SearchParameterInfo.Description, + targets = p.SearchParameterInfo.TargetResourceTypes?.Select(t => t.ToString()), + })); + } + catch (Exception ex) + { + return Results.Json(new { error = ex.Message }, statusCode: 400); + } +}); + +app.Run("http://localhost:5200"); diff --git a/tools/SqlSearchDebugger/SqlSearchDebugger.csproj b/tools/SqlSearchDebugger/SqlSearchDebugger.csproj new file mode 100644 index 0000000000..e6d6624b01 --- /dev/null +++ b/tools/SqlSearchDebugger/SqlSearchDebugger.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + SqlSearchDebugger + R4 + false + false + + + + + + + + diff --git a/tools/SqlSearchDebugger/wwwroot/app.js b/tools/SqlSearchDebugger/wwwroot/app.js new file mode 100644 index 0000000000..4fd6508c6f --- /dev/null +++ b/tools/SqlSearchDebugger/wwwroot/app.js @@ -0,0 +1,92 @@ +function setExample(url) { + document.getElementById('fhir-url').value = url; + document.getElementById('continuation-token').value = ''; + parseUrl(); +} + +async function parseUrl() { + const url = document.getElementById('fhir-url').value.trim(); + const ct = document.getElementById('continuation-token').value.trim(); + if (!url) return; + + try { + const resp = await fetch('/api/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, continuationToken: ct || null }) + }); + const data = await resp.json(); + + if (data.error) { + document.getElementById('sql-output').innerHTML = `
Error:\n${escapeHtml(data.error)}\n\n${escapeHtml(data.stackTrace || '')}
`; + document.getElementById('params-output').innerHTML = ''; + document.getElementById('info-output').innerHTML = ''; + } else { + renderParams(data.queryParameters); + renderInfo(data); + renderSql(data.formattedSql || data.generatedSql); + } + } catch (e) { + document.getElementById('sql-output').innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +function renderParams(params) { + if (!params) { document.getElementById('params-output').innerHTML = '
No parameters
'; return; } + let html = ''; + for (const [key, values] of Object.entries(params)) { + const badges = values.map(v => `${escapeHtml(v)}`).join(' '); + html += ``; + } + html += '
ParameterValue(s)
${escapeHtml(key)}${badges}
'; + document.getElementById('params-output').innerHTML = html; +} + +function renderInfo(data) { + let html = ''; + html += ``; + if (data.continuationTokenParsed) { + html += ``; + } + html += '
Resource Type${escapeHtml(data.resourceType)} TypeId: ${data.resourceTypeId}
Continuation Token${escapeHtml(data.continuationTokenParsed)}
'; + document.getElementById('info-output').innerHTML = html; +} + +function renderSql(sql) { + if (!sql) { + document.getElementById('sql-output').innerHTML = '
(no SQL generated)
'; + return; + } + document.getElementById('sql-output').innerHTML = `
${highlightSql(escapeHtml(sql))}
`; +} + +function highlightSql(sql) { + const keywords = ['WITH', 'AS', 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'INNER JOIN', 'LEFT JOIN', + 'ON', 'ORDER BY', 'GROUP BY', 'HAVING', 'UNION ALL', 'UNION', 'TOP', 'DISTINCT', + 'EXISTS', 'NOT EXISTS', 'IN', 'NOT IN', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', + 'ASC', 'DESC', 'IS NULL', 'IS NOT NULL', 'LIKE', 'BETWEEN', 'OPTION', 'INTERSECT']; + const functions = ['ROW_NUMBER', 'OVER', 'count_big', 'COALESCE', 'CAST', 'CONVERT', 'ISNULL']; + + let result = sql; + result = result.replace(/'([^&#]*(?:&#[^x][^;]*;[^&#]*)*)'/g, '\'$1\''); + result = result.replace(/\b(\d+)\b/g, '$1'); + for (const fn of functions) { + result = result.replace(new RegExp(`\\b(${fn})\\b`, 'gi'), '$1'); + } + for (const kw of keywords) { + result = result.replace(new RegExp(`\\b(${kw.replace(' ', '\\s+')})\\b`, 'gi'), '$1'); + } + result = result.replace(/\b(dbo\.\w+)\b/g, '$1'); + result = result.replace(/\b(cte\d+\w*)\b/g, '$1'); + return result; +} + +function escapeHtml(text) { + if (!text) return ''; + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('fhir-url').addEventListener('keypress', e => { if (e.key === 'Enter') parseUrl(); }); + document.getElementById('continuation-token').addEventListener('keypress', e => { if (e.key === 'Enter') parseUrl(); }); +}); diff --git a/tools/SqlSearchDebugger/wwwroot/index.html b/tools/SqlSearchDebugger/wwwroot/index.html new file mode 100644 index 0000000000..c5e365a3e5 --- /dev/null +++ b/tools/SqlSearchDebugger/wwwroot/index.html @@ -0,0 +1,56 @@ + + + + + + SQL Search Parser Debugger + + + +
+

🔍 SQL Search Parser Debugger

+ +
+
+ + + +
+
+ + +
+ +
+ +
+
+

📋 Parsed Parameters

+
Enter a FHIR URL and click Parse
+
+
+

ℹ️ Query Info

+
Resource type and ID mappings will appear here
+
+
+

🗃️ Generated SQL

+
SQL output will appear here
+
+
+
+ + + + diff --git a/tools/SqlSearchDebugger/wwwroot/style.css b/tools/SqlSearchDebugger/wwwroot/style.css new file mode 100644 index 0000000000..ef090d83a8 --- /dev/null +++ b/tools/SqlSearchDebugger/wwwroot/style.css @@ -0,0 +1,34 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: 'Segoe UI', system-ui, sans-serif; background: #1e1e2e; color: #cdd6f4; min-height: 100vh; } +.container { max-width: 1400px; margin: 0 auto; padding: 20px; } +h1 { color: #89b4fa; margin-bottom: 20px; font-size: 1.8em; } +h2 { color: #a6e3a1; margin: 15px 0 10px; font-size: 1.2em; } +.input-section { background: #313244; border-radius: 8px; padding: 20px; margin-bottom: 20px; } +.input-group { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; } +.input-group label { min-width: 140px; color: #bac2de; } +input[type="text"] { flex: 1; padding: 10px 14px; border: 1px solid #45475a; border-radius: 6px; background: #1e1e2e; color: #cdd6f4; font-size: 14px; font-family: 'Cascadia Code', 'Fira Code', monospace; } +input[type="text"]:focus { outline: none; border-color: #89b4fa; } +button { padding: 10px 24px; border: none; border-radius: 6px; background: #89b4fa; color: #1e1e2e; font-weight: 600; cursor: pointer; font-size: 14px; } +button:hover { background: #b4d0fb; } +button:active { transform: scale(0.98); } +.output-section { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.panel { background: #313244; border-radius: 8px; padding: 20px; overflow: auto; } +.panel.full-width { grid-column: 1 / -1; } +pre { background: #1e1e2e; padding: 15px; border-radius: 6px; overflow-x: auto; font-size: 13px; line-height: 1.5; font-family: 'Cascadia Code', 'Fira Code', monospace; white-space: pre-wrap; word-break: break-word; } +.sql-keyword { color: #cba6f7; font-weight: bold; } +.sql-function { color: #f9e2af; } +.sql-string { color: #a6e3a1; } +.sql-number { color: #fab387; } +.sql-table { color: #89dceb; } +.sql-column { color: #f5c2e7; } +.error { background: #45273a; border: 1px solid #f38ba8; color: #f38ba8; padding: 15px; border-radius: 6px; white-space: pre-wrap; font-family: monospace; font-size: 13px; } +.params-table { width: 100%; border-collapse: collapse; } +.params-table th, .params-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #45475a; } +.params-table th { color: #89b4fa; background: #1e1e2e; } +.params-table td { font-family: 'Cascadia Code', monospace; font-size: 13px; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; background: #45475a; color: #cdd6f4; margin: 2px; } +.badge.type { background: #1e3a5f; color: #89b4fa; } +.badge.id { background: #2d3a1e; color: #a6e3a1; } +.examples { margin-top: 10px; } +.examples a { color: #89b4fa; text-decoration: none; margin-right: 15px; font-size: 13px; cursor: pointer; } +.examples a:hover { text-decoration: underline; }