Skip to content

Fix ScalarTemporalEqualityRewriter union handling and predecessor selection#5624

Open
mikaelweave wants to merge 6 commits into
mainfrom
mikaelweave/fix-temporal-rewriter-union-handling
Open

Fix ScalarTemporalEqualityRewriter union handling and predecessor selection#5624
mikaelweave wants to merge 6 commits into
mainfrom
mikaelweave/fix-temporal-rewriter-union-handling

Conversation

@mikaelweave

@mikaelweave mikaelweave commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

ScalarTemporalEqualityRewriter rewrites exact-day birthdate equality into a UNION ALL. That union shape broke two parts of the SQL pipeline; this PR fixes both, end to end, while preserving the existing date-range overlap behavior (exact-day searches still match the broader month/year-precision values — customer-visible behavior is unchanged).

Phase 1 — bare union detection, splitting, and sorting

  • Handle bare UnionExpression predicates in SearchParamTableExpressionExtensions (HasUnionAllExpression / SplitExpressions) so the union SQL path is correctly detected and split (scalar-temporal shape, not just the SmartV2 nested-in-MultiaryExpression shape).
  • Add SortRewriter.VisitUnion with all-branches semantics to avoid reconstructing unions with null branches.

Phase 2 — restricting-predecessor selection for union + concatenation pairs

  • Bug: when the birthdate union is followed by a Normal+Concatenation date-range pair (from DateTimeBoundedRangeRewriter / ConcatenationRewriter), SqlQueryGenerator selected the wrong predecessor CTE for the Concatenation branch — it joined its own Normal sibling instead of the shared union-aggregate CTE.
  • Root cause: FindRestrictingPredecessorTableExpressionIndex mixed two coordinate systems — the generated-CTE counter (which a union advances by N) and indices into the unsorted _rootExpression.SearchParamTableExpressions. A union inflating the counter tripped the out-of-range guard before the Concatenation recursion could skip its sibling.
  • Fix (Option A): record generation order explicitly in _generatedTableExpressions ((Kind, OutputCteIndex) per emitted CTE) and resolve the restricting predecessor by walking that authoritative order. A Concatenation branch resolves to its Normal sibling's predecessor, so both branches join the shared union aggregate.
  • Documented the SortExpressionsByQueryLogic ordering invariant (unions move first; the (Normal, Concatenation) sibling pair stays adjacent) that the predecessor walk depends on.

Test Plan

  • New unit tests: SqlQueryGeneratorTests asserting both branches share the union-aggregate predecessor (plus a non-union regression); SearchParamTableExpressionExtensionsTests covering bare/nested-union shapes and the SortExpressionsByQueryLogic ordering invariant; SortRewriterTests.
  • Full Microsoft.Health.Fhir.SqlServer.UnitTests suite green (1026/1026 on a clean run; net8.0 + net9.0).
  • E2E coverage in ChainingSearchTests (reverse-chain _has:ImagingStudy:patient:started + exact-day birthdate; chained birthdate + same-target criteria; _has:Observation + exact-day birthdate) and DateSearchTests (multi-value OR overlap, ne, accurate total, sort, leap-day — all asserting no duplicate entries and overlap semantics).

When ScalarTemporalEqualityRewriter emits a bare UnionExpression as the
predicate of a SearchParamTableExpression (for day-precision birthdate
equality), two downstream components could not handle this shape:

1. HasUnionAllExpression / SplitExpressions checked for a union nested
   inside an IExpressionsContainer, so a bare union was never detected
   and the SQL union code path was never triggered.

2. SortRewriter had no VisitUnion override, so the base class visitor
   returned null for each SearchParameterExpression branch and then
   tried to construct new UnionExpression([null, null]), hitting the
   EnsureArg guard and throwing ArgumentException on every _sort=birthdate
   query.

Fixes:
- SearchParamTableExpressionExtensions.HasUnionAllExpression: return true
  immediately when predicate IS a UnionExpression (bare shape).
- SearchParamTableExpressionExtensions.SplitExpressions: detect bare union
  predicate, set unionExpression = that union, allOtherRemainingExpressions
  = null (safe: AppendNewSetOfUnionAllTableExpressions never reads it).
- SortRewriter.VisitUnion: visit all branches; return null (found signal)
  only if ALL branches return null; otherwise return the original reference
  unchanged (never reconstruct, to avoid null-in-ctor crash).

Tests added:
- SearchParamTableExpressionExtensionsTests: 6 tests covering
  HasUnionAllExpression and SplitExpressions for bare-union, nested-union,
  and plain-predicate shapes.
- SortRewriterTests: 4 tests verifying SortWithFilter vs Sort/NotExists
  emission when the union covers all, none, or a subset of branches for
  the sort parameter; plus a baseline test for plain SearchParameterExpression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mikaelweave mikaelweave requested a review from a team as a code owner June 18, 2026 19:04
@codecov-commenter

codecov-commenter commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.54%. Comparing base (109e69b) to head (85591a7).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #5624      +/-   ##
==========================================
+ Coverage   76.91%   77.54%   +0.63%     
==========================================
  Files         997      997              
  Lines       36616    36622       +6     
  Branches     5529     5532       +3     
