wiki/b-reference/platform-core/fk-label-resolution.md
2026-06-09 22:59:57 +02:00

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

  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().dbgetRecordset(Mandate) label oder name
FeatureInstance resolveInstanceLabels() getRootInterface().dbgetRecordset(FeatureInstance) label
UserInDB resolveUserLabels() getRootInterface().dbgetRecordset(UserInDB) displayName / username / email
Role resolveRoleLabels() getRootInterface().dbgetRecordset(Role) roleLabel
FileItem resolveFileLabels() getRootInterface().dbgetRecordset(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"},
    }
)
  • 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:

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:

  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)

@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.documentIdTrusteeDocument), 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

  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