-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathPlanAnalyzer.cs
More file actions
1460 lines (1296 loc) · 72 KB
/
PlanAnalyzer.cs
File metadata and controls
1460 lines (1296 loc) · 72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using PlanViewer.Core.Models;
namespace PlanViewer.Core.Services;
/// <summary>
/// Post-parse analysis pass that walks a parsed plan tree and adds warnings
/// for common performance anti-patterns. Called after ShowPlanParser.Parse().
/// </summary>
public static class PlanAnalyzer
{
private static readonly Regex FunctionInPredicateRegex = new(
@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex LeadingWildcardLikeRegex = new(
@"\blike\b[^'""]*?N?'%",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex CaseInPredicateRegex = new(
@"\bCASE\s+(WHEN\b|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Matches CTE definitions: WITH name AS ( or , name AS (
private static readonly Regex CteDefinitionRegex = new(
@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null)
{
var cfg = config ?? AnalyzerConfig.Default;
foreach (var batch in plan.Batches)
{
foreach (var stmt in batch.Statements)
{
AnalyzeStatement(stmt, cfg);
if (stmt.RootNode != null)
AnalyzeNodeTree(stmt.RootNode, stmt, cfg);
}
}
// Apply severity overrides to all warnings
if (cfg.Rules?.SeverityOverrides?.Count > 0)
ApplySeverityOverrides(plan, cfg);
}
// Rule number → WarningType mapping for severity overrides
private static readonly Dictionary<int, string> RuleWarningTypes = new()
{
[1] = "Filter Operator", [2] = "Eager Index Spool", [3] = "Serial Plan",
[4] = "UDF Execution", [5] = "Row Estimate Mismatch", [6] = "Scalar UDF",
[7] = "Spill", [8] = "Parallel Skew", [9] = "Memory Grant",
[10] = "Key Lookup", [11] = "Scan With Predicate", [12] = "Non-SARGable Predicate",
[13] = "Data Type Mismatch", [14] = "Lazy Spool Ineffective", [15] = "Join OR Clause",
[16] = "Nested Loops High Executions", [17] = "Many-to-Many Merge Join",
[18] = "Compile Memory Exceeded", [19] = "High Compile CPU", [20] = "Local Variables",
[21] = "CTE Multiple References", [22] = "Table Variable", [23] = "Table-Valued Function",
[24] = "Top Above Scan", [25] = "Ineffective Parallelism", [26] = "Row Goal",
[27] = "Optimize For Unknown", [28] = "NOT IN with Nullable Column",
[29] = "Implicit Conversion", [30] = "Wide Index Suggestion",
[31] = "Parallel Wait Bottleneck"
};
// Reverse lookup: WarningType → rule number
private static readonly Dictionary<string, int> WarningTypeToRule;
static PlanAnalyzer()
{
WarningTypeToRule = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var (rule, type) in RuleWarningTypes)
WarningTypeToRule[type] = rule;
}
private static void ApplySeverityOverrides(ParsedPlan plan, AnalyzerConfig cfg)
{
foreach (var batch in plan.Batches)
{
foreach (var stmt in batch.Statements)
{
foreach (var w in stmt.PlanWarnings)
TryOverrideSeverity(w, cfg);
if (stmt.RootNode != null)
ApplyOverridesToTree(stmt.RootNode, cfg);
}
}
}
private static void ApplyOverridesToTree(PlanNode node, AnalyzerConfig cfg)
{
foreach (var w in node.Warnings)
TryOverrideSeverity(w, cfg);
foreach (var child in node.Children)
ApplyOverridesToTree(child, cfg);
}
private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg)
{
// Find the rule number for this warning type (partial match for flexibility)
int? ruleNumber = null;
foreach (var (rule, type) in RuleWarningTypes)
{
if (warning.WarningType.Contains(type, StringComparison.OrdinalIgnoreCase) ||
type.Contains(warning.WarningType, StringComparison.OrdinalIgnoreCase))
{
ruleNumber = rule;
break;
}
}
if (ruleNumber == null) return;
var overrideSeverity = cfg.GetSeverityOverride(ruleNumber.Value);
if (overrideSeverity == null) return;
if (Enum.TryParse<PlanWarningSeverity>(overrideSeverity, ignoreCase: true, out var severity))
warning.Severity = severity;
}
private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
{
// Rule 3: Serial plan with reason
if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason))
{
var reason = stmt.NonParallelPlanReason switch
{
"MaxDOPSetToOne" => "MAXDOP is set to 1",
"EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)",
"NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism",
"CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints",
"QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution",
_ => stmt.NonParallelPlanReason
};
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Serial Plan",
Message = $"Query running serially: {reason}.",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 9: Memory grant issues (statement-level)
if (!cfg.IsRuleDisabled(9) && stmt.MemoryGrant != null)
{
var grant = stmt.MemoryGrant;
// Excessive grant — granted far more than actually used
if (grant.GrantedMemoryKB > 0 && grant.MaxUsedMemoryKB > 0)
{
var wasteRatio = (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB;
if (wasteRatio >= 10 && grant.GrantedMemoryKB >= 1048576)
{
var grantMB = grant.GrantedMemoryKB / 1024.0;
var usedMB = grant.MaxUsedMemoryKB / 1024.0;
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Excessive Memory Grant",
Message = $"Granted {grantMB:N0} MB but only used {usedMB:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries.",
Severity = PlanWarningSeverity.Warning
});
}
}
// Grant wait — query had to wait for memory
if (grant.GrantWaitTimeMs > 0)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Memory Grant Wait",
Message = $"Query waited {grant.GrantWaitTimeMs:N0}ms for a memory grant before it could start running. Other queries were using all available workspace memory.",
Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
// Large memory grant with sort/hash guidance
if (grant.GrantedMemoryKB >= 1048576 && stmt.RootNode != null)
{
var consumers = new List<string>();
FindMemoryConsumers(stmt.RootNode, consumers);
var grantMB = grant.GrantedMemoryKB / 1024.0;
var guidance = consumers.Count > 0
? $" Memory consumers: {string.Join(", ", consumers)}. Check whether these operators are processing more rows than necessary."
: "";
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Large Memory Grant",
Message = $"Query granted {grantMB:F0} MB of memory.{guidance}",
Severity = grantMB >= 4096 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
// Rule 18: Compile memory exceeded (early abort)
if (!cfg.IsRuleDisabled(18) && stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded")
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Compile Memory Exceeded",
Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan is likely suboptimal. Simplify the query by breaking it into smaller steps using #temp tables.",
Severity = PlanWarningSeverity.Critical
});
}
// Rule 19: High compile CPU
if (!cfg.IsRuleDisabled(19) && stmt.CompileCPUMs >= 1000)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "High Compile CPU",
Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU just to compile a plan (before any data was read). Simplify the query by breaking it into smaller steps using #temp tables.",
Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
// Rule 4 (statement-level): UDF execution timing from QueryTimeStats
// Some plans report UDF timing only at the statement level, not per-node.
if (!cfg.IsRuleDisabled(4) && (stmt.QueryUdfCpuTimeMs > 0 || stmt.QueryUdfElapsedTimeMs > 0))
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
// Rule 20: Local variables without RECOMPILE
// Parameters with no CompiledValue are likely local variables — the optimizer
// cannot sniff their values and uses density-based ("unknown") estimates.
if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0)
{
var unsnifffedParams = stmt.Parameters
.Where(p => string.IsNullOrEmpty(p.CompiledValue))
.ToList();
if (unsnifffedParams.Count > 0)
{
var hasRecompile = stmt.StatementText.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase);
if (!hasRecompile)
{
var names = string.Join(", ", unsnifffedParams.Select(p => p.Name));
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Local Variables",
Message = $"Local variables detected: {names}. SQL Server cannot sniff local variable values at compile time, so it uses average density estimates instead of your actual values. Test with OPTION (RECOMPILE) to see if the plan improves. For a permanent fix, use dynamic SQL or a stored procedure to pass the values as parameters instead of local variables.",
Severity = PlanWarningSeverity.Warning
});
}
}
}
// Rule 21: CTE referenced multiple times
if (!cfg.IsRuleDisabled(21) && !string.IsNullOrEmpty(stmt.StatementText))
{
DetectMultiReferenceCte(stmt);
}
// Rule 27: OPTIMIZE FOR UNKNOWN in statement text
if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) &&
Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase))
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Optimize For Unknown",
Message = "OPTIMIZE FOR UNKNOWN uses average density estimates instead of sniffed parameter values. This can help when parameter sniffing causes plan instability, but may produce suboptimal plans for skewed data distributions.",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 25: Ineffective parallelism — DOP-aware efficiency scoring
// Efficiency = (speedup - 1) / (DOP - 1) * 100
// where speedup = CPU / Elapsed. At DOP 1 speedup=1 (0%), at DOP=speedup (100%).
// Rule 31: Parallel wait bottleneck — elapsed >> CPU means threads waiting, not working.
if (!cfg.IsRuleDisabled(25) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null)
{
var cpu = stmt.QueryTimeStats.CpuTimeMs;
var elapsed = stmt.QueryTimeStats.ElapsedTimeMs;
var dop = stmt.DegreeOfParallelism;
if (elapsed >= 1000 && cpu > 0)
{
var speedup = (double)cpu / elapsed;
var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0));
if (speedup < 0.5 && !cfg.IsRuleDisabled(31))
{
// CPU well below Elapsed: threads are waiting, not doing CPU work
var waitPct = (1.0 - speedup) * 100;
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Parallel Wait Bottleneck",
Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " +
$"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " +
$"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.",
Severity = PlanWarningSeverity.Warning
});
}
else if (efficiency < 40)
{
// CPU >= Elapsed but well below DOP potential — parallelism is ineffective
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Ineffective Parallelism",
Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " +
$"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " +
$"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.",
Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
}
// Rule 30: Missing index quality evaluation
if (!cfg.IsRuleDisabled(30))
{
// Detect duplicate suggestions for the same table
var tableSuggestionCount = stmt.MissingIndexes
.GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
foreach (var mi in stmt.MissingIndexes)
{
var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count;
var includeCount = mi.IncludeColumns.Count;
var tableKey = $"{mi.Schema}.{mi.Table}";
// Low-impact suggestion (< 25% improvement)
if (mi.Impact < 25)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Low Impact Index",
Message = $"Missing index suggestion for {mi.Table} has only {mi.Impact:F0}% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement.",
Severity = PlanWarningSeverity.Info
});
}
// Wide INCLUDE columns (> 5)
if (includeCount > 5)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Wide Index Suggestion",
Message = $"Missing index suggestion for {mi.Table} has {includeCount} INCLUDE columns. This is a \"kitchen sink\" index — SQL Server suggests covering every column the query touches, but the resulting index would be very wide and expensive to maintain. Evaluate which columns are actually needed, or consider a narrower index with fewer includes.",
Severity = PlanWarningSeverity.Warning
});
}
// Wide key columns (> 4)
else if (keyCount > 4)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Wide Index Suggestion",
Message = $"Missing index suggestion for {mi.Table} has {keyCount} key columns ({mi.EqualityColumns.Count} equality + {mi.InequalityColumns.Count} inequality). Wide key columns increase index size and maintenance cost. Evaluate whether all key columns are needed for seek predicates.",
Severity = PlanWarningSeverity.Warning
});
}
// Multiple suggestions for same table
if (tableSuggestionCount.TryGetValue(tableKey, out var count))
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Duplicate Index Suggestions",
Message = $"{count} missing index suggestions target {mi.Table}. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them.",
Severity = PlanWarningSeverity.Warning
});
// Only warn once per table
tableSuggestionCount.Remove(tableKey);
}
}
}
// Rule 22 (statement-level): Table variable warnings
// Walk the tree to find table variable references, then emit statement-level warnings
if (!cfg.IsRuleDisabled(22) && stmt.RootNode != null)
{
var hasTableVar = false;
var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE";
var modifiesTableVar = false;
CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar);
if (hasTableVar)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Table Variable",
Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
Severity = PlanWarningSeverity.Warning
});
}
if (modifiesTableVar)
{
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Table Variable",
Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.",
Severity = PlanWarningSeverity.Critical
});
}
}
}
private static void CheckForTableVariables(PlanNode node, bool isModification,
ref bool hasTableVar, ref bool modifiesTableVar)
{
if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
{
hasTableVar = true;
// The modification target is typically an Insert/Update/Delete operator on a table variable
if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
|| node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
|| node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase)
|| node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)))
{
modifiesTableVar = true;
}
}
foreach (var child in node.Children)
CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar);
}
private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg)
{
AnalyzeNode(node, stmt, cfg);
foreach (var child in node.Children)
AnalyzeNodeTree(child, stmt, cfg);
}
private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg)
{
// Rule 1: Filter operators — rows survived the tree just to be discarded
// Quantify the impact by summing child subtree cost (reads, CPU, time).
if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
{
var impact = QuantifyFilterImpact(node);
var predicate = Truncate(node.Predicate, 200);
var message = "Filter operator discarding rows late in the plan.";
if (!string.IsNullOrEmpty(impact))
message += $"\n{impact}";
message += $"\nPredicate: {predicate}";
node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
Message = message,
Severity = PlanWarningSeverity.Warning
});
}
// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
if (!cfg.IsRuleDisabled(2) && node.LogicalOp == "Eager Spool" &&
node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase))
{
var message = "SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely.";
if (!string.IsNullOrEmpty(node.SuggestedIndex))
message += $"\n\nCreate this index:\n{node.SuggestedIndex}";
node.Warnings.Add(new PlanWarning
{
WarningType = "Eager Index Spool",
Message = message,
Severity = PlanWarningSeverity.Critical
});
}
// Rule 4: UDF timing — any node spending time in UDFs (actual plans)
if (!cfg.IsRuleDisabled(4) && (node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0))
{
node.Warnings.Add(new PlanWarning
{
WarningType = "UDF Execution",
Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump the query results to a #temp table first and apply the UDF only to the final result set.",
Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
// Rule 5: Large estimate vs actual row gaps (actual plans only)
// Only warn when the bad estimate actually causes observable harm:
// - The node itself spilled (Sort/Hash with bad memory grant)
// - 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
if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0)
{
if (node.ActualRows == 0)
{
// Zero rows is always worth noting — resources were allocated for nothing
if (node.EstimateRows >= 100)
{
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Estimate Mismatch",
Message = $"Estimated {node.EstimateRows:N0} rows but actual 0 rows returned. SQL Server allocated resources for rows that never materialized.",
Severity = PlanWarningSeverity.Warning
});
}
}
else
{
// Compare per-execution actuals to estimates (SQL Server estimates are per-execution)
var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1;
var actualPerExec = (double)node.ActualRows / executions;
var ratio = actualPerExec / node.EstimateRows;
if (ratio >= 10.0 || ratio <= 0.1)
{
var harm = AssessEstimateHarm(node, ratio);
if (harm != null)
{
var direction = ratio >= 10.0 ? "underestimated" : "overestimated";
var factor = ratio >= 10.0 ? ratio : 1.0 / ratio;
var actualDisplay = executions > 1
? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)"
: $"Actual {node.ActualRows:N0}";
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Estimate Mismatch",
Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}",
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
}
}
// Rule 6: Scalar UDF references (works on estimated plans too)
if (!cfg.IsRuleDisabled(6))
foreach (var udf in node.ScalarUdfs)
{
var type = udf.IsClrFunction ? "CLR" : "T-SQL";
node.Warnings.Add(new PlanWarning
{
WarningType = "Scalar UDF",
Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 7: Spill detection — calculate operator time and set severity
// based on what percentage of statement elapsed time the spill accounts for.
// Exchange spills on Parallelism operators get special handling since their
// timing is unreliable but the write count tells the story.
if (!cfg.IsRuleDisabled(7))
foreach (var w in node.Warnings.ToList())
{
if (w.SpillDetails == null)
continue;
var isExchangeSpill = w.SpillDetails.SpillType == "Exchange";
if (isExchangeSpill)
{
// Exchange spills: severity based on write count since timing is unreliable
var writes = w.SpillDetails.WritesToTempDb;
if (writes >= 1_000_000)
w.Severity = PlanWarningSeverity.Critical;
else if (writes >= 10_000)
w.Severity = PlanWarningSeverity.Warning;
// Surface Parallelism operator time when available (actual plans)
if (node.ActualElapsedMs > 0)
{
var operatorMs = GetParallelismOperatorElapsedMs(node);
var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0;
if (stmtMs > 0 && operatorMs > 0)
{
var pct = (double)operatorMs / stmtMs;
w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement).";
}
}
}
else if (node.ActualElapsedMs > 0)
{
// Sort/Hash spills: severity based on operator time percentage
var operatorMs = GetOperatorOwnElapsedMs(node);
var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0;
if (stmtMs > 0)
{
var pct = (double)operatorMs / stmtMs;
w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement).";
if (pct >= 0.5)
w.Severity = PlanWarningSeverity.Critical;
else if (pct >= 0.1)
w.Severity = PlanWarningSeverity.Warning;
}
}
}
// Rule 8: Parallel thread skew (actual plans with per-thread stats)
// Only warn when there are enough rows to meaningfully distribute across threads
// Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators
if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1)
{
var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList();
if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback
var totalRows = workerThreads.Sum(t => t.ActualRows);
var minRowsForSkew = workerThreads.Count * 1000;
if (totalRows >= minRowsForSkew)
{
var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First();
var skewRatio = (double)maxThread.ActualRows / totalRows;
// At DOP 2, a 60/40 split is normal — use higher threshold
var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50;
if (skewRatio >= skewThreshold)
{
var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much.";
var severity = PlanWarningSeverity.Warning;
// Batch mode sorts produce all output on a single thread by design
// unless their parent is a batch mode Window Aggregate
if (node.PhysicalOp == "Sort"
&& (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch"
&& node.Parent?.PhysicalOp != "Window Aggregate")
{
message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate.";
severity = PlanWarningSeverity.Info;
}
node.Warnings.Add(new PlanWarning
{
WarningType = "Parallel Skew",
Message = message,
Severity = severity
});
}
}
}
// Rule 10: Key Lookup / RID Lookup with residual predicate
// Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true
if (!cfg.IsRuleDisabled(10) && node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase))
{
var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table.";
if (!string.IsNullOrEmpty(node.Predicate))
message += $" Predicate: {Truncate(node.Predicate, 200)}";
node.Warnings.Add(new PlanWarning
{
WarningType = "RID Lookup",
Message = message,
Severity = PlanWarningSeverity.Warning
});
}
else if (!cfg.IsRuleDisabled(10) && node.Lookup && !string.IsNullOrEmpty(node.Predicate))
{
node.Warnings.Add(new PlanWarning
{
WarningType = "Key Lookup",
Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column.\nPredicate: {Truncate(node.Predicate, 200)}",
Severity = PlanWarningSeverity.Critical
});
}
// Rule 12: Non-SARGable predicate on scan
var nonSargableReason = cfg.IsRuleDisabled(12) ? null : DetectNonSargablePredicate(node);
if (nonSargableReason != null)
{
var nonSargableAdvice = nonSargableReason switch
{
"Implicit conversion (CONVERT_IMPLICIT)" =>
"Implicit conversion (CONVERT_IMPLICIT) prevents an index seek. Match the parameter or variable data type to the column data type.",
"ISNULL/COALESCE wrapping column" =>
"ISNULL/COALESCE wrapping a column prevents an index seek. Rewrite the predicate to avoid wrapping the column, e.g. use \"WHERE col = @val OR col IS NULL\" instead of \"WHERE ISNULL(col, '') = @val\".",
"Leading wildcard LIKE pattern" =>
"Leading wildcard LIKE prevents an index seek — SQL Server must scan every row. If substring search performance is critical, consider a full-text index or a trigram-based approach.",
"CASE expression in predicate" =>
"CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.",
_ when nonSargableReason.StartsWith("Function call") =>
$"{nonSargableReason} prevents an index seek. Remove the function from the column side — apply it to the parameter instead, or create a computed column with the expression and index that.",
_ =>
$"{nonSargableReason} prevents an index seek, forcing a scan."
};
node.Warnings.Add(new PlanWarning
{
WarningType = "Non-SARGable Predicate",
Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 11: Scan with residual predicate (skip if non-SARGable already flagged)
// A PROBE() alone is just a bitmap filter — not a real residual predicate.
if (!cfg.IsRuleDisabled(11) && nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) &&
!IsProbeOnly(node.Predicate))
{
var displayPredicate = StripProbeExpressions(node.Predicate);
node.Warnings.Add(new PlanWarning
{
WarningType = "Scan With Predicate",
Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: {Truncate(displayPredicate, 200)}",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert)
if (!cfg.IsRuleDisabled(13) && node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues))
{
var hasMismatch = node.DefinedValues.Contains("GetRangeWithMismatchedTypes", StringComparison.OrdinalIgnoreCase);
var hasConvert = node.DefinedValues.Contains("GetRangeThroughConvert", StringComparison.OrdinalIgnoreCase);
if (hasMismatch || hasConvert)
{
var reason = hasMismatch
? "Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column."
: "CONVERT/CAST wrapping a column in the predicate. SQL Server is converting every row to compare, preventing index seeks. Match your data types — convert the parameter/literal instead of the column.";
node.Warnings.Add(new PlanWarning
{
WarningType = "Data Type Mismatch",
Message = reason,
Severity = PlanWarningSeverity.Warning
});
}
}
// Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio
// Rebinds = cache misses (child re-executes), rewinds = cache hits (reuse cached result)
// Exclude Lazy Index Spools: they cache by correlated parameter value (like a hash table)
// so rebind/rewind counts are unreliable. See https://www.sql.kiwi/2025/02/lazy-index-spool/
if (!cfg.IsRuleDisabled(14) && node.LogicalOp == "Lazy Spool"
&& !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase))
{
var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds;
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
var source = node.HasActualStats ? "actual" : "estimated";
if (rebinds > 100 && rewinds < rebinds * 5)
{
var severity = rewinds < rebinds
? PlanWarningSeverity.Critical
: PlanWarningSeverity.Warning;
var ratio = rewinds > 0
? $"{rewinds / rebinds:F1}x rewinds (cache hits) per rebind (cache miss)"
: "no rewinds (cache hits) at all";
node.Warnings.Add(new PlanWarning
{
WarningType = "Lazy Spool Ineffective",
Message = $"Lazy spool has low cache hit ratio ({source}): {rebinds:N0} rebinds (cache misses), {rewinds:N0} rewinds (cache hits) — {ratio}. The spool is caching results but rarely reusing them, adding overhead for no benefit.",
Severity = severity
});
}
}
// Rule 15: Join OR clause
// Pattern: Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation → [Compute Scalar] → 2+ Constant Scans
if (!cfg.IsRuleDisabled(15) && node.PhysicalOp == "Concatenation")
{
var constantScanBranches = node.Children
.Count(c => c.PhysicalOp == "Constant Scan" ||
(c.PhysicalOp == "Compute Scalar" &&
c.Children.Any(gc => gc.PhysicalOp == "Constant Scan")));
if (constantScanBranches >= 2 && IsOrExpansionChain(node))
{
node.Warnings.Add(new PlanWarning
{
WarningType = "Join OR Clause",
Message = $"OR in a join predicate. SQL Server rewrote the OR as {constantScanBranches} separate lookups, each evaluated independently — this multiplies the work on the inner side. Rewrite as separate queries joined with UNION ALL. For example, change \"FROM a JOIN b ON a.x = b.x OR a.y = b.y\" to \"FROM a JOIN b ON a.x = b.x UNION ALL FROM a JOIN b ON a.y = b.y\".",
Severity = PlanWarningSeverity.Warning
});
}
}
// Rule 16: Nested Loops high inner-side execution count
// Deep analysis: combine execution count + outer estimate mismatch + inner cost
if (!cfg.IsRuleDisabled(16) && node.PhysicalOp == "Nested Loops" &&
node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) &&
!node.IsAdaptive &&
node.Children.Count >= 2)
{
var outerChild = node.Children[0];
var innerChild = node.Children[1];
if (innerChild.HasActualStats && innerChild.ActualExecutions > 100000)
{
var dop = stmt.DegreeOfParallelism > 0 ? stmt.DegreeOfParallelism : 1;
var details = new List<string>();
// Core fact
details.Add($"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop}).");
// Outer side estimate mismatch — explains WHY the optimizer chose NL
if (outerChild.HasActualStats && outerChild.EstimateRows > 0)
{
var outerExecs = outerChild.ActualExecutions > 0 ? outerChild.ActualExecutions : 1;
var outerActualPerExec = (double)outerChild.ActualRows / outerExecs;
var outerRatio = outerActualPerExec / outerChild.EstimateRows;
if (outerRatio >= 10.0)
{
details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualPerExec:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations.");
}
}
// Inner side cost — reads and time spent doing the repeated work
long innerReads = SumSubtreeReads(innerChild);
if (innerReads > 0)
details.Add($"Inner side total: {innerReads:N0} logical reads.");
if (innerChild.ActualElapsedMs > 0)
{
var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0;
if (stmtMs > 0)
{
var pct = (double)innerChild.ActualElapsedMs / stmtMs * 100;
details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms ({pct:N0}% of statement).");
}
else
{
details.Add($"Inner side time: {innerChild.ActualElapsedMs:N0}ms.");
}
}
// Cause/recommendation
var hasParams = stmt.Parameters.Count > 0;
if (hasParams)
details.Add("This may be caused by parameter sniffing — the optimizer chose Nested Loops based on a sniffed value that produced far fewer outer rows.");
else
details.Add("Consider whether a hash or merge join would be more appropriate for this row count.");
node.Warnings.Add(new PlanWarning
{
WarningType = "Nested Loops High Executions",
Message = string.Join(" ", details),
Severity = innerChild.ActualExecutions > 1000000
? PlanWarningSeverity.Critical
: PlanWarningSeverity.Warning
});
}
// Estimated plans: the optimizer knew the row count and chose Nested Loops
// deliberately — don't second-guess it without actual execution data.
}
// Rule 17: Many-to-many Merge Join
// In actual plans, the Merge Join operator reports logical reads when the worktable is used.
// When ActualLogicalReads is 0, the worktable wasn't hit and the warning is noise.
if (!cfg.IsRuleDisabled(17) && node.ManyToMany && node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase) &&
(!node.HasActualStats || node.ActualLogicalReads > 0))
{
node.Warnings.Add(new PlanWarning
{
WarningType = "Many-to-Many Merge Join",
Message = node.HasActualStats
? $"Many-to-many Merge Join — SQL Server created a worktable in TempDB ({node.ActualLogicalReads:N0} logical reads) because both sides have duplicate values in the join columns."
: "Many-to-many Merge Join — SQL Server will create a worktable in TempDB because both sides have duplicate values in the join columns.",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 22: Table variables (Object name starts with @)
if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) &&
node.ObjectName.StartsWith("@"))
{
node.Warnings.Add(new PlanWarning
{
WarningType = "Table Variable",
Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 23: Table-valued functions
if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function")
{
var funcName = node.ObjectName ?? node.PhysicalOp;
node.Warnings.Add(new PlanWarning
{
WarningType = "Table-Valued Function",
Message = $"Table-valued function: {funcName}. Multi-statement TVFs have no statistics — SQL Server guesses 1 row (pre-2017) or 100 rows (2017+) regardless of actual size. Rewrite as an inline table-valued function if possible, or dump the function results into a #temp table and join to that instead.",
Severity = PlanWarningSeverity.Warning
});
}
// Rule 24: Top above a scan
// Detects Top or Top N Sort operators feeding from a scan. This often means the
// query is scanning the entire table/index and sorting just to return a few rows,
// when an appropriate index could satisfy the request directly.
if (!cfg.IsRuleDisabled(24))
{
var isTop = node.PhysicalOp == "Top";
var isTopNSort = node.LogicalOp == "Top N Sort";
if ((isTop || isTopNSort) && node.Children.Count > 0)
{
// Walk through pass-through operators below the Top to find the scan
var scanCandidate = node.Children[0];
while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism")
&& scanCandidate.Children.Count > 0)
scanCandidate = scanCandidate.Children[0];
if (IsScanOperator(scanCandidate))
{
var topLabel = isTopNSort ? "Top N Sort" : "Top";
var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2
&& node.Parent.Children[1] == node;
var innerNote = onInner
? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row."
: "";
var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate)
? " The scan has a residual predicate, so it may read many rows before the Top is satisfied."
: "";
node.Warnings.Add(new PlanWarning
{
WarningType = "Top Above Scan",
Message = $"{topLabel} reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}).{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.",
Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
}
// Rule 26: Row Goal (informational) — optimizer reduced estimate due to TOP/EXISTS/IN
// Only surface on data access operators (seeks/scans) where the row goal actually matters
var isDataAccess = node.PhysicalOp != null &&
(node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek"));
if (!cfg.IsRuleDisabled(26) && isDataAccess &&
node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 &&
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
{
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Goal",
Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.",
Severity = PlanWarningSeverity.Info
});
}
// Rule 28: Row Count Spool — NOT IN with nullable column
// Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate,
// and statement text contains NOT IN
if (!cfg.IsRuleDisabled(28) && node.PhysicalOp.Contains("Row Count Spool"))
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
{
node.Warnings.Add(new PlanWarning
{
WarningType = "NOT IN with Nullable Column",
Message = $"Row Count Spool with {rewinds:N0} rewinds. This pattern occurs when NOT IN is used with a nullable column — SQL Server cannot use an efficient Anti Semi Join because it must check for NULL values on every outer row. Rewrite as NOT EXISTS, or add WHERE column IS NOT NULL to the subquery.",
Severity = rewinds > 1_000_000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
// Rule 29: Enhance implicit conversion warnings — Seek Plan is more severe
if (!cfg.IsRuleDisabled(29))
foreach (var w in node.Warnings.ToList())
{
if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan"))
{
w.Severity = PlanWarningSeverity.Critical;
w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}";
}
}
}
/// <summary>
/// Detects the NOT IN with nullable column pattern: statement has NOT IN,
/// and a nearby Nested Loops Anti Semi Join has an IS NULL residual predicate.
/// Checks ancestors and their children (siblings of ancestors) since the IS NULL
/// predicate may be on a sibling Anti Semi Join rather than a direct parent.
/// </summary>
private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt)
{
// Check statement text for NOT IN
if (string.IsNullOrEmpty(stmt.StatementText) ||
!Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase))
return false;
// Walk up the tree checking ancestors and their children
var parent = spoolNode.Parent;
while (parent != null)
{
if (IsAntiSemiJoinWithIsNull(parent))
return true;
// Check siblings: the IS NULL predicate may be on a sibling Anti Semi Join
// (e.g. outer NL Anti Semi Join has two children: inner NL Anti Semi Join + Row Count Spool)
foreach (var sibling in parent.Children)
{
if (sibling != spoolNode && IsAntiSemiJoinWithIsNull(sibling))
return true;
}
parent = parent.Parent;