Causion with Describe Methods (Winter 18 Regression)

After Winter 18 release I encounter issue, that can demonstrate the risks when using describe methods improperly for dynamically looping over object fields.

Common usage for describe method can be as follow:


 Map<String, Schema.SObjectType> schemaMap = Schema.getGlobalDescribe();  
 Schema.SObjectType OppSchema = schemaMap.get('Opportunity');  
 map<String, Schema.SObjectField> OppfieldMap = OppSchema.getDescribe().fields.getMap();  
   
 for(String field: OppfieldMap.keySet()){  
      //Do something  
 }  


The customer develop customize sync process between Quote & Opportunity.
His process uses describe method to get all the Quote and Opportunity fields and synchronize any field which have the same API name.
This process start causing errors or unexpected behaviour after winter 18 release.

After investigating the issue, we found that the main change that break the process is that salesforce introduce/expose in Quote object the field ownerId, which is populated by the quote creator and cannot be updated.
Generally, quote is related to Opportunity in Master-Detail relation. In such case, normally the child (quote) doesn't have owner. The owner of the parent (opportunity) is also the owner of child (quote).

But seems SF might break this normal behaviour for Quote object. It is not documented in the release note, but it is probably related to the pilot Quotes Without Opportunities Pilot - Winter '18, which contains below feature and make sense to have the field ownerId in Quote.
    1. Create new quotes without opportunities
    2. Update or remove opportunities from existing quotes
    3. Control access on old and new quotes
    4. Change owner on old and new quotes


Anyway, back to the customize sync process. After winter 18, when running the sync process, if trying to sync the fields from Opportunity to Quote the process was failing with exception, as it try to update the opportunity owner to the quote owner, and the field is not writable.
If trying to sync the other direction - Quote to opportunity - the process success, but it wasn't the expected result - opportunity owner was override with the quote owner (which is the quote creator and is not always the same person).

As fix, we exclude hard-coded the field ownerId from the process. It solve the issue for now.
For future, their process does need to be amended to sync only explicit list of fields.

Some insights I took from this case:
1.should be careful when using describe methods and working with the entire fields. Even when retrieving the fields dynamically, it's recommended to limits the result with other setup -e.g. fieldSet, Custom Settings.... Actually, in many case you might use only the former setup (fieldSet, Custom Settings) to control the fields you need, instead of going over all object fields.

2.When working with standard Object should be carefull when override standard functionality.
You should use dynamic process, which allow you to have changes in future without changing the code, but must take under consideration also SF changes for their standard.

3.Run full tests after the release changes are in sandbox. Recommended full sandbox with integration. It not 100% insurance, but might help to detect regression issue in advance.

Using Salesforce History Data

There is build-in functionality in Salesforce for tracking history on specific fields, which can save you development efforts.

Per each object you can enable the tracking history feature, and select the fields you would like track. In each page layout you may add the history related list, that will show all changes for this record. All this feature is build-in and can be done with setup only.

I had once issue, with main object that have several telated childs objects. When user view the history for the object he doesn't want to dive into each child record, but  rather to see all the changes (main object + childs) in one place.
Therefore I develop kind of history page which show all changes together.
At first this page was design for the specific custom object. Lately, with some modification it was amended to work completely generic. Means, it can work on any custom object.
Its show only changes for custom objects, as SF objects sometimes have different names for their history table.

The relevant components are visualforce page and controller, usage is by the URL:
/apex/HistoryPage?ids=<recordID>

The URL should get as parameter: ids. The Id of the record you want to view its history.

First should select the objects (master/child) to view their changes, press 'Get Changes' and view the result.

I created for example custom object: "Parent Object", and 2 child custom object.








Controller Code:


public class vf_HistoryPage {  

 public Id objID {get; set;}        //main object id (should get as URL parameter)   
 public String objName {get; set;}     //main object name (should get as URL parameter)   

 public List<HistoryChange> historyChangeLst {get; set;}     //list with all changes   

 public list<String> childObjLst {get; set;}                       //list of all child objects   
 public map<String, Boolean> obj_showBol_map {get; set;}               //for each object if need to show its changes   
 public map<String, String> objLabel_API_map {get; set;}               //map from Label to API   

 private map<String, String> objAPI_objLabel=new map<String, String>();     //for each object its label name   
 private map<String, String> objAPI_fieldName_map=new map<String, String>();   //for each object the field name of his parent   

 private map<String, Set<String>> tableFieldAdd_Map=new Map<String, Set<String>>();   //used to prevent from same change return twice   

