Skip to content

Commit 913098e

Browse files
authored
fix: enhance context variable caching by capturing a snapshot of global context variables in Environment. (#240)
1 parent 0552c47 commit 913098e

3 files changed

Lines changed: 98 additions & 14 deletions

File tree

expression-src/main/src/interpreter/Environment.cls

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ public with sharing class Environment {
2626
*/
2727
private static final Map<String, Object> GLOBAL_CONTEXT_VARIABLES = new Map<String, Object>();
2828

29+
/**
30+
* @description A snapshot of the global context variables (e.g. custom `@` variables) as they
31+
* were when this Environment was constructed. Context variables live in the static
32+
* GLOBAL_CONTEXT_VARIABLES map, which is reset after each evaluation, so they are not part of
33+
* the instance state otherwise. Capturing a copy here makes them part of the Environment's
34+
* identity (see equals/hashCode), so result caches keyed by Environment do not incorrectly
35+
* reuse results across evaluations that differ only by their context variable values.
36+
*/
37+
private final Map<String, Object> contextSnapshot = new Map<String, Object>(GLOBAL_CONTEXT_VARIABLES);
38+
2939
private static Map<String, Object> getAllGlobalVariables() {
3040
Map<String, Object> allGlobalVariables = new Map<String, Object>();
3141
allGlobalVariables.putAll(GLOBAL_VARIABLES);
@@ -190,11 +200,17 @@ public with sharing class Environment {
190200

191201
public Boolean equals(Object obj) {
192202
Environment other = (Environment)obj;
193-
return parentEnvironment == other.parentEnvironment && context == other.context && variables.equals(other.variables);
203+
return parentEnvironment == other.parentEnvironment &&
204+
context == other.context &&
205+
variables.equals(other.variables) &&
206+
contextSnapshot.equals(other.contextSnapshot);
194207
}
195208

196209
public override Integer hashCode() {
197-
return (parentEnvironment != null ? parentEnvironment.hashCode() : 0) ^ (context != null ? context.hashCode() : 0) ^ variables.hashCode();
210+
return (parentEnvironment != null ? parentEnvironment.hashCode() : 0) ^
211+
(context != null ? context.hashCode() : 0) ^
212+
variables.hashCode() ^
213+
contextSnapshot.hashCode();
198214
}
199215

200216
public class ClearContext implements EvaluatorEventListener {

expression-src/main/src/interpreter/tests/EvaluatorCustomContextCacheTest.cls

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,72 @@ private class EvaluatorCustomContextCacheTest {
3232
);
3333
}
3434

35+
@IsTest
36+
static void standardFunctionCacheEnabled_respectsChangingCustomContextValues() {
37+
Configuration config1 = new Configuration();
38+
config1.cacheStandardFunctionResults = true;
39+
config1.customContext = new Map<String, Object>{
40+
'value' => 'first'
41+
};
42+
43+
Configuration config2 = new Configuration();
44+
config2.cacheStandardFunctionResults = true;
45+
config2.customContext = new Map<String, Object>{
46+
'value' => 'second'
47+
};
48+
49+
Test.startTest();
50+
Object firstResult = Evaluator.run('UPPER(@value)', config1);
51+
Object secondResult = Evaluator.run('UPPER(@value)', config2);
52+
Test.stopTest();
53+
54+
System.assertEquals('FIRST', firstResult, 'First evaluation must use the first customContext.');
55+
System.assertEquals(
56+
'SECOND',
57+
secondResult,
58+
'Second evaluation must respect the updated context variable even when caching is enabled.'
59+
);
60+
}
61+
62+
@IsTest
63+
static void queryCacheRespectsChangingCustomContextValues() {
64+
// Two records that the same query can match depending on the @name context variable.
65+
Account first = new Account(Name = 'CtxCacheFirst');
66+
Account second = new Account(Name = 'CtxCacheSecond');
67+
insert new List<Account>{ first, second };
68+
69+
Configuration config1 = new Configuration();
70+
config1.cacheStandardFunctionResults = true;
71+
config1.customContext = new Map<String, Object>{
72+
'name' => 'CtxCacheFirst'
73+
};
74+
75+
Configuration config2 = new Configuration();
76+
config2.cacheStandardFunctionResults = true;
77+
config2.customContext = new Map<String, Object>{
78+
'name' => 'CtxCacheSecond'
79+
};
80+
81+
String expr = 'QUERY(Account(where: Name = @name) [Id, Name])';
82+
83+
Test.startTest();
84+
List<Account> firstResult = (List<Account>) Evaluator.run(expr, config1);
85+
List<Account> secondResult = (List<Account>) Evaluator.run(expr, config2);
86+
Test.stopTest();
87+
88+
System.assertEquals(1, firstResult.size(), 'First query should match exactly one Account.');
89+
System.assertEquals(
90+
'CtxCacheFirst',
91+
firstResult[0].Name,
92+
'First query must match the Account named by the first customContext.'
93+
);
94+
95+
System.assertEquals(1, secondResult.size(), 'Second query should match exactly one Account.');
96+
System.assertEquals(
97+
'CtxCacheSecond',
98+
secondResult[0].Name,
99+
'Second query must respect the updated context variable, not return the cached result from the first run.'
100+
);
101+
}
102+
35103
}

expression-src/main/src/resolver/EvaluatorResolver.cls

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,12 @@ public with sharing abstract class EvaluatorResolver {
206206
if (records != null && records.size() > 0) {
207207
record = records[0];
208208
}
209-
Environment env = new Environment(record);
210-
209+
// Register the global context variables before constructing the Environment so that
210+
// the Environment's context snapshot (used as part of its cache-key identity) is complete.
211211
Environment.addGlobalContextVariable(contextPrefix, 'id', recordId);
212212
Environment.addGlobalContextVariable(contextPrefix, 'context', record);
213213

214-
return env;
214+
return new Environment(record);
215215
}
216216
}
217217

@@ -230,15 +230,15 @@ public with sharing abstract class EvaluatorResolver {
230230
}
231231
ContextResolver ctxInterpreter = new ContextResolver(recordContexts, contextPrefix, customFunctionDeclarations);
232232
List<SObject> records = ctxInterpreter.build(expressions);
233-
// The environment is created without a record, as we are dealing with a list of records,
234-
// so it would not be possible to interpret references to record fields. Instead, the @context
235-
// variable should be used to access the list of records.
236-
Environment env = new Environment();
237-
233+
// Register the global context variables before constructing the Environment so that
234+
// the Environment's context snapshot (used as part of its cache-key identity) is complete.
238235
Environment.addGlobalContextVariable(contextPrefix, 'ids', recordIds);
239236
Environment.addGlobalContextVariable(contextPrefix, 'context', records);
240237

241-
return env;
238+
// The environment is created without a record, as we are dealing with a list of records,
239+
// so it would not be possible to interpret references to record fields. Instead, the @context
240+
// variable should be used to access the list of records.
241+
return new Environment();
242242
}
243243
}
244244

@@ -251,8 +251,6 @@ public with sharing abstract class EvaluatorResolver {
251251

252252
public override Environment prepareEnvironment(List<Expr> expressions, List<Expr.FunctionDeclaration> customFunctionDeclarations,
253253
String contextPrefix) {
254-
Environment env = new Environment();
255-
256254
Map<SObjectType, List<CustomRecordContext>> contextsBySObjectTypes = new Map<SObjectType, List<CustomRecordContext>>();
257255
for (CustomRecordContext context : this.contexts) {
258256
SObjectType sObjectType = context.recordId.getSobjectType();
@@ -288,7 +286,9 @@ public with sharing abstract class EvaluatorResolver {
288286
}
289287
}
290288

291-
return env;
289+
// Construct the Environment after all global context variables have been registered so
290+
// that its context snapshot (used as part of its cache-key identity) is complete.
291+
return new Environment();
292292
}
293293
}
294294

0 commit comments

Comments
 (0)