Summary#
Two kits that do not know each other can be related by a third package — a bridge — or by the app, without a link
entity: a partial entity adds an optional reference to one kit's entity and its inverse collection to the other's.
// in a bridge package that uses both kits
partial entity Quote { Opportunity? Opportunity; } // a column on Quote's table
partial entity Opportunity { [ForeignKey(Opportunity)] Quote[] Quotes; } // navigation: no columnFrom then on they are ordinary members, for the bridge's code and the app's alike: q.Opportunity = o;,
o.Quotes.Sum(q => q.Total), a report from Quote grouped by Opportunity.Name.
Signature#
partial entity <Entity> {
<Type>? <Member>; // a reference or a value nobody has to supply
<Type> <Member> = <default>; // …or one with a default
[ForeignKey(<Back>)] <Element>[] <Members>; // the inverse collection of a reference <Element>.<Back>
security { deny <ops> <Member> when …; } // a bridge may only take access away, from what it adds
}Description#
What it is#
A partial entity names an entity a used package declares, and its members move onto that entity's own declaration
before anything is compiled. So each one is an ordinary member of the owner:
- a reference or a value is a column on the owner's table — schema evolution adds it on the compile that first sees
it (the owner's existing rows read it as
null), and every later compile keeps it and its data; - a collection is navigation only: the live list of the rows whose reference points back, with nothing stored. It updates before a commit like any collection, so it is the right way to list them (not a query on the back-reference);
- Explore and Reports walk it, because they follow declared relations — a report rooted at
Quotecan group byOpportunity.Name, andOpportunity › Quotes › Totalis offered as a field; - each member remembers the file that declared it, so the model says which package (or the app) added it.
The owner's own code was compiled without the member and never names it. Generic readers — Explore, Reports, Fields,
Privacy, osy model — see it, because they read the model rather than the owner's source.
Optional only#
The owner creates rows with code that has never heard of the member, so a member every row must have could never be
supplied. A member is optional when it is nullable (T?), has a default (= …), is a collection, or is a number or a
bool (whose zero is a value). The compiler refuses everything else where it is written:
`Quote.Opportunity` is an extension member — added to `Quote` by package `Acme.Crm.Cpq` — so it must be optional:
`Acme.Cpq` creates `Quote` rows with code compiled without it, and could never supply it. It carries `[Required]` with
no default. Write `Opportunity? Opportunity;` (none until something sets it), or give it a default
(`Opportunity Opportunity = …;`).For the same reason it may not carry [Unique], [Key] or [Id]: those would refuse writes the owner's code makes.
One name, one owner#
A name the owner (or its base) already declares cannot be added again, and two extensions cannot add the same name to one entity. Both are refused naming both sides — which is also how a later version of the owner that adds the same name is caught, on the compile after the update:
`Quote.Opportunity` is added by both package `Acme.Crm.Cpq` and package `Acme.Deals` — two extensions cannot add one
name to one entity. Drop one of the two from this app's `use` lines, or rename one of them.
`Quote.Total` is declared by `Quote` itself and added again by package `Acme.Deals` — one name on one table cannot have
two owners. Rename the extension (e.g. `DealsTotal`), or use the `Total` `Quote` already has.Security#
The member is a column of the owner's rows, so the owner's security { } decides every read and write of it:
reading o.Quotes returns the quotes the reader may read, and writing q.Opportunity needs update on the quote.
Whoever extends may only take access away:
- a bridge may write
security { deny <ops> <Member> when …; }over the members it adds — appended to the owner's own rules. Anallow, adefault, or a rule over a member it did not add is refused:Package `Acme.Crm.Cpq` extends `Quote`, so it may only take access AWAY, and only from what it adds: `deny <ops> <Member> when …;` naming one of `Opportunity`. Who may read or write a `Quote` row is `Quote`'s own `security { }` (and the app's `partial entity Quote`). - the app states its rules as always, with
partial entity Quote { security { … } }(see [Authority] for how far an app may change a package's rules).
Whose entity can be extended#
An entity a package declares — a kit, or a package of your own. Not:
- one of the platform's own tables (the core baseline, a capability the platform ships):
partial entity Xover one of those may statesecurity { }and nothing else, and the refusal says to put the reference on your own entity instead; - your own entity — add the member to its own
entity X { … }block; - from the app, a COLUMN naming one of the app's own types — the package's table is compiled before the app's types
exist, so put the reference the other way round, on your entity:
Its inverse is the exception — see the app's list of its own rows below.`Quote.Deal` names `Deal`, which this app declares — but `Quote` is a package's table, compiled before the app's own types exist, so an extension member on it can name only what the packages declare. Point the reference the other way: declare `Quote? Quote;` on your `Deal`.
The app's list of its own rows#
Once your entity references a package's — [Required] Conversation Conversation; on your Ticket — the package's
entity can carry the list of your rows that point at it:
partial entity Conversation { [ForeignKey(Conversation)] Ticket[] Tickets; }It is navigation, not a column, so nothing in the package's table changes: the compiler emits it in your app's pass,
over your own reference. It is an ordinary collection — it shows a ticket created in the same unit of work before
anything commits, loads on first touch, and Include pre-loads it — so conversation.Tickets is the way to read
them, not Ticket.Where(t => t.Conversation == conversation). (A query over a SET of conversations at once —
Ticket.Where(t => ids.Contains(t.Conversation.Id)) — is still a query: it is one statement where a list per
conversation would be one load each.)
[ForeignKey(…)] names your entity's reference to the package's entity; without it, or naming something else, the
compiler says which member to name:
`Conversation.Tickets` lists the app's `Ticket` rows — a list on a package's entity is the inverse of a reference your
`Ticket` holds, so it names that reference: `[ForeignKey(Conversation)] Ticket[] Tickets;`.Deleting the package's row#
Your reference to a package's entity is a real foreign key, whether or not you declare its list. Deleting the package's row — from your code, or from the package's own, which has never heard of your types — never deletes yours:
| your reference | deleting the package's row… |
|---|---|
required ([Required] Conversation Conversation;) | is refused while one of your rows points at it |
optional (Conversation? Conversation;) | empties the reference on your rows |
A reference between your OWN entities differs on purpose: there a required reference deletes its rows with the parent (see relations), because both sides are yours.
When the key arrives over existing rows#
An app compiled before its references to a package's entity carried a key — or one that turns an id into a reference — may hold rows naming a package row that has since been deleted: nothing stopped it then. The compile that adds the key settles those rows first, in the same statement:
| your reference | a row naming a package row that does not exist… |
|---|---|
optional (Conversation? Conversation;) | is emptied — the reference reads null, which is what an optional reference to nothing means |
required ([Required] Conversation Conversation;) | refuses the compile, and changes nothing, naming the rows |
A refusal names what you need to fix the data — the entity and member, each row's id and the id it names (the first twenty), and the way out:
Schema evolution failed: 23503: Ticket.Conversation is required, and 2 Ticket row(s) name a Acme.Talk.Conversation that does not exist, so its foreign key cannot be added over them. Point each at a Acme.Talk.Conversation that exists or delete it, or make the reference optional (Acme.Talk.Conversation? Conversation;), whose missing rows are emptied; then compile again. The rows (Ticket id -> missing Acme.Talk.Conversation id): 0b1c… -> 77e2…, 4f0a… -> 9d13….
osy compile prints this line as it is; the database's name and the statements it ran stay in the platform's log.
The same holds for every key the platform adds over rows that were already there, between your own entities too.
What it is not#
It does not change the owner's behaviour: a partial may not declare an invariant, a method or a constructor. A bridge
adds data and navigation; the owner's rules, events and pages stay its own. To change those, take over the kit's page,
handle its events, or fork it into your project's kits/.
Examples#
The app's own field on a kit's entity#
The app adds a member to the Identity kit's Principal and reads it like any other.
app Club {
use Osysharp.Identity;
model "model/**/*.osy";
}
using Osysharp.Identity;
[Role] enum ClubRole { Authenticator, Member, Steward }
/// What a member likes to be called — the app's own field on the Identity kit's `Principal`. Optional, as every
/// extension member is: the kit creates principals with code that has never heard of it.
partial entity Principal { [MaxLength(60)] string? Nickname; }
public string Greeting(Principal p) { return "Hello, " + (p.Nickname ?? p.DisplayName) + "!"; }Every kind of account derived from Principal — the UserAccounts kit's User, an app's Member : Principal — shares its
table, so each of them has Nickname too.
The app's list of its own rows on a kit's entity#
The app's Note references the Identity kit's Principal; the kit's entity gains the list of notes each one wrote.
app Club {
use Osysharp.Identity;
model "model/**/*.osy";
}
using Osysharp.Identity;
[Role] enum ClubRole { Authenticator, Member, Steward }
/// A note a member wrote — the app's own entity, referencing the kit's `Principal`.
entity Note {
[Required, MaxLength(200)] string Text;
[Required] Principal Author;
security { allow read, create when IsAuthenticated; }
}
/// Every note a principal wrote: the inverse of `Note.Author`, on the kit's entity.
partial entity Principal { [ForeignKey(Author)] Note[] Notes; }
public int NotesBy(Principal p) { return p.Notes.Count(); }Deleting a principal who wrote a note is refused, because Note.Author is required; make it Principal? Author; and
the delete empties it instead.
A bridge relating two kits#
The whole of a bridge between a CRM kit and a CPQ kit — its package uses both, and neither knows it exists:
namespace Acme.Crm.Cpq;
using Acme.Crm;
using Acme.Cpq;
/// The opportunity a quote prices — a quote made in CPQ alone has none.
partial entity Quote {
Opportunity? Opportunity;
security { deny read Opportunity when IsAnonymous; } // only ever tighter than Quote's own rules
}
/// Every quote made for this opportunity.
partial entity Opportunity { [ForeignKey(Opportunity)] Quote[] Quotes; }
/// A quote for an opportunity — the bridge writes the member it added.
public Quote QuoteFor(Opportunity o, decimal total) {
return new Quote { Title = o.Name, Total = total, Opportunity = o };
}
/// What an opportunity is quoted at — through the inverse collection.
public decimal Quoted(Opportunity o) { return o.Quotes.Sum(q => q.Total); }The platform's own tests compose exactly these three packages and assert each sentence above
(Platform.Tests/Runtime/Function/ExtensionMembersTests.cs).
See also#
- relations — references and the
[ForeignKey]collection an extension adds - security { } — the owner's
security { }, which governs the member - [Authority] — how far an app's
partial entitymay change a package's rules - entity Sub : Base — a subtype of a package's entity, the other way to add members (for one kind of row)
- Entity — a reference to a row of any type, when the target is not one entity