OData und Facetten¶
Jede Entity wird mit einer Zeile zu einem OData-Set. Tabellen, Exporte und externe Werkzeuge lesen darüber; Listenabfragen schreibst du nicht.
[DependsOn(typeof(CoworkeeODataModule))]
public sealed class MyAppCatalogModule : CoworkeeModule
{
public override void ConfigureServices(ModuleServiceContext context)
{
context.Services.AddODataEntity<Brand>("Brands", CatalogPermissions.Brands.View);
context.Services.AddODataEntity<Product>("Products", CatalogPermissions.Products.View);
}
}
| Anfrage | Ergebnis |
|---|---|
GET /odata/Products?$filter=Rate gt 20&$orderby=Name&$top=25&$skip=50&$count=true |
eine Seite, Gesamtzahl |
GET /odata/Products?$expand=Brand |
Produkte mit ihrer Marke |
GET /odata/Products({id}) |
ein Produkt |
GET /odata/Products?$select=Name,Rate |
nur diese Spalten |
Die Berechtigung des Sets wird bei jedem Aufruf geprüft, Mandanten- und Soft-Delete-Filter gelten wie überall. $top ist auf 1000 begrenzt (Coworkee:OData:MaxTop); ungültige Abfragen beantworten 400 mit Problem Details.
Schlüssel außer Id¶
Ein Set mit einer anderen Schlüsseleigenschaft, von beliebigem primitivem Typ, nennt sie mit Key:
services.AddODataEntity<Country>("Countries", MasterDataPermissions.View, o => o.Key(c => c.IsoAlpha2Code));
GET /odata/Countries('DE') liest eine Zeile (/odata/Ranks(2) bei einem Zahlenschlüssel). Listen, $count, Facetten, die globale Suche (die Id des Treffers ist der Schlüssel), Exporte, Berichte und query_data funktionieren wie bei jedem Set. Funktionen, die Zeilen über eine Guid-Id ansprechen, weisen eine Entity ohne sie schon bei der Registrierung ab: Datensatz-Anhänge, Social (Kommentare, Tags, Bewertungen, Reaktionen), Workflows, Entity-Historie und Webhook-Entity-Ereignisse. Client-Entities lassen solche Sets auf dem Server. Importe, die aktualisieren oder upserten (AddODataImport mit Id), finden Zeilen über den Schlüssel des Sets, der eine Guid sein muss; bei anderen Schlüsseln den Import ohne Id registrieren (nur Anlegen), sonst wirft die Registrierung. AddExtendedAttributes weist eine Entity ohne Guid-Id ab. Eine Entity, die neben einem anderen OData-Schlüssel eine Guid-Id behält, hat sie alle weiterhin.
Namen in der URI passen unabhängig von Groß- und Kleinschreibung, ein camelCase-Client darf also $filter=name eq 'x', $orderby=price oder $expand=brand senden; Coworkee:OData:CaseInsensitive (Standard true) schaltet das ab. Antworten behalten die Namen der C#-Eigenschaften (Name); ein Client, der camelCase-JSON möchte, bildet sie selbst ab. Abfrageoptionen brauchen ihr $: filter= wird nicht gelesen. Eine Antwort 400 erklärt ODatas eigene Fehler in detail; andere Fehler einer Abfrage (etwa wenn EF sie nicht übersetzen kann) kommen ohne Detail und werden protokolliert, weil ihre Meldungen Zeilenfilter zitieren können.
Facetten¶
Markiere die Eigenschaften, nach denen Benutzer filtern sollen:
using Nextended.Core.Facets;
public sealed class Product : AuditedEntity, IMultiTenant
{
[ProvideFacet(Label = "Brand", ValuePath = "BrandId", LabelPath = "Brand.Name", ValueType = typeof(Guid))]
public Guid BrandId { get; set; }
public Brand? Brand { get; set; }
}
LabelPath ist ein Dynamic-LINQ-Ausdruck. Eine Navigation, die wie eines seiner Schlüsselwörter heißt (Parent, It, Root, New, ...), braucht das Präfix it.: LabelPath = "it.Parent.Name"; ohne scheitert die Facettenabfrage. coworkee entity add schreibt immer it..
Fragt der Request mit Prefer: odata.include-annotations="cn.facets" danach, enthält die Seite die Facettengruppen unter @cn.facets: jede Option mit Beschriftung, Anzahl und dem OData-Filterfragment, das sie setzt. Die Zahlen folgen dem aktuellen Filter, wie man es von Shop-Filtern kennt. Enum-Optionen tragen den Membernamen als Wert und Filter (Status eq 'Shipped') und ihren [Display]-Namen als Beschriftung; [Flags]-Enums listen jedes Flag einzeln (Options has 'Express'). Zahlenbereiche und Datumswerte kommen mit kleinstem und größtem Wert und einem Histogramm. [FacetBehavior] neben [ProvideFacet] oder services.ConfigureFacet<T>(...) legt die Ansicht fest und wie Werte verknüpft werden (eines von, alle von, einzeln, mit Ausschluss). Mit dem Header Coworkee-Facets zählt eine Gruppe ohne ihre eigene Auswahl. CoworkeeDataTable zeigt sie in Ansichten passend zu ihren Werten.

