8

I've got a class which uses a wrapper class to handle OpportunityLineItem's which both have ids and don't have an id. Before calling an upsert on these records to update them, I extract the records from the wrappers, then use those records.

This works perfectly, except if someone tries to set the Quantity or UnitPrice to 0. I think the field itself rejects these changes, so I added a validation rule over the fields to try to help with this. When this rule fails, the system throws a DMLException, as expected. This DML Exception specifies the real cause of the problem:

FIELD_INTEGRITY_EXCEPTION, field integrity exception: TotalPrice

This however, is not the error presented to the end user. Instead, they get:

FATAL_ERROR|System.StringException: Invalid id: ProductManager.UpdateAll();

What happens between these two errors? SLEEPY. The system tries to update a list of records, which looks like:

{call sOppLineItem.update_opplineitems(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)}

Which clearly fails. Then this message:

15:46:29.0 (124359697)|VF_PAGE_MESSAGE|common.exception.SfdcSqlException: ORA-20067: OPPORTUNITY_LINEITEM.QUANTITY
ORA-06512: at "SLEEPY.SOPPLINEITEM", line 1203
ORA-06512: at line 1

Instead of a validation rules highlight, they get a broken page. I can replicate this in a sandbox, but I don't have a mention of SLEEPY in that log.

Heres a repo class & page. You'll need to create a project, add a few products, and then change the preset id (i know, its a demo) to your project.

public class ProductManager_demo {

    private Id projectId {
        get {
            return Project == null ? '' : Project.Id; 
        }
    }

    public String projectName { 
        get {
            return Project == null ? '' : Project.Name; 
        } 
    }

    public Opportunity Project { get; set; }

    @testVisible 
    private Map<Integer, ProductWrapper> ProductWrapperMap { get; set; }

    // Used for DeleteSelected and UpdateTotals    
    public Integer SelectedProductId { get; set; }

    public ProductManager_demo() {
        //if (Utility.IsBlank(ApexPages.currentPage().getParameters().get('id'))) {
            //ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, 'There must be an Id specified in the url parameters. Please contact your administrator.'));
        //} else {
            Project = [
                SELECT Id, Name, Amount
                FROM Opportunity 
                WHERE Id = '006c000000IE6RI' // :ApexPages.currentPage().getParameters().get('id')
                LIMIT 1 
            ];
        //}
    }

    public List<ProductWrapper> getProducts() {
        if (ProductWrapperMap == null) {
            ProductWrapperMap = new Map<Integer, ProductWrapper>(); 

            ProductWrapperMap.putAll(ConvertToWrapper(QueryLineItems())); 
        } 

        return ProductWrapperMap.values(); 
    }

    // Replace w/ field set ? 
    private List<OpportunityLineItem> QueryLineItems() {
        return [
            SELECT Id, Description, Quantity, UnitPrice, ServiceDate
            FROM OpportunityLineItem 
            WHERE OpportunityId = :projectId
        ];
    }

    public void DeleteSelected() {
        DeleteSelected_future(ProductWrapperMap.get(SelectedProductId).Record.Id); // may return null - need to test 
        ProductWrapperMap.remove(SelectedProductId); 
    }

    // Need to check invoice amount before rendering delete link & in product trigger 
    // 
    // Uses @future to quickly remove product from page, and then very shortly after, delete the product 
    @future 
    public static void DeleteSelected_future(Id id) {
        if (Utility.IsNotBlank(id)) {
            try {
                delete [SELECT Id FROM OpportunityLineItem WHERE Id = :id]; 
            } catch (Exception e) {
                ApexPages.addMessages(e);
                ErrorLogger.LogApexError(e, '@future ProductManager.Delete();', id);
            }
        }
    }

    // Will likely remove 
    public PageReference DeleteAll() {
        try {
            delete ExtractValidValuesFromWrapperMap(ProductWrapperMap);

            ProductWrapperMap = null; 

            return Back();
        } catch (Exception e) {
            ApexPages.addMessages(e);
            ErrorLogger.LogApexError(e, 'ProductManager.DeleteAll();', project.Id);
        }

        return null;
    }

    public PageReference UpdateAll() {
        try {
            upsert ExtractValuesFromWrapperMap(ProductWrapperMap);

            return Back();
        } catch (Exception e) {
            ApexPages.addMessages(e);
            ErrorLogger.LogApexError(e, 'ProductManager.UpdateAll();', project.Id);
        }

        return null;
    }

    public PageReference Back() {
        return new PageReference('https://' + System.URL.getSalesforceBaseUrl().getHost() + '/' + projectId);
    }

    // Below methods are for interacting with ProductWrappers - could likely be moved somewhere else 
    // Unique per record Hash (Could also use System.HashCode())
    private static Integer someHash = 0; 

    private static Integer NextHash() {
        someHash ++; 
        return someHash; 
    }

    private Map<Integer, ProductWrapper> ConvertToWrapper(List<OpportunityLineItem> items) {
        Map<Integer, ProductWrapper> productMap = new Map<Integer, ProductWrapper>(); 

        for (OpportunityLineItem item:items) {
            ProductWrapper wrapper = new ProductWrapper(item);

            productMap.put(wrapper.Hash, wrapper); 
        }

        return productMap; 
    }

    // Problem is likely with data returned from here 
    // Data isn't the same as on page - problem? Cant connect back to wrapper to show validation error on page? 
    private List<OpportunityLineItem> ExtractValidValuesFromWrapperMap(Map<Integer, ProductWrapper> wrapperList) {
        List<OpportunityLineItem> items = new List<OpportunityLineItem>();

        for (ProductWrapper wrapper:wrapperList.values()) {
            if (Utility.IsNotBlank(wrapper.Record.Id)) {
                items.add(wrapper.Record);
            }
        }

        return items; 
    }

    private List<OpportunityLineItem> ExtractValuesFromWrapperMap(Map<Integer, ProductWrapper> wrapperList) {
        List<OpportunityLineItem> items = new List<OpportunityLineItem>();

        for (ProductWrapper wrapper:wrapperList.values()) {
            items.add(wrapper.Record);
        }

        return items; 
    }

    @testVisible
    private class ProductWrapper {

        public Integer Hash { 
            get {
                if (Hash == null) {
                    Hash = ProductManager_demo.NextHash(); 
                }

                return Hash; 
            } 
            set;
        }

        private PriceBookEntry Entry {
            get;
            set;
        }

        public String Name {
            get {
                if (Entry != null && Utility.IsNotBlank(Entry.Name)) {
                    return Entry.Name; 
                } else {
                    return ''; 
                }
            }
        }

        public Double ListPrice {
            get {
                if (Entry != null && Entry.UnitPrice != null)  {
                    return Double.ValueOf(Entry.UnitPrice);
                } else {
                    return 0; 
                }
            }
        }

        public OpportunityLineItem Record { get; set; }

        public ProductWrapper(Id projectId, PriceBookEntry entry) {
            Record = new OpportunityLineItem(
                OpportunityId= projectId,
                PricebookEntryId= entry.Id,
                UnitPrice= entry.UnitPrice, 
                Quantity= 1
            );

            this.Entry = entry; // Stored in own record to prevent recalculateFormulas error 
        }

        public ProductWrapper(OpportunityLineItem lineItem) {
            this.Record = lineItem;
        }
    }
}

Page

<apex:page controller="ProductManager_demo" tabstyle="Opportunity" title="Manage Products for: {!projectName}" sidebar="false" >
    <apex:sectionHeader title="Manage Products" subtitle="Manage Products on {!projectName}"/>
    <apex:outputPanel rendered="true" id="componentBody">
        <apex:form>

            <apex:pageBlock title="Manage Products for: {!projectName}">
                <apex:pageBlockButtons location="top">
                    <div id="top-button-stop">
                        <apex:commandButton value="Delete All" immediate="true" action="{!DeleteAll}" rerender="table_panel" rendered="{!IF((products.size > 0), true, false)}" style="float: right; margin-bottom: 7px;" />
                    </div>
                    <div id="top-button-start">
                        <apex:commandButton value="Saving..." disabled="true" style="float: right; margin-bottom: 7px;" />
                    </div>
                </apex:pageBlockButtons>

                <apex:pageBlockButtons location="bottom" rendered="{!IF((products.size > 0), true, false)}">
                    <apex:actionStatus id="SavingStatus">
                        <apex:facet name="stop">
                            <apex:outputPanel id="stop_panel">
                                <apex:commandButton value="Save" action="{!UpdateAll}" status="SavingStatus" rerender="table_panel,stop_panel" />
                                <apex:commandButton value="Cancel" immediate="true" status="SavingStatus" action="{!Back}" />
                            </apex:outputPanel>
                        </apex:facet>
                        <apex:facet name="start">
                            <apex:outputPanel >
                                <apex:commandButton value="Saving..." disabled="true" status="SavingStatus" />
                                <apex:commandButton value="Saving..." disabled="true" status="SavingStatus" />
                            </apex:outputPanel>
                        </apex:facet>
                    </apex:actionStatus>
                </apex:pageBlockButtons>
                <apex:outputPanel rendered="{!IF((products.size > 0), true, false)}">
                    <apex:outputPanel id="table_panel" rendered="true" layout="block">
                        <apex:pageMessages />
                        <apex:pageBlockTable value="{!products}" var="wrapper">
                            <apex:column value="{!wrapper.Hash}" />
                            <apex:column headerValue="Product Name" width="600px">
                                <apex:outputText value="{!wrapper.Name}" />
                                <apex:outputPanel rendered="{!ISBLANK(wrapper.Record.Id)}"><span style="color: red;">&nbsp;(Unsaved)</span></apex:outputPanel>
                            </apex:column>
                            <apex:column headervalue="Number of Units">
                                <apex:inputField value="{!wrapper.Record['Quantity']}" onblur="updateTotals('Quantity', this.value, '{!wrapper.Hash}');" />
                            </apex:column>
                            <apex:column headervalue="List Price">
                                <apex:outputText value="{0, Number, Currency}">
                                    <apex:param value="{!wrapper.ListPrice}" /> 
                                </apex:outputText>
                            </apex:column>
                            <apex:column headervalue="Sales Price">
                                <apex:inputField value="{!wrapper.Record['UnitPrice']}" required="true" onblur="updateTotals('UnitPrice', this.value, '{!wrapper.Hash}');" />
                            </apex:column>
                            <apex:column headervalue="Date">
                                <apex:inputField value="{!wrapper.Record['ServiceDate']}" />
                            </apex:column>
                            <apex:column headervalue="Line Description">
                                <apex:inputField value="{!wrapper.Record['Description']}" />
                            </apex:column>
                            <apex:column headervalue="Remove Product">
                                <apex:commandLink action="{!DeleteSelected}" value="Delete" rerender="table_panel">
                                    <apex:param name="SelectedProductId" value="{!wrapper.Hash}" assignTo="{!SelectedProductId}" />
                                </apex:commandLink>
                            </apex:column>
                        </apex:pageBlockTable>
                    </apex:outputPanel>
                </apex:outputPanel>
                <apex:outputPanel rendered="{!IF((products.size <= 0), true, false)}">
                    <apex:pageMessages />
                    <div style="margin-left: 31%; margin-top: 20px;">
                        No records to display.
                    </div>
                </apex:outputPanel>
            </apex:pageBlock>
        </apex:form>
    </apex:outputPanel>
</apex:page>

I'm pretty sure the problem is with the objects returned from the wrapper class. These are technically the same records whos fields are displayed on the layout, but I'm wondering if the wrapper is enough to much up salesforce.

I'm also sure I'll need to contact salesforce support over this one, but, well, I honestly don't want to if I don't have too, I'm expecting to be bumped to the developer forums since I don't have developer support (Unless platform bugs like this get priority). Hopefully someone whos seen this before can chime in with something I can try. I'll be trying a few things on my own over the next 24 hours, such as dropping the wrapper class and other error handling patterns (I just need the right exception to show on the page).

3
  • If you had a straight GACK response then support should accept it. Even if you don't have premier support. A dwarf hidden in a VF_PAGE_MESSAGE may be a harder sell Jun 4, 2018 at 22:36
  • Also, I wouldn't expect to be able to set the Quantity to zero. As per the idea Opportunity with Products - change amount total and zero quantity Jun 4, 2018 at 22:39
  • I'm expecting validation failures from two rules I added to help clarify that users can't modify these fields. Instead, I get a crashed page. Im trying to trim up the UI to report the right error. Jun 5, 2018 at 13:53

1 Answer 1

7

I've reproduced the problem with setting the OpportunityLineItem.Quantity to zero. As you would expect, there is a field integrity exception: Quantity (quantity must be nonzero) message added to the page automatically.

I also got the unexpected Dwarf details added to the page messages.

enter image description here

It appears the additional validation message is coming from the exception handling in UpdateAll()

try {
    upsert ExtractValuesFromWrapperMap(ProductWrapperMap);

    return Back();
} catch (Exception e) {
    // The call to addMessages is appending the additional Dwarf details.
    ApexPages.addMessages(e);
    ErrorLogger.LogApexError(e, 'ProductManager.UpdateAll();', project.Id);
}

I found if you leave out the ApexPages.addMessages(e); you still get the field validation message without the underlying SfdcSqlException details.

Beyond that, raising a support case would be your best option. Let me know if they give you the run around and I might be able to help. Ideally they should be accepting platform bugs regardless of support level.

I found the Apex to replicate the problem could be greatly simplified:

Controller:

public class ProductManager_demo {

    public PageReference UpdateAll() {
        try {
            // Update hardcoded OLI ID as needed
            OpportunityLineItem oli = [Select Id, Quantity from OpportunityLineItem where Id = '00k0g00000ifVtiAAE'];
            oli.Quantity = 0;
            update oli;

        } catch (Exception e) {
            ApexPages.addMessages(e);
            System.debug(LoggingLevel.Error, e);
        }

        return null;
    }

}

Visualforce Page:

<apex:page controller="ProductManager_demo" tabstyle="Opportunity" title="Manage Products" sidebar="false" >

    <apex:outputPanel rendered="true" id="componentBody">
        <apex:form>
            <apex:pageBlock title="Manage Products">
                <apex:pageBlockButtons location="bottom">
                    <apex:commandButton value="Save" action="{!UpdateAll}" />
                </apex:pageBlockButtons>
            </apex:pageBlock>

            <apex:pageMessages />

        </apex:form>
    </apex:outputPanel>
</apex:page>

This dumps the dwarf details out to the page messages.

enter image description here

6
  • Formatting got a little screwy.
    – Adrian Larson
    Jun 4, 2018 at 23:31
  • @AdrianLarson Better now? Jun 4, 2018 at 23:32
  • Huh. Not sure why adding another page message would cause the page to "crash" and just show the one message, instead of just adding the message? Ill definitely take a look at this tomorrow. Jun 4, 2018 at 23:47
  • 1
    @battery.cord I suspect the ApexPages.addMessages(e); is walking up the chain of exceptions too far and getting into the internal error handling. Jun 4, 2018 at 23:49
  • 1
    .. Its my error logger. Its trying to do something invalid & ends up throwing some crazy errors around. Thats where the third error (Invalid Id) comes from. Without my extra validation rules you still get a SLEEPY error code dumped onto the page, but with some rules in place (Q > 0, LP > 0) you get some nice validation messages, bound to the right fields. I cant help but see the irony that my own error handling created a monster of an error. Jun 5, 2018 at 14:18

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .