Skip to content

Scoped Entity Sets (row-level OData scoping) ​

ScopedAbstractEntitySet adds row-level visibility to an OData entity set: it narrows your source query to the rows the current actor may see, before odata applies its $filter / $orderby / $skip / $top pipeline. You write the unscoped query; a single scope attribute declares how it is narrowed.

Fail-closed by design. If no actor context is bound, or the actor resolves to an empty set, the query returns no rows (1 = 0) — never "everything". A scoped entity set cannot leak rows by accident.

Anatomy ​

  1. Extend ScopedAbstractEntitySet and implement baseQuery(): Builder — the unscoped source query.
  2. Declare at most one scope attribute on the class. Two families exist; mixing them (or declaring two) throws a LogicException.
  3. That's it. query() interposes the scope and hands a Builder to odata.
php
use LaravelUi5\Sdk\Odata\ScopedAbstractEntitySet;
use LaravelUi5\Sdk\Partners\Attributes\ScopedToOrgActors;

#[ScopedToOrgActors]                       // ← one scope attribute
final readonly class MyLicenseEntitySet extends ScopedAbstractEntitySet
{
    protected function baseQuery(): Builder
    {
        return DB::table('sdk_licenses')->whereNull('terminated_at');
    }
    // entitySetName(), key(), columns() … as usual
}

The two scope families ​

FamilyAttribute(s)AsksReadsApplier
Partner-scope#[ScopedToActor], #[ScopedToOrgActors], … (any PartnerScopeAttributeInterface)who is this actor?sdk_partner_relationshipsPartnerScopeApplier
Role-scope#[ScopedByRole(role, contextType)]what does this actor have permission over?sdk_role_assignmentsRoleScopeApplier

The families are deliberately unrelated — different tables, different questions.

Partner-scope ​

The attribute names a resolver (via resolverClass()) that returns the partner-id set, and a key column (keyColumn(), default partner_id). The applier emits whereIn(keyColumn, ids).

Column convention (contract): your baseQuery() must expose the keyColumn — partner_id by default, or whatever you pass (#[ScopedToOrgActors('org_id')]). Built-in resolvers:

  • #[ScopedToActor] → SelfResolver — rows whose column equals the actor's own partner_id.
  • #[ScopedToOrgActors] → OrgPartnerResolver — rows whose column matches an org the actor is a primary employee of.

A new bridge = one resolver (ScopeResolverInterface) + one sugar attribute (PartnerScopeAttributeInterface); no applier change.

Role-scope ​

#[ScopedByRole(role, contextType)] shows rows the actor holds role on, within contextType. The applier emits whereIn("{contextType}_id", contextIds).

Column convention (contract): your baseQuery() must expose "{contextType}_id" — for #[ScopedByRole('key_account', 'partner')] that's partner_id; for contextType: 'territory', territory_id.

php
#[ScopedByRole('key_account', 'partner')]
final readonly class ProspectsEntitySet extends ScopedAbstractEntitySet
{
    protected function baseQuery(): Builder
    {
        return DB::table('prospects_view');   // exposes partner_id
    }
}

Fail-closed cases (all return zero rows) ​

  • No SdkContext bound (outside the UI5/OData pipeline, or the upstream invariant was violated). On the OData prefix this is wired by BindSdkContextForOData.
  • The resolver returns an empty set (no active employment, no role grant, …).
  • A role code isn't found in sdk_roles (not synced or renamed — run ui5:sync).

Notes ​

  • Composes with odata: scope is applied first; $filter/$orderby/$skip/$top chain on top, so paging is stable within the scoped set.
  • Requires ui5:sync — role-scope reads sdk_roles/sdk_role_assignments.
  • The appliers (PartnerScopeApplier, RoleScopeApplier) are internal; you only ever touch ScopedAbstractEntitySet + one attribute.