==========================================
+ Hits        28163    28399     +236     
+ Misses       7104     6873     -231     
- Partials     1349     1350       +1     

see 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mikaelweave and others added 2 commits June 19, 2026 08:07
ScalarTemporalEqualityRewriter rewrites exact-day birthdate equality into a UNION ALL. When that union is followed by a Normal+Concatenation date-range pair (from DateTimeBoundedRangeRewriter/ConcatenationRewriter), SqlQueryGenerator selected the wrong predecessor CTE for the Concatenation branch: it joined its own Normal sibling instead of the shared union-aggregate CTE.

Root cause: FindRestrictingPredecessorTableExpressionIndex mixed two coordinate systems - the generated-CTE counter (which a union advances by N) and indices into the unsorted _rootExpression.SearchParamTableExpressions. A union inflating the counter tripped the out-of-range guard before the Concatenation recursion could skip its sibling.

Fix (Option A): record generation order explicitly in _generatedTableExpressions ((Kind, OutputCteIndex) per emitted CTE) and resolve the restricting predecessor by walking that authoritative order. A Concatenation branch resolves to its Normal sibling's predecessor, so both branches join the shared union aggregate. Documented the SortExpressionsByQueryLogic ordering invariant the walk relies on.

Tests: add SqlQueryGenerator tests asserting both branches share the union-aggregate predecessor (and a non-union regression), plus a SortExpressionsByQueryLogic test asserting unions move first while the (Normal, Concatenation) sibling pair stays adjacent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mikaelweave mikaelweave changed the title Fix bare UnionExpression handling for temporal equality and sorting Fix ScalarTemporalEqualityRewriter union handling and predecessor selection Jun 19, 2026
mikaelweave and others added 2 commits June 19, 2026 10:23
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Phase 2 _generatedTableExpressions list (generation-order tracking)
desynced from _tableExpressionCounter on SmartV2 include/rev-include
searches, where the filtered-data CTE and SmartV2 union re-generation emit
CTEs not recorded in the list. Position-based predecessor lookup then
resolved to a stale/forward CTE, producing 'Invalid object name cteN'.

Replace it with counter-based resolution: every kind returns
_tableExpressionCounter - 1, except Concatenation which returns
_tableExpressionCounter - 2 to skip its single-CTE Normal sibling so both
UNION ALL branches restrict against the shared predecessor. This is
byte-identical to the original generator for all non-Concatenation cases,
resolving the regression, while still fixing the union+date-range pair
join target. Update ADR-2606 to record the final decision.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mikaelweave mikaelweave added this to the FY26\Q4\2Wk\2Wk26 milestone Jun 20, 2026
@mikaelweave mikaelweave added Bug Bug bug bug. Azure Healthcare APIs Label denotes that the issue or PR is relevant to the FHIR service in the Azure Healthcare APIs ADR-Included ADR Included in the PR No-PaaS-breaking-change Schema Version unchanged labels Jun 20, 2026
}

private int FindRestrictingPredecessorTableExpressionIndex()
private int FindRestrictingPredecessorTableExpressionIndex(SearchParamTableExpressionKind currentKind)

@mikaelweave mikaelweave Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The brittle recursive index-walker was replaced with simple, signature-driven counter arithmetic.

Concatenation was in issue in the past with Union CTEs.

/// union, because the <see cref="UnionExpression"/> constructor rejects null children and
/// would throw if any branch visit returned null.
/// </summary>
public override Expression VisitUnion(UnionExpression expression, SqlSearchOptions context)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ScalarTemporalEqualityRewriter now emits a UnionExpression, a shape SortRewriter never saw before. We need this so the base visitor can handle correctly.

…correct SQL

Removes the VisitChained guard in ScalarTemporalEqualityRewriter so chained and reverse-chained exact-day birthdate equality also rewrite into the day-split UNION ALL (ADR-2605), and makes SqlQueryGenerator lower that union correctly when it is nested inside a chain.

SortExpressionsByQueryLogic now only hoists ChainLevel == 0 unions (a chain-nested union stays after its chain link); AppendNewSetOfUnionAllTableExpressions propagates the parent ChainLevel and pins all branches to the shared chain-link predecessor; HandleParamTableUnion restricts ChainLevel>0 branches against that predecessor on the chain target columns. ChainLevel == 0 output is byte-identical, so SMART-scope, _type, and compartment unions are unaffected.

Fixes production 500s in ICMs 21000001063947 and 815288838. Adds forward and reverse chained union SQL-generation unit tests, a deterministic ModelInfoProvider in the rewriter test harness, and an end-to-end regression (chained exact-day birthdate + base-resource sort + second chained predicate). Documents the decision in ADR-2606 (chain-aware union lowering).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ADR-Included ADR Included in the PR Azure Healthcare APIs Label denotes that the issue or PR is relevant to the FHIR service in the Azure Healthcare APIs Bug Bug bug bug. No-PaaS-breaking-change Schema Version unchanged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants