Apex Filter Plugins
Filter scheduler and component data with Apex when a SOQL clause can't express the filter.
Admin Guide
Why this exists
App definitions and loaders normally filter records with SOQL WHERE clauses (selectionClause in an app definition, filterRecordsBy in a loader). SOQL has hard limits though — most notably, a semi-join can only cross one relationship level. This clause is rejected by Salesforce:
b3s__Work_Job__r.b3s__Account__c IN (SELECT b3s__Account__c FROM b3s__Group_Assignment__c WHERE b3s__Group__c = :contextId)
Error: The left operand 'b3s__Work_Job__r.b3s__Account__c' cannot have more than one level of relationships
An Apex filter plugin solves this: instead of a clause, the configuration names an Apex class. The server runs that class, the class returns the set of record Ids that pass the filter, and the query is restricted to those Ids (Id IN :ids). Anything Apex can compute can now drive what an app loads.
How to use one
Add an apexFilter key to the JSON configuration, naming the class (namespace-qualified where one exists):
In an app definition schedulable (b3s__Scheduling_Context_Provider__mdt / b3s__Component_Configuration__mdt):
"shift": {
"objectType": "b3s__Shift__c",
"apexFilter": "b3s.GroupShiftLoader",
...
}
In a loader (b3s__Scheduling_Loader__mdt / b3s__Component_Loader__mdt b3s__JSON__c):
{
"name": "groupPatterns",
"objectName": "b3s__Working_Pattern__c",
"fieldToLoad": ["Name", "b3s__Start__c", "b3s__End__c"],
"apexFilter": "b3s.GroupWorkingPatternLoader"
}
Rules:
apexFiltercan stand alone (noselectionClause/filterRecordsByneeded) — the plugin's Ids are then the only filter.- It can also combine with clauses — the plugin's Ids are ANDed with every clause, and with the definition's date-window filter where one is configured.
- In
loadRecords,matchApexFilterpairs withmatchSelectionClausethe same wayapexFilterpairs withselectionClause(marketplace/self-search queries stay independently configurable). - Class name resolution supports namespaces: use
"b3s.GroupShiftLoader"for a packaged plugin, or just"MyOrgFilter"for a class built in your own org. Namespace-qualified names still resolve in orgs without that namespace (sandboxes/scratch orgs), so packaged configuration is portable.
Packaged plugins
| Plugin | Object | What it loads |
|---|---|---|
b3s.GroupShiftLoader
|
b3s__Shift__c
|
Shifts whose job's account or site's account has a Group Assignment for the contextual group, starting within the loaded period. |
b3s.GroupWorkingPatternLoader
|
b3s__Working_Pattern__c
|
Working Patterns whose job's account or site's account has a Group Assignment for the contextual group and whose start/end dates overlap the loaded period (undated patterns always load). |
Both are referenced by the Group scheduling context provider, where the context record (:contextId) is the sharing group being scheduled.
Developer Guide
The contract
Implement the packaged global interface FilterPlugin:
global interface FilterPlugin {
Set<Id> execute(Map<String, Object> context);
}
Return the Ids of the records that should load. Returning an empty set (or null) loads nothing. Your class needs a public no-arg constructor (the default one is fine) — it is instantiated with Type.newInstance().
Minimal org-level example:
public with sharing class MyOrgFilter implements FilterPlugin {
public Set<Id> execute(Map<String, Object> context) {
Id contextId = (Id) context.get('contextId');
Datetime windowStart = (Datetime) context.get('contextStart') ?? Datetime.newInstanceGmt(1970, 1, 1);
// ... any Apex you need ...
return new Map<Id, b3s__Shift__c>([
SELECT Id FROM b3s__Shift__c
WHERE /* your filter */ b3s__Absolute_Start_Time__c >= :windowStart
]).keySet();
}
}
Reference it from configuration as "apexFilter": "MyOrgFilter".
The context map
| Key | Type | Value |
|---|---|---|
contextId
|
Id (nullable)
|
The contextual record the app runs against (e.g. the sharing group in group scheduling). |
contactId
|
String (nullable)
|
The acting contact. Resolved through AppAccess — for portal guests this is pinned to the sessionKey's contact, a client-supplied contactId cannot redirect it.
|
contextStart
|
Datetime (nullable)
|
Start of the period the app is loading, parsed from the client's fetch window. |
contextEnd
|
Datetime (nullable)
|
End of the loaded period. |
contextDate
|
String (nullable)
|
The single contextual date some component apps send (YYYY-MM-DD).
|
Nullable means nullable — guard every key. The window keys are absent when the caller doesn't send a fetch window (e.g. some loader invocations), and contextId is absent when the app runs without a context record.
How the Ids are applied
- The server prepends
Id IN :apexFilterIdsto the query'sWHEREclause with the Ids travelling as a bind variable — there is no SOQL statement-length limit on the number of Ids, and the standard Id-cursor pagination (hasMore/nextLastId) works unchanged on top of it. - The plugin runs once per page request. Keep
executecheap: it counts against the synchronous limits of the request that triggered it (SOQL rows, CPU, heap). A plugin returning hundreds of thousands of Ids will work, but the 50k-row query limit inside your own plugin's SOQL is usually the ceiling — filter as hard as you can inside the plugin query itself. - Execution points:
RecordLoadingService.loadRecords(schedulable loading) andDataLoadingService.getLoaderData(CMT loaders). Both resolve the plugin the same way.
Name resolution (namespaces)
RecordLoadingService.resolvePluginType resolves the configured name in this order:
ns.Class→Type.forName('ns', 'Class')ns.Class(fallback) → the same string as a localOuter.Inneror, with the namespace stripped, a local class — so"b3s.GroupShiftLoader"still resolves in an un-namespaced dev org.- Bare
Class→ current namespace first, then the org's default namespace — so subscriber orgs can ship plugins without any namespace.
If nothing resolves, or the class doesn't implement FilterPlugin, the request fails with Apex filter [...] not found or does not implement FilterPlugin.
Security model
- The plugin name only ever comes from Custom Metadata (app definitions, loaders) — never from the client. Treat adding an
apexFilterto a definition with the same care as writing the clause it replaces. - Plugins run in their own sharing context. Declare it deliberately:
with sharing— right for authenticated surfaces (the scheduler runs in USER_MODE anyway). Both packaged plugins do this.- A plugin intended for elevated portal guests (sessionKey-authorized apps) must run
without sharingor it will return zero Ids — guests own no records and share into nothing. This mirrors the query-sharing routing rule forctx.query(...).
- The final record query still runs at the
AppAccess-resolved level (USER_MODE / SYSTEM_MODE), so the plugin can only ever narrow what the surface would return, not widen field or object access.
Testing
- Unit-test plugins directly — build the record graph, call
new MyPlugin().execute(context)with a hand-built context map, and assert on the returned Id set (seeGroupShiftLoaderTest,GroupWorkingPatternLoaderTest). - To test a plugin through the full loading path, inject a definition/loader with
RecordLoadingService.testDefinitionJson/DataLoadingService.testLoaderJsonand reference the plugin by name — inner classes of a test class resolve too ('MyServiceTest.MyStubPlugin'), which is how the mechanism itself is covered inRecordLoadingServiceTestandDataLoadingServiceTest.