diff --git a/Darling/Darling.Tests/PlanSync4522Tests.cs b/Darling/Darling.Tests/PlanSync4522Tests.cs new file mode 100644 index 0000000000..8d3d627787 --- /dev/null +++ b/Darling/Darling.Tests/PlanSync4522Tests.cs @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using PerformanceMonitor.PlanAnalysis; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4522 (PerformanceStudio#577) — Rule 5 (Row Estimate Mismatch) checked +/// node.HasActualStats && node.EstimateRows > 0 without also requiring +/// node.ActualExecutions > 0. An operator in a branch that never ran (an outer join's +/// inner side, a conditional CASE branch, an OR branch of a concatenation) returns 0 rows +/// because it never executed, not because the estimate was wrong. The rule now skips operators +/// with zero executions, the same way rules 11, 12 and 29 already do, and the now-unreachable +/// ActualExecutions > 0 ? ActualExecutions : 1 fallback is removed. +/// +/// The repro XML is a synthetic actual plan (dbo.t) with two Sort operators under a +/// Concatenation: one ran zero times (a branch that never executed), one ran once and returned +/// nothing despite a large estimate. +/// +public sealed class PlanSync4522Tests +{ + private const string ReproXmlTemplate = """ + + + + + + + + + + + + + + + + + + + + + + + """; + + private static ParsedPlan ParseAndAnalyze(long firstExecutions, long secondExecutions) + { + var xml = ReproXmlTemplate + .Replace("{{FIRST_EXECUTIONS}}", firstExecutions.ToString()) + .Replace("{{SECOND_EXECUTIONS}}", secondExecutions.ToString()); + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan); + return plan; + } + + private static bool HasRowEstimateMismatch(PlanNode node) => + node.Warnings.Any(w => w.WarningType == "Row Estimate Mismatch"); + + private static PlanNode FindSort(ParsedPlan plan, int nodeId) + { + var root = plan.Batches.SelectMany(b => b.Statements).Single().RootNode!; + return Flatten(root).Single(n => n.NodeId == nodeId); + } + + private static System.Collections.Generic.IEnumerable Flatten(PlanNode node) + { + yield return node; + foreach (var child in node.Children) + foreach (var descendant in Flatten(child)) + yield return descendant; + } + + [Fact] + public void Rule05_OperatorThatNeverExecuted_IsNotAnEstimateMismatch() + { + // Node 1 never executed (ActualExecutions=0) despite an estimate of 1000 rows. + var plan = ParseAndAnalyze(firstExecutions: 0, secondExecutions: 1); + var neverRan = FindSort(plan, nodeId: 1); + + Assert.False(HasRowEstimateMismatch(neverRan)); + } + + [Fact] + public void Rule05_OperatorThatExecutedAndReturnedNothing_StillWarns() + { + // Node 2 ran once (ActualExecutions=1) and still returned 0 rows against an estimate + // of 1000 — a real mismatch, not a branch that never ran. + var plan = ParseAndAnalyze(firstExecutions: 0, secondExecutions: 1); + var ranButEmpty = FindSort(plan, nodeId: 2); + + Assert.True(HasRowEstimateMismatch(ranButEmpty)); + } +} diff --git a/Darling/Darling.Tests/PlanSync4525Tests.cs b/Darling/Darling.Tests/PlanSync4525Tests.cs new file mode 100644 index 0000000000..81a311b52e --- /dev/null +++ b/Darling/Darling.Tests/PlanSync4525Tests.cs @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using PerformanceMonitor.PlanAnalysis; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4525 (PerformanceStudio#584) — Rule 23 (Table-Valued Function) fired on every +/// LogicalOp="Table-valued function" operator, including the engine's own functions: +/// STRING_SPLIT, OPENJSON, GENERATE_SERIES, and every DMV/DMF. Those run as +/// the same operator, but their Object element names no database and no schema — a +/// function the user wrote always has both. now carries the +/// schema next to (both set from the parsed Object +/// element), and rule 23 skips an operator with neither. +/// +/// The repro XML is a synthetic actual plan, one variant with no Object element (as +/// the engine's own functions appear) and one with a dbo/[db] user function. +/// +public sealed class PlanSync4525Tests +{ + private const string EngineFunctionXml = """ + + + + + + + + + + + """; + + private const string UserFunctionXml = """ + + + + + + + + + + + """; + + private static ParsedPlan ParseAndAnalyze(string xml) + { + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan); + return plan; + } + + private static System.Collections.Generic.IEnumerable Flatten(PlanNode node) + { + yield return node; + foreach (var child in node.Children) + foreach (var descendant in Flatten(child)) + yield return descendant; + } + + // RootNode is the synthetic statement wrapper (NodeId == -1); the parsed Table-valued + // function operator is its child. + private static PlanNode FindTvfNode(ParsedPlan plan) + { + var root = plan.Batches.SelectMany(b => b.Statements).Single().RootNode!; + return Flatten(root).Single(n => n.LogicalOp == "Table-valued function"); + } + + [Fact] + public void Rule23_EngineFunctionWithNoDatabaseAndNoSchema_DoesNotWarn() + { + var plan = ParseAndAnalyze(EngineFunctionXml); + var node = FindTvfNode(plan); + + Assert.Null(node.DatabaseName); + Assert.DoesNotContain(node.Warnings, w => w.WarningType == "Table-Valued Function"); + } + + [Fact] + public void Rule23_UserFunctionWithDatabaseAndSchema_StillWarns() + { + var plan = ParseAndAnalyze(UserFunctionXml); + var node = FindTvfNode(plan); + + Assert.Equal("db", node.DatabaseName); + Assert.Contains(node.Warnings, w => w.WarningType == "Table-Valued Function"); + } + + // Compile-only on dev: PlanNode.SchemaName is a new member (#4525 / PS#584); the parser + // now sets it next to DatabaseName from the same element. + [Fact] + public void Parser_SetsSchemaName_NextToDatabaseName() + { + var enginePlan = ParseAndAnalyze(EngineFunctionXml); + var engineNode = FindTvfNode(enginePlan); + Assert.Null(engineNode.SchemaName); + + var userPlan = ParseAndAnalyze(UserFunctionXml); + var userNode = FindTvfNode(userPlan); + Assert.Equal("dbo", userNode.SchemaName); + } +} diff --git a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs index 2031492e2d..94c6456c51 100644 --- a/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs +++ b/PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs @@ -524,7 +524,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) // - A parent join may have chosen the wrong strategy // - Root nodes with no parent to harm are skipped // - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped + // An operator that never executed returned zero rows because it never ran, so its + // zero is no evidence that the estimate was wrong. if (node.HasActualStats && node.EstimateRows > 0 + && node.ActualExecutions > 0 && !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading { if (node.ActualRows == 0) @@ -546,7 +549,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) else { // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; + var executions = node.ActualExecutions; var actualPerExec = (double)node.ActualRows / executions; var ratio = actualPerExec / node.EstimateRows; if (ratio >= 10.0 || ratio <= 0.1) @@ -1010,7 +1013,12 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn } // Rule 23: Table-valued functions - if (node.LogicalOp == "Table-valued function") + // A function the engine supplies runs as the same operator: STRING_SPLIT, OPENJSON, + // GENERATE_SERIES, and every DMV and DMF. Its Object names no database and no schema, + // and a function a user wrote always has both. The advice below is about code the + // user can rewrite, so the engine's own functions are skipped. + var isEngineFunction = string.IsNullOrEmpty(node.DatabaseName) && string.IsNullOrEmpty(node.SchemaName); + if (node.LogicalOp == "Table-valued function" && !isEngineFunction) { var funcName = node.ObjectName ?? node.PhysicalOp; node.Warnings.Add(new PlanWarning diff --git a/PerformanceMonitor.PlanAnalysis/PlanModels.cs b/PerformanceMonitor.PlanAnalysis/PlanModels.cs index ffab64d5d7..83ee540002 100644 --- a/PerformanceMonitor.PlanAnalysis/PlanModels.cs +++ b/PerformanceMonitor.PlanAnalysis/PlanModels.cs @@ -198,6 +198,7 @@ public class PlanNode // Detail properties (for tooltip/properties panel) public string? DatabaseName { get; set; } + public string? SchemaName { get; set; } public string? ObjectName { get; set; } public string? FullObjectName { get; set; } public string? IndexName { get; set; } diff --git a/PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs b/PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs index 56ce8c164c..b999b6a0fe 100644 --- a/PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs +++ b/PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs @@ -716,6 +716,7 @@ private static PlanNode ParseRelOp(XElement relOpEl) var index = objEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); node.DatabaseName = db; + node.SchemaName = schema; node.IndexName = index; var shortParts = new List();