@@ -65,15 +65,15 @@ public async Task<SourceText> GetStoredProcedureExtensionsCodeAsync(Definition.S
6565 bool NeedsModel ( Definition . StoredProcedure sp )
6666 {
6767 if ( sp . ResultSets == null || sp . ResultSets . Count == 0 ) return false ;
68- // Primäres Set = erstes JSON sonst erstes
68+ // Primary set = first JSON result set if any, otherwise the first result set
6969 var primary = sp . ResultSets . FirstOrDefault ( r => r . ReturnsJson ) ?? sp . ResultSets . First ( ) ;
7070 if ( primary == null ) return false ;
71- if ( primary . ReturnsJson ) return true ; // JSON immer Modell (auch bei Column=0 für Deserialize )
71+ if ( primary . ReturnsJson ) return true ; // JSON always implies a model (even if column count is 0 for deserialize )
7272 var cols = primary . Columns ? . Count ?? 0 ;
7373 if ( cols == 0 ) return false ;
7474 if ( cols == 1 )
7575 {
76- // Einzelspalte non-JSON -> nur Modell wenn nicht Skalar nvarchar(max) CRUD Pseudo
76+ // Single non-JSON column -> only treat as model when not just a scalar nvarchar(max) pseudo CRUD value
7777 var c = primary . Columns [ 0 ] ;
7878 bool isNVarChar = ( c . SqlTypeName ? . StartsWith ( "nvarchar" , StringComparison . OrdinalIgnoreCase ) ?? false ) ;
7979 // Wenn es weitere Sets gibt und eines JSON ist -> wir brauchen das Modell falls dieses Set nicht das JSON ist
@@ -226,6 +226,19 @@ private enum StoredProcedureMethodKind { Raw, Deserialize }
226226
227227 private MethodDeclarationSyntax GenerateStoredProcedureMethodText ( MethodDeclarationSyntax methodNode , Definition . StoredProcedure storedProcedure , StoredProcedureMethodKind kind , bool isOverload )
228228 {
229+ bool IsPseudoTabularCrud ( Definition . StoredProcedure sp , StoredProcedureContentModel . ResultSet set )
230+ {
231+ if ( sp == null || set == null ) return false ;
232+ var cols = set . Columns ? . Count ?? 0 ;
233+ if ( cols != 1 ) return false ;
234+ var lone = set . Columns . First ( ) ;
235+ bool loneIsNVarChar = ( lone . SqlTypeName ? . StartsWith ( "nvarchar" , StringComparison . OrdinalIgnoreCase ) ?? false ) ;
236+ bool noRoot = ! ( set . JsonRootProperty ? . Length > 0 ) ;
237+ bool flatPath = string . IsNullOrWhiteSpace ( lone . JsonPath ) || string . Equals ( lone . JsonPath , lone . Name , StringComparison . OrdinalIgnoreCase ) ;
238+ bool legacyJsonSentinel = lone . Name . Equals ( "JSON_F52E2B61-18A1-11d1-B105-00805F49916B" , StringComparison . OrdinalIgnoreCase ) ;
239+ // Rein strukturelle Heuristik: Einzelne nvarchar(max) Spalte ohne Root/verschachtelten Pfad -> pseudo tabular
240+ return loneIsNVarChar && noRoot && flatPath && ! legacyJsonSentinel ;
241+ }
229242 // Method name
230243 var baseName = $ "{ storedProcedure . Name } Async";
231244 if ( kind == StoredProcedureMethodKind . Deserialize )
@@ -341,7 +354,7 @@ private MethodDeclarationSyntax GenerateStoredProcedureMethodText(MethodDeclarat
341354 var returnType = "Task<CrudResult>" ;
342355 var returnModel = "CrudResult" ;
343356
344- // Primäres Set bestimmen: bevorzuge erstes NICHT -ExecSource Platzhalter-Set, sonst erstes .
357+ // Determine primary result set: prefer the first non -ExecSource placeholder, otherwise fall back to the first set .
345358 var firstSet = storedProcedure . ResultSets == null
346359 ? null
347360 : storedProcedure . ResultSets . FirstOrDefault ( rs => string . IsNullOrEmpty ( rs . ExecSourceProcedureName ) )
@@ -370,32 +383,31 @@ private MethodDeclarationSyntax GenerateStoredProcedureMethodText(MethodDeclarat
370383 var rs0 = targetSp . Content ? . ResultSets ? . FirstOrDefault ( ) ;
371384 if ( rs0 != null )
372385 {
373- firstSet = rs0 ; // nutze echte Struktur für Rückgabeheuristik
386+ firstSet = rs0 ; // use real target structure for return type heuristic
374387 isJson = rs0 . ReturnsJson ;
375388 isJsonArray = isJson && rs0 . ReturnsJsonArray ;
376389 }
377390 }
378391 }
379392 catch { /* best effort forward resolve */ }
380393 }
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 ).
394+ // Heuristic: Some CRUD-like procedures are incorrectly classified as JSON while they only emit a single nvarchar(max) value (e.g. sub-select) .
395+ // Downgrade criteria (structural only now): exactly 1 column, no explicit JsonRootProperty, column name not legacy FOR JSON sentinel ,
396+ // column type starts with nvarchar, column .JsonPath equals column .Name (flat structure ).
384397 if ( isJson && firstSet != null )
385398 {
399+ // Structural-only downgrade (name-based CRUD heuristic removed) except when the parser explicitly flagged JSON array/without wrapper.
400+ // We now skip downgrade if the result set indicates array semantics or explicit JSON intent via ReturnsJsonArray/ReturnsJsonWithoutArrayWrapper.
386401 var colCount = firstSet . Columns ? . Count ?? 0 ;
387- if ( colCount == 1 )
402+ if ( colCount == 1 && ! firstSet . ReturnsJsonArray && ! firstSet . ReturnsJsonWithoutArrayWrapper )
388403 {
389- var spNameLower = storedProcedure . Name . ToLowerInvariant ( ) ;
390- bool crudName = spNameLower . Contains ( "create" ) || spNameLower . Contains ( "update" ) || spNameLower . Contains ( "delete" ) || spNameLower . Contains ( "merge" ) || spNameLower . Contains ( "upsert" ) ;
391404 var col = firstSet . Columns [ 0 ] ;
392405 bool isNVarChar = ( col . SqlTypeName ? . StartsWith ( "nvarchar" , StringComparison . OrdinalIgnoreCase ) ?? false ) ;
393406 bool isLegacyJsonSentinel = col . Name . Equals ( "JSON_F52E2B61-18A1-11d1-B105-00805F49916B" , StringComparison . OrdinalIgnoreCase ) ;
394407 bool hasRoot = ! string . IsNullOrWhiteSpace ( firstSet . JsonRootProperty ) ;
395408 bool flatPath = string . Equals ( col . JsonPath , col . Name , StringComparison . OrdinalIgnoreCase ) || string . IsNullOrWhiteSpace ( col . JsonPath ) ;
396- if ( crudName && isNVarChar && ! isLegacyJsonSentinel && ! hasRoot && flatPath )
409+ if ( isNVarChar && ! isLegacyJsonSentinel && ! hasRoot && flatPath )
397410 {
398- // Rückstufung: Behandle als Nicht-JSON -> korrigiere Flags für nachfolgende Logik.
399411 isJson = false ;
400412 isJsonArray = false ;
401413 }
@@ -406,13 +418,13 @@ private MethodDeclarationSyntax GenerateStoredProcedureMethodText(MethodDeclarat
406418 var requiresAsync = isJson && kind == StoredProcedureMethodKind . Deserialize && ! isOverload ;
407419
408420 var rawJson = false ;
409- // Sonderfall: mehrere ResultSets, genau ein JSON Set -> treat as JSON primary
421+ // Special case: multiple result sets but exactly one JSON set -> treat JSON as primary
410422 var totalSets = storedProcedure . ResultSets ? . Count ?? 0 ;
411423 var jsonSetCount = storedProcedure . ResultSets ? . Count ( rs => rs . ReturnsJson ) ?? 0 ;
412424 bool singleJsonAmongMultiple = totalSets > 1 && jsonSetCount == 1 && isJson ;
413425 if ( ( isReferenceOnlyForward || singleJsonAmongMultiple ) && isJson && kind == StoredProcedureMethodKind . Raw )
414426 {
415- // Referenz -only: Raw-Methode soll weiterhin string liefern (Durchreichen), aber nachfolgende Deserialize Methode nutzt Zielmodell .
427+ // Reference -only forwarding: raw method still returns a string pass-through, deserialize variant uses the forwarded target model .
416428 rawJson = true ;
417429 returnType = "Task<string>" ;
418430 returnExpression = returnExpression
@@ -511,25 +523,18 @@ string ReplacePlaceholder(string expr, string replacement)
511523 }
512524 else
513525 {
514- var firstSet2 = firstSet ; // verwende primäres Set für Typheuristik
526+ var firstSet2 = firstSet ; // use primary set for type heuristic
515527 var columnCount = firstSet2 ? . Columns ? . Count ?? 0 ;
516528 var hasTabularResult = columnCount > 0 ;
517529 var hasOutputs = storedProcedure . HasOutputs ( ) ;
518- // Normalisierte Skip-Liste (ohne '@') für konsistente Erkennung
530+ // Normalized skip list (without '@') for consistent detection
519531 var baseOutputPropSkip = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) { "ResultId" , "RecordId" , "RowVersion" , "Result" } ;
520532 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 ;
533+ // Pseudo-tabular? Single nvarchar(max) column without root/complex structure -> treat as non-tabular (forces Output logic)
534+ bool pseudoTabularCrud = IsPseudoTabularCrud ( storedProcedure , firstSet2 ) ;
530535 if ( pseudoTabularCrud )
531536 {
532- hasTabularResult = false ; // erzwinge Output-Logik
537+ hasTabularResult = false ; // force Output logic
533538 }
534539
535540 if ( ! hasTabularResult && hasOutputs && customOutputCount > 0 )
@@ -561,22 +566,22 @@ string ReplacePlaceholder(string expr, string replacement)
561566
562567 if ( onlyMetaColumns && noCustomOutputs )
563568 {
564- // Generische Meta-Spalten -> CrudResult Rückgabe unabhängig vom Prozedurnamen
569+ // Meta-only columns -> return CrudResult regardless of procedure name
565570 returnType = "Task<CrudResult>" ;
566571 returnExpression = ReplacePlaceholder ( returnExpression , "ExecuteSingleAsync<CrudResult>" ) ;
567572 }
568573 else
569574 {
570575
571- // OBSOLETE Heuristic (scheduled for removal): List vs Single inference for *Find* / *List* names.
576+ // OBSOLETE heuristic (scheduled for removal): list vs single inference for *Find* / *List* names.
572577 // Maintained temporarily for backward compatibility. Will be replaced by explicit metadata.
573578 var nonOutputParams = storedProcedure . Input . Where ( p => ! p . IsOutput && ! ( p . IsTableType ?? false ) ) . ToList ( ) ;
574579 bool IsIdName ( string n ) => n . Equals ( "@Id" , StringComparison . OrdinalIgnoreCase ) || n . EndsWith ( "Id" , StringComparison . OrdinalIgnoreCase ) ;
575580 var idParams = nonOutputParams . Where ( p => IsIdName ( p . Name ) ) . ToList ( ) ;
576581 bool singleIdParam = idParams . Count == 1 && nonOutputParams . Count == 1 ;
577582 var nameLower = storedProcedure . Name . ToLowerInvariant ( ) ;
578583 bool nameSuggestsFind = nameLower . Contains ( "find" ) && ! nameLower . Contains ( "list" ) ;
579- // Treat any pattern FindBy* as explicit single-row intent (not nur FindById)
584+ // Treat any FindBy* pattern as explicit single-row intent (not only FindById)
580585 bool nameIsFindByPattern = nameLower . Contains ( "findby" ) ;
581586 bool fewParams = nonOutputParams . Count <= 2 ;
582587 // Force single row when:
0 commit comments