Summary#
entity RushOrder : Order derives one entity from another, with C#'s colon and C#'s meaning. The subtype is its
own type — its own name, its own security rules, its own workflows — and it carries every member of its base
plus whatever it adds. Both types store their rows in one table.
Those two facts are the whole feature, and everything below is a consequence of one or the other.
Signature#
entity <Sub> : <Base> {
<attributes> <Type> <Member>; // members of its OWN, on top of everything Base declares
invariant <condition>; // its own checks; Base's still apply
security { … } // its OWN grants — only Base's DENIES carry over
}Description#
What a subtype is#
A subtype is a distinct type. It has its own name, and everything the platform keys on a type keys on it separately: its security rules are its own, a workflow that tracks a column of its OWN drives only it, and a row of it reads back as itself.
It is also complete. You do not reach through a base to get at inherited members — RushOrder simply has
Reference, in a query, in a UI binding, in a function:
entity Order {
[Required, MaxLength(120)] string Reference;
int Quantity;
}
entity RushOrder : Order {
[Required, MaxLength(60)] string Courier;
}
void Ship() {
// `Reference` and `Quantity` come from Order; `Courier` is RushOrder's own. No difference at the use site.
var rush = new RushOrder { Reference = "R-1001", Quantity = 2, Courier = "DHL" };
}Where are a subtype's rows stored?#
A subtype's rows live in its base's table, alongside the base's own. A hidden platform-written column records which type each row is.
This is not an implementation detail you can ignore, because it is what makes the useful things possible: a
reference typed Order can hold a RushOrder, a parent-child tree can span types, and there is one id space, so
nothing has to ask "which table is this id in". It is also why a subtype may not redeclare a base member — see
One column, so no redeclaring — and why a [Unique] on a base reaches further than you might expect,
while one on a derived type does not — see Unique on a base, unique per type.
One consequence is worth knowing if you ever look at the table directly: a member only a subtype declares is
optional in the database, because rows of every other type genuinely have no value for it. [Required] is still
enforced — for that type, on every write — but the column itself holds NULL for everyone else, rather than a
fabricated blank.
A subtype goes wherever its base is wanted#
A SignedContract is a Document, so it goes anywhere a Document is expected — a member, a function
argument — with no cast, exactly as in C#. This is what one table and one id space buy: a single reference column
holds any kind, and nothing at the far end knows the hierarchy exists.
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
entity Note {
[Required] Document Document; // typed as the ROOT…
[Required, MaxLength(400)] string Body;
}
void Annotate() {
var c = new Contract { Title = "Supply", Counterparty = "Acme" };
new Note { Document = c, Body = "countersigned" }; // …and a Contract goes straight in
}"Anywhere a Document is expected" includes the places where two values have to agree on one type — a ?:, a
switch expression, a ?? fallback. The result is the base of the two, so Document d = rush ? contract : doc;
is the ordinary way to pick between them. Two SIBLINGS — a Contract and an Invoice — have no common type to
infer, so they take the type they are written INTO: Document d = rush ? contract : invoice; is fine, while
var d = … has nothing to take and says so.
The other direction — treating a Document you are holding as a Contract — can fail at run time, so you state it:
test the row with is, or narrow a whole set with OfType<T>().
Reading a type returns everything below it#
Order.Where(…) returns rush orders too, because a RushOrder is an Order — the same thing a List<Order>
means in C#. Narrowing is what you state: RushOrder.Where(…) returns rush orders and whatever derives from
them, and never a plain Order. Count(), a collection you navigate to and a tree you walk all read the same way.
Each row that comes back is governed by its own type's rules, never by the type you asked through — so a base read can return fewer rows than the table holds, and that is the rules working rather than a missing row.
int AllOrders() {
// The rush orders too — they are orders.
return Order.Where(o => o.Quantity > 0).ToList().Count;
}
int RushOnly() {
return RushOrder.Where(o => o.Quantity > 0).ToList().Count;
}How do I get back just one kind? — is and OfType<T>#
A base read hands you every kind. is tests one row; OfType<T>() narrows a whole set — and both are
polymorphic downward, because a SignedContract is a Contract.
x is Contract is a plain condition: it works in a query, where it becomes a check the database does without
reading any rows, and in ordinary code. is not Contract is its negation.
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
entity SignedContract : Contract { [Required, MaxLength(60)] string Signatory; }
// In a query — the database answers it; nothing is loaded to decide.
int ContractCount() { return Document.Where(d => d is Contract).ToList().Count; }
// `OfType<T>()` gives you a set of that type, so its own members are readable.
string Counterparties() {
var all = "";
foreach (var c in Document.OfType<Contract>().ToList()) { all = all + c.Counterparty + ";"; }
return all;
}Both count the SignedContract too. To ask for only the leaf, name it: Document.OfType<SignedContract>().
Why can't I read the subtype's members after is?
is on its own answers a question; it does not change what you may read. d is Contract tells you the row is a
contract, and d is still a Document, so d.Counterparty does not compile. Give the test a name and it does:
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
entity Memo : Document { }
string Render() {
var lines = "";
foreach (var d in Document.OrderBy(x => x.Title).ToList()) {
if (d is Contract c) {
lines = lines + d.Title + " with " + c.Counterparty + "\n"; // `c` is the same row, as a Contract
} else {
lines = lines + d.Title + "\n";
}
}
return lines;
}c is the row you tested — nothing is copied — and it exists inside the if only. That is deliberate: outside
the branch the test may not have held, and a name that reads a row as a kind it is not would be worse than no name.
For the same reason these are refused, each with a sentence saying what to write instead:
| Written | Why |
|---|---|
is not Contract c | the test failing says nothing about what c would be |
var b = d is Contract c; | c belongs to a branch, and there is no branch here — use is Contract to get the answer |
A cast is the other way to say it, for a row you know is one: (Contract)d is the same row as a Contract, and
when it is not one it fails — InvalidCastException, naming both types — rather than reading on as the wrong kind.
d as Contract answers null instead. Casting UP ((Document)contract) always succeeds. Inside a query, where the
database answers, a cast that could fail is refused: test with is, or narrow the read with OfType<T>().
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
string CounterpartyOf(Guid id) {
var c = (Contract)Document.Single(d => d.Id == id); // the same row, as a Contract
return c.Counterparty;
}
string WhatAWrongCastSays(Guid id) {
try { return ((Contract)Document.Single(d => d.Id == id)).Counterparty; }
catch (InvalidCastException e) { return e.Message; }
}Grants are never inherited — denies are#
A subtype declares its own security { } block: none of its base's grants carry over. Every deny its base
states does.
Both halves are the safe direction. An entity with no policy grants nothing, so a subtype whose author has not yet
thought about who may read it returns no rows — rather than silently receiving whatever grant its base happened to
have. And a subtype's row IS a row of its base — one table, and a base read returns it — so a deny that stopped at the
base's own rows would narrow nothing: deny read where Withdrawn on Document would leave every withdrawn Contract
readable. A deny only ever narrows, so carrying it down cannot open anything. The subtype's own rules cannot widen past
it either — an inherited deny is decided after every rule the subtype states.
The same holds for a deny you state on a type you did not write: partial entity User { security { deny read … } }
over a kit's User holds on every type derived from it, the kit's own and yours.
A subtype may also narrow a field it inherits with a deny of its own — deny read Notes when !IsReviewer; on a
Memo : Document — and never widen one: an allow that names an inherited field is refused
(deny on an inherited field).
[Principal]
entity User {
[Required, MaxLength(255)] string Email;
security { allow read when IsAuthenticated; }
}
entity Document {
[Required, MaxLength(200)] string Title;
bool Withdrawn;
security {
allow create, read when IsAuthenticated; // any signed-in user sees any Document…
deny read where Withdrawn; // …that has not been withdrawn
}
}
entity Contract : Document {
[Required, MaxLength(80)] string Counterparty;
[CreatedBy] Guid? CreatedBy;
// Its OWN grant, and narrower. Nothing of Document's grant carries over — had this block been left out entirely, a
// Contract would be readable by nobody, which is the direction you want to be wrong in. Document's DENY does carry
// over: a withdrawn Contract is hidden from everybody, its creator included.
security { allow create, read where CreatedBy == user.Id; }
}What IS inherited#
| Declaration | Inherited? | Why |
|---|---|---|
members, and their attributes ([Required], [MaxLength], [Searchable], …) | yes | they travel with the member, which the subtype has |
semantic => … (the search card) | yes, and a subtype may declare its own to override | it is a description, and a subtype that adds nothing genuinely describes itself the same way |
| depth | unlimited — SignedContract : Contract : Document carries every member of both levels above it | |
invariant | yes, and the subtype's own add to them | it is a constraint, and constraints only accumulate — a subtype cannot drop one |
security { } grants | no — always its own | it is an authority question, where silence must mean no |
security { } denies | yes, decided after the subtype's own rules | a deny only narrows, and the subtype's rows are the base's rows |
| a field rule on an inherited field | a deny only — an allow is refused | the base is the floor; below it permissions only narrow |
Description defaults to inheritance; authority defaults to denial.
Can I redeclare a base's member on a subtype?#
Because both types share a table, a member declared on both is not a shadowed member — it is one column claimed by
two declarations. C# lets you shadow with new; a table cannot, so there is no spelling for it and the compiler
asks you to delete one:
entity Order { [Required] string Reference; }
entity RushOrder : Order {
[Required] string Reference; // ✗ 'RushOrder.Reference' redeclares 'Order.Reference'
}The realistic way to hit this is not a typo — it is a base gaining a member later that a subtype already used. The error names both declarations so you can see which one you meant.
To type an inherited REFERENCE narrower on the subtype, which is a different intent, say so with
override: override Company Organization; is the base's one column, typed as a type derived
from the base's, and the redeclaration error above suggests it whenever the member is a reference.
Two SIBLINGS may share a member name, as long as they agree about it. Rush.Note and Standby.Note do not
collide with each other the way a subtype collides with its base — they are different rows — so one shared column
serves both:
entity Ticket { [Required, MaxLength(200)] string Title; }
entity Bug : Ticket { [MaxLength(80)] string Area; }
entity Feature : Ticket { [MaxLength(80)] string Area; } // same column, same meaning — fineWhat is refused is the two disagreeing, because there is only one column and only one of them can win:
entity Bug : Ticket { [MaxLength(80)] string Area; }
entity Feature : Ticket { int Area; } // ✗ one column, declared as `string` by one type and `int` by the other[Unique] on a base, and unique per type#
A unique constraint is over a table, and one table holds every type in the hierarchy. So [Unique] on a base is
unique across the base and every type derived from it:
entity Order {
[Required, Unique, MaxLength(40)] string Code; // no two Orders share a Code…
}
entity RushOrder : Order { // …and a RushOrder is an Order, so it is in the same space
[Required, MaxLength(60)] string Courier;
}An Order with Code = "A-1" and a RushOrder with Code = "A-1" is refused. That is usually exactly what you
want — one id space is much of the point of sharing a table — but it is impossible to read off the declaration, and
the other reading ("unique among Orders") is equally plausible. So the compiler warns, naming every type the
constraint covers:
'Order.Code' is unique across EVERY type stored in 'Order's table — Order, RushOrder — not just 'Order'.
It is a warning rather than an error because the behaviour is correct; what was missing is that you were told.
A [Unique] written on a derived type is unique per type. It holds among that type's own rows, and a sibling type
sharing the table may hold the same value — the database enforces it, whatever writes the row. Write it on each type
that needs it, naming a member the type declares or one it inherits:
entity Tag {
[Required, MaxLength(40)] string Name;
}
[Unique(Name, "Another product tag already has that name.")]
entity ProductTag : Tag { }
[Unique(Name, "Another customer tag already has that name.")]
entity CustomerTag : Tag { }A ProductTag and a CustomerTag may both be called "Wholesale"; a second ProductTag called "Wholesale" is refused
with the sentence written beside the rule. "Per type" means exactly that type: a type derived from ProductTag has
rows of its own type, and declares its own [Unique] if it needs one.
What happens to the rows if I remove a subtype?#
Deleting a subtype from your source means the same thing as deleting any other entity: its rows go, and nobody else's do. It does not drop the shared table, the base and its siblings are untouched, and the columns that only the removed type declared go with it.
Like every other drop it is gated — a plain recompile reports it and keeps the type, and removing it for real needs
--prune in development or an acknowledged migration in production.
Deleting a base while something still derives from it is a compile error: the subtype's : Base names a type
your application no longer declares. Remove the subtype in the same change, or keep the base.
What is refused#
| Written | Refused because |
|---|---|
a sealed base | the type said no type may derive from it — see sealed |
two bases (: A, B) | Osy# has no interfaces, so a second name could only be a second base, and a row has one type |
class X : Y | a class is an in-memory value, not a table; give it a field of the other type and compose |
| an entity deriving from a class, or the reverse | different kinds — one is a table, one is not |
extends / implements | other languages' spellings; Osy# keeps C#'s colon, one spelling per concept |
| two siblings declaring one member DIFFERENTLY | one column, and only one of the two declarations can win |
a written DOWNCAST ((Contract)d) | it must fail at run time when the row is not one, and that check is not built — narrow instead with is or OfType<T>(), which cannot fail |
a member other than security { } on a subtype of a platform type | it shares a table the platform owns and writes — see below |
| a member on a subtype of a package type that the package's type already has | one column, and the package's declaration is already in it — see below |
Deriving from a platform type#
The types the platform's own capabilities bring in — Osysharp.Workflow, Osysharp.Storage and their neighbours —
are sealed: an app does not derive from them. To state your own access to one, declare a partial entity over
it (security { }).
Where a capability does leave a type open, a subtype of it declares security { } and nothing else. It shares
a table the platform owns and writes, so adding a column to it would reshape platform storage — the same rule a
partial entity over a platform type follows. Rows of a platform type stay platform-written through the subtype:
the new name grants no ability to create, change or delete one that the base did not. Your own types are
unrestricted: a subtype of a type you declare adds whatever it likes.
Deriving from a package's type#
A type from a package you use — a shop kit's Product, a third party's — is different from a platform type in the
one way that matters here: its table is yours, written by your app's own compiled code. So a subtype of it may
add fields, exactly like a subtype of a type you declare:
using Osysharp.Shop; // a package the app uses; it ships `entity Product { string Name; … }`
entity Bike : Product { // one more kind of product, in Product's own table
[MaxLength(60)] string Tubing; // a column only bikes use
security { allow read when IsAnonymous || IsAuthenticated; }
}The "platform type" rule above is about the capabilities the platform itself ships — Osysharp.Workflow,
Osysharp.Storage and their neighbours.
A subtype cannot claim a member its base already has — it is one column, as for your own types.
That is also what happens when a LATER version of the package adds a member your subtype already declared: the
compile after osy update refuses it by name, naming both declarations, and you rename yours or adopt the package's.
Nothing is silently merged, and no row changes meaning without you seeing it.
A package's own rules are the FLOOR your subtype stands on. Its grants are not inherited — a subtype still
states its own security { }, and without one returns nothing — but whatever the package's type DENIES, it denies on
your subtype's rows too: the package's deny read PasswordHash when !IsAuthenticator on
User holds on your Employee : User, whatever Employee's own block grants. A kit's
guarantee cannot be undone by deriving from its type, which is what lets a kit ship a credential at all.
Who decides who may change the fields I add?#
The fields your subtype adds follow the package's rules until your block names them. A package's own-row rule —
the UserAccounts kit's "a person edits their own account" — covers every column of the row by default, the ones you added
included. Name one of YOUR fields in an allow update rule of the subtype's own block, and for that field your rules decide
instead:
entity Member : User {
Member? Mentor; // the app's own field
[MaxLength(20)] string Badge = ""; // also the app's own — not named below, so it follows the kit's rules
security {
allow read when IsAuthenticated || IsAuthenticator;
allow create when IsAuthenticator;
allow update when IsAuthenticated || IsAuthenticator where Id == user.Id;
allow update Mentor when IsLead; // ← names the app's field: for Mentor, this decides
}
}- Only your own fields. The package's fields — its own and those it inherits — stay governed by the package. Your
block may add a
denyon one of them, which only narrows; anallowon one is refused (deny on an inherited field). - A field follows the type that declares it. The same holds when the extender is itself a kit: kit B's subtype of kit A's entity decides the fields kit B adds, on its rows and on any app subtype below it, while kit A's fields stay kit A's ([[security-inherited-field-deny#kit-on-kit]]).
- Only
update, field by field. Who may read the row, create it or delete it stays the package's. A write that touches one of your named fields AND a package field passes only if both are allowed: a lead above may setMentoron anybody's account, and a write ofMentorwith the account's name in it is refused.
A workflow over a base covers its subtypes#
Tracks = WorkItem.Handling drives Handling on every row that is a WorkItem — its subtypes' rows included —
exactly as a function taking a WorkItem takes a Ticket. An Autostart workflow over a base therefore starts a run
for a new Ticket row, WorkHandling.RaiseRespond(ticket) takes the subtype, and Workflow.WorkByItem<Ticket>()
answers with the tickets among the base workflow's items while Workflow.WorkByItem<WorkItem>() answers with all of
them. This is what lets a kit own a process over a base the app derives from — the WorkQueues kit's WorkHandling over every
app's Ticket : WorkItem.
One machine per column still holds. A second Autostart workflow over the same column anywhere in the hierarchy —
Tracks = Ticket.Handling beside the base's Tracks = WorkItem.Handling — is refused at compile, naming both, because
every ticket would get two runs driving one value. Give the subtype its process over a column of its own:
entity WorkItem { Handling Handling; } // WorkHandling tracks this, for every kind of item
entity Ticket : WorkItem { TicketStage Stage; } // the ticket's own lifecycle tracks this
workflow WorkHandling { Tracks = WorkItem.Handling; Autostart = true; Initial = New; /* … */ }
workflow TicketLife { Tracks = Ticket.Stage; Autostart = true; Initial = Open; /* … */ }
// workflow TicketHandling { Tracks = Ticket.Handling; Autostart = true; … } ✗ refused — WorkHandling drives itAn item with two runs, like the ticket above, is still one row of Workflow.WorkByItem<Ticket>(): its governing
clock is the soonest across both runs, and its holder whoever holds a slot of either.
Examples#
enum TaskState { Open, Doing, Done }
entity WorkItem {
[Required, MaxLength(200)] string Title;
TaskState State = TaskState.Open;
}
// Its own type, so its own rules and its own process — and no new columns.
entity UrgentItem : WorkItem {
[Required] DateTime DueBy;
}See also#
- sealed — how a type says no one may derive from it
- entity — what an
entityis, and what the platform provides for free - security { } — the
security { }block a subtype must write for itself - invariant — the row-level checks a subtype accumulates from its base
- relations — a reference typed as a base holds any of its subtypes
demo/entity-inheritance— the runnable demo: four kinds of document in one table, three levels deep, with per-type security you can see by signing in as three different people (osy docs sampledoes not ship it; it lives in the repo'sdemo/tree)