Trait reference
An app class is mostly composition. The traits under SepoEngine\Traits supply
identity, CRUD, sharing, formatting and security; the app supplies tables,
methods and declarations.
Each trait below lists what it gives you, what it assumes exists on the class, and the behaviour that will surprise you.
At a glance
| Trait | Gives you | Assumes |
|---|---|---|
AuthUserTrait | Who is calling, and the auth DB handle | Env config |
CrudSecurityTrait | Capability and resource permission checks | $this->pdo on the auth database |
StandardCrudTrait | Create, update, delete, bulk, cascade, sync | inferAliasFromId, $multi, $validator, $shell, $executor |
SharingTrait | Ownership-scoped reads, share management | inferAliasFromId, getSharingKeyMap |
RecordInjectionTrait | Per-row _can_* flags; payload cleaning | AuthUserTrait |
FormatterTrait | Output shaping, WHERE building, file URLs | $multi (for setupTransformers only) |
SyncableTrait | Replication hooks | $this->pdo, $this->multi |
UniversalSyncLockTrait | Cascading state toggles | syncUpdateRecord |
HasUserFilters | User-type query scopes | — |
ResponseTrait | success() / error() envelopes | — |
UidGenTrait | UUID v4 generation | — |
Order of use statements does not matter to PHP here — none of these traits
declare conflicting members. Order them for readability.
AuthUserTrait
Answers "who is calling?" and owns a connection to the auth database.
protected function getAuthEnginePDO(): PDO
protected function getAuthenticatedUser(): string
protected function getUserRoleTag(string $uid): string
protected function getRequesterFlag(string $uid): string
protected function isRequesterAdmin(string $uid): bool
Identity is a request header. getAuthenticatedUser() constructs a
Request and reads api-user-key. It returns '' on any failure rather than
throwing — callers must treat empty as "no user", and the CRUD layer does
exactly that (an empty UID is a denial on any ownership-scoped table, not a
bypass).
Admin means one of two flags: UserType::ADMIN_FLAG or the literal
~SYS_USER. Admin status short-circuits sharing filters, row-level write
checks, and the resource grant check.
Connection. getAuthEnginePDO() reads SQLITE_ENABLED and either opens a
SQLite file under storage/ or a MySQL connection using AUTH_DB_HOST,
AUTH_DB_NAME, AUTH_DB_USER and AUTH_DB_PASS_ENC — the password is stored
encrypted and decrypted with CryptoAES and the AES key. The env key is
AUTH_DB_PASS_ENC, not AUTH_DB_PASS.
Gotchas
self::$cachedPdois a static in a trait, which PHP scopes per using class, not globally. Each app class gets its own handle. Combined with$this->pdofrom the shell, one app can hold two connections to the same auth database.getUserRoleTag()caches per request in$runtimeRoleCache.getRequesterFlag()andisRequesterAdmin()do not cache — every call is a query.deleteBulkcallsdeleteRecordper row, which callsisRequesterAdminper row. Expect N+1 on large batches.
CrudSecurityTrait
The two declarative permission layers.
protected function verifyAccess(?string $uid, string $operation, array $customMap = []): array
protected function isResourcePermitted(?string $uid, array $subjects, string $resourceType,
string $action, string $appSource, array $securityMap = []): bool
protected function isTablePermitted(string $uid, string $table, string $mappedPermission): bool
public function setSecurityPdo(PDO $pdo): void
verifyAccess — capability
Maps a method name to a permission column (can_select_data,
can_update_data, can_create_table, …), then checks that column for the user
in tsp_sys_security. No row there falls back to defaults derived from the
user's sp_flag via SystemObject::getFallbackPermissions.
Returns an array, not a bool. Check ['status'] === 'error' and return it
verbatim — the message is already client-safe.
The custom map replaces the base map.
$fullMap = !empty($customMap) ? $customMap : $baseMap;. Pass a$methodSecurityMapand the ~50 built-in mappings stop applying to your app entirely. Your map must be complete.
An unmapped operation returns SECURITY_MAPPING_MISSING. That is a deliberate
fail-closed: forgetting to map a new method denies it rather than defaulting it
to something permissive.
isResourcePermitted — resource grant
Checks tsp_sys_user_resources for a row matching any of $subjects (the user
UID, then their role tag — order is the precedence order), scoped to
sp_resource_source = APP_ID and sp_resource_type = <physical table>. Falls
back to a '*' row for the app. No matching row means denied.
Gotchas
resolveSecurityPdo()finds$this->pdofirst. It must be the auth connection. Failing that it reflects into$authModel,$apiKeyModelor$modelproperties looking for a PDO, and throws if none is found.isResourcePermittedusesORDER BY FIELD(...), which is MySQL-only. On SQLite the query throws, thecatch (\Throwable)returnsfalse, and every resource check silently denies. If your app must run on SQLite, this needs a portable ordering expression — aCASEladder does the same job.isTablePermittedinterpolates$mappedPermissioninto the SQL string unescaped.verifyAccessandisResourcePermittedonly interpolate values drawn from their own whitelists, so they are safe;isTablePermittedtakes its column name from the caller. Never pass user input to it.- Both resource methods return
falseon any exception. A denial can mean "no grant" or "the query failed". Log inside the catch while developing.
StandardCrudTrait
Generic CRUD with hooks, validation and row-level authorisation.
abstract protected function inferAliasFromId(string $idField): string;
public function createRecord(array $data): array
public function createBulk(array $payload): array
public function updateRecord(array $where, array $newData): array
public function updateBulkRecords(array $params): array
public function deleteRecord(array $where): array
public function deleteBulk(...$args): array
public function cascadeDeleteRecord(array $payload): array
public function syncUpdateRecord(array $where, array $newData): array
public function syncDeleteRecord(array $where): array
protected function transferData(string $sourceAlias, array $conditions, string $sourcePk,
array $fieldMap, array $updateSource = []): array
What a create does
- Unwraps a single-element list payload.
- Walks the payload keys through
inferAliasFromIdto find the alias. - If the PK is empty, generates a UID via
$this->shell->generateUid()and stampssp_owner_uidwith the current user. - Runs
before_<alias>_create. - Validates through
DataIntegrityValidator. - Inserts.
- Runs
after_<alias>_create.
Step 3 is conditional: supply a non-empty PK and sp_owner_uid is not set.
That is the correct behaviour for a caller-assigned UID, but it means an insert
with an explicit UID and no owner column produces an unowned row that the
sharing filters will hide from everyone but admins.
Row-level authorisation
updateRecord, deleteRecord and cascadeDeleteRecord fetch the target row
before writing and call assertRecordAccessible(). syncUpdateRecord and
syncDeleteRecord do the same as an authorize stage in their StageExecutor
pipeline.
checkRecordAccess() decides:
| Situation | Update | Delete |
|---|---|---|
| Admin | true | true |
Table has no sp_owner_uid | true | true |
| Caller owns the row | true | false |
sp_shared_with entry with admin | true | true |
| Anything else, or no authenticated UID | false | false |
The "no sp_owner_uid" row is the one to watch: an unscoped table is open to
anyone who cleared layers 1 and 2. Declare the column on every table holding
user data.
Gotchas
cascadeDeleteRecorddeletes children unscoped, by design — they belong to a parent that was already authorised. Make sure your cascade map only names genuine children.- The sync methods write to the mirror table directly, bypassing
updateRecord/deleteRecordand therefore the target-side ownership check. Only the source row is authorised. Safe when source and target ownership always agree; verify that for your alias pairs. transferDatafetches the source unscoped. It is a migration utility. Do not expose it throughexposeMethod.deleteBulk's argument unwrapping is fragile. It inspects$args[0]heuristically to decide whether it received a list of criteria or a spread. Test the exact call shape your dispatcher produces before relying on it; a mis-detected payload surfaces asUnknown ID mapping: 0.updateBulkRecordscallsupdateRecordper row and throws on the first failure — but it is not wrapped in a transaction, so earlier rows stay written.
SharingTrait
Ownership-scoped reads, plus management of the two sharing columns.
// scoped fetches
public function fetchWithSharing(string $alias, array $fields = ['*'], array $extraWhere = [], array $options = []): array
public function fetchSelectedWithSharing(array $aliases, array $fields = ['*'], array $extraWhere = [], array $options = []): array
public function fetchOwnerDataOnly(...) // owner only, no shares
public function fetchSharedOnly(...) // shares only, not owned
public function fetchPublicData(...) // is_resource_public = 1
// … and fetchSelected* variants of each
// per-record sharing (sp_shared_with)
public function updateSharing(string $idField, string $uid, array $newShareList): array
public function revokeSharing(string $idField, string $uid, string $targetUserUid): array
public function getRecordSharing(string $idField, string $uid): array
// general distribution (sp_general_sharing)
public function updateGeneralSharing(string $idField, string $uid, array $newList): array
public function revokeGeneralSharing(string $idField, string $uid, string $targetValue): array
public function getGeneralRecordSharing(string $idField, string $uid): array
// approvals, group membership
public function updateApprovalStatus(...), getApprovalStatus(...)
public function addMembersToGroupGeneric(...), deleteMemberFromGroupGeneric(...), getGroupMembersGeneric(...)
The two sharing columns
sp_shared_with is an access-control list. Entries look like
['uid' => '…', 'permission' => 'view'|'edit'|'admin']. The fetch filters and
checkRecordAccess both read it.
sp_general_sharing is an application-defined distribution list — it
carries no access meaning. The School app uses it to hold a student's
per-assignment scores on the grade row, and computes a weighted final grade from
it at read time.
Because its entries are not keyed on uid, getSharingKeyMap() tells the trait
which field identifies an entry:
protected function getSharingKeyMap(): array
{
return ['sp_student_grade_uid' => 'sp_assignment_uid'];
}
Calling a general-sharing method for an ID field missing from that map throws.
How the scoping works
For a non-admin, fetchWithSharing builds:
['OR' => [
"{$alias}.sp_owner_uid" => $userUid,
"{$alias}.sp_shared_with:json" => ['uid' => $userUid],
]]
then merges $extraWhere and re-applies the security block afterwards, so
an extra filter cannot remove it.
For fetchSelectedWithSharing the constraint applies to $aliases[0] only.
The first alias is the security anchor; every other alias in the array is joined
without an ownership constraint of its own.
Gotchas
- An
ORkey in$extraWherewidens access. The re-application step merges yourORconditions into the sameORgroup as the ownership filter, so a row matching your condition passes even if the user neither owns it nor is shared on it. Keep extra filters as plainANDkeys, or scope them yourself. - Sharing merges, it never replaces.
updateSharingmerges each incoming entry into the existing list byuid. To remove access, callrevokeSharing— sending a shorter list does nothing. - Every
fetch*method appends apagekey to each row containingcurrent/limit/offset. It is metadata, not data — strip it before writing a row back. fetchPublicDataand thefetchSelectedPublicDatavariant require anis_resource_publiccolumn on the anchor table.
RecordInjectionTrait
Turns raw rows into rows the UI can reason about.
protected function injectSharedPermission(array $rows): array
protected function injectFullSecurityContext(array $rows): array // alias for the above
protected function cleanData(array $data): array
injectSharedPermission adds to every row:
| Key | Meaning |
|---|---|
_is_owner | sp_owner_uid matches the caller |
_is_reviewer, _is_respondent | Role columns, when present |
_is_system_admin | Caller holds an admin flag |
_user_permission_group | Display label |
_can_view / _can_update / _can_delete / _can_share / _can_comment | Capability flags |
_permission | Rolled-up string: admin, edit, view, delete_only, none |
The flags are the intersection of two things: the user's global CRUD rights
(from tsp_sys_security) and their record-level standing (owner, or a
sp_shared_with entry). A shared admin grant on a record does not confer
delete if the user lacks can_delete_data globally.
_can_share is narrower still: it needs global update rights and either
ownership or an admin share.
Gotchas
cleanData()is never called for you. The_*keys travel to the client and come back on any round-tripped row. CallcleanData()on incoming payloads yourself, or the write will carry columns that do not exist.- Injection issues one query per call to fetch the user's CRUD permissions — fine per request, wasteful if you call it in a loop. Inject once, over the whole result set.
FormatterTrait
Output shaping and query-building helpers. Nothing here touches the database
except setupTransformers.
Output shapes
formatForCategory(array $rows, string $idKey, string $textKey, string $valueKey = '', string $dataKey = ''): array
Produces the select/dropdown shape the front end requires. Do not change it:
[{ "value": "…", "label": "…", "id": "…", "data": { "id": "…" }, "text": "…" }]
formatForGroupUI(array $rows, string $groupTitle = 'Group', ?string $groupField = null): array
Returns a map of group value → rows. With no $groupField you get a single
bucket under $groupTitle; missing values land under Uncategorized.
Group by an identifier, not a display string. The reference app groups grades by
sp_user_fullname, so two students with the same name share a bucket. Group by the UID and carry the name as a field.
buildTree(array $rows, array $hierarchy, array $idMap, array $titleMap): array
Builds a nested tree from flat joined rows. Nodes are keyed on the path to the node, not the bare UID, so the same course under two different programs stays distinct.
Also available: pivot, resolveHierarchy, formatByParentName, groupBy,
sortBy, pluck, map, where, resultMerge.
buildFilteredWhere
buildFilteredWhere(string $alias, array $filters = [], array $extras = [], array $skipZeroColumns = []): array
The right way to build a WHERE from optional parameters. It drops '',
null, 'all' and empty arrays; ignores numeric keys; qualifies bare column
names with the alias; and recurses into OR blocks. Use it wherever a filter
can legitimately be absent.
formatFileUrl
formatFileUrl(?string $path, string $default = ''): string
Normalises a stored file path into a URL against RouterSettings::get('plugin_url').
Handles absolute Windows paths left over from earlier uploads by splitting on
sepodesk/, and passes complete URLs through untouched. Always run stored file
paths through it rather than concatenating a base URL yourself.
setupTransformers
Registers an output transformer on $this->multi that flattens prefixed column
keys — courses__sp_course_title becomes sp_course_title. Call it in the
constructor. Its consequence: two joined tables with the same column name
collide after flattening, last one wins. Alias one of them in your field list
if you need both.
SyncableTrait
protected function attachSyncHooks(array $aliasToUidMap): void
protected function dispatchRelayPush(string $recipientEmail, string $contextType, array $messageBody): void
attachSyncHooks registers after_<alias>_create|update|delete listeners that
forward the event to SyncHookListener with the row's UID field. Call it from
setupHooks() with a map of alias → primary UID column.
It returns silently when the PDO driver is neither sqlite nor mysql, and
silently for any alias not in the map. Both are quiet failures — an alias
missing from the map simply never replicates.
UniversalSyncLockTrait
public function toggleHierarchyState(string $primaryUid, array $config, $value = 0): array
Flips one field across a root record, its children and any linked records — the "lock this whole tree" operation.
$config = [
'root' => ['pk' => 'sp_budget_uid', 'field' => 'sp_locked'],
'children' => [
['alias' => 'bi', 'pk' => 'sp_item_uid', 'fk' => 'sp_budget_uid', 'field' => 'sp_locked'],
],
'links' => [
['pk' => 'sp_other_uid', 'target_uid' => $uid, 'field' => 'sp_locked'],
],
];
Child failures are caught per row and logged, so one bad record does not abort
the sweep. It writes through syncUpdateRecord, so every row still passes its
own ownership check — a partial result is the expected outcome when the caller
owns some of the tree but not all of it.
HasUserFilters
public function scopeAdmin(array &$where): void
public function scopeStandardUser(array &$where): void
public function isType(array $row, int $typeNum): bool
Small helpers that set sp_flag on a WHERE array by reference, for queries
against the users table. No database access.
ResponseTrait
protected function success($message = "Operation successful", $data = []): array
protected function error($message = "An error occurred"): array
protected function apiResponse(string $status, $data, ?string $key = 'message'): array
success()behaves differently depending on the argument type. Pass a string and you get['status' => 'success', 'message' => …]. Pass an array and it is merged at the top level —success(['data' => $row])yields['status' => 'success', 'data' => $row]with nomessagekey at all.
StandardCrudTraituses both forms:updateRecordreturns the array form,deleteRecordreturns the string form. Clients must handle a response wheremessagemay be absent.
Pick one form per method and keep it stable — the response shape is part of your API.
UidGenTrait
public function generateUid(): string
RFC 4122 version 4 UUID from random_bytes. Note that StandardCrudTrait
generates UIDs through $this->shell->generateUid(), not through this trait, so
an app class does not need to use it unless it mints UIDs itself.