@@ -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