 public vf_HistoryPage() {  

  //record ID should be as parameters in the URL   
  objID=ApexPages.currentPage().getParameters().get('ids');   
  objName=objID.getsobjecttype().getDescribe().getName();   

  //DescribeResult of the main object   
  Schema.DescribeSObjectResult objectResult = Schema.getGlobalDescribe().get(objName).getDescribe();   

  //intialize maps   
  objLabel_API_map=new map<String, String>();   
  childObjLst=new list<String>{objectResult.getLabel()};   
  obj_showBol_map=new map<String, Boolean>();   

  //adding the main object   
  obj_showBol_map.put(objName, true);   
  objAPI_objLabel.put(objName, objectResult.getLabel());   
  objLabel_API_map.put(objectResult.getLabel(), objName);   
  objAPI_fieldName_map.put(objName, 'id');   

  for(Schema.ChildRelationship child : objectResult.getChildRelationships()) {  

   Schema.DescribeSObjectResult objRes=child.getChildSObject().getDescribe();   

   if(objRes.isCustom()) {  
    childObjLst.add(objRes.getLabel());   

    obj_showBol_map.put(objRes.getName(), true);   
    objAPI_objLabel.put(objRes.getName(), objRes.getLabel());   
    objLabel_API_map.put(objRes.getLabel(), objRes.getName());   
    objAPI_fieldName_map.put(objRes.getName(), child.getField().getDescribe().getName());   
   }   
  }   
  historyChangeLst=new List<HistoryChange>();   
 }   

 //Get all the changes   
 public PageReference getChanges() {  

  //clear list of changes   
  historyChangeLst.clear();   

  for(String childObjName : obj_showBol_map.KeySet()) {  
   if(obj_showBol_map.get(childObjName)) {  

    //reset this list for the specific object   
    tableFieldAdd_Map.put(childObjName, new Set<String>());   
 
    //collect all the childs IDs   
    Set<ID> childObjIDSet=new Set<ID>();   

    for(sObject childObj : Database.query('SELECT id FROM ' + childObjName + ' WHERE ' + objAPI_fieldName_map.get(childObjName) + ' =\'' + objID +'\'')) {   
     childObjIDSet.add(childObj.id);   
    }  

    //add all the object changes to list of all changes      
    if(!childObjIDSet.isEmpty()) {  
     addAllChanges(childObjIDSet,    
      objAPI_objLabel.get(childObjName),   
      childObjName,   
      childObjName.endsWith('__c') ? childObjName.replace('__c', '__History') : childObjName + 'History');   
    }  
   }   
  }   
  historyChangeLst.sort();   

  return ApexPages.currentPage();   
 }   

 public void addAllChanges(   
  Set<Id> idLst,   
  String objectName,   
  String tableName,   
  String tableHistoryName) {  

  String idConcatenateLst='';   
  String dynamicSQLHistory;   

  for(String objID : idLst) {  
   idConcatenateLst+= idConcatenateLst!='' ? ', \''+objID+'\'' : '\''+objID+'\'';   
  }  

  dynamicSQLHistory='SELECT id, oldvalue, parentid, newvalue, field, createdDate, CreatedBy.LastName, CreatedBy.FirstName';    
  dynamicSQLHistory+=' FROM ' + tableHistoryName + ' WHERE parentId IN ( ' + idConcatenateLst + ')';   
  dynamicSQLHistory+= ' order by createdDate';   

  try {   
   List<sObject> objLst=Database.query(dynamicSQLHistory);   
   sObject userObj;   
   String firstName, lastName;   

   for(sObject histObj : objLst) {  

    userObj=histObj.getSObject('CreatedBy');   
    firstName=(String)(userObj.get('FirstName'));   
    lastName=(String)(userObj.get('LastName'));   

    addChange(tableName,   
     (String)histObj.get('field'),   
     histObj.get('oldvalue'),   
     histObj.get('newValue'),   
     (DateTime)histObj.get('createdDate'),   
     (firstName==null ? '' : (firstName + ' '))+ (lastName==null ? '' : lastName),   
     objectName,   
     (String)histObj.get('parentid'));   
   }   
  }   
  catch(System.QueryException sqe) {  
   System.debug('##err: ' + sqe);   //possible error: object not support history   
   System.debug('##DYNAMIC SQL: ' + dynamicSQLHistory);   
  }   
  catch(Exception e) {  
   ApexPages.addMessage(new ApexPages.Message(ApexPages.severity.ERROR, ' Error with ' + tableName + '. ' + e));   
  }   
 }   