Regeln pro Zeile¶
Manche Zeilen sind nicht für alle. Ein Entity-Filter schränkt Listen, Zähler, Facetten und Einzelabfragen gleichermaßen ein:
internal sealed class DocumentODataFilter(DocumentVisibility visibility) : IODataEntityFilter<Document>
{
public Task<IQueryable<Document>> ApplyAsync(IQueryable<Document> query, CancellationToken cancellationToken) =>
visibility.VisibleAsync(query, cancellationToken);
}
services.AddScoped<IODataEntityFilter<Document>, DocumentODataFilter>();
Kind-Zeilen folgen ihrem Elternteil¶
Zeilen, die zu einem Elternteil gehören (Punkte eines Gesprächs, Positionen eines Auftrags), sollen genau dann sichtbar sein, wenn das Elternteil es ist. Statt den Filter des Elternteils zu wiederholen:
Ein Punkt wird gezeigt, wenn die sichtbaren Gespräche (alle IODataEntityFilter<Talk>, Mandant und Soft Delete, so wie ODataEntityAccess.VisibleAsync<Talk> sie liefert) sein Gespräch enthalten; die Abfrage ist eine Unterabfrage auf den Fremdschlüssel, kein Join. Punkte ohne Gespräch sind ausgeblendet. Weitere Filter des Kinds gelten zusätzlich.
Navigationen folgen ihrem Ziel¶
Über Navigationen erreicht eine Abfrage andere Zeilen: $expand (auch [AutoExpand]-Navigationen, die ohne es expandieren), $filter=Invoices/any(...), $orderby=Customer/Name, verschachteltes $count, $compute, /$count, Facetten und ihre Beschriftungen, Filter von Export und query_data, Felder der globalen Suche, Berichtsfelder. Sie zeigen genau das, was das eigene Set des Ziels dem Benutzer auflistet:
- eine Sammlung enthält nur die Zeilen, die die
IODataEntityFilter<T>des Ziels durchlassen, undany,allund$countsehen nur diese; - eine einzelne Navigation (
Customer) istnull, wenn ihre Zeile ausgeblendet ist, auf jeder Ebene (Invoice/Shipment/Customer), und eine Sammlung unter einer ausgeblendeten Referenz ist leer; $expandin ein Set, dessen Berechtigung dem Benutzer fehlt, antwortet403mit dem Namen der Navigation; in einem$filterund hinter einer[AutoExpand]-Navigation hat dieses Set keine Zeilen;- geschützte Eigenschaften (
HideUnless), ausgeblendete Member einer geteilten Basis und berechnete Eigenschaften mitPermissiondes Ziels bleiben ausgeblendet, auch über die Navigation: wer sie nennt, bekommt403, expandierte Zeilen lassen sie weg; - ein Typ mit Zeilenfiltern, aber ohne eigenes Set, bekommt seine Filter ebenfalls;
- eine Navigation zu einem Basistyp (
Invoice.Shipmentvom TypShipmentBase) wendet die Filter und Berechtigungen jedes abgeleiteten Sets auf ihre Zeilen an; - Facetten zählen die eingeschränkten Zeilen, und eine Facettenbeschriftung über eine Navigation (
[ProvideFacet(LabelPath = "it.Customer.Name")]) zeigt den Schlüssel statt des Namens, wenn der Benutzer die Zeile nicht sehen darf.
Die Filter wirken innerhalb der Abfrage, deshalb müssen sie einfache Where-Aufrufe auf der übergebenen Abfrage sein (Closures mit Ids, EF.Parameter, Unterabfragen und AddODataChildFilter gehen). Ein Filter, der mehr tut (OrderBy, Take, Join, eine neue Abfrage) oder auf der leeren In-Memory-Abfrage scheitert, aus der er gelesen wird (einer, der zuerst die Datenbank fragt), lässt sich nicht auf eine Navigation anwenden: $expand in seinen Typ antwortet 400, und in einem $filter hat der Typ keine Zeilen. Andere Abfragen trifft das nicht: geprüft werden nur die Ziele, die eine Abfrage wirklich erreicht, einmal pro Anfrage. Die eigene Liste des Sets nutzt den Filter weiter so, wie er ist. EF-Abfragefilter des DbContext (Mandant, Soft Delete) gelten für Navigationen wie immer.
Die Prüfungen laufen auf der Abfrage, die OData bindet, nicht auf ihrem Text: jede Eigenschaft und Navigation, die $filter, $orderby, $compute, $apply, $select und $expand (auch verschachtelte Optionen) lesen, wird gegen die gesperrten Member des Typs geprüft, von dem sie gelesen wird, seiner Basen und, über einen Basistyp gelesen, jedes abgeleiteten Sets. ?+$filter=, %20$expand= und Featured/ Size werden also wie jede andere Schreibweise geprüft, und ein Literal wie 'a/Note' ist keine Eigenschaft. Member werden geprüft, gleich welcher Knoten sie liest, Aggregate über Entity-Sets (aggregate(Employees(Salary with max as M))) und in $expand verschachtelte Optionen eingeschlossen, und eine Abfrage, die die Prüfung nicht parsen kann, antwortet 400, statt zu laufen. $search sucht nur in den Text-Eigenschaften, die der Benutzer lesen darf (kein Hide, kein HideUnless ohne die Berechtigung), in Listen und Exporten; ein Suchfeld, das eine ISearchable-Entity nennt (x => x.FirstName + " " + x.LastName, x => x.Note.ToLower()), fällt weg, wenn ein Member, den es liest, ausgeblendet oder gesperrt ist oder über eine Navigation erreicht wird, deren Ziel Zeilenfilter oder eine fehlende Berechtigung hat. Parameter-Aliase ($filter=@a gt 5&@a=Size) werden mit 400 abgewiesen, ebenso $apply über eine Navigation zu Zeilen mit Zeilenfiltern oder Berechtigungen (ein ausgeblendetes Ziel ergäbe eine Gruppe leerer Werte); $apply über die eigenen Eigenschaften des Sets oder über Navigationen ohne solche Regeln funktioniert. Eine Facette, deren Wert über eine Navigation gelesen wird (ValuePath = "it.Customer.Name"), fällt weg, wenn ihr Ziel Zeilenfilter oder eine Berechtigung hat, die dem Benutzer fehlt, oder der Wert ihm gesperrt ist; der Schlüssel des Ziels (Customer/Id) bleibt, wenn er der eigene, gemappte und lesbare Fremdschlüssel der Zeile auf den EF-Schlüssel des Ziels ist, und komplexe Member und Ziele ohne solche Regeln bleiben. Eine Facette über eine Sammlung (it.Lines.Id) wird beim Aufbau des Modells abgewiesen. $expand=*($levels=2) in ein Set, das der Benutzer nicht lesen darf, kommt leer zurück statt mit 403, wie eine [AutoExpand]-Navigation.
Nicht abgedeckt: eigener Code, der ohne den Scope Navigationen auf ODataEntityAccess.VisibleAsync setzt, und die Funktionen Title, Subtitle, Url und Thumbnail der globalen Suche (sie sehen nur die eigenen Spalten der Zeile). ODataNavigationScope.For(services, typeof(T)) gibt eigenen Abfragen dieselben Regeln: await scope.ApplyAsync(query, ct) schränkt jede Navigation darin ein, außerhalb der Zeilen, die VisibleAsync geliefert hat.
Berechnete Eigenschaften¶
Ein Wert, der aus anderen Spalten oder anderen Tabellen berechnet wird, ist eine ODataComputedProperty des Sets. Die Datenbank wertet sie aus, deshalb funktionieren $filter, $orderby und $select auf ihr; zurück kommt sie nur, wenn $select sie nennt:
internal sealed class ShipmentProperties : IODataComputedPropertySource
{
private static readonly Expression<Func<CalculatedShipment, string>> Display = s => s.ShipmentNumber + " / " + s.HouseBill;
public IEnumerable<ODataComputedProperty> Properties(ODataEntityRegistry registry) =>
[new(typeof(CalculatedShipment), "ReferenceNumberDisplay", typeof(string), row => ODataComputedProperty.Inline(Display, row))];
}
services.AddSingleton<IODataComputedPropertySource, ShipmentProperties>();
GET /odata/CalculatedShipments?$select=Id,ReferenceNumberDisplay&$orderby=ReferenceNumberDisplay&$filter=contains(ReferenceNumberDisplay, 'HB-1')
Ein Ausdruck über die eigenen Spalten der Zeile funktioniert auf jedem Set, auch auf einem einer externen Datenbank. Eine Unterabfrage auf eine andere Tabelle nutzt ComputedQueryRoot.Of<T>() als Quelle; das braucht den eigenen DbContext der App. Permission blendet die Eigenschaft für Benutzer ohne die Berechtigung aus, Exported macht sie zur Exportspalte, Facet gibt ihr eine Facettengruppe. Eine C#-Eigenschaft nur mit Getter (public string Display => ...) ist keine Spalte; das OData-Modell lässt sie weg, also so einen Wert auf diesem Weg anbieten.
Eigenschaften ausblenden¶
Interne Felder bleiben aus dem Modell, sie werden weder ausgeliefert noch sind sie filterbar:
Hide und HideUnless nehmen auch Member, die eine Basisklasse deklariert (o => o.Hide(s => s.Tags) mit Tags auf ShipmentBase). Meist gibt das EDM jedem Set eine eigene Kopie der Member der Basis, und sie verschwinden nur aus diesem Set. Zeigt eine Navigation auf die Basisklasse (Invoice.Shipment vom Typ ShipmentBase), hat das EDM die Basis als eigenen Typ, den alle abgeleiteten Sets teilen, und ein ausgeblendeter Member kann ihn nicht für ein Set allein verlassen. Er bleibt dann im Modell, und auf dem ausblendenden Set wird er für alle abgewiesen wie eine HideUnless-Eigenschaft, deren Berechtigung niemand hat: nicht serialisiert, $filter, $orderby, $select und $expand mit seinem Namen antworten 403, Exporte, Berichte und Suche lassen ihn weg. Andere Sets der Basis behalten ihn. Die App protokolliert beim Start eine Warnung. Zeilen, die über eine Navigation zur Basis erreicht werden, lassen die ausgeblendeten Eigenschaften und Navigationen ihres eigenen Sets weg (Stickers?$expand=Owner($expand=Stickers) hat keine Sticker an Besitzern, deren Set sie ausblendet).
Eigenschaften hinter einer Berechtigung¶
Manche Felder sind nur für manche Benutzer, Gehälter etwa für die Personalabteilung. HideUnless behält eine Eigenschaft im Modell, hält sie aber von Benutzern ohne die Berechtigung fern:
services.AddODataEntity<Employee>("Employees", HrPermissions.Employees.View, o => o
.Hide(e => e.PasswordResetToken)
.HideUnless(e => e.Salary, HrPermissions.Salaries)
.HideUnless(e => e.BirthDate, HrPermissions.PersonalData));
Für einen Benutzer ohne die Berechtigung:
- Listen und Einzelabrufe lassen die Eigenschaft weg (im DTO des Clients nullable machen, damit sie leer ankommt und nicht als
0); $filter,$orderby,$select,$expand,$apply,$computeund Facettenfilter, die sie nennen, antworten mit403, auch über eine Navigation (Manager/Salary);- ihre Facettengruppe fehlt;
- Exporte lassen die Spalte weg und lehnen Filter darauf ab;
- Berichte bieten das Feld nicht an und gruppieren, aggregieren und filtern nicht danach;
- die globale Suche findet nichts über sie (eigene
Title- oderSubtitle-Funktionen dürfen sie auch nicht zeigen); list_data_setsundquery_datades Assistenten listen, liefern und filtern sie nicht;- die Entity-Historie lässt ihre Änderungen weg, und eine Wiederherstellung fasst sie nicht an;
- Webhooks eines Endpunkts, dessen Besitzer die Berechtigung fehlt, lassen sie weg.
Geprüft wird pro Berechtigung, nicht pro Zeile. Das Audit-Log für Admins (Audit.View) zeigt weiterhin jede Änderung.
Aus dem Blazor-Client¶
IODataClient baut die URL und liest Werte, Anzahl und Facetten:
var page = await odata.QueryAsync<BrandDto>("Brands", new ODataQuery { OrderBy = "Name", Top = 1000, Count = false });
Der BFF leitet /odata mit dem Token des Benutzers an die API weiter.
Client-Entities¶
Markiere eine Entity mit [ClientEntity], und Blazor-Clients halten eine Kopie ihres Sets und fragen sie lokal ab; siehe Client-Entities.
Export und Import¶
Jedes Entity-Set lässt sich mit $filter, $search und $orderby der Liste als Excel, CSV oder JSON exportieren; AddODataImport macht ein Set über einen Assistenten importierbar, mit Spaltenzuordnung, Vorschau, Upsert, Probelauf, Hintergrundjobs und Rückgängig:
services.AddODataImport("Brands", (Guid? id, AddEditBrandRequest row) => new AddEditBrandCommand(id, row));

Alles Weitere steht unter Import und Export.