├── LICENSE ├── README.md ├── classes ├── MergeValues.cls ├── MergeValues.cls-meta.xml ├── MergeValuesTest.cls ├── MergeValuesTest.cls-meta.xml ├── Template.cls ├── Template.cls-meta.xml ├── TemplateTest.cls └── TemplateTest.cls-meta.xml └── package.xml /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Andrzej Chodor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | salesforce-apex-templates 2 | ========================= 3 | 4 | APEX Templates provide a simple template engine, similar to the standard Salesforce mail merge one. Its aim is to generate messages and emails directly from APEX, for provided SObjects or maps of values. 5 | 6 | Basic usage 7 | ----------- 8 | 9 | The below snippet demonstrates the most basic usage of APEX Templates: 10 | 11 | ```javascript 12 | Case someCase = new Case( 13 | Subject = 'Test Case' 14 | ); 15 | 16 | // The below will return 'A message for Test Case.' 17 | new Template( 18 | 'A message for {!Case.Subject}.' 19 | ).evaluate(someCase); 20 | ``` 21 | -------------------------------------------------------------------------------- /classes/MergeValues.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Andrzej Chodor 3 | */ 4 | public with sharing class MergeValues { 5 | private static Map globalDescribe; 6 | 7 | private static Map> fieldDescribes; 8 | 9 | private Map values = new Map(); 10 | 11 | private Map> registeredFields = new Map>(); 12 | 13 | public MergeValues() { 14 | } 15 | 16 | public MergeValues(Map values) { 17 | this(); 18 | putAll(values); 19 | } 20 | 21 | public void put(String key, Object o) { 22 | values.put(key, o); 23 | } 24 | 25 | public void put(SObject o) { 26 | String objectName = o.getSObjectType().getDescribe().getName(); 27 | values.put(objectName, o); 28 | } 29 | 30 | public void putSObject(String sobjectTypeName, Id sobjectId) { 31 | if(!registeredFields.containsKey(sobjectTypeName)) { 32 | return; 33 | } 34 | 35 | String query = 36 | 'SELECT ' + String.join(new List(registeredFields.get(sobjectTypeName)), ',') 37 | + ' FROM ' + sobjectTypeName 38 | + ' WHERE id = :sobjectId'; 39 | SObject[] results = Database.query(query); 40 | if(!results.isEmpty()) { 41 | values.put(sobjectTypeName, results[0]); 42 | } 43 | } 44 | 45 | public void putAll(Map topLevelValues) { 46 | values.putAll(topLevelValues); 47 | } 48 | 49 | public Object get(String path) { 50 | String[] atoms = path.split('\\s*\\.\\s*'); 51 | Object currentValue = values; 52 | for(Integer i = 0, size = atoms.size(); i < size; i++) { 53 | currentValue = getProperty(currentValue, atoms[i], i < size - 1); 54 | } 55 | return currentValue; 56 | } 57 | 58 | public void registerFieldSecurely(String path) { 59 | if(globalDescribe == null) { 60 | globalDescribe = Schema.getGlobalDescribe(); 61 | fieldDescribes = new Map>(); 62 | } 63 | 64 | String[] atoms = path.split('\\s*\\.\\s*'); 65 | if(atoms.size() != 2) { 66 | return; 67 | } 68 | 69 | if(!registeredFields.containsKey(atoms[0])) { 70 | Schema.SObjectType objectType = globalDescribe.get(atoms[0]); 71 | if(objectType == null || !objectType.getDescribe().isAccessible()) { 72 | return; 73 | } 74 | 75 | registeredFields.put(atoms[0], new Set{'Id'}); 76 | fieldDescribes.put(atoms[0], objectType.getDescribe().fields.getMap()); 77 | } 78 | 79 | Set objectRegisteredFields = registeredFields.get(atoms[0]); 80 | if(!objectRegisteredFields.contains(atoms[1])) { 81 | Schema.SObjectField field = fieldDescribes.get(atoms[0]).get(atoms[1]); 82 | if(field != null && field.getDescribe().isAccessible()) { 83 | objectRegisteredFields.add(atoms[1]); 84 | } 85 | } 86 | } 87 | 88 | private static Object getProperty(Object o, String property, Boolean isInnerProperty) { 89 | if(o == null) { 90 | return null; 91 | } 92 | 93 | if(o instanceof Map) { 94 | return ((Map)o).get(property); 95 | } 96 | 97 | if(o instanceof SObject) { 98 | if(isInnerProperty) { 99 | return ((SObject)o).getSObject(property); 100 | } 101 | return ((SObject)o).get(property); 102 | } 103 | 104 | return null; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /classes/MergeValues.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /classes/MergeValuesTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | class MergeValuesTest { 3 | static testmethod void testBasicUse() { 4 | Map values = new Map { 5 | 'someString' => 'someValue', 6 | 'someMap' => new Map { 7 | 'someChild' => 'someChildValue', 8 | 'someNumber' => 1, 9 | 'someCase' => new Case(subject = 'Test Case') 10 | } 11 | }; 12 | 13 | MergeValues bag = new MergeValues(values); 14 | 15 | System.assertEquals('someValue', bag.get('someString')); 16 | System.assertEquals('someChildValue', bag.get('someMap.someChild')); 17 | System.assertEquals(1, bag.get('someMap.someNumber')); 18 | System.assertEquals('Test Case', bag.get('someMap.someCase.subject')); 19 | } 20 | 21 | static testmethod void testUnretrievedField() { 22 | // LastName is not retrieved. 23 | User someUser = [ 24 | SELECT Id, FirstName FROM User WHERE Id = :UserInfo.getUserId() 25 | ]; 26 | 27 | MergeValues bag = new MergeValues(); 28 | bag.put(someUser); 29 | 30 | System.assertEquals(UserInfo.getFirstName(), bag.get('User.FirstName')); 31 | 32 | Boolean exceptionThrown = false; 33 | try { 34 | bag.get('User.LastName'); 35 | } catch(SObjectException e) { 36 | exceptionThrown = true; 37 | } 38 | System.assert(exceptionThrown, 'Field User.LastName was not retrieved, thus an exception was expected.'); 39 | } 40 | 41 | static testmethod void testQueryingSObjects() { 42 | MergeValues bag = new MergeValues(); 43 | bag.registerFieldSecurely('User.FirstName'); 44 | bag.registerFieldSecurely('User.LastName'); 45 | bag.putSObject('User', UserInfo.getUserId()); 46 | 47 | System.assertEquals(UserInfo.getFirstName(), bag.get('User.FirstName')); 48 | System.assertEquals(UserInfo.getLastName(), bag.get('User.LastName')); 49 | } 50 | } -------------------------------------------------------------------------------- /classes/MergeValuesTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /classes/Template.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Andrzej Chodor 3 | */ 4 | public class Template { 5 | private final Pattern MERGE_FIELD_PATTERN = Pattern.compile('\\{!([\\w\\.]+)\\}'); 6 | 7 | public final String content; 8 | 9 | private Object[] lexems; 10 | 11 | public Template(String content) { 12 | this.content = content; 13 | } 14 | 15 | public static Template fromEmailTemplate(String developerName) { 16 | EmailTemplate[] emailTpls = [ 17 | SELECT Body, HtmlValue, TemplateType 18 | FROM EmailTemplate 19 | WHERE developerName = :developerName 20 | ]; 21 | if(emailTpls.isEmpty()) { 22 | throw new TemplateNotFoundException('Template with Unique Name "' + developerName + '" was not found.'); 23 | } 24 | if(emailTpls[0].TemplateType == 'html' || emailTpls[0].TemplateType == 'custom') { 25 | return new Template(emailTpls[0].HtmlValue); 26 | } 27 | return new Template(emailTpls[0].Body); 28 | } 29 | 30 | public String evaluate(MergeValues values) { 31 | compile(); 32 | 33 | String buffer = ''; 34 | for(Object lexem : lexems) { 35 | Object value = evaluate(lexem, values); 36 | buffer += format(value); 37 | } 38 | return buffer; 39 | } 40 | 41 | public String evaluate(SObject sobjectValue) { 42 | MergeValues values = new MergeValues(); 43 | values.put(sobjectValue); 44 | return evaluate(values); 45 | } 46 | 47 | public String evaluate(Map values) { 48 | return evaluate(new MergeValues(values)); 49 | } 50 | 51 | public void registerFieldsSecurely(MergeValues values) { 52 | compile(); 53 | 54 | for(Object lexem : lexems) { 55 | if(lexem instanceof Gap) { 56 | values.registerFieldSecurely(((Gap)lexem).key); 57 | } 58 | } 59 | } 60 | 61 | private Boolean isCompiled() { 62 | return lexems != null; 63 | } 64 | 65 | private void compile() { 66 | if(isCompiled()) { 67 | return; 68 | } 69 | 70 | lexems = new List(); 71 | 72 | Matcher contentMatcher = MERGE_FIELD_PATTERN.matcher(content); 73 | Integer processedEnd = 0; 74 | while(contentMatcher.find()) { 75 | if(processedEnd < contentMatcher.start()) { 76 | lexems.add(content.substring(processedEnd, contentMatcher.start())); 77 | } 78 | 79 | Gap gapLexem = new Gap(contentMatcher.group(1)); 80 | lexems.add(gapLexem); 81 | 82 | processedEnd = contentMatcher.end(); 83 | } 84 | 85 | if(processedEnd < content.length()) { 86 | lexems.add(content.substring(processedEnd)); 87 | } 88 | } 89 | 90 | private static Object evaluate(Object lexem, MergeValues values) { 91 | if(lexem instanceof String) { 92 | return lexem; 93 | } 94 | 95 | if(lexem instanceof Gap) { 96 | String key = ((Gap)lexem).key; 97 | try { 98 | return values.get(key); 99 | } catch(SObjectException e) { 100 | return null; 101 | } 102 | } 103 | 104 | return null; 105 | } 106 | 107 | private static String format(Object value) { 108 | if(value == null) { 109 | return ''; 110 | } else if(value instanceof String) { 111 | return (String)value; 112 | } 113 | 114 | return String.valueOf(value); 115 | } 116 | 117 | private class Gap { 118 | public final String key; 119 | 120 | Gap(String key) { 121 | this.key = key; 122 | } 123 | } 124 | 125 | public class TemplateNotFoundException extends Exception { 126 | } 127 | } -------------------------------------------------------------------------------- /classes/Template.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /classes/TemplateTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | public class TemplateTest { 3 | static testmethod void testSimpleTemplate() { 4 | String tplContent = '-start-{!valueA}-inner-{!valueB}-end-'; 5 | Template tpl = new Template(tplContent); 6 | 7 | Test.startTest(); 8 | String result = tpl.evaluate(new Map { 9 | 'valueA' => 'A', 10 | 'valueB' => 'B' 11 | }); 12 | Test.stopTest(); 13 | 14 | System.assertEquals('-start-A-inner-B-end-', result); 15 | } 16 | 17 | static testmethod void testSObjectTemplate() { 18 | User anUser = [SELECT LastName FROM User WHERE Id = :UserInfo.getUserId()]; 19 | 20 | Test.startTest(); 21 | String result = new Template('{!User.FirstName} {!User.LastName}') 22 | .evaluate(anUser); 23 | Test.stopTest(); 24 | 25 | System.assertEquals(' ' + UserInfo.getLastName(), result); 26 | } 27 | 28 | static testmethod void testEmailTemplateFactoryMethod() { 29 | Boolean exceptionThrown = false; 30 | try { 31 | Template tpl = Template.fromEmailTemplate('non-existing-template'); 32 | } catch(Exception e) { 33 | exceptionThrown = true; 34 | } 35 | System.assert(exceptionThrown, 'Should throw an exception if template does not exist.'); 36 | 37 | insert new EmailTemplate( 38 | Name = 'Test Template', 39 | DeveloperName = 'Test', 40 | TemplateType = 'text', 41 | Body = 'Test', 42 | FolderId = UserInfo.getUserId() 43 | ); 44 | 45 | Template tpl = Template.fromEmailTemplate('Test'); 46 | System.assertEquals('Test', tpl.evaluate(new MergeValues())); 47 | } 48 | 49 | static testmethod void testRegisteringFields() { 50 | MergeValues bag = new MergeValues(); 51 | 52 | Template tpl = new Template('{!User.FirstName} {!User.LastName}'); 53 | tpl.registerFieldsSecurely(bag); 54 | 55 | bag.putSObject('User', UserInfo.getUserId()); 56 | String result = tpl.evaluate(bag); 57 | 58 | System.assertEquals(UserInfo.getFirstName() + ' ' + UserInfo.getLastName(), result); 59 | } 60 | } -------------------------------------------------------------------------------- /classes/TemplateTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apex Templates 4 | 5 | MergeValues 6 | MergeValuesTest 7 | Template 8 | TemplateTest 9 | ApexClass 10 | 11 | 26.0 12 | 13 | --------------------------------------------------------------------------------