14 KiB
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
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
- Modell-Scan:
buildLabelResolversFromModel(modelClass)iteriert ueber alle Felder des Pydantic-Modells und liestjson_schema_extra.fk_target.table. - labelField-Gate: Felder mit
fk_target.labelField = Nonewerden uebersprungen (Junction-IDs etc. brauchen kein Label). - Builtin-Lookup: Wenn der Tabellenname in
_BUILTIN_FK_RESOLVERSexistiert, wird der zugehoerige Resolver dem Feld zugeordnet. - Extra-Resolvers: Zusaetzliche Resolver (z.B. fuer feature-interne FKs) werden via
extraResolversgemerged. - Batch-Resolve: Pro Resolver werden alle einzigartigen IDs gesammelt und in einem Batch aufgeloest.
- Label-Injection: Jede Row erhaelt eine neue Spalte
{field}Labelmit dem aufgeloesten Label (oderNonewenn 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.
mandateId: Optional[str] = Field(
default=None,
json_schema_extra={
"label": "Mandat",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
}
)
labelFieldgibt an, welche Spalte als menschenlesbares Label dient.labelField: None= kein Label noetig (Junction-IDs, reine Referenzen). KeindisplayFieldwird 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:
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:
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
enrichRowsWithFkLabels(
rows,
modelClass,
extraResolvers={"journalEntryId": myResolverFunction},
)
Referenz-Implementierung: _buildFeatureInternalResolvers
In routeFeatureTrustee.py existiert ein generischer Builder, der als Vorlage fuer andere Features dient:
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:
- Scannt das Modell nach
fk_target-Annotationen - Prueft, ob das Ziel ein feature-internes Modell ist (via
_FEATURE_ENTITY_MODELS-Dict) - Erstellt einen Resolver, der die Ziel-Tabelle abfragt und ein Label aus den ersten 2 verfuegbaren beschreibenden Feldern baut
- Wird im Route-Handler via
_paginatedReadEndpointautomatisch aufgerufen
KRITISCH: Enrichment in ALLEN Pfaden (FormGeneratorTable)
Jeder Route-Handler der eine
FormGeneratorTablebedient MUSSenrichRowsWithFkLabelsin 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)
@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)
# 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": ...}
# 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:
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:
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
json_schema_extramitfk_targetannotieren (db,table,labelField— alle drei Pflicht)- Fertig — der Builtin-Resolver wird automatisch erkannt
Fall 2: FK auf ein feature-internes Modell
json_schema_extramitfk_targetannotieren (table= Ziel-Modellname)- Sicherstellen, dass das Ziel-Modell im Feature-Entity-Dict registriert ist (z.B.
_TRUSTEE_ENTITY_MODELS) - Im Route-Handler
_buildFeatureInternalResolversaufrufen und das Ergebnis alsextraResolversanenrichRowsWithFkLabelsuebergeben - Testen: API-Response pruefen, ob
{field}Labelkorrekt aufgeloest wird (nichtNoneoderNA(...))
Fall 3: FK auf ein Modell in einem anderen Feature oder einer externen Tabelle
- Einen dedizierten Resolver schreiben (Signatur:
(ids: List[str]) -> Dict[str, Optional[str]]) - Im Route-Handler als
extraResolversuebergeben - Fuer haeufig verwendete Ziele: Resolver in
_BUILTIN_FK_RESOLVERSinfkLabelResolver.pyaufnehmen
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)vorhandleFilterValuesInMemory - GroupSummary-Pfad (falls vorhanden): Enrichment vor
build_group_summary_groups - IDs-Pfad (
mode=ids): kein Enrichment noetig db=Parameter: ImmergetRootInterface().dbfuer Builtin-FK-Resolver (Mandate, User, etc.), NICHT die Feature-DBextraResolvers: Falls das Modell feature-interne FKs hat,_buildFeatureInternalResolvers(ModelClass, interface.db)uebergebenfk_targetauf dem Modell mitdb,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 — Frontend-Darstellung der aufgeloesten Labels
- Gateway Architektur — Modulstruktur und Resolver-Einbindung