wiki/b-reference/platform-core/fk-label-resolution.md
2026-06-02 09:42:10 +02:00

241 lines
11 KiB
Markdown

<!-- status: canonical -->
<!-- lastReviewed: 2026-04-26 -->
<!-- verifiedAgainst: routeHelpers.py (enrichRowsWithFkLabels), routeFeatureTrustee.py (_buildFeatureInternalResolvers) -->
# FK Label Resolution
## Ueberblick
Wenn das Backend eine paginierte Liste ausliefert, enthalten FK-Felder (z.B. `mandateId`, `featureInstanceId`, `userId`) nur die rohe UUID. Das UI zeigt stattdessen ein menschenlesbares Label. Damit das funktioniert, fuegt `enrichRowsWithFkLabels()` automatisch eine `{field}Label`-Spalte pro FK-Feld hinzu, bevor die Response an das Frontend geht.
```
Beispiel: mandateId = "a1b2c3..." → mandateIdLabel = "Demo AG"
```
Das Frontend rendert dann `mandateIdLabel` anstelle der ID. Felder, die keinen Resolver haben, zeigen im UI `NA(...)`.
## Architektur
```mermaid
flowchart TD
Model["Pydantic Model\n(json_schema_extra.fk_target)"] -->|"fk_target.table"| AutoBuild["_buildLabelResolversFromModel()"]
AutoBuild -->|"resolvers dict"| Enrich["enrichRowsWithFkLabels()"]
ExtraRes["extraResolvers\n(feature-intern)"] -->|"merge"| Enrich
Enrich -->|"rows + {field}Label"| Response["API Response"]
BuiltIn["_BUILTIN_FK_RESOLVERS\n(Mandate, FeatureInstance,\nUserInDB, Role)"] -->|"lookup"| AutoBuild
```
### Ablauf
1. **Modell-Scan**: `_buildLabelResolversFromModel(modelClass)` iteriert ueber alle Felder des Pydantic-Modells und liest `json_schema_extra.fk_target.table`.
2. **labelField-Gate**: Felder mit `fk_target.labelField = None` werden uebersprungen (Junction-IDs etc. brauchen kein Label).
3. **Builtin-Lookup**: Wenn der Tabellenname in `_BUILTIN_FK_RESOLVERS` existiert, wird der zugehoerige Resolver dem Feld zugeordnet.
4. **Extra-Resolvers**: Zusaetzliche Resolver (z.B. fuer feature-interne FKs) werden via `extraResolvers` gemerged.
5. **Batch-Resolve**: Pro Resolver werden alle einzigartigen IDs gesammelt und in einem Batch aufgeloest.
6. **Label-Injection**: Jede Row erhaelt eine neue Spalte `{field}Label` mit dem aufgeloesten Label (oder `None` wenn nicht aufloesbar).
## Builtin-Resolvers
Definiert in `gateway/modules/routes/routeHelpers.py`:
| `fk_target.table` | Resolver-Funktion | Datenquelle | Label-Feld |
|---|---|---|---|
| `Mandate` | `resolveMandateLabels()` | `interfaceDbApp``getMandatesByIds()` | `label` oder `name` |
| `FeatureInstance` | `resolveInstanceLabels()` | `interfaceFeatures``getFeatureInstance()` | `label` |
| `UserInDB` | `resolveUserLabels()` | `interfaceDbApp``getRecordset(UserInDB)` | `displayName` / `username` / `email` |
| `Role` | `resolveRoleLabels()` | `interfaceDbApp``getRecordset(Role)` | `roleLabel` |
Diese vier Resolver decken alle plattformweiten FK-Beziehungen ab. FK-Felder, die auf andere Tabellen zeigen, werden **nicht** automatisch aufgeloest.
## Pydantic-Modell-Annotation
### Canonical Format
Jede FK-Annotation verwendet ausschliesslich `fk_target` mit drei Pflichtschluesseln: `db`, `table`, `labelField`.
```python
mandateId: Optional[str] = Field(
default=None,
json_schema_extra={
"label": "Mandat",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
}
)
```
- `labelField` gibt an, welche Spalte als menschenlesbares Label dient.
- `labelField: None` = kein Label noetig (Junction-IDs, reine Referenzen). Kein `displayField` wird ans Frontend geliefert, kein Resolver wird automatisch zugeordnet.
- `"table": "UserInDB"` (physischer DB-Tabellenname), **nicht** `"User"`.
### Startup-Validierung
`validateFkTargets()` in `fkRegistry.py` prueft beim Gateway-Start, dass jeder `fk_target`-Dict exakt `db`, `table` und `labelField` enthaelt. Fehlende Keys brechen den Start ab.
### Builtin-Ziel (automatisch aufgeloest)
Wenn `fk_target.table` einem Builtin-Resolver entspricht und `labelField` gesetzt ist, ist keine weitere Konfiguration noetig:
```python
userId: str = Field(
json_schema_extra={
"label": "Benutzer",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
}
)
```
`_buildLabelResolversFromModel` erkennt `table: "UserInDB"` und ordnet `resolveUserLabels` zu. Das Ergebnis: jede Row erhaelt `userIdLabel`.
### Feature-internes FK-Ziel (extra Resolver noetig)
Wenn `fk_target.table` **nicht** in den Builtins existiert (z.B. `TrusteeDataJournalEntry`), wird das Feld **nicht** automatisch aufgeloest. Hier muss der Route-Handler einen `extraResolver` bereitstellen:
```python
journalEntryId: str = Field(
json_schema_extra={
"label": "Buchung",
"fk_target": {"db": "poweron_trustee", "table": "TrusteeDataJournalEntry", "labelField": "reference"},
}
)
```
Ohne `extraResolver` zeigt das UI `NA(...)` fuer dieses Feld.
## Feature-interne FK-Resolver
### Problem
Die Builtin-Resolver decken nur plattformweite Entitaeten ab (Mandate, UserInDB, etc.). Feature-Module haben eigene Modelle (z.B. `TrusteeDataJournalEntry`, `TrusteeOrganisation`), die als FK-Ziele dienen. Diese muessen explizit aufgeloest werden.
### Loesung: `extraResolvers` in `enrichRowsWithFkLabels`
```python
enrichRowsWithFkLabels(
rows,
modelClass,
extraResolvers={"journalEntryId": myResolverFunction},
)
```
### Referenz-Implementierung: `_buildFeatureInternalResolvers`
In `routeFeatureTrustee.py` existiert ein generischer Builder, der als Vorlage fuer andere Features dient:
```python
def _buildFeatureInternalResolvers(modelClass, db) -> Dict[str, Any]:
resolvers = {}
for name, fieldInfo in modelClass.model_fields.items():
extra = fieldInfo.json_schema_extra
if not extra or not isinstance(extra, dict):
continue
tgt = extra.get("fk_target")
if not isinstance(tgt, dict):
continue
tableName = tgt.get("table", "")
if tableName not in _FEATURE_ENTITY_MODELS:
continue
targetModel = _FEATURE_ENTITY_MODELS[tableName]
def _makeResolver(model, field=name):
def _resolve(ids):
result = {i: None for i in ids}
recs = db.getRecordset(model, recordFilter={"id": list(set(ids))}) or []
for r in recs:
row = r if isinstance(r, dict) else r.model_dump()
rid = row.get("id", "")
parts = []
for col in ("externalId", "reference", "bookingDate", "label", "name", "accountNumber"):
val = row.get(col)
if val:
parts.append(str(val))
if len(parts) >= 2:
break
result[rid] = " | ".join(parts) if parts else rid[:8]
return result
return _resolve
resolvers[name] = _makeResolver(targetModel)
return resolvers
```
Dieses Pattern:
1. Scannt das Modell nach `fk_target`-Annotationen
2. Prueft, ob das Ziel ein feature-internes Modell ist (via `_FEATURE_ENTITY_MODELS`-Dict)
3. Erstellt einen Resolver, der die Ziel-Tabelle abfragt und ein Label aus den ersten 2 verfuegbaren beschreibenden Feldern baut
4. Wird im Route-Handler via `_paginatedReadEndpoint` automatisch aufgerufen
## WICHTIG: Filter-Dropdown-Enrichment (FormGeneratorTable)
> **Regel fuer jeden Route-Handler der eine paginierte Datentabelle (FormGeneratorTable) bedient:**
>
> Der `mode=filterValues`-Pfad MUSS `enrichRowsWithFkLabels(items, ModelClass)` aufrufen
> **bevor** `handleFilterValuesInMemory(items, column, ...)` aufgerufen wird.
> Ohne diesen Schritt zeigen Filter-Dropdowns rohe UUIDs statt menschenlesbarer Labels.
>
> `_extractDistinctValues` erkennt FK-Labels nur, wenn `{field}Label`-Spalten in den Items vorhanden sind.
> Diese werden ausschliesslich durch `enrichRowsWithFkLabels` hinzugefuegt.
**Korrektes Pattern (In-Memory-Route):**
```python
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required")
items = _buildItems()
enrichRowsWithFkLabels(items, MyModel)
return handleFilterValuesInMemory(items, column, pagination)
```
**Korrektes Pattern (DB-Paginated mit Fallback):**
```python
if mode == "filterValues":
try:
values = db.getDistinctColumnValues(MyModel, column, crossPagination, recordFilter)
return JSONResponse(content=sorted(values, ...))
except Exception:
items = [r.model_dump() for r in db.getRecordset(MyModel, ...)]
enrichRowsWithFkLabels(items, MyModel)
return handleFilterValuesInMemory(items, column, pagination)
```
## Checkliste: Neues FK-Feld hinzufuegen
### Fall 1: FK auf Mandate / FeatureInstance / UserInDB / Role
1. `json_schema_extra` mit `fk_target` annotieren (`db`, `table`, `labelField` — alle drei Pflicht)
2. Fertig — der Builtin-Resolver wird automatisch erkannt
### Fall 2: FK auf ein feature-internes Modell
1. `json_schema_extra` mit `fk_target` annotieren (`table` = Ziel-Modellname)
2. Sicherstellen, dass das Ziel-Modell im Feature-Entity-Dict registriert ist (z.B. `_TRUSTEE_ENTITY_MODELS`)
3. Im Route-Handler `_buildFeatureInternalResolvers` aufrufen und das Ergebnis als `extraResolvers` an `enrichRowsWithFkLabels` uebergeben
4. Testen: API-Response pruefen, ob `{field}Label` korrekt aufgeloest wird (nicht `None` oder `NA(...)`)
### Fall 3: FK auf ein Modell in einem anderen Feature oder einer externen Tabelle
1. Einen dedizierten Resolver schreiben (Signatur: `(ids: List[str]) -> Dict[str, Optional[str]]`)
2. Im Route-Handler als `extraResolvers` uebergeben
3. Fuer haeufig verwendete Ziele: Resolver in `_BUILTIN_FK_RESOLVERS` in `routeHelpers.py` aufnehmen
### Checkliste fuer neue Datentabellen-Route (FormGeneratorTable)
1. Tabellen-Pfad: `enrichRowsWithFkLabels(rows, ModelClass)` vor Response
2. **Filter-Pfad (`mode=filterValues`)**: `enrichRowsWithFkLabels(items, ModelClass)` vor `handleFilterValuesInMemory`
3. IDs-Pfad (`mode=ids`): kein Enrichment noetig
4. `fk_target` auf dem Modell mit `db`, `table`, `labelField` (Pflicht, validiert beim Start)
## Kern-Dateien
| Datei | Zweck |
|---|---|
| `gateway/modules/routes/routeHelpers.py` | `_BUILTIN_FK_RESOLVERS`, `_buildLabelResolversFromModel`, `enrichRowsWithFkLabels` |
| `gateway/modules/shared/fkRegistry.py` | `validateFkTargets` (Startup-Validierung), FK-Discovery |
| `gateway/modules/features/trustee/routeFeatureTrustee.py` | `_buildFeatureInternalResolvers` (Referenz-Implementierung) |
| `gateway/modules/features/trustee/datamodelFeatureTrustee.py` | Beispiel-Annotationen (`fk_target` auf allen Modellen) |
## Siehe auch
- [FormGenerator Referenz](../frontend-nyla/formgenerator.md) — Frontend-Darstellung der aufgeloesten Labels
- [Gateway Architektur](architecture.md) — Modulstruktur und routeHelpers