Apex Filter Plugins

From 3B Knowledge
Jump to navigation Jump to search

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:

  • apexFilter can stand alone (no selectionClause / filterRecordsBy needed) — 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, matchApexFilter pairs with matchSelectionClause the same way apexFilter pairs with selectionClause (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 :apexFilterIds to the query's WHERE clause 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 execute cheap: 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) and DataLoadingService.getLoaderData (CMT loaders). Both resolve the plugin the same way.

Name resolution (namespaces)

RecordLoadingService.resolvePluginType resolves the configured name in this order:

  1. ns.ClassType.forName('ns', 'Class')
  2. ns.Class (fallback) → the same string as a local Outer.Inner or, with the namespace stripped, a local class — so "b3s.GroupShiftLoader" still resolves in an un-namespaced dev org.
  3. 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 apexFilter to 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 sharing or it will return zero Ids — guests own no records and share into nothing. This mirrors the query-sharing routing rule for ctx.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 (see GroupShiftLoaderTest, GroupWorkingPatternLoaderTest).
  • To test a plugin through the full loading path, inject a definition/loader with RecordLoadingService.testDefinitionJson / DataLoadingService.testLoaderJson and reference the plugin by name — inner classes of a test class resolve too ('MyServiceTest.MyStubPlugin'), which is how the mechanism itself is covered in RecordLoadingServiceTest and DataLoadingServiceTest.