 //add change   
 private void addChange(   
  String table_Str,   
  String field_Str,   
  Object oldValue_Obj,   
  Object newValue_Obj,   
  DateTime createDate,   
  String user,   
  String objectName,   
  String objID) {  

  String fieldLabel_Str, name_Str='';   
  String result_Str='';   
  Boolean creation=false;   

  if(!tableFieldAdd_Map.get(table_Str).contains(field_Str+createDate+objID)) {  

   tableFieldAdd_Map.get(table_Str).add(field_Str+createDate+objID);   
   if(field_Str=='created')   {   
    result_Str='Record created';   
    creation=true;   
   }   
   else   {   
    if(oldValue_Obj != null && newValue_Obj != null) {  
     result_Str+=' changed from ' + (oldValue_Obj==null ? '' : oldValue_Obj) + ' to ' + (newValue_Obj==null ? '' : newValue_Obj);   
    }  
    else if(oldValue_Obj == null && newValue_Obj != null) {  
     result_Str+=' added ' + (newValue_Obj==null ? '' : newValue_Obj);   
    }  
    else if(oldValue_Obj != null && newValue_Obj == null) {  
     result_Str+=' deleted ' + (oldValue_Obj==null ? '' : oldValue_Obj);   
    }  
    else {  
     result_Str+=' Field has been changed';   //for long text field SF doesn't store the values   
    }  
   }   

   if(result_Str!=null && result_Str.trim() !='') {  
    historyChangeLst.add(new HistoryChange(result_Str, user, createDate, objectName, objID, table_Str, field_Str=='RecordType' ? 'RecordTypeID' : field_Str, creation));   
   }  
  }   
 }   

 //Object HistoryChange - represent history record   
 public class HistoryChange implements Comparable {  

  public String changeStr {get; set;}   
  public DateTime changeDate {get; set;}   
  public String userStr {get; set;}   
  public String objectName {get; set;}   
  public String objectID {get; set;}   
  public String objectAPI {get; set;}   
  public String fieldAPI {get; set;}   
  public Boolean creationChange {get; set;}   

  public HistoryChange(String pChange, String pUser, DateTime pChangeDate, String pObjectName, String pObjectID,   
   String pObjectAPI, String pFieldAPI, Boolean pCreationChange) {  
   changeStr=pChange;   
   changeDate=pChangeDate;   
   userStr=pUser;   
   objectName=pObjectName;   
   objectID=pObjectID;   
   objectAPI=pObjectAPI;   
   fieldAPI=pFieldAPI;   
   creationChange=pCreationChange;   
  }   

  public Integer compareTo(Object historyRec) {   
   HistoryChange compareToHC = (HistoryChange)historyRec;   
   if(changeDate!=compareToHC.changeDate) {  
    return changeDate >= compareToHC.changeDate ? 0 : 1;   
   }  
   else {  
    return creationChange ? 1 : 0;   
   }  
  }   
 }   
}  


Visualforce Page:

<apex:page controller="vf_HistoryPage">   
 <apex:pageMessages />   
 <apex:form id="frm">   
  <apex:pageBlock mode="edit" id="mainblock" title="Object Selection">   
   <apex:pageBlockButtons id="buttons" location="bottom">   
    <apex:commandButton value="Get Changes" action="{!getChanges}"/>   
   </apex:pageBlockButtons>   
   <apex:pageBlockSection id="filters" columns="1" collapsible="true">   
    <apex:repeat value="{!childObjLst}" var="obj">   
     <apex:pageBlockSectionItem >   
      <apex:outputText value="{!obj}"/>   
      <apex:inputCheckbox value="{!obj_showBol_map[objLabel_API_map[obj]]}"/>   
     </apex:pageBlockSectionItem>   
    </apex:repeat>   
   </apex:pageBlockSection>   
  </apex:pageBlock>   
  
  <apex:pageBlock mode="edit" id="results">   
   <apex:pageBlockTable value="{!historyChangeLst}" var="change">   
    <apex:column headerValue="Date">   
     <apex:outputText value="{0,date,dd'/'MM'/'yyyy HH:mm:ss}">   
      <apex:param value="{!change.changeDate}" id="datefromid"/>   
     </apex:outputText>   
    </apex:column>   
    <apex:column headerValue="User">   
     <apex:outputText value="{!change.userStr}"/>   
    </apex:column>   
    <apex:column headerValue="Object">   
     <apex:outputLink target="_blank" value="/{!change.objectID}" id="eds"> {!change.objectName}</apex:outputLink>   
    </apex:column>   
    <apex:column headerValue="Change">   
     <apex:outputPanel rendered="{!NOT change.creationChange}">   
      {!$ObjectType[change.objectAPI].fields[change.fieldAPI].Label}   
     </apex:outputPanel>   
     {!change.changeStr}   
    </apex:column>   
   </apex:pageBlockTable>   
  </apex:pageBlock>   
 </apex:form>   
</apex:page>  

Retire of Permission on Profiles

If you are working as a Salesforce admin/developer you've probably heard somewhere that Salesforce is planning to make a significant cha...