diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Search/Expressions/UnionExpressionTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Search/Expressions/UnionExpressionTests.cs index deaacc0018..7bf59cde57 100644 --- a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Search/Expressions/UnionExpressionTests.cs +++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Search/Expressions/UnionExpressionTests.cs @@ -49,5 +49,20 @@ public void GivenUnionExpression_WhenInitializedImproperly_ThrownAnInvalidOperat UnionExpression unionExpressionFail2 = new UnionExpression(UnionOperator.All, new Expression[] { multiaryExpression1 }); }); } + + [Fact] + public void GivenUnionExpression_WhenCreated_ThenNoSplitMarkerDefaultsToFalseAndCanBeEnabled() + { + StringExpression expression1 = new StringExpression(StringOperator.Equals, FieldName.String, componentIndex: 0, value: "rush", ignoreCase: true); + StringExpression expression2 = new StringExpression(StringOperator.Equals, FieldName.String, componentIndex: 0, value: "2112", ignoreCase: true); + + UnionExpression unionExpression = new UnionExpression(UnionOperator.All, new Expression[] { expression1, expression2 }); + + Assert.False(unionExpression.DoNotSplitIntoSeparateCtes); + + unionExpression.DoNotSplitIntoSeparateCtes = true; + + Assert.True(unionExpression.DoNotSplitIntoSeparateCtes); + } } } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/ExpressionRewriter.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/ExpressionRewriter.cs index 131614918a..88445789bc 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/ExpressionRewriter.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/ExpressionRewriter.cs @@ -80,7 +80,16 @@ public virtual Expression VisitMultiary(MultiaryExpression expression, TContext public virtual Expression VisitUnion(UnionExpression expression, TContext context) { IReadOnlyList rewrittenExpressions = VisitArray(expression.Expressions, context); - return ReferenceEquals(rewrittenExpressions, expression.Expressions) ? expression : new UnionExpression(expression.Operator, rewrittenExpressions); + + if (ReferenceEquals(rewrittenExpressions, expression.Expressions)) + { + return expression; + } + + return new UnionExpression(expression.Operator, rewrittenExpressions) + { + DoNotSplitIntoSeparateCtes = expression.DoNotSplitIntoSeparateCtes, + }; } public virtual Expression VisitString(StringExpression expression, TContext context) diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/UnionExpression.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/UnionExpression.cs index 1e0450212b..cb993bfd23 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/UnionExpression.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/Expressions/UnionExpression.cs @@ -31,6 +31,11 @@ public UnionExpression(UnionOperator unionOperator, IReadOnlyList ex public IReadOnlyList Expressions { get; } + /// + /// Gets or sets a value indicating whether this union must remain in-place and not be split into separate SQL CTE branches. + /// + public bool DoNotSplitIntoSeparateCtes { get; set; } + public override TOutput AcceptVisitor(IExpressionVisitor visitor, TContext context) { EnsureArg.IsNotNull(visitor, nameof(visitor)); 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 index 31d8e9eeb6..7f13428a21 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ScalarTemporalEqualityRewriterTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/ScalarTemporalEqualityRewriterTests.cs @@ -164,6 +164,7 @@ private static void AssertDaySplitUnion(Expression result, DateTimeOffset expect { var union = Assert.IsType(result); Assert.Equal(UnionOperator.All, union.Operator); + Assert.True(union.DoNotSplitIntoSeparateCtes); Assert.Collection( union.Expressions, shortBranch => AssertSearchParameterAnd( diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SearchParamTableExpressionExtensionsTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SearchParamTableExpressionExtensionsTests.cs new file mode 100644 index 0000000000..27f55753f6 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/Expressions/SearchParamTableExpressionExtensionsTests.cs @@ -0,0 +1,206 @@ +// ------------------------------------------------------------------------------------------------- +// 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 +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class SearchParamTableExpressionExtensionsTests + { + private static readonly SearchParameterInfo BirthdateParam = new SearchParameterInfo( + "birthdate", + "birthdate", + SearchParamType.Date, + new Uri("http://hl7.org/fhir/SearchParameter/individual-birthdate"), + expression: "Patient.birthDate", + baseResourceTypes: new[] { "Patient" }); + + private static readonly SearchParameterInfo NameParam = new SearchParameterInfo( + "name", + "name", + SearchParamType.String, + new Uri("http://hl7.org/fhir/SearchParameter/individual-name"), + expression: "Patient.name", + baseResourceTypes: new[] { "Patient" }); + + public static TheoryData UnionDetectionCases => new() + { + { BuildBareUnion(), true }, + { Expression.And(BuildBareUnion()), true }, + { Expression.And(BuildSmartV2Union(), new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, "Smith"))), true }, + { new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, "Smith")), false }, + }; + + private static UnionExpression BuildBareUnion() + { + var endOfDay = new DateTimeOffset(2016, 7, 6, 23, 59, 59, TimeSpan.Zero); + var startOfDay = new DateTimeOffset(2016, 7, 6, 0, 0, 0, TimeSpan.Zero); + var shortBranch = new SearchParameterExpression(BirthdateParam, Expression.Equals(FieldName.DateTimeEnd, null, endOfDay)); + var longBranch = new SearchParameterExpression(BirthdateParam, Expression.GreaterThanOrEqual(FieldName.DateTimeStart, null, startOfDay)); + return Expression.Union(UnionOperator.All, new Expression[] { shortBranch, longBranch }); + } + + private static UnionExpression BuildSmartV2Union() + { + var branch = new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, "Smith")); + return Expression.Union(UnionOperator.All, new Expression[] { branch }); + } + + [Theory] + [MemberData(nameof(UnionDetectionCases))] + public void HasUnionAllExpression_DetectsBareAndNestedUnions(Expression predicate, bool expected) + { + var tableExpr = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + predicate, + SearchParamTableExpressionKind.Normal); + + Assert.Equal(expected, tableExpr.HasUnionAllExpression()); + } + + [Fact] + public void SplitExpressions_WhenPredicateContainsSingleUnionChild_ReturnsTrueWithUnionAndNullRemainder() + { + var union = BuildBareUnion(); + var tableExpr = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + Expression.And(union), + SearchParamTableExpressionKind.Normal); + + bool result = tableExpr.SplitExpressions(out UnionExpression outUnion, out SearchParamTableExpression outRemainder); + + Assert.True(result); + Assert.Same(union, outUnion); + Assert.Null(outRemainder); + } + + [Fact] + public void SplitExpressions_WhenPredicateIsBareUnion_ReturnsTrueWithUnionAndNullRemainder() + { + var union = BuildBareUnion(); + var tableExpr = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + union, + SearchParamTableExpressionKind.Normal); + + bool result = tableExpr.SplitExpressions(out UnionExpression outUnion, out SearchParamTableExpression outRemainder); + + Assert.True(result); + Assert.Same(union, outUnion); + Assert.Null(outRemainder); + } + + [Fact] + public void SplitExpressions_WhenUnionIsMarkedAsNoSplit_StillSeparatesUnion() + { + // DoNotSplitIntoSeparateCtes controls only how the union is lowered to SQL (a single inline UNION ALL CTE). + // It no longer prevents the union from being separated from its siblings, so the union is still returned. + var union = BuildBareUnion(); + union.DoNotSplitIntoSeparateCtes = true; + + var tableExpr = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + Expression.And(union), + SearchParamTableExpressionKind.Normal); + + bool result = tableExpr.SplitExpressions(out UnionExpression outUnion, out SearchParamTableExpression outRemainder); + + Assert.True(result); + Assert.Same(union, outUnion); + Assert.True(outUnion.DoNotSplitIntoSeparateCtes); + Assert.Null(outRemainder); + } + + [Fact] + public void SplitExpressions_WhenUnionNestedInMultiaryWithSiblings_ReturnsTrueWithUnionAndRemainder() + { + var union = BuildSmartV2Union(); + var otherExpr = new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, "Smith")); + var multiary = Expression.And(union, otherExpr); + + var tableExpr = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + multiary, + SearchParamTableExpressionKind.Normal); + + bool result = tableExpr.SplitExpressions(out UnionExpression outUnion, out SearchParamTableExpression outRemainder); + + Assert.True(result); + Assert.Same(union, outUnion); + Assert.NotNull(outRemainder); + var remainderAnd = Assert.IsType(outRemainder.Predicate); + Assert.DoesNotContain(remainderAnd.Expressions, e => e is UnionExpression); + } + + [Fact] + public void SplitExpressions_WhenPredicateIsPlainSearchParameter_ReturnsFalse() + { + var predicate = new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, "Smith")); + var tableExpr = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + predicate, + SearchParamTableExpressionKind.Normal); + + bool result = tableExpr.SplitExpressions(out UnionExpression outUnion, out SearchParamTableExpression outRemainder); + + Assert.False(result); + Assert.Null(outUnion); + Assert.Null(outRemainder); + } + + [Fact] + public void SortExpressionsByQueryLogic_WhenRegularUnionPrecedesConcatenationPair_ThenUnionMovesFirstAndPairStaysAdjacent() + { + var leadingNormal = BuildNormalTableExpression("leading"); + var regularUnion = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + Expression.And(BuildBareUnion()), + SearchParamTableExpressionKind.Normal); + var normalSibling = BuildNormalTableExpression("sibling-normal"); + var concatenationSibling = new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, "sibling-concat")), + SearchParamTableExpressionKind.Concatenation); + var trailingNormal = BuildNormalTableExpression("trailing"); + + var input = new List + { + leadingNormal, + regularUnion, + normalSibling, + concatenationSibling, + trailingNormal, + }; + + IReadOnlyList sorted = input.SortExpressionsByQueryLogic(); + + Assert.Same(regularUnion, sorted[0]); + Assert.Same(leadingNormal, sorted[1]); + Assert.Same(normalSibling, sorted[2]); + Assert.Same(concatenationSibling, sorted[3]); + Assert.Same(trailingNormal, sorted[4]); + Assert.Equal(SearchParamTableExpressionKind.Concatenation, sorted[3].Kind); + } + + private static SearchParamTableExpression BuildNormalTableExpression(string code) + { + return new SearchParamTableExpression( + DateTimeQueryGenerator.Instance, + new SearchParameterExpression(NameParam, Expression.Equals(FieldName.TokenCode, null, code)), + SearchParamTableExpressionKind.Normal); + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs index d6ef87d017..f7ada08add 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs @@ -35,6 +35,22 @@ namespace Microsoft.Health.Fhir.SqlServer.UnitTests.Features.Search; [Trait(Traits.Category, Categories.Search)] public class SqlQueryGeneratorTests { + private static readonly SearchParameterInfo BirthdateParam = new( + "birthdate", + "birthdate", + SearchParamType.Date, + new Uri("http://hl7.org/fhir/SearchParameter/individual-birthdate"), + expression: "Patient.birthDate", + baseResourceTypes: new[] { "Patient" }); + + private static readonly SearchParameterInfo NameParam = new( + "name", + "name", + SearchParamType.String, + new Uri("http://hl7.org/fhir/SearchParameter/individual-name"), + expression: "Patient.name", + baseResourceTypes: new[] { "Patient" }); + private readonly ISqlServerFhirModel _fhirModel; private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; private readonly SchemaInformation _schemaInformation = new(SchemaVersionConstants.Min, SchemaVersionConstants.Max); @@ -213,4 +229,64 @@ public void GivenReferenceSearchParameterWithMultipleTargetTypes_WhenSqlGenerate _fhirModel.Received(1).TryGetResourceTypeId("Patient", out Arg.Any()); _fhirModel.Received(1).TryGetResourceTypeId("Practitioner", out Arg.Any()); } + + [Fact] + public void GivenNoSplitDayUnionWithSibling_WhenSqlGenerated_ThenUnionIsASingleCteWithInlineUnionAll() + { + UnionExpression union = BuildDaySplitUnion(doNotSplit: true); + + SqlRootExpression sqlExpression = BuildUnionWithSibling(union); + SearchOptions searchOptions = new() { Sort = [], ResourceVersionTypes = ResourceVersionType.Latest }; + + _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); + string sql = _strBuilder.ToString(); + + // The union is lowered to a single CTE (cte0) whose body combines both branches with inline UNION ALL, + // and the sibling (cte1) intersects it. There is no separate per-branch or aggregating CTE, so cte2 never appears. + Assert.Contains("UNION ALL", sql); + Assert.Contains("cte0", sql); + Assert.Contains("cte1", sql); + Assert.DoesNotContain("cte2", sql); + Assert.Contains("EXISTS (SELECT * FROM cte0", sql); + } + + [Fact] + public void GivenDayUnionWithoutNoSplitFlag_WhenSqlGenerated_ThenUnionFansOutIntoSeparateCtes() + { + UnionExpression union = BuildDaySplitUnion(doNotSplit: false); + + SqlRootExpression sqlExpression = BuildUnionWithSibling(union); + SearchOptions searchOptions = new() { Sort = [], ResourceVersionTypes = ResourceVersionType.Latest }; + + _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); + string sql = _strBuilder.ToString(); + + // Without the flag the union fans out into one CTE per branch (cte0, cte1) plus an aggregating CTE (cte2) + // that UNION ALLs them, followed by the sibling CTE (cte3). + Assert.Contains("SELECT * FROM cte0", sql); + Assert.Contains("cte3", sql); + } + + private static UnionExpression BuildDaySplitUnion(bool doNotSplit) + { + var endOfDay = new DateTimeOffset(2016, 7, 6, 23, 59, 59, TimeSpan.Zero); + var startOfDay = new DateTimeOffset(2016, 7, 6, 0, 0, 0, TimeSpan.Zero); + var shortBranch = new SearchParameterExpression(BirthdateParam, Expression.Equals(FieldName.DateTimeEnd, null, endOfDay)); + var longBranch = new SearchParameterExpression(BirthdateParam, Expression.GreaterThanOrEqual(FieldName.DateTimeStart, null, startOfDay)); + var union = Expression.Union(UnionOperator.All, new Expression[] { shortBranch, longBranch }); + union.DoNotSplitIntoSeparateCtes = doNotSplit; + return union; + } + + private static SqlRootExpression BuildUnionWithSibling(UnionExpression union) + { + // The runtime shape: the union is the entire predicate of its own table expression (a bare UnionExpression). + var unionTable = new SearchParamTableExpression(DateTimeQueryGenerator.Instance, union, SearchParamTableExpressionKind.Normal); + var siblingTable = new SearchParamTableExpression( + StringQueryGenerator.Instance, + new SearchParameterExpression(NameParam, Expression.StringEquals(FieldName.String, null, "Smith", false)), + SearchParamTableExpressionKind.Normal); + + return new SqlRootExpression(new[] { unionTable, siblingTable }, new List()); + } } diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs index a735c01488..416f629d33 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/SearchParamTableExpressionExtensions.cs @@ -20,6 +20,13 @@ internal static class SearchParamTableExpressionExtensions /// Instance of under evaluation. public static bool HasUnionAllExpression(this SearchParamTableExpression expression) { + // The predicate may either BE a UnionExpression (e.g. a scalar date day-split that replaced a single + // SearchParameterExpression) or be a container (e.g. And(union, ...)) that holds one. + if (expression.Predicate is UnionExpression) + { + return true; + } + IExpressionsContainer expressionContainer = expression.Predicate as IExpressionsContainer; return expressionContainer?.Expressions.Any(e => e is UnionExpression) ?? false; } @@ -36,6 +43,14 @@ public static bool SplitExpressions(this SearchParamTableExpression expression, unionExpression = null; allOtherRemainingExpressions = null; + // The union may be the entire predicate (e.g. a scalar date day-split that replaced a single + // SearchParameterExpression). In that case there are no sibling expressions to separate out. + if (expression.Predicate is UnionExpression bareUnionExpression) + { + unionExpression = bareUnionExpression; + return true; + } + IExpressionsContainer expressionContainer = expression.Predicate as IExpressionsContainer; if (expressionContainer != null) 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 index e2ad628365..5ef9bddb10 100644 --- 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 @@ -61,6 +61,12 @@ internal class SqlQueryGenerator : DefaultSqlExpressionVisitor _ctePredecessorByCteIndex = new Dictionary(); + private SearchParamTableExpressionKind? _currentTableExpressionKind; private bool _isAsyncOperation; private readonly HashSet _searchParamIds = new(); private readonly SearchParamTableExpressionQueryGeneratorFactory _queryGeneratorFactory; @@ -508,6 +514,10 @@ public override object VisitTable(SearchParamTableExpression searchParamTableExp const string referenceSourceTableAlias = "refSource"; const string referenceTargetResourceTableAlias = "refTarget"; + // Remember the kind of the CTE currently being generated so FindRestrictingPredecessorTableExpressionIndex + // can special-case a Concatenation (date-overlap second branch). + _currentTableExpressionKind = searchParamTableExpression.Kind; + switch (searchParamTableExpression.Kind) { case SearchParamTableExpressionKind.Normal: @@ -574,64 +584,74 @@ public override object VisitTable(SearchParamTableExpression searchParamTableExp 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"); + AppendUnionAllBranchSelect(searchParamTableExpression, context); + } - var searchParameterExpressionPredicate = searchParamTableExpression.Predicate as SearchParameterExpression; + StringBuilder.AppendLine("),"); + } - // 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)); + /// + /// Appends the SELECT ... FROM ... WHERE ... body for a single branch of a UNION ALL. Callers are + /// responsible for the surrounding CTE wrapper and for separating consecutive branches with UNION ALL. + /// + private void AppendUnionAllBranchSelect(SearchParamTableExpression searchParamTableExpression, SearchOptions context) + { + var specialCaseTableName = searchParamTableExpression.QueryGenerator.Table; - if (allAreResourceTypeOrId) - { - specialCaseTableName = VLatest.Resource; - } - } + StringBuilder.Append("SELECT ") + .Append(VLatest.Resource.ResourceTypeId, null).Append(" AS T1, ") + .Append(VLatest.Resource.ResourceSurrogateId, null).AppendLine(" AS Sid1"); - StringBuilder.Append("FROM ").AppendLine(specialCaseTableName); - } + var searchParameterExpressionPredicate = searchParamTableExpression.Predicate as SearchParameterExpression; - using (var delimited = StringBuilder.BeginDelimitedWhereClause()) + // 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) { - // Apply History and Delete clause when querying from Resource table in case of compartment unions - AppendHistoryClause(delimited, context.ResourceVersionTypes, searchParamTableExpression, null, specialCaseTableName); + bool allAreResourceTypeOrId = multiaryExpression.Expressions.All(e => + e is SearchParameterExpression spe && + (spe.Parameter.Name == SearchParameterNames.ResourceType || spe.Parameter.Name == SearchParameterNames.Id)); - if (specialCaseTableName.Equals(VLatest.Resource)) + if (allAreResourceTypeOrId) { - AppendDeletedClause(delimited, context.ResourceVersionTypes); - } - - if (searchParamTableExpression.Predicate != null && !(searchParamTableExpression.Predicate is CompartmentSearchExpression)) - { - delimited.BeginDelimitedElement(); - searchParamTableExpression.Predicate.AcceptVisitor(searchParamTableExpression.QueryGenerator, GetContext()); + specialCaseTableName = VLatest.Resource; } } + + StringBuilder.Append("FROM ").AppendLine(specialCaseTableName); } - StringBuilder.AppendLine("),"); + 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()); + } + } } private void HandleTableKindNormal(SearchParamTableExpression searchParamTableExpression, SearchOptions context) @@ -1429,6 +1449,51 @@ private void AppendNewSetOfUnionAllTableExpressions(SearchOptions context, Union throw new ArgumentOutOfRangeException(unionExpression.Operator.ToString()); } + // Unions flagged with DoNotSplitIntoSeparateCtes (e.g. scalar date day-splits) are emitted as a single CTE + // whose body combines the branches with inline UNION ALL, instead of one CTE per branch plus an aggregating + // CTE. Both forms produce the same aggregated (T1, Sid1) CTE, so the predecessor wiring below is shared. + if (unionExpression.DoNotSplitIntoSeparateCtes) + { + AppendInlinedUnionAllTableExpression(context, unionExpression, defaultQueryGenerator); + } + else + { + AppendSplitUnionAllTableExpressions(context, unionExpression, defaultQueryGenerator); + } + + // 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 AppendSplitUnionAllTableExpressions(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator) + { // Iterate through all expressions and create a unique CTE for each one. int firstInclusiveTableExpressionId = _tableExpressionCounter + 1; foreach (Expression innerExpression in unionExpression.Expressions) @@ -1464,36 +1529,45 @@ private void AppendNewSetOfUnionAllTableExpressions(SearchOptions context, Union 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) + /// + /// Emits a single CTE whose body combines each branch of with inline + /// UNION ALL (one SELECT ... FROM ... WHERE ... per branch). Used for unions marked with + /// so the union does not fan out into one CTE per + /// branch plus an aggregating CTE. The resulting CTE has the same (T1, Sid1) shape as the split form. + /// + private void AppendInlinedUnionAllTableExpression(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator) + { + StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); + + bool isFirstBranch = true; + foreach (Expression innerExpression in unionExpression.Expressions) { - var prevUnionAggregateTableName = TableExpressionName(_unionAggregateCTEIndex); - var currentUnionAggregateTableName = TableExpressionName(_tableExpressionCounter); + // Determine the appropriate query generator for this specific inner expression + var queryGenerator = DetermineQueryGeneratorForExpression(innerExpression, defaultQueryGenerator); - StringBuilder.Append(", "); - StringBuilder.AppendLine(); - StringBuilder.Append(TableExpressionName(++_tableExpressionCounter)).AppendLine(" AS").AppendLine("("); + var branchExpression = new SearchParamTableExpression( + queryGenerator, + innerExpression, + SearchParamTableExpressionKind.Union); 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(); + if (!isFirstBranch) + { + StringBuilder.AppendLine("UNION ALL"); + } + + // AppendUnionAllBranchSelect ends with the WHERE clause, which already terminates with a newline, + // so the next branch's "UNION ALL" (or the closing paren below) starts on its own line. + AppendUnionAllBranchSelect(branchExpression, context); } - StringBuilder.Append(")"); + isFirstBranch = false; } - _unionAggregateCTEIndex = _tableExpressionCounter; - - _unionVisited = true; - _firstChainAfterUnionVisited = false; + StringBuilder.Append(")"); } private void AppendSmartNewSetOfUnionAllTableExpressions(SearchOptions context, UnionExpression unionExpression, SearchParamTableExpressionQueryGenerator defaultQueryGenerator, bool skipJoinFromPreviousUnions) @@ -1716,7 +1790,26 @@ int FindImpl(int currentIndex) } } - return FindImpl(_tableExpressionCounter); + int predResult; + + // A Concatenation is the second branch of a date-overlap split (e.g. DateTimeEqualityRewriter emits a + // "longer than a day = true" CTE followed by a Concatenation for the "= false" rows). Both branches must + // restrict against the SAME predecessor. The sibling is always the immediately-preceding CTE + // (cte{counter-1}); reuse the predecessor we already computed for it. FindImpl cannot be relied on here + // because, when a UNION ALL precedes these CTEs (e.g. a scalar date day-split), the CTE counter no longer + // lines up with the (unsorted) table-expression list it indexes, so the Concatenation skip-back is missed. + if (_currentTableExpressionKind == SearchParamTableExpressionKind.Concatenation + && _ctePredecessorByCteIndex.TryGetValue(_tableExpressionCounter - 1, out int siblingPredecessorIndex)) + { + predResult = siblingPredecessorIndex; + } + else + { + predResult = FindImpl(_tableExpressionCounter); + } + + _ctePredecessorByCteIndex[_tableExpressionCounter] = predResult; + return predResult; } private void AppendDeletedClause(in IndentedStringBuilder.DelimitedScope delimited, ResourceVersionType resourceVersionType, string tableAlias = null) 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 index 8297ad6aa1..cc379407c9 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/ScalarTemporalEqualityRewriter.cs @@ -196,7 +196,9 @@ private static UnionExpression BuildDaySplitUnion( startPredicate, endPredicate)); - return Expression.Union(UnionOperator.All, new Expression[] { shortBranch, longBranch }); + UnionExpression union = Expression.Union(UnionOperator.All, new Expression[] { shortBranch, longBranch }); + union.DoNotSplitIntoSeparateCtes = true; + return union; } private static bool IsStartGe(BinaryExpression be) => 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 index 658f49c84e..8af1ee6502 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SortRewriter.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/SortRewriter.cs @@ -170,5 +170,13 @@ public override Expression VisitSearchParameter(SearchParameterExpression expres return expression; } + + // A union (e.g. a scalar date day-split) can itself be the filter over the sort parameter. + // If any branch filters on the sort parameter, treat the whole union as a filter match by + // returning null (so VisitSqlRoot emits a SortWithFilter). Do NOT fall through to the base + // implementation, which would rebuild the union from branches that VisitSearchParameter + // collapsed to null and trip the UnionExpression null-branch guard. + public override Expression VisitUnion(UnionExpression expression, SqlSearchOptions context) => + expression.Expressions.All(e => e.AcceptVisitor(this, context) == null) ? null : expression; } } diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/ChainingSearchTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/ChainingSearchTests.cs index 29133b4005..4d1a9a31ae 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/ChainingSearchTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/ChainingSearchTests.cs @@ -96,6 +96,70 @@ public async Task GivenAChainedSearchExpressionOverBirthdate_WhenSearched_ThenCo ValidateBundle(bundle, Fixture.SmithSnomedDiagnosticReport, Fixture.SmithLoincDiagnosticReport); } + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + public async Task GivenAChainedSearchExpressionOverBirthdateMonthPrecision_WhenSearched_ThenCorrectBundleShouldBeReturned() + { + string query = $"_tag={Fixture.Tag}&subject:Patient.birthdate=1990-05"; + + Bundle bundle = await Client.SearchAsync(ResourceType.DiagnosticReport, query); + + ValidateBundle(bundle, Fixture.SmithSnomedDiagnosticReport, Fixture.SmithLoincDiagnosticReport, Fixture.TrumanSnomedDiagnosticReport, Fixture.TrumanLoincDiagnosticReport); + } + + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + public async Task GivenAChainedSearchExpressionOverBirthdateDayWithAdditionalCriteriaOnTheSameTarget_WhenSearched_ThenCorrectBundleShouldBeReturned() + { + string query = $"_tag={Fixture.Tag}&subject:Patient.birthdate={Fixture.SmithPatientBirthDate}&subject:Patient.family=Smith"; + + Bundle bundle = await Client.SearchAsync(ResourceType.DiagnosticReport, query); + + ValidateBundle(bundle, Fixture.SmithSnomedDiagnosticReport, Fixture.SmithLoincDiagnosticReport); + } + + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + public async Task GivenAChainedExactDayBirthdateWithBaseResourceSortAndSecondChainedPredicate_WhenSearched_ThenCorrectBundleShouldBeReturned() + { + string tag = Guid.NewGuid().ToString(); + var meta = new Meta { Tag = new List { new Coding("testTag", tag) } }; + const string exactDay = "1951-06-08"; + + Patient matchingPatient = (await Client.CreateAsync( + new Patient { Meta = meta, BirthDate = exactDay, Name = new List { new HumanName { Family = "Smith" } } })).Resource; + Patient wrongFamilyPatient = (await Client.CreateAsync( + new Patient { Meta = meta, BirthDate = exactDay, Name = new List { new HumanName { Family = "Jones" } } })).Resource; + Patient wrongDayPatient = (await Client.CreateAsync( + new Patient { Meta = meta, BirthDate = "1951-06-09", Name = new List { new HumanName { Family = "Smith" } } })).Resource; + + var code = new CodeableConcept("http://loinc.org", "4548-4"); + + Observation matchA = await CreateObservationWithEffective(matchingPatient, "2021-03-03"); + Observation matchB = await CreateObservationWithEffective(matchingPatient, "2021-01-01"); + await CreateObservationWithEffective(wrongFamilyPatient, "2021-02-02"); + await CreateObservationWithEffective(wrongDayPatient, "2021-04-04"); + + string query = $"_tag={tag}&subject:Patient.birthdate={exactDay}&subject:Patient.family=Smith&_sort=-date&_count=100"; + + Bundle bundle = await Client.SearchAsync(ResourceType.Observation, query); + + ValidateBundle(bundle, matchA, matchB); + + async Task CreateObservationWithEffective(Patient patient, string effective) + { + return (await Client.CreateAsync( + new Observation + { + Meta = meta, + Status = ObservationStatus.Final, + Code = code, + Subject = new ResourceReference($"Patient/{patient.Id}"), + Effective = new FhirDateTime(effective), + })).Resource; + } + } + [Fact] public async Task GivenAChainedSearchExpressionOverASimpleParameter_WhenSearchedWithPaging_ThenCorrectBundleShouldBeReturned() { @@ -144,6 +208,86 @@ public async Task GivenAReverseChainSearchExpressionOverASimpleParameter_WhenSea ValidateBundle(bundle, Fixture.TrumanPatient); } + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GivenAReverseChainSearchExpressionCombinedWithAnExactDayBirthdate_WhenSearched_ThenCorrectBundleShouldBeReturned(bool includeAccurateTotal) + { + string query = $"_tag={Fixture.Tag}&birthdate={Fixture.SmithPatientBirthDate}&_has:Observation:patient:code={Fixture.SnomedCode}"; + if (includeAccurateTotal) + { + query += "&_total=accurate"; + } + + Bundle bundle = await Client.SearchAsync(ResourceType.Patient, query); + + if (includeAccurateTotal) + { + Assert.Equal(1, bundle.Total.GetValueOrDefault()); + } + + ValidateBundle(bundle, Fixture.SmithPatient); + } + +#if !Stu3 + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + public async Task GivenAReverseChainSearchExpressionOverImagingStudyStartedCombinedWithAnExactDayBirthdateAndTag_WhenSearchedWithPost_ThenCorrectBundleShouldBeReturned() + { + string tenantTagCode = Guid.NewGuid().ToString("N"); + var tenantMeta = new Meta + { + Tag = new List + { + new Coding(Fixture.TenantTagSystem, tenantTagCode), + }, + }; + + Patient imagingStudyExactDayPatient = (await Client.CreateAsync( + new Patient + { + Meta = tenantMeta, + BirthDate = Fixture.ImagingStudyPatientBirthDate, + })).Resource; + + Patient imagingStudyMonthPrecisionPatient = (await Client.CreateAsync( + new Patient + { + Meta = tenantMeta, + BirthDate = Fixture.ImagingStudyMonthPrecisionPatientBirthDate, + })).Resource; + + await Client.CreateAsync( + new ImagingStudy + { + Meta = tenantMeta, + Status = ImagingStudy.ImagingStudyStatus.Available, + Subject = new ResourceReference($"Patient/{imagingStudyExactDayPatient.Id}"), + Started = Fixture.ImagingStudyStarted, + }); + + await Client.CreateAsync( + new ImagingStudy + { + Meta = tenantMeta, + Status = ImagingStudy.ImagingStudyStatus.Available, + Subject = new ResourceReference($"Patient/{imagingStudyMonthPrecisionPatient.Id}"), + Started = Fixture.ImagingStudyStarted, + }); + + Bundle bundle = await Client.SearchPostAsync( + ResourceType.Patient.ToString(), + null, + default, + ("_has:ImagingStudy:patient:started", Fixture.ImagingStudyStarted), + ("birthdate", Fixture.ImagingStudyPatientBirthDate), + ("_tag", $"{Fixture.TenantTagSystem}|{tenantTagCode}")); + + ValidateBundle(bundle, imagingStudyExactDayPatient, imagingStudyMonthPrecisionPatient); + } +#endif + [Fact] public async Task GivenAReverseChainSearchExpressionWithMultipleTargetTypes_WhenSearched_ThenCorrectBundleShouldBeReturned() { @@ -400,6 +544,16 @@ public ClassFixture(DataStore dataStore, Format format, TestFhirServerFactory te public string OrganizationIdentifier { get; } = Guid.NewGuid().ToString(); +#if !Stu3 + public string TenantTagSystem { get; } = "urn:tenantId"; + + public string ImagingStudyPatientBirthDate { get; } = "2018-06-06"; + + public string ImagingStudyMonthPrecisionPatientBirthDate { get; } = "2018-06"; + + public string ImagingStudyStarted { get; } = "2018-02-02T05:00:00.000"; +#endif + public Patient SmithPatient { get; private set; } public DiagnosticReport SmithSnomedDiagnosticReport { get; private set; } diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/DateSearchTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/DateSearchTests.cs index f4237aad2c..85ded1fedd 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/DateSearchTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/Search/DateSearchTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Threading.Tasks; using Hl7.Fhir.Model; using Microsoft.Health.Fhir.Client; using Microsoft.Health.Fhir.Tests.Common; @@ -298,10 +299,161 @@ public async Task GivenPatientsWithPartialBirthdates_WhenSearchedByEquality_Then } } + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + [Trait(Traits.Priority, Priority.One)] + public async Task GivenPatientsWithPartialBirthdates_WhenSearchedByMultiValueOr_ThenUnionOfPerValueOverlapSetsIsReturned() + { + string tag = Guid.NewGuid().ToString(); + PartialBirthdateMatrix matrix = await CreatePartialBirthdateMatrixAsync(tag); + + Bundle bundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=2000-03-03,2001-12-31&_tag={tag}"); + + ValidateBundle(bundle, matrix.Year2000, matrix.March2000, matrix.March03, matrix.Year2001, matrix.December2001, matrix.December31_2001); + AssertNoDuplicateEntries(bundle); + } + + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + [Trait(Traits.Priority, Priority.One)] + public async Task GivenPatientsWithPartialBirthdates_WhenSearchedByNotEquals_ThenOnlyTheExactDayValueIsExcluded() + { + string tag = Guid.NewGuid().ToString(); + PartialBirthdateMatrix matrix = await CreatePartialBirthdateMatrixAsync(tag); + + Bundle bundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=ne2000-03-03&_tag={tag}&_total=accurate&_count=100"); + + Assert.Equal(11, bundle.Total.GetValueOrDefault()); + ValidateBundle( + bundle, + matrix.Year2000, + matrix.March2000, + matrix.December31_1999, + matrix.April01_2000, + matrix.December2000, + matrix.March31_2000, + matrix.Year2001, + matrix.November30_2001, + matrix.December2001, + matrix.December31_2001, + matrix.January01_2002); + AssertNoDuplicateEntries(bundle); + } + + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + [Trait(Traits.Priority, Priority.One)] + public async Task GivenPatientsWithPartialBirthdates_WhenDayEqualitySearchedWithTotal_ThenCountIsAccurateAndEntriesAreNotDuplicated() + { + string tag = Guid.NewGuid().ToString(); + PartialBirthdateMatrix matrix = await CreatePartialBirthdateMatrixAsync(tag); + + Bundle bundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=2000-03-03&_tag={tag}&_total=accurate"); + + Assert.Equal(3, bundle.Total.GetValueOrDefault()); + ValidateBundle(bundle, matrix.Year2000, matrix.March2000, matrix.March03); + AssertNoDuplicateEntries(bundle); + } + + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + [Trait(Traits.Priority, Priority.One)] + public async Task GivenPatientsWithPartialBirthdates_WhenDayEqualitySearchedWithSort_ThenResultsAreOrderedByBirthdateWithoutDuplicates() + { + string tag = Guid.NewGuid().ToString(); + PartialBirthdateMatrix matrix = await CreatePartialBirthdateMatrixAsync(tag); + + Bundle bundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=2000-03-03&_sort=birthdate&_tag={tag}"); + + AssertNoDuplicateEntries(bundle); + Assert.Equal( + new[] { matrix.Year2000.Id, matrix.March2000.Id, matrix.March03.Id }, + bundle.Entry.Select(e => e.Resource.Id).ToArray()); + } + + [HttpIntegrationFixtureArgumentSets(DataStore.SqlServer, Format.Json)] + [Fact] + [Trait(Traits.Priority, Priority.One)] + public async Task GivenPatientsWithLeapDayBirthdates_WhenSearchedByDay_ThenCorrectBundleShouldBeReturned() + { + string tag = Guid.NewGuid().ToString(); + Patient[] patients = await Client.CreateResourcesAsync( + p => SetPatientBirthDate(p, "2020", tag), + p => SetPatientBirthDate(p, "2020-02", tag), + p => SetPatientBirthDate(p, "2020-02-28", tag), + p => SetPatientBirthDate(p, "2020-02-29", tag), + p => SetPatientBirthDate(p, "2020-03-01", tag)); + Patient year2020 = patients[0]; + Patient february2020 = patients[1]; + Patient february28 = patients[2]; + Patient february29 = patients[3]; + Patient march01 = patients[4]; + + Bundle leapDayBundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=2020-02-29&_tag={tag}"); + ValidateBundle(leapDayBundle, year2020, february2020, february29); + + Bundle feb28Bundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=2020-02-28&_tag={tag}"); + ValidateBundle(feb28Bundle, year2020, february2020, february28); + + Bundle march01Bundle = await Client.SearchAsync(ResourceType.Patient, $"birthdate=2020-03-01&_tag={tag}"); + ValidateBundle(march01Bundle, year2020, march01); + } + + private async System.Threading.Tasks.Task CreatePartialBirthdateMatrixAsync(string tag) + { + Patient[] patients = await Client.CreateResourcesAsync( + p => SetPatientBirthDate(p, "2000", tag), + p => SetPatientBirthDate(p, "2000-03", tag), + p => SetPatientBirthDate(p, "2000-03-03", tag), + p => SetPatientBirthDate(p, "1999-12-31", tag), + p => SetPatientBirthDate(p, "2000-04-01", tag), + p => SetPatientBirthDate(p, "2000-12", tag), + p => SetPatientBirthDate(p, "2000-03-31", tag), + p => SetPatientBirthDate(p, "2001", tag), + p => SetPatientBirthDate(p, "2001-11-30", tag), + p => SetPatientBirthDate(p, "2001-12", tag), + p => SetPatientBirthDate(p, "2001-12-31", tag), + p => SetPatientBirthDate(p, "2002-01-01", tag)); + + return new PartialBirthdateMatrix( + Year2000: patients[0], + March2000: patients[1], + March03: patients[2], + December31_1999: patients[3], + April01_2000: patients[4], + December2000: patients[5], + March31_2000: patients[6], + Year2001: patients[7], + November30_2001: patients[8], + December2001: patients[9], + December31_2001: patients[10], + January01_2002: patients[11]); + } + + private static void AssertNoDuplicateEntries(Bundle bundle) + { + List ids = bundle.Entry.Select(e => e.Resource.Id).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + private static void SetPatientBirthDate(Patient patient, string birthDate, string tag) { patient.Meta = new Meta { Tag = new List { new Coding(null, tag) } }; patient.BirthDate = birthDate; } + + private sealed record PartialBirthdateMatrix( + Patient Year2000, + Patient March2000, + Patient March03, + Patient December31_1999, + Patient April01_2000, + Patient December2000, + Patient March31_2000, + Patient Year2001, + Patient November30_2001, + Patient December2001, + Patient December31_2001, + Patient January01_2002); } }