334 lines
14 KiB
Markdown
334 lines
14 KiB
Markdown
<!-- status: canonical -->
|
|
<!-- lastReviewed: 2026-06-09 -->
|
|
<!-- verifiedAgainst: modules/dbHelpers/fkLabelResolver.py, routeFeatureTrustee.py (_buildFeatureInternalResolvers, _paginatedReadEndpoint) -->
|
|
|
|
# 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, FileItem)"] -->|"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 `platform-core/modules/dbHelpers/fkLabelResolver.py`:
|
|
|
|
| `fk_target.table` | Resolver-Funktion | Datenquelle | Label-Feld |
|
|
|---|---|---|---|
|
|
| `Mandate` | `resolveMandateLabels()` | `getRootInterface().db` → `getRecordset(Mandate)` | `label` oder `name` |
|
|
| `FeatureInstance` | `resolveInstanceLabels()` | `getRootInterface().db` → `getRecordset(FeatureInstance)` | `label` |
|
|
| `UserInDB` | `resolveUserLabels()` | `getRootInterface().db` → `getRecordset(UserInDB)` | `displayName` / `username` / `email` |
|
|
| `Role` | `resolveRoleLabels()` | `getRootInterface().db` → `getRecordset(Role)` | `roleLabel` |
|
|
| `FileItem` | `resolveFileLabels()` | `getRootInterface().db` → `getRecordset(FileItem)` | `fileName` |
|
|
|
|
Diese fuenf 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
|
|
|
|
---
|
|
|
|
## KRITISCH: Enrichment in ALLEN Pfaden (FormGeneratorTable)
|
|
|
|
> **Jeder Route-Handler der eine `FormGeneratorTable` bedient MUSS `enrichRowsWithFkLabels` in ALLEN Datenpfaden aufrufen — nicht nur im Filter-Pfad!**
|
|
>
|
|
> **Haeufigster Fehler**: Enrichment nur im `mode=filterValues`-Pfad, aber NICHT im Standard-Paginated-Pfad. Ergebnis: Filter-Dropdowns zeigen Labels, aber die Tabellenzellen zeigen rohe UUIDs.
|
|
|
|
### Vollstaendiges korrektes Pattern (Referenz)
|
|
|
|
```python
|
|
@router.get("/{instanceId}/entities")
|
|
def get_entities(
|
|
request: Request,
|
|
instanceId: str = Path(...),
|
|
pagination: Optional[str] = Query(None),
|
|
mode: Optional[str] = Query(None),
|
|
column: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
|
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
|
|
# --- Mode: filterValues ---
|
|
if mode == "filterValues":
|
|
if not column:
|
|
raise HTTPException(status_code=400, detail="column parameter required")
|
|
items = _loadAllItems(interface)
|
|
enrichRowsWithFkLabels(items, MyModel, db=getRootInterface().db) # ← PFLICHT
|
|
return handleFilterValuesInMemory(items, column, pagination)
|
|
|
|
# --- Mode: ids ---
|
|
if mode == "ids":
|
|
items = _loadAllItems(interface)
|
|
return handleIdsInMemory(items, pagination)
|
|
|
|
# --- Standard-Pfad: Paginierte Tabellendaten ---
|
|
paginationParams = _parsePagination(pagination)
|
|
result = interface.getAllEntities(paginationParams)
|
|
|
|
def _toDicts(items):
|
|
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
|
|
|
if paginationParams and hasattr(result, "items"):
|
|
enriched = enrichRowsWithFkLabels( # ← PFLICHT
|
|
_toDicts(result.items), MyModel, db=getRootInterface().db
|
|
)
|
|
return {
|
|
"items": enriched,
|
|
"pagination": PaginationMetadata(...).model_dump(),
|
|
}
|
|
|
|
items = result if isinstance(result, list) else result.items
|
|
enriched = enrichRowsWithFkLabels( # ← PFLICHT
|
|
_toDicts(items), MyModel, db=getRootInterface().db
|
|
)
|
|
return {"items": enriched, "pagination": None}
|
|
```
|
|
|
|
### Die drei Pflicht-Enrichment-Stellen
|
|
|
|
| Pfad | Enrichment noetig? | Warum |
|
|
|---|---|---|
|
|
| **Standard (paginated)** | **JA** | Tabellenzellen muessen Labels zeigen |
|
|
| **`mode=filterValues`** | **JA** | Filter-Dropdowns muessen Labels zeigen |
|
|
| **`mode=groupSummary`** | **JA** | Gruppierungs-Headers muessen Labels zeigen |
|
|
| `mode=ids` | Nein | Gibt nur IDs zurueck, keine Labels noetig |
|
|
|
|
### Haeufige Fehler (NICHT nachmachen)
|
|
|
|
```python
|
|
# FALSCH: Nur im filterValues-Pfad enrichen
|
|
if mode == "filterValues":
|
|
enrichRowsWithFkLabels(items, MyModel, db=...)
|
|
return handleFilterValuesInMemory(...)
|
|
|
|
# Standard-Pfad OHNE Enrichment → UUIDs im UI!
|
|
return {"items": _toDicts(result.items), "pagination": ...}
|
|
```
|
|
|
|
```python
|
|
# FALSCH: enrichRowsWithFkLabels aufrufen aber falschen db-Connector uebergeben
|
|
# Die Mandate/FeatureInstance/User-Tabellen liegen in poweron_app,
|
|
# nicht in der Feature-DB!
|
|
enrichRowsWithFkLabels(items, MyModel, db=featureInterface.db) # ← FALSCH
|
|
enrichRowsWithFkLabels(items, MyModel, db=getRootInterface().db) # ← RICHTIG
|
|
```
|
|
|
|
### Bestes Pattern: Enrichment VOR dem Branching
|
|
|
|
Wenn alle Modi dieselben Items laden, ist das sauberste Pattern:
|
|
|
|
```python
|
|
items = _loadAllItems(interface)
|
|
enrichRowsWithFkLabels(items, MyModel, db=getRootInterface().db)
|
|
|
|
if mode == "filterValues":
|
|
return handleFilterValuesInMemory(items, column, pagination)
|
|
if mode == "ids":
|
|
return handleIdsInMemory(items, pagination)
|
|
# ... Standard-Pagination ...
|
|
```
|
|
|
|
So kann kein Pfad das Enrichment vergessen. Siehe `routeAdminFeatures.py` als Referenz-Implementierung.
|
|
|
|
### Feature-interne FKs im Standard-Pfad
|
|
|
|
Wenn das Modell FK-Felder hat die auf andere Feature-Tabellen zeigen (z.B. `TrusteePosition.documentId` → `TrusteeDocument`), muessen diese ueber `extraResolvers` aufgeloest werden:
|
|
|
|
```python
|
|
featureResolvers = _buildFeatureInternalResolvers(MyModel, interface.db)
|
|
enrichRowsWithFkLabels(
|
|
items, MyModel,
|
|
db=getRootInterface().db,
|
|
extraResolvers=featureResolvers or None,
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Checkliste: Neues FK-Feld hinzufuegen
|
|
|
|
### Fall 1: FK auf Mandate / FeatureInstance / UserInDB / Role / FileItem
|
|
|
|
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 `fkLabelResolver.py` aufnehmen
|
|
|
|
### Checkliste fuer neue Datentabellen-Route (FormGeneratorTable)
|
|
|
|
- [ ] **Hauptpfad (paginated)**: `enrichRowsWithFkLabels(rows, ModelClass, db=getRootInterface().db)` vor Response
|
|
- [ ] **Filter-Pfad (`mode=filterValues`)**: `enrichRowsWithFkLabels(items, ModelClass, db=getRootInterface().db)` vor `handleFilterValuesInMemory`
|
|
- [ ] **GroupSummary-Pfad** (falls vorhanden): Enrichment vor `build_group_summary_groups`
|
|
- [ ] IDs-Pfad (`mode=ids`): kein Enrichment noetig
|
|
- [ ] `db=` Parameter: Immer `getRootInterface().db` fuer Builtin-FK-Resolver (Mandate, User, etc.), NICHT die Feature-DB
|
|
- [ ] `extraResolvers`: Falls das Modell feature-interne FKs hat, `_buildFeatureInternalResolvers(ModelClass, interface.db)` uebergeben
|
|
- [ ] `fk_target` auf dem Modell mit `db`, `table`, `labelField` (Pflicht, validiert beim Start)
|
|
- [ ] Testen: Tabelle im UI laden → keine UUIDs sichtbar in FK-Spalten
|
|
|
|
## Kern-Dateien
|
|
|
|
| Datei | Zweck |
|
|
|---|---|
|
|
| `platform-core/modules/dbHelpers/fkLabelResolver.py` | `_BUILTIN_FK_RESOLVERS`, `buildLabelResolversFromModel`, `enrichRowsWithFkLabels`, alle Resolver-Funktionen |
|
|
| `platform-core/modules/shared/fkRegistry.py` | `validateFkTargets` (Startup-Validierung), FK-Discovery |
|
|
| `platform-core/modules/features/trustee/routeFeatureTrustee.py` | `_buildFeatureInternalResolvers` (Referenz), `_paginatedReadEndpoint` (generischer Handler) |
|
|
| `platform-core/modules/features/trustee/datamodelFeatureTrustee.py` | Beispiel-Annotationen (`fk_target` auf allen Modellen) |
|
|
| `platform-core/modules/routes/routeAdminFeatures.py` | Bestes Pattern: Enrichment VOR Mode-Branching |
|
|
|
|
## Siehe auch
|
|
|
|
- [FormGenerator Referenz](../ui-nyla/formgenerator.md) — Frontend-Darstellung der aufgeloesten Labels
|
|
- [Gateway Architektur](architecture.md) — Modulstruktur und Resolver-Einbindung
|