Skip to content

Commit 5290dd9

Browse files
authored
Feature: New CMDT Module (#122)
* exploring creating a Cmdt class * checkpoint - designing test seams * fixing issue with retrieval * reworking pattern to be reflexive based on query and heap concerns * cleanup * checkpoint - building out MockCmdt functionality * checkpoint - sketching out implementation in DBL * checkpoint - building out databaselayer configs * contd. * update existing useMock/Real data methods to use the new Config object * cleanup * fixed issue with databaselayer setter * refining mock * starting to build out tests * consolidating into a single class, plus refoldering * contd. * all tests passing * removed unnecessary method * simplifying DatabaseLayer; adding Cmdt test * transitioning mock method to static * updated getInstance queries * contd. * fixing test error introduced by queries * fixing test coverage, cleanup * cleanup * removing/refactoring metadata selector in light of the new Cmdt module * cleanup; investigating issue w/plugins * contd. * fixed issue with settings lazy-loading, other tests * checkpoint - still trying to figure out plugin issue * fixing mockdmltest * fixing mocksoqltest * fixing dmltest * fixing soqltest * patching coverage * cleanup * reverting changes to DatabaseLayer.Configuration -- nto ready for this yet * removing dml.plugin * cleanup * cleanup * adding reset functionality * fixing pmd errors * adding missing apexdocs * cleanup * adjusted MockRepository to extend CacheBasedRepository * split up mocking functionality into its own class * adding ApexDoc return * cleanup * removing cognitivecomplexity suppression - no longer needed since we split up into two classes * removing public MockCmdt.add by map overload * removing remove by map method for simplicity * checkpoint - exploring repositorybuilder pattern * Revert "checkpoint - exploring repositorybuilder pattern" This reverts commit f5aedd3. * ran prettier * moving MockCmdt to correct directory * fixing logical error w/mockcmdt * renaming mapByKey method
1 parent f61f3fe commit 5290dd9

19 files changed

+732
-135
lines changed

plugins/nebula-logger/README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
This plugin leverages the [Plugin Framework](https://github.com/jasonsiders/apex-database-layer/wiki/The-Plugin-Framework) to automatically logs details about your DML and SOQL operations, via _Nebula Logger_.
22

3-
[Nebula Logger](https://github.com/jongpie/NebulaLogger/tree/main) is a popular logging framework for Salesforce. Like Apex Database Layer, it's free, and open-source.
3+
[Nebula Logger](https://github.com/jongpie/NebulaLogger/tree/main) is a popular logging framework for Salesforce. Like Apex Database Layer, it's free, and open-source.
44

55
## Getting Started
66

77
### Prerequisites
8+
89
To use this plugin, you must have the most recent version of [Apex Database Layer](https://github.com/jasonsiders/apex-database-layer) and [Nebula Logger](https://github.com/jongpie/NebulaLogger/tree/main) installed.
910

1011
### Installation
@@ -19,34 +20,36 @@ sf package install --package <<package_version_id>> --wait 10
1920
2021
> :warning: **Note:** If you are using a managed version of _Apex Database Layer_ and/or _Nebula Logger_, you won't be able to formally install the package. Instead, manually copy the contents of these two Apex Classes in your desired environment:
2122
>
22-
>- [`DatabaseLayerNebulaLoggerAdapter.cls`](https://github.com/jasonsiders/apex-database-layer/blob/main/plugins/nebula-logger/source/classes/DatabaseLayerNebulaLoggerAdapter.cls)
23-
>- [`DatabaseLayerNebulaLoggerAdapterTest.cls`](https://github.com/jasonsiders/apex-database-layer/blob/main/plugins/nebula-logger/source/classes/DatabaseLayerNebulaLoggerAdapterTest.cls))
23+
> - [`DatabaseLayerNebulaLoggerAdapter.cls`](https://github.com/jasonsiders/apex-database-layer/blob/main/plugins/nebula-logger/source/classes/DatabaseLayerNebulaLoggerAdapter.cls)
24+
> - [`DatabaseLayerNebulaLoggerAdapterTest.cls`](https://github.com/jasonsiders/apex-database-layer/blob/main/plugins/nebula-logger/source/classes/DatabaseLayerNebulaLoggerAdapterTest.cls))
2425
2526
### Setup
2627
27-
Once installed, navigate to `Setup > Custom Metadata > Database Layer Settings`. If a record already exists, use that record. Else, create a new record, called "Default".
28+
Once installed, navigate to `Setup > Custom Metadata > Database Layer Settings`. If a record already exists, use that record. Else, create a new record, called "Default".
2829
2930
Set the Custom Metadata record's _DML: Pre & Post Processor_ and _SOQL: Pre & Post Processor_ fields to be the name of the Apex class: `DatabaseLayerNebulaLoggerAdapter`:
3031
3132
<img width="1178" alt="image" src="https://github.com/user-attachments/assets/db8a5ed1-453f-4c91-bf88-d4c911579669" />
3233
33-
**Note:** Once configured, this custom metadata record won't be altered by upgrading the _Apex Database Layer_ package, or the plugin package itself.
34+
**Note:** Once configured, this custom metadata record won't be altered by upgrading the _Apex Database Layer_ package, or the plugin package itself.
3435
3536
---
3637
3738
## Usage
3839
39-
Whenever a DML or SOQL operation runs, the plugin will log the details of those operations to Nebula Logger. This results in log entries with the `apex-database-layer` _Log Entry Tag_.
40+
Whenever a DML or SOQL operation runs, the plugin will log the details of those operations to Nebula Logger. This results in log entries with the `apex-database-layer` _Log Entry Tag_.
4041
4142
### DML Logging
42-
Just before a DML operation is processed, the plugin will issue a `FINEST` log entry summarizing the action that's about to take place.
43+
44+
Just before a DML operation is processed, the plugin will issue a `FINEST` log entry summarizing the action that's about to take place.
45+
4346
- The [`Dml.Request`](https://github.com/jasonsiders/apex-database-layer/wiki/The-Dml.Request-Class) is serialized and shown in the message body
4447
- The records being operated on are shown in the `Related Records` tab
4548
4649
<img width="1400" alt="image" src="https://github.com/user-attachments/assets/d9e8cfd1-5f87-4a0e-ad60-abe0593902fe" />
4750
<img width="1400" alt="image" src="https://github.com/user-attachments/assets/04beeed6-ddbf-476e-84ee-2aaf3a029a9a" />
4851
49-
After a DML operation is processed, the plugin issues another `FINEST` log entry summarizing the action that took place.
52+
After a DML operation is processed, the plugin issues another `FINEST` log entry summarizing the action that took place.
5053
5154
- The [`Dml.Request`](https://github.com/jasonsiders/apex-database-layer/wiki/The-Dml.Request-Class) is serialized and shown in the message body
5255
- The records that were operated on are shown in the `Related Records` tab
@@ -75,7 +78,7 @@ After a SOQL operation is processed, the plugin issues another `FINEST` log entr
7578
7679
- The text of the query is available in the message body
7780
- The resulting SObject records are available in the `Related Records` tab
78-
- Note: Other query operations (ex., `getCursor`, `countQuery`) that do _not_ output SObjects will be printed in the message body instead
81+
- Note: Other query operations (ex., `getCursor`, `countQuery`) that do _not_ output SObjects will be printed in the message body instead
7982
8083
<img width="1431" alt="image" src="https://github.com/user-attachments/assets/51b574ed-ef2f-42ef-b97d-96f965290982" />
8184
<img width="1406" alt="image" src="https://github.com/user-attachments/assets/e37a5a19-72b0-4701-aa17-06c16343b034" />
@@ -90,6 +93,7 @@ If an exception is thrown during a SOQL operation, an `ERROR` log entry is issue
9093
### Considerations
9194
9295
#### `MockSoql`: Additional query logs for non-standard SOQL operations
93-
Many `MockSoql` query operations use the `query` method as the basis for building mock results. This may result in additional logs being issued.
9496
95-
For example, `MockSoql.getQueryLocator` calls `MockSoql.query` to generate the list of records to be returned, and then wraps the results in a `Soql.QueryLocator`. In this scenario, the plugin issues 4 `FINEST` logs: one before/after `MockSoql.getQueryLocator`, and one before/after `MockSoql.query`.
97+
Many `MockSoql` query operations use the `query` method as the basis for building mock results. This may result in additional logs being issued.
98+
99+
For example, `MockSoql.getQueryLocator` calls `MockSoql.query` to generate the list of records to be returned, and then wraps the results in a `Soql.QueryLocator`. In this scenario, the plugin issues 4 `FINEST` logs: one before/after `MockSoql.getQueryLocator`, and one before/after `MockSoql.query`.

plugins/nebula-logger/source/classes/DatabaseLayerNebulaLoggerAdapter.cls

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @description Adapter class that integrates Nebula Logger with the Apex Database Layer framework.
3-
* Implements both DML and SOQL pre/post processors to log database operations.
3+
* Implements both DML and SOQL pre/post processors to log database operations.
44
*/
55
@SuppressWarnings('PMD.AvoidGlobalModifier')
66
global class DatabaseLayerNebulaLoggerAdapter implements Dml.PreAndPostProcessor, Soql.PreAndPostProcessor {

source/classes/Cmdt.cls

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* @description Provides access to Custom Metadata Type records with caching and namespace support.
3+
* Supports both DeveloperName and qualified namespace__DeveloperName lookups.
4+
*/
5+
@SuppressWarnings('PMD.AvoidGlobalModifier')
6+
global inherited sharing class Cmdt {
7+
@TestVisible
8+
private static final String DEVELOPER_NAME = 'DeveloperName';
9+
private static final String NAMESPACE_PREFIX = 'NamespacePrefix';
10+
@TestVisible
11+
private static Set<String> objectsToCache;
12+
@TestVisible
13+
private static Map<String, Cmdt.Repository> repos;
14+
@TestVisible
15+
private static DatabaseLayer.SoqlProvider soqlProvider;
16+
17+
static {
18+
Cmdt.objectsToCache = Cmdt.getObjectsToCache();
19+
Cmdt.repos = new Map<String, Cmdt.Repository>{};
20+
}
21+
22+
/**
23+
* @description Creates a new Cmdt instance with the specified DatabaseLayer factory.
24+
* @param factory The DatabaseLayer factory to use for SOQL operations
25+
*/
26+
public Cmdt(DatabaseLayer factory) {
27+
Cmdt.soqlProvider = Cmdt.soqlProvider ?? new Cmdt.SoqlProvider(factory);
28+
}
29+
30+
/**
31+
* @description Gets or creates a Repository for the specified Custom Metadata Type.
32+
* @param objectType The SObjectType of the Custom Metadata Type
33+
* @return A Repository instance for accessing records of the specified type
34+
*/
35+
global Cmdt.Repository ofType(SObjectType objectType) {
36+
String objectApiName = objectType?.toString();
37+
Cmdt.Repository repo = Cmdt.repos?.get(objectApiName) ?? this.initRepository(objectType);
38+
Cmdt.repos?.put(objectApiName, repo);
39+
return repo;
40+
}
41+
42+
private Cmdt.Repository initRepository(SObjectType objectType) {
43+
// Maximize tradeoffs between heap size storage & query limits
44+
if (this.includesLongTextFields(objectType) == true) {
45+
// CMDT objects w/Long Text fields count towards query limits; query once and then cache
46+
return new CacheBasedRepository(objectType);
47+
} else {
48+
// All other CMDT objects are "free" to query; avoid cacheing to minimize heap usage
49+
return new QueryBasedRepository(objectType);
50+
}
51+
}
52+
53+
private Boolean includesLongTextFields(SObjectType objectType) {
54+
// Returns true if the SObjectType includes long-text fields
55+
String objectApiName = objectType?.toString();
56+
return Cmdt.objectsToCache?.contains(objectApiName) == true;
57+
}
58+
59+
// **** STATIC **** //
60+
/**
61+
* @description Map each custom metadata record by its QualifiedApiName (NamespacePrefix + DeveloperName)
62+
* @param recordList A list of custom metadata SObject records
63+
* @return A map of custom metadata SObject records by their QualifiedApiName
64+
*/
65+
public static Map<String, SObject> mapByQualifiedApiName(List<SObject> recordList) {
66+
Map<String, SObject> results = new Map<String, SObject>{};
67+
for (SObject record : recordList) {
68+
String namespace = (String) record?.get(NAMESPACE_PREFIX);
69+
String developerName = (String) record?.get(DEVELOPER_NAME);
70+
Set<String> components = new Set<String>{ namespace, developerName };
71+
components?.remove(null);
72+
String key = String.join(components, '__');
73+
results?.put(key, record);
74+
}
75+
return results;
76+
}
77+
78+
@TestVisible
79+
private static Set<String> extractEntityNames(List<EntityDefinition> entities) {
80+
Set<String> results = new Set<String>{};
81+
for (EntityDefinition entity : entities) {
82+
String objectApiName = entity?.QualifiedApiName;
83+
results?.add(objectApiName);
84+
}
85+
return results;
86+
}
87+
88+
private static Set<String> getObjectsToCache() {
89+
List<EntityDefinition> entities = Cmdt.queryEntityDefinitions();
90+
return Cmdt.extractEntityNames(entities);
91+
}
92+
93+
private static List<EntityDefinition> queryEntityDefinitions() {
94+
// This static query must **always** execute using real SOQL
95+
return [
96+
SELECT QualifiedApiName
97+
FROM EntityDefinition
98+
WHERE
99+
QualifiedApiName LIKE '%__mdt'
100+
AND DurableId IN (SELECT EntityDefinitionId FROM FieldDefinition WHERE DataType LIKE 'Long Text%')
101+
WITH SYSTEM_MODE
102+
];
103+
}
104+
105+
// **** INNER **** //
106+
/**
107+
* @description Repository implementation that caches query results to avoid repeated SOQL queries.
108+
* Use this for Custom Metadata Types that include long-text area fields, as queries for
109+
* these objects count towards SOQL governor limits.
110+
* https://help.salesforce.com/s/articleView?language=en_US&id=platform.custommetadatatypes_limits.htm&type=5
111+
*/
112+
global inherited sharing virtual class CacheBasedRepository extends Cmdt.QueryBasedRepository {
113+
protected transient Map<String, SObject> records;
114+
115+
protected CacheBasedRepository(SObjectType objectType) {
116+
super(objectType);
117+
}
118+
119+
global override Map<String, SObject> getAll() {
120+
this.retrieve();
121+
return this.records;
122+
}
123+
124+
global override SObject getInstance(String qualifiedApiName) {
125+
return this.getAll()?.get(qualifiedApiName);
126+
}
127+
128+
private void retrieve() {
129+
if (this.records == null) {
130+
List<SObject> recordList = this.queryAll();
131+
this.records = Cmdt.mapByQualifiedApiName(recordList);
132+
}
133+
}
134+
}
135+
136+
/**
137+
* @description Repository implementation that issues a new SOQL query each time.
138+
* Use this for most Custom Metadata Types; SOQL queries will not count towards governor limits,
139+
* unless the object contains a long-text area field
140+
* https://help.salesforce.com/s/articleView?language=en_US&id=platform.custommetadatatypes_limits.htm&type=5
141+
*/
142+
global inherited sharing virtual class QueryBasedRepository implements Cmdt.Repository {
143+
protected transient SObjectType objectType;
144+
145+
protected QueryBasedRepository(SObjectType objectType) {
146+
this.objectType = objectType;
147+
}
148+
149+
/**
150+
* @description Retrieves all Custom Metadata Type records as a map keyed by DeveloperName.
151+
* @return Map of DeveloperName to SObject record
152+
*/
153+
global virtual Map<String, SObject> getAll() {
154+
List<SObject> records = this.queryAll();
155+
return Cmdt.mapByQualifiedApiName(records);
156+
}
157+
158+
/**
159+
* @description Retrieves a specific Custom Metadata Type record by its DeveloperName.
160+
* @param qualifiedApiName The DeveloperName of the record to retrieve, including Namespace (if applicable)
161+
* @return The SObject record, or null if not found
162+
*/
163+
global virtual SObject getInstance(String qualifiedApiName) {
164+
return this.initQuery()?.addWhere(DEVELOPER_NAME, Soql.EQUALS, qualifiedApiName)?.toSoql()?.queryFirst();
165+
}
166+
167+
private Soql initQuery() {
168+
// Note: CMDT SOQL queries don't count against SOQL query limits (!)
169+
return Cmdt.soqlProvider.newQuery(this.objectType)?.selectAll()?.toSoql();
170+
}
171+
172+
private List<SObject> queryAll() {
173+
return this.initQuery()?.query();
174+
}
175+
}
176+
177+
/**
178+
* @description Interface for accessing Custom Metadata Type records.
179+
* Provides methods for retrieving all records or specific instances by developer name.
180+
*/
181+
global interface Repository {
182+
/**
183+
* @description Retrieves all Custom Metadata Type records as a map keyed by DeveloperName.
184+
* @return Map of DeveloperName to SObject record
185+
*/
186+
Map<String, SObject> getAll();
187+
/**
188+
* @description Retrieves a specific Custom Metadata Type record by its DeveloperName.
189+
* @param qualifiedApiName The DeveloperName of the record to retrieve, including Namespace (if applicable)
190+
* @return The SObject record, or null if not found
191+
*/
192+
SObject getInstance(String qualifiedApiName);
193+
}
194+
195+
/**
196+
* @description Custom SoqlProvider that will always return a real (not mock) Soql object
197+
* This enables CMDT operations to use real Soql, even when MockSoql is used elsewhere
198+
*/
199+
private class SoqlProvider extends DatabaseLayer.SoqlProvider {
200+
private DatabaseLayer factory;
201+
202+
private SoqlProvider(DatabaseLayer factory) {
203+
super(factory);
204+
this.useMocks = false;
205+
}
206+
}
207+
}

source/classes/Cmdt.cls-meta.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>64.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>

0 commit comments

Comments
 (0)