wiki/b-reference/gateway/fk-label-resolution.md
2026-04-26 18:12:03 +02:00

11 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)"] -->|"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() interfaceDbAppgetMandatesByIds() label oder name
FeatureInstance resolveInstanceLabels() interfaceFeaturesgetFeatureInstance() label
UserInDB resolveUserLabels() interfaceDbAppgetRecordset(UserInDB) displayName / username / email
Role resolveRoleLabels() interfaceDbAppgetRecordset(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.

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

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):

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):

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