Here’s a quick solution for anyone who needs to count the number of files attached to a Salesforce object.
There are two relevant standard objects that attach a file to a record: ContentDocument, which contains info about the document itself, and ContentDocumentLink, which sticks it to your object. The following class takes a set of record IDs and returns a map containing the number of attachments. The return map includes all records in the initial set, even those that don’t have any files attached. The class takes a second parameter, a list of ids of deleted documents. This second parameter is used when calling this class from a Before Delete trigger. In all other cases, this second parameter should be passed as NULL. Examples follow.Ā
public class CountAttachedFiles {
public static map<id,integer> CountFiles(set<id> EntityIds,set<id> deleteddocuments){
aggregateresult[] dcs = [Select LinkedEntityid, count(id)dcount from ContentDocumentLink where isDeleted=false and contentdocument.isdeleted = false and LinkedEntityid in :EntityIds and contentdocumentid not in :deleteddocuments group by LinkedEntityid];
map<id,integer> filecountmap = new map<id,integer>();
for(aggregateresult dc: dcs){
id leid = string.valueof(dc.get('LinkedEntityid'));
integer dcount = integer.valueof(dc.get('dcount'));
filecountmap.put(leid,dcount);
}
for(id eid: entityids){
if(!filecountmap.containskey(eid)){
filecountmap.put(eid,0);
}
}
return filecountmap;
}
}
In my specific case, I’ve got a custom object where I need to keep a running count of attached files, so I needed to call this class from a trigger on ContentDocumentLink. One wrinkle in this whole process, though, is that deleting a document (ContentDocument) does not cause an isDelete event on the ContentDocumentLink. Apparently this is the intended functionality.
The result is that I also needed a trigger on ContentDocument to update the count after a document is deleted. The following trigger handler handles both of those actions, as well as a third function to allow us to easily update all records in the custom object, since some records already have attached files for which we want an updated count. This last is not bulkified because the number of custom records didn’t call for it – a bigger data set might require a revised approach.
public class ContentDocumentLinkTriggerHelper {
public static void countCOfiles (list<contentdocumentlink> cdls, set<id> deleteddocids){
set<id> COids = new set<id>();
for (contentdocumentlink cdl: cdls){
//does the contentDocumentLink that was just added/update/deleted link to our custom object? Replace ### below with the 3-character id prefix for your custom object
if(string.valueof(cdl.linkedentityid).left(3)=='###'){
COids.add(cdl.linkedentityid);
}
}
if(COids.size()>0){
list<Custom_Object__c> PSAs = [Select id, attachment_count__c from Custom_Object__c where id in :COids];
map<id,integer> attachmentcounts = CountAttachedFiles.countfiles(COids, deleteddocids);
for(Custom_Object__c CO: COs){
CO.attachment_count__c = attachmentcounts.get(CO.id);
}
update COs;
}
}
public static void documentdelete (map<id,contentdocument> cdmap){
//used in the contentdocument trigger, essential to handle the delete function
set<id> deleteddocids = cdmap.keySet();
list<contentdocumentlink> cdls = [select id, linkedentityid from contentdocumentlink where contentdocumentid in :deleteddocids];
countCOfiles(cdls, deleteddocids);
}
public static void updateallCOs(){
//this is here to allow us to play catch-up and update all the PSAs with current document counts
list<Custom_Object__c> COs = [Select id, attachment_count__c from Custom_Object__c];
set<id> COids = new set<id>();
for(Custom_Object__c CO: COs){
COids.add(CO.id);
}
list<contentdocumentlink> cdls = [Select id, linkedentityid from contentdocumentlink where linkedentityid in :COids];
countCOfiles(cdls,null);
}
}
As they should be, the triggers are the least interesting parts of code here. I’m not sure that ContentDocumentLink records get updated in normal operations, but the afterDelete gets triggered when you remove a link between a document and a record (as opposed to deleting the file itself.)
trigger ContentDocumentLinkTrigger on ContentDocumentLink (after insert,after update, after delete) {
if(trigger.isafter && (trigger.isupdate || trigger.isinsert)){
contentdocumentlinktriggerhelper.countPSAfiles(trigger.new,null);
}
if(trigger.isafter && trigger.isdelete){
contentdocumentlinktriggerhelper.countPSAfiles(trigger.old,null);
}
}
And for the file deletion problem. . .
trigger ContentDocumentTrigger on ContentDocument (before delete) {
if(trigger.isbefore && trigger.isdelete){
contentdocumentlinktriggerhelper.documentdelete(trigger.oldmap);
}
}
And here is a test class that should test all of the above out at 100%. You’ll need to modify it to handle the insertion of appropriate test records for your custom object. Much of this test class was copped from this post and specifically the solution posted by Santanu Boral.
@isTest
private class Test_ContentDocumentTriggers {
private static Custom_Object__c getCustomObject(){
Custom_Object__c item = new Custom_Object__c(
Name = 'Test Item')
insert item;
return item;
}
static testMethod void testCDLinkcud() {
Custom_Object__c item = getCustomObject();
ContentVersion contentVersion = new ContentVersion(
Title = 'Penguins',
PathOnClient = 'Penguins.jpg',
VersionData = Blob.valueOf('Test Content'),
IsMajorVersion = true
);
insert contentVersion;
List<ContentDocument> documents = [SELECT Id, Title, LatestPublishedVersionId FROM ContentDocument];
//create ContentDocumentLink record
ContentDocumentLink cdl = New ContentDocumentLink();
cdl.LinkedEntityId = item.id;
cdl.ContentDocumentId = documents[0].Id;
cdl.shareType = 'V';
insert cdl;
ContentDocumentLinkTriggerHelper.updateallPSAs();
delete cdl;
}
static testMethod void deletedoc() {
Custom_Object__c item = getCustomObject();
ContentVersion contentVersion = new ContentVersion(
Title = 'Penguins',
PathOnClient = 'Penguins.jpg',
VersionData = Blob.valueOf('Test Content'),
IsMajorVersion = true
);
insert contentVersion;
List<ContentDocument> documents = [SELECT Id, Title, LatestPublishedVersionId FROM ContentDocument];
//create ContentDocumentLink record
ContentDocumentLink cdl = New ContentDocumentLink();
cdl.LinkedEntityId = item.id;
cdl.ContentDocumentId = documents[0].Id;
cdl.shareType = 'V';
insert cdl;
delete documents;
}
}