Summary#
A kit that works on "some record" — a notification, a comment, a question — cannot know the record's type, so it
cannot know what to call it, where it opens or who may read it. Those are the record's own facts, declared once where
the record and its page are declared, and Records answers them for any row:
| call | answers | from | |
|---|---|---|---|
Records.Title(x) | what it is called — "#1042 · Refund not received" | its [[entity-title | [Title]]], else its first required text |
Records.Link(x) | where it opens — "/w/mine/t/1042"; "" when no page opens it | the page that [Opens] its type | |
Records.Noun(x) | what kind of thing it is — "Urgent case" | its type's name, in words | |
Records.MayRead(person, x) | may this person read it | the row's security, as that person | |
Records.Readable(person, rows) | the rows this person may read, in the order given | the same, one read per type |
Each takes a row of any type, or an Entity. A null — an Entity the caller may not read — answers
"" or false.
A kit that works on a record by its FIELDS — a rule editor, a filter builder — asks these:
| call | answers |
|---|---|
Records.Type(x) | its type's full name |
Records.IsA(type, baseType) | whether one type is the other, or derives from it |
Records.Fields(type) | the fields of that type the caller may read, each a RecordField — Name, Label, Kind (Text, Number, Flag, Choice, Time), an enum's Choices, a reference's Target. No statement |
Records.Choices(type, field) | what a person picks a value of that field from — an enum's members, or the rows a reference points at, as the caller reads them |
Records.Value(x, field) | one field's value as text — an enum member's name, a reference's id, true/false, a number, "" for none |
Records.Time(x, field) | one time field's value |
Records.RollsUpTo(x) | the records its history is also told on — its [RollsUpTo] references |
Records.FieldName(selector) | the field a selector names — t => t.Title → "Title" — so a kit takes a field the compiler checked |
Records.Text(selector, value) | a value of that field's own type, as the text Records.Value answers the field in — JobStage.Ready → "Ready" |
Signature#
string Records.Title(<row>)
string Records.Link(<row>)
string Records.Noun(<row>)
bool Records.MayRead(Principal person, <row>)
List<T> Records.Readable(Principal person, List<T> rows)
string Records.Type(<row>)
bool Records.IsA(string type, string baseType)
List<RecordField> Records.Fields(string type)
List<ActionChoice> Records.Choices(string type, string field)
string Records.Value(<row>, string field)
DateTime? Records.Time(<row>, string field)
List<Entity> Records.RollsUpTo(<row>)
string Records.FieldName(Func<T, V> selector) // t => t.Title → "Title"
string Records.Text(Func<T, V> selector, V value) // (t => t.Stage, JobStage.Ready) → "Ready"
[NotInRules] <type> <Field>; // never offered by Records.Fields
[RollsUpTo] <Record> <Reference>; // what happens to this record is told on that one too
[Route("/<route with {params}>")] [Opens(<Type>, <param> = <value or member path>, …)]
component <PageName>(<params>) { … }Description#
Where does a record open?#
[Opens(Ticket)] on a page says it is where a Ticket opens. Records.Link(t) fills the page's route from the
record: each route parameter is filled by a named argument of [Opens] (a literal, or a path over the record's members
— id = Article.Id), else by the record's member of the same name ({number} ← Number), else by the parameter's
default. Values are URL-escaped.
A derived type with no page of its own opens where its base does. An app's page beats a kit's for the same type, and a
page that takes over a kit's page keeps its [Opens]. Two pages opening one type in the same place are refused. A
record no page opens has no link ("") — the kit then shows it without one.
Who may read it — as whom?#
Records.MayRead(person, x) evaluates the row's security { } as person, never as the caller — what a notifier
asks before telling somebody about a record. Records.Readable(person, rows) asks it of many rows at once — a digest,
a list of mentions — in one secured read per type among them, whatever their number. SECURITY-INVARIANT
batch-may-read-answers-as-the-person.
Which fields may a person ask about?#
Records.Fields(type) answers the fields the CALLER may read — a field their rules mask is not in it, so a rule they
write cannot ask about it, and could not tell them its value by firing or not. Mark a field [NotInRules] to keep it
out for everybody (an internal code, a hash). A reference's rows cost a read, so they are not in the field:
Records.Choices(type, field) lists them when a person is actually picking one.
How does a kit take a field the compiler checks?#
A kit that asks the app to NAME a field — a rule's condition, a column to sort by — takes it as a selector,
Func<T, V>, the way EF's Property(t => t.Title) does, and asks Records.FieldName(selector) which field it names.
The app writes t => t.Title; a field the type does not have is a compile error where it is written ("entity 'Job'
has no property 'Titel'. Did you mean 'Title'?"), so a misspelling never becomes a rule that silently never holds. A
method declaring Test<V>(Func<T, V> field) needs no type argument at the call: V is whatever the lambda produces.
t => t.Customer.Name answers "Customer.Name"; a selector that computes something (t => t.A + t.B) names no
field, and the call says so.
A VALUE for that field is typed the same way: Test<V>(Func<T, V> field, V value) takes a value of the field's own type,
and Records.Text(field, value) writes it as the text Records.Value answers the field in — an enum member's name, a
row's id, true/false, a number in invariant form, an ISO time (a TimeSpan as hours) — so the two compare. A value
of another type is refused where it is written, at the value: cannot pass 'string' to parameter 'value' of type
'JobStage'. JobStage declares: ….
Whose history does it roll up to?#
[RollsUpTo] on a reference says that what happens to this record also happened to the one it points at — a quote's
events belong on its opportunity's timeline too. Records.RollsUpTo(x) answers those records, which is how a
timeline (The RecordTimeline kit — what happened to a record, on its own timeline) shows a parent's whole story.
Where can I call them?#
On the server — in a function, a workflow body, a [Test]. Each reads the model's declarations, and MayRead another
person's rules. A page shows what a server function answered.
Examples#
[Principal] entity Agent {
[Required, MaxLength(100)] string Name;
security { allow read when IsAuthenticated; }
}
[Title($"#{Number} · {Subject}")]
entity Ticket {
[Required] int Number;
[Required, MaxLength(200)] string Subject;
bool Restricted;
security { allow read, create when IsAuthenticated where !Restricted; }
}
/// Where a ticket opens: `{view}` from the attribute, `{number}` from the ticket's own Number.
[Route("/w/{view}/t/{number}")] [Opens(Ticket, view = "mine")] [Authorize] [Render(CSR)]
component TicketPage(string view, int number) { render { Text("ticket"); } }
public string LineFor(Ticket t) { return Records.Title(t) + " → " + Records.Link(t); }
/// The tickets this agent may read, among many — one read for all of them.
public int ReadableBy(Agent agent, List<Ticket> tickets) { return Records.Readable(agent, tickets).Count; }A record's fields, as a rule engine sees them#
entity Opportunity {
[Required, MaxLength(100)] string Name;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
enum QuoteStage { Draft, Sent, Accepted }
entity Quote {
[Required, MaxLength(100)] string Title;
QuoteStage Stage;
decimal Amount;
[NotInRules, MaxLength(40)] string InternalCode = "";
[RollsUpTo] Opportunity Opportunity;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
public string FieldsOf(Quote q) {
return string.Join(", ", Records.Fields(Records.Type(q)).Select(f => f.Name + " " + f.Kind.Name));
}[Test]
void A_quote_by_its_fields() {
var o = new Opportunity { Name = "Fleet renewal" };
var q = new Quote { Title = "Twelve vans", Stage = QuoteStage.Sent, Amount = 18000, InternalCode = "X-9", Opportunity = o };
Assert.Equal("Title Text, Stage Choice, Amount Number, Opportunity Choice", FieldsOf(q));
Assert.Equal("Sent", Records.Value(q, "Stage"));
Assert.Equal(o.Id, Records.RollsUpTo(q).Single().Id);
}A kit that takes a field by selector#
entity Job {
[Required, MaxLength(100)] string Title;
int Copies;
security { allow read, create when IsAnonymous || IsAuthenticated; }
}
public class Condition { public string Field = ""; public string Value = ""; }
/// What a kit offers: the app's own rules about `T`, each naming a field and a value of its type the compiler checks.
public class Rules<T> {
public Condition Test<V>(Func<T, V> field, V value) {
return new Condition { Field = Records.FieldName(field), Value = Records.Text(field, value) };
}
}
/// …and what the app writes — `j => j.Copis`, or `Test(j => j.Copies, "500")`, would not compile.
public class JobRules : Rules<Job> {
public List<Condition> Shipped() { return [Test(j => j.Copies, 500), Test(j => j.Title, "poster")]; }
}[Test]
void A_selector_names_its_field() {
Assert.Equal(["Copies", "Title"], new JobRules().Shipped().Select(c => c.Field).ToList());
Assert.Equal(["500", "poster"], new JobRules().Shipped().Select(c => c.Value).ToList());
}See also#
- [Title] on an entity — what each record is called
- Entity — a reference to a row of any type, which these answer for
- [OnDelete] — what deleting a referenced row does
- Security.MayRead — may THIS person read this row, under their own rules —
Security.MayRead, the single-row question for a known type - routes and pages — pages and their routes
- The Automations kit — rules that do an app's routine work when something happens — conditions offered from a record's fields
- The RecordTimeline kit — what happened to a record, on its own timeline — a timeline that rolls up