Skip to content

Commit 625f5fd

Browse files
committed
Improve stored procedure model and forwarding heuristics
1 parent ee2d81c commit 625f5fd

6 files changed

Lines changed: 546 additions & 53 deletions

File tree

src/CodeGenerators/Models/ModelGenerator.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,22 @@ public async Task<SourceText> GetModelTextForStoredProcedureAsync(Definition.Sch
4545
var resultColumns = currentSet?.Columns?.ToList() ?? [];
4646
var hasResultColumns = resultColumns.Any();
4747

48+
// Suppression-Heuristik: CRUD-Prozedur mit genau 1 Spalte vom Typ nvarchar(max), keine JsonRootProperty, keine echte Tabularstruktur
49+
// und die Spalte stammt aus einem Subselect (flacher JsonPath oder identisch mit Name) => kein Model generieren, nur Output.
50+
var spNameLower = storedProcedure.Name.ToLowerInvariant();
51+
bool isCrudVerbName = spNameLower.Contains("create") || spNameLower.Contains("update") || spNameLower.Contains("delete") || spNameLower.Contains("merge") || spNameLower.Contains("upsert");
52+
bool singlePseudoColumn = hasResultColumns && resultColumns.Count == 1;
53+
var loneCol = singlePseudoColumn ? resultColumns[0] : null;
54+
bool loneIsNVarChar = loneCol != null && (loneCol.SqlTypeName?.StartsWith("nvarchar", System.StringComparison.OrdinalIgnoreCase) ?? false);
55+
bool noJsonRoot = !(currentSet?.JsonRootProperty?.Length > 0);
56+
bool flatJsonPath = loneCol != null && (string.IsNullOrWhiteSpace(loneCol.JsonPath) || string.Equals(loneCol.JsonPath, loneCol.Name, System.StringComparison.OrdinalIgnoreCase));
57+
bool legacyJsonSentinel = loneCol != null && loneCol.Name.Equals("JSON_F52E2B61-18A1-11d1-B105-00805F49916B", System.StringComparison.OrdinalIgnoreCase);
58+
if (isCrudVerbName && singlePseudoColumn && loneIsNVarChar && noJsonRoot && flatJsonPath && !legacyJsonSentinel)
59+
{
60+
consoleService.Verbose($"[model-skip] Suppressed model for CRUD '{storedProcedure.Name}' (pseudo single nvarchar column) – using Output only.");
61+
return null; // keine Model-Erzeugung
62+
}
63+
4864
// Heuristic: Legacy FOR JSON output (single synthetic column) -> treat as raw JSON
4965
// Detection: exactly one column, name = JSON_F52E2B61-18A1-11d1-B105-00805F49916B (case-insensitive), nvarchar(max)
5066
var currentSetReturnsJson = currentSet?.ReturnsJson ?? false;
@@ -454,6 +470,20 @@ private async Task WriteSingleModelAsync(Definition.Schema schema, Definition.St
454470
var currentSetReturnsJson = currentSet.ReturnsJson;
455471
var hasResultCols = (currentSet.Columns?.Any() ?? false);
456472
var isScalarResultCols = hasResultCols && !currentSetReturnsJson && currentSet.Columns.Count == 1;
473+
// Gleiche Suppression-Heuristik wie oben anwenden, damit WriteSingleModelAsync keine Datei schreibt.
474+
var spNameLower2 = storedProcedure.Name.ToLowerInvariant();
475+
bool isCrudVerbName2 = spNameLower2.Contains("create") || spNameLower2.Contains("update") || spNameLower2.Contains("delete") || spNameLower2.Contains("merge") || spNameLower2.Contains("upsert");
476+
bool singlePseudoColumn2 = hasResultCols && currentSet.Columns.Count == 1;
477+
var loneCol2 = singlePseudoColumn2 ? currentSet.Columns[0] : null;
478+
bool loneIsNVarChar2 = loneCol2 != null && (loneCol2.SqlTypeName?.StartsWith("nvarchar", System.StringComparison.OrdinalIgnoreCase) ?? false);
479+
bool noJsonRoot2 = !(currentSet?.JsonRootProperty?.Length > 0);
480+
bool flatJsonPath2 = loneCol2 != null && (string.IsNullOrWhiteSpace(loneCol2.JsonPath) || string.Equals(loneCol2.JsonPath, loneCol2.Name, System.StringComparison.OrdinalIgnoreCase));
481+
bool legacyJsonSentinel2 = loneCol2 != null && loneCol2.Name.Equals("JSON_F52E2B61-18A1-11d1-B105-00805F49916B", System.StringComparison.OrdinalIgnoreCase);
482+
if (isCrudVerbName2 && singlePseudoColumn2 && loneIsNVarChar2 && noJsonRoot2 && flatJsonPath2 && !legacyJsonSentinel2)
483+
{
484+
consoleService.Verbose($"[model-skip] Suppressed model write for CRUD '{storedProcedure.Name}' (pseudo single nvarchar column) – Output only.");
485+
return; // nichts schreiben
486+
}
457487
// Reverted: previously single-column non-JSON result sets were skipped entirely to reduce trivial DTO noise.
458488
// External projects rely on a stable model presence even for scalar result sets, so we now ALWAYS generate the
459489
// model (it will contain a single property). If future suppression is desired, introduce a config flag instead.

src/CodeGenerators/Models/StoredProcedureGenerator.cs

Lines changed: 117 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,23 @@ public async Task<SourceText> GetStoredProcedureExtensionsCodeAsync(Definition.S
6464
// Determine if any stored procedure in this group actually produces a model (skip pure scalar non-JSON procs)
6565
bool NeedsModel(Definition.StoredProcedure sp)
6666
{
67-
var set = sp.ResultSets?.FirstOrDefault();
68-
if (set == null) return false; // zero-result => no model
69-
var returnsJson = set.ReturnsJson;
70-
var hasCols = set.Columns?.Any() ?? false;
71-
var scalarNonJson = hasCols && !returnsJson && set.Columns.Count == 1; // skipped by model generator
72-
if (!returnsJson && scalarNonJson) return false;
73-
return true; // multi-col, json, or other tabular
67+
if (sp.ResultSets == null || sp.ResultSets.Count == 0) return false;
68+
// Primäres Set = erstes JSON sonst erstes
69+
var primary = sp.ResultSets.FirstOrDefault(r => r.ReturnsJson) ?? sp.ResultSets.First();
70+
if (primary == null) return false;
71+
if (primary.ReturnsJson) return true; // JSON immer Modell (auch bei Column=0 für Deserialize)
72+
var cols = primary.Columns?.Count ?? 0;
73+
if (cols == 0) return false;
74+
if (cols == 1)
75+
{
76+
// Einzelspalte non-JSON -> nur Modell wenn nicht Skalar nvarchar(max) CRUD Pseudo
77+
var c = primary.Columns[0];
78+
bool isNVarChar = (c.SqlTypeName?.StartsWith("nvarchar", StringComparison.OrdinalIgnoreCase) ?? false);
79+
// Wenn es weitere Sets gibt und eines JSON ist -> wir brauchen das Modell falls dieses Set nicht das JSON ist
80+
bool hasOtherJson = sp.ResultSets.Any(r => r != primary && r.ReturnsJson);
81+
if (isNVarChar && !hasOtherJson) return false; // rein skalar
82+
}
83+
return true;
7484
}
7585

7686
var needsModelUsing = storedProcedures.Any(NeedsModel);
@@ -112,9 +122,12 @@ bool NeedsModel(Definition.StoredProcedure sp)
112122
root = root.WithUsings([.. usings]);
113123

114124
// Conditionally add outputs usings (root + schema) if any proc has OUTPUT parameters
115-
var baseOutputSkip = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "@ResultId", "@RecordId", "@RowVersion", "@Result" };
125+
// Normalisierte Skip-Liste (ohne '@'), damit uneinheitliche Metadaten-Namensgebung (mit/ohne '@') konsistent behandelt wird.
126+
var baseOutputSkip = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "ResultId", "RecordId", "RowVersion", "Result" };
127+
// Helper zur Normalisierung eines OUTPUT-Namens
128+
string NormalizeOutputName(string name) => (name ?? string.Empty).TrimStart('@');
116129
bool HasAnyOutputs = storedProcedures.Any(sp => sp.GetOutputs()?.Any() ?? false);
117-
bool HasCustomOutputs = storedProcedures.Any(sp => (sp.GetOutputs()?.Count(o => !baseOutputSkip.Contains(o.Name)) ?? 0) > 0);
130+
bool HasCustomOutputs = storedProcedures.Any(sp => (sp.GetOutputs()?.Count(o => !baseOutputSkip.Contains(NormalizeOutputName(o.Name))) ?? 0) > 0);
118131
// Für Extension-Rollen werden zwar keine Bootstrap-Outputs.cs Dateien erzeugt, aber individuelle Output-Klassen (Schema) werden generiert.
119132
// Daher benötigen die StoredProcedure-Extensions auch bei Extension-Rollen ein using auf das lokale Schema-Outputs-Namespace, damit
120133
// z.B. OrganizationUpdateIsDeletedOutput aufgelöst wird.
@@ -328,15 +341,85 @@ private MethodDeclarationSyntax GenerateStoredProcedureMethodText(MethodDeclarat
328341
var returnType = "Task<CrudResult>";
329342
var returnModel = "CrudResult";
330343

331-
var firstSet = storedProcedure.ResultSets?.FirstOrDefault();
344+
// Primäres Set bestimmen: bevorzuge erstes NICHT-ExecSource Platzhalter-Set, sonst erstes.
345+
var firstSet = storedProcedure.ResultSets == null
346+
? null
347+
: storedProcedure.ResultSets.FirstOrDefault(rs => string.IsNullOrEmpty(rs.ExecSourceProcedureName))
348+
?? storedProcedure.ResultSets.FirstOrDefault();
332349
var isJson = firstSet?.ReturnsJson ?? false;
333350
var isJsonArray = isJson && (firstSet?.ReturnsJsonArray ?? false);
351+
// Forwarding Referenz-only: genau ein Set, kein JSON, Columns leer, ExecSource gesetzt -> Ziel auflösen für Modellwahl
352+
bool isReferenceOnlyForward = false;
353+
string forwardSchema = null; string forwardProc = null;
354+
if (storedProcedure.ResultSets?.Count == 1 && firstSet != null && !isJson && (firstSet.Columns == null || firstSet.Columns.Count == 0)
355+
&& !string.IsNullOrEmpty(firstSet.ExecSourceProcedureName))
356+
{
357+
isReferenceOnlyForward = true;
358+
forwardSchema = firstSet.ExecSourceSchemaName;
359+
forwardProc = firstSet.ExecSourceProcedureName;
360+
}
361+
if (isReferenceOnlyForward && !string.IsNullOrWhiteSpace(forwardSchema) && !string.IsNullOrWhiteSpace(forwardProc))
362+
{
363+
try
364+
{
365+
var schemasMeta = metadataProvider.GetSchemas();
366+
var targetSchema = schemasMeta.FirstOrDefault(s => s.Name.Equals(forwardSchema, StringComparison.OrdinalIgnoreCase));
367+
var targetSp = targetSchema?.StoredProcedures?.FirstOrDefault(sp => sp.Name.Equals(forwardProc, StringComparison.OrdinalIgnoreCase));
368+
if (targetSp != null)
369+
{
370+
var rs0 = targetSp.Content?.ResultSets?.FirstOrDefault();
371+
if (rs0 != null)
372+
{
373+
firstSet = rs0; // nutze echte Struktur für Rückgabeheuristik
374+
isJson = rs0.ReturnsJson;
375+
isJsonArray = isJson && rs0.ReturnsJsonArray;
376+
}
377+
}
378+
}
379+
catch { /* best effort forward resolve */ }
380+
}
381+
// Heuristik: Einige CRUD Procs werden fälschlich als JSON erkannt, obwohl ein einziger nvarchar(max)-Wert (z.B. Subselect) ausgegeben wird.
382+
// Kriterien für Rückstufung: Name enthält CRUD Verb, genau 1 Column, keine explizite JsonRootProperty, Column-Name kein FOR JSON Sentinel,
383+
// Column.SqlTypeName beginnt mit nvarchar, Column.JsonPath == Column.Name (keine verschachtelte Struktur).
384+
if (isJson && firstSet != null)
385+
{
386+
var colCount = firstSet.Columns?.Count ?? 0;
387+
if (colCount == 1)
388+
{
389+
var spNameLower = storedProcedure.Name.ToLowerInvariant();
390+
bool crudName = spNameLower.Contains("create") || spNameLower.Contains("update") || spNameLower.Contains("delete") || spNameLower.Contains("merge") || spNameLower.Contains("upsert");
391+
var col = firstSet.Columns[0];
392+
bool isNVarChar = (col.SqlTypeName?.StartsWith("nvarchar", StringComparison.OrdinalIgnoreCase) ?? false);
393+
bool isLegacyJsonSentinel = col.Name.Equals("JSON_F52E2B61-18A1-11d1-B105-00805F49916B", StringComparison.OrdinalIgnoreCase);
394+
bool hasRoot = !string.IsNullOrWhiteSpace(firstSet.JsonRootProperty);
395+
bool flatPath = string.Equals(col.JsonPath, col.Name, StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(col.JsonPath);
396+
if (crudName && isNVarChar && !isLegacyJsonSentinel && !hasRoot && flatPath)
397+
{
398+
// Rückstufung: Behandle als Nicht-JSON -> korrigiere Flags für nachfolgende Logik.
399+
isJson = false;
400+
isJsonArray = false;
401+
}
402+
}
403+
}
334404

335405
// Only the pipe variant of a JSON Deserialize method performs the awaited deserialization; the context overload delegates.
336406
var requiresAsync = isJson && kind == StoredProcedureMethodKind.Deserialize && !isOverload;
337407

338408
var rawJson = false;
339-
if (isJson && kind == StoredProcedureMethodKind.Raw)
409+
// Sonderfall: mehrere ResultSets, genau ein JSON Set -> treat as JSON primary
410+
var totalSets = storedProcedure.ResultSets?.Count ?? 0;
411+
var jsonSetCount = storedProcedure.ResultSets?.Count(rs => rs.ReturnsJson) ?? 0;
412+
bool singleJsonAmongMultiple = totalSets > 1 && jsonSetCount == 1 && isJson;
413+
if ((isReferenceOnlyForward || singleJsonAmongMultiple) && isJson && kind == StoredProcedureMethodKind.Raw)
414+
{
415+
// Referenz-only: Raw-Methode soll weiterhin string liefern (Durchreichen), aber nachfolgende Deserialize Methode nutzt Zielmodell.
416+
rawJson = true;
417+
returnType = "Task<string>";
418+
returnExpression = returnExpression
419+
.Replace("ExecuteSingleAsync<CrudResult>", "ReadJsonAsync")
420+
.Replace("ExecuteListAsync<CrudResult>", "ReadJsonAsync");
421+
}
422+
else if (isJson && kind == StoredProcedureMethodKind.Raw)
340423
{
341424
rawJson = true;
342425
// Raw JSON keeps Task<string> and we call ReadJsonAsync
@@ -428,12 +511,26 @@ string ReplacePlaceholder(string expr, string replacement)
428511
}
429512
else
430513
{
431-
var firstSet2 = storedProcedure.ResultSets?.FirstOrDefault();
514+
var firstSet2 = firstSet; // verwende primäres Set für Typheuristik
432515
var columnCount = firstSet2?.Columns?.Count ?? 0;
433516
var hasTabularResult = columnCount > 0;
434517
var hasOutputs = storedProcedure.HasOutputs();
435-
var baseOutputPropSkip = new[] { "@ResultId", "@RecordId", "@RowVersion", "@Result" };
436-
var customOutputCount = storedProcedure.GetOutputs()?.Count(o => !baseOutputPropSkip.Contains(o.Name, StringComparer.OrdinalIgnoreCase)) ?? 0;
518+
// Normalisierte Skip-Liste (ohne '@') für konsistente Erkennung
519+
var baseOutputPropSkip = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "ResultId", "RecordId", "RowVersion", "Result" };
520+
int customOutputCount = storedProcedure.GetOutputs()?.Count(o => !baseOutputPropSkip.Contains(o.Name.TrimStart('@'))) ?? 0;
521+
// Pseudo-Tabular? Einzelne nvarchar(max)-Spalte ohne Root/komplexe Struktur bei CRUD -> nicht als echtes Tabular behandeln
522+
bool isCrudName = storedProcedure.Name.ToLowerInvariant().Contains("create") || storedProcedure.Name.ToLowerInvariant().Contains("update") || storedProcedure.Name.ToLowerInvariant().Contains("delete") || storedProcedure.Name.ToLowerInvariant().Contains("merge") || storedProcedure.Name.ToLowerInvariant().Contains("upsert");
523+
bool singlePseudoColumn = columnCount == 1;
524+
var lone = singlePseudoColumn ? firstSet2?.Columns?.FirstOrDefault() : null;
525+
bool loneIsNVarChar = lone != null && (lone.SqlTypeName?.StartsWith("nvarchar", StringComparison.OrdinalIgnoreCase) ?? false);
526+
bool noRoot = !(firstSet2?.JsonRootProperty?.Length > 0);
527+
bool flatPath = lone != null && (string.IsNullOrWhiteSpace(lone.JsonPath) || string.Equals(lone.JsonPath, lone.Name, StringComparison.OrdinalIgnoreCase));
528+
bool legacyJsonSentinel = lone != null && lone.Name.Equals("JSON_F52E2B61-18A1-11d1-B105-00805F49916B", StringComparison.OrdinalIgnoreCase);
529+
bool pseudoTabularCrud = isCrudName && singlePseudoColumn && loneIsNVarChar && noRoot && flatPath && !legacyJsonSentinel;
530+
if (pseudoTabularCrud)
531+
{
532+
hasTabularResult = false; // erzwinge Output-Logik
533+
}
437534

438535
if (!hasTabularResult && hasOutputs && customOutputCount > 0)
439536
{
@@ -457,29 +554,16 @@ string ReplacePlaceholder(string expr, string replacement)
457554
// AND the first result set only contains [ResultId] and/or [RecordId] (no real data columns),
458555
// we collapse to Output and map those columns into Output so consumers have a consistent pattern.
459556
// This predates richer model generation and will be removed once callers are migrated.
460-
var nameLowerCrud = storedProcedure.Name.ToLowerInvariant();
461-
bool isCrudVerb = nameLowerCrud.Contains("create") || nameLowerCrud.Contains("update") || nameLowerCrud.Contains("delete") || nameLowerCrud.Contains("merge") || nameLowerCrud.Contains("upsert");
462-
// Special cases: procedures without a classic CRUD verb that still only emit meta columns
463-
// and should be treated as minimal CRUD (fallback -> CrudResult)
464-
var crudVerbWhitelist = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
465-
{
466-
"invoicesend"
467-
};
468-
if (crudVerbWhitelist.Contains(nameLowerCrud))
469-
{
470-
isCrudVerb = true;
471-
}
472-
var crudAllowedCols = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "resultid", "recordid" };
473-
var firstSetCols = firstSet2?.Columns?.Select(c => c.Name)?.ToList() ?? new List<string>();
474-
bool onlyCrudMetaColumns = firstSetCols.Count > 0 && firstSetCols.All(c => crudAllowedCols.Contains(c));
557+
var metaColsSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "resultid", "recordid", "rowversion" };
558+
var firstSetCols = firstSet2?.Columns?.Select(c => c.Name)?.Where(n => !string.IsNullOrWhiteSpace(n)).ToList() ?? new List<string>();
559+
bool onlyMetaColumns = firstSetCols.Count > 0 && firstSetCols.All(c => metaColsSet.Contains(c));
475560
bool noCustomOutputs = !hasOutputs || customOutputCount == 0;
476561

477-
if (isCrudVerb && onlyCrudMetaColumns && noCustomOutputs)
562+
if (onlyMetaColumns && noCustomOutputs)
478563
{
479-
// OBSOLETE CRUD minimal result heuristic: collapse meta-only resultset to CrudResult
564+
// Generische Meta-Spalten -> CrudResult Rückgabe unabhängig vom Prozedurnamen
480565
returnType = "Task<CrudResult>";
481-
returnExpression = ReplacePlaceholder(returnExpression, $"ExecuteSingleAsync<CrudResult>");
482-
// Skip obsolete list/find heuristic for this branch
566+
returnExpression = ReplacePlaceholder(returnExpression, "ExecuteSingleAsync<CrudResult>");
483567
}
484568
else
485569
{

0 commit comments

Comments
 (0)