Skip to content

Computed Fields

Status: Offen

Bedarf aus den Samples

SampleEntityFeldBerechnung
mietnomadeleasetotalRentbaseRent + prepayment + heatingPrepayment
mietnomadeproperty(detail query)totalUnits, vacancyRate — Aggregation ueber Units
beammycarorderstateBerechnet aus driverOrder-States (→ siehe state-machine.md)
beammycarinvoicetotalNetSUM(lines.priceNet * lines.itemCount)
beammycarinvoicetotalVatSUM(lines.priceNet * lines.itemCount * lines.taxRate)
beammycarinvoicetotalGrosstotalNet + totalVat

Muster

  1. Einfache Formel: totalRent = a + b + c (Felder derselben Entity)
  2. Aggregation: totalNet = SUM(child.field) (Felder einer Kind-Entity)
  3. Berechneter Status: order.state aus Sub-Entities (→ eigenes Thema, siehe state-machine.md)
  4. Query-Zeit Aggregation: property.totalUnits, vacancyRate — nur in Queries, nicht gespeichert

Zwei Grundfragen

Gespeichert oder berechnet?

  • Gespeichert (materialized): Wert steht in der DB. Schnell zu lesen, muss bei Aenderung aktualisiert werden
  • Berechnet (virtual): Wert wird bei Query on-the-fly berechnet. Immer aktuell, braucht mehr Query-Logik

Wann aktualisieren (wenn gespeichert)?

  • preSave Hook: Vor dem Schreiben berechnen → immer konsistent
  • postSave der Quelle: Wenn sich ein Kind aendert → Parent aktualisieren (Aggregation)

Weg A: Computed Field als Entity-Definition

Computed Fields als eigener Feld-Typ in der Entity-Definition. Framework berechnet automatisch.

API

r.entity("lease", {
fields: {
baseRent: { type: "money", required: true },
prepayment: { type: "money", required: true },
heatingPrepayment: { type: "money", required: true },
totalRent: {
type: "computed",
resultType: "money",
compute: (data) => data.baseRent + data.prepayment + data.heatingPrepayment,
// Wann neu berechnen:
dependsOn: ["baseRent", "prepayment", "heatingPrepayment"],
},
},
});
r.entity("invoice", {
fields: {
totalNet: {
type: "computed",
resultType: "money",
// Aggregation ueber Kind-Entity:
compute: async (data, ctx) => {
const lines = await ctx.db.query("invoiceLine", { where: { invoiceId: data.id } });
return lines.reduce((sum, l) => sum + l.priceNet * l.itemCount, 0);
},
dependsOn: { entity: "invoiceLine", fields: ["priceNet", "itemCount"] },
},
},
});

DB

-- Computed Fields werden gespeichert (materialized)
total_rent INTEGER NOT NULL DEFAULT 0

Berechnung

1. lease.create/update → preSave Hook prueft dependsOn
→ Wenn baseRent/prepayment/heatingPrepayment geaendert → compute() aufrufen → Wert in DB
2. invoiceLine.create/update/delete → postSave Hook auf Parent
→ invoice.totalNet neu berechnen → Wert in DB

Vorteile

  • Deklarativ: Berechnung steht bei der Entity-Definition
  • Automatisch: Framework kuemmert sich um Neuberechnung
  • Indexierbar: Wert steht in DB → WHERE total_rent > 100000 funktioniert
  • Sortierbar: ORDER BY total_rent funktioniert
  • Konsistent: dependsOn garantiert Neuberechnung

Nachteile

  • Neues Feld-Typ-Konzept: compute Funktion kann sync oder async sein (Aggregation braucht DB-Zugriff)
  • Cross-Entity dependsOn: invoiceLine → invoice erfordert Reverse-Lookup der Relation
  • Zirkulaere Dependencies: Framework muss erkennen wenn A von B abhaengt und B von A
  • Stale bei Bulk: 100 invoiceLines aendern → 100x invoice.totalNet neu berechnen
  • Write-Feld oder Read-Only? totalRent darf nicht manuell gesetzt werden → neues access-Konzept

Weg B: Kein eigener Feld-Typ — Berechnung in Hooks + Queries

Computed Fields sind kein Framework-Konzept. Stattdessen:

  • Gespeicherte Berechnungen → preSave Hook oder postSave Hook (bestehende Mechanismen)
  • Query-Zeit Berechnungen → Custom Query Handler

API

