diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs index 4f7cb4b..2c52595 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -300,15 +300,13 @@ private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int total // to compare fairly against the summed ActualRows. Everywhere else — including a // parallel zone, where ActualExecutions just counts threads — the estimate stays // per-execution. RowEstimateHelper is the one place that decides which applies. - var expectedRows = RowEstimateHelper.GetExpectedRows(node); + // #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict + // the percentage ("1 of 1 (89%)"). var accuracyRatio = RowEstimateHelper.GetRowAccuracyRatio(node); IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; - var accuracy = expectedRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; stack.Children.Add(new TextBlock { - Text = $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}", + Text = PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node)), FontSize = 10, Foreground = rowBrush, TextAlignment = TextAlignment.Center, diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4aad7cc..0bc20df 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Web; +using PlanViewer.Core.Services; namespace PlanViewer.Core.Output; @@ -573,10 +574,11 @@ private static void WriteOperatorLine(StringBuilder sb, OperatorResult node) if (node.ActualRows.HasValue) { // #594: the same execution-aware estimate the plan viewer's node label shows. + // #611: and the same printed numbers, so they agree with the percentage. var est = node.ExpectedRows ?? node.EstimatedRows; - var ratio = est > 0 ? (double)node.ActualRows.Value / est : 0; - var accuracy = est > 0 ? $" ({ratio * 100:F0}%)" : ""; - sb.Append($" {node.ActualRows.Value:N0} of {est:N0} rows{accuracy}"); + var (actualText, expectedText, percent) = PlanRowAccuracy.PrintActualOfExpected(node.ActualRows.Value, est); + var accuracy = percent != null ? $" ({percent}%)" : ""; + sb.Append($" {actualText} of {expectedText} rows{accuracy}"); } else { diff --git a/src/PlanViewer.Core/Services/PlanRowAccuracy.cs b/src/PlanViewer.Core/Services/PlanRowAccuracy.cs new file mode 100644 index 0000000..1e3ca73 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanRowAccuracy.cs @@ -0,0 +1,110 @@ +using System; +using System.Globalization; + +namespace PlanViewer.Core.Services; + +/// +/// The plan viewer's node row line: the rows an operator actually returned against the rows it was +/// expected to, printed as "{actual} of {expected} ({percent}%)". The expected figure is +/// , a total like ActualRows (#594). Shared by +/// the App and Web node labels and the HTML export (#611). PerformanceMonitor's plan viewer ports +/// the same rule (its PlanRowAccuracy, #4684), so both apps print the same string for the +/// same plan. +/// +public static class PlanRowAccuracy +{ + /// The most decimals adds to make a label agree with its percentage. + private const int MaxAgreementDecimals = 4; + + /// + /// The node row line: "{actual} of {expected} ({percent}%)", or just "{actual} of {expected}" + /// when is not above zero. See for the rule. + /// A Key Lookup that ran 117 times for 1 row (estimate 0.00964372 each) reads 1 of 1.128 (89%), not the + /// 1 of 1 (89%) that printing both counts N0 gave. + /// + public static string FormatActualOfExpected(double actualRows, double expectedRows, IFormatProvider? provider = null) + { + var (actual, expected, percent) = PrintActualOfExpected(actualRows, expectedRows, provider); + return percent == null + ? string.Concat(actual, " of ", expected) + : string.Concat(actual, " of ", expected, " (", percent, "%)"); + } + + /// + /// The two row counts and the whole percentage that joins, for a surface + /// that words the line its own way (the HTML export adds "rows"). The rule, word for word: + /// Print N0. If the whole percent computed from the printed numbers differs from the printed percentage, add the + /// fewest decimals (fixed-point, never scientific, capped at 4) at which they agree. A non-zero value never prints as + /// zero. If it would, print it fixed-point to its first significant digit. + /// + /// The percentage is actualRows / expectedRows * 100 to a whole number, from the unrounded values. It is + /// null when is not above zero (there is nothing to divide by). + /// The agreement search runs from 0 to 4 decimals. At each count a whole number prints N0 and any other + /// number prints N plus that count. The text is parsed back (group separator allowed, so "2,983" reads as + /// 2983) and the search stops at the first count where the whole percent of the printed numbers equals the printed + /// percentage. A printed divisor of zero never agrees. At 4 it stops either way, so a value like 0.00012345 can + /// print a line that still contradicts its percentage: the cap wins. + /// A non-zero value whose text would read as zero is reprinted with as many decimals as it takes to show its + /// first significant digit (0.000005 prints "0.000005"); the cap does not apply to that. + /// Only N formats print the row counts, so no magnitude prints an exponent. Culture follows + /// , the current culture by default. + /// + /// + public static (string Actual, string Expected, string? Percent) PrintActualOfExpected( + double actualRows, double expectedRows, IFormatProvider? provider = null) + { + provider ??= CultureInfo.CurrentCulture; + + var percent = expectedRows > 0 ? WholePercent(actualRows, expectedRows, provider) : null; + + var actualText = ""; + var expectedText = ""; + for (var decimals = 0; decimals <= MaxAgreementDecimals; decimals++) + { + actualText = PrintRows(actualRows, decimals, provider); + expectedText = PrintRows(expectedRows, decimals, provider); + + if (percent == null || PrintedNumbersGive(percent, actualText, expectedText, provider)) + break; + } + + return (actualText, expectedText, percent); + } + + private static string WholePercent(double actualRows, double expectedRows, IFormatProvider provider) + => (actualRows / expectedRows * 100).ToString("F0", provider); + + /// + /// One row count as text: a whole number is always N0, any other number N{decimals}. A non-zero + /// value that would print as zero is reprinted to its first significant digit instead. + /// + private static string PrintRows(double value, int decimals, IFormatProvider provider) + { + var text = value.ToString(Math.Floor(value) == value ? "N0" : NFormat(decimals), provider); + + if (value != 0 && double.IsFinite(value) + && double.TryParse(text, NumberStyles.Number, provider, out var printed) && printed == 0) + { + var firstSignificantDecimals = -(int)Math.Floor(Math.Log10(Math.Abs(value))); + text = value.ToString(NFormat(firstSignificantDecimals), provider); + } + + return text; + } + + /// True when the whole percent of the two printed numbers is the printed percentage. + private static bool PrintedNumbersGive(string percent, string actualText, string expectedText, IFormatProvider provider) + { + if (!double.TryParse(actualText, NumberStyles.Number, provider, out var printedActual) + || !double.TryParse(expectedText, NumberStyles.Number, provider, out var printedExpected)) + { + return false; + } + + // A printed divisor of zero gives NaN or Infinity, which never equals a percentage. + var printedPercent = printedActual / printedExpected * 100; + return double.IsFinite(printedPercent) && printedPercent.ToString("F0", provider) == percent; + } + + private static string NFormat(int decimals) => string.Create(CultureInfo.InvariantCulture, $"N{decimals}"); +} diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 7637a05..db6eafb 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -538,13 +538,13 @@ else // #594: EstimateRows is per execution — RowEstimateHelper scales it by // ActualExecutions only on the inner side of a Nested Loops join, where that count is // real rather than a parallel zone's thread count. - var expectedRows = RowEstimateHelper.GetExpectedRows(node); + // #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict + // the percentage ("1 of 1 (89%)"). var ratio = RowEstimateHelper.GetRowAccuracyRatio(node); var rowClass = (ratio < 0.1 || ratio > 10.0) ? " rows-skewed" : ""; - var accuracy = expectedRows > 0 ? $" ({ratio * 100:F0}%)" : ""; builder.OpenElement(28, "div"); builder.AddAttribute(29, "class", $"node-rows{rowClass}"); - builder.AddContent(30, $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}"); + builder.AddContent(30, PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node))); builder.CloseElement(); } else diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 75d9bda..85c32b7 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -32,6 +32,7 @@ + diff --git a/tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs b/tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs new file mode 100644 index 0000000..904df52 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanRowAccuracyTests.cs @@ -0,0 +1,270 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using PlanViewer.App.Controls; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// #611: the node row label printed both counts N0 but took its percentage from the unrounded +/// values, so a Key Lookup that ran 117 times for 1 row (estimate 0.00964372 each, 1.128 expected +/// in total) read "1 of 1 (89%)". PlanRowAccuracy adds the fewest decimals at which the printed +/// numbers give the printed percentage. The rule and these cases are PerformanceMonitor's (#4684), +/// so both apps print the same string for the same plan. +/// +public class PlanRowAccuracyTests +{ + private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + + // ---- the rule ---------------------------------------------------------------------------- + + /// Whole numbers print N0 and a missing or zero expectation drops the percentage, exactly as the label always did. + [Theory] + [InlineData(1.0, 1.0, "1 of 1 (100%)")] + [InlineData(5.0, 4.0, "5 of 4 (125%)")] + [InlineData(1234.5, 1000.0, "1,234 of 1,000 (123%)")] // N0 rounds an exact .5 to even + [InlineData(105.5128, 103.694, "106 of 104 (102%)")] + [InlineData(1234567.0, 2000000.0, "1,234,567 of 2,000,000 (62%)")] + [InlineData(1.0, 0.0, "1 of 0")] + [InlineData(0.0, 0.0, "0 of 0")] + [InlineData(12.0, 0.0, "12 of 0")] + [InlineData(0.0, 250.0, "0 of 250 (0%)")] + [InlineData(1.0, 1000000.0, "1 of 1,000,000 (0%)")] // the percentage is whole; only the row counts are kept off zero + public void WholeNumbers_PrintN0(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + /// The fewest decimals at which the printed numbers give the printed percentage. A whole number keeps its N0 + /// text, and a fixed-point count means a trailing zero can appear ("0.60", "0.30"). + [Theory] + [InlineData(1.0, 1.12831524, "1 of 1.128 (89%)")] + [InlineData(0.0, 0.5, "0 of 0.5 (0%)")] + [InlineData(0.5, 5.0, "0.5 of 5 (10%)")] + [InlineData(3.0, 0.4, "3 of 0.4 (750%)")] + [InlineData(0.1, 0.3, "0.1 of 0.3 (33%)")] + [InlineData(2.5, 2.0, "2.5 of 2 (125%)")] + [InlineData(100000.0, 12.5, "100,000 of 12.5 (800000%)")] + [InlineData(0.6, 0.75, "0.60 of 0.75 (80%)")] + [InlineData(0.25, 0.3, "0.25 of 0.30 (83%)")] + [InlineData(0.99, 1.01, "0.99 of 1.01 (98%)")] + [InlineData(0.6, 0.55555, "0.600 of 0.556 (108%)")] + [InlineData(0.0104, 0.0096, "0.0104 of 0.0096 (108%)")] + [InlineData(25.0, 16.4, "25 of 16.4 (152%)")] + [InlineData(1.0, 0.4, "1 of 0.4 (250%)")] + public void Decimals_AreAddedOnlyUntilTheNumbersAgreeWithThePercentage(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + /// Four decimals is the ceiling for making the numbers agree, so a label can still contradict its + /// percentage there. It still never prints a non-zero value as 0: 0.00001234 needs five decimals to show a digit, and + /// the cap does not apply to that. + [Theory] + [InlineData(1.0, 0.00012345, "1 of 0.0001 (810045%)")] + [InlineData(0.000123, 0.000456, "0.0001 of 0.0005 (27%)")] + [InlineData(1.0, 0.00001234, "1 of 0.00001 (8103728%)")] + [InlineData(0.0000004, 1.0, "0.0000004 of 1 (0%)")] + [InlineData(0.00001, 0.0, "0.00001 of 0")] + [InlineData(0.0, 0.000005, "0 of 0.000005 (0%)")] + public void Cap_StopsAtFourDecimals_ButANonZeroValueNeverPrintsAsZero(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + /// No magnitude prints an exponent. + [Theory] + [InlineData(1e15, 3e15, "1,000,000,000,000,000 of 3,000,000,000,000,000 (33%)")] + [InlineData(1e21, 4e21, "1,000,000,000,000,000,000,000 of 4,000,000,000,000,000,000,000 (25%)")] + public void LargeNumbers_PrintEveryDigit(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant)); + } + + [Theory] + [InlineData(1e300, 3e300)] + [InlineData(1.0, double.MaxValue)] + [InlineData(5e-324, 1e-320)] + [InlineData(1e-300, 1e300)] + public void ExtremeMagnitudes_NeverPrintAnExponent(double actual, double expected) + { + var label = PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant); + Assert.DoesNotContain("E", label, StringComparison.OrdinalIgnoreCase); + Assert.Matches(new Regex(@"^[\d,.]+ of [\d,.]+ \(\d+%\)$", RegexOptions.CultureInvariant), label); + } + + /// The HTML export words the line its own way, so it takes the pieces: the same two numbers, and no + /// percentage when there is nothing to divide by. + [Fact] + public void PrintActualOfExpected_ReturnsThePiecesTheLabelJoins() + { + Assert.Equal(("1", "1.128", "89"), PlanRowAccuracy.PrintActualOfExpected(1.0, 1.12831524, Invariant)); + Assert.Equal(("1", "0", (string?)null), PlanRowAccuracy.PrintActualOfExpected(1.0, 0.0, Invariant)); + } + + // ---- culture ----------------------------------------------------------------------------- + + /// The numbers, the decimal mark and the group mark follow the caller's culture, and the agreement check + /// parses the group mark back ("2.983" is 2983 in de-DE). + [Theory] + [InlineData(1.0, 1.12831524, "1 of 1,128 (89%)")] + [InlineData(609.0, 2983.02, "609 of 2.983 (20%)")] + [InlineData(0.6, 0.75, "0,60 of 0,75 (80%)")] + public void Culture_FollowsTheCallers(double actual, double expected, string label) + { + Assert.Equal(label, PlanRowAccuracy.FormatActualOfExpected(actual, expected, new CultureInfo("de-DE"))); + } + + [Fact] + public void Culture_DefaultsToTheCurrentCulture() + { + var saved = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1 of 1,128 (89%)", PlanRowAccuracy.FormatActualOfExpected(1.0, 1.12831524)); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + + // ---- a sweep ----------------------------------------------------------------------------- + + /// 2,000 pairs from 1e-6 up to 1e6, whole and fractional, a twentieth with no actual rows. Each label must + /// (1) agree with its own percentage or have stopped at four decimals, (2) carry no exponent, and (3) never print a + /// non-zero value as zero. + [Fact] + public void Sweep_EveryLabelAgreesOrHitTheCap_HasNoExponent_AndNeverPrintsANonZeroValueAsZero() + { + var random = new Random(4684); + var shape = new Regex(@"^(?\S+) of (?\S+) \((?

