You can use JSON.deserializeUntyped()
to convert any JSON-serialized SObject
into a Map<String,Object>
containing as its keys fields actually present on that SObject. Using your example with Account:
Account myAcc = new Account(Name = 'Doug', BillingCity=null);
// Serialize our Sobject into JSON
String s = JSON.serialize(myAcc);
// Deserialize it back into a key/value map
Map<String,Object> obj =
(Map<String,Object>) JSON.deserializeUntyped(s);
// Build a set containing the fields present on our SObject
Set<String> fieldsPresent = obj.keyset().clone();
// Salesforce always adds an extra 'attributes' key to SObjects
// when you do JSON stuff, so let's get rid of that.
fieldsPresent.remove('attributes');
System.debug(fieldsPresent);
// Returns: {Name,BillingCity}
There's nothing "horrible" or "heavyweight" about serializing into JSON / deserializing temporarily to get this information. Won't hurt your heap-size at all (as long as you don't maintain references after you're through with your work), and it only takes a couple of script statements.
The values() list is not guaranteed to be in order by default, because the hashing algorithm may arbitrarily reorder the elements in the list. I am not sure if I would classify this as a bug, but I do know I would never depend on the values() list being in any particular order; Visualforce has a hard time dealing with maps in general.
What I would do is instead create a wrapper class that keeps the list in a predefined order. I created this technique after I ran across a bug where Visualforce would die unexpectedly when a key was missing from a map, but I'm sure it has other uses as well. Here's the design pattern:
public class Controller {
Map<Id, SObject> objects;
public ObjectWrapper[] objectList { get; set; }
public class ObjectWrapper {
Controller controller;
Id key;
public ObjectWrapper(Controller controller, Id key) {
this.controller = controller;
this.key = key;
}
public SObject record {
get { return controller.get(value); }
set { controller.put(key, value); }
}
}
public Controller() {
objects = new Map<Id, SObject>();
objectList = new ObjectWrapper[0];
for(SObject record: [...]) {
objects.put(record.Id, record);
objectList.add(new ObjectWrapper(this, record.Id);
}
}
}
The basic premise is that the objectList provides a stable ordering of records which guarantees that the data won't be obliterated during deserialization. You can add values to the objectList, and you can even specify the same key twice to cause the value to appear more than once (but then you risk the object being clobbered randomly).
Best of all, you could implement Comparable to allow automatic sorting of the objectList while leaving the map alone. Since the map is naturally unordered, storing the order as a separate list will set things straight. Best of all, this pattern specifically avoids the "missing map key" error.
Update
As an example version of faulty logic, imagine we have the following code:
Opportunity[] records = somemap.values().deepClone(true, true, true);
String[] fields = new String[] { 'Name', 'CloseDate', 'Id' };
for(Opportunity record: records)
record.closedate = System.Today()+(1000*Math.random()).intValue();
for(Integer fdx = 0; fdx < fields.size(); fdx++)
for(Integer idx = 0; idx < records.size(); idx++)
somemap.values()[idx].put(fdx, records[idx].get(fields[fdx]));
This code would be perfectly sane if we replaced somemap.values()
with someList
, instead. That's because Map.values()
returns an unordered list, which means that the results are subject to re-order themselves arbitrarily, probably due to internal hashing. Therefore, since somemap.values()
is not guaranteed to return the same order, the fields could easily become scrambled; you may as well be calling: somemap.values()[(somemap.size()*Math.random()).intValue()].put(fdx, records[idx].get(fields[fdx]));
Hopefully this illustrates the problem better. It's nothing to do with serializing or deserializing, it has to do with the values() list not necessarily retaining its order, since it is, by definition, an unordered list.
Best Answer
The apex dev team saw Andy's case and the resulting discussion and logged bug W-3223800 for this. The behavior you're seeing is a bug.
The fix is scheduled for Spring 17, although safeharbor as always. Also, I've been informed it will be a versioned change, so be sure to update the API version of your apex classes to v39.0 or later.
Maps of Populated SObject Fields Return Correct Values