Computed Fields
Status: Offen
Bedarf aus den Samples
| Sample | Entity | Feld | Berechnung |
|---|---|---|---|
| mietnomade | lease | totalRent | baseRent + prepayment + heatingPrepayment |
| mietnomade | property | (detail query) | totalUnits, vacancyRate — Aggregation ueber Units |
| beammycar | order | state | Berechnet aus driverOrder-States (→ siehe state-machine.md) |
| beammycar | invoice | totalNet | SUM(lines.priceNet * lines.itemCount) |
| beammycar | invoice | totalVat | SUM(lines.priceNet * lines.itemCount * lines.taxRate) |
| beammycar | invoice | totalGross | totalNet + totalVat |
Muster
- Einfache Formel: totalRent = a + b + c (Felder derselben Entity)
- Aggregation: totalNet = SUM(child.field) (Felder einer Kind-Entity)
- Berechneter Status: order.state aus Sub-Entities (→ eigenes Thema, siehe state-machine.md)
- 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 0Berechnung
1. lease.create/update → preSave Hook prueft dependsOn → Wenn baseRent/prepayment/heatingPrepayment geaendert → compute() aufrufen → Wert in DB2. invoiceLine.create/update/delete → postSave Hook auf Parent → invoice.totalNet neu berechnen → Wert in DBVorteile
- 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 totalRentr.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
| Kriterium | Weg A (Computed Field Type) | Weg B (Hooks + Queries) |
|---|---|---|
| Framework-Aufwand | Hoch (neuer Feld-Typ, Auto-Hooks, dependsOn) | Keiner |
| Boilerplate pro Feature | Niedrig (deklarativ) | Mittel (Hook pro Feld) |
| Flexibilitaet | Mittel (compute-Funktion) | Hoch (voller Hook-Zugriff) |
| Fehleranfaelligkeit | Niedrig (dependsOn erzwingt Update) | Mittel (Hook vergessen → stale) |
| UI-Integration | Ja (Framework weiss: computed → kein Edit) | Nein (access: write:[] als Workaround) |
| Cross-Entity | Komplex (Reverse-Relation-Lookup) | Einfach (expliziter postSave Hook) |
| Testbarkeit | compute-Funktion isoliert testbar | Hook 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:
- Alle Sample-Anforderungen sind mit bestehenden Hooks loesbar — preSave fuer eigene Felder, postSave fuer Cross-Entity
- Read-Only ist via
access: { write: [] }schon da - Query-Zeit Berechnungen (totalUnits, vacancyRate) gehoeren sowieso in Query Handler, nicht in die Entity
- 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).