From ccc8debc6470194270a1232a896194e136ddeb7e Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Sun, 21 Dec 2025 10:18:54 -0400 Subject: [PATCH 1/3] Improve context resolution and add integration tests for query expressions --- .idea/formula-evaluator.iml | 1 + .../main/src/interpreter/ContextResolver.cls | 9 +++-- .../main/src/resolver/EvaluatorResolver.cls | 7 +++- package-lock.json | 19 +++++----- package.json | 2 +- sfdx-project.json | 4 +++ .../classes/IntegrationTest.cls | 35 +++++++++++++++++++ .../classes/IntegrationTest.cls-meta.xml | 5 +++ 8 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 unpackaged/examples/integration-tests/classes/IntegrationTest.cls create mode 100644 unpackaged/examples/integration-tests/classes/IntegrationTest.cls-meta.xml diff --git a/.idea/formula-evaluator.iml b/.idea/formula-evaluator.iml index cefdb28b..a6df9590 100644 --- a/.idea/formula-evaluator.iml +++ b/.idea/formula-evaluator.iml @@ -18,6 +18,7 @@ + diff --git a/expression-src/main/src/interpreter/ContextResolver.cls b/expression-src/main/src/interpreter/ContextResolver.cls index 59ac79be..23fb88f2 100644 --- a/expression-src/main/src/interpreter/ContextResolver.cls +++ b/expression-src/main/src/interpreter/ContextResolver.cls @@ -272,7 +272,12 @@ public with sharing class ContextResolver implements Visitor { // References to @Context fields will always go to the top level query // as they are part of the global contextual context tied to the record Id // from which the Evaluation was started. - addFieldToQuery(this.topLevelQuery, referenceName.toLowerCase()); + + // TODO Add UT: + // @Context.Contacts + // -> WHERE(FirstName = "Georgina") + + return addFieldToQuery(this.topLevelQuery, referenceName.toLowerCase()); } else { this.queryContext.queryBuilder.selectField(referenceName); } @@ -320,7 +325,7 @@ public with sharing class ContextResolver implements Visitor { // If context is being accessed, then we always want to run the query. this.shouldExecuteQuery = true; - // recordId migh be null when this is being run from within a subquery. + // recordId might be null when this is being run from within a subquery. // If so, return early. if (this.queryContext.recordId == null) { return null; diff --git a/expression-src/main/src/resolver/EvaluatorResolver.cls b/expression-src/main/src/resolver/EvaluatorResolver.cls index 301adbe3..889eb89a 100644 --- a/expression-src/main/src/resolver/EvaluatorResolver.cls +++ b/expression-src/main/src/resolver/EvaluatorResolver.cls @@ -270,7 +270,12 @@ public with sharing abstract class EvaluatorResolver { ContextResolver ctxInterpreter = new ContextResolver(contextsForType, contextPrefix, customFunctionDeclarations); List queriedRecords = ctxInterpreter.build(expressions); if (queriedRecords == null || queriedRecords.isEmpty()) { - continue; + queriedRecords = new List(); + for (Id recordId : contextByRecordId.keySet()) { + SObject emptyRecord = currentType.newSObject(); + emptyRecord.Id = recordId; + queriedRecords.add(emptyRecord); + } } Map recordById = new Map(queriedRecords); for (Id recordId : recordById.keySet()) { diff --git a/package-lock.json b/package-lock.json index 6a82e345..192ff961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "license": "MIT", "devDependencies": { "@cparra/apex-reflection": "^2.4.1", - "@cparra/apexdocs": "3.16.1", + "@cparra/apexdocs": "^3.17.0-beta.7", "@tailwindcss/forms": "^0.5.6", "@types/node": "^20.10.2", "js-yaml": "^4.1.0", @@ -55,20 +55,20 @@ } }, "node_modules/@cparra/apex-reflection": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@cparra/apex-reflection/-/apex-reflection-2.21.1.tgz", - "integrity": "sha512-ciNzZnAK0ikruEn1Wj8+e0cA6K5ShFx91AwduzW2nYK6cXqs8Qr3QUAPk82WKA8437FNqKLDd7v2TJMSSvzayg==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@cparra/apex-reflection/-/apex-reflection-2.22.0.tgz", + "integrity": "sha512-c/TNhzzAmMDuzBoA1o26aaDRWnOVP8mDg5BgmjaBXbhrbQj0rInDUqeGDiM/1yU1/DdA+7Ph/dXKBFrkYbYuVQ==", "dev": true, "license": "ISC" }, "node_modules/@cparra/apexdocs": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@cparra/apexdocs/-/apexdocs-3.16.1.tgz", - "integrity": "sha512-PmwS8gvZ6+cvtLSk6pCPn3pkZRvrpyUYwaG/7k33MMTxLiHl2n3i9LD0GQ8C2v+LhBkxX8Oh2HDYOcLzEH3+ow==", + "version": "3.17.0-beta.7", + "resolved": "https://registry.npmjs.org/@cparra/apexdocs/-/apexdocs-3.17.0-beta.7.tgz", + "integrity": "sha512-qT9G8I4Ealfp0N0IgmHMEWzpyjcLyY1CQWSVs4xl81uJ0VW6YgE7Hoor25IuD3W/Suudt0hr26FEIylPmJ3nSQ==", "dev": true, "license": "MIT", "dependencies": { - "@cparra/apex-reflection": "2.21.1", + "@cparra/apex-reflection": "2.22.0", "@salesforce/source-deploy-retrieve": "^12.20.1", "@types/js-yaml": "^4.0.9", "@types/yargs": "^17.0.32", @@ -84,6 +84,9 @@ }, "bin": { "apexdocs": "dist/cli/generate.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@cparra/apexdocs/node_modules/minimatch": { diff --git a/package.json b/package.json index 7c790d4c..9ec8a176 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "homepage": "https://github.com/cesarParra/formula-evaluator#readme", "devDependencies": { "@cparra/apex-reflection": "^2.4.1", - "@cparra/apexdocs": "3.16.1", + "@cparra/apexdocs": "^3.17.0-beta.7", "@tailwindcss/forms": "^0.5.6", "@types/node": "^20.10.2", "js-yaml": "^4.1.0", diff --git a/sfdx-project.json b/sfdx-project.json index b881b7d9..ec7d677b 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -11,6 +11,10 @@ { "path": "src-pull", "default": true + }, + { + "path": "unpackaged", + "default": false } ], "name": "Expression", diff --git a/unpackaged/examples/integration-tests/classes/IntegrationTest.cls b/unpackaged/examples/integration-tests/classes/IntegrationTest.cls new file mode 100644 index 00000000..02fda854 --- /dev/null +++ b/unpackaged/examples/integration-tests/classes/IntegrationTest.cls @@ -0,0 +1,35 @@ +@IsTest +private class IntegrationTest { + @IsTest + static void hasPurchasedSomethingInThePast() { + List contexts = getContexts(); + + String pipedExpression = '@Customer.Assets -> WHERE(Product2Id = @Product.Id && Status = "Purchased") -> SIZE() > 0'; + Boolean pipedResult = (Boolean)Evaluator.run(pipedExpression, contexts, new Configuration()); + Assert.isTrue(pipedResult, 'The customer should have purchased the product in the past.'); + + String nestedExpression = 'SIZE(WHERE(@Customer.Assets, Product2Id = @Product.Id && Status = "Purchased")) > 0'; + Boolean nestedResult = (Boolean)Evaluator.run(nestedExpression, contexts, new Configuration()); + Assert.isTrue(nestedResult, 'The customer should have purchased the product in the past.'); + } + + private static List getContexts() { + Account someAccount = new Account(Name = 'Test Account'); + insert someAccount; + Contact someone = new Contact(FirstName = 'Test', LastName = 'User', AccountId = someAccount.Id); + insert someone; + Product2 sampleProduct = new Product2(Name = 'Test Product', IsActive = true); + insert sampleProduct; + + Asset sampleAsset = new Asset( + Name = 'Test Asset', + ContactId = someone.Id, Status = 'Purchased', + Product2Id = sampleProduct.Id); + insert sampleAsset; + + CustomRecordContext customer = new CustomRecordContext('Customer', someone.Id); + CustomRecordContext product = new CustomRecordContext('Product', sampleProduct.Id); + List contexts = new List { customer, product }; + return contexts; + } +} diff --git a/unpackaged/examples/integration-tests/classes/IntegrationTest.cls-meta.xml b/unpackaged/examples/integration-tests/classes/IntegrationTest.cls-meta.xml new file mode 100644 index 00000000..82775b98 --- /dev/null +++ b/unpackaged/examples/integration-tests/classes/IntegrationTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 65.0 + Active + From 51162c0df8d0ea0e98c05ef3e508596bf23ae0e4 Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Sun, 21 Dec 2025 10:43:04 -0400 Subject: [PATCH 2/3] Update apexdocs dependency to stable version 3.17.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 192ff961..d24b89cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "license": "MIT", "devDependencies": { "@cparra/apex-reflection": "^2.4.1", - "@cparra/apexdocs": "^3.17.0-beta.7", + "@cparra/apexdocs": "^3.17.0", "@tailwindcss/forms": "^0.5.6", "@types/node": "^20.10.2", "js-yaml": "^4.1.0", @@ -62,9 +62,9 @@ "license": "ISC" }, "node_modules/@cparra/apexdocs": { - "version": "3.17.0-beta.7", - "resolved": "https://registry.npmjs.org/@cparra/apexdocs/-/apexdocs-3.17.0-beta.7.tgz", - "integrity": "sha512-qT9G8I4Ealfp0N0IgmHMEWzpyjcLyY1CQWSVs4xl81uJ0VW6YgE7Hoor25IuD3W/Suudt0hr26FEIylPmJ3nSQ==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@cparra/apexdocs/-/apexdocs-3.17.0.tgz", + "integrity": "sha512-ONSpQADzBj7gjC62tshhVJ9DA3Uk2dCQF98B1vNqkxXRk//Mu/LEoPmuZZKxD1IwC34TRksQ2L9XVW4sFhfG3g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9ec8a176..0052db2d 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "homepage": "https://github.com/cesarParra/formula-evaluator#readme", "devDependencies": { "@cparra/apex-reflection": "^2.4.1", - "@cparra/apexdocs": "^3.17.0-beta.7", + "@cparra/apexdocs": "^3.17.0", "@tailwindcss/forms": "^0.5.6", "@types/node": "^20.10.2", "js-yaml": "^4.1.0", From 5162b48e4d1d34d422b5ebb496fc4555ab57dfcc Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Mon, 22 Dec 2025 08:32:26 -0400 Subject: [PATCH 3/3] Refactor context resolution logic and add integration tests for child relationships --- docs/public/packages.json | 2 +- .../main/src/interpreter/ContextResolver.cls | 5 ----- sfdx-project.json | 2 +- sfdx-project_packaging.json | 5 +++-- .../classes/IntegrationTest.cls | 17 +++++++++++++++++ .../classes/IntegrationTest.cls-meta.xml | 0 6 files changed, 22 insertions(+), 9 deletions(-) rename unpackaged/{examples => }/integration-tests/classes/IntegrationTest.cls (62%) rename unpackaged/{examples => }/integration-tests/classes/IntegrationTest.cls-meta.xml (100%) diff --git a/docs/public/packages.json b/docs/public/packages.json index 8d11d14f..9d058b4b 100644 --- a/docs/public/packages.json +++ b/docs/public/packages.json @@ -1,4 +1,4 @@ { - "packageId": "04tRb0000044fYYIA", + "packageId": "04tRb0000045WPxIAM", "componentPackageId": "04tRb0000012Mv8IAE" } diff --git a/expression-src/main/src/interpreter/ContextResolver.cls b/expression-src/main/src/interpreter/ContextResolver.cls index 23fb88f2..08336b64 100644 --- a/expression-src/main/src/interpreter/ContextResolver.cls +++ b/expression-src/main/src/interpreter/ContextResolver.cls @@ -272,11 +272,6 @@ public with sharing class ContextResolver implements Visitor { // References to @Context fields will always go to the top level query // as they are part of the global contextual context tied to the record Id // from which the Evaluation was started. - - // TODO Add UT: - // @Context.Contacts - // -> WHERE(FirstName = "Georgina") - return addFieldToQuery(this.topLevelQuery, referenceName.toLowerCase()); } else { this.queryContext.queryBuilder.selectField(referenceName); diff --git a/sfdx-project.json b/sfdx-project.json index ec7d677b..69f02f99 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -13,7 +13,7 @@ "default": true }, { - "path": "unpackaged", + "path": "unpackaged/integration-tests", "default": false } ], diff --git a/sfdx-project_packaging.json b/sfdx-project_packaging.json index 0e5dca50..ff4227e3 100644 --- a/sfdx-project_packaging.json +++ b/sfdx-project_packaging.json @@ -3,7 +3,7 @@ { "package": "Expression", "versionName": "Version 1.36", - "versionNumber": "1.47.0.NEXT", + "versionNumber": "1.48.0.NEXT", "path": "expression-src", "default": false, "versionDescription": "Expression core language", @@ -76,6 +76,7 @@ "Expression@1.44.0-1": "04tRb000003z0hJIAQ", "Expression@1.45.0-1": "04tRb0000042CNlIAM", "Expression@1.46.0-1": "04tRb000004400jIAA", - "Expression@1.47.0-1": "04tRb0000044fYYIAY" + "Expression@1.47.0-1": "04tRb0000044fYYIAY", + "Expression@1.48.0-1": "04tRb0000045WPxIAM" } } \ No newline at end of file diff --git a/unpackaged/examples/integration-tests/classes/IntegrationTest.cls b/unpackaged/integration-tests/classes/IntegrationTest.cls similarity index 62% rename from unpackaged/examples/integration-tests/classes/IntegrationTest.cls rename to unpackaged/integration-tests/classes/IntegrationTest.cls index 02fda854..023522e9 100644 --- a/unpackaged/examples/integration-tests/classes/IntegrationTest.cls +++ b/unpackaged/integration-tests/classes/IntegrationTest.cls @@ -1,5 +1,22 @@ @IsTest private class IntegrationTest { + @IsTest + static void usingChildRelationships() { + Account anyAccount = new Account(Name = 'Sample Account'); + insert anyAccount; + Contact firstContact = new Contact(FirstName = 'First', LastName = 'Contact', AccountId = anyAccount.Id); + Contact secondContact = new Contact(FirstName = 'Second', LastName = 'Contact', AccountId = anyAccount.Id); + insert new List { firstContact, secondContact }; + + String expressionPiped = '@Context.Contacts -> WHERE(FirstName = "First") -> SIZE() = 1'; + Boolean pipedResult = (Boolean)Evaluator.run(expressionPiped, anyAccount.Id); + Assert.isTrue(pipedResult, 'There should be exactly one contact with the first name "First".'); + + String expressionNested = 'SIZE(WHERE(@Context.Contacts, FirstName = "First")) = 1'; + Boolean nestedResult = (Boolean)Evaluator.run(expressionNested, anyAccount.Id); + Assert.isTrue(nestedResult, 'There should be exactly one contact with the first name "First".'); + } + @IsTest static void hasPurchasedSomethingInThePast() { List contexts = getContexts(); diff --git a/unpackaged/examples/integration-tests/classes/IntegrationTest.cls-meta.xml b/unpackaged/integration-tests/classes/IntegrationTest.cls-meta.xml similarity index 100% rename from unpackaged/examples/integration-tests/classes/IntegrationTest.cls-meta.xml rename to unpackaged/integration-tests/classes/IntegrationTest.cls-meta.xml