\d+)%\)$", RegexOptions.CultureInvariant); + int pairs = 0, withDecimals = 0, atTheCap = 0, pastTheCap = 0; + + for (var i = 0; i < 2000; i++) + { + var expected = Math.Pow(10, random.NextDouble() * 12 - 6); + if (i % 3 == 0) + expected = Math.Max(Math.Round(expected), 1); + var actual = expected * Math.Pow(10, random.NextDouble() * 2 - 1); + if (i % 5 == 0) + actual = Math.Round(actual); + if (i % 20 == 0) + actual = 0; + + var label = PlanRowAccuracy.FormatActualOfExpected(actual, expected, Invariant); + var context = $"actual {actual:R}, expected {expected:R} -> \"{label}\""; + var match = shape.Match(label); + Assert.True(match.Success, $"unexpected label shape: {context}"); + pairs++; + + // (2) no exponent + Assert.False(label.Contains('E') || label.Contains('e'), $"exponent in {context}"); + + var actualText = match.Groups["a"].Value; + var expectedText = match.Groups["e"].Value; + var printedActual = double.Parse(actualText, NumberStyles.Number, Invariant); + var printedExpected = double.Parse(expectedText, NumberStyles.Number, Invariant); + + // (3) a non-zero value never prints as zero + Assert.True(actual == 0 || printedActual != 0, $"actual printed as zero: {context}"); + Assert.True(printedExpected != 0, $"expected printed as zero: {context}"); + + // (1) the printed numbers give the printed percentage, unless the search ran out at four decimals + var decimals = Math.Max(DecimalPlaces(actualText), DecimalPlaces(expectedText)); + var agrees = (printedActual / printedExpected * 100).ToString("F0", Invariant) == match.Groups["p"].Value; + Assert.True(agrees || decimals >= 4, $"the label contradicts its percentage before the cap: {context}"); + + if (decimals > 0) + withDecimals++; + if (decimals >= 4) + atTheCap++; + if (decimals >= 5) + pastTheCap++; + } + + Assert.Equal(2000, pairs); + // The sweep has to reach the interesting paths, or the three properties above prove little. + Assert.True(withDecimals > 800, $"only {withDecimals} labels needed decimals"); + Assert.True(atTheCap > 200, $"only {atTheCap} labels reached the cap"); + Assert.True(pastTheCap > 100, $"only {pastTheCap} labels needed the first-significant-digit rule"); + } + + private static int DecimalPlaces(string number) + { + var point = number.IndexOf('.'); + return point < 0 ? 0 : number.Length - point - 1; + } + + // ---- the surfaces ------------------------------------------------------------------------ + + ///