r.entity("lease", {
fields: {
baseRent: { type: "money", required: true },
prepayment: { type: "money", required: true },
heatingPrepayment: { type: "money", required: true },
totalRent: { type: "money", access: { write: [] } }, // read-only via access
},
});
// preSave Hook berechnet totalRent
r.hook("preSave", "lease", async (ctx) => {
if (ctx.changes.baseRent || ctx.changes.prepayment || ctx.changes.heatingPrepayment) {
ctx.data.totalRent = ctx.data.baseRent + ctx.data.prepayment + ctx.data.heatingPrepayment;
}
});
// postSave Hook fuer Cross-Entity (invoiceLine → invoice)
r.hook("postSave", "invoiceLine", async (ctx) => {
const lines = await ctx.db.query("invoiceLine", { where: { invoiceId: ctx.data.invoiceId } });
const totalNet = lines.reduce((sum, l) => sum + l.priceNet * l.itemCount, 0);
const totalVat = lines.reduce((sum, l) => sum + Math.round(l.priceNet * l.itemCount * l.taxRate), 0);
await ctx.db.update("invoice", ctx.data.invoiceId, {
totalNet,
totalVat,
totalGross: totalNet + totalVat,
});
});
// Query-Zeit Aggregation (nicht gespeichert)
r.queryHandler("property.list", {
handler: async (ctx) => {
const properties = await ctx.db.query("property", ctx.input);
return Promise.all(properties.map(async (p) => {
const units = await ctx.db.query("unit", { where: { propertyId: p.id } });
return {
...p,
totalUnits: units.length,
vacancyRate: units.filter(u => u.status === "vacant").length / units.length,
};
}));
},
});

Vorteile

  • Kein neues Konzept: Nutzt bestehende Hooks + Query Handler
  • Flexibel: Jede Berechnung kann beliebig komplex sein
  • Explizit: Entwickler sieht genau wann was berechnet wird
  • Read-Only: access: { write: [] } verhindert manuelles Setzen — existiert schon
  • Kein Framework-Aufwand: 0 neue Zeilen im Framework

Nachteile

  • Verstreut: Berechnung steht im Hook, nicht bei der Feld-Definition
  • Manuell: Entwickler muss selbst an dependsOn denken (vergessener Hook → stale Daten)
  • Kein Auto-UI: Framework weiss nicht dass totalRent berechnet ist → UI zeigt evtl. Edit-Feld
  • Boilerplate: changes-Check + Berechnung in jedem Hook

Vergleich

KriteriumWeg A (Computed Field Type)Weg B (Hooks + Queries)
Framework-AufwandHoch (neuer Feld-Typ, Auto-Hooks, dependsOn)Keiner
Boilerplate pro FeatureNiedrig (deklarativ)Mittel (Hook pro Feld)
FlexibilitaetMittel (compute-Funktion)Hoch (voller Hook-Zugriff)
FehleranfaelligkeitNiedrig (dependsOn erzwingt Update)Mittel (Hook vergessen → stale)
UI-IntegrationJa (Framework weiss: computed → kein Edit)Nein (access: write:[] als Workaround)
Cross-EntityKomplex (Reverse-Relation-Lookup)Einfach (expliziter postSave Hook)
Testbarkeitcompute-Funktion isoliert testbarHook isoliert testbar

Anforderungen aus den Samples

  • Einfache Formel (totalRent = a + b + c)
  • Cross-Entity Aggregation (invoice.totalNet aus invoiceLines)
  • Query-Zeit Berechnung (property.totalUnits — nicht gespeichert)
  • Read-Only (totalRent darf nicht manuell gesetzt werden)
  • Automatische Neuberechnung bei Aenderung der Quell-Felder

Empfehlung

Weg B (Hooks + Queries) fuer den Start. Gruende:

  1. Alle Sample-Anforderungen sind mit bestehenden Hooks loesbar — preSave fuer eigene Felder, postSave fuer Cross-Entity
  2. Read-Only ist via access: { write: [] } schon da
  3. Query-Zeit Berechnungen (totalUnits, vacancyRate) gehoeren sowieso in Query Handler, nicht in die Entity
  4. Cross-Entity dependsOn (invoiceLine → invoice) ist in Weg A der komplexeste Teil und in Weg B ein einfacher postSave Hook

Wenn sich Patterns wiederholen und die Hooks zu Boilerplate werden → type: "computed" als Sugar ueber Hooks bauen (Weg A wird Weg B unter der Haube).