Skip to content

Commit 78b7de8

Browse files
committed
Remove restrictive name-based JSON heuristics
1 parent 625f5fd commit 78b7de8

4 files changed

Lines changed: 373 additions & 223 deletions

File tree

CHECKLIST.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,12 @@ EPICS Übersicht (oberste Steuerungsebene)
139139
acceptance: - DI Registrierung (IServiceCollection) vorhanden - Minimal API Mappings generierbar - Beispiel-Endpunkt im Sample funktioniert
140140
depends: [E003]
141141

142-
- [ ] EPIC-E007 Heuristik-Abbau (P3)
142+
- [~] EPIC-E007 Heuristik-Abbau (P3)
143143
id: E007
144144
goal: Entfernung restriktiver Namens-/Strukturheuristiken
145145
acceptance: - Liste entfernte / geänderte Heuristiken dokumentiert (Dokumentation ausreichend, kein vollständiger Audit) - Regressionstests schützen kritische Fälle
146146
depends: [E003]
147+
progress: Namensbasierte JSON-Erkennung (ProcName enthält 'Json'/'AsJson') entfernt; Legacy CrudResult Fallback für einfache nvarchar(max) JSON-Sets revertiert; Primäres ResultSet Auswahl = erstes nicht-ExecSource Placeholder; strukturelle Pseudo-CRUD Erkennung (ein einzelnes nvarchar(max) ohne FOR JSON) aktiv; Placeholder-Minimierung für Wrapper (nur ExecSource* Metadaten) umgesetzt; Fixup-Phase rekonstruiert fehlende JSON Sets (synthetische Columns mit SqlTypeName=null); Logging Präfixe vereinheitlicht ([proc-forward-xschema], [proc-exec-append-xschema], [proc-fixup-json-*]); CHANGELOG Removed Abschnitt ausstehend.
147148

148149
- [ ] EPIC-E008 Konfig-Bereinigung (P2)
149150
id: E008
@@ -219,6 +220,16 @@ EPICS Übersicht (oberste Steuerungsebene)
219220
- [x] Ermittlung des Namespaces automatisiert und dokumentierte Fallback-Strategie vorhanden
220221
- [x] Zentrale Positive Schema Allow-List (SPOCR_BUILD_SCHEMAS) für Procedures & TableTypes implementiert
221222
- [ ] Entfernte Spezifikationen/Heuristiken sauber entfernt und CHANGELOG Eintrag erstellt
223+
details:
224+
- Entfernt: Namensbasierte JSON-Heuristik ("Json" / "AsJson" im Prozedurnamen) – alleinige Struktur (FOR JSON / Parser Flags) entscheidet jetzt
225+
- Entfernt: Legacy CrudResult Fallback für einfache nvarchar(max) "JSON" Sets (Revert → richtige Output/Model Erzeugung)
226+
- Geändert: Primäres ResultSet = erstes nicht-Placeholder (kein ExecSource*) statt erstes JSON-ähnliches Set
227+
- Neu: IsPseudoTabularCrud Erkennung (ein einzelnes nvarchar(max) ohne FOR JSON) → Nutzung ExecuteSingleAsync statt ReadJsonAsync
228+
- Placeholder-Minimierung: Wrapper-Prozeduren persistieren nur ExecSource* Metadaten (keine synthetischen Spalten) im Snapshot
229+
- Fixup-Phase: Rekonstruiert fehlende JSON ResultSets bei Wrapper-only Snapshots; synthetische JSON Columns erhalten SqlTypeName=null (Signal: künstlich)
230+
- Logging: Vereinheitlichte Präfixe [proc-forward-xschema], [proc-exec-append-xschema], [proc-fixup-json-*] ersetzt frühere uneinheitliche Tags
231+
- Offen: CHANGELOG Eintrag "Removed heuristics" + vollständige Dokumentationsseite (removed-heuristics-v5.md inhaltlich füllen)
232+
status: [~] (technische Entfernung abgeschlossen, Dokumentation & CHANGELOG folgen)
222233
- [ ] Neuer `SpocRDbContext` implementiert inkl. moderner DI Patterns & Minimal API Extensions
223234
- [x] Grundgerüst via Template-Generator (Interface, Context, Options, DI) – aktiviert in `SPOCR_GENERATOR_MODE=dual|next`
224235
- [x] DbContext Optionen (ConnectionString / Name / Timeout / Retry / Diagnostics)

src/CodeGenerators/Models/StoredProcedureGenerator.cs

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)