The issue's own example: key_lookup_plan's Key Lookup (NodeId 4, on the inner side of the Nested Loops) + /// ran 117 times for 1 row. The App's node label reads "1 of 1.128 (89%)", not "1 of 1 (89%)". + [Fact] + public void KeyLookupPlan_AppNodeLabel_AgreesWithItsPercentage() + { + HeadlessUi.Run(() => + { + var path = Path.Combine("Plans", "key_lookup_plan.sqlplan"); + Assert.True(File.Exists(path), $"Test plan not found: {path}"); + var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + var viewer = new PlanViewerControl(); + Assert.True(viewer.LoadPlan(xml, "key_lookup_plan.sqlplan"), $"Plan failed to load: {viewer.LastLoadError}"); + var window = new Window { Content = viewer, Width = 1600, Height = 1000 }; + window.Show(); + window.UpdateLayout(); + + var texts = viewer.GetLogicalDescendants().OfType().Select(t => t.Text).ToList(); + Assert.Contains("1 of 1.128 (89%)", texts); + Assert.DoesNotContain("1 of 1 (89%)", texts); + }); + } + + /// The HTML export prints the same numbers, in its own "X of Y rows (P%)" wording. + [Fact] + public void KeyLookupPlan_HtmlExport_AgreesWithItsPercentage() + { + var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan"); + foreach (var batch in plan.Batches) + foreach (var stmt in batch.Statements) + PlanLayoutEngine.Layout(stmt); + + var result = ResultMapper.Map(plan, "key_lookup_plan.sqlplan"); + var html = HtmlExporter.Export(result, TextFormatter.Format(result)); + + Assert.Contains("1 of 1.128 rows (89%)", html); + Assert.DoesNotContain("1 of 1 rows (89%)", html); + } + + /// The Web viewer is not referenced by this project, so this pins its node label to the shared formatter + /// instead of rendering it. + [Fact] + public void WebNodeLabel_UsesTheSharedFormatter() + { + var razor = File.ReadAllText(Path.Combine(SolutionRoot(), "src", "PlanViewer.Web", "Pages", "Index.razor")); + + Assert.Contains("PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node))", razor); + Assert.DoesNotContain("{expectedRows:N0}", razor); + } + + private static string SolutionRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "PlanViewer.sln"))) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the solution root."); + } +}