To be honest with you, I really didn’t expect the explanation to be this in depth, but you never know until you start. So here we cover the question “WHAT ARE THE BASIC CLASSES THAT WE WILL NEED FIRST?” I think we’ll need about 6 of them:
- TCriteria
- TPaging (With it’s Respective IPagingEventHandler interface)
- ISOQLValueConverter
- TSelectionEntity
- TApexEntity
- TCustomSearch
TCriteria
The purpose of this class is to handle the search criteria for the objects. It consists of a field name (the object field to compare such as First_Name__c), a comparative element (such as ‘=’, ‘<‘, ‘!=’, etc.), and a target value (such as ‘bub’). All together a criteria can produce a concatenation of <field name>+<comparator>+<target value> (such as First_Name__c = ‘john’). Can you see where I’m going with this? My intention is to generate SOQL statements :).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
/* @ClassName: TCriteria @Author: Nethaneel Edwards @LastModifiedDate: 8/9/2013 @Purpose: The purpose of this class is to handle the search criteria for the objects */ public class TCriteria { //The name of the SOQL field to search against public string FieldName { get; set; } //The LOGICAL comparison to use in the search public string Comparator { get; set; } //The value to search for public string TargetValue { get; set; } /* @Summary: Default constructor for class @param pFieldName: The name of the field to search against @param pComparator: The Logical comparison to use in th esearch @param pTargetValue: The value to search for */ public TCriteria(String pFieldName, String pComparator, String pTargetValue) { this.FieldName = pFieldName; this.Comparator = pComparator; this.TargetValue = pTargetValue; } } |
TPaging
Now we know that as of the time of this writing, SalesForce limits us to a 200 record max limit on a SOQL/SOSL query. As a result, we will need a reusable way to page these records. This is why I’ve created the TPaging class. The purpose of this class is to act as a paging model for data sets. The code snippet bellow thoroughly explains the properties of this model and the application of each.
1 2 3 4 |
public interface IPagingEventHandler { void OnPagingSearch(); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
/* @ClassName: TPaging @Author: Nethaneel Edwards @LastModifiedDate: 8/12/2013 @Purpose: The purpose of this class is to act as pager model for data sets */ public class TPaging { //The offset of the records. Otherwise known as the page*(page size -1) public Integer Offset { get; set; } //The page size - the number of records to show at a time public Integer PageSize { get; set; } //The total records available to page through public Integer TotalRecords { get; set; } //Property used to store the record count limit to use in count queries //due to governer limits public Integer RecordCountLimit { get; set; } //The property to indicate whether or not to trigger the paging event handler //on a paging event public Boolean EnableTrigger { get; set; } //The pointer to an instance that implements the IPagingEventHandler interface //This pointer holds the refresh method to call if the trigger is enabled public IPagingEventHandler PagingEventHandler { get; set; } //The property to calculate the total number of pages available public Integer TotalPages { get { if (this.PageSize == 0) { return 1; } if (math.mod(this.TotalRecords, this.PageSize) > 0) { return this.TotalRecords / this.PageSize + 1; } else { return this.TotalRecords / this.PageSize; } } } //The page number currently on public Integer PageNumber { get { if (this.PageSize == 0 || this.TotalPages == 0) { return 0; } else { return this.Offset / this.PageSize + 1; } } } //Whether or not the page can be decremented to the first one public Boolean AllowFirst { get; set; } //Whether or not the page can be decremented any further back public Boolean AllowPrevious { get; set; } //Whether or not the page can be incremented any further forward public Boolean AllowNext { get; set; } //Whether or not the page can be incremented to the last one public Boolean AllowLast { get; set; } /* @Summary: Default constructor for this class. It initializes the default paging settings which includes setting the EnableTrigger property to false and defaulting to the first page @param pOffset: The offset of the records. Otherwise known as the page*(page size -1) @param pPageSize: The page size - the number of records to show at a time @param pTotalRecords: The total records available to page through */ public TPaging(Integer pOffset, Integer pPageSize, Integer pTotalRecords) { this.Configure(False, False, False, False); this.Offset = pOffset; this.PageSize = pPageSize; this.TotalRecords = pTotalRecords; EnableTrigger = false; this.First(); } /* @Summary: Method configures the pager "Allow" properties @param pAllowFirst: Whether or not the page can be decremented to the first one @param pAllowPrevious: Whether or not the page can be decremented any further back @param pAllowNext: Whether or not the page can be incremented any further forward @param pAllowLast: Whether or not the page can be incremented to the last one */ public void Configure(Boolean pAllowFirst, Boolean pAllowPrevious, Boolean pAllowNext, Boolean pAllowLast) { this.AllowFirst = pAllowFirst; this.AllowPrevious = pAllowPrevious; this.AllowNext = pAllowNext; this.AllowLast = pAllowLast; } /* @Summary: Method moves to the first page */ public void First() { this.Offset = 0; this.Configure(this.Offset > 0, this.Offset > 0, this.Offset + this.PageSize < this.TotalRecords, this.Offset + this.PageSize < this.TotalRecords); TriggerSearch(); } /* @Summary: Method moves to the previous page */ public void Previous() { this.Offset -= this.PageSize; this.Configure(this.Offset > 0, this.Offset > 0, this.Offset + this.PageSize < this.TotalRecords, this.Offset + this.PageSize < this.TotalRecords); TriggerSearch(); } /* @Summary: Method moves to the next page */ public void Next() { this.Offset += this.PageSize; this.Configure(this.Offset > 0, this.Offset > 0, this.Offset + this.PageSize < this.TotalRecords, this.Offset + this.PageSize < this.TotalRecords); TriggerSearch(); } /* @Summary: Method moves to the last page */ public void Last() { this.Offset = this.TotalRecords - math.mod(this.TotalRecords, this.PageSize); this.Configure(this.Offset > 0, this.Offset > 0, this.Offset + this.PageSize < this.TotalRecords, this.Offset + this.PageSize < this.TotalRecords); TriggerSearch(); } /* @Summary: Method triggers the PagingEventHandler's OnPagingSearch method if EnabledTrigger is set to 'true' and there exists a PagingEventHandler. */ public void TriggerSearch() { if (EnableTrigger && PagingEventHandler != null) { PagingEventHandler.OnPagingSearch(); } } } |
ISOQLValueConverter
Yes I know, I stole the name from Silverlight’s IValueConverter in System.Windows.Data. I’m a Silverlight Developer at heart ;).
Now, after swimming in the sea of SOQL for a while you encounter a few uncomfortable “biters”. One of the most provoking ones being date to string value conversion. You can only force a user to do so much on the UI after a while. I ran into a situation where I needed to convert the value the user entered for a date into the one and only form that was compatible with SOQL. Yes, this can be done using the typical if statements and other basic programming constructs. But what happens when constraints are introduced indefinitely for more and more types? Do I increase my comparison statements? We must keep in mind that case statements are not available in APEX. I figured the best solution must be one where both the run time and ease of implementation is considered. With an interface like the ISOQLValueConverter, I can create a registry of value converters indexed by their respective field types, therefore allowing me to access the appropriate conversion method in a runtime of O (1).
Now the value converter requires two method implementations. These are called ConvertForward (convert original value into new format) and ConvertBackward (convert SOQL value into a presentation/original format). The later isn’t required for now, but it is good to have for unforeseen issues.
1 2 3 4 5 |
public interface ISOQLValueConverter { String ConvertForward(Object value); Object ConvertBackward(String value); } |
1 2 3 4 5 6 7 8 9 10 11 |
/* @ClassName: SOQLValueConverters @Author: Nethaneel Edwards @LastModifiedDate: 8/12/2013 @Purpose: The purpose of this class is to act as a names space for value converters used for SOQL statements. These value converters deal with the appropriate string representation for types that must be customized. */ public class SOQLValueConverters { } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
/* @ClassName: TDateTimeSOQLValueConverter @Author: Nethaneel Edwards @LastModifiedDate: 8/12/2013 @Purpose: Class responsible for datetime value conversion */ public class TDateTimeSOQLValueConverter implements ISOQLValueConverter{ /* @Summary: Method converts the given value into it's DateTime string representation @Return String: The converted string */ public String ConvertForward(Object value){ //modify the date format to "YYYY-MM-DDThh:mm:ssZ" DateTime dt = Date.parse((string)value); String result = String.format('{0}-{1}-{2}T{3}:{4}:{5}Z',new string[] { string.valueOf(dt.year()),('0'+string.valueOf(dt.month())).right(2), ('0'+string.valueOf(dt.day())).right(2),('0'+string.valueOf(dt.hour())).right(2),('0'+string.valueOf(dt.minute())).right(2), ('0'+string.valueOf(dt.second())).right(2)}); return result; } /* @Summary: Method converts the given value into it's DateTime object representation from the given String. @Return Object : The datetime object */ public Object ConvertBackward(String value){ DateTime dt = Date.parse(value); return dt; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/* @ClassName: TDateSOQLValueConverter @Author: Nethaneel Edwards @LastModifiedDate: 8/12/2013 @Purpose: Class responsible for date conversion */ public class TDateSOQLValueConverter implements ISOQLValueConverter{ /* @Summary: Method converts the given value into it's date string representation @Return String: The converted string */ public String ConvertForward(Object value){ //modify the date format to "YYYY-MM-DDThh:mm:ssZ" Date dt = Date.parse((string)value); String result = String.format('{0}-{1}-{2}',new string[] { string.valueOf(dt.year()),('0'+string.valueOf(dt.month())).right(2), ('0'+string.valueOf(dt.day())).right(2)}); return result; } /* @Summary: Method converts the given value into it's Date object representation from the given String. @Return Object: The date object */ public Object ConvertBackward(String value){ Date dt = Date.parse(value); return dt; } } |
TSelectionEntity
I’ve often found myself in need for a very simple object – an entity that provides a list of elements to select from as well as the selected option. Strangely enough, I could not find an existing model for that and so I made a very straight forward one as seen below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
/* @ClassName: TSelectionEntity @Author: Nethaneel Edwards @LastModifiedDate: 8/12/2013 @Purpose: The purpose of this class is to act as a container for selection based properties */ public class TSelectionEntity { //The list of options to choose from public List Options { get; set; } //The selected option chosen public String SelectedOption { get; set; } //The selected options chosen public String[] SelectedOptions { get; set; } //A map for extended properties to configure. Very helpful //when Run Time Type Inspection(RTTI) is not available as is the case with VF public Map<String, String> ExtendedProperties { get; set; } /* @Summary: Default constructor for this class. Method initializes the options to a blank list. */ public TSelectionEntity() { Options = new List(); ExtendedProperties = new Map<String, String>(); SelectedOptions = new String[] { }; } /* @Summary: Optional constructor for this class. Method initializes the options to a given list of SelectOption. @param pOptions: The list of SelectOption to use for this class instance. */ public TSelectionEntity(List pOptions) { Options = new List(); ExtendedProperties = new Map<String, String>(); SelectedOptions = new String[] { }; Options.clear(); for(SelectOption entry: pOptions){ Options.add(new SelectOption(entry.getValue(),entry.getLabel())); } ClearSelection(); } /* @Summary: Method provides the option(s) that are chosen by the user. This comes in handy for in statements in SOQL */ public String[] getSelectedOptionFilter() { String[] result = new List(); //do not bother to search for the selected option if it's a multi select if (SelectedOptions.size() > 0) return SelectedOptions; for (Integer i=1; i<this.Options.size(); i++){ if (this.SelectedOption=='all' || this.SelectedOption==this.Options[i].getValue()){ result.add(this.Options[i].getValue()); } } if (this.SelectedOption=='all'){ result.add(null); result.add(''); } return result; } /* @Summary: Method defaults the selected option back to all */ public void ClearSelection() { this.SelectedOption='all'; } } |
TApexEntity
This is just a generic ancestor class I use to descend any custom classes that need to act as models from. It only has a single property for storing a string value. This will come in handy for the comparison values.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
/* @ClassName: TApexEntity @Author: Nethaneel Edwards @LastModifiedDate: 8/9/2013 @Purpose: The purpose of this class is to act as the common ancestor for any classes designed as data contexts. */ public virtual class TApexEntity { //Optional constant identifier for an instance of this class public string DBConst { get; set; } /* @Summary: Default constructor for this class. Calls the reset method */ public TApexEntity() { this.Reset(); } /* @Summary: Method should be able to take an instance of the same type and copy the given values to the current instantiation of the object. @param iSource: The source of the information to copy over */ public virtual void AssignFrom(TApexEntity iSource) { this.DBConst = iSource.DBConst; } /* Method resets the class properties to an empty state */ public virtual void Reset() { this.DBConst = ''; } } |
TCustomSearch
Now we need a way present a set of comparison and field options, as well as collect their respective target values in context of the current entity type in question. That’s the purpose of the TCustomSearch class. It uses lists of TSelectEntity to store the available comparison options, and field options for each criteria we choose to add. In summary, I tell my custom search how many field criterias I’m allowing for a given entity type and it builds the options to configure each filed for me. Each method and it’s respective parameters are documented below. Please take note of the method LoadValueConverters and how it registers the value converters we previously made.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
/* @ClassName: TCustomSearch @Author: Nethaneel Edwards @LastModifiedDate: 8/12/2013 @Purpose: The purpose of this class is to handle the search configuration for the User's query */ public class TCustomSearch extends TApexEntity { //The type of Entity being searched public String EntityType { get; set; } //The name of the entity being searched (it is retrieved via describes actually) public String EntityName { get; set; } //The number of search fields to configure public Integer FieldCount { get; set; } //The comparison options for each field public List ComparisonOptions { get; set; } //The list of fields to choose from public List FieldOptions { get; set; } //A map of information for each field public Map<String, SObjectField> FieldInfo { get; set; } //A map of information for each field public Map<Schema.DisplayType, List> CustomSelectOptions { get; set; } //The list of values to match to public List TargetValues { get; set; } //The Map of registered value converters for each display type public Map<Schema.DisplayType, ISOQLValueConverter> ValueConverters { get; set; } /* @Summary: Default constructor for this class. It initializes the properties to an empty state. @param pFieldCount: The number of search fields to configure @param pEntityType: The type of Entity being searched */ public TCustomSearch(Integer pFieldCount, string pEntityType) { ComparisonOptions = new List(); FieldOptions = new List(); TargetValues = new List(); FieldInfo = new Map<String, SObjectField>(); ValueConverters = new Map<Schema.DisplayType, ISOQLValueConverter>(); CustomSelectOptions = new Map<Schema.DisplayType, List>(); FieldCount = pFieldCount; EntityType = pEntityType; } /* @Summary: Method calls the methods to load the default information for a search. LoadComparisons,LoadFields and LoadValueConverters */ public void Initialize() { LoadComparisons(); LoadFields(); LoadValueConverters(); } /* @Summary: Method registers the available value converters for use in the SOQL generation. */ public void LoadValueConverters() { ValueConverters.put(Schema.DisplayType.DateTime, new SOQLValueConverters.TDateTimeSOQLValueConverter()); ValueConverters.put(Schema.DisplayType.Date, new SOQLValueConverters.TDateSOQLValueConverter()); } /* @Summary: Method creates a list of SelectOptions from the given array @param objValues: The list of values to create the list from @return List: The matching list of selection options created from the given array */ public List CreateSelectOptions(SelectOption[] objValues) { List result = new List(); for(SelectOption so: objValues){ result.add(so); } return result; } /* @Summary: Method appends an array of select options to an existing list @param objExisting: The list of values to append to @param objNewValues: The list of new list of values to append @return List: The new composite list created */ public List AppendSelectOptions(List objExisting, SelectOption[] objNewValues) { List result = new List(); for(SelectOption eso: objExisting){ result.add(eso); } for(SelectOption nso: objNewValues){ result.add(nso); } return result; } /* @Summary: Method loads the available comparisons that can be done */ public void LoadComparisons() { List pOptions = new List(); pOptions.add(new SelectOption('','--None--')); //split up the comparison types List pDistinctOptions = CreateSelectOptions(new SelectOption[] {new SelectOption(' = \'{0}\'','equals'), new SelectOption(' != \'{0}\'','not equal to')}); List pStringOptions = CreateSelectOptions(new SelectOption[] { new SelectOption(' LIKE \'%{0}\'','starts with'), new SelectOption(' LIKE \'%{0}%\'','contains'),new SelectOption(' NOT LIKE \'%{0}%\'','does not contain')}); List pNumericOptions = CreateSelectOptions(new SelectOption[] { new SelectOption(' < \'{0}\'','less than'), new SelectOption(' > \'{0}\'','greater than'),new SelectOption(' <= \'{0}\'','less or equal'), new SelectOption(' >= \'{0}\'','greater or equal') }); //configure the type comparison filters (not used at the moment ) CustomSelectOptions.put(Schema.DisplayType.DateTime, AppendSelectOptions(pDistinctOptions, pNumericOptions)); CustomSelectOptions.put(Schema.DisplayType.Date, AppendSelectOptions(pDistinctOptions, pNumericOptions)); CustomSelectOptions.put(Schema.DisplayType.Boolean, pDistinctOptions); //confgirue the generic options pOptions = AppendSelectOptions(pOptions, pDistinctOptions); pOptions = AppendSelectOptions(pOptions, pStringOptions); pOptions = AppendSelectOptions(pOptions, pNumericOptions); for (Integer i = 0; i < FieldCount; i++) { ComparisonOptions.add(new TSelectionEntity(pOptions)); FieldOptions.add(new TSelectionEntity()); TargetValues.add(new TApexEntity()); } } /* @Summary: Method applies the template for the comparison to the given value. It takes into consideration any value converters available. @param pTemplate: The template to apply @param pValue: The value to template @param pDisplayType: The type of value being templated @return String: The final templated value */ public String ApplyTemplate(String pTemplate, string pValue, Schema.DisplayType pDisplayType) { if (ValueConverters.containsKey(pDisplayType)){ return pTemplate.replace('\'{0}\'', ValueConverters.get(pDisplayType).ConvertForward(pValue)); } else return pTemplate.replace('{0}', pValue); } /* @Summary: Method loads the field options and field info based on the entity type */ public void LoadFields() { if (TStringUtils.IsNullOrEmpty(EntityType)) { return; } SObjectType objToken = Schema.getGlobalDescribe().get(EntityType); DescribeSObjectResult objDef = objToken.getDescribe(); EntityName = objDef.getLabelPlural(); Map<String, SObjectField> fields = objDef.fields.getMap(); //the set of fields Set fieldSet = fields.keySet(); List sortedFieldSet = new List(); for(String s:fieldSet) { sortedFieldSet.add(s); } sortedFieldSet.sort(); List pOptions = new List(); pOptions.add(new SelectOption('','--None--')); for (Integer i = 0; i < sortedFieldSet.size(); i++) { String s = sortedFieldSet[i]; SObjectField fieldToken = fields.get(s); DescribeFieldResult selectedField = fieldToken.getDescribe(); pOptions.add(new SelectOption(selectedField.getName(), selectedField.getLabel())); FieldInfo.put(selectedField.getName(), fieldToken); } for (Integer i = 0; i < FieldOptions.size(); i++) { FieldOptions[i] = new TSelectionEntity(pOptions); } } } |
Yes, I know it seems like a lot, but it’s all in your head. Mere figment of your imagination. Just wait it out…Next the core logic – Tales of the Salesforce Spirit: Custom Object Mass Deleter (Part III of IV)