Problem
I want to model a domain where events of different shapes share a life — an order, a payment against it, a refund. In SQL I'd have three tables and hope the foreign keys hold. In Morel I can have one list of events and a type system that makes "payment for an order that doesn't exist" unrepresentable. Plus I want to check a business rule — refunds never exceed payments — and have the answer land as a table I can look at.
Setup
datatype event =
Order of { id : int, amount : real }
| Payment of { orderId : int, amount : real }
| Refund of { orderId : int, amount : real };
val events = [
Order { id = 1, amount = 222.00 },
Order { id = 2, amount = 840.00 },
Payment { orderId = 1, amount = 222.00 },
Payment { orderId = 2, amount = 500.00 },
Refund { orderId = 2, amount = 100.00 },
Order { id = 3, amount = 90.00 },
Payment { orderId = 3, amount = 90.00 }
];
Example
Three projections — kind, amount, and a group-sum — as one pipeline:
fun kind (Order _) = "order"
| kind (Payment _) = "payment"
| kind (Refund _) = "refund";
fun amount (Order { id = _, amount = a }) = a
| amount (Payment { orderId = _, amount = a }) = a
| amount (Refund { orderId = _, amount = a }) = a;
from e in events
yield { kind = kind e, amount = amount e }
group kind
compute { total = sum over amount };
val it =
[{kind="refund",total=100.0},{kind="payment",total=812.0},
{kind="order",total=1152.0}] : {kind:string, total:real} list
What's happening
datatype event = Order of … | Payment of … | Refund of … is an
algebraic data type — a sum of product types. Each case is its own
constructor with its own payload, and the compiler enforces
exhaustiveness when you pattern-match. Add a fourth event kind, and
every fun that matches event must gain a clause for it. That's
the guarantee SQL gives you with foreign keys and nothing else: the
shape is the schema, and the schema is the code.
Two helper functions — kind and amount — turn the typed events
back into uniform fields Morel's query operators understand. That's
the bridge: define the data as precisely as you can, then project
to the flat shape your aggregates need. The query looks like a SQL
query and the type definition looks like a Haskell one, and they
live in the same file.
Small 0.8 gotcha to remember. Partial record patterns inside a
constructor payload — Order { id, ... } — hit the same bug you'll
have seen in recipe 02. Bind every field with =, even if with a
wildcard (id = _, amount = a). Tedious for wide records, fine for
three.
Variations
The business invariant as a query. For each distinct order ID, sum
payments and refunds, assert one doesn't exceed the other. The ok
column is your test — if any false shows up, you've got a bug:
fun orderId (Order { id = i, amount = _ }) = i
| orderId (Payment { orderId = i, amount = _ }) = i
| orderId (Refund { orderId = i, amount = _ }) = i;
fun isPayment (Payment _) = true | isPayment _ = false;
fun isRefund (Refund _) = true | isRefund _ = false;
fun paidFor id =
List.foldl (op +) 0.0
(from e in events where isPayment e andalso orderId e = id yield amount e);
fun refundedFor id =
List.foldl (op +) 0.0
(from e in events where isRefund e andalso orderId e = id yield amount e);
val ids = from e in events group orderId e;
from id in ids
yield { id, paid = paidFor id, refunded = refundedFor id,
ok = refundedFor id <= paidFor id };
val it =
[{id=1,ok=true,paid=222.0,refunded=0.0},
{id=2,ok=true,paid=500.0,refunded=100.0},
{id=3,ok=true,paid=90.0,refunded=0.0}]
: {id:int, ok:bool, paid:real, refunded:real} list
This is what testing looks like in Morel. Queries and assertions
share an evaluator. No separate mocks, no "run the test harness."
Write the invariant, render it as a table, read the ok column.
See also
- Recipe 12 — Derive columns with pattern matching — the
caseand function-clause patterns used throughout. - Recipe 13 — Handle missing values —
optionis the smallest ADT; everything on this page is a bigger version. - Recipe 19 — Compose queries from small pieces —
paidForandrefundedForare the pattern that recipe makes explicit.