diff --git a/.afv/rules/trigger-lib.md b/.afv/rules/trigger-lib.md index 4aeeacb..3aa3a5c 100644 --- a/.afv/rules/trigger-lib.md +++ b/.afv/rules/trigger-lib.md @@ -9,7 +9,7 @@ alwaysApply: true This project runs its Apex trigger logic through Trigger Lib (`TriggerOrchestrator.cls`). - Each object has one trigger that lists all seven events (only `after insert` on a platform event or change event), and its body is only `TriggerOrchestrator.run(new TriggerOrchestrator());` or `TriggerOrchestrator.runFromMetadata();`. Don't put logic in a trigger body, and don't write `Trigger.isBefore` or `Trigger.isInsert` branches, loops over `Trigger.new`, or handler classes of other trigger frameworks. -- Write each concern as one top-level handler class with one role per context, and register it in run order: in the object's orchestrator, or as a `TriggerHandler__mdt` record with `Order__c` when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context. +- Write each concern as one top-level handler class with one role per context, and register it in run order in each context it runs in: in the object's orchestrator, or as one `TriggerHandler__mdt` record per context, with `Order__c`, whose context field, such as `BeforeInsert__c`, names the object, when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context. - Never run SOQL or DML in a handler's predicate (`...When`) or action, because the library throws. Read parents with ParentQuery or PriorParentQuery, other records with RelatedQuery and configuration from static variables. Register writes with `unitOfWork.toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish`, as new instances that hold the Id and the changed fields. - In the update contexts, make every predicate require a change, such as `record.isChanged(...)` or `isChangedTo(...)`. - Test handlers with `TriggerOrchestrator.mock().For(X.class).with(...)` and `TriggerOrchestrator.runTestFor(new X())`, and mock the unit of work with `DML.mock('triggerUow').allDmls()`, instead of inserting records to fire the trigger. Keep one test per trigger that saves a record with DML, because a trigger without coverage can't be deployed to production. diff --git a/.afv/skills/trigger-lib-handler/SKILL.md b/.afv/skills/trigger-lib-handler/SKILL.md index ef77410..f34c01d 100644 --- a/.afv/skills/trigger-lib-handler/SKILL.md +++ b/.afv/skills/trigger-lib-handler/SKILL.md @@ -13,9 +13,9 @@ A handler is a small class with one role in one trigger context. For every recor 2. No SOQL and no DML in a predicate, or in the per-record action of a Populator, Validator or Writer. Both throw a `TriggerTypes.TriggerLibException` and fail the save. Read other records as shown in [Reading other records](#reading-other-records), and register DML on the action's `unitOfWork`. A Dispatcher's dispatch method and a Finalizer run once per chunk, outside that check. 3. Change the record being saved only from a Populator, with `record.put(field, value)`, in before insert or before update. In the after contexts the rows are read-only: `put` throws a `System.FinalException` that no `catch` stops. 4. Before insert and before update allow no DML and no event publish anywhere in a handler, its Finalizer included. Change other records from a Writer in before delete or an after context. -5. In before update and after update, make the predicate require a change, with `isChanged`, `isChangedTo` or another change check. Every later update of the record in the transaction runs the update handlers again. The built-in cap of 3 actions per record per transaction only limits the damage. +5. In before update and after update, make the predicate require a change, with `isChanged`, `isChangedTo` or another change check. Every later update of the record in the transaction runs the update handlers again. After 3 actions on a record in the transaction, each later run logs an error, and only a RecursionGuard stops the loop. 6. When a Validator's predicate returns true, its error method must call `record.addError(...)`, or the library throws for the whole chunk. Put the whole condition in the predicate. -7. A new handler runs only once it is registered: add it to the object's orchestrator in run order (Populators before Validators), or add its `TriggerHandler__mdt` record when the trigger calls `runFromMetadata()`. The `trigger-lib-orchestrator` skill covers both. +7. A new handler runs only once it is registered, and only in the contexts it is registered in: add it to the object's orchestrator in run order (Populators before Validators), or add one `TriggerHandler__mdt` record per context the class runs in, each naming the object in that context's field, such as `BeforeInsert__c`, when the trigger calls `runFromMetadata()`. A handler that gains a context needs the same registration for it. The `trigger-lib-orchestrator` skill covers both. 8. Pass fields as `SObjectField` tokens (`Account.Rating`). Cast a row to read its fields: `(Account) record.getNewSObject()`. ## Before writing @@ -266,13 +266,13 @@ Implement as many as you need next to the role interface. Each exists only in th | PriorParentQuery | `Map queryPriorParentsOn()` | Load the parents from before the save | BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete | | RelatedQuery | `Map.RecordsProvider> queryRelatedOn()` | Load children, siblings or any other records | every context | | Bypassable | `Boolean bypassOnWhen()` | Skip the handler for the chunk on a condition that doesn't depend on the records | every context | -| RecursionGuard | `Integer maxRunsPerRecordOn()` | Change the cap of 3 actions per record per transaction | BeforeUpdate (Populator only), AfterUpdate | +| RecursionGuard | `Integer maxRunsPerRecordOn()` | Set the recursion depth and skip the record there, instead of only logging at the default depth of 3 | BeforeUpdate (Populator only), AfterUpdate | | Finalizer | `void finalizeOn(records)` | Run once per chunk with the qualified records | every context | | ContinueOnError | none, it's a marker | Log and swallow the handler's own exceptions | every context | | OwnUnitOfWork | `DML.Committable ownUnitOfWorkOn()` | Give a Writer its own `DML` settings, committed right after it | AfterInsert, AfterUpdate, BeforeDelete, AfterDelete, AfterUndelete | - **Bypassable** runs once per handler per run and gets no records. Use it for a static flag, `System.isBatch()` or `FeatureManagement.checkPermission('...')`. It's checked before the first handler of the run, so a flag that an earlier handler sets applies from the next run. An exception there isn't logged and fails the save. To skip single records, return false from the predicate instead. -- **RecursionGuard** counts every time the predicate returns true for a record, per class and context, for the whole transaction. `null` means no limit, and `0` or less skips every record. Avoid `1`: a partial save, such as `Database.update(rows, false)`, Data Loader or the Bulk API, rolls back and runs the trigger again for the rows that succeeded. The count isn't rolled back, so with a cap of `1` their action is lost without an error. Prefer a change gate in the predicate, which passes again on that re-run, and keep any cap at 2 or more. +- **RecursionGuard** counts every time the predicate returns true for a record, per class and context, for the whole transaction. When the record comes back at that count, it's skipped and the run logs an error. Without it the depth is 3, and the run logs the same error but the handler keeps going. `null` means no depth and no error, and `0` or less skips every record. Avoid `1`: a partial save, such as `Database.update(rows, false)`, Data Loader or the Bulk API, rolls back and runs the trigger again for the rows that succeeded. The count isn't rolled back, so with a cap of `1` their action is skipped on that re-run. Prefer a change gate in the predicate, which passes again on that re-run, and keep any cap at 2 or more. - **Finalizer** runs after the handler's records, only when at least one qualified. SOQL is allowed there. It gets no unit of work, so to register more DML, keep the action's `unitOfWork` in an instance field. - **ContinueOnError** is for nice-to-have work such as a notification. After an exception, the handler's remaining records and its Finalizer are skipped, later handlers still run, and a Writer's registrations are dropped. It never swallows a `TriggerTypes.TriggerLibException`, an `EventBus.RetryableException` that asks for redelivery of a platform event, a `System.LimitException`, the `FinalException` from writing a trigger row, or an error outside the handler's own methods. Never add it to a Validator that guards a save, and add a Logger, or a swallowed failure leaves no trace. - **OwnUnitOfWork** returns a DML Lib unit, such as `new DML().userMode().allowPartialSuccess().identifier('OpportunityLossReviewWriter')`. A plain `new DML()` runs in user mode and throws on a duplicate registration, unlike the shared unit. @@ -282,7 +282,7 @@ Implement as many as you need next to the role interface. Each exists only in th | Context | Know this | |---|---| | BeforeInsert | `getId()` is `null` and `records.getIds()` is empty. Key maps by a lookup or a field value | -| BeforeUpdate | The old row is read-only. A Validator runs on every pass; a Populator acts at most 3 times per record | +| BeforeUpdate | The old row is read-only. A Validator runs on every pass; a Populator logs an error when a record comes back after 3 passes | | BeforeDelete | Writes commit before the delete, while other records still point at the rows. Leave the rows being deleted alone: a `toDelete` of one fails with `SELF_REFERENCE_FROM_TRIGGER`, and an update of one is lost. Cascade-deleted children fire no triggers of their own, so handle them here with a RelatedQuery | | AfterInsert, AfterUpdate | The rows are read-only. Inserting or updating the same object runs its triggers again, and the insert contexts have no recursion cap | | AfterDelete | SOQL no longer finds the rows, so query by their old lookups, such as `records.getIdsOf(Contact.AccountId)`. Merge losers arrive here with `MasterRecordId` set | diff --git a/.afv/skills/trigger-lib-orchestrator/SKILL.md b/.afv/skills/trigger-lib-orchestrator/SKILL.md index 9a670bd..48977d4 100644 --- a/.afv/skills/trigger-lib-orchestrator/SKILL.md +++ b/.afv/skills/trigger-lib-orchestrator/SKILL.md @@ -70,7 +70,7 @@ public with sharing class OpportunityTriggerOrchestrator implements TriggerOrche - List order is run order, whatever the role. Each handler finishes all records of the chunk before the next one starts, and its predicate sees what earlier handlers set with `put`. - The Writers' DML on the shared unit of work commits once, after the last handler, so later handlers never see it. A Writer with OwnUnitOfWork or ContinueOnError commits right after itself. -- In the update contexts, a Populator, Writer or Dispatcher acts on the same record at most 3 times per transaction by default, and later passes skip the record without an error. A handler's RecursionGuard add-on changes its limit. +- In the update contexts, when a record comes back to a Populator, Writer or Dispatcher that already acted on it 3 times in the transaction, the run logs an error and the handler keeps running. A handler's RecursionGuard add-on sets its own depth, and there the record is also skipped. ## Handlers in custom metadata @@ -82,7 +82,7 @@ trigger AccountTrigger on Account(before insert, after insert, before update, af } ``` -Deploy one `TriggerObject__mdt` record for the object and one `TriggerHandler__mdt` record per handler class, in `customMetadata/..md-meta.xml`: +Deploy one `TriggerObject__mdt` record for the object and one `TriggerHandler__mdt` record per handler class and context, in `customMetadata/..md-meta.xml`: ```xml @@ -100,15 +100,15 @@ Deploy one `TriggerObject__mdt` record for the object and one `TriggerHandler__m ``` -That is `TriggerObject.Account.md-meta.xml`. Next to it, `TriggerHandler.AccountRatingPopulator.md-meta.xml`: +That is `TriggerObject.Account.md-meta.xml`. Next to it, `TriggerHandler.AccountRatingPopulator_BeforeInsert.md-meta.xml`: ```xml - + false - TriggerObject__c + BeforeInsert__c Account @@ -123,25 +123,32 @@ That is `TriggerObject.Account.md-meta.xml`. Next to it, `TriggerHandler.Account Order__c 10 + + Description__c + Sets Rating from AnnualRevenue. + ``` +The same class in before update is a second record, `TriggerHandler.AccountRatingPopulator_BeforeUpdate.md-meta.xml`, with `BeforeUpdate__c` in place of `BeforeInsert__c` and its own `Order__c`. + - Declare `xmlns:xsd`. Without it, the deployment fails with an `UNKNOWN_EXCEPTION` that names no component. -- `TriggerObject__c` holds the `TriggerObject__mdt` record's DeveloperName alone, such as `Account`, not `TriggerObject.Account`. -- `ObjectAPIName__c` is the object's API name, such as `Account` or `Invoice__c`. Keep one `TriggerObject__mdt` record per object: with several, only one is read. -- `Order__c` is an `xsd:double`. The lowest runs first, a blank order runs last (leave its `` block out), and ties run in class name order. +- The seven context fields are `BeforeInsert__c`, `AfterInsert__c`, `BeforeUpdate__c`, `AfterUpdate__c`, `BeforeDelete__c`, `AfterDelete__c` and `AfterUndelete__c`. A record fills exactly one of them, with the `TriggerObject__mdt` record's DeveloperName alone, such as `Account`, not `TriggerObject.Account`, and the other six stay blank. That registers the class in that one context on that object. A class that runs in several contexts has one record per context, each with its own `Order__c`, and a class on two objects has records for each object. The validation rule `OneContextRequired` rejects a record with no context field filled or with more than one, in Setup and in deployments. +- `ObjectAPIName__c` is the object's API name, such as `Account` or `Invoice__c`. Keep one `TriggerObject__mdt` record per object: with several, the library uses one, and the handler records that name another are ignored. +- `Order__c` is a required `xsd:double`: the run order within the record's context. The lowest runs first, and ties run in class name order. - `ApexClassName__c` holds the full class name: `AccountRules.RatingPopulator` for an inner class, or `ns.AccountRatingPopulator` for a class in another namespace. -- A record's DeveloperName, the part of the file name after the type, holds at most 40 characters: letters, digits and single underscores, starting with a letter. Shorten it when the class name is longer, and keep the full name in `ApexClassName__c`. -- The class needs a public no-argument constructor. The library creates every registered class in every context of the object and runs it in each context whose role it implements, so keep the constructor cheap. -- A record whose class can't be found or created, or that implements no handler interface, fails every save on the object with a `TriggerTypes.TriggerLibException`. Add a registration test with the `trigger-lib-testing` skill. -- With the `btcdev` package, the type and field names carry the prefix and the values don't: the files are `btcdev__TriggerObject.Account.md-meta.xml` and `btcdev__TriggerHandler.AccountRatingPopulator.md-meta.xml`, each field is written like `btcdev__Bypass__c`, and `btcdev__TriggerObject__c` and `btcdev__ApexClassName__c` hold `Account` and `AccountRatingPopulator` as above. +- `Description__c` is free text for admins, such as what the handler does. The library never reads it. +- A record's DeveloperName, the part of the file name after the type, is `_`, such as `AccountRatingPopulator_BeforeUpdate`, and holds at most 40 characters: letters, digits and single underscores, starting with a letter. Shorten the class part when the name is longer, and keep the full name in `ApexClassName__c`. The label is free text. +- The class needs a public no-argument constructor. The library creates it on every run of each context its records name, so keep the constructor cheap. +- A record fails every save on the object in its context, with a `TriggerTypes.TriggerLibException`, when its class can't be found or created, or doesn't implement that context's `Handler`, such as `BeforeInsert.Handler` for `BeforeInsert__c`. Add a registration test with the `trigger-lib-testing` skill. +- With the `btcdev` package, the type and field names carry the prefix and the values don't: the files are `btcdev__TriggerObject.Account.md-meta.xml` and `btcdev__TriggerHandler.AccountRatingPopulator_BeforeInsert.md-meta.xml`, each field is written like `btcdev__Bypass__c`, and `btcdev__BeforeInsert__c` and `btcdev__ApexClassName__c` hold `Account` and `AccountRatingPopulator` as above. ## Bypasses | You need to | Use | |---|---| | Skip handlers in one transaction from Apex, such as a data fix, a batch or test setup | `TriggerOrchestrator.bypass()` with `sObject(...)`, `handler(...)`, `orchestrator(...)` or `all()`, and `clear()` in `finally` | -| Let admins switch an object or one handler off for the whole org without a deployment | `Bypass__c` on the object's `TriggerObject__mdt` record, which switches off every handler on the object, or on a `TriggerHandler__mdt` record under it, which switches off one class | +| Let admins switch an object or one handler off for the whole org without a deployment | `Bypass__c` on the object's `TriggerObject__mdt` record, which switches off every handler on the object, or on a `TriggerHandler__mdt` record, which switches off one class in the one context the record names | | Skip one handler in one context on a condition that doesn't depend on the records, such as a feature flag, `System.isBatch()` or a custom permission | The context's Bypassable add-on, `bypassOnWhen()` (see `trigger-lib-handler`) | | Skip some records | Return false from the handler's predicate | | Skip handlers for some users | There is no built-in per-user bypass. Check a custom permission in `bypassOnWhen()`, or return an empty list from `Handlers()` under the same check | @@ -157,10 +164,10 @@ try { - An Apex bypass lasts until `clear()` or the end of the transaction, nested saves included. `clear()` removes every bypass, also those a caller set, so call it only in the code that set the bypass. A Queueable, a future method or the next batch `execute` starts without it. - `handler(X.class)` and `orchestrator(X.class)` never match an inner class. For an inner handler, use a `TriggerHandler__mdt` record or the Bypassable add-on. `orchestrator(...)` has no effect under `runFromMetadata()`. -- To give admins a switch for one handler, deploy its `TriggerHandler__mdt` record with `Bypass__c` unchecked, together with the object's `TriggerObject__mdt` record. The object's record alone switches off every handler on the object, which suits a data load. +- To give admins a switch for one handler, deploy its `TriggerHandler__mdt` records with `Bypass__c` unchecked, one record per context the class runs in, together with the object's `TriggerObject__mdt` record. The object's record alone switches off every handler on the object, which suits a data load. - An admin flips the switch in Setup: Custom Metadata Types, then Manage Records next to Trigger Handler (or Trigger Object), then Edit on the record, then the Bypass checkbox. Metadata is read once per transaction, so the change applies from the next transaction. -- A `TriggerHandler__mdt` record needs its object's `TriggerObject__mdt` record, even with `Bypass__c` unchecked. A record covers every context of its object; to skip one context only, use that context's Bypassable. A class used on two objects needs a record under each. -- Under `run(orchestrator)`, a `TriggerHandler__mdt` record only switches its class off, and `Order__c` is ignored. Deleting the record doesn't switch the class off. +- A `TriggerHandler__mdt` record needs its object's `TriggerObject__mdt` record, even with `Bypass__c` unchecked. Its `Bypass__c` switches the class off only in the record's context on that object, so one checked record switches off one context, and the class's other records keep running. A class used on two objects needs records for each object. +- Under `run(orchestrator)`, a `TriggerHandler__mdt` record only switches its class off, in its context, and `Order__c` is ignored, though it is still required. Deleting the record doesn't switch the class off. - A deployment that includes the records sets `Bypass__c` back to the deployed value. Once admins flip switches in production, leave the records out of later deployments or keep the files in step with production. To ship a handler switched off, deploy its record checked and uncheck it when it should go live. - A bypass skips only Trigger Lib handlers. Flows, validation rules and other triggers still run. @@ -205,7 +212,7 @@ public with sharing class TriggerErrorLogger implements TriggerOrchestrator.Logg } ``` -- `log(error)` runs for every exception in a handler's turn, before the library rethrows it or ContinueOnError swallows it. `flush()` runs at the end of each outermost run, also when the run failed, so clear your buffer there. +- `log(error)` runs for every exception in a handler's turn, before the library rethrows it or ContinueOnError swallows it. It also gets one `TriggerTypes.TriggerLibException`, never thrown, for each record that comes back to an update handler at its recursion depth: 3, or the RecursionGuard's number. That call happens inside the record loop, so keep `log()` free of SOQL and DML. `flush()` runs at the end of each outermost run, also when the run failed, so clear your buffer there. The exception is a save that fails with `maximum trigger depth exceeded`: `flush()` doesn't run for it or for any later save in that transaction. - With two Logger classes in the org, every Trigger Lib trigger throws. Never throw from a Logger: its exception replaces the handler's and fails the save. - ContinueOnError never swallows a `TriggerTypes.TriggerLibException` or an `EventBus.RetryableException`, which asks for redelivery of a platform event. - Not logged, and never swallowed by ContinueOnError: exceptions in `Handlers()`, in creating `runFromMetadata()` classes, in `bypassOnWhen()`, `ownUnitOfWorkOn()` and `maxRunsPerRecordOn()`, in the ParentQuery and PriorParentQuery methods, and in the shared unit of work's commit. @@ -242,6 +249,8 @@ Then replace the trigger's body with the `run(...)` line, remove or deactivate t | `bypass()` without `clear()` in a `finally` block | Wrap the DML in `try` / `finally` | | `bypass().handler(...)` with an inner class | A top-level class, or a `TriggerHandler__mdt` record | | Custom metadata XML without `xmlns:xsd` | Copy the header above | -| `TriggerObject__c` set to `TriggerObject.Account` | The DeveloperName alone: `Account` | +| A context field such as `BeforeInsert__c` set to `TriggerObject.Account` | The DeveloperName alone: `Account` | +| A `TriggerHandler__mdt` record in a context whose `Handler` the class doesn't implement | A record only for a context whose `Handler` the class implements | +| A `TriggerHandler__mdt` record with two context fields filled, or none, which `OneContextRequired` rejects | One record per context, each with exactly one context field filled | | A second class implementing `TriggerOrchestrator.Logger` | Keep exactly one | | A metadata handler with constructor arguments | A public no-argument constructor | diff --git a/.afv/skills/trigger-lib-testing/SKILL.md b/.afv/skills/trigger-lib-testing/SKILL.md index 7c0cc4c..0fd6b5c 100644 --- a/.afv/skills/trigger-lib-testing/SKILL.md +++ b/.afv/skills/trigger-lib-testing/SKILL.md @@ -16,7 +16,7 @@ Queue a mocked trigger context for a handler class with `TriggerOrchestrator.moc 5. Give parents with `withParent(lookupField, parent)`. Each parent carries the Id that the row's lookup holds. 6. Test one behaviour per method, including the records the predicate must skip. Name the method after the handler method it proves and the case, such as `populateOnBeforeInsertWithMixedCaseEmail`, `writeOnAfterInsertWithInactiveOwner` or `bypassOnAfterInsertWhenFlagIsSet`, and mark the parts with `// Setup`, `// Test` and `// Verify`. 7. Add `Test.startTest()` and `Test.stopTest()` only when the test needs fresh limits or must run async work. -8. If Trigger Lib is installed as the `btcdev` package (existing code calls `btcdev.TriggerOrchestrator`, or `sfdx-project.json` or `sf package installed list` shows the Trigger Lib package), prefix the library types and the custom metadata: `btcdev.TriggerOrchestrator.mock()`, `btcdev.TriggerTypes.RandomIdGenerator`, `btcdev.DML.mock('triggerUow')`, `btcdev.SOQL.mock(...)`, `btcdev__TriggerObject__mdt`, `btcdev__Bypass__c`. Keep the mock keys `'triggerUow'`, `'TriggerObject__mdt'` and `'ApexTypeImplementor'` unprefixed: they are text inside the package. Mock the library's queries and commit through `btcdev.SOQL` and `btcdev.DML` even when the project has its own `SOQL` or `DML` class, and mock a provider's query through the SOQL class the provider uses. +8. If Trigger Lib is installed as the `btcdev` package (existing code calls `btcdev.TriggerOrchestrator`, or `sfdx-project.json` or `sf package installed list` shows the Trigger Lib package), prefix the library types and the custom metadata: `btcdev.TriggerOrchestrator.mock()`, `btcdev.TriggerTypes.RandomIdGenerator`, `btcdev.DML.mock('triggerUow')`, `btcdev.SOQL.mock(...)`, `btcdev__TriggerObject__mdt`, `btcdev__Bypass__c`. Keep the mock keys `'triggerUow'`, `'TriggerObject__mdt'`, `'TriggerHandler__mdt'` and `'ApexTypeImplementor'` unprefixed: they are text inside the package. Mock the library's queries and commit through `btcdev.SOQL` and `btcdev.DML` even when the project has its own `SOQL` or `DML` class, and mock a provider's query through the SOQL class the provider uses. ## Test API @@ -220,25 +220,36 @@ static void afterInsertHandlersContainsWelcomeTaskWriter() { } ``` -A trigger that calls `runFromMetadata()` has no orchestrator to run. Test its handlers one by one, and test that every deployed `TriggerHandler__mdt` record names a class that exists, can be created with no arguments and implements a context's `Handler`: +A trigger that calls `runFromMetadata()` has no orchestrator to run. Test its handlers one by one, and test that every deployed `TriggerHandler__mdt` record names a class that exists, can be created with no arguments and implements the `Handler` of the context whose field the record fills: ```apex @IsTest -static void accountHandlerRecordsResolve() { +static void handlerRecordsResolve() { // Setup List brokenClasses = new List(); // Test - for (TriggerHandler__mdt handlerRecord : [SELECT ApexClassName__c FROM TriggerHandler__mdt WHERE TriggerObject__r.ObjectAPIName__c = 'Account']) { + for (TriggerHandler__mdt handlerRecord : [ + SELECT ApexClassName__c, BeforeInsert__c, AfterInsert__c, BeforeUpdate__c, AfterUpdate__c, BeforeDelete__c, AfterDelete__c, AfterUndelete__c + FROM TriggerHandler__mdt + ]) { Object handler = Type.forName(handlerRecord.ApexClassName__c.trim())?.newInstance(); - if (!(handler instanceof BeforeInsert.Handler || handler instanceof AfterInsert.Handler || handler instanceof BeforeUpdate.Handler || handler instanceof AfterUpdate.Handler || handler instanceof BeforeDelete.Handler || handler instanceof AfterDelete.Handler || handler instanceof AfterUndelete.Handler)) { + if ( + (handlerRecord.BeforeInsert__c != null && !(handler instanceof BeforeInsert.Handler)) || + (handlerRecord.AfterInsert__c != null && !(handler instanceof AfterInsert.Handler)) || + (handlerRecord.BeforeUpdate__c != null && !(handler instanceof BeforeUpdate.Handler)) || + (handlerRecord.AfterUpdate__c != null && !(handler instanceof AfterUpdate.Handler)) || + (handlerRecord.BeforeDelete__c != null && !(handler instanceof BeforeDelete.Handler)) || + (handlerRecord.AfterDelete__c != null && !(handler instanceof AfterDelete.Handler)) || + (handlerRecord.AfterUndelete__c != null && !(handler instanceof AfterUndelete.Handler)) + ) { brokenClasses.add(handlerRecord.ApexClassName__c); } } // Verify - Assert.areEqual(new List(), brokenClasses, 'Every registered class should be a handler.'); + Assert.areEqual(new List(), brokenClasses, 'Every registered class should implement the context its record names.'); } ``` @@ -253,9 +264,21 @@ SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); ``` -With the `btcdev` package, the mock keys stay the same and the types get the prefix: `btcdev.SOQL.mock('TriggerObject__mdt').thenReturn(new List());`. +The handler records come from a second query, `'TriggerHandler__mdt'`. Each is attached to the object record whose Id its context field holds, and the org's records can't hold a mocked record's Id, so the `'TriggerObject__mdt'` mock above keeps them out of the test too. Mock `'TriggerHandler__mdt'` only when the test wants handler records. Each mocked row is a plain constructor with the object record's fake Id in its one context field, one row per context, and the list order is the run order: -- To test a metadata bypass, return `new TriggerObject__mdt(ObjectAPIName__c = 'Account', Bypass__c = true)` instead of the empty list. `TriggerHandler__mdt` rows sit in the parent's `TriggerHandlers__r` relationship, which a constructor can't set, so build the parent with `JSON.deserialize`. +```apex +TriggerObject__mdt accountSetting = new TriggerObject__mdt(Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), ObjectAPIName__c = 'Account', Bypass__c = false); + +SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); +SOQL.mock('TriggerHandler__mdt').thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'AccountHotRatingPopulator', Bypass__c = true, BeforeUpdate__c = accountSetting.Id) }); +SOQL.mock('ApexTypeImplementor').thenReturn(new List()); +``` + +With the `btcdev` package, the mock keys stay the same and the types and fields get the prefix: `btcdev.SOQL.mock('TriggerObject__mdt').thenReturn(new List());`, `btcdev__TriggerHandler__mdt`, `btcdev__BeforeUpdate__c`. + +- To test an object bypass, return `new TriggerObject__mdt(ObjectAPIName__c = 'Account', Bypass__c = true)` instead of the empty list. +- To test a handler bypass, return a `TriggerHandler__mdt` row with `Bypass__c = true`, as above. It switches its class off only in the context whose field holds the object record's Id, and a row that holds no mocked Id is ignored. +- A mock ignores `WHERE` and `ORDER BY`, so a save in an integration test of a `runFromMetadata()` trigger runs the mocked rows in list order, whatever their `Order__c`. - To test an Apex bypass, call `TriggerOrchestrator.bypass().handler(X.class)` or `.sObject(...)` before `runTestFor`. `.orchestrator(...)` has no effect under `runTestFor`. - To test a Bypassable that reads a static flag, set the flag in `// Setup` and assert that nothing happened. - To test ContinueOnError, set `TriggerOrchestrator.triggerLogger.logger` to a test class of your own that collects the errors. This works only when the Trigger Lib source is in the project: in the `btcdev` package that member isn't visible to your tests. @@ -289,7 +312,8 @@ Name each unit test class `Test` and keep it next to the handler, unles | A Writer test fails in the commit | The unit of work isn't mocked, so real DML runs with fake Ids | `DML.mock('triggerUow').allDmls()` | | `retrieveResultFor('triggerUow')` misses an OwnUnitOfWork Writer's records | It commits under its own identifier | Mock and read that identifier | | Provider rows never arrive | The provider uses inline SOQL, or the mock key differs from its `mockId` | SOQL Lib with `mockId`, and `SOQL.mock('')` | -| A handler is skipped in one org and runs in another | The org's bypass metadata applies to tests | Mock `'TriggerObject__mdt'` before the first reference to `TriggerOrchestrator` | +| A handler is skipped in one org and runs in another | The org's `TriggerObject__mdt` and `TriggerHandler__mdt` records apply to tests | Mock `'TriggerObject__mdt'` before the first reference to `TriggerOrchestrator`. The org's handler records can't point at a mocked object record | +| A mocked `TriggerHandler__mdt` row has no effect | Its context field doesn't hold the mocked object record's Id | Set a fake Id on the object record and put it in the row's context field, one row per context | | `bypass().orchestrator(...)` has no effect | It doesn't apply under `runTestFor` | `bypass().handler(...)` or `sObject(...)` | | The test fails after the last line with a callout error | A job the Dispatcher enqueued runs when the test ends | `Test.setMock(...)` for the callout | | A test inserts records to fire the trigger | Old trigger testing habit | Queue the context with `mock()` and run with `runTestFor`. Keep DML for integration tests | diff --git a/.claude/rules/trigger-lib.md b/.claude/rules/trigger-lib.md index 85c8fdf..37c460e 100644 --- a/.claude/rules/trigger-lib.md +++ b/.claude/rules/trigger-lib.md @@ -9,7 +9,7 @@ paths: This project runs its Apex trigger logic through Trigger Lib (`TriggerOrchestrator.cls`). - Each object has one trigger that lists all seven events (only `after insert` on a platform event or change event), and its body is only `TriggerOrchestrator.run(new TriggerOrchestrator());` or `TriggerOrchestrator.runFromMetadata();`. Don't put logic in a trigger body, and don't write `Trigger.isBefore` or `Trigger.isInsert` branches, loops over `Trigger.new`, or handler classes of other trigger frameworks. -- Write each concern as one top-level handler class with one role per context, and register it in run order: in the object's orchestrator, or as a `TriggerHandler__mdt` record with `Order__c` when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context. +- Write each concern as one top-level handler class with one role per context, and register it in run order in each context it runs in: in the object's orchestrator, or as one `TriggerHandler__mdt` record per context, with `Order__c`, whose context field, such as `BeforeInsert__c`, names the object, when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context. - Never run SOQL or DML in a handler's predicate (`...When`) or action, because the library throws. Read parents with ParentQuery or PriorParentQuery, other records with RelatedQuery and configuration from static variables. Register writes with `unitOfWork.toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish`, as new instances that hold the Id and the changed fields. - In the update contexts, make every predicate require a change, such as `record.isChanged(...)` or `isChangedTo(...)`. - Test handlers with `TriggerOrchestrator.mock().For(X.class).with(...)` and `TriggerOrchestrator.runTestFor(new X())`, and mock the unit of work with `DML.mock('triggerUow').allDmls()`, instead of inserting records to fire the trigger. Keep one test per trigger that saves a record with DML, because a trigger without coverage can't be deployed to production. diff --git a/.claude/skills/trigger-lib-handler/SKILL.md b/.claude/skills/trigger-lib-handler/SKILL.md index ef77410..f34c01d 100644 --- a/.claude/skills/trigger-lib-handler/SKILL.md +++ b/.claude/skills/trigger-lib-handler/SKILL.md @@ -13,9 +13,9 @@ A handler is a small class with one role in one trigger context. For every recor 2. No SOQL and no DML in a predicate, or in the per-record action of a Populator, Validator or Writer. Both throw a `TriggerTypes.TriggerLibException` and fail the save. Read other records as shown in [Reading other records](#reading-other-records), and register DML on the action's `unitOfWork`. A Dispatcher's dispatch method and a Finalizer run once per chunk, outside that check. 3. Change the record being saved only from a Populator, with `record.put(field, value)`, in before insert or before update. In the after contexts the rows are read-only: `put` throws a `System.FinalException` that no `catch` stops. 4. Before insert and before update allow no DML and no event publish anywhere in a handler, its Finalizer included. Change other records from a Writer in before delete or an after context. -5. In before update and after update, make the predicate require a change, with `isChanged`, `isChangedTo` or another change check. Every later update of the record in the transaction runs the update handlers again. The built-in cap of 3 actions per record per transaction only limits the damage. +5. In before update and after update, make the predicate require a change, with `isChanged`, `isChangedTo` or another change check. Every later update of the record in the transaction runs the update handlers again. After 3 actions on a record in the transaction, each later run logs an error, and only a RecursionGuard stops the loop. 6. When a Validator's predicate returns true, its error method must call `record.addError(...)`, or the library throws for the whole chunk. Put the whole condition in the predicate. -7. A new handler runs only once it is registered: add it to the object's orchestrator in run order (Populators before Validators), or add its `TriggerHandler__mdt` record when the trigger calls `runFromMetadata()`. The `trigger-lib-orchestrator` skill covers both. +7. A new handler runs only once it is registered, and only in the contexts it is registered in: add it to the object's orchestrator in run order (Populators before Validators), or add one `TriggerHandler__mdt` record per context the class runs in, each naming the object in that context's field, such as `BeforeInsert__c`, when the trigger calls `runFromMetadata()`. A handler that gains a context needs the same registration for it. The `trigger-lib-orchestrator` skill covers both. 8. Pass fields as `SObjectField` tokens (`Account.Rating`). Cast a row to read its fields: `(Account) record.getNewSObject()`. ## Before writing @@ -266,13 +266,13 @@ Implement as many as you need next to the role interface. Each exists only in th | PriorParentQuery | `Map queryPriorParentsOn()` | Load the parents from before the save | BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete | | RelatedQuery | `Map.RecordsProvider> queryRelatedOn()` | Load children, siblings or any other records | every context | | Bypassable | `Boolean bypassOnWhen()` | Skip the handler for the chunk on a condition that doesn't depend on the records | every context | -| RecursionGuard | `Integer maxRunsPerRecordOn()` | Change the cap of 3 actions per record per transaction | BeforeUpdate (Populator only), AfterUpdate | +| RecursionGuard | `Integer maxRunsPerRecordOn()` | Set the recursion depth and skip the record there, instead of only logging at the default depth of 3 | BeforeUpdate (Populator only), AfterUpdate | | Finalizer | `void finalizeOn(records)` | Run once per chunk with the qualified records | every context | | ContinueOnError | none, it's a marker | Log and swallow the handler's own exceptions | every context | | OwnUnitOfWork | `DML.Committable ownUnitOfWorkOn()` | Give a Writer its own `DML` settings, committed right after it | AfterInsert, AfterUpdate, BeforeDelete, AfterDelete, AfterUndelete | - **Bypassable** runs once per handler per run and gets no records. Use it for a static flag, `System.isBatch()` or `FeatureManagement.checkPermission('...')`. It's checked before the first handler of the run, so a flag that an earlier handler sets applies from the next run. An exception there isn't logged and fails the save. To skip single records, return false from the predicate instead. -- **RecursionGuard** counts every time the predicate returns true for a record, per class and context, for the whole transaction. `null` means no limit, and `0` or less skips every record. Avoid `1`: a partial save, such as `Database.update(rows, false)`, Data Loader or the Bulk API, rolls back and runs the trigger again for the rows that succeeded. The count isn't rolled back, so with a cap of `1` their action is lost without an error. Prefer a change gate in the predicate, which passes again on that re-run, and keep any cap at 2 or more. +- **RecursionGuard** counts every time the predicate returns true for a record, per class and context, for the whole transaction. When the record comes back at that count, it's skipped and the run logs an error. Without it the depth is 3, and the run logs the same error but the handler keeps going. `null` means no depth and no error, and `0` or less skips every record. Avoid `1`: a partial save, such as `Database.update(rows, false)`, Data Loader or the Bulk API, rolls back and runs the trigger again for the rows that succeeded. The count isn't rolled back, so with a cap of `1` their action is skipped on that re-run. Prefer a change gate in the predicate, which passes again on that re-run, and keep any cap at 2 or more. - **Finalizer** runs after the handler's records, only when at least one qualified. SOQL is allowed there. It gets no unit of work, so to register more DML, keep the action's `unitOfWork` in an instance field. - **ContinueOnError** is for nice-to-have work such as a notification. After an exception, the handler's remaining records and its Finalizer are skipped, later handlers still run, and a Writer's registrations are dropped. It never swallows a `TriggerTypes.TriggerLibException`, an `EventBus.RetryableException` that asks for redelivery of a platform event, a `System.LimitException`, the `FinalException` from writing a trigger row, or an error outside the handler's own methods. Never add it to a Validator that guards a save, and add a Logger, or a swallowed failure leaves no trace. - **OwnUnitOfWork** returns a DML Lib unit, such as `new DML().userMode().allowPartialSuccess().identifier('OpportunityLossReviewWriter')`. A plain `new DML()` runs in user mode and throws on a duplicate registration, unlike the shared unit. @@ -282,7 +282,7 @@ Implement as many as you need next to the role interface. Each exists only in th | Context | Know this | |---|---| | BeforeInsert | `getId()` is `null` and `records.getIds()` is empty. Key maps by a lookup or a field value | -| BeforeUpdate | The old row is read-only. A Validator runs on every pass; a Populator acts at most 3 times per record | +| BeforeUpdate | The old row is read-only. A Validator runs on every pass; a Populator logs an error when a record comes back after 3 passes | | BeforeDelete | Writes commit before the delete, while other records still point at the rows. Leave the rows being deleted alone: a `toDelete` of one fails with `SELF_REFERENCE_FROM_TRIGGER`, and an update of one is lost. Cascade-deleted children fire no triggers of their own, so handle them here with a RelatedQuery | | AfterInsert, AfterUpdate | The rows are read-only. Inserting or updating the same object runs its triggers again, and the insert contexts have no recursion cap | | AfterDelete | SOQL no longer finds the rows, so query by their old lookups, such as `records.getIdsOf(Contact.AccountId)`. Merge losers arrive here with `MasterRecordId` set | diff --git a/.claude/skills/trigger-lib-orchestrator/SKILL.md b/.claude/skills/trigger-lib-orchestrator/SKILL.md index 9a670bd..48977d4 100644 --- a/.claude/skills/trigger-lib-orchestrator/SKILL.md +++ b/.claude/skills/trigger-lib-orchestrator/SKILL.md @@ -70,7 +70,7 @@ public with sharing class OpportunityTriggerOrchestrator implements TriggerOrche - List order is run order, whatever the role. Each handler finishes all records of the chunk before the next one starts, and its predicate sees what earlier handlers set with `put`. - The Writers' DML on the shared unit of work commits once, after the last handler, so later handlers never see it. A Writer with OwnUnitOfWork or ContinueOnError commits right after itself. -- In the update contexts, a Populator, Writer or Dispatcher acts on the same record at most 3 times per transaction by default, and later passes skip the record without an error. A handler's RecursionGuard add-on changes its limit. +- In the update contexts, when a record comes back to a Populator, Writer or Dispatcher that already acted on it 3 times in the transaction, the run logs an error and the handler keeps running. A handler's RecursionGuard add-on sets its own depth, and there the record is also skipped. ## Handlers in custom metadata @@ -82,7 +82,7 @@ trigger AccountTrigger on Account(before insert, after insert, before update, af } ``` -Deploy one `TriggerObject__mdt` record for the object and one `TriggerHandler__mdt` record per handler class, in `customMetadata/..md-meta.xml`: +Deploy one `TriggerObject__mdt` record for the object and one `TriggerHandler__mdt` record per handler class and context, in `customMetadata/..md-meta.xml`: ```xml @@ -100,15 +100,15 @@ Deploy one `TriggerObject__mdt` record for the object and one `TriggerHandler__m ``` -That is `TriggerObject.Account.md-meta.xml`. Next to it, `TriggerHandler.AccountRatingPopulator.md-meta.xml`: +That is `TriggerObject.Account.md-meta.xml`. Next to it, `TriggerHandler.AccountRatingPopulator_BeforeInsert.md-meta.xml`: ```xml - + false - TriggerObject__c + BeforeInsert__c Account @@ -123,25 +123,32 @@ That is `TriggerObject.Account.md-meta.xml`. Next to it, `TriggerHandler.Account Order__c 10 + + Description__c + Sets Rating from AnnualRevenue. + ``` +The same class in before update is a second record, `TriggerHandler.AccountRatingPopulator_BeforeUpdate.md-meta.xml`, with `BeforeUpdate__c` in place of `BeforeInsert__c` and its own `Order__c`. + - Declare `xmlns:xsd`. Without it, the deployment fails with an `UNKNOWN_EXCEPTION` that names no component. -- `TriggerObject__c` holds the `TriggerObject__mdt` record's DeveloperName alone, such as `Account`, not `TriggerObject.Account`. -- `ObjectAPIName__c` is the object's API name, such as `Account` or `Invoice__c`. Keep one `TriggerObject__mdt` record per object: with several, only one is read. -- `Order__c` is an `xsd:double`. The lowest runs first, a blank order runs last (leave its `` block out), and ties run in class name order. +- The seven context fields are `BeforeInsert__c`, `AfterInsert__c`, `BeforeUpdate__c`, `AfterUpdate__c`, `BeforeDelete__c`, `AfterDelete__c` and `AfterUndelete__c`. A record fills exactly one of them, with the `TriggerObject__mdt` record's DeveloperName alone, such as `Account`, not `TriggerObject.Account`, and the other six stay blank. That registers the class in that one context on that object. A class that runs in several contexts has one record per context, each with its own `Order__c`, and a class on two objects has records for each object. The validation rule `OneContextRequired` rejects a record with no context field filled or with more than one, in Setup and in deployments. +- `ObjectAPIName__c` is the object's API name, such as `Account` or `Invoice__c`. Keep one `TriggerObject__mdt` record per object: with several, the library uses one, and the handler records that name another are ignored. +- `Order__c` is a required `xsd:double`: the run order within the record's context. The lowest runs first, and ties run in class name order. - `ApexClassName__c` holds the full class name: `AccountRules.RatingPopulator` for an inner class, or `ns.AccountRatingPopulator` for a class in another namespace. -- A record's DeveloperName, the part of the file name after the type, holds at most 40 characters: letters, digits and single underscores, starting with a letter. Shorten it when the class name is longer, and keep the full name in `ApexClassName__c`. -- The class needs a public no-argument constructor. The library creates every registered class in every context of the object and runs it in each context whose role it implements, so keep the constructor cheap. -- A record whose class can't be found or created, or that implements no handler interface, fails every save on the object with a `TriggerTypes.TriggerLibException`. Add a registration test with the `trigger-lib-testing` skill. -- With the `btcdev` package, the type and field names carry the prefix and the values don't: the files are `btcdev__TriggerObject.Account.md-meta.xml` and `btcdev__TriggerHandler.AccountRatingPopulator.md-meta.xml`, each field is written like `btcdev__Bypass__c`, and `btcdev__TriggerObject__c` and `btcdev__ApexClassName__c` hold `Account` and `AccountRatingPopulator` as above. +- `Description__c` is free text for admins, such as what the handler does. The library never reads it. +- A record's DeveloperName, the part of the file name after the type, is `_`, such as `AccountRatingPopulator_BeforeUpdate`, and holds at most 40 characters: letters, digits and single underscores, starting with a letter. Shorten the class part when the name is longer, and keep the full name in `ApexClassName__c`. The label is free text. +- The class needs a public no-argument constructor. The library creates it on every run of each context its records name, so keep the constructor cheap. +- A record fails every save on the object in its context, with a `TriggerTypes.TriggerLibException`, when its class can't be found or created, or doesn't implement that context's `Handler`, such as `BeforeInsert.Handler` for `BeforeInsert__c`. Add a registration test with the `trigger-lib-testing` skill. +- With the `btcdev` package, the type and field names carry the prefix and the values don't: the files are `btcdev__TriggerObject.Account.md-meta.xml` and `btcdev__TriggerHandler.AccountRatingPopulator_BeforeInsert.md-meta.xml`, each field is written like `btcdev__Bypass__c`, and `btcdev__BeforeInsert__c` and `btcdev__ApexClassName__c` hold `Account` and `AccountRatingPopulator` as above. ## Bypasses | You need to | Use | |---|---| | Skip handlers in one transaction from Apex, such as a data fix, a batch or test setup | `TriggerOrchestrator.bypass()` with `sObject(...)`, `handler(...)`, `orchestrator(...)` or `all()`, and `clear()` in `finally` | -| Let admins switch an object or one handler off for the whole org without a deployment | `Bypass__c` on the object's `TriggerObject__mdt` record, which switches off every handler on the object, or on a `TriggerHandler__mdt` record under it, which switches off one class | +| Let admins switch an object or one handler off for the whole org without a deployment | `Bypass__c` on the object's `TriggerObject__mdt` record, which switches off every handler on the object, or on a `TriggerHandler__mdt` record, which switches off one class in the one context the record names | | Skip one handler in one context on a condition that doesn't depend on the records, such as a feature flag, `System.isBatch()` or a custom permission | The context's Bypassable add-on, `bypassOnWhen()` (see `trigger-lib-handler`) | | Skip some records | Return false from the handler's predicate | | Skip handlers for some users | There is no built-in per-user bypass. Check a custom permission in `bypassOnWhen()`, or return an empty list from `Handlers()` under the same check | @@ -157,10 +164,10 @@ try { - An Apex bypass lasts until `clear()` or the end of the transaction, nested saves included. `clear()` removes every bypass, also those a caller set, so call it only in the code that set the bypass. A Queueable, a future method or the next batch `execute` starts without it. - `handler(X.class)` and `orchestrator(X.class)` never match an inner class. For an inner handler, use a `TriggerHandler__mdt` record or the Bypassable add-on. `orchestrator(...)` has no effect under `runFromMetadata()`. -- To give admins a switch for one handler, deploy its `TriggerHandler__mdt` record with `Bypass__c` unchecked, together with the object's `TriggerObject__mdt` record. The object's record alone switches off every handler on the object, which suits a data load. +- To give admins a switch for one handler, deploy its `TriggerHandler__mdt` records with `Bypass__c` unchecked, one record per context the class runs in, together with the object's `TriggerObject__mdt` record. The object's record alone switches off every handler on the object, which suits a data load. - An admin flips the switch in Setup: Custom Metadata Types, then Manage Records next to Trigger Handler (or Trigger Object), then Edit on the record, then the Bypass checkbox. Metadata is read once per transaction, so the change applies from the next transaction. -- A `TriggerHandler__mdt` record needs its object's `TriggerObject__mdt` record, even with `Bypass__c` unchecked. A record covers every context of its object; to skip one context only, use that context's Bypassable. A class used on two objects needs a record under each. -- Under `run(orchestrator)`, a `TriggerHandler__mdt` record only switches its class off, and `Order__c` is ignored. Deleting the record doesn't switch the class off. +- A `TriggerHandler__mdt` record needs its object's `TriggerObject__mdt` record, even with `Bypass__c` unchecked. Its `Bypass__c` switches the class off only in the record's context on that object, so one checked record switches off one context, and the class's other records keep running. A class used on two objects needs records for each object. +- Under `run(orchestrator)`, a `TriggerHandler__mdt` record only switches its class off, in its context, and `Order__c` is ignored, though it is still required. Deleting the record doesn't switch the class off. - A deployment that includes the records sets `Bypass__c` back to the deployed value. Once admins flip switches in production, leave the records out of later deployments or keep the files in step with production. To ship a handler switched off, deploy its record checked and uncheck it when it should go live. - A bypass skips only Trigger Lib handlers. Flows, validation rules and other triggers still run. @@ -205,7 +212,7 @@ public with sharing class TriggerErrorLogger implements TriggerOrchestrator.Logg } ``` -- `log(error)` runs for every exception in a handler's turn, before the library rethrows it or ContinueOnError swallows it. `flush()` runs at the end of each outermost run, also when the run failed, so clear your buffer there. +- `log(error)` runs for every exception in a handler's turn, before the library rethrows it or ContinueOnError swallows it. It also gets one `TriggerTypes.TriggerLibException`, never thrown, for each record that comes back to an update handler at its recursion depth: 3, or the RecursionGuard's number. That call happens inside the record loop, so keep `log()` free of SOQL and DML. `flush()` runs at the end of each outermost run, also when the run failed, so clear your buffer there. The exception is a save that fails with `maximum trigger depth exceeded`: `flush()` doesn't run for it or for any later save in that transaction. - With two Logger classes in the org, every Trigger Lib trigger throws. Never throw from a Logger: its exception replaces the handler's and fails the save. - ContinueOnError never swallows a `TriggerTypes.TriggerLibException` or an `EventBus.RetryableException`, which asks for redelivery of a platform event. - Not logged, and never swallowed by ContinueOnError: exceptions in `Handlers()`, in creating `runFromMetadata()` classes, in `bypassOnWhen()`, `ownUnitOfWorkOn()` and `maxRunsPerRecordOn()`, in the ParentQuery and PriorParentQuery methods, and in the shared unit of work's commit. @@ -242,6 +249,8 @@ Then replace the trigger's body with the `run(...)` line, remove or deactivate t | `bypass()` without `clear()` in a `finally` block | Wrap the DML in `try` / `finally` | | `bypass().handler(...)` with an inner class | A top-level class, or a `TriggerHandler__mdt` record | | Custom metadata XML without `xmlns:xsd` | Copy the header above | -| `TriggerObject__c` set to `TriggerObject.Account` | The DeveloperName alone: `Account` | +| A context field such as `BeforeInsert__c` set to `TriggerObject.Account` | The DeveloperName alone: `Account` | +| A `TriggerHandler__mdt` record in a context whose `Handler` the class doesn't implement | A record only for a context whose `Handler` the class implements | +| A `TriggerHandler__mdt` record with two context fields filled, or none, which `OneContextRequired` rejects | One record per context, each with exactly one context field filled | | A second class implementing `TriggerOrchestrator.Logger` | Keep exactly one | | A metadata handler with constructor arguments | A public no-argument constructor | diff --git a/.claude/skills/trigger-lib-testing/SKILL.md b/.claude/skills/trigger-lib-testing/SKILL.md index 7c0cc4c..0fd6b5c 100644 --- a/.claude/skills/trigger-lib-testing/SKILL.md +++ b/.claude/skills/trigger-lib-testing/SKILL.md @@ -16,7 +16,7 @@ Queue a mocked trigger context for a handler class with `TriggerOrchestrator.moc 5. Give parents with `withParent(lookupField, parent)`. Each parent carries the Id that the row's lookup holds. 6. Test one behaviour per method, including the records the predicate must skip. Name the method after the handler method it proves and the case, such as `populateOnBeforeInsertWithMixedCaseEmail`, `writeOnAfterInsertWithInactiveOwner` or `bypassOnAfterInsertWhenFlagIsSet`, and mark the parts with `// Setup`, `// Test` and `// Verify`. 7. Add `Test.startTest()` and `Test.stopTest()` only when the test needs fresh limits or must run async work. -8. If Trigger Lib is installed as the `btcdev` package (existing code calls `btcdev.TriggerOrchestrator`, or `sfdx-project.json` or `sf package installed list` shows the Trigger Lib package), prefix the library types and the custom metadata: `btcdev.TriggerOrchestrator.mock()`, `btcdev.TriggerTypes.RandomIdGenerator`, `btcdev.DML.mock('triggerUow')`, `btcdev.SOQL.mock(...)`, `btcdev__TriggerObject__mdt`, `btcdev__Bypass__c`. Keep the mock keys `'triggerUow'`, `'TriggerObject__mdt'` and `'ApexTypeImplementor'` unprefixed: they are text inside the package. Mock the library's queries and commit through `btcdev.SOQL` and `btcdev.DML` even when the project has its own `SOQL` or `DML` class, and mock a provider's query through the SOQL class the provider uses. +8. If Trigger Lib is installed as the `btcdev` package (existing code calls `btcdev.TriggerOrchestrator`, or `sfdx-project.json` or `sf package installed list` shows the Trigger Lib package), prefix the library types and the custom metadata: `btcdev.TriggerOrchestrator.mock()`, `btcdev.TriggerTypes.RandomIdGenerator`, `btcdev.DML.mock('triggerUow')`, `btcdev.SOQL.mock(...)`, `btcdev__TriggerObject__mdt`, `btcdev__Bypass__c`. Keep the mock keys `'triggerUow'`, `'TriggerObject__mdt'`, `'TriggerHandler__mdt'` and `'ApexTypeImplementor'` unprefixed: they are text inside the package. Mock the library's queries and commit through `btcdev.SOQL` and `btcdev.DML` even when the project has its own `SOQL` or `DML` class, and mock a provider's query through the SOQL class the provider uses. ## Test API @@ -220,25 +220,36 @@ static void afterInsertHandlersContainsWelcomeTaskWriter() { } ``` -A trigger that calls `runFromMetadata()` has no orchestrator to run. Test its handlers one by one, and test that every deployed `TriggerHandler__mdt` record names a class that exists, can be created with no arguments and implements a context's `Handler`: +A trigger that calls `runFromMetadata()` has no orchestrator to run. Test its handlers one by one, and test that every deployed `TriggerHandler__mdt` record names a class that exists, can be created with no arguments and implements the `Handler` of the context whose field the record fills: ```apex @IsTest -static void accountHandlerRecordsResolve() { +static void handlerRecordsResolve() { // Setup List brokenClasses = new List(); // Test - for (TriggerHandler__mdt handlerRecord : [SELECT ApexClassName__c FROM TriggerHandler__mdt WHERE TriggerObject__r.ObjectAPIName__c = 'Account']) { + for (TriggerHandler__mdt handlerRecord : [ + SELECT ApexClassName__c, BeforeInsert__c, AfterInsert__c, BeforeUpdate__c, AfterUpdate__c, BeforeDelete__c, AfterDelete__c, AfterUndelete__c + FROM TriggerHandler__mdt + ]) { Object handler = Type.forName(handlerRecord.ApexClassName__c.trim())?.newInstance(); - if (!(handler instanceof BeforeInsert.Handler || handler instanceof AfterInsert.Handler || handler instanceof BeforeUpdate.Handler || handler instanceof AfterUpdate.Handler || handler instanceof BeforeDelete.Handler || handler instanceof AfterDelete.Handler || handler instanceof AfterUndelete.Handler)) { + if ( + (handlerRecord.BeforeInsert__c != null && !(handler instanceof BeforeInsert.Handler)) || + (handlerRecord.AfterInsert__c != null && !(handler instanceof AfterInsert.Handler)) || + (handlerRecord.BeforeUpdate__c != null && !(handler instanceof BeforeUpdate.Handler)) || + (handlerRecord.AfterUpdate__c != null && !(handler instanceof AfterUpdate.Handler)) || + (handlerRecord.BeforeDelete__c != null && !(handler instanceof BeforeDelete.Handler)) || + (handlerRecord.AfterDelete__c != null && !(handler instanceof AfterDelete.Handler)) || + (handlerRecord.AfterUndelete__c != null && !(handler instanceof AfterUndelete.Handler)) + ) { brokenClasses.add(handlerRecord.ApexClassName__c); } } // Verify - Assert.areEqual(new List(), brokenClasses, 'Every registered class should be a handler.'); + Assert.areEqual(new List(), brokenClasses, 'Every registered class should implement the context its record names.'); } ``` @@ -253,9 +264,21 @@ SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); ``` -With the `btcdev` package, the mock keys stay the same and the types get the prefix: `btcdev.SOQL.mock('TriggerObject__mdt').thenReturn(new List());`. +The handler records come from a second query, `'TriggerHandler__mdt'`. Each is attached to the object record whose Id its context field holds, and the org's records can't hold a mocked record's Id, so the `'TriggerObject__mdt'` mock above keeps them out of the test too. Mock `'TriggerHandler__mdt'` only when the test wants handler records. Each mocked row is a plain constructor with the object record's fake Id in its one context field, one row per context, and the list order is the run order: -- To test a metadata bypass, return `new TriggerObject__mdt(ObjectAPIName__c = 'Account', Bypass__c = true)` instead of the empty list. `TriggerHandler__mdt` rows sit in the parent's `TriggerHandlers__r` relationship, which a constructor can't set, so build the parent with `JSON.deserialize`. +```apex +TriggerObject__mdt accountSetting = new TriggerObject__mdt(Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), ObjectAPIName__c = 'Account', Bypass__c = false); + +SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); +SOQL.mock('TriggerHandler__mdt').thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'AccountHotRatingPopulator', Bypass__c = true, BeforeUpdate__c = accountSetting.Id) }); +SOQL.mock('ApexTypeImplementor').thenReturn(new List()); +``` + +With the `btcdev` package, the mock keys stay the same and the types and fields get the prefix: `btcdev.SOQL.mock('TriggerObject__mdt').thenReturn(new List());`, `btcdev__TriggerHandler__mdt`, `btcdev__BeforeUpdate__c`. + +- To test an object bypass, return `new TriggerObject__mdt(ObjectAPIName__c = 'Account', Bypass__c = true)` instead of the empty list. +- To test a handler bypass, return a `TriggerHandler__mdt` row with `Bypass__c = true`, as above. It switches its class off only in the context whose field holds the object record's Id, and a row that holds no mocked Id is ignored. +- A mock ignores `WHERE` and `ORDER BY`, so a save in an integration test of a `runFromMetadata()` trigger runs the mocked rows in list order, whatever their `Order__c`. - To test an Apex bypass, call `TriggerOrchestrator.bypass().handler(X.class)` or `.sObject(...)` before `runTestFor`. `.orchestrator(...)` has no effect under `runTestFor`. - To test a Bypassable that reads a static flag, set the flag in `// Setup` and assert that nothing happened. - To test ContinueOnError, set `TriggerOrchestrator.triggerLogger.logger` to a test class of your own that collects the errors. This works only when the Trigger Lib source is in the project: in the `btcdev` package that member isn't visible to your tests. @@ -289,7 +312,8 @@ Name each unit test class `Test` and keep it next to the handler, unles | A Writer test fails in the commit | The unit of work isn't mocked, so real DML runs with fake Ids | `DML.mock('triggerUow').allDmls()` | | `retrieveResultFor('triggerUow')` misses an OwnUnitOfWork Writer's records | It commits under its own identifier | Mock and read that identifier | | Provider rows never arrive | The provider uses inline SOQL, or the mock key differs from its `mockId` | SOQL Lib with `mockId`, and `SOQL.mock('')` | -| A handler is skipped in one org and runs in another | The org's bypass metadata applies to tests | Mock `'TriggerObject__mdt'` before the first reference to `TriggerOrchestrator` | +| A handler is skipped in one org and runs in another | The org's `TriggerObject__mdt` and `TriggerHandler__mdt` records apply to tests | Mock `'TriggerObject__mdt'` before the first reference to `TriggerOrchestrator`. The org's handler records can't point at a mocked object record | +| A mocked `TriggerHandler__mdt` row has no effect | Its context field doesn't hold the mocked object record's Id | Set a fake Id on the object record and put it in the row's context field, one row per context | | `bypass().orchestrator(...)` has no effect | It doesn't apply under `runTestFor` | `bypass().handler(...)` or `sObject(...)` | | The test fails after the last line with a callout error | A job the Dispatcher enqueued runs when the test ends | `Test.setMock(...)` for the callout | | A test inserts records to fire the trigger | Old trigger testing habit | Queue the context with `mock()` and run with `runTestFor`. Keep DML for integration tests | diff --git a/.cursor/rules/trigger-lib.mdc b/.cursor/rules/trigger-lib.mdc index 18356f0..6fee976 100644 --- a/.cursor/rules/trigger-lib.mdc +++ b/.cursor/rules/trigger-lib.mdc @@ -9,7 +9,7 @@ alwaysApply: false This project runs its Apex trigger logic through Trigger Lib (`TriggerOrchestrator.cls`). - Each object has one trigger that lists all seven events (only `after insert` on a platform event or change event), and its body is only `TriggerOrchestrator.run(new TriggerOrchestrator());` or `TriggerOrchestrator.runFromMetadata();`. Don't put logic in a trigger body, and don't write `Trigger.isBefore` or `Trigger.isInsert` branches, loops over `Trigger.new`, or handler classes of other trigger frameworks. -- Write each concern as one top-level handler class with one role per context, and register it in run order: in the object's orchestrator, or as a `TriggerHandler__mdt` record with `Order__c` when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context. +- Write each concern as one top-level handler class with one role per context, and register it in run order in each context it runs in: in the object's orchestrator, or as one `TriggerHandler__mdt` record per context, with `Order__c`, whose context field, such as `BeforeInsert__c`, names the object, when the trigger calls `runFromMetadata()`. The roles: a Populator sets fields with `record.put` in before insert or before update, a Validator rejects with `record.addError` in before insert, before update or before delete, a Writer changes other records through `TriggerTypes.UnitOfWork` in before delete or an after context, and a Dispatcher makes one bulk call per chunk, such as `System.enqueueJob`, in an after context. - Never run SOQL or DML in a handler's predicate (`...When`) or action, because the library throws. Read parents with ParentQuery or PriorParentQuery, other records with RelatedQuery and configuration from static variables. Register writes with `unitOfWork.toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish`, as new instances that hold the Id and the changed fields. - In the update contexts, make every predicate require a change, such as `record.isChanged(...)` or `isChangedTo(...)`. - Test handlers with `TriggerOrchestrator.mock().For(X.class).with(...)` and `TriggerOrchestrator.runTestFor(new X())`, and mock the unit of work with `DML.mock('triggerUow').allDmls()`, instead of inserting records to fire the trigger. Keep one test per trigger that saves a record with DML, because a trigger without coverage can't be deployed to production. diff --git a/force-app/main/default/classes/AfterDelete.cls b/force-app/main/default/classes/AfterDelete.cls index b4e0936..8abe95c 100644 --- a/force-app/main/default/classes/AfterDelete.cls +++ b/force-app/main/default/classes/AfterDelete.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class AfterDelete { // Roles - pick one diff --git a/force-app/main/default/classes/AfterDeleteTest.cls b/force-app/main/default/classes/AfterDeleteTest.cls index 2d3788a..3b2689b 100644 --- a/force-app/main/default/classes/AfterDeleteTest.cls +++ b/force-app/main/default/classes/AfterDeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/AfterInsert.cls b/force-app/main/default/classes/AfterInsert.cls index 5fd185a..515cb6e 100644 --- a/force-app/main/default/classes/AfterInsert.cls +++ b/force-app/main/default/classes/AfterInsert.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class AfterInsert { // Roles - pick one diff --git a/force-app/main/default/classes/AfterInsertTest.cls b/force-app/main/default/classes/AfterInsertTest.cls index b18f065..8ef8692 100644 --- a/force-app/main/default/classes/AfterInsertTest.cls +++ b/force-app/main/default/classes/AfterInsertTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/AfterUndelete.cls b/force-app/main/default/classes/AfterUndelete.cls index 5628897..3e62695 100644 --- a/force-app/main/default/classes/AfterUndelete.cls +++ b/force-app/main/default/classes/AfterUndelete.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class AfterUndelete { // Roles - pick one diff --git a/force-app/main/default/classes/AfterUndeleteTest.cls b/force-app/main/default/classes/AfterUndeleteTest.cls index b25de31..47dd41c 100644 --- a/force-app/main/default/classes/AfterUndeleteTest.cls +++ b/force-app/main/default/classes/AfterUndeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/AfterUpdate.cls b/force-app/main/default/classes/AfterUpdate.cls index efeab7d..3f078f6 100644 --- a/force-app/main/default/classes/AfterUpdate.cls +++ b/force-app/main/default/classes/AfterUpdate.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class AfterUpdate { // Roles - pick one @@ -97,6 +103,19 @@ public class AfterUpdate { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); for (TriggerTypes.TriggerRecord record : records) { + if (this.recursionTracker.isDepthReached(record)) { + this.logError( + new TriggerTypes.TriggerLibException( + this.handlerName + + ' reached the recursion depth of ' + + this.recursionTracker.maxRunsPerRecord + + ' on record ' + + record.getId() + + ' in after update.' + ) + ); + } + this.write(record); } @@ -125,7 +144,7 @@ public class AfterUpdate { } private void write(TriggerTypes.TriggerRecord record) { - if (!this.recursionTracker.isLimitReached(record) && this.writer.writeOnAfterUpdateWhen(record)) { + if (!this.recursionTracker.isSkippedByRecursionGuard(record) && this.writer.writeOnAfterUpdateWhen(record)) { this.recursionTracker.countRun(record); this.qualifiedRecords.add(record); this.writer.writeOnAfterUpdate(record, this.unitOfWork); @@ -164,6 +183,19 @@ public class AfterUpdate { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); for (TriggerTypes.TriggerRecord record : records) { + if (this.recursionTracker.isDepthReached(record)) { + this.logError( + new TriggerTypes.TriggerLibException( + this.handlerName + + ' reached the recursion depth of ' + + this.recursionTracker.maxRunsPerRecord + + ' on record ' + + record.getId() + + ' in after update.' + ) + ); + } + this.qualify(record); } @@ -189,7 +221,7 @@ public class AfterUpdate { } private void qualify(TriggerTypes.TriggerRecord record) { - if (!this.recursionTracker.isLimitReached(record) && this.dispatcher.dispatchOnAfterUpdateWhen(record)) { + if (!this.recursionTracker.isSkippedByRecursionGuard(record) && this.dispatcher.dispatchOnAfterUpdateWhen(record)) { this.recursionTracker.countRun(record); this.qualifiedRecords.add(record); } @@ -307,13 +339,21 @@ public class AfterUpdate { } public class RecursionTracker extends TriggerOrchestrator.RecursionTracker { + private AfterUpdate.Handler handler; + public RecursionTracker(AfterUpdate.Handler handler, String handlerName) { super(handlerName + ':AFTER_UPDATE'); + this.handler = handler; + if (handler instanceof AfterUpdate.RecursionGuard) { this.maxRunsPerRecord = ((AfterUpdate.RecursionGuard) handler).maxRunsPerRecordOnAfterUpdate(); } } + + public override Boolean hasRecursionGuard() { + return this.handler instanceof AfterUpdate.RecursionGuard; + } } public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.AfterUpdateMockable, TriggerOrchestrator.AfterUpdate { diff --git a/force-app/main/default/classes/AfterUpdateTest.cls b/force-app/main/default/classes/AfterUpdateTest.cls index 2a8f80c..6e3102b 100644 --- a/force-app/main/default/classes/AfterUpdateTest.cls +++ b/force-app/main/default/classes/AfterUpdateTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -398,7 +398,121 @@ private class AfterUpdateTest { TriggerOrchestrator.runTestFor(new AccountWriter()); // Verify - Assert.areEqual(3, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should not run beyond the recursion depth.'); + Assert.areEqual(4, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should run beyond the default recursion depth.'); + } + + @IsTest + static void writerWithLoggerWhenDefaultRecursionDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual( + 'AccountWriter reached the recursion depth of 3 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + + @IsTest + static void writerWithLoggerWithinDefaultRecursionDepth() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 2; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.isTrue(logger.errors.isEmpty(), 'Nothing should be logged within the recursion depth.'); + } + + @IsTest + static void writerWithLoggerWhenRecordAtDefaultDepthDoesNotQualify() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(new Contact(Id = doe.Id, LastName = 'Doe'), new Contact(Id = doe.Id, LastName = 'Doe')); + } + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(new Contact(Id = doe.Id, LastName = 'Roe'), new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual(1, logger.errors.size(), 'The record should be logged when it comes back at the recursion depth.'); + } + + @IsTest + static void writerWithLoggerWhenDefaultRecursionDepthIsReachedForTwoRecords() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + Contact janeDoe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), FirstName = 'Jane', LastName = 'Doe'); + + TriggerOrchestrator.mock() + .afterUpdateFor(AccountWriter.class) + .with( + new List{ doe, janeDoe }, + new List{ new Contact(Id = doe.Id, LastName = 'Doe'), new Contact(Id = janeDoe.Id, FirstName = 'Jane', LastName = 'Doe') } + ); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual(2, logger.errors.size(), 'Each record at the recursion depth should be logged.'); } @IsTest @@ -421,7 +535,37 @@ private class AfterUpdateTest { TriggerOrchestrator.runTestFor(dispatcher); // Verify - Assert.areEqual(3, dispatcher.dispatchedIds.size(), 'The dispatcher should not run beyond the recursion depth.'); + Assert.areEqual(4, dispatcher.dispatchedIds.size(), 'The dispatcher should run beyond the default recursion depth.'); + } + + @IsTest + static void dispatcherWithLoggerWhenDefaultRecursionDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + IdDispatcher dispatcher = new IdDispatcher(); + + TriggerOrchestrator.mock().afterUpdateFor(IdDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(dispatcher); + } + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual( + 'IdDispatcher reached the recursion depth of 3 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); } @IsTest @@ -444,6 +588,120 @@ private class AfterUpdateTest { Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should not run beyond its recursion depth.'); } + @IsTest + static void writerWithLoggerWhenRecursionGuardDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuard()); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuard()); + + // Verify + Assert.areEqual( + 'AccountWriterWithRecursionGuard reached the recursion depth of 1 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + + @IsTest + static void writerWithRecursionGuardOfTwo() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfTwo.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 2; run++) { + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfTwo()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfTwo()); + + // Verify + Assert.areEqual(2, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should not run beyond its recursion depth.'); + } + + @IsTest + static void writerWithRecursionGuardOfNull() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfNull.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + + // Verify + Assert.areEqual(4, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should run without a recursion depth.'); + } + + @IsTest + static void writerWithLoggerAndRecursionGuardOfNull() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfNull.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + + // Verify + Assert.isTrue(logger.errors.isEmpty(), 'Nothing should be logged without a recursion depth.'); + } + + @IsTest + static void writerWithRecursionGuardOfZero() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfZero.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfZero()); + + // Verify + Assert.areEqual(0, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should skip every record.'); + } + @IsTest static void dispatcherWithRecursionGuard() { // Setup @@ -465,6 +723,34 @@ private class AfterUpdateTest { Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should not run beyond its recursion depth.'); } + @IsTest + static void dispatcherWithLoggerWhenRecursionGuardDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + IdDispatcherWithRecursionGuard dispatcher = new IdDispatcherWithRecursionGuard(); + + TriggerOrchestrator.mock().afterUpdateFor(IdDispatcherWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + TriggerOrchestrator.runTestFor(dispatcher); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual( + 'IdDispatcherWithRecursionGuard reached the recursion depth of 1 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + @IsTest static void writerQualificationWhenRecursionDepthIsExceeded() { // Setup @@ -474,19 +760,17 @@ private class AfterUpdateTest { Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - QualificationCountingWriter writer = new QualificationCountingWriter(); + QualificationCountingWriterWithRecursionGuard writer = new QualificationCountingWriterWithRecursionGuard(); - TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingWriterWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(writer); - } + TriggerOrchestrator.runTestFor(writer); // Test TriggerOrchestrator.runTestFor(writer); // Verify - Assert.areEqual(3, writer.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); + Assert.areEqual(1, writer.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); } @IsTest @@ -497,19 +781,17 @@ private class AfterUpdateTest { Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - QualificationCountingDispatcher dispatcher = new QualificationCountingDispatcher(); + QualificationCountingDispatcherWithRecursionGuard dispatcher = new QualificationCountingDispatcherWithRecursionGuard(); - TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingDispatcherWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(dispatcher); - } + TriggerOrchestrator.runTestFor(dispatcher); // Test TriggerOrchestrator.runTestFor(dispatcher); // Verify - Assert.areEqual(3, dispatcher.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); + Assert.areEqual(1, dispatcher.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); } @IsTest @@ -568,19 +850,17 @@ private class AfterUpdateTest { Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - FinalizeCountingWriter writer = new FinalizeCountingWriter(); + FinalizeCountingWriterWithRecursionGuard writer = new FinalizeCountingWriterWithRecursionGuard(); - TriggerOrchestrator.mock().afterUpdateFor(FinalizeCountingWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + TriggerOrchestrator.mock().afterUpdateFor(FinalizeCountingWriterWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(writer); - } + TriggerOrchestrator.runTestFor(writer); // Test TriggerOrchestrator.runTestFor(writer); // Verify - Assert.areEqual(3, writer.finalizeCalls, 'The finalizer should not run beyond the recursion depth.'); + Assert.areEqual(1, writer.finalizeCalls, 'The finalizer should not run beyond the recursion depth.'); } @IsTest @@ -1061,13 +1341,31 @@ private class AfterUpdateTest { } } + private class AccountWriterWithRecursionGuardOfTwo extends AccountWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 2; + } + } + + private class AccountWriterWithRecursionGuardOfNull extends AccountWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return null; + } + } + + private class AccountWriterWithRecursionGuardOfZero extends AccountWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 0; + } + } + private class IdDispatcherWithRecursionGuard extends IdDispatcher implements AfterUpdate.RecursionGuard { public Integer maxRunsPerRecordOnAfterUpdate() { return 1; } } - private class QualificationCountingWriter implements AfterUpdate.Writer { + private virtual class QualificationCountingWriter implements AfterUpdate.Writer { public Integer qualificationCalls = 0; public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -1080,7 +1378,13 @@ private class AfterUpdateTest { } } - private class QualificationCountingDispatcher implements AfterUpdate.Dispatcher { + private class QualificationCountingWriterWithRecursionGuard extends QualificationCountingWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 1; + } + } + + private virtual class QualificationCountingDispatcher implements AfterUpdate.Dispatcher { public Integer qualificationCalls = 0; public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -1092,7 +1396,13 @@ private class AfterUpdateTest { } } - private class FinalizeCountingWriter extends AccountWriter implements AfterUpdate.Finalizer { + private class QualificationCountingDispatcherWithRecursionGuard extends QualificationCountingDispatcher implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 1; + } + } + + private virtual class FinalizeCountingWriter extends AccountWriter implements AfterUpdate.Finalizer { public Integer finalizeCalls = 0; public void finalizeOnAfterUpdate(TriggerTypes.UpdateRecords records) { @@ -1100,6 +1410,12 @@ private class AfterUpdateTest { } } + private class FinalizeCountingWriterWithRecursionGuard extends FinalizeCountingWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 1; + } + } + private class DmlAccountWriter implements AfterUpdate.Writer { public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { return record.equals(Contact.LastName, 'Doe'); diff --git a/force-app/main/default/classes/BeforeDelete.cls b/force-app/main/default/classes/BeforeDelete.cls index 2f6ef12..475037d 100644 --- a/force-app/main/default/classes/BeforeDelete.cls +++ b/force-app/main/default/classes/BeforeDelete.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class BeforeDelete { // Roles - pick one diff --git a/force-app/main/default/classes/BeforeDeleteTest.cls b/force-app/main/default/classes/BeforeDeleteTest.cls index a086526..cb3493d 100644 --- a/force-app/main/default/classes/BeforeDeleteTest.cls +++ b/force-app/main/default/classes/BeforeDeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/BeforeInsert.cls b/force-app/main/default/classes/BeforeInsert.cls index 9b513eb..3ff3de3 100644 --- a/force-app/main/default/classes/BeforeInsert.cls +++ b/force-app/main/default/classes/BeforeInsert.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class BeforeInsert { // Roles - pick one diff --git a/force-app/main/default/classes/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index 9763789..c5a1a44 100644 --- a/force-app/main/default/classes/BeforeInsertTest.cls +++ b/force-app/main/default/classes/BeforeInsertTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/BeforeUpdate.cls b/force-app/main/default/classes/BeforeUpdate.cls index 0db11f3..eb228c8 100644 --- a/force-app/main/default/classes/BeforeUpdate.cls +++ b/force-app/main/default/classes/BeforeUpdate.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ public class BeforeUpdate { // Roles - pick one @@ -92,6 +98,19 @@ public class BeforeUpdate { Integer queriesBefore = this.getUndeclaredQueries(); for (TriggerTypes.TriggerRecord record : records) { + if (this.recursionTracker.isDepthReached(record)) { + this.logError( + new TriggerTypes.TriggerLibException( + this.handlerName + + ' reached the recursion depth of ' + + this.recursionTracker.maxRunsPerRecord + + ' on record ' + + record.getId() + + ' in before update.' + ) + ); + } + this.populate(record); } @@ -126,7 +145,7 @@ public class BeforeUpdate { } private void populate(TriggerTypes.TriggerRecord record) { - if (!this.recursionTracker.isLimitReached(record) && this.populator.populateOnBeforeUpdateWhen(record)) { + if (!this.recursionTracker.isSkippedByRecursionGuard(record) && this.populator.populateOnBeforeUpdateWhen(record)) { this.recursionTracker.countRun(record); this.qualifiedRecords.add(record); this.populator.populateOnBeforeUpdate(record); @@ -287,13 +306,21 @@ public class BeforeUpdate { } public class RecursionTracker extends TriggerOrchestrator.RecursionTracker { + private BeforeUpdate.Handler handler; + public RecursionTracker(BeforeUpdate.Handler handler, String handlerName) { super(handlerName + ':BEFORE_UPDATE'); + this.handler = handler; + if (handler instanceof BeforeUpdate.RecursionGuard) { this.maxRunsPerRecord = ((BeforeUpdate.RecursionGuard) handler).maxRunsPerRecordOnBeforeUpdate(); } } + + public override Boolean hasRecursionGuard() { + return this.handler instanceof BeforeUpdate.RecursionGuard; + } } public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.BeforeUpdateMockable, TriggerOrchestrator.BeforeUpdate { diff --git a/force-app/main/default/classes/BeforeUpdateTest.cls b/force-app/main/default/classes/BeforeUpdateTest.cls index c689bd3..338cb5a 100644 --- a/force-app/main/default/classes/BeforeUpdateTest.cls +++ b/force-app/main/default/classes/BeforeUpdateTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -525,7 +525,35 @@ private class BeforeUpdateTest { TriggerOrchestrator.runTestFor(new TitlePopulator()); // Verify - Assert.isNull(doe.Title, 'The populator should not run beyond the recursion depth.'); + Assert.areEqual('CEO', doe.Title, 'The populator should run beyond the default recursion depth.'); + } + + @IsTest + static void populatorWithLoggerWhenDefaultRecursionDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(TitlePopulator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new TitlePopulator()); + } + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulator()); + + // Verify + Assert.areEqual( + 'TitlePopulator reached the recursion depth of 3 on record ' + doe.Id + ' in before update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); } @IsTest @@ -551,32 +579,50 @@ private class BeforeUpdateTest { } @IsTest - static void populatorQualificationWhenRecursionDepthIsExceeded() { + static void populatorWithLoggerWhenRecursionGuardDepthIsReached() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - QualificationCountingPopulator populator = new QualificationCountingPopulator(); + TriggerOrchestrator.mock().beforeUpdateFor(TitlePopulatorWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.mock() - .beforeUpdateFor(QualificationCountingPopulator.class) - .with(new Contact(Id = doe.Id, LastName = 'Doe'), new Contact(Id = doe.Id, LastName = 'Doe')); - } + TriggerOrchestrator.runTestFor(new TitlePopulatorWithRecursionGuard()); - TriggerOrchestrator.mock().beforeUpdateFor(QualificationCountingPopulator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithRecursionGuard()); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(populator); - } + // Verify + Assert.areEqual( + 'TitlePopulatorWithRecursionGuard reached the recursion depth of 1 on record ' + doe.Id + ' in before update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + + @IsTest + static void populatorQualificationWhenRecursionDepthIsExceeded() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + QualificationCountingPopulatorWithRecursionGuard populator = new QualificationCountingPopulatorWithRecursionGuard(); + + TriggerOrchestrator.mock().beforeUpdateFor(QualificationCountingPopulatorWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + TriggerOrchestrator.runTestFor(populator); // Test TriggerOrchestrator.runTestFor(populator); // Verify - Assert.areEqual(3, populator.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); + Assert.areEqual(1, populator.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); } @IsTest @@ -602,11 +648,14 @@ private class BeforeUpdateTest { } @IsTest - static void validatorWhenRecursionDepthIsExceeded() { + static void validatorWithLoggerWhenDefaultRecursionDepthIsReached() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); for (Integer run = 0; run < 3; run++) { @@ -623,7 +672,7 @@ private class BeforeUpdateTest { TriggerOrchestrator.runTestFor(new DoeValidator()); // Verify - Assert.areEqual('Doe is not allowed.', doe.getErrors()[0].getMessage(), 'The validator should not be limited by the recursion depth.'); + Assert.isTrue(logger.errors.isEmpty(), 'A validator should not log a recursion error.'); } @IsTest @@ -1100,7 +1149,7 @@ private class BeforeUpdateTest { } } - private class QualificationCountingPopulator implements BeforeUpdate.Populator { + private virtual class QualificationCountingPopulator implements BeforeUpdate.Populator { public Integer qualificationCalls = 0; public Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -1113,6 +1162,12 @@ private class BeforeUpdateTest { } } + private class QualificationCountingPopulatorWithRecursionGuard extends QualificationCountingPopulator implements BeforeUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnBeforeUpdate() { + return 1; + } + } + private virtual class PriorAccountNamePopulator implements BeforeUpdate.Populator, BeforeUpdate.PriorParentQuery { public Map queryPriorParentsOnBeforeUpdate() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/force-app/main/default/classes/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index a86b323..44cf744 100644 --- a/force-app/main/default/classes/TriggerOrchestrator.cls +++ b/force-app/main/default/classes/TriggerOrchestrator.cls @@ -2,7 +2,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -301,32 +301,48 @@ public with sharing class TriggerOrchestrator { } public Boolean isHandlerBypassed(TriggerContext context, String handlerName) { - return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.handlerRowByName.get(handlerName.toLowerCase())?.Bypass__c == true; + return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.getHandlerRow(context.triggerOperation, handlerName)?.Bypass__c == true; } - public List getHandlerRows(SObjectType sObjectType) { - return this.metadataByObjectName.get(sObjectType.toString().toLowerCase())?.objectRow.TriggerHandlers__r ?? new List(); + public List getHandlerRows(SObjectType sObjectType, System.TriggerOperation triggerOperation) { + return this.metadataByObjectName.get(sObjectType.toString().toLowerCase())?.getHandlerRows(triggerOperation) ?? new List(); } private Map resolveObjectMetadata() { + Map metadataByObjectId = new Map(); Map metadataByObjectName = new Map(); - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); for ( TriggerObject__mdt triggerObject : (List) SOQL.of(TriggerObject__mdt.SObjectType) .with(TriggerObject__mdt.ObjectAPIName__c, TriggerObject__mdt.Bypass__c) - .with( - SOQL.SubQuery.of(namespacePrefix + 'TriggerHandlers__r') - .with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c, TriggerHandler__mdt.Order__c) - .orderBy(TriggerHandler__mdt.Order__c) - .nullsLast() - .orderBy(TriggerHandler__mdt.ApexClassName__c) - ) .systemMode() .mockId('TriggerObject__mdt') .toList() ) { - metadataByObjectName.put(triggerObject.ObjectAPIName__c.trim().toLowerCase(), new ObjectMetadata(triggerObject)); + ObjectMetadata objectMetadata = new ObjectMetadata(triggerObject); + + metadataByObjectId.put(triggerObject.Id, objectMetadata); + metadataByObjectName.put(triggerObject.ObjectAPIName__c.trim().toLowerCase(), objectMetadata); + } + + for ( + TriggerHandler__mdt handlerRow : (List) SOQL.of(TriggerHandler__mdt.SObjectType) + .with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c, TriggerHandler__mdt.Order__c) + .with(TriggerHandler__mdt.BeforeInsert__c, TriggerHandler__mdt.AfterInsert__c, TriggerHandler__mdt.BeforeUpdate__c, TriggerHandler__mdt.AfterUpdate__c) + .with(TriggerHandler__mdt.BeforeDelete__c, TriggerHandler__mdt.AfterDelete__c, TriggerHandler__mdt.AfterUndelete__c) + .orderBy(TriggerHandler__mdt.Order__c) + .orderBy(TriggerHandler__mdt.ApexClassName__c) + .systemMode() + .mockId('TriggerHandler__mdt') + .toList() + ) { + metadataByObjectId.get(handlerRow.BeforeInsert__c)?.addHandlerRow(System.TriggerOperation.BEFORE_INSERT, handlerRow); + metadataByObjectId.get(handlerRow.AfterInsert__c)?.addHandlerRow(System.TriggerOperation.AFTER_INSERT, handlerRow); + metadataByObjectId.get(handlerRow.BeforeUpdate__c)?.addHandlerRow(System.TriggerOperation.BEFORE_UPDATE, handlerRow); + metadataByObjectId.get(handlerRow.AfterUpdate__c)?.addHandlerRow(System.TriggerOperation.AFTER_UPDATE, handlerRow); + metadataByObjectId.get(handlerRow.BeforeDelete__c)?.addHandlerRow(System.TriggerOperation.BEFORE_DELETE, handlerRow); + metadataByObjectId.get(handlerRow.AfterDelete__c)?.addHandlerRow(System.TriggerOperation.AFTER_DELETE, handlerRow); + metadataByObjectId.get(handlerRow.AfterUndelete__c)?.addHandlerRow(System.TriggerOperation.AFTER_UNDELETE, handlerRow); } return metadataByObjectName; @@ -335,27 +351,35 @@ public with sharing class TriggerOrchestrator { @TestVisible private class ObjectMetadata { - public TriggerObject__mdt objectRow = new TriggerObject__mdt(); - public Map handlerRowByName = new Map(); + public TriggerObject__mdt objectRow; + private Map> handlerRowsByOperation = new Map>(); + private Map> handlerRowByNameByOperation = new Map>(); - public ObjectMetadata(TriggerObject__mdt triggerObject) { - this.objectRow = triggerObject; - this.addHandlerRows(); + public ObjectMetadata(TriggerObject__mdt objectRow) { + this.objectRow = objectRow; } - private void addHandlerRows() { - for (TriggerHandler__mdt triggerHandler : this.objectRow.TriggerHandlers__r ?? new List()) { - this.addHandlerRow(triggerHandler); - } + public List getHandlerRows(System.TriggerOperation triggerOperation) { + return this.handlerRowsByOperation.get(triggerOperation) ?? new List(); + } + + public TriggerHandler__mdt getHandlerRow(System.TriggerOperation triggerOperation, String handlerName) { + return this.handlerRowByNameByOperation.get(triggerOperation)?.get(handlerName.toLowerCase()); } - private void addHandlerRow(TriggerHandler__mdt triggerHandler) { - String handlerName = triggerHandler.ApexClassName__c.trim().toLowerCase(); + public void addHandlerRow(System.TriggerOperation triggerOperation, TriggerHandler__mdt handlerRow) { + String handlerName = handlerRow.ApexClassName__c.trim().toLowerCase(); - this.handlerRowByName.put(handlerName, triggerHandler); + if (!this.handlerRowsByOperation.containsKey(triggerOperation)) { + this.handlerRowsByOperation.put(triggerOperation, new List()); + this.handlerRowByNameByOperation.put(triggerOperation, new Map()); + } + + this.handlerRowsByOperation.get(triggerOperation).add(handlerRow); + this.handlerRowByNameByOperation.get(triggerOperation).put(handlerName, handlerRow); if (handlerName.contains('.')) { - this.handlerRowByName.put(handlerName.substringAfterLast('.'), triggerHandler); + this.handlerRowByNameByOperation.get(triggerOperation).put(handlerName.substringAfterLast('.'), handlerRow); } } } @@ -371,10 +395,8 @@ public with sharing class TriggerOrchestrator { public List beforeInsertHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof BeforeInsert.Handler) { - handlers.add((BeforeInsert.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.BEFORE_INSERT, BeforeInsert.Handler.class)) { + handlers.add((BeforeInsert.Handler) handler); } return handlers; @@ -383,10 +405,8 @@ public with sharing class TriggerOrchestrator { public List afterInsertHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterInsert.Handler) { - handlers.add((AfterInsert.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_INSERT, AfterInsert.Handler.class)) { + handlers.add((AfterInsert.Handler) handler); } return handlers; @@ -395,10 +415,8 @@ public with sharing class TriggerOrchestrator { public List beforeUpdateHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof BeforeUpdate.Handler) { - handlers.add((BeforeUpdate.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.BEFORE_UPDATE, BeforeUpdate.Handler.class)) { + handlers.add((BeforeUpdate.Handler) handler); } return handlers; @@ -407,10 +425,8 @@ public with sharing class TriggerOrchestrator { public List afterUpdateHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterUpdate.Handler) { - handlers.add((AfterUpdate.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_UPDATE, AfterUpdate.Handler.class)) { + handlers.add((AfterUpdate.Handler) handler); } return handlers; @@ -419,10 +435,8 @@ public with sharing class TriggerOrchestrator { public List beforeDeleteHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof BeforeDelete.Handler) { - handlers.add((BeforeDelete.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.BEFORE_DELETE, BeforeDelete.Handler.class)) { + handlers.add((BeforeDelete.Handler) handler); } return handlers; @@ -431,10 +445,8 @@ public with sharing class TriggerOrchestrator { public List afterDeleteHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterDelete.Handler) { - handlers.add((AfterDelete.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_DELETE, AfterDelete.Handler.class)) { + handlers.add((AfterDelete.Handler) handler); } return handlers; @@ -443,55 +455,41 @@ public with sharing class TriggerOrchestrator { public List afterUndeleteHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterUndelete.Handler) { - handlers.add((AfterUndelete.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_UNDELETE, AfterUndelete.Handler.class)) { + handlers.add((AfterUndelete.Handler) handler); } return handlers; } - private List newHandlers() { + private List newHandlers(System.TriggerOperation triggerOperation, System.Type handlerInterface) { List handlers = new List(); - for (TriggerHandler__mdt handlerRow : TriggerOrchestrator.metadataBypass.getHandlerRows(this.sObjectType)) { + for (TriggerHandler__mdt handlerRow : TriggerOrchestrator.metadataBypass.getHandlerRows(this.sObjectType, triggerOperation)) { if (handlerRow.Bypass__c != true) { - handlers.add(this.newHandler(handlerRow.ApexClassName__c.trim())); + handlers.add(this.newHandler(handlerRow.ApexClassName__c.trim(), handlerInterface)); } } return handlers; } - private Object newHandler(String className) { + private Object newHandler(String className, System.Type handlerInterface) { System.Type handlerType = Type.forName(className) ?? Type.forName(null, className); if (handlerType == null) { throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' was not found.'); } - Object handler; + if (!handlerInterface.isAssignableFrom(handlerType)) { + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' does not implement ' + handlerInterface.getName() + '.'); + } try { - handler = handlerType.newInstance(); + return handlerType.newInstance(); } catch (Exception e) { throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' could not be instantiated.', e); } - - if ( - handler instanceof BeforeInsert.Handler || - handler instanceof AfterInsert.Handler || - handler instanceof BeforeUpdate.Handler || - handler instanceof AfterUpdate.Handler || - handler instanceof BeforeDelete.Handler || - handler instanceof AfterDelete.Handler || - handler instanceof AfterUndelete.Handler - ) { - return handler; - } - - throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' does not implement a handler interface.'); } } @@ -1016,21 +1014,27 @@ public with sharing class TriggerOrchestrator { } public abstract class RecursionTracker { + public Integer maxRunsPerRecord = 3; private Map runCountByRecordId; - protected Integer maxRunsPerRecord = 3; public RecursionTracker(String recursionScope) { TriggerOrchestrator.runCounter.registerScope(recursionScope); this.runCountByRecordId = TriggerOrchestrator.runCounter.getForRecursionScope(recursionScope); } - public Boolean isLimitReached(TriggerTypes.TriggerRecord record) { + public Boolean isDepthReached(TriggerTypes.TriggerRecord record) { return (this.runCountByRecordId.get(record.getId()) ?? 0) >= this.maxRunsPerRecord; } + public Boolean isSkippedByRecursionGuard(TriggerTypes.TriggerRecord record) { + return this.hasRecursionGuard() && this.isDepthReached(record); + } + public void countRun(TriggerTypes.TriggerRecord record) { this.runCountByRecordId.put(record.getId(), (this.runCountByRecordId.get(record.getId()) ?? 0) + 1); } + + public abstract Boolean hasRecursionGuard(); } private class RunCounter { diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 9c16ea6..13e1912 100644 --- a/force-app/main/default/classes/TriggerOrchestratorTest.cls +++ b/force-app/main/default/classes/TriggerOrchestratorTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -508,6 +508,7 @@ private class TriggerOrchestratorTest { static void configurationQueriesWhenRunTwice() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('TriggerHandler__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); TriggerOrchestrator.mock().beforeInsertFor(TitlePopulator.class).with(new Contact(LastName = 'Doe')); @@ -518,7 +519,7 @@ private class TriggerOrchestratorTest { TriggerOrchestrator.runTestFor(new TitlePopulator()); // Verify - Assert.areEqual(2, SOQL.getQueries(), 'The framework should query its configuration once per transaction.'); + Assert.areEqual(3, SOQL.getQueries(), 'The framework should query its configuration once per transaction.'); } @IsTest @@ -951,29 +952,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadata() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -987,32 +973,41 @@ private class TriggerOrchestratorTest { Assert.isNull(doe.Title, 'The handler should not run.'); } + @IsTest + static void bypassHandlerByMetadataInOtherContext() { + // Setup + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulator', Bypass__c = true, AfterInsert__c = contactSetting.Id) }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulator()); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The handler should be bypassed only in the contexts its record names.'); + } + @IsTest static void bypassHandlerByMetadataWithOtherHandlers() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'DoeValidator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'DoeValidator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1033,29 +1028,18 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithQualifiedName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1072,29 +1056,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWhenObjectDoesNotMatch() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt accountSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Account', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt accountSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Account', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulator', Bypass__c = true, BeforeInsert__c = accountSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1111,29 +1080,18 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithBypassable() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TitlePopulatorWithBypassCounter', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulatorWithBypassCounter', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); TitlePopulatorWithBypassCounter populator = new TitlePopulatorWithBypassCounter(); @@ -1150,29 +1108,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithPaddedName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => ' TitlePopulator ', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = ' TitlePopulator ', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1189,29 +1132,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithUpperCaseName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TITLEPOPULATOR', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TITLEPOPULATOR', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1247,29 +1175,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnBeforeInsert() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1286,29 +1203,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterInsert() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1325,29 +1231,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnBeforeUpdate() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, BeforeUpdate__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1364,29 +1259,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterUpdate() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterUpdate__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1403,29 +1287,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnBeforeDelete() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, BeforeDelete__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1442,29 +1315,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterDelete() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterDelete__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1481,29 +1343,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterUndelete() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterUndelete__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1520,34 +1371,19 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataInQueryOrder() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 2, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - }, - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.CeoDepartmentPopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id), + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.CeoDepartmentPopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1564,29 +1400,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithPaddedName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => ' TriggerOrchestratorTest.TitlePopulator ', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = ' TriggerOrchestratorTest.TitlePopulator ', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1603,29 +1428,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithBypassedRow() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1642,29 +1456,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithBypassedUnknownClass() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.MissingHandler', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1687,29 +1490,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithHandlerOfOtherContext() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, AfterInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1726,35 +1518,56 @@ private class TriggerOrchestratorTest { } // Verify - Assert.isNull(expectedException, 'The run should not throw an exception.'); + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.TitlePopulator does not implement ' + AfterInsert.Handler.class.getName() + '.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); } @IsTest - static void runFromMetadataWithRowsOfOtherObject() { + static void runFromMetadataWithRowOfOtherContext() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt accountSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Account', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id) } - ), - TriggerObject__mdt.class + ); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe, new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.isNull(doe.Title, 'The handler should run only in the contexts its record names.'); + } + + @IsTest + static void runFromMetadataWithRowsOfOtherObject() { + // Setup + TriggerObject__mdt accountSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Account', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, BeforeInsert__c = accountSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1771,29 +1584,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithUnknownClass() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.MissingHandler', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1820,29 +1622,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithoutNoArgConstructor() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.NestedRunPopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.NestedRunPopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1869,29 +1660,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithClassThatIsNotHandler() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TestLogger', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TestLogger', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1909,7 +1689,7 @@ private class TriggerOrchestratorTest { // Verify Assert.areEqual( - 'Handler class TriggerOrchestratorTest.TestLogger does not implement a handler interface.', + 'Handler class TriggerOrchestratorTest.TestLogger does not implement ' + BeforeInsert.Handler.class.getName() + '.', expectedException?.getMessage(), 'The exception message should match the expected one.' ); diff --git a/force-app/main/default/classes/TriggerTypes.cls b/force-app/main/default/classes/TriggerTypes.cls index 4bd03ee..79edb58 100644 --- a/force-app/main/default/classes/TriggerTypes.cls +++ b/force-app/main/default/classes/TriggerTypes.cls @@ -2,7 +2,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index caa75f4..794eb08 100644 --- a/force-app/main/default/classes/TriggerTypesTest.cls +++ b/force-app/main/default/classes/TriggerTypesTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/layouts/TriggerHandler__mdt-Trigger Handler Layout.layout-meta.xml b/force-app/main/default/layouts/TriggerHandler__mdt-Trigger Handler Layout.layout-meta.xml new file mode 100644 index 0000000..ee5e8a1 --- /dev/null +++ b/force-app/main/default/layouts/TriggerHandler__mdt-Trigger Handler Layout.layout-meta.xml @@ -0,0 +1,139 @@ + + + + false + false + true + + + + Required + MasterLabel + + + Required + DeveloperName + + + + + Edit + IsProtected + + + Required + NamespacePrefix + + + + + + true + true + true + + + + Required + ApexClassName__c + + + Required + Order__c + + + + + Edit + Bypass__c + + + + + + true + true + true + + + + Edit + BeforeInsert__c + + + Edit + BeforeUpdate__c + + + Edit + BeforeDelete__c + + + + + Edit + AfterInsert__c + + + Edit + AfterUpdate__c + + + Edit + AfterDelete__c + + + Edit + AfterUndelete__c + + + + + + true + true + true + + + + Edit + Description__c + + + + + + false + false + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + true + true + false + + + + + + + false + false + false + false + false + diff --git a/force-app/main/default/layouts/TriggerObject__mdt-Trigger Object Layout.layout-meta.xml b/force-app/main/default/layouts/TriggerObject__mdt-Trigger Object Layout.layout-meta.xml new file mode 100644 index 0000000..05bc342 --- /dev/null +++ b/force-app/main/default/layouts/TriggerObject__mdt-Trigger Object Layout.layout-meta.xml @@ -0,0 +1,83 @@ + + + + false + false + true + + + + Required + MasterLabel + + + Required + DeveloperName + + + + + Edit + IsProtected + + + Required + NamespacePrefix + + + + + + true + true + true + + + + Required + ObjectAPIName__c + + + + + Edit + Bypass__c + + + + + + false + false + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + true + true + false + + + + + + + false + false + false + false + false + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterDelete__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterDelete__c.field-meta.xml new file mode 100644 index 0000000..66c69bb --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterDelete__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterDelete__c + The trigger object this record applies to in after delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after delete. + SubscriberControlled + The trigger object this record applies to in after delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after delete. + + TriggerObject__mdt + After Delete Handlers + AfterDeleteHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterInsert__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterInsert__c.field-meta.xml new file mode 100644 index 0000000..f1ed04e --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterInsert__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterInsert__c + The trigger object this record applies to in after insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after insert. + SubscriberControlled + The trigger object this record applies to in after insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after insert. + + TriggerObject__mdt + After Insert Handlers + AfterInsertHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterUndelete__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterUndelete__c.field-meta.xml new file mode 100644 index 0000000..6a7ab38 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterUndelete__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterUndelete__c + The trigger object this record applies to in after undelete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after undelete. + SubscriberControlled + The trigger object this record applies to in after undelete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after undelete. + + TriggerObject__mdt + After Undelete Handlers + AfterUndeleteHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterUpdate__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterUpdate__c.field-meta.xml new file mode 100644 index 0000000..87c5b80 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/AfterUpdate__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterUpdate__c + The trigger object this record applies to in after update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after update. + SubscriberControlled + The trigger object this record applies to in after update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after update. + + TriggerObject__mdt + After Update Handlers + AfterUpdateHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeDelete__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeDelete__c.field-meta.xml new file mode 100644 index 0000000..58247b0 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeDelete__c.field-meta.xml @@ -0,0 +1,13 @@ + + + BeforeDelete__c + The trigger object this record applies to in before delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before delete. + SubscriberControlled + The trigger object this record applies to in before delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before delete. + + TriggerObject__mdt + Before Delete Handlers + BeforeDeleteHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeInsert__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeInsert__c.field-meta.xml new file mode 100644 index 0000000..39245b9 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeInsert__c.field-meta.xml @@ -0,0 +1,13 @@ + + + BeforeInsert__c + The trigger object this record applies to in before insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before insert. + SubscriberControlled + The trigger object this record applies to in before insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before insert. + + TriggerObject__mdt + Before Insert Handlers + BeforeInsertHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeUpdate__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeUpdate__c.field-meta.xml new file mode 100644 index 0000000..ee96f1a --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/BeforeUpdate__c.field-meta.xml @@ -0,0 +1,13 @@ + + + BeforeUpdate__c + The trigger object this record applies to in before update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before update. + SubscriberControlled + The trigger object this record applies to in before update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before update. + + TriggerObject__mdt + Before Update Handlers + BeforeUpdateHandlers + false + MetadataRelationship + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/Description__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/Description__c.field-meta.xml new file mode 100644 index 0000000..ead2388 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/Description__c.field-meta.xml @@ -0,0 +1,9 @@ + + + Description__c + SubscriberControlled + + 32768 + LongTextArea + 3 + diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml index 873aef1..840c9e4 100644 --- a/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml @@ -4,7 +4,7 @@ SubscriberControlled 18 - false + true 0 Number false diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/TriggerObject__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/TriggerObject__c.field-meta.xml deleted file mode 100644 index db0110f..0000000 --- a/force-app/main/default/objects/TriggerHandler__mdt/fields/TriggerObject__c.field-meta.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - TriggerObject__c - SubscriberControlled - - TriggerObject__mdt - Trigger Handlers - TriggerHandlers - true - MetadataRelationship - diff --git a/force-app/main/default/objects/TriggerHandler__mdt/validationRules/OneContextRequired.validationRule-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/validationRules/OneContextRequired.validationRule-meta.xml new file mode 100644 index 0000000..23e9f91 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/validationRules/OneContextRequired.validationRule-meta.xml @@ -0,0 +1,14 @@ + + + OneContextRequired + true + A handler record applies to one context on one object, so exactly one context field is filled. + IF(ISBLANK(BeforeInsert__c), 0, 1) + +IF(ISBLANK(AfterInsert__c), 0, 1) + +IF(ISBLANK(BeforeUpdate__c), 0, 1) + +IF(ISBLANK(AfterUpdate__c), 0, 1) + +IF(ISBLANK(BeforeDelete__c), 0, 1) + +IF(ISBLANK(AfterDelete__c), 0, 1) + +IF(ISBLANK(AfterUndelete__c), 0, 1) <> 1 + Select the trigger object in exactly one context. For a class that runs in several contexts, create one record per context. + diff --git a/package/main/default/classes/AfterDelete.cls b/package/main/default/classes/AfterDelete.cls index d64d90f..2df39a3 100644 --- a/package/main/default/classes/AfterDelete.cls +++ b/package/main/default/classes/AfterDelete.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class AfterDelete { // Roles - pick one diff --git a/package/main/default/classes/AfterDeleteTest.cls b/package/main/default/classes/AfterDeleteTest.cls index 2d3788a..3b2689b 100644 --- a/package/main/default/classes/AfterDeleteTest.cls +++ b/package/main/default/classes/AfterDeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/AfterInsert.cls b/package/main/default/classes/AfterInsert.cls index 15f4792..067911b 100644 --- a/package/main/default/classes/AfterInsert.cls +++ b/package/main/default/classes/AfterInsert.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class AfterInsert { // Roles - pick one diff --git a/package/main/default/classes/AfterInsertTest.cls b/package/main/default/classes/AfterInsertTest.cls index b18f065..8ef8692 100644 --- a/package/main/default/classes/AfterInsertTest.cls +++ b/package/main/default/classes/AfterInsertTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/AfterUndelete.cls b/package/main/default/classes/AfterUndelete.cls index 92b049c..bab22f8 100644 --- a/package/main/default/classes/AfterUndelete.cls +++ b/package/main/default/classes/AfterUndelete.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class AfterUndelete { // Roles - pick one diff --git a/package/main/default/classes/AfterUndeleteTest.cls b/package/main/default/classes/AfterUndeleteTest.cls index b25de31..47dd41c 100644 --- a/package/main/default/classes/AfterUndeleteTest.cls +++ b/package/main/default/classes/AfterUndeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/AfterUpdate.cls b/package/main/default/classes/AfterUpdate.cls index 6bf59f7..910d9b2 100644 --- a/package/main/default/classes/AfterUpdate.cls +++ b/package/main/default/classes/AfterUpdate.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class AfterUpdate { // Roles - pick one @@ -97,6 +103,19 @@ global class AfterUpdate { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); for (TriggerTypes.TriggerRecord record : records) { + if (this.recursionTracker.isDepthReached(record)) { + this.logError( + new TriggerTypes.TriggerLibException( + this.handlerName + + ' reached the recursion depth of ' + + this.recursionTracker.maxRunsPerRecord + + ' on record ' + + record.getId() + + ' in after update.' + ) + ); + } + this.write(record); } @@ -125,7 +144,7 @@ global class AfterUpdate { } private void write(TriggerTypes.TriggerRecord record) { - if (!this.recursionTracker.isLimitReached(record) && this.writer.writeOnAfterUpdateWhen(record)) { + if (!this.recursionTracker.isSkippedByRecursionGuard(record) && this.writer.writeOnAfterUpdateWhen(record)) { this.recursionTracker.countRun(record); this.qualifiedRecords.add(record); this.writer.writeOnAfterUpdate(record, this.unitOfWork); @@ -164,6 +183,19 @@ global class AfterUpdate { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); for (TriggerTypes.TriggerRecord record : records) { + if (this.recursionTracker.isDepthReached(record)) { + this.logError( + new TriggerTypes.TriggerLibException( + this.handlerName + + ' reached the recursion depth of ' + + this.recursionTracker.maxRunsPerRecord + + ' on record ' + + record.getId() + + ' in after update.' + ) + ); + } + this.qualify(record); } @@ -189,7 +221,7 @@ global class AfterUpdate { } private void qualify(TriggerTypes.TriggerRecord record) { - if (!this.recursionTracker.isLimitReached(record) && this.dispatcher.dispatchOnAfterUpdateWhen(record)) { + if (!this.recursionTracker.isSkippedByRecursionGuard(record) && this.dispatcher.dispatchOnAfterUpdateWhen(record)) { this.recursionTracker.countRun(record); this.qualifiedRecords.add(record); } @@ -307,13 +339,21 @@ global class AfterUpdate { } public class RecursionTracker extends TriggerOrchestrator.RecursionTracker { + private AfterUpdate.Handler handler; + public RecursionTracker(AfterUpdate.Handler handler, String handlerName) { super(handlerName + ':AFTER_UPDATE'); + this.handler = handler; + if (handler instanceof AfterUpdate.RecursionGuard) { this.maxRunsPerRecord = ((AfterUpdate.RecursionGuard) handler).maxRunsPerRecordOnAfterUpdate(); } } + + public override Boolean hasRecursionGuard() { + return this.handler instanceof AfterUpdate.RecursionGuard; + } } public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.AfterUpdateMockable, TriggerOrchestrator.AfterUpdate { diff --git a/package/main/default/classes/AfterUpdateTest.cls b/package/main/default/classes/AfterUpdateTest.cls index 2a8f80c..6e3102b 100644 --- a/package/main/default/classes/AfterUpdateTest.cls +++ b/package/main/default/classes/AfterUpdateTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -398,7 +398,121 @@ private class AfterUpdateTest { TriggerOrchestrator.runTestFor(new AccountWriter()); // Verify - Assert.areEqual(3, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should not run beyond the recursion depth.'); + Assert.areEqual(4, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should run beyond the default recursion depth.'); + } + + @IsTest + static void writerWithLoggerWhenDefaultRecursionDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual( + 'AccountWriter reached the recursion depth of 3 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + + @IsTest + static void writerWithLoggerWithinDefaultRecursionDepth() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 2; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.isTrue(logger.errors.isEmpty(), 'Nothing should be logged within the recursion depth.'); + } + + @IsTest + static void writerWithLoggerWhenRecordAtDefaultDepthDoesNotQualify() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(new Contact(Id = doe.Id, LastName = 'Doe'), new Contact(Id = doe.Id, LastName = 'Doe')); + } + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriter.class).with(new Contact(Id = doe.Id, LastName = 'Roe'), new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual(1, logger.errors.size(), 'The record should be logged when it comes back at the recursion depth.'); + } + + @IsTest + static void writerWithLoggerWhenDefaultRecursionDepthIsReachedForTwoRecords() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + Contact janeDoe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), FirstName = 'Jane', LastName = 'Doe'); + + TriggerOrchestrator.mock() + .afterUpdateFor(AccountWriter.class) + .with( + new List{ doe, janeDoe }, + new List{ new Contact(Id = doe.Id, LastName = 'Doe'), new Contact(Id = janeDoe.Id, FirstName = 'Jane', LastName = 'Doe') } + ); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriter()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual(2, logger.errors.size(), 'Each record at the recursion depth should be logged.'); } @IsTest @@ -421,7 +535,37 @@ private class AfterUpdateTest { TriggerOrchestrator.runTestFor(dispatcher); // Verify - Assert.areEqual(3, dispatcher.dispatchedIds.size(), 'The dispatcher should not run beyond the recursion depth.'); + Assert.areEqual(4, dispatcher.dispatchedIds.size(), 'The dispatcher should run beyond the default recursion depth.'); + } + + @IsTest + static void dispatcherWithLoggerWhenDefaultRecursionDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + IdDispatcher dispatcher = new IdDispatcher(); + + TriggerOrchestrator.mock().afterUpdateFor(IdDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(dispatcher); + } + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual( + 'IdDispatcher reached the recursion depth of 3 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); } @IsTest @@ -444,6 +588,120 @@ private class AfterUpdateTest { Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should not run beyond its recursion depth.'); } + @IsTest + static void writerWithLoggerWhenRecursionGuardDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuard()); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuard()); + + // Verify + Assert.areEqual( + 'AccountWriterWithRecursionGuard reached the recursion depth of 1 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + + @IsTest + static void writerWithRecursionGuardOfTwo() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfTwo.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 2; run++) { + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfTwo()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfTwo()); + + // Verify + Assert.areEqual(2, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should not run beyond its recursion depth.'); + } + + @IsTest + static void writerWithRecursionGuardOfNull() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfNull.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + + // Verify + Assert.areEqual(4, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should run without a recursion depth.'); + } + + @IsTest + static void writerWithLoggerAndRecursionGuardOfNull() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfNull.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + } + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfNull()); + + // Verify + Assert.isTrue(logger.errors.isEmpty(), 'Nothing should be logged without a recursion depth.'); + } + + @IsTest + static void writerWithRecursionGuardOfZero() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithRecursionGuardOfZero.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithRecursionGuardOfZero()); + + // Verify + Assert.areEqual(0, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should skip every record.'); + } + @IsTest static void dispatcherWithRecursionGuard() { // Setup @@ -465,6 +723,34 @@ private class AfterUpdateTest { Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should not run beyond its recursion depth.'); } + @IsTest + static void dispatcherWithLoggerWhenRecursionGuardDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + IdDispatcherWithRecursionGuard dispatcher = new IdDispatcherWithRecursionGuard(); + + TriggerOrchestrator.mock().afterUpdateFor(IdDispatcherWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + TriggerOrchestrator.runTestFor(dispatcher); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual( + 'IdDispatcherWithRecursionGuard reached the recursion depth of 1 on record ' + doe.Id + ' in after update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + @IsTest static void writerQualificationWhenRecursionDepthIsExceeded() { // Setup @@ -474,19 +760,17 @@ private class AfterUpdateTest { Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - QualificationCountingWriter writer = new QualificationCountingWriter(); + QualificationCountingWriterWithRecursionGuard writer = new QualificationCountingWriterWithRecursionGuard(); - TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingWriterWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(writer); - } + TriggerOrchestrator.runTestFor(writer); // Test TriggerOrchestrator.runTestFor(writer); // Verify - Assert.areEqual(3, writer.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); + Assert.areEqual(1, writer.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); } @IsTest @@ -497,19 +781,17 @@ private class AfterUpdateTest { Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - QualificationCountingDispatcher dispatcher = new QualificationCountingDispatcher(); + QualificationCountingDispatcherWithRecursionGuard dispatcher = new QualificationCountingDispatcherWithRecursionGuard(); - TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + TriggerOrchestrator.mock().afterUpdateFor(QualificationCountingDispatcherWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(dispatcher); - } + TriggerOrchestrator.runTestFor(dispatcher); // Test TriggerOrchestrator.runTestFor(dispatcher); // Verify - Assert.areEqual(3, dispatcher.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); + Assert.areEqual(1, dispatcher.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); } @IsTest @@ -568,19 +850,17 @@ private class AfterUpdateTest { Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - FinalizeCountingWriter writer = new FinalizeCountingWriter(); + FinalizeCountingWriterWithRecursionGuard writer = new FinalizeCountingWriterWithRecursionGuard(); - TriggerOrchestrator.mock().afterUpdateFor(FinalizeCountingWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + TriggerOrchestrator.mock().afterUpdateFor(FinalizeCountingWriterWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(writer); - } + TriggerOrchestrator.runTestFor(writer); // Test TriggerOrchestrator.runTestFor(writer); // Verify - Assert.areEqual(3, writer.finalizeCalls, 'The finalizer should not run beyond the recursion depth.'); + Assert.areEqual(1, writer.finalizeCalls, 'The finalizer should not run beyond the recursion depth.'); } @IsTest @@ -1061,13 +1341,31 @@ private class AfterUpdateTest { } } + private class AccountWriterWithRecursionGuardOfTwo extends AccountWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 2; + } + } + + private class AccountWriterWithRecursionGuardOfNull extends AccountWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return null; + } + } + + private class AccountWriterWithRecursionGuardOfZero extends AccountWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 0; + } + } + private class IdDispatcherWithRecursionGuard extends IdDispatcher implements AfterUpdate.RecursionGuard { public Integer maxRunsPerRecordOnAfterUpdate() { return 1; } } - private class QualificationCountingWriter implements AfterUpdate.Writer { + private virtual class QualificationCountingWriter implements AfterUpdate.Writer { public Integer qualificationCalls = 0; public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -1080,7 +1378,13 @@ private class AfterUpdateTest { } } - private class QualificationCountingDispatcher implements AfterUpdate.Dispatcher { + private class QualificationCountingWriterWithRecursionGuard extends QualificationCountingWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 1; + } + } + + private virtual class QualificationCountingDispatcher implements AfterUpdate.Dispatcher { public Integer qualificationCalls = 0; public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -1092,7 +1396,13 @@ private class AfterUpdateTest { } } - private class FinalizeCountingWriter extends AccountWriter implements AfterUpdate.Finalizer { + private class QualificationCountingDispatcherWithRecursionGuard extends QualificationCountingDispatcher implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 1; + } + } + + private virtual class FinalizeCountingWriter extends AccountWriter implements AfterUpdate.Finalizer { public Integer finalizeCalls = 0; public void finalizeOnAfterUpdate(TriggerTypes.UpdateRecords records) { @@ -1100,6 +1410,12 @@ private class AfterUpdateTest { } } + private class FinalizeCountingWriterWithRecursionGuard extends FinalizeCountingWriter implements AfterUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnAfterUpdate() { + return 1; + } + } + private class DmlAccountWriter implements AfterUpdate.Writer { public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { return record.equals(Contact.LastName, 'Doe'); diff --git a/package/main/default/classes/BeforeDelete.cls b/package/main/default/classes/BeforeDelete.cls index 43708c4..39e6e3f 100644 --- a/package/main/default/classes/BeforeDelete.cls +++ b/package/main/default/classes/BeforeDelete.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class BeforeDelete { // Roles - pick one diff --git a/package/main/default/classes/BeforeDeleteTest.cls b/package/main/default/classes/BeforeDeleteTest.cls index a086526..cb3493d 100644 --- a/package/main/default/classes/BeforeDeleteTest.cls +++ b/package/main/default/classes/BeforeDeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/BeforeInsert.cls b/package/main/default/classes/BeforeInsert.cls index 7ed4b21..06b989b 100644 --- a/package/main/default/classes/BeforeInsert.cls +++ b/package/main/default/classes/BeforeInsert.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class BeforeInsert { // Roles - pick one diff --git a/package/main/default/classes/BeforeInsertTest.cls b/package/main/default/classes/BeforeInsertTest.cls index 9763789..c5a1a44 100644 --- a/package/main/default/classes/BeforeInsertTest.cls +++ b/package/main/default/classes/BeforeInsertTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/BeforeUpdate.cls b/package/main/default/classes/BeforeUpdate.cls index bdbfebe..a4f401d 100644 --- a/package/main/default/classes/BeforeUpdate.cls +++ b/package/main/default/classes/BeforeUpdate.cls @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) + * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) + * + * v1.1.0 + **/ global class BeforeUpdate { // Roles - pick one @@ -92,6 +98,19 @@ global class BeforeUpdate { Integer queriesBefore = this.getUndeclaredQueries(); for (TriggerTypes.TriggerRecord record : records) { + if (this.recursionTracker.isDepthReached(record)) { + this.logError( + new TriggerTypes.TriggerLibException( + this.handlerName + + ' reached the recursion depth of ' + + this.recursionTracker.maxRunsPerRecord + + ' on record ' + + record.getId() + + ' in before update.' + ) + ); + } + this.populate(record); } @@ -126,7 +145,7 @@ global class BeforeUpdate { } private void populate(TriggerTypes.TriggerRecord record) { - if (!this.recursionTracker.isLimitReached(record) && this.populator.populateOnBeforeUpdateWhen(record)) { + if (!this.recursionTracker.isSkippedByRecursionGuard(record) && this.populator.populateOnBeforeUpdateWhen(record)) { this.recursionTracker.countRun(record); this.qualifiedRecords.add(record); this.populator.populateOnBeforeUpdate(record); @@ -287,13 +306,21 @@ global class BeforeUpdate { } public class RecursionTracker extends TriggerOrchestrator.RecursionTracker { + private BeforeUpdate.Handler handler; + public RecursionTracker(BeforeUpdate.Handler handler, String handlerName) { super(handlerName + ':BEFORE_UPDATE'); + this.handler = handler; + if (handler instanceof BeforeUpdate.RecursionGuard) { this.maxRunsPerRecord = ((BeforeUpdate.RecursionGuard) handler).maxRunsPerRecordOnBeforeUpdate(); } } + + public override Boolean hasRecursionGuard() { + return this.handler instanceof BeforeUpdate.RecursionGuard; + } } public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.BeforeUpdateMockable, TriggerOrchestrator.BeforeUpdate { diff --git a/package/main/default/classes/BeforeUpdateTest.cls b/package/main/default/classes/BeforeUpdateTest.cls index c689bd3..338cb5a 100644 --- a/package/main/default/classes/BeforeUpdateTest.cls +++ b/package/main/default/classes/BeforeUpdateTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -525,7 +525,35 @@ private class BeforeUpdateTest { TriggerOrchestrator.runTestFor(new TitlePopulator()); // Verify - Assert.isNull(doe.Title, 'The populator should not run beyond the recursion depth.'); + Assert.areEqual('CEO', doe.Title, 'The populator should run beyond the default recursion depth.'); + } + + @IsTest + static void populatorWithLoggerWhenDefaultRecursionDepthIsReached() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(TitlePopulator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + for (Integer run = 0; run < 3; run++) { + TriggerOrchestrator.runTestFor(new TitlePopulator()); + } + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulator()); + + // Verify + Assert.areEqual( + 'TitlePopulator reached the recursion depth of 3 on record ' + doe.Id + ' in before update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); } @IsTest @@ -551,32 +579,50 @@ private class BeforeUpdateTest { } @IsTest - static void populatorQualificationWhenRecursionDepthIsExceeded() { + static void populatorWithLoggerWhenRecursionGuardDepthIsReached() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); - QualificationCountingPopulator populator = new QualificationCountingPopulator(); + TriggerOrchestrator.mock().beforeUpdateFor(TitlePopulatorWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.mock() - .beforeUpdateFor(QualificationCountingPopulator.class) - .with(new Contact(Id = doe.Id, LastName = 'Doe'), new Contact(Id = doe.Id, LastName = 'Doe')); - } + TriggerOrchestrator.runTestFor(new TitlePopulatorWithRecursionGuard()); - TriggerOrchestrator.mock().beforeUpdateFor(QualificationCountingPopulator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithRecursionGuard()); - for (Integer run = 0; run < 3; run++) { - TriggerOrchestrator.runTestFor(populator); - } + // Verify + Assert.areEqual( + 'TitlePopulatorWithRecursionGuard reached the recursion depth of 1 on record ' + doe.Id + ' in before update.', + logger.errors[0].getException().getMessage(), + 'The logged exception message should match the expected one.' + ); + } + + @IsTest + static void populatorQualificationWhenRecursionDepthIsExceeded() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + QualificationCountingPopulatorWithRecursionGuard populator = new QualificationCountingPopulatorWithRecursionGuard(); + + TriggerOrchestrator.mock().beforeUpdateFor(QualificationCountingPopulatorWithRecursionGuard.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + TriggerOrchestrator.runTestFor(populator); // Test TriggerOrchestrator.runTestFor(populator); // Verify - Assert.areEqual(3, populator.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); + Assert.areEqual(1, populator.qualificationCalls, 'The qualification should not be asked beyond the recursion depth.'); } @IsTest @@ -602,11 +648,14 @@ private class BeforeUpdateTest { } @IsTest - static void validatorWhenRecursionDepthIsExceeded() { + static void validatorWithLoggerWhenDefaultRecursionDepthIsReached() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); for (Integer run = 0; run < 3; run++) { @@ -623,7 +672,7 @@ private class BeforeUpdateTest { TriggerOrchestrator.runTestFor(new DoeValidator()); // Verify - Assert.areEqual('Doe is not allowed.', doe.getErrors()[0].getMessage(), 'The validator should not be limited by the recursion depth.'); + Assert.isTrue(logger.errors.isEmpty(), 'A validator should not log a recursion error.'); } @IsTest @@ -1100,7 +1149,7 @@ private class BeforeUpdateTest { } } - private class QualificationCountingPopulator implements BeforeUpdate.Populator { + private virtual class QualificationCountingPopulator implements BeforeUpdate.Populator { public Integer qualificationCalls = 0; public Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -1113,6 +1162,12 @@ private class BeforeUpdateTest { } } + private class QualificationCountingPopulatorWithRecursionGuard extends QualificationCountingPopulator implements BeforeUpdate.RecursionGuard { + public Integer maxRunsPerRecordOnBeforeUpdate() { + return 1; + } + } + private virtual class PriorAccountNamePopulator implements BeforeUpdate.Populator, BeforeUpdate.PriorParentQuery { public Map queryPriorParentsOnBeforeUpdate() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/package/main/default/classes/TriggerOrchestrator.cls b/package/main/default/classes/TriggerOrchestrator.cls index 54bc215..7926ac4 100644 --- a/package/main/default/classes/TriggerOrchestrator.cls +++ b/package/main/default/classes/TriggerOrchestrator.cls @@ -2,7 +2,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -298,32 +298,48 @@ global with sharing class TriggerOrchestrator { } public Boolean isHandlerBypassed(TriggerContext context, String handlerName) { - return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.handlerRowByName.get(handlerName.toLowerCase())?.Bypass__c == true; + return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.getHandlerRow(context.triggerOperation, handlerName)?.Bypass__c == true; } - public List getHandlerRows(SObjectType sObjectType) { - return this.metadataByObjectName.get(sObjectType.toString().toLowerCase())?.objectRow.TriggerHandlers__r ?? new List(); + public List getHandlerRows(SObjectType sObjectType, System.TriggerOperation triggerOperation) { + return this.metadataByObjectName.get(sObjectType.toString().toLowerCase())?.getHandlerRows(triggerOperation) ?? new List(); } private Map resolveObjectMetadata() { + Map metadataByObjectId = new Map(); Map metadataByObjectName = new Map(); - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); for ( TriggerObject__mdt triggerObject : (List) SOQL.of(TriggerObject__mdt.SObjectType) .with(TriggerObject__mdt.ObjectAPIName__c, TriggerObject__mdt.Bypass__c) - .with( - SOQL.SubQuery.of(namespacePrefix + 'TriggerHandlers__r') - .with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c, TriggerHandler__mdt.Order__c) - .orderBy(TriggerHandler__mdt.Order__c) - .nullsLast() - .orderBy(TriggerHandler__mdt.ApexClassName__c) - ) .systemMode() .mockId('TriggerObject__mdt') .toList() ) { - metadataByObjectName.put(triggerObject.ObjectAPIName__c.trim().toLowerCase(), new ObjectMetadata(triggerObject)); + ObjectMetadata objectMetadata = new ObjectMetadata(triggerObject); + + metadataByObjectId.put(triggerObject.Id, objectMetadata); + metadataByObjectName.put(triggerObject.ObjectAPIName__c.trim().toLowerCase(), objectMetadata); + } + + for ( + TriggerHandler__mdt handlerRow : (List) SOQL.of(TriggerHandler__mdt.SObjectType) + .with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c, TriggerHandler__mdt.Order__c) + .with(TriggerHandler__mdt.BeforeInsert__c, TriggerHandler__mdt.AfterInsert__c, TriggerHandler__mdt.BeforeUpdate__c, TriggerHandler__mdt.AfterUpdate__c) + .with(TriggerHandler__mdt.BeforeDelete__c, TriggerHandler__mdt.AfterDelete__c, TriggerHandler__mdt.AfterUndelete__c) + .orderBy(TriggerHandler__mdt.Order__c) + .orderBy(TriggerHandler__mdt.ApexClassName__c) + .systemMode() + .mockId('TriggerHandler__mdt') + .toList() + ) { + metadataByObjectId.get(handlerRow.BeforeInsert__c)?.addHandlerRow(System.TriggerOperation.BEFORE_INSERT, handlerRow); + metadataByObjectId.get(handlerRow.AfterInsert__c)?.addHandlerRow(System.TriggerOperation.AFTER_INSERT, handlerRow); + metadataByObjectId.get(handlerRow.BeforeUpdate__c)?.addHandlerRow(System.TriggerOperation.BEFORE_UPDATE, handlerRow); + metadataByObjectId.get(handlerRow.AfterUpdate__c)?.addHandlerRow(System.TriggerOperation.AFTER_UPDATE, handlerRow); + metadataByObjectId.get(handlerRow.BeforeDelete__c)?.addHandlerRow(System.TriggerOperation.BEFORE_DELETE, handlerRow); + metadataByObjectId.get(handlerRow.AfterDelete__c)?.addHandlerRow(System.TriggerOperation.AFTER_DELETE, handlerRow); + metadataByObjectId.get(handlerRow.AfterUndelete__c)?.addHandlerRow(System.TriggerOperation.AFTER_UNDELETE, handlerRow); } return metadataByObjectName; @@ -332,27 +348,35 @@ global with sharing class TriggerOrchestrator { @TestVisible private class ObjectMetadata { - public TriggerObject__mdt objectRow = new TriggerObject__mdt(); - public Map handlerRowByName = new Map(); + public TriggerObject__mdt objectRow; + private Map> handlerRowsByOperation = new Map>(); + private Map> handlerRowByNameByOperation = new Map>(); - public ObjectMetadata(TriggerObject__mdt triggerObject) { - this.objectRow = triggerObject; - this.addHandlerRows(); + public ObjectMetadata(TriggerObject__mdt objectRow) { + this.objectRow = objectRow; } - private void addHandlerRows() { - for (TriggerHandler__mdt triggerHandler : this.objectRow.TriggerHandlers__r ?? new List()) { - this.addHandlerRow(triggerHandler); - } + public List getHandlerRows(System.TriggerOperation triggerOperation) { + return this.handlerRowsByOperation.get(triggerOperation) ?? new List(); + } + + public TriggerHandler__mdt getHandlerRow(System.TriggerOperation triggerOperation, String handlerName) { + return this.handlerRowByNameByOperation.get(triggerOperation)?.get(handlerName.toLowerCase()); } - private void addHandlerRow(TriggerHandler__mdt triggerHandler) { - String handlerName = triggerHandler.ApexClassName__c.trim().toLowerCase(); + public void addHandlerRow(System.TriggerOperation triggerOperation, TriggerHandler__mdt handlerRow) { + String handlerName = handlerRow.ApexClassName__c.trim().toLowerCase(); - this.handlerRowByName.put(handlerName, triggerHandler); + if (!this.handlerRowsByOperation.containsKey(triggerOperation)) { + this.handlerRowsByOperation.put(triggerOperation, new List()); + this.handlerRowByNameByOperation.put(triggerOperation, new Map()); + } + + this.handlerRowsByOperation.get(triggerOperation).add(handlerRow); + this.handlerRowByNameByOperation.get(triggerOperation).put(handlerName, handlerRow); if (handlerName.contains('.')) { - this.handlerRowByName.put(handlerName.substringAfterLast('.'), triggerHandler); + this.handlerRowByNameByOperation.get(triggerOperation).put(handlerName.substringAfterLast('.'), handlerRow); } } } @@ -368,10 +392,8 @@ global with sharing class TriggerOrchestrator { public List beforeInsertHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof BeforeInsert.Handler) { - handlers.add((BeforeInsert.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.BEFORE_INSERT, BeforeInsert.Handler.class)) { + handlers.add((BeforeInsert.Handler) handler); } return handlers; @@ -380,10 +402,8 @@ global with sharing class TriggerOrchestrator { public List afterInsertHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterInsert.Handler) { - handlers.add((AfterInsert.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_INSERT, AfterInsert.Handler.class)) { + handlers.add((AfterInsert.Handler) handler); } return handlers; @@ -392,10 +412,8 @@ global with sharing class TriggerOrchestrator { public List beforeUpdateHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof BeforeUpdate.Handler) { - handlers.add((BeforeUpdate.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.BEFORE_UPDATE, BeforeUpdate.Handler.class)) { + handlers.add((BeforeUpdate.Handler) handler); } return handlers; @@ -404,10 +422,8 @@ global with sharing class TriggerOrchestrator { public List afterUpdateHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterUpdate.Handler) { - handlers.add((AfterUpdate.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_UPDATE, AfterUpdate.Handler.class)) { + handlers.add((AfterUpdate.Handler) handler); } return handlers; @@ -416,10 +432,8 @@ global with sharing class TriggerOrchestrator { public List beforeDeleteHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof BeforeDelete.Handler) { - handlers.add((BeforeDelete.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.BEFORE_DELETE, BeforeDelete.Handler.class)) { + handlers.add((BeforeDelete.Handler) handler); } return handlers; @@ -428,10 +442,8 @@ global with sharing class TriggerOrchestrator { public List afterDeleteHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterDelete.Handler) { - handlers.add((AfterDelete.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_DELETE, AfterDelete.Handler.class)) { + handlers.add((AfterDelete.Handler) handler); } return handlers; @@ -440,55 +452,41 @@ global with sharing class TriggerOrchestrator { public List afterUndeleteHandlers() { List handlers = new List(); - for (Object handler : this.newHandlers()) { - if (handler instanceof AfterUndelete.Handler) { - handlers.add((AfterUndelete.Handler) handler); - } + for (Object handler : this.newHandlers(System.TriggerOperation.AFTER_UNDELETE, AfterUndelete.Handler.class)) { + handlers.add((AfterUndelete.Handler) handler); } return handlers; } - private List newHandlers() { + private List newHandlers(System.TriggerOperation triggerOperation, System.Type handlerInterface) { List handlers = new List(); - for (TriggerHandler__mdt handlerRow : TriggerOrchestrator.metadataBypass.getHandlerRows(this.sObjectType)) { + for (TriggerHandler__mdt handlerRow : TriggerOrchestrator.metadataBypass.getHandlerRows(this.sObjectType, triggerOperation)) { if (handlerRow.Bypass__c != true) { - handlers.add(this.newHandler(handlerRow.ApexClassName__c.trim())); + handlers.add(this.newHandler(handlerRow.ApexClassName__c.trim(), handlerInterface)); } } return handlers; } - private Object newHandler(String className) { + private Object newHandler(String className, System.Type handlerInterface) { System.Type handlerType = Type.forName(className) ?? Type.forName(null, className); if (handlerType == null) { throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' was not found.'); } - Object handler; + if (!handlerInterface.isAssignableFrom(handlerType)) { + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' does not implement ' + handlerInterface.getName() + '.'); + } try { - handler = handlerType.newInstance(); + return handlerType.newInstance(); } catch (Exception e) { throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' could not be instantiated.', e); } - - if ( - handler instanceof BeforeInsert.Handler || - handler instanceof AfterInsert.Handler || - handler instanceof BeforeUpdate.Handler || - handler instanceof AfterUpdate.Handler || - handler instanceof BeforeDelete.Handler || - handler instanceof AfterDelete.Handler || - handler instanceof AfterUndelete.Handler - ) { - return handler; - } - - throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' does not implement a handler interface.'); } } @@ -1013,21 +1011,27 @@ global with sharing class TriggerOrchestrator { } public abstract class RecursionTracker { + public Integer maxRunsPerRecord = 3; private Map runCountByRecordId; - protected Integer maxRunsPerRecord = 3; public RecursionTracker(String recursionScope) { TriggerOrchestrator.runCounter.registerScope(recursionScope); this.runCountByRecordId = TriggerOrchestrator.runCounter.getForRecursionScope(recursionScope); } - public Boolean isLimitReached(TriggerTypes.TriggerRecord record) { + public Boolean isDepthReached(TriggerTypes.TriggerRecord record) { return (this.runCountByRecordId.get(record.getId()) ?? 0) >= this.maxRunsPerRecord; } + public Boolean isSkippedByRecursionGuard(TriggerTypes.TriggerRecord record) { + return this.hasRecursionGuard() && this.isDepthReached(record); + } + public void countRun(TriggerTypes.TriggerRecord record) { this.runCountByRecordId.put(record.getId(), (this.runCountByRecordId.get(record.getId()) ?? 0) + 1); } + + public abstract Boolean hasRecursionGuard(); } private class RunCounter { diff --git a/package/main/default/classes/TriggerOrchestratorTest.cls b/package/main/default/classes/TriggerOrchestratorTest.cls index 9c16ea6..13e1912 100644 --- a/package/main/default/classes/TriggerOrchestratorTest.cls +++ b/package/main/default/classes/TriggerOrchestratorTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -508,6 +508,7 @@ private class TriggerOrchestratorTest { static void configurationQueriesWhenRunTwice() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('TriggerHandler__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); TriggerOrchestrator.mock().beforeInsertFor(TitlePopulator.class).with(new Contact(LastName = 'Doe')); @@ -518,7 +519,7 @@ private class TriggerOrchestratorTest { TriggerOrchestrator.runTestFor(new TitlePopulator()); // Verify - Assert.areEqual(2, SOQL.getQueries(), 'The framework should query its configuration once per transaction.'); + Assert.areEqual(3, SOQL.getQueries(), 'The framework should query its configuration once per transaction.'); } @IsTest @@ -951,29 +952,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadata() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -987,32 +973,41 @@ private class TriggerOrchestratorTest { Assert.isNull(doe.Title, 'The handler should not run.'); } + @IsTest + static void bypassHandlerByMetadataInOtherContext() { + // Setup + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulator', Bypass__c = true, AfterInsert__c = contactSetting.Id) }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulator()); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The handler should be bypassed only in the contexts its record names.'); + } + @IsTest static void bypassHandlerByMetadataWithOtherHandlers() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'DoeValidator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'DoeValidator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1033,29 +1028,18 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithQualifiedName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1072,29 +1056,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWhenObjectDoesNotMatch() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt accountSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Account', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt accountSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Account', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulator', Bypass__c = true, BeforeInsert__c = accountSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1111,29 +1080,18 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithBypassable() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TitlePopulatorWithBypassCounter', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TitlePopulatorWithBypassCounter', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); TitlePopulatorWithBypassCounter populator = new TitlePopulatorWithBypassCounter(); @@ -1150,29 +1108,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithPaddedName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => ' TitlePopulator ', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = ' TitlePopulator ', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1189,29 +1132,14 @@ private class TriggerOrchestratorTest { @IsTest static void bypassHandlerByMetadataWithUpperCaseName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TITLEPOPULATOR', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn(new List{ new TriggerHandler__mdt(ApexClassName__c = 'TITLEPOPULATOR', Bypass__c = true, BeforeInsert__c = contactSetting.Id) }); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1247,29 +1175,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnBeforeInsert() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1286,29 +1203,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterInsert() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1325,29 +1231,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnBeforeUpdate() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, BeforeUpdate__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1364,29 +1259,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterUpdate() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterUpdate__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1403,29 +1287,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnBeforeDelete() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, BeforeDelete__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1442,29 +1315,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterDelete() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterDelete__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1481,29 +1343,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataOnAfterUndelete() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.OperationStamper', Bypass__c = false, AfterUndelete__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1520,34 +1371,19 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataInQueryOrder() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 2, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - }, - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.CeoDepartmentPopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id), + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.CeoDepartmentPopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1564,29 +1400,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithPaddedName() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => ' TriggerOrchestratorTest.TitlePopulator ', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = ' TriggerOrchestratorTest.TitlePopulator ', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1603,29 +1428,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithBypassedRow() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1642,29 +1456,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithBypassedUnknownClass() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', - String.valueOf(TriggerHandler__mdt.Bypass__c) => true - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.MissingHandler', Bypass__c = true, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1687,29 +1490,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithHandlerOfOtherContext() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, AfterInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1726,35 +1518,56 @@ private class TriggerOrchestratorTest { } // Verify - Assert.isNull(expectedException, 'The run should not throw an exception.'); + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.TitlePopulator does not implement ' + AfterInsert.Handler.class.getName() + '.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); } @IsTest - static void runFromMetadataWithRowsOfOtherObject() { + static void runFromMetadataWithRowOfOtherContext() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt accountSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Account', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id) } - ), - TriggerObject__mdt.class + ); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe, new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.isNull(doe.Title, 'The handler should run only in the contexts its record names.'); + } + + @IsTest + static void runFromMetadataWithRowsOfOtherObject() { + // Setup + TriggerObject__mdt accountSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Account', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TitlePopulator', Bypass__c = false, BeforeInsert__c = accountSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1771,29 +1584,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithUnknownClass() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.MissingHandler', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1820,29 +1622,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithoutNoArgConstructor() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.NestedRunPopulator', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.NestedRunPopulator', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1869,29 +1660,18 @@ private class TriggerOrchestratorTest { @IsTest static void runFromMetadataWithClassThatIsNotHandler() { // Setup - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( - JSON.serialize( - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, - String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', - String.valueOf(TriggerObject__mdt.Bypass__c) => false, - namespacePrefix + 'TriggerHandlers__r' => new Map{ - 'totalSize' => 1, - 'done' => true, - 'records' => new List{ - new Map{ - 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, - String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TestLogger', - String.valueOf(TriggerHandler__mdt.Bypass__c) => false - } - } - } - } - ), - TriggerObject__mdt.class + TriggerObject__mdt contactSetting = new TriggerObject__mdt( + Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), + ObjectAPIName__c = 'Contact', + Bypass__c = false ); SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('TriggerHandler__mdt') + .thenReturn( + new List{ + new TriggerHandler__mdt(ApexClassName__c = 'TriggerOrchestratorTest.TestLogger', Bypass__c = false, BeforeInsert__c = contactSetting.Id) + } + ); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); Contact doe = new Contact(LastName = 'Doe'); @@ -1909,7 +1689,7 @@ private class TriggerOrchestratorTest { // Verify Assert.areEqual( - 'Handler class TriggerOrchestratorTest.TestLogger does not implement a handler interface.', + 'Handler class TriggerOrchestratorTest.TestLogger does not implement ' + BeforeInsert.Handler.class.getName() + '.', expectedException?.getMessage(), 'The exception message should match the expected one.' ); diff --git a/package/main/default/classes/TriggerTypes.cls b/package/main/default/classes/TriggerTypes.cls index 7c470fb..98b4d0b 100644 --- a/package/main/default/classes/TriggerTypes.cls +++ b/package/main/default/classes/TriggerTypes.cls @@ -2,7 +2,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record diff --git a/package/main/default/classes/TriggerTypesTest.cls b/package/main/default/classes/TriggerTypesTest.cls index caa75f4..794eb08 100644 --- a/package/main/default/classes/TriggerTypesTest.cls +++ b/package/main/default/classes/TriggerTypesTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.4.0 + * v1.1.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/layouts/TriggerHandler__mdt-Trigger Handler Layout.layout-meta.xml b/package/main/default/layouts/TriggerHandler__mdt-Trigger Handler Layout.layout-meta.xml new file mode 100644 index 0000000..ee5e8a1 --- /dev/null +++ b/package/main/default/layouts/TriggerHandler__mdt-Trigger Handler Layout.layout-meta.xml @@ -0,0 +1,139 @@ + + + + false + false + true + + + + Required + MasterLabel + + + Required + DeveloperName + + + + + Edit + IsProtected + + + Required + NamespacePrefix + + + + + + true + true + true + + + + Required + ApexClassName__c + + + Required + Order__c + + + + + Edit + Bypass__c + + + + + + true + true + true + + + + Edit + BeforeInsert__c + + + Edit + BeforeUpdate__c + + + Edit + BeforeDelete__c + + + + + Edit + AfterInsert__c + + + Edit + AfterUpdate__c + + + Edit + AfterDelete__c + + + Edit + AfterUndelete__c + + + + + + true + true + true + + + + Edit + Description__c + + + + + + false + false + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + true + true + false + + + + + + + false + false + false + false + false + diff --git a/package/main/default/layouts/TriggerObject__mdt-Trigger Object Layout.layout-meta.xml b/package/main/default/layouts/TriggerObject__mdt-Trigger Object Layout.layout-meta.xml new file mode 100644 index 0000000..05bc342 --- /dev/null +++ b/package/main/default/layouts/TriggerObject__mdt-Trigger Object Layout.layout-meta.xml @@ -0,0 +1,83 @@ + + + + false + false + true + + + + Required + MasterLabel + + + Required + DeveloperName + + + + + Edit + IsProtected + + + Required + NamespacePrefix + + + + + + true + true + true + + + + Required + ObjectAPIName__c + + + + + Edit + Bypass__c + + + + + + false + false + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + true + true + false + + + + + + + false + false + false + false + false + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/AfterDelete__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/AfterDelete__c.field-meta.xml new file mode 100644 index 0000000..66c69bb --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/AfterDelete__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterDelete__c + The trigger object this record applies to in after delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after delete. + SubscriberControlled + The trigger object this record applies to in after delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after delete. + + TriggerObject__mdt + After Delete Handlers + AfterDeleteHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/AfterInsert__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/AfterInsert__c.field-meta.xml new file mode 100644 index 0000000..f1ed04e --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/AfterInsert__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterInsert__c + The trigger object this record applies to in after insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after insert. + SubscriberControlled + The trigger object this record applies to in after insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after insert. + + TriggerObject__mdt + After Insert Handlers + AfterInsertHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/AfterUndelete__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/AfterUndelete__c.field-meta.xml new file mode 100644 index 0000000..6a7ab38 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/AfterUndelete__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterUndelete__c + The trigger object this record applies to in after undelete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after undelete. + SubscriberControlled + The trigger object this record applies to in after undelete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after undelete. + + TriggerObject__mdt + After Undelete Handlers + AfterUndeleteHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/AfterUpdate__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/AfterUpdate__c.field-meta.xml new file mode 100644 index 0000000..87c5b80 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/AfterUpdate__c.field-meta.xml @@ -0,0 +1,13 @@ + + + AfterUpdate__c + The trigger object this record applies to in after update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after update. + SubscriberControlled + The trigger object this record applies to in after update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in after update. + + TriggerObject__mdt + After Update Handlers + AfterUpdateHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/BeforeDelete__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/BeforeDelete__c.field-meta.xml new file mode 100644 index 0000000..58247b0 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/BeforeDelete__c.field-meta.xml @@ -0,0 +1,13 @@ + + + BeforeDelete__c + The trigger object this record applies to in before delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before delete. + SubscriberControlled + The trigger object this record applies to in before delete. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before delete. + + TriggerObject__mdt + Before Delete Handlers + BeforeDeleteHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/BeforeInsert__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/BeforeInsert__c.field-meta.xml new file mode 100644 index 0000000..39245b9 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/BeforeInsert__c.field-meta.xml @@ -0,0 +1,13 @@ + + + BeforeInsert__c + The trigger object this record applies to in before insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before insert. + SubscriberControlled + The trigger object this record applies to in before insert. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before insert. + + TriggerObject__mdt + Before Insert Handlers + BeforeInsertHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/BeforeUpdate__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/BeforeUpdate__c.field-meta.xml new file mode 100644 index 0000000..ee96f1a --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/BeforeUpdate__c.field-meta.xml @@ -0,0 +1,13 @@ + + + BeforeUpdate__c + The trigger object this record applies to in before update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before update. + SubscriberControlled + The trigger object this record applies to in before update. runFromMetadata() runs the class there, and Bypass switches it off there. Left empty, the record has no effect in before update. + + TriggerObject__mdt + Before Update Handlers + BeforeUpdateHandlers + false + MetadataRelationship + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/Description__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/Description__c.field-meta.xml new file mode 100644 index 0000000..ead2388 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/Description__c.field-meta.xml @@ -0,0 +1,9 @@ + + + Description__c + SubscriberControlled + + 32768 + LongTextArea + 3 + diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml index 873aef1..840c9e4 100644 --- a/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml +++ b/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml @@ -4,7 +4,7 @@ SubscriberControlled 18 - false + true 0 Number false diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/TriggerObject__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/TriggerObject__c.field-meta.xml deleted file mode 100644 index db0110f..0000000 --- a/package/main/default/objects/TriggerHandler__mdt/fields/TriggerObject__c.field-meta.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - TriggerObject__c - SubscriberControlled - - TriggerObject__mdt - Trigger Handlers - TriggerHandlers - true - MetadataRelationship - diff --git a/package/main/default/objects/TriggerHandler__mdt/validationRules/OneContextRequired.validationRule-meta.xml b/package/main/default/objects/TriggerHandler__mdt/validationRules/OneContextRequired.validationRule-meta.xml new file mode 100644 index 0000000..23e9f91 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/validationRules/OneContextRequired.validationRule-meta.xml @@ -0,0 +1,14 @@ + + + OneContextRequired + true + A handler record applies to one context on one object, so exactly one context field is filled. + IF(ISBLANK(BeforeInsert__c), 0, 1) + +IF(ISBLANK(AfterInsert__c), 0, 1) + +IF(ISBLANK(BeforeUpdate__c), 0, 1) + +IF(ISBLANK(AfterUpdate__c), 0, 1) + +IF(ISBLANK(BeforeDelete__c), 0, 1) + +IF(ISBLANK(AfterDelete__c), 0, 1) + +IF(ISBLANK(AfterUndelete__c), 0, 1) <> 1 + Select the trigger object in exactly one context. For a class that runs in several contexts, create one record per context. + diff --git a/website/.vitepress/context-facts.mjs b/website/.vitepress/context-facts.mjs index a816ffa..c5d40c8 100644 --- a/website/.vitepress/context-facts.mjs +++ b/website/.vitepress/context-facts.mjs @@ -99,7 +99,7 @@ export const addOnFacts = { returns: '`true` to skip this handler for this chunk' }, RecursionGuard: { - summary: `Cap how often this handler acts on one record per transaction (default ${recursionDefault}).`, + summary: `Skip a record at your own recursion depth and log an error, instead of only logging at ${recursionDefault}.`, called: 'once per chunk, when the handler list is built', returns: diff --git a/website/.vitepress/page-templates.mjs b/website/.vitepress/page-templates.mjs index 0ea4ebc..a6581a3 100644 --- a/website/.vitepress/page-templates.mjs +++ b/website/.vitepress/page-templates.mjs @@ -87,7 +87,7 @@ export const leadPatterns = { Bypassable: 'Skip a handler for the whole chunk when a condition holds, such as a static flag, a batch job or a custom permission.', RecursionGuard: - 'Cap how many times a {…} acts on the same record in one transaction (default 3).', + 'Cap how many times a {…} acts on the same record in one transaction.', Finalizer: 'Run code once per chunk with the records that qualified, such as {…}.', ContinueOnError: diff --git a/website/.vitepress/scripts/generate.mjs b/website/.vitepress/scripts/generate.mjs index 4624bad..b1831b4 100644 --- a/website/.vitepress/scripts/generate.mjs +++ b/website/.vitepress/scripts/generate.mjs @@ -188,7 +188,7 @@ function methodLine(context, item, method) { ? roleCalls[item.name].predicate : roleCalls[item.name].action; if (isPredicate && honours(context, item.name).includes('RecursionGuard')) - called += '; a record past its recursion limit is skipped without a call'; + called += '; with a RecursionGuard, a record past its limit is skipped without a call'; parts.push(`called ${called}.`); if (isPredicate) parts.push(`Return ${code('true')} ${predicateReturns[item.name]}.`); diff --git a/website/after-delete/add-ons/bypassable.md b/website/after-delete/add-ons/bypassable.md index 5817f6a..a0c3dff 100644 --- a/website/after-delete/add-ons/bypassable.md +++ b/website/after-delete/add-ons/bypassable.md @@ -25,7 +25,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **To skip single records, use the predicate.** Return false from `writeOnAfterDeleteWhen` or `dispatchOnAfterDeleteWhen`. - **Static flags last the whole transaction.** Later chunks and nested saves see them too. Reset a flag in a `finally` block. -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: warning Outside the error handling. An exception here is not logged, ContinueOnError does not apply, and the delete fails. diff --git a/website/after-insert/add-ons/bypassable.md b/website/after-insert/add-ons/bypassable.md index 2306f06..d0fe792 100644 --- a/website/after-insert/add-ons/bypassable.md +++ b/website/after-insert/add-ons/bypassable.md @@ -28,7 +28,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **To skip single records, return false from the predicate.** - **Checked before the first handler runs.** A flag that an earlier handler sets takes effect from the next chunk. - **Static flags last the transaction.** `ContactOwnerAlignmentWriter.isBypassed = true` stays set for every later chunk and nested save until you reset it. -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: warning An exception from `bypassOnAfterInsertWhen()` is not logged, ContinueOnError does not apply, and the insert fails. diff --git a/website/after-undelete/add-ons/bypassable.md b/website/after-undelete/add-ons/bypassable.md index 4ed10b5..44c8837 100644 --- a/website/after-undelete/add-ons/bypassable.md +++ b/website/after-undelete/add-ons/bypassable.md @@ -25,7 +25,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **To skip single records, return false from the predicate.** - **Reset static flags.** A flag lasts the whole transaction, nested saves and later chunks included. Reset it in a `finally` block. -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: warning An exception in `bypassOnAfterUndeleteWhen()` is not logged, ContinueOnError does not apply, and the restore fails. diff --git a/website/after-update/add-ons/bypassable.md b/website/after-update/add-ons/bypassable.md index 2e8cab5..1dd8839 100644 --- a/website/after-update/add-ons/bypassable.md +++ b/website/after-update/add-ons/bypassable.md @@ -27,7 +27,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **Reset static flags in `finally`.** A static flag lasts for the whole transaction, nested saves included. - **Outside the error handling.** An exception here is not logged, ContinueOnError does not apply, and the update fails. -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: tip To skip single records, return false from the predicate. diff --git a/website/after-update/add-ons/recursion-guard.md b/website/after-update/add-ons/recursion-guard.md index 3a4756b..4a7b56f 100644 --- a/website/after-update/add-ons/recursion-guard.md +++ b/website/after-update/add-ons/recursion-guard.md @@ -2,12 +2,12 @@ template: add-on context: AfterUpdate interface: RecursionGuard -description: Cap how many times an after update Writer or Dispatcher acts on the same record in one transaction (default 3). +description: Cap how many times an after update Writer or Dispatcher acts on the same record in one transaction. At the cap, the record is skipped and an error is logged. --- # AfterUpdate.RecursionGuard -Cap how many times a Writer or Dispatcher acts on the same record in one transaction (default 3). +Cap how many times a Writer or Dispatcher acts on the same record in one transaction. **Signature** @@ -27,10 +27,10 @@ Cap how many times a Writer or Dispatcher acts on the same record in one transac ## Rules {#rules} -- **It counts passes, not nesting depth.** The count goes up each time the predicate returns true and never resets during the transaction. Separate updates of the same record count too. -- **Skipped silently.** A record at the limit is skipped before its predicate. Nothing is logged, and the Finalizer does not get it. -- **Counted per class name and context.** Two instances of one class share one count. BeforeUpdate keeps a separate count. -- **`0` or less stops the handler.** Every record is skipped. `null` means no limit. +- **Without it, the depth is 3 and nothing is skipped.** When a record comes back after 3 runs, each run logs an error and the handler keeps acting. A loop that never ends fails the save at the platform's limit of 16 nested triggers. +- **It counts passes per class name and context, not nesting depth.** The count goes up each time the predicate returns true and never resets during the transaction. Separate updates of the same record count too. Two instances of one class share one count, and BeforeUpdate keeps its own. +- **Skipped and logged.** A record at the cap is skipped before its predicate, and the run logs an error. The Finalizer does not get the record. +- **`0` or less stops the handler.** Every record is skipped, and every run logs an error. `null` means no cap and no error. ::: tip Prefer a change gate. `isChanged` or `isChangedTo` in the predicate stops re-entry without counting. diff --git a/website/after-update/index.md b/website/after-update/index.md index 0114a95..069726c 100644 --- a/website/after-update/index.md +++ b/website/after-update/index.md @@ -20,7 +20,7 @@ Runs in **after update**, after the records are saved and before the transaction ## Rules {#rules} - **Updating this object runs the update triggers again.** Gate the predicate on a change, such as `isChangedTo`, so the nested run skips the record. -- **Recursion limit of 3.** Each handler acts on the same record at most 3 times per transaction. Change the limit with [RecursionGuard](/after-update/add-ons/recursion-guard). +- **A recursion depth of 3 is logged.** When a record comes back to a handler that already acted on it 3 times in the transaction, the run logs an error and the handler keeps running. [RecursionGuard](/after-update/add-ons/recursion-guard) sets its own depth and also skips the record there. - **Writer wins.** A class that implements both roles runs only as a Writer. - **Called per chunk.** `afterUpdateHandlers()` runs for every chunk of up to 200 records, and each chunk commits its own unit of work. diff --git a/website/api/custom-metadata.md b/website/api/custom-metadata.md index 97607ae..ecb9db0 100644 --- a/website/api/custom-metadata.md +++ b/website/api/custom-metadata.md @@ -1,10 +1,10 @@ --- -description: 'TriggerObject__mdt and TriggerHandler__mdt reference: switch off every handler on an object, or one handler class, org-wide without deploying code, and register the handlers of a runFromMetadata() trigger in Order__c order; fields, the ApexClassName__c format, when the records are read, and how to deploy them.' +description: 'TriggerObject__mdt and TriggerHandler__mdt reference: switch off every handler on an object, or one handler class in one context, org-wide without deploying code, and register the handlers of a runFromMetadata() trigger per context in Order__c order; fields, the ApexClassName__c format, when the records are read, and how to deploy them.' --- # Custom Metadata -Two custom metadata types switch handlers off for the whole org, without code changes. For a trigger that calls [`runFromMetadata()`](/api/trigger-orchestrator#run-from-metadata), they also register its handlers. Create the records in Setup under **Custom Metadata Types → Manage Records**, or deploy them. +Two custom metadata types switch handlers off for the whole org, without code changes. For a trigger that calls [`runFromMetadata()`](/api/trigger-orchestrator#run-from-metadata), they also register its handlers. Create the records in Setup under **Custom Metadata Types → Manage Records**, or deploy them. In Setup, the Trigger Handler page layout groups the seven context fields in a **Contexts** section. ## TriggerObject__mdt {#trigger-object} @@ -16,23 +16,25 @@ One record per object. | `Bypass__c` | checked: every handler on the object is off, in every context | - **The whole run is skipped.** No handler, parent query or provider runs, and no Logger method is called. -- **Also the parent of handler records.** `TriggerHandler__mdt` records need it, even with `Bypass__c` unchecked. -- **Keep one record per object.** With several, only one is read, and the handler records under the others are ignored. +- **Also the parent of handler records.** Their context field names it, so create it even with `Bypass__c` unchecked. +- **Keep one record per object.** With several, only one applies, and the handler records that name the others are ignored. ## TriggerHandler__mdt {#trigger-handler} -One record per handler class on one object. +One record is one handler class in one context on one object. It names the class and, in exactly one of the seven context fields, the object. | Field | Holds | |---|---| -| `TriggerObject__c` | the `TriggerObject__mdt` record of the object | | `ApexClassName__c` | the handler's class name | -| `Bypass__c` | checked: this handler is off on that object | -| `Order__c` | the run order under `runFromMetadata()`, lowest first; blank runs last | +| `BeforeInsert__c`, `AfterInsert__c`, `BeforeUpdate__c`, `AfterUpdate__c`, `BeforeDelete__c`, `AfterDelete__c`, `AfterUndelete__c` | exactly one of them: the `TriggerObject__mdt` record of the object, in the record's context; the other six stay blank | +| `Bypass__c` | checked: this class is off on that object, in that context | +| `Order__c` | required; the run order within the context under `runFromMetadata()`, lowest first, ties by class name | +| `Description__c` | free text for admins; the library never reads it | -- **Two roles.** Under `runFromMetadata()`, every record runs its class unless `Bypass__c` is checked. Under `run(orchestrator)`, a record only switches its class off, and `Order__c` is ignored. -- **In every context.** A class registered in before insert and before update is off in both. To switch off one context only, use that context's Bypassable add-on. -- **Only on that object.** A class used on Account and on Contact needs a record under each object. +- **Two roles.** Under `runFromMetadata()`, a record runs its class in its context, unless `Bypass__c` is checked. Under `run(orchestrator)`, it only switches its class off in that context, and `Order__c` is ignored, though it is still required. Deleting a record switches nothing off. +- **Exactly one context.** The validation rule `OneContextRequired` rejects a record with no context field filled, or with more than one, in Setup and in a deployment. A record with `BeforeInsert__c` filled neither runs nor switches off its class in after insert. +- **One record per context.** A class that runs in several contexts has a record for each, with its own `Order__c`. Name them `_`, such as `AccountRatingPopulator_BeforeInsert` and `AccountRatingPopulator_BeforeUpdate`. To switch the class off everywhere, check `Bypass__c` on each. +- **One object per record.** The context field names one object, so a class used on Account and on Contact needs a record for each. ### ApexClassName__c Format {#class-name-format} @@ -49,13 +51,13 @@ For a bypass, the value is matched against the class name without its outer clas ## When They Are Read {#reading} -- **Once per transaction.** The first `run`, `runFromMetadata` or `bypass()` call reads all records with one query. Changes apply from the next transaction. +- **Once per transaction.** The first `run`, `runFromMetadata` or `bypass()` call runs two queries: one for the `TriggerObject__mdt` records, then one for the `TriggerHandler__mdt` records, ordered by `Order__c` and then `ApexClassName__c`. Each handler record is attached to the object record its context field names. Changes apply from the next transaction. - **No SOQL limit cost.** Custom metadata queries do not count toward the limit. -- **Tests see the org's records.** Tests that run the orchestrator can [mock the query](/guide/testing#mock-metadata). +- **Tests see the org's records.** Tests that run the orchestrator can [mock the queries](/guide/testing#mock-metadata). ## Deploy Records {#deploy} -The file name is `..md-meta.xml`, for example in `force-app/main/default/customMetadata/`. +The file name is `..md-meta.xml`, for example in `force-app/main/default/customMetadata/`. A handler record is named `_`. **Example** @@ -77,19 +79,19 @@ The file name is `..md-meta.xml`, for example in `force-app ``` -```xml [TriggerHandler.AccountRatingPopulator.md-meta.xml] +```xml [TriggerHandler.AccountRatingPopulator_BeforeInsert.md-meta.xml] - + false - - TriggerObject__c - Account - ApexClassName__c AccountRatingPopulator + + BeforeInsert__c + Account + Bypass__c false @@ -104,5 +106,5 @@ The file name is `..md-meta.xml`, for example in `force-app ::: - **Declare `xmlns:xsd`.** Without it, the deployment fails with an `UNKNOWN_EXCEPTION` that names no component. -- **`TriggerObject__c` holds the DeveloperName alone,** such as `Account`, not `TriggerObject.Account`. -- **`Order__c` is an `xsd:double`.** Leave the `` block out for a blank order. +- **The context field holds the DeveloperName alone,** such as `Account`, not `TriggerObject.Account`. Leave out the `` blocks of the other six context fields. A second context is a second file, such as `TriggerHandler.AccountRatingPopulator_BeforeUpdate.md-meta.xml`. +- **`Order__c` is a required `xsd:double`.** A record without it fails to deploy. diff --git a/website/api/trigger-orchestrator.md b/website/api/trigger-orchestrator.md index 69db5f5..53ba33d 100644 --- a/website/api/trigger-orchestrator.md +++ b/website/api/trigger-orchestrator.md @@ -25,7 +25,7 @@ public static void run(Object orchestrator) ## runFromMetadata {#run-from-metadata} -Runs the handlers that [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) records register for the trigger's object. There is no orchestrator class, so the trigger's package never refers to the handlers, and they can live in packages that depend on it. +Runs the handlers that [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) records register for the trigger's object and context. There is no orchestrator class, so the trigger's package never refers to the handlers, and they can live in packages that depend on it. **Signature** @@ -41,11 +41,11 @@ trigger AccountTrigger on Account(before insert, after insert, before update, af } ``` -- **Every record of the object runs,** unless its `Bypass__c` is checked. -- **`Order__c` is run order,** lowest first. Records without it run last. Ties run in class name order. -- **The class decides the contexts.** It runs in every context whose role it implements, at the same `Order__c`. -- **A new instance per run.** The class needs a public no-argument constructor. Every record's class is created in every context, so keep constructors cheap. -- **A record that cannot run throws.** A class that is not found, cannot be created or implements no handler interface fails the save with a `TriggerTypes.TriggerLibException`. +- **A record runs its class in the one context whose field, such as `BeforeInsert__c`, names the object,** unless its `Bypass__c` is checked. +- **`Order__c` is run order** within the context, lowest first. It is required. Ties run in class name order. +- **The record decides the context.** Its class must implement it. A class that runs in several contexts has a record per context, each with its own `Order__c`. +- **A new instance per run.** The class needs a public no-argument constructor. It is created on every run of the context its record names, so keep constructors cheap. +- **A record that cannot run throws.** A class that is not found, does not implement the context's `Handler` interface or cannot be created fails the save with a `TriggerTypes.TriggerLibException`. - **`bypass().orchestrator(…)` does not apply.** Use `sObject(…)`, `handler(…)` or the record's `Bypass__c`. ## Registration Interfaces {#registration} @@ -143,7 +143,8 @@ public interface Logger { ``` - **`log(error)`** runs for every exception in a handler's turn, before the library rethrows it or ContinueOnError swallows it. -- **`flush()`** runs at the end of each outermost run, also when it failed. +- **Recursion is logged, not thrown.** In the update contexts, each record that comes back to a handler at its recursion depth logs one [recursion error](#triggerlibexception), from inside the record loop. Keep `log()` free of SOQL and DML there, or the library's guards throw. +- **`flush()`** runs at the end of each outermost run, also when it failed. After a save fails with `maximum trigger depth exceeded`, it does not run again in that transaction. - **Found automatically.** Implement it in one class and register nothing. With two implementations, the first call to `run`, `runFromMetadata` or `bypass()` throws. - **Not logged:** exceptions outside a handler's turn, listed in [Errors & Logging](/guide/error-handling#never-logged). @@ -175,8 +176,8 @@ public interface Error { | `Called outside of a trigger context.` | `run` or `runFromMetadata` is called outside a trigger | | `Multiple implementations of TriggerOrchestrator.Logger found. Only one implementation is allowed.` | the org has two Logger classes | | `Handler class was not found.` | a `TriggerHandler__mdt` record of a `runFromMetadata` object names a class that does not exist or is not public | +| `Handler class does not implement .Handler.` | a `TriggerHandler__mdt` record names the object in a context whose `Handler` interface the class does not implement; the `btcdev` package prints `btcdev..Handler` | | `Handler class could not be instantiated.` | the class has no public no-argument constructor, or its constructor threw; the cause holds the original exception | -| `Handler class does not implement a handler interface.` | the class implements no context's handler interface | | ` performed DML in before insert. Move that work to an after insert Writer.` | a before insert Populator or Validator ran DML or published an immediate event | | ` performed DML in before update. Move that work to an after update Writer.` | the same in before update | | ` performed DML in or . Register that work in the unit of work.` | in before delete or an after context, a Writer's predicate or action ran DML. A Validator or a Dispatcher gets `Move that work to a Writer.` instead, and a Dispatcher's message names only its predicate | @@ -186,3 +187,5 @@ public interface Error { | `No related records provider named is declared. Return it from the queryRelatedOn method of the handler first.` | `getRelated` with a name the handler's RelatedQuery did not return | Code that fires the trigger through DML gets a `DmlException` that contains the original message. + +One message is logged and never thrown: ` reached the recursion depth of on record in .` A Populator, Writer or Dispatcher logs it for each record that comes back after `` runs in the transaction: 3, or what its RecursionGuard returns. Without RecursionGuard the handler keeps running. With it, the record is skipped. diff --git a/website/before-delete/add-ons/bypassable.md b/website/before-delete/add-ons/bypassable.md index 00d2f41..fc11c98 100644 --- a/website/before-delete/add-ons/bypassable.md +++ b/website/before-delete/add-ons/bypassable.md @@ -25,7 +25,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **To skip single records, use the predicate.** Return false from `addErrorOnBeforeDeleteWhen` or `writeOnBeforeDeleteWhen`. - **Reset static flags.** A static flag lasts the whole transaction, nested saves included. Reset it in a `finally` block after the DML it was meant for. -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: warning The method runs outside the error handling. An exception in `bypassOnBeforeDeleteWhen()` is not logged, ContinueOnError does not apply, and the delete fails. diff --git a/website/before-insert/add-ons/bypassable.md b/website/before-insert/add-ons/bypassable.md index 2121188..68fdf2f 100644 --- a/website/before-insert/add-ons/bypassable.md +++ b/website/before-insert/add-ons/bypassable.md @@ -28,7 +28,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **Reset static flags.** A flag stays set for the rest of the transaction, nested saves included. Set it back to false in a `finally` block after your DML. - **Checked before any handler runs.** A flag that an earlier handler sets takes effect only from the next chunk. - **Exceptions here are not logged.** The method runs outside the handler's error handling. An exception fails the insert, even with [ContinueOnError](/before-insert/add-ons/continue-on-error). -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: tip To skip only some records, return false from the predicate instead. diff --git a/website/before-update/add-ons/bypassable.md b/website/before-update/add-ons/bypassable.md index f86213a..7bebf0e 100644 --- a/website/before-update/add-ons/bypassable.md +++ b/website/before-update/add-ons/bypassable.md @@ -27,7 +27,7 @@ Skip a handler for the whole chunk when a condition holds, such as a static flag - **To skip single records, return false from the predicate.** - **Reset a static flag in `finally`.** The flag stays set for every chunk and every nested update in the transaction. -- **Only this context.** [Other bypasses](/guide/bypasses) cover every context, and `bypass().handler(X.class)` never matches an inner class. +- **Only this context.** [Apex bypasses](/guide/bypasses#apex) cover every context, and `bypass().handler(X.class)` never matches an inner class. A [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record covers the one context it names, for every user. ::: warning Exceptions here are not logged. `bypassOnBeforeUpdateWhen()` runs outside the handler's error handling. ContinueOnError does not apply, and the update fails. diff --git a/website/before-update/add-ons/recursion-guard.md b/website/before-update/add-ons/recursion-guard.md index b738440..27b1eff 100644 --- a/website/before-update/add-ons/recursion-guard.md +++ b/website/before-update/add-ons/recursion-guard.md @@ -2,12 +2,12 @@ template: add-on context: BeforeUpdate interface: RecursionGuard -description: Limit how many times a before update Populator acts on the same record in one transaction. The default is 3. +description: Limit how many times a before update Populator acts on the same record in one transaction. At the limit, the record is skipped and an error is logged. --- # BeforeUpdate.RecursionGuard -Cap how many times a Populator acts on the same record in one transaction (default 3). +Cap how many times a Populator acts on the same record in one transaction. **Signature** @@ -27,10 +27,10 @@ Cap how many times a Populator acts on the same record in one transaction (defau ## Rules {#rules} -- **Update contexts only.** RecursionGuard exists in BeforeUpdate and AfterUpdate, and each keeps its own count. -- **It counts qualifying passes.** The count goes up by 1 each time the predicate returns true for a record Id. It is not the nesting level. -- **A limit of 1 blocks a second edit.** When one transaction updates a record twice, both updates qualify, and the second one is skipped. -- **Skipped records vanish silently.** A skipped record is not logged, and the Finalizer does not get it. +- **Without it, the depth is 3 and nothing is skipped.** When a record comes back after 3 passes, each run logs an error and the Populator keeps acting. +- **It counts qualifying passes.** The count goes up by 1 each time the predicate returns true for a record Id. It is not the nesting level. RecursionGuard exists only in BeforeUpdate and AfterUpdate, and each keeps its own count. +- **A limit of 1 blocks a second edit.** When one transaction updates a record twice, both updates qualify, and the second one is skipped and logged. +- **Skipped records are logged.** Each run that skips a record logs an error. The Finalizer does not get the record. ::: tip A change gate often does the job. On a nested update, `isChanged` compares with the values the previous update saved, so it is false unless the field changed again. diff --git a/website/before-update/index.md b/website/before-update/index.md index a4346fa..f225ed2 100644 --- a/website/before-update/index.md +++ b/website/before-update/index.md @@ -20,7 +20,7 @@ Runs in **before update**, before the changes are saved, with the old and new va ## Rules {#rules} - **The old row is read-only.** Writing to `getOldSObject()` or calling `addError` on it throws a `FinalException` that no `catch` stops. The update fails. -- **Updates re-enter.** A later update of the same records in the transaction runs BeforeUpdate again. A Populator acts on a record at most 3 times per transaction by default. A Validator runs on every pass. +- **Updates re-enter.** A later update of the same records in the transaction runs BeforeUpdate again. When a record comes back to a Populator after 3 passes, the run logs an error and the Populator keeps running. A Validator runs on every pass. - **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/before-update/add-ons/parent-query) and read `record.getNewParent(Contact.AccountId)`. - **One role per class.** A class that implements both roles runs only as a Populator. List Populators first, so Validators see the values they set. diff --git a/website/before-update/populator.md b/website/before-update/populator.md index ca43a2a..af2b38c 100644 --- a/website/before-update/populator.md +++ b/website/before-update/populator.md @@ -28,7 +28,7 @@ Set fields on records being updated before they are saved. ## Rules {#rules} - **`put` counts as a change.** Handlers listed later see the value, and `isChanged` is true for them. Putting the old value back makes the field unchanged. -- **Gate on a change.** The Populator runs again when the same records are updated again in the transaction. There, `isChanged` compares with the values the previous update saved. The default limit is 3 passes per record; change it with a [RecursionGuard](/before-update/add-ons/recursion-guard). +- **Gate on a change.** The Populator runs again when the same records are updated again in the transaction. There, `isChanged` compares with the values the previous update saved. When a record comes back after 3 passes, each run logs an error; a [RecursionGuard](/before-update/add-ons/recursion-guard) sets its own depth and also skips the record. - **Never write to `getOldSObject()`.** The `FinalException` cannot be caught and fails the update. - **Populator wins.** A class that also implements `BeforeUpdate.Validator` runs only as a Populator. diff --git a/website/guide/bypasses.md b/website/guide/bypasses.md index 6a9ff4b..792d4a0 100644 --- a/website/guide/bypasses.md +++ b/website/guide/bypasses.md @@ -24,7 +24,7 @@ try { ## From Custom Metadata {#metadata} -Check `Bypass__c` on a `TriggerObject__mdt` record to switch off every handler on an object for every user, or on a `TriggerHandler__mdt` record under it to switch off one class. Under `runFromMetadata()`, a bypassed record's class is not even created. Changes apply from the next transaction. To ship a handler switched off, deploy it with its `TriggerHandler__mdt` record checked, then uncheck `Bypass__c` when it should go live. Fields, deployment and test mocks: [Custom Metadata](/api/custom-metadata). +Check `Bypass__c` on a `TriggerObject__mdt` record to switch off every handler on an object for every user, or on a `TriggerHandler__mdt` record to switch off one class in the one context its field, such as `BeforeInsert__c`, names. Under `runFromMetadata()`, a bypassed record's class is not even created. Changes apply from the next transaction. To ship a handler switched off, deploy it with `Bypass__c` checked on its `TriggerHandler__mdt` records, then uncheck them when it should go live. Fields, deployment and test mocks: [Custom Metadata](/api/custom-metadata). ## From the Handler {#bypassable} @@ -34,7 +34,7 @@ Implement the context's Bypassable for feature flags and other conditions that d - **Checked last.** The Apex and metadata bypasses win. - **Checked before the first handler runs.** A flag set by an earlier handler in the same run applies from the next run. -- **One context only.** The other bypasses cover every context. To skip a class in one context, use Bypassable, or leave it out of that context's list when an orchestrator lists the handlers. +- **One context only.** Apex bypasses cover every context, a `TriggerHandler__mdt` record one. To skip a class in one context on a condition, use Bypassable. To skip it outright, check `Bypass__c` on that context's record, or leave the class out of that context's list when an orchestrator lists the handlers. - **To skip only some records,** return false from the predicate instead. ## Per User {#per-user} diff --git a/website/guide/error-handling.md b/website/guide/error-handling.md index 16aa89e..5b27295 100644 --- a/website/guide/error-handling.md +++ b/website/guide/error-handling.md @@ -48,7 +48,8 @@ public with sharing class TriggerErrorLogger implements TriggerOrchestrator.Logg } ``` -- **One instance per transaction.** `flush()` runs at the end of each outermost run, also when the run fails. Clear your buffer there. +- **One instance per transaction.** `flush()` runs at the end of each outermost run, also when the run fails, except after a [`maximum trigger depth exceeded`](#recursion) failure. Clear your buffer there. +- **Only buffer in `log()`.** A recursion error is logged inside the record loop, where SOQL or DML trips the library's guards. Write in `flush()`. - **Exactly one class.** With two implementations, every Trigger Lib trigger in the org throws. - **Never throw from a Logger.** Its exception replaces the handler's and fails the save. @@ -60,6 +61,14 @@ Implement the context's ContinueOnError to let the save go on when the handler f - **A Writer that throws before its commit saves nothing.** Its registrations go to the automatic unit of work, which is skipped after a failure. If the commit itself fails, statements that already ran stay: there is no savepoint. - **Use it for nice-to-have work,** such as a notification. Without a Logger, a swallowed failure leaves no trace. +## Recursion {#recursion} + +In the update contexts, when a record comes back to a Populator, Writer or Dispatcher that already acted on it 3 times in the transaction, the library logs one `TriggerTypes.TriggerLibException` for that record, throws nothing, and the handler keeps running. Without a Logger, the error leaves no trace. + +- **Fix the cause.** Gate the predicate on a change, such as `isChanged`, so a nested update does not qualify the record again. +- **Or cap it.** A [RecursionGuard](/after-update/add-ons/recursion-guard) sets the depth and skips the record there. The error is logged too. +- **An endless loop fails the save** with `maximum trigger depth exceeded` at the platform's limit of 16 nested triggers. `flush()` does not run for that save or for any later one in the transaction, so a Logger that writes only in `flush()` loses these errors. + ## Never Logged {#never-logged} This code runs outside every handler. An exception there is not logged, ContinueOnError does not apply, and the save fails: diff --git a/website/guide/execution-order.md b/website/guide/execution-order.md index 9c4b4e4..496baec 100644 --- a/website/guide/execution-order.md +++ b/website/guide/execution-order.md @@ -13,8 +13,8 @@ Each run calls `Handlers()`, loads parents, runs providers and commits agai ## Order of a Run {#order} 1. **Run bypasses.** `TriggerOrchestrator.bypass()` (`all()`, `sObject(…)`, `orchestrator(…)`) or a checked `TriggerObject__mdt.Bypass__c` ends the run before any of your code. So does an orchestrator that does not implement the context. -2. **Handler list.** `Handlers()` runs. Under `runFromMetadata()`, the library instead creates the class of every unbypassed `TriggerHandler__mdt` record of the object, in `Order__c` order, and keeps those that implement the context. A class that implements two roles runs only as the first: Populator over Validator, Validator over Writer, Writer over Dispatcher. -3. **Handler bypasses.** `bypass().handler(…)`, then `TriggerHandler__mdt.Bypass__c`, then `bypassOnWhen()`. A skipped handler declares no parents. When no handler is left, the run ends here, and only the Logger step follows. +2. **Handler list.** `Handlers()` runs. Under `runFromMetadata()`, the library instead creates the class of every unbypassed `TriggerHandler__mdt` record whose field for the context names the object, in `Order__c` order. A class without the context's `Handler` interface throws. A class that implements two roles runs only as the first: Populator over Validator, Validator over Writer, Writer over Dispatcher. +3. **Handler bypasses.** `bypass().handler(…)`, then `TriggerHandler__mdt.Bypass__c` for this context, then `bypassOnWhen()`. A skipped handler declares no parents. When no handler is left, the run ends here, and only the Logger step follows. 4. **Parent declarations.** The remaining handlers declare their parents. A lookup is queried the first time a handler reads its parent. 5. **Each handler runs, in list order.** - Each RelatedQuery provider runs over all records the first time the handler reads it. @@ -22,7 +22,7 @@ Each run calls `Handlers()`, loads parents, runs providers and commits agai - The Finalizer runs when at least one record qualified. - A Writer with OwnUnitOfWork or ContinueOnError commits its own unit of work. 6. **Shared commit.** In before delete and the after contexts, the [shared unit of work](/guide/unit-of-work#which-unit) commits once. -7. **Logger.** The outermost run calls `flush()` on the org's Logger. +7. **Logger.** The outermost run calls `flush()` on the org's Logger. After a `maximum trigger depth exceeded` failure, no later run in the transaction does. ## Query Cost {#query-cost} @@ -54,5 +54,5 @@ SOQL queries a run spends outside your code: A commit or direct DML fires the triggers of the saved records at once. Each is a full nested run with its own parents, providers and commit. -- **Recursion counts are shared.** In the update contexts, a Populator, Writer or Dispatcher skips a record after acting on it 3 times in the transaction. Change the limit with a [RecursionGuard](/after-update/add-ons/recursion-guard). +- **Recursion counts are shared.** In the update contexts, when a record comes back to a Populator, Writer or Dispatcher that already acted on it 3 times in the transaction, the run logs an error and the handler keeps running. A [RecursionGuard](/after-update/add-ons/recursion-guard) sets its own depth and also skips the record there. - **Salesforce allows 16 levels.** The next one fails with "maximum trigger depth exceeded". diff --git a/website/guide/orchestrator.md b/website/guide/orchestrator.md index 89ea796..97c32b2 100644 --- a/website/guide/orchestrator.md +++ b/website/guide/orchestrator.md @@ -34,17 +34,17 @@ trigger AccountTrigger on Account(before insert, after insert, before update, af } ``` -Each [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record under the object's `TriggerObject__mdt` record registers one class. +Each [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record registers one class in one context: the one whose field, such as `BeforeInsert__c`, names the object's `TriggerObject__mdt` record. - **No orchestrator class.** The trigger's package never refers to the handlers. -- **The class decides the contexts.** It runs in every context whose role it implements. +- **The record decides the context.** The class runs only in the context its record names, and must implement it. A class that runs in several contexts has a record per context. - **`Order__c` is the list order,** as below. - **A record whose class can't run fails the save.** Test that every record resolves: see [Registration Tests](/guide/testing#registration). ## Handler Order {#order} - **List order is run order**, whatever the role. Each handler finishes before the next one starts. -- **Under `runFromMetadata()`, `Order__c` is the list order.** Lowest runs first, records without it run last, and ties run in class name order. +- **Under `runFromMetadata()`, `Order__c` is the list order** of each context. It is required. Lowest runs first, and ties run in class name order. - **Later handlers see earlier changes.** List Populators before Validators. - **Writers commit last.** By default, the Writers' DML commits once, after the last handler. @@ -77,9 +77,9 @@ public with sharing class ContactBirthdateRangeValidator implements BeforeInsert ``` - **Method names carry the context**, so they never clash. -- **Register the class in each context's list.** Under `runFromMetadata()`, one record covers every context. +- **Register the class in each context's list.** Under `runFromMetadata()`, create a record per context, such as `ContactBirthdateRangeValidator_BeforeInsert` and `ContactBirthdateRangeValidator_BeforeUpdate`, each with its own `Order__c`. - **Add-ons are per context too**, such as `BeforeInsert.ParentQuery` and `BeforeUpdate.ParentQuery`. -- **Bypasses cover every context.** A `TriggerHandler__mdt` record switches the class off in every context of its object. To skip one context, use that context's Bypassable. +- **`bypass().handler(…)` covers every context.** A `TriggerHandler__mdt` record switches the class off only in its context. To skip one context on a condition, use that context's Bypassable. ## Instances per Chunk {#instances-per-chunk} @@ -89,7 +89,7 @@ The library calls the handler list method on every run: per context, per chunk o - **Static fields last the whole transaction.** - **A static "already ran" flag skips later chunks.** Gate the predicate on a change such as `isChanged` instead, or use RecursionGuard in the update contexts. - **Constructor arguments are allowed.** They are evaluated on every run, so keep SOQL out of them. -- **Under `runFromMetadata()`, there are no constructor arguments.** The library creates every registered class in every context with its public no-argument constructor, so keep that constructor cheap. +- **Under `runFromMetadata()`, there are no constructor arguments.** The library creates each registered class with its public no-argument constructor, on every run of the context its record names, so keep that constructor cheap. ## Before to After Handoff {#before-after-handoff} diff --git a/website/guide/queries.md b/website/guide/queries.md index 481e80b..297ee14 100644 --- a/website/guide/queries.md +++ b/website/guide/queries.md @@ -60,7 +60,7 @@ public with sharing class ContactDepartmentPopulator implements BeforeInsert.Pop } ``` -- **Static, not in the constructor.** A static variable is set once per transaction. The library calls `Handlers()` on every run, per context, per chunk of up to 200 records and per nested run, so a query in the constructor runs every time. Under `runFromMetadata()`, every registered class is created in every context. +- **Static, not in the constructor.** A static variable is set once per transaction. The library calls `Handlers()` on every run, per context, per chunk of up to 200 records and per nested run, so a query in the constructor runs every time. Under `runFromMetadata()`, the library creates each class the same way, in each context a record registers it in. - **Custom settings and custom metadata need no SOQL.** Read them with `getOrgDefaults()`, `getInstance()` or `getAll()`. SOQL on custom metadata doesn't count toward the 100-query limit either, unless it selects a long text area field. - **Outside the guard.** The static variable is set when the handler is created, before the record loop. diff --git a/website/guide/testing.md b/website/guide/testing.md index b1d06aa..096210a 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -183,16 +183,16 @@ static void afterInsertHandlersContainsWelcomeTaskWriter() { } ``` -Under `runFromMetadata()`, a record whose class can't be found fails every save on the object. Test that the deployed records resolve: +Under `runFromMetadata()`, a record whose class can't be found fails every save in its context. Test that the deployed records resolve: ```apex @IsTest -static void accountHandlerRecordsResolve() { +static void handlerRecordsResolve() { // Setup List missingClasses = new List(); // Test - for (TriggerHandler__mdt handlerRecord : [SELECT ApexClassName__c FROM TriggerHandler__mdt WHERE TriggerObject__r.ObjectAPIName__c = 'Account']) { + for (TriggerHandler__mdt handlerRecord : [SELECT ApexClassName__c FROM TriggerHandler__mdt]) { if (Type.forName(handlerRecord.ApexClassName__c.trim()) == null) { missingClasses.add(handlerRecord.ApexClassName__c); } @@ -207,13 +207,27 @@ static void accountHandlerRecordsResolve() { Mock the bypass metadata and the Logger search before anything refers to `TriggerOrchestrator`, so before `TriggerOrchestrator.mock()`. Otherwise the org's deployed bypass records and its real Logger apply to your test: -```apex +::: code-group + +```apex [Isolated] SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); ``` +```apex [Handler record] +TriggerObject__mdt contactSetting = new TriggerObject__mdt(Id = new TriggerTypes.RandomIdGenerator().get(TriggerObject__mdt.SObjectType), ObjectAPIName__c = 'Contact', Bypass__c = false); +TriggerHandler__mdt populatorBypass = new TriggerHandler__mdt(ApexClassName__c = 'ContactEmailNormalizationPopulator', Bypass__c = true, BeforeInsert__c = contactSetting.Id); + +SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); +SOQL.mock('TriggerHandler__mdt').thenReturn(new List{ populatorBypass }); +SOQL.mock('ApexTypeImplementor').thenReturn(new List()); +``` + +::: + - **A metadata bypass.** Return `new TriggerObject__mdt(ObjectAPIName__c = 'Account', Bypass__c = true)` instead of the empty list. -- **Handler records.** `TriggerHandler__mdt` rows, with `Order__c`, sit in the parent's `TriggerHandlers__r` relationship. A plain constructor can't set it, so build the parent with `JSON.deserialize`. +- **Handler records.** Mock `'TriggerHandler__mdt'` too, with plain constructors. A record's single context field, such as `BeforeInsert__c`, holds the Id of the mocked `TriggerObject__mdt` record, so give that record an Id. A class in several contexts gets a record per context. The list order is the run order, since a mock ignores `Order__c`. +- **`'TriggerObject__mdt'` alone isolates a test.** The org's handler records name its own object records, so none of them matches a mocked one. With the `btcdev` package, the mock keys stay unprefixed. - **An Apex bypass.** Call `TriggerOrchestrator.bypass().handler(…)` or `.sObject(…)` before `runTestFor`. `.orchestrator(…)` does not apply under `runTestFor`. - **A Logger.** Set `TriggerOrchestrator.triggerLogger.logger` to a class of your own that collects the errors. Use it to test ContinueOnError. diff --git a/website/installation.md b/website/installation.md index 8b9dd62..5bf6f5b 100644 --- a/website/installation.md +++ b/website/installation.md @@ -1,5 +1,5 @@ --- -description: Install Trigger Lib in a Salesforce org - deploy via button, or copy the Apex classes and custom metadata types together with the SOQL Lib and DML Lib dependencies. +description: Install Trigger Lib in a Salesforce org - deploy via button, or copy the Apex classes, custom metadata types and page layouts together with the SOQL Lib and DML Lib dependencies. --- # Installation @@ -56,6 +56,11 @@ The button also deploys [SOQL Lib](https://soql.beyondthecloud.dev) 6.12.0 and [ - [`TriggerObject__mdt`](https://github.com/beyond-the-cloud-dev/trigger-lib/tree/main/force-app/main/default/objects/TriggerObject__mdt) - [`TriggerHandler__mdt`](https://github.com/beyond-the-cloud-dev/trigger-lib/tree/main/force-app/main/default/objects/TriggerHandler__mdt) +**Page Layouts** + +- [`TriggerObject__mdt-Trigger Object Layout`](https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/force-app/main/default/layouts/TriggerObject__mdt-Trigger%20Object%20Layout.layout-meta.xml) +- [`TriggerHandler__mdt-Trigger Handler Layout`](https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/force-app/main/default/layouts/TriggerHandler__mdt-Trigger%20Handler%20Layout.layout-meta.xml) + ### SOQL Lib _(required)_ {#soql-lib} **Apex** diff --git a/website/introduction.md b/website/introduction.md index ba27adc..a6e3d85 100644 --- a/website/introduction.md +++ b/website/introduction.md @@ -13,7 +13,7 @@ Trigger Lib is an Apex trigger framework for Salesforce. You write one small cla - **Declared Queries** - Declare parent fields and related records, with no SOQL in your handler - **Unit of Work** - Writers register DML, committed once after the last handler by default - **Guard Rails** - SOQL or DML in a per-record method fails the save, whether a developer or an AI agent wrote it -- **Bypasses and Recursion Control** - Switch handlers off in Apex or custom metadata, and cap recursion in the update contexts +- **Bypasses and Recursion Control** - Switch handlers off in Apex or custom metadata, and log or cap recursion in the update contexts - **Testability** - Test handlers with in-memory records, no trigger and no DML ## Quick Example {#quick-example} @@ -50,7 +50,7 @@ Around that loop, the library: - **Commits DML in bulk.** Writers register DML on a unit of work, which commits after the last handler by default. DML in a predicate or an action throws. See [Unit of Work](/guide/unit-of-work). - **Handles errors.** A handler's exception goes to your Logger and fails the save, unless the handler implements ContinueOnError. See [Errors & Logging](/guide/error-handling). - **Skips what you switch off** from Apex, custom metadata or the handler itself. See [Bypassing](/guide/bypasses). -- **Caps recursion.** In the update contexts, a Populator, Writer or Dispatcher acts on the same record at most 3 times per transaction by default. +- **Reports recursion.** In the update contexts, a record that comes back to a Populator, Writer or Dispatcher after 3 runs in the transaction logs an error. A RecursionGuard sets its own depth and also skips the record. Every step of a run, in order: [Execution Order & Cost](/guide/execution-order). @@ -74,6 +74,6 @@ Pick one role per context and as many add-ons as you need. Each add-on exists on | RelatedQuery | Loads children, siblings or any other records with your own query. | [Every context](/contexts) | | OwnUnitOfWork | Gives a Writer its own unit of work and settings, committed right after it. | [AfterInsert](/after-insert/add-ons/own-unit-of-work), [AfterUpdate](/after-update/add-ons/own-unit-of-work), [BeforeDelete](/before-delete/add-ons/own-unit-of-work), [AfterDelete](/after-delete/add-ons/own-unit-of-work), [AfterUndelete](/after-undelete/add-ons/own-unit-of-work) | | Bypassable | Skips the handler for a run on a condition, such as a feature flag. | [Every context](/contexts) | -| RecursionGuard | Changes how many times the handler acts on the same record. | [BeforeUpdate](/before-update/add-ons/recursion-guard), [AfterUpdate](/after-update/add-ons/recursion-guard) | +| RecursionGuard | Skips a record after the handler acted on it a set number of times. | [BeforeUpdate](/before-update/add-ons/recursion-guard), [AfterUpdate](/after-update/add-ons/recursion-guard) | | Finalizer | Runs once after the loop when records qualified, with those records. | [Every context](/contexts) | | ContinueOnError | Logs the handler's exception and lets the save go on. | [Every context](/contexts) | diff --git a/website/introduction/design-principles.md b/website/introduction/design-principles.md index a276caf..c17a177 100644 --- a/website/introduction/design-principles.md +++ b/website/introduction/design-principles.md @@ -30,11 +30,11 @@ Every role declares which records it wants. An action never runs for a record it ## Library Errors Always Surface {#library-errors} -ContinueOnError swallows a handler's own exception. It never swallows a `TriggerTypes.TriggerLibException`: a broken contract is a bug. +ContinueOnError swallows a handler's own exception. It never swallows a `TriggerTypes.TriggerLibException`: a broken contract is a bug. The one library error that is only logged, never thrown, is the recursion error. -## Safe by Default {#safe-by-default} +## Recursion Is Reported {#recursion-is-reported} -In the update contexts, a Populator, Writer or Dispatcher acts on the same record at most three times per transaction by default. +In the update contexts, a record that comes back to a Populator, Writer or Dispatcher after three runs in the transaction logs an error, and the handler keeps running. The library never skips a record on its own: only a RecursionGuard on the handler does, and it logs the error too. ## No Required Metadata {#no-required-metadata}