ZuloOne
English
Sign in
Menu

Data Entity

Updated

A public OData resource of a model: an external projection of a dictionary or document. This is not a web service. A web service is an operation (POST /api/rest/{Name}). A Data Entity is a record set with $metadata, $filter, $select, and CRUD.

← Developer wiki

Why#

An integrator (bank, EDI, Power BI) works with a collection, not with RPC. Internal /api/data/{table} is not suitable for this: it is the shape of internal tables, without a public contract.

What it consists of#

File What it contains
DataEntities/<Name>/<Name>.dataentity.json object + sources + fields
DataEntities/<Name>/<Name>EventHandler.script.json event script binding
DataEntities/<Name>/<Name>EventHandler.cs mapping and write hooks

Designer: Developer → Scripting → Data entities (/dataentities).

Header: name, PublicCollectionName (empty = object name), Active, Read-only, Require explicit roles. Tabs: sources, fields, rights. The event script is behind the Events button in the header bar — the same place every other metadata designer keeps its module, and it opens as a workspace tab, not a floating window. On an entity that has no script yet the button reads Create event script and creates it on the first click.

That first click goes through POST /api/metadata/dataentities/{id}/ensure-script for a reason worth knowing before you wire another entry point to it. Unlike a dictionary or a document, whose handler is found by ObjectMetaId + ScriptType, a data entity's handler is resolved through the owner's foreign key: DataEntityODataService reads MetaDataEntities.ScriptMetaId, and the workspace exporter files the script by the same column. A MetaScript created by the generic slot editor would carry the right ObjectType/ScriptType (DataEntity / DataEntityEvents), compile cleanly, and still never run — nothing would have set that column. Create the script through the ensure endpoint, which sets it.

One source must be primary. Join is Inner or Outer by relation fields. A field is either mapped to a source column or IsComputed (code only). The entity key is the fields with IsKey (usually MetaId). Without a key there is no GET-by-key and no writes.

A disabled entity (IsActive = false) returns 404, like an unknown name.

How to call from outside#

The token is issued by login.getzulo.com; the tenant does not issue it.

POST https://login.getzulo.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=integrator
&client_secret=…
&resource=https://acme.getzulo.com

The token aud = resource, and only if the URL is in the client allow-list. Then:

GET https://acme.getzulo.com/odata/CustomersV2?$filter=ID eq 'C-100'&$select=ID,Name&$top=20
Authorization: ******

Examples with numbers:

  • $filter=Qty gt 10 and Qty le 50 — lines with quantity 11…50
  • $top=100&$skip=200 — the third page of 100
  • $count=true — @odata.count next to value

Codes:

  • 401 — signature/aud is not for this instance (ZuloOne:PublicUrl)
  • 403 — the email from the token is not in MetaUsers, the account is blocked, or there is no Read/Create/Update/Delete right
  • 404 — no such collection or IsActive=false
  • 405 — IsReadOnly and POST/PATCH/DELETE was called
  • 400 — $filter on a computed field (not stored yet)

Calling /api/rest/{Name} or /api/data/{table} with the same ****** uses the same MetaUser and roles. An integration account is not made Developer.

Cabinet and the control panel do not pass resource: their aud remains client_id, and the tenant rejects such a JWT. The tenant SPA still logs in through POST /api/auth/login.

Rights and RLS#

Two layers:

  1. On the entity — role × Read/Create/Update/Delete. RequireExplicitRoles: with an empty matrix it is public by default; with the flag, at least one grant is required.
  2. On the source table — existing MetaRecordPredicate Dictionary/Document. A Data Entity does not bypass dictionary RLS.

A superuser bypasses both layers.

Events#

public partial class CustomersV2EventHandler : TypedDataEntityEventHandler<CustomersV2>
{
    public override void MapFromSource(CustomersV2 entity, DataEntitySourceRow source, EventContext context)
    {
        // entity.Label = source.Get<string>("p.Name") + "-x";
    }

    public override void MapToSource(CustomersV2 entity, IDictionary<string, object?> source, EventContext context) { }

    public override Task<EventResult> OnValidatingWriteAsync(CustomersV2 entity, EventContext context)
        => Task.FromResult(EventResult.Ok());
}

The owner is chain link 0 and does not call next. An extension of another model’s entity uses [ExtensionOf("CustomersV2")] and calls next(...) from every override.

Writing goes to the primary table through IDataService, so events of the source dictionary fire.

How to call from a script#

Outside — /odata. From an event, command, service, or test — the same contract through GetService<IDataEntityManager>(). Do not go to the source table through IDataService / IDictionaryManager if you need the projection: the manager runs field mapping, MapFromSource / MapToSource, write hooks, and source RLS.

RequireExplicitRoles is HTTP only. A script of its own model must not need an OData grant to read its own entity. Source-table RLS remains: the manager does not bypass it.

The filter is OData $filter grammar, not SQL and not an IDictionaryManager predicate.

var de = context.GetService<IDataEntityManager>();
// in a service / test / tx: GetService<IDataEntityManager>()

var rows = await de.QueryAsync("CustomersV2", filter: "ID eq 'C-100'", select: "ID,Name", top: 20);
var one = await de.GetAsync("CustomersV2", customerMetaId);   // null if absent or hidden by RLS
var created = await de.InsertAsync("CustomersV2", new Dictionary<string, object?>
{
    ["ID"] = "C-101",
    ["Name"] = "Acme",
});
await de.UpdateAsync("CustomersV2", created["MetaId"]!, new Dictionary<string, object?> { ["Name"] = "Acme Ltd" });
await de.DeleteAsync("CustomersV2", created["MetaId"]!);
var n = await de.CountAsync("CustomersV2", "Name eq 'Acme'");

Example with numbers: QueryAsync("SalesLinesV2", filter: "Qty gt 10 and Qty le 50", top: 100, skip: 200) — the third page of 100 lines with quantity 11…50.

Typed, when the POCO is in scope (handler of this entity, class name = object Name): QueryAsync<CustomersV2>(filter: "ID eq 'C-100'"). In another event or service, the POCO is not included in the global entity assembly — call by collection name, like IDictionaryManager.SaveRecordAsync("Contract", bag).

A read-only entity on Insert/Update/Delete throws InvalidOperationException (same as 405 outside). An unknown name or IsActive = false is also an exception. GetAsync / DeleteAsync on a missing key: null / false.

A command has the DataEntityManager property on the base class, like DictionaryManager.

What this is not#

DMF, Excel add-in, $expand, registers as a source, nested document lines as a nested collection, or local POST /api/auth/token on the tenant. When a projection is needed, use IDataEntityManager, not raw IDataService / IDictionaryManager on the source table.

Web service for an operation, not a collection: web services.

Where next#

Was this page helpful?