Oh my god. It's full of code!

Dynamic PDF Generator

I recently had a requirment where a list of any kind of sObject could be given to a visualforce page, and it should spit out a PDF report of those objects. The fields returned could possibly be defined by a field set, passed in the URL directly, or I could get passed nothing and would just have to query for all fields on the object. It was decided that the best course of action was to write a nice re-useable apex class that can handle these requirements and use the visualforce renderas attribute to make it easy to generate printable reports. You can easily rig up a custom button on a list view to get the checked elements and pass them into the exporter page as well to basically allow exporting from any list view. The following is the first draft of said functionality.

4/03/2013 EDIT: Thanks to a good tip by Cal Smith I changed how the visualforce page outputs the content and it seems to be much faster and probably safer too. I also included two new params for the exporter. You can now provided a field to order by by specifying order_by in the url. Also, if you want the records returned in the same order the ids were provided in the url you can specify return_in_order=true. This is probably slow on large data sets but in cases where your users may have put records in the order they want you can then pass the ids in that order to the controller and the PDF will be generated with the same order. Kinda a nice feature I thought.

4/05/2013 EDIT: I added the force_download param and filename params to allow you to force the user to download the file and specify a name for the downloaded file instead of letting them view it in their browser. Not totally sure why someone might want this, but it was a request I got and it was fairly easy to add.

The Apex Class

/*
Name: queryGenerator
Author: Daniel Llewellyn
Date: 4/02/2013
Description: Meant to be invoked by a visualforce page. This class can take url params to query for a list of any kind of
             sObject. Those sObjects can then be used to power user interface elements. By passing in a list of Ids and an
             option field set, this class is able to determine the object type, find a matching field set, use a default field set
             or if none is specified query for all fields defined on the object. Useful for generating lists of sObjects when the type
             of object, and desired fields is not known ahead of time.

URL Params:
name            type         req      description
--------------------------------------------------------------------------------------------------------------------
ids:            csv of ids   true     a list of sObject Ids seperated by commas. The objects to include in the return query
fields:         string       false    a comma seperated list of fields to include. Takes precidense over fieldSet if specified.
fieldSet:       string       false    the name of a fieldset to use to determine which fields to include. Used if fields param not specified. If both are null, all fields are queried.
order_by        string       false    the name of a field on the object to order the results by
return_in_order boolean      false    should the results be returned in the same order they were provided in the URL? Overrides the order_by param if set to true.
force_download  boolean      false    should the PDF file be forced to donwload instead of displayed in the browser window?
filename        string       false    the name to assign to the downloaded file if force_download is set to true. Defaults to object label + ' report.pdf' Do not include .pdf. It is appened automatically.

Gotchas:
Due to the way the query is built (filtering by a list of Ids) you can only get probably about 500 records max before the query length gets too long.
Shouldn't be a big deal though, a report of more than 500 records starts to get kind of meaningless most of the time. It will attempt to gracefully handle
any errors and return them nicely to the user.

*/

public class queryGenerator
{
    //Params. Can be used in your visualforce page to customize report data.
    public Schema.SObjectType OBJECT_SCHEMA_TYPE{get;set;}
    public string OBJECT_TYPE_NAME{get;set;}
    public string OBJECT_TYPE_LABEL{get;set;}
    public string ORDER_BY{get;set;}
    public boolean RETURN_IN_ORDER{get;set;}
    public list<string> OBJECT_FIELDS{get;set;}
    public map<string,string> OBJECT_FIELD_MAP{get;set;}
    public list<id> OBJECT_IDS{get;set;}
    public list<sobject> OBJECTS{get;set;}
    public integer RECORD_COUNT{get { 
        return objects.size();
    }set;}

    public queryGenerator(){

        try
        {

            OBJECT_FIELD_MAP = new map<string,string>();

            //get the list of ids to query for. We expect them to come in a url param called ids, and they should be
            //comma seperated. Since we know that, we can split them based on , to get a list of ids.
            if(ApexPages.currentPage().getParameters().get('ids') == null)
            {
                throw new applicationException('Please include a list of a comma seperated ids to query for in the url by specifying ?ids=id1,id2,id3 etc');
            }
            OBJECT_IDS = ApexPages.currentPage().getParameters().get('ids').split(',');

            //use the ids getSObjecType method to figure out what kind of objects these are we got passed. 
            OBJECT_SCHEMA_TYPE = OBJECT_IDS[0].getSObjectType(); 

            //caching describe results makes for faster iteration
            map<string,Schema.sObjectField> fieldMap = OBJECT_SCHEMA_TYPE.getDescribe().fields.getMap();

            for(Schema.SObjectField field : fieldMap.values())
            {
                OBJECT_FIELD_MAP.put(field.getDescribe().getName(),field.getDescribe().getLabel());
            }

            //get the name of this object type
            OBJECT_TYPE_NAME = OBJECT_SCHEMA_TYPE.getDescribe().getName();

            OBJECT_TYPE_LABEL = OBJECT_SCHEMA_TYPE.getDescribe().getLabel();

            //get the list of fields we will query for and display
            if(ApexPages.currentPage().getParameters().get('fields') == null)
            {
                OBJECT_FIELDS = getObjectQueryFields(OBJECT_SCHEMA_TYPE, ApexPages.currentPage().getParameters().get('fieldset'));    
            }
            else
            {
                OBJECT_FIELDS = ApexPages.currentPage().getParameters().get('fields').split(',');
            }
            //set the order by statment. If no order by is specified, just tell it to order by Id to prevent a syntax error
            if(ApexPages.currentPage().getParameters().get('order_by') != null)
            {
                ORDER_BY= ApexPages.currentPage().getParameters().get('order_by');   
            }
            else
            {
                ORDER_BY = 'Id';
            }            

            RETURN_IN_ORDER = false;
            if(ApexPages.currentPage().getParameters().get('return_in_order') != null && ApexPages.currentPage().getParameters().get('return_in_order') == 'true')
            {
                RETURN_IN_ORDER = true;   
            }       

            OBJECTS = getSojects();   

            if(ApexPages.currentPage().getParameters().get('force_download') != null && ApexPages.currentPage().getParameters().get('force_download') == 'true') 
            {
                string fileName = 'Report of '+OBJECT_TYPE_LABEL+'.pdf';
                if(apexPages.currentPage().getParameters().get('filename') != null)
                {
                    fileName = apexPages.currentPage().getParameters().get('filename') +'.pdf';
                }
                Apexpages.currentPage().getHeaders().put('content-disposition', 'attachemnt; filename='+fileName);
            }    

        }
        catch(exception ex)
        {
            //catch and return errors. Most often will happen from a bad Id of fieldname being passed in.
            system.debug('\n\n\n------Error occured during page init!');
            system.debug(ex.getMessage() + ' on line ' + ex.getLineNumber());
            ApexPages.addmessage(new ApexPages.message(ApexPages.severity.WARNING,ex.getMessage() + ' on line ' + ex.getLineNumber()));

        }
    } 
    //this method will be invoked by a visualforce page. It will determine the sObject
    //type by examining the Ids passed in the ids param. Once it knows the object type it will
    //then attempt to find a locate the specified fieldset if one was passed in the URL. If no fieldset
    //was provided, then it will query for all sObject fields.

    public list<sobject> getSojects()
    {
        list<sobject> queryResults;

        //lets get a list of fields to query for by using the getObjectQueryFields method. We will pass in the object type
        //and the fieldset url param (which may be null, but that doesnt matter).
        string queryFields = listToCsv(OBJECT_FIELDS);

        //build this query string
        string queryString = 'select ' + queryFields + ' from ' + OBJECT_TYPE_NAME + ' where id in :OBJECT_IDS ORDER BY '+ORDER_BY;

        if(queryString.length() > 10000)
        {
            throw new applicationException('Query too long ('+queryString.length()+'). Please reduce the number of ids or reduce the number of fields queried for to get the length under 10,000');
        }
        //run the query.
        queryResults = database.query(queryString);

        if(RETURN_IN_ORDER)
        {
            queryResults = sortQueryInOrder(OBJECT_IDS, queryResults);
        }
        return queryResults;
    }

    //takes the list of sObjects and sorts them in the order they were passed in the URL. This allows for a custom sorting order to be passed in
    //without having to make use of the SOQL order by clause which may not be robust enough to handle the types of sorts desired.
    //WARNING THIS IS PROBABLY PRETTY DAMN SLOW!
    public list<sObject> sortQueryInOrder(list<id> objectOrder, list<sObject> objects)
    {
        map<id,sObject> objectMap = new map<id,sObject>();
        list<sObject> sortedList = new list<sObject>();
        for(sObject obj : objects)
        {
            objectMap.put((id) obj.get('id'), obj);
        }

        for(id objId : objectOrder)
        {
            sortedList.add(objectMap.get(objId));
        }
        return sortedList;

    }
    //takes an sObject type and optional name of a fieldset for that sObject type (can be null). Returns a list
    //of strings of fields to query for either based on the fieldset, or by finding all sObject fields if no fieldSet
    //is specified, or a matching fieldSet can not be found.
    public list<string> getObjectQueryFields(Schema.SObjectType objectType, string fieldSetName)
    {
        set<string> fields = new set<string>();
        Schema.FieldSet thisFieldSet = null;

        //first any fieldsets that are defined for this object type. It is possible this might be empty.
        Map<String, Schema.FieldSet> fieldSetMap = objectType.getDescribe().fieldSets.getMap();  

        //check to see if the user passed in a field set, and if so, does it exist? 
        //if so, use that fieldset. Otherwise, use all fields on the object
        if(fieldSetName != null && fieldSetMap.containsKey(fieldSetName))
        {
            thisFieldSet = fieldSetMap.get(fieldSetName);
            //now that we know what field set we are using we have to iterate over it and get it feildsetmembers
            //and add each field into the query string.
            for(Schema.FieldSetMember f : thisFieldSet.getFields())
            {
                fields.add(f.getFieldPath());
            }            
        }             

        //if there are no field sets defined for this object, then lets just query for all the fields
        else
        {
            fields = getObjectFields(objectType);            
        }

        //return our variable that contains a properly comma seperated list of all the fields to query for.
        list<string> fieldList = new list<string>();
        fieldList.addAll(fields);
        return fieldList;
    }

    //a simple possibly overly abstracted method to get the fields on an object
    public set<string> getObjectFields(Schema.SObjectType objectType)
    {
        return objectType.getDescribe().fields.getMap().keySet();
    }

    //takes a list of strings and returns them in a comma seperated fashion, suitable for feeding into a query.
    public string listToCsv(list<string> stringList)
    {
        string itemList = '';
        for(string thisString : stringList)
        {
            itemList += thisString+',';
        }
        itemList=itemList.substring(0,itemList.length()-1);
        return itemList;
    }

    @isTest
    public static void testQueryGenerator()
    {
        //setup our test account
        Account testAccount = new Account();
        testAccount.name = 'My Test account';
        testAccount.billingStreet = '1234 Test Street';
        testAccount.billingState = 'NY';
        testAccount.billingPostalCode = '55555';
        testAccount.billingCountry = 'USA';

        insert testAccount;

        test.StartTest();

        PageReference pageRef = Page.exportPdf;
        Test.setCurrentPage(pageRef);

        //run it with no ids. It will come back with no records since it will error. Since the error gets caught
        //we don't need to try/catch here though.
        queryGenerator qg = new queryGenerator();

        //run test with nothing but ids specified. This will make it query for all fields
        ApexPages.currentPage().getParameters().put('ids', testAccount.id);        
        qg = new queryGenerator();

        //make sure it found our account
        system.assertEquals(1,qg.RECORD_COUNT);
        system.assertEquals(testAccount.name,(string) qg.OBJECTS[0].get('name'));

        ApexPages.currentPage().getParameters().put('fields', 'name,id,billingStreet');        
        qg = new queryGenerator();        
        //make sure it found our account
        system.assertEquals(1,qg.RECORD_COUNT);
        system.assertEquals(testAccount.billingStreet,(string) qg.OBJECTS[0].get('billingStreet'));

        ApexPages.currentPage().getParameters().put('order_by', 'name'); 
        ApexPages.currentPage().getParameters().put('return_in_order', 'true'); 
        ApexPages.currentPage().getParameters().put('force_download', 'true');
        ApexPages.currentPage().getParameters().put('filename', 'My PDF file');
        qg = new queryGenerator();       

    }
    class applicationException extends Exception {}
}

The ExportPDF visualforce Page

<apex:page controller="queryGenerator" renderAs="pdf"  standardStylesheets="false">
<head>
  <style>
    @page {
        size:landscape;
        margin : .5in;
        @top-center {
            content : element(header);
         }

        @bottom-left {
            content : element(footer);
        }

    }
    table
    {
        width:100%;
    }
    @bottom-left {
        content : element(footer);
    }
    div.footer {
        position : running(footer) ;
    }    
  </style> 
</head>
    <apex:pageMessages></apex:pageMessages>
    <h1>Report of {!OBJECT_TYPE_LABEL} ({!RECORD_COUNT} Records)</h1>

    <table>
        <tr>
            <apex:repeat value="{!OBJECT_FIELDS}" var="FieldLable">
                <apex:outputText><th>{!OBJECT_FIELD_MAP[FieldLable]}</th></apex:outputText>
            </apex:repeat>        
        </tr>

        <apex:repeat value="{!OBJECTS}" var="rec">
            <tr>
                <apex:repeat value="{!OBJECT_FIELDS}" var="FieldLable">
                    <apex:outputText><td>{!rec[FieldLable]}</td></apex:outputText>
                </apex:repeat>
            </tr>
        </apex:repeat>
    </table>

    <div class="footer">
    <apex:outputText value="The Date: {0,date,MMMMM dd, yyyy 'at' hh:mm a}" styleClass="footer" >
        <apex:param value="{!NOW()}" />
    </apex:outputText> 
    </div>    
</apex:page>

Sample List View Button

window.open('/apex/exportPdf?ids='+ {!GETRECORDIDS($ObjectType.YOUR_OBJECT_TYPE)}+'&fieldset=YOUR_FIELD_SET_NAME_HERE&order_by=name&return_in_order=false','1364931211178','width=700,height=500,toolbar=0,menubar=0,location=0,status=1,scrollbars=1,resizable=1,left=0,top=0')

You’ll need to replace the $ObjectType.YOUR_OBJECT_TYPE and the fieldset=YOUR_FIELD_SET_NAME_HERE in the list view button. Or you can just remove the fieldset part entirly, or replace it with a ‘fields’ attribute where you can specify a comma separated list of fields to query for. You’ll probably want to play with the formatting of the report a little but, but I’ll leave that as an exercise to the reader. Hopefully this helps someone out there.

11 responses

  1. Cal Smith

    Great work!

    I’d add the following based on experience and the SF Apex documentation: Best Practices for Rendering a PDF:

    Components to Use with Caution in a PDF

    Components That Are Unsafe to Use in a PDF

    I’d suggest you replace the above in your mark-up with standard HTML such as open and closed and tags instead of and which you could easily do using outputText with param value repeats for each of your sObjects.

    You’ll also want to add standardStylesheets=”false” at the top of your page. PDF’s don’t render well using standard VF stylesheets.

    April 3, 2013 at 5:50 pm

    • Good call. I’m updating my code now. New version will be posted shortly. Thanks for the heads up!

      April 4, 2013 at 2:34 am

  2. I LOVE the Description and Gotchas section, I’ve never seen anyone do that before and I think it is excellent. Also love the commented code; as a beginner it is tremendously helpful to see comments. Thank you, I read every post. Keep up the great work!

    April 17, 2013 at 1:52 pm

    • Hey man, glad you liked it. Thanks for the kind words. I’ll keep posting as I keep coming up with cool stuff, so stay tuned 🙂

      April 17, 2013 at 3:40 pm

  3. Unfortunately, for start-ups, spending a news ton of criticism even from conservatives.
    Israeli agriculture officials sent crop dusters into the air to spray against
    the locusts that swept in from North Africa in the first paragraph contained the most pertinent information.

    August 29, 2013 at 11:10 pm

  4. Error Error: queryGenerator Compile Error: Test methods must be in test classes at line 199 column 24 I am getting this error please send correct answer to me

    I just copy and paste in my org

    plz send reply to bhaskar.anumolu@gmail.com

    October 30, 2013 at 4:40 am

    • Dude I dont mean to be rude but that error is an easy fix and would take 2 seconds to figure out if you actually read it or did any research. You sound extremely entitled when you tell me to email you a fix. I’ve already done all the hard work, try doing something for yourself. I don’t owe you anything, I run this blog purely as a favor to the community and when people start demanding I do stuff for them it makes me wonder why I bother.

      October 30, 2013 at 4:46 am

      • Jacob

        You should introduce a “like” button mate.

        Thanks for sharing bro

        February 4, 2014 at 8:56 am

  5. Jake H

    Awesome work man!

    Appreciate the info

    November 20, 2014 at 11:30 pm

  6. Awesome things here. I am very satisfied to peer your post.

    Thank you so much and I am taking a look ahead to contact you.
    Will you kindly drop me a mail?

    May 8, 2015 at 2:03 am

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s