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
- Extend
ScopedAbstractEntitySetand implementbaseQuery(): Builder— the unscoped source query. - Declare at most one scope attribute on the class. Two families exist; mixing them (or declaring two) throws a
LogicException. - That's it.
query()interposes the scope and hands aBuilderto odata.
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
| Family | Attribute(s) | Asks | Reads | Applier |
|---|---|---|---|---|
| Partner-scope | #[ScopedToActor], #[ScopedToOrgActors], … (any PartnerScopeAttributeInterface) | who is this actor? | sdk_partner_relationships | PartnerScopeApplier |
| Role-scope | #[ScopedByRole(role, contextType)] | what does this actor have permission over? | sdk_role_assignments | RoleScopeApplier |
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 ownpartner_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.
#[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
SdkContextbound (outside the UI5/OData pipeline, or the upstream invariant was violated). On the OData prefix this is wired byBindSdkContextForOData. - 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 — runui5:sync).
Notes
- Composes with odata: scope is applied first;
$filter/$orderby/$skip/$topchain on top, so paging is stable within the scoped set. - Requires
ui5:sync— role-scope readssdk_roles/sdk_role_assignments. - The appliers (
PartnerScopeApplier,RoleScopeApplier) are internal; you only ever touchScopedAbstractEntitySet+ one attribute.