Reference 12 Assignment - daveclay/velle GitHub Wiki
A rule changes a stored value by assigning to it. The new value replaces the old one in the record itself, at the commit that fired the rule. The old value is gone unless you chose to keep a history. This page shows how to write an assignment, which values can be assigned, what happens when two rules could write the same value, how to keep a history instead, and how a rule deletes a record.
A customer sends in a corrected email address, and the business wants the customer record to show the new address from then on. From examples/billing/billing.velle, where CorrectEmail is declared as an act:
shape CorrectEmail {
customer: one Customer
corrected: text
}
rule ApplyEmailCorrection when CorrectEmail {
customer.email = corrected
}
-
CorrectEmailis the request. It names the customer and carries the new address. -
ApplyEmailCorrectionfires when aCorrectEmailis committed (§11). -
customer.email = correctedwrites the new address onto the customer. The write lands in the same commit.
Three things about the statement:
-
=inside a rule means "becomes, now." Inside a shape body the same symbol means "is always defined as," which is a derived property. The two never meet: a rule body holds no definitions and a shape body holds no effects. -
The left side is a fixed path written out in full.
customer.emailnames one field of one record reachable from the rule's subject. There is no way to compute which field to write. Because every write names its field, the compiler can tell you every refinement and derived property a write may change. -
The left side is a stored field. A derived property is a formula, and a formula cannot be assigned.
invoice.balance = 0is a compile error whenbalanceis derived. Instead, commit the data the formula reads, such as aPaymentrecord.
Across commits, the last write wins. Within one commit there is no "last," which is why the compiler insists on one writer per field, below.
If the right side reads the field being assigned, as in account.balance = account.balance + amount, the value is a running total. Running totals need extra care, covered in §19.
To assign the same field on every member of a collection, see "Committing and assigning collections" in §6.
Two rules may both assign the same field, as long as no single commit can fire both. If one commit could fire both, the compiler rejects the pair, because the field would have no defined value.
shape CorrectEmail { customer: one Customer, corrected: text }
shape OverrideEmail { customer: one Customer, admin: one Admin, overridden: text }
rule ApplyEmailCorrection when CorrectEmail {
customer.email = corrected
}
rule ApplyEmailOverride when OverrideEmail {
customer.email = overridden -- legal: an OverrideEmail is never a CorrectEmail,
-- so these always fire from different commits
}
rule NormalizeEmail when CorrectEmail {
customer.email = lowercase(corrected) -- compile error: same trigger shape as
-- ApplyEmailCorrection — one commit fires both
}
-
ApplyEmailCorrectionandApplyEmailOverrideare fine together. A commit is either aCorrectEmailor anOverrideEmail, never both, so whichever is committed later wins. That matches the business: the admin's override stands until the customer corrects it again. -
NormalizeEmailis rejected. It has the same trigger asApplyEmailCorrection, so oneCorrectEmailcommit would fire both and writeemailtwice.
The compiler decides "could one commit fire both?" from the declarations alone:
- Same trigger shape: yes.
- A refinement and its base shape, or two refinements that can overlap: yes.
- Two unrelated shapes: no.
- Anything the compiler cannot settle counts as yes, and the pair is rejected.
The check covers the whole spec. A second rule writing customer.email, added months later in another file, is reported in one diagnostic naming both rules. then (§15) cannot order two writes to one field within a rule. Write one assignment whose right side states the whole intent.
An assignment belongs in a rule, never in the shape that triggers it:
shape CorrectEmail {
customer: one Customer
corrected: text
customer.email = corrected -- rejected
}
Write the rule instead, as in the first example. Committing a record already stores it, so a rule is needed only when the commit has a further consequence, and the rule's name says what that is. A transient act is not stored, so it does nothing without a rule.
If the business needs the history of a value, do not overwrite it. Record each change as a new record, and define the current value as the latest one:
shape Customer {
signupEmail: text
email: text = if exists EmailCorrection for this
then latest(EmailCorrection for this by correctedOn).corrected
else signupEmail
}
shape EmailCorrection {
customer: one Customer
corrected: text
correctedOn: timestamp on create
}
-
EmailCorrectionis one change. Nothing ever edits it. -
correctedOnis filled in when the record is created (§5). -
emailis a derived property.latest(... by correctedOn)picks the newest correction by that field. The ordering field is always named. - The
existscheck comes first so that.correctedis read only when a correction exists.
The choice is made per field. "The email changed" is an assignment. "The email changed and we need to know what it was" is a ledger.
A rule body may contain three kinds of statement: an assignment, the creation of a record, and delete <path>. The delete removes one record from the state at the rule's commit.
A marketplace lets a seller discard a draft listing. The listing is removed, and the business keeps a note of what was discarded and why. From examples/moderation/moderation.velle:
act Seller {
name: text
}
act Listing {
title: text
seller: one Seller? initially required -- required at creation; absent once the seller is deleted
isDraft: boolean initially true
}
shape Draft = Listing where isDraft
transient act DiscardDraft {
listing: one Listing
reason: text
}
shape DiscardRecord {
listingTitle: text -- copied: the listing will not exist to reference
reason: text
discardedOn: timestamp on create
}
rule ApplyDiscard when (DiscardDraft where listing is Draft) {
DiscardRecord from { listingTitle: listing.title, reason: reason }
delete listing
}
-
seller: one Seller? initially requiredmeans a listing must name a seller when created, but the field becomes absent if that seller is later deleted. Nothing writes the field; it goes absent on its own. -
DiscardDraftis a transient act: the request itself is not stored. -
DiscardRecordis what the business keeps. It copies the title, because the listing will not exist to point at. -
ApplyDiscardfires only for a draft. It creates the record from the listing's title, then deletes the listing, in one commit.
What you can and cannot write:
- The target is one record, named by a fixed path such as
delete thisordelete listing, never a collection. To delete several records, write a rule whose subject is each one; it fires once per record (§16). - A rule may read the record it deletes, and create other records from what it reads, in the same commit.
- A commit may not both assign a field of a record and delete that record.
- A deletion leaves nothing behind: no tombstone, no
is deletedtest, no reference that outlives its target. To remember a deletion, create a record for it, asDiscardRecorddoes. To react to one, usewhen deleted(§13). - A refinement may declare
undeletable, which forbids deleting a record while it is a member. It is written wherefrozenis written (§8).
Delete current-state records such as a Customer or a Listing. Records of something that happened, such as a committed act or a DiscardRecord, are kept.
- Rejects assigning a derived property, or any left side that is not a fixed path to a stored field.
- Rejects two assignments to the same field whose triggers could fire from one commit, and names both rules (checks.md, V1).
- Rejects an assignment in a shape body.
-
Rejects a
deletewhose target is a collection or a transient act, two deletes of one record in one commit, and a commit that both assigns a field of a record and deletes it (checks.md, V23). -
Rejects deleting a record that a required
onereference still points at, unless the referrer is deleted in the same commit, declares the reference? initially required, or keeps copies of what it needs. Optional andmanyreferences need nothing. There is no cascade (checks.md, V24). -
Rejects a delete when a
not existsguard, a singular(Shape for ...)read, or aneverdepends on the deleted shape, unless the deleting rule's condition provably excludes those records (checks.md, V25, V26). -
Rejects a delete that could reach a record in an
undeletablerefinement. The usual fix is to trigger the deleting rule on the deletable subset, asApplyDiscarddoes withDraft(checks.md, V27). -
Rejects a
when leavingrule withafter commitor a schedule that a deletion could hand a record that no longer exists (checks.md, V28). -
Warns when
undeletableis declared where no rule could delete the record anyway (checks.md, A2).
- §7 Derived properties, for values that are computed rather than assigned.
- §11 rule, for what fires a rule and which commit its writes land in.
- §19 Self-referential folds, for running totals and other values updated from their own previous value.