ProIT: медіа для профі в IT
18 хв.

Пагінація нескінченного списку записів у Salesforce. Складнощі та методи вирішення

author avatar NIX Team

Salesforce developer Євген Кисельов команди NIX спеціально для ProIT розповів про пагінацію на основі порівняння записів по одному з полів. Цей спосіб пагінації не є новим чи незвичайним, але мало де докладно описаний. Те, що ми показали відрізняється від тих способів, які ви можете нагуглити.

Що таке пагінація?

Простіше кажучи — посторінкова навігація. Це спосіб відображення великої кількості однотипної інформації шляхом розподілу контента на сторінки. Багатьом Salesforce-розробникам, у тому числі й мені, доводиться мати справу з пагінацією при відображенні великої кількості даних на інтерфейсі користувача. На одному з моїх проєктів у таблиці з даними виводилися телефонні номери. У деяких випадках дані не відображалися, тому що вивантаження інформації відбувалося надто довго. Користувачі не могли отримати жодних даних. Чому ж так відбувалося?

Контакти обиралися на основі кількох вкладених запитів до бази даних

Ми мали справу з декількома рівнями Parent-Child зв'язків між Контактами і дочірніми об'єктами. За вимогою бізнес-логіки потрібно було фільтрувати контакти, як на основі фільтрів самих контактів, так і на основі фільтрів які застосовані до дочірніх об’єктів контактів.

Приклад Parent-Child зв'язків

Величезна кількість записів у Контактах (кілька сотень тисяч) та їх дочірніх об'єктів.

Щоб пояснити необхідність обраного мною способу пагінації, перерахую й відразу порівняю чотири інших методи, котрі пропонує платформа Salesforce:

  • Пагінація з використанням List Controller for Visualforce pages
  • Пагінація з використанням Database.getQueryLocator class
  • Пагінація із застосуванням SOQL запиту та OFFSET оператора
  • Пагінація за допомогою Apex коду для вилучення всіх батьківських записів у єдиний список за допомогою SOQL запиту. Далі з цього списку можна обрати потрібні записи відповідно до сторінки.

Перші три інструменти мені не підійшли з таких причин:

  1. List Controller for Visualforce pages не застосовується для компонентів LWC і має обмежену кількість записів, які він здатний обробити — 10 000 записів.
  2. getQueryLocator також має ліміт 10 000 записів і взагалі не поєднується з умовою задачі.
  3. SOQL запит з OFFSET оператором лімітовано 2000 записів, які можуть забезпечити OFFSET у запиті. Тож не може використовуватись при пагінації у разі великої кількості даних.

Окремо варто розглянути 4 варіант пагінації. І давайте відразу зазначимо  перший важливий нюанс — у нас немає можливості отримати всі дані, тому що є ліміт 50 000 записів які ми можемо отримати у всіх запитах в межах однієї транзакції. До чого це призводить? Якщо ми запитуємо дочірні записи, на підставі яких потім запитуємо контакти, можемо отримати, наприклад, 47 000 усіх дочірніх записів. Але тоді ми можемо вилучити лише 3000 контактів. Навіть якщо їх насправді більше, наприклад, 6000. Тобто користувачу ми надамо завідомо недостовірні дані. Він просто не знатиме, скільки записів він справді може отримати в таблиці з даними. Він не побачить частину Контактів і не буде взаємодіяти з ними, оскільки вважатиме, що має не всі дані. Я називаю це “Айсберг даних користувача”.

Айсберг даних користувача

Таким чином користувач отримує спотворену, неповну інформацію, що негативно вплине на його роботу з даними.

Другий момент — агрегація у списки даних із вкладених запитів до БД займає багато часу. Це спричиняє перевищення CPU Time limit. У результаті користувач зовсім не отримує даних. Зменшити час на обробку вкладених запитів, так само як і здолати ліміти записів в одній транзакції, ми не можемо. Адже маємо обмеження, накладені базою даних Salesforce (далі розповім, як можна обійти ці ліміти). Тому я вирішив зменшити кількість записів у запиті для самих контактів, щоб хоча б за рахунок цього скоротити час на отримання та обробку даних. Тож, якщо раніше я писав запит для контактів:

SELECT Id FROM Contact WHERE Id IN :ids ORDER BY Next_Contact_Date_Time__c LIMIT 50000,

де ids — це лист, що включає Id дочірніх записів із вкладеного запиту, то тепер я повинен написати:

SELECT Id FROM Contact WHERE Id IN :ids ORDER BY Next_Contact_Date_Time__c LIMIT 50, обмеживши кількість контактів на сторінці до 50-ти.

Це дозволило зменшити загальний час на роботу коду з вилучення записів, необхідних користувачеві. А також витягувати або всі записи Контактів, або значно більшу частину, ніж у першому запиті.

Однак це лише перші 50 контактів, а мені потрібні всі контакти для таблиці даних…

Що далі?

На деяких ресурсах пропонують упорядковувати записи за певним полем. Наприклад, по ID. Тоді пагінація здійснюється за рахунок порівняння значення поля зі значенням поля останнього або першого запису попередньої сторінки. Все залежить від напряму пагінації.

Ідеї, які мені траплялися, були або не до кінця допрацьовані, або недостатньо розкриті для більш загального випадку застосування. До того ж такий метод (наскільки я можу судити з особистого досвіду) досить рідко використовується. Переважна кількість рекомендацій в інтернеті стосуються застосування одного з 4-х вищенаведених інструментів пагінації. На мою думку, це незаслужено. Тож саме цю ідею я взяв на озброєння і довів її практично до готового в багатьох випадках застосування коду.

Як це працює?

  • Для “гортання” сторінок від попередньої до наступної потрібно впорядковувати в кожному запиті до бази даних записи по одному з полів (наприклад, ID) у порядку зростання. В умові запиту вказується, щоб величина цього поля для N-записів наступної сторінки була більшою, ніж величина поля останнього запису попередньої сторінки.
  • Для “гортання” сторінок назад потрібно впорядковувати у кожному запиті до бази даних записи одного з полів у порядку спадання. Тепер в умові запиту величина поля для N-записів наступної сторінки має бути меншою, ніж величина поля для першого запису попередньої сторінки.

Дещо “випадають” з цієї концепції перша та остання сторінки. Перша — тому що для неї немає попередньої сторінки, а остання — бо для неї немає наступної. А також через те, що остання сторінка майже завжди містить менше записів, ніж усі попередні.

Як ця концепція виглядає в коді?

Нижче наведу приклад. Зверніть увагу: у методах зафіксовано розмір сторінки в 50 записів. Якщо вам знадобиться інший розмір, можете замінити 50 на той, що вам потрібен. Або замість цього числа введіть у методи додатковий параметр, в який будете передавати необхідний розмір сторінки.

Я використовував два методи, котрі стосуються LWC компонента, що містить таблицю з даними. Фільтри, призначені для вилучення контактів із бази даних, містилися в записах окремого об'єкта. Але замість цього можете використовувати згенерований вами об'єкт JSON в коді LWC компонента.

Перший метод 'getFirstPage' компонента LWC призначений для отримання записів для першої сторінки при початковому завантаженні таблиці:

/** LWC component method that initially retrieves the data for the first page */
getFirstPage(event){
    /** Imported method to component @salesforce/apex/ApexClassName.getContacts 
    * Parameters:
    * recId: filterRecordId - ID of the record that contains filters for retrieving contacts
    * pageRecords: null - array of the records IDs from the page to pass them into the Apex methods as the previous page IDs, it has the NULL value when the first page is loaded first time as we have no previous page yet
    * comparingSign: null - the symbol for the copmarison of the records by the field which is used for ordering the records(contacts), it has the NULL value when the first page is loaded the first time
    * order: ordering symbol which can be 'ASC' or 'DESC' depending on your wishes
    */
    /* The variable that defines in ASC or DESC order records should be sorted. You can use some drop-down menu on the UI for selecting it. */
    let currentSorting = event.detail.sortingOrder;
    getContacts({ recId: filterRecordId, pageRecords: null, comparingSign: null, order: currentSorting, currentPage: 1, sortingOrder: currentSorting })
        .then((result) => {
            if (result.contacts.length > 0) {
                //the first page contacts
                this.contacts = [...this.result.contacts];
                //the total pages count
                this.totalPages = this.result.totalPages;
                /** The returned number of the first page.
                 * You can modify and assign the first page directly if you want.*/
                this.currentPage = this.result.currentPage;
                /* Your code that processes the data */
            }
            else {
                this.contacts = [];
                this.totalPages = 0;
                this.currentPage = 0;
            }
        })
        .catch((error) => {
            console.log(error);
            console.log('error due to request' + error);
        });
}

Наведений метод компонента викликає метод Apex класу getContacts, який повертає об'єкт із контактами для початкової сторінки. Другий метод handlePageChange LWC компонента призначений для обробки подій. А саме для натискання користувачем на кнопки управління для переходу на наступну, попередню, останню та першу сторінку після того, як була отримана початкова перша сторінка.

/** LWC method that retrieves the data when on of the pagination buttons (next page/previous page/last page/fist page) is clicked */
handlePageChange = (message) => {
    /* The variable that defines in ASC or DESC order records should be sorted. You can use some drop-down menu on the UI for selecting it. */
    let currentSorting = message.sortingOrder;
    /* Selecting the comparison sign and ordering direction depending on the pagination direction */
    let compSign;
    let sortOrder;
    if (currentSorting = 'ASC') {
        compSign = this.currentPage > message.currentPage ? '<' : '>';
        sortOrder = this.currentPage > message.currentPage ? 'DESC' : 'ASC';
    } else {
        compSign = this.currentPage > message.currentPage ? '>' : '<';
        sortOrder = this.currentPage > message.currentPage ? 'ASC' : 'DESC';
    }
    /* Combining the record IDs from the page to pass them into the Apex methods as the previous page IDs */
    let contIds = [];
    this.contacts.forEach(cont => {
        contIds.push(cont.contactId);
    });
    let conditions = {};
    conditions.recId = this.dialListId;

    /** Selecting the comparison sign and ordering direction depending on the pagination direction and checking if the next page is the first/last page */
    if (message.currentPage == 1 || message.currentPage == this.totalPages) {
        conditions.pageRecords = null;
        conditions.comparingSign = null;
        if (currentSorting = 'ASC') {
            conditions.order = message.currentPage == 1 ? 'ASC' : 'DESC';
        } else {
            conditions.order = message.currentPage == 1 ? 'DESC' : 'ASC';
        }
    } else {
        conditions.pageRecords = contIds;
        if (currentSorting = 'ASC') {
            conditions.comparingSign = this.currentPage > message.currentPage ? '<' : '>';
            conditions.order = this.currentPage > message.currentPage ? 'DESC' : 'ASC';
        } else {
            conditions.comparingSign = this.currentPage > message.currentPage ? '>' : '<';
            conditions.order = this.currentPage > message.currentPage ? 'ASC' : 'DESC';
        }
    }
    this.currentPage = message.currentPage;

    /* Imported method to component @salesforce/apex/ApexClassName.getContacts */
    getContacts({
        recId: conditions.recId, pageRecords: conditions.pageRecords,
        comparingSign: conditions.comparingSign, order: conditions.order,
        currentPage: message.currentPage, sortingOrder: currentSorting
    })
        .then((result) => {
            this.contacts = this.formatContacts(result.contacts);
            this.totalPages = result.totalPages;
            /* < Your code that processes the data > */
        })
        .catch((error) => {
            console.log(error);
            console.log('error due to request' + error);
        });
}

Метод handlePageChange компонента також викликає метод Apex класу getContacts, який повертає об'єкт з контактами для початкової першої сторінки.

Слід відзначити, що через наявний код і задля збереження подібності підходів між різними інтерфейсами, я використав два методи. Але ви можете трохи видозмінити другий метод handlePageChange і використовувати лише його для завантаження початкової сторінки.

Apex клас, зазначений у коді методів LWC компонента як ApexClassName, має наступні методи:

1. getContacts:

public static Integer totalRecordsCount;
public static String CONTACT_FIELDS = 'FirstName, LastName';

/** getContacts is the method that returns the contacts to front-end logic in LWC 
* If you want you can combine getContacts and getData methods into one method */
@AuraEnabled(cacheable = true)
public static PayLoad getContacts(String recId, List < String > pageRecords, String comparingSign, String order, Integer currentPage, String sortingOrder){
PayLoad payloadResult = new PayLoad();
    /* Sending the parameters to the getData method that returns contacts for the current page */
    List < Contact > contacts = getData(recId, pageRecords, comparingSign, order, currentPage, sortingOrder);
Double pagesCount = Double.valueOf(totalRecordsCount);
Double totalPages = Decimal.valueOf(pagesCount / 50).round(System.RoundingMode.UP);
    // The returned total pages number
    payloadResult.contacts = contacts;
    payloadResult.totalPages = Integer.valueOf(totalPages);
    payloadResult.totalContacts = totalRecordsCount;
    payloadResult.currentPage = currentPage;
    return payloadResult;
}
/** The returned type of data to LWC component */
public class PayLoad {
    @AuraEnabled public Integer totalPages;
    @AuraEnabled public Integer totalContacts;
    @AuraEnabled public Integer currentPage;
    @AuraEnabled public List<Contact> contacts;
} 

Цей метод лише передає параметри в метод getData і повертає результат їх обробки в LWC компонент у вигляді об'єкта класу PayLoad.

2. getData:

/** The method that retrieves the IDs of the child records and prepares the part of the input parameters for the method getRecords that retrieves contacts */
public static List < Contact > getData(String recId, List < String > pageRecords, String comparingSign, String order, Integer pageNumber, String sortingOrder){
    List < Id > ids = new List < Id > ();
/* Some custom code that retrieves the IDs of the child records using the filtering logic saved in the record with the id='recId' and adds them to the 'ids' variable*/
/** Instead of the 'recId' and saved logic in the record of some SObject you can pass filtering logic inside JSON object for example. It depends on how you want to build your application */
String idAsString = idSetAsSting(ids);//This is the conversion of the list of IDs into a string 
/* the countQuery string is to define the total scope of the contacts that are corresponding to our condition. Here you can use your own condition for the definition of the records total count */
String countQuery = 'SELECT count() FROM  Contact WHERE Id IN ' + idAsString;
    totalRecordsCount = database.countQuery(countQuery);
/** The queryLimit is the required parameter  for specifying the number  of records per page. This is required because the last page may have a  different quantity of records than  the other pages have */
Integer queryLimit = findCurrentLimit(totalRecordsCount, pageNumber);
String query = 'SELECT Id, ' + CONTACT_FIELDS + ' FROM Contact' + ' WHERE Id IN ' + idAsString;
/** The previous page Contacts are required to compare the last or the first record ID depending on pagination direction */
String queryPreviousPage = 'SELECT ID,Next_Contact_Date_Time__c FROM Contact WHERE ID IN :pageRecords ORDER BY Id ' + sortingOrder;
    List < Contact > previousContacts = database.query(queryPreviousPage);
    /** The next string is the contacts retrieved for the page */
    List < Contact > contacts = getRecords(previousContacts, comparingSign, order, queryLimit, query, 'Id', sortingOrder);
    return contacts;
}

Метод getData використовується для:

  • виконання всіх вкладених запитів у БД (за потреби й супутньої логіки);
  • формування списку ID контактів, які задовольняють результатам пошуку всередині виконаних вкладених запитів (цей список позначений змінною 'ids');
  • формування параметрів для отримання записів даної сторінки, а саме: queryLimit — кількість записів, що виводяться на сторінку; previousContacts — перелік записів (у моєму випадку — список контактів).

Отримання кількості записів, що виводяться на сторінку, необхідно проводити з метою їхнього обмеження для останньої сторінки. Таким чином, не буде порушуватися послідовність записів при гортанні сторінок від попередньої до наступної.

Отримання записів попередньої сторінки previousContacts через SOQL запит не є обов'язковим. Це зручно, коли ви працюєте з порівняно статичними даними, які не змінюються занадто часто. Також це трохи зменшує обсяг інформації, котра передається на сервер для подальшої обробки. В іншому випадку краще передавати список даних безпосередньо зі сторінки. Важливо враховувати можливість змінити місце запису на сторінці або перенесення записів на інші сторінки при зміні даних всередині записів. До речі, цього не позбавлена й пагінація за допомогою інших методів.

3. getRecords:

/** The method that retrieves contacts for the current page.
* Its input parameters are: 
* pageRecords - the list of the records from the previous page
* comparingSign - one the signs '>' or '<'
* order - the order in which the records are ordered in the particular request
* newLimit - the quantity of the records for the current page
* query - the query with filters that will be modified to get the records for the current page
* orderBy - the name of the object field according to which the records are ordered
* sortingOrder - the order in which the records are ordered for the pages 
*/
public static List < SObject > getRecords(List < SObject > pageRecords,
    String comparingSign, String order, Integer newLimit, String query, String orderBy, String sortingOrder){
String lastId; //the variable that stores the ID that will be used in the query for comparison
String orderByString = orderBy; //the necessity of the orderByString variable will be explained further 
String firstQuery = query; //the necessity of the firstQuery variable will be explained further 
    if (pageRecords != null && !pageRecords.isEmpty()) {
        if (order == sortingOrder) {
            //if records are ordered in ascending order the lastId equals to the ID of the last record from the previous page
            lastId = String.valueOf(pageRecords[pageRecords.size() - 1].get(orderByString));
        } else {
            //if records are ordered in descending order the lastId equals to the ID of the first record from the previous page
            lastId = String.valueOf(pageRecords[0].get(orderByString));
        }
        lastId = '\'' + lastId + '\'';
    }
    //if the current page is not the first or the last then we need to add a comparison substring to the query
    if (lastId != null && comparingSign != null) {
        //but first we need to check  that query contains keyword WHERE
        if (query.toLowerCase().substringAfterLast('from').contains('where')) {
            query = query + ' AND ' + orderByString + ' ' + comparingSign + ' ' + lastId;
        } else {
            query = query + ' WHERE ' + orderByString + ' ' + comparingSign + ' ' + lastId;
        }
    }

Метод getRecords витягує записи однієї сторінки і видає їх впорядкованими у вказаному користувачем порядку. У першій та останній сторінці змінна 'lastId' ігнорується. Запит не здійснює порівняння, а просто вибудовує записи в порядку ASC або DESC. Він завжди відображає першу сторінку, але із записами, впорядкованими в ASC або DESC. Метод 'sortByIdAndSortingOrder' необхідно викликати, щоб гарантовано доставити записи у потрібному порядку.

4. sortByIdAndSortingOrder:

/** The method that guaranteed returns the records in the ascending  order ordering them by orderBy field */
public static List < SObject > sortByIdAndSortingOrder(Map < Id, SObject > pageRecords, String orderBy, String sortingOrder){
    Set < ID > idSet = new Set < ID > ();
    for (ID recId : pageRecords.keySet()) {
        idSet.add(recId);
    }
String sObjName = pageRecords.values()[0].Id.getSObjectType().getDescribe().getName();
String rightOrderQuery = 'SELECT Id FROM ' + sObjName + ' WHERE Id in :idSet ORDER BY ' + orderBy + ' ' + sortingOrder;
    List < SObject > records = Database.query(rightOrderQuery);
    List < SObject > recordsToReturn = new List < SObject > ();
    for (SObject obj : records) {
        recordsToReturn.add(pageRecords.get(obj.Id));
    }
    return recordsToReturn;
}

Метод 'sortByIdAndSortingOrder' є суто утилітарним. Його призначення — гарантовано доставити записи, зібрані в потрібному порядку.

5. idSetAsSting

 /** The method transforms the list of IDs into a string with quotes and brackets */
public static String idSetAsSting(List < String > ids){
String stringSet = '(';
    if (!ids.isEmpty()) {
        for (String id : ids) {
            stringSet = stringSet + '\'' + id + '\'' + ',';
        }
    } else {
        stringSet = stringSet + '\'' + '\'';
    }
    stringSet = stringSet.removeEnd(',') + ')';
    return stringSet;
}

Цей метод теж утилітарний. Він необхідний, щоб перевести список ID в рядок для запиту до бази даних.

6. findCurrentLimit:

 /** The method defines the limit of the records for the current page request */
public static Integer findCurrentLimit(Integer totalRecords, Integer pageNum){
Double pagesCount = Double.valueOf(totalRecords);
Double totalPages = Decimal.valueOf(pagesCount / 50).round(System.RoundingMode.UP);
Integer queryLimit = pageNum == Integer.valueOf(totalPages) ? totalRecords - ((Integer.valueOf(totalPages) - 1) * 50) : 50;
    return queryLimit;
}

Метод findCurrentLimit вказує ліміт записів для сторінки та визначає їх кількість для останньої сторінки.

Описаний підхід використовує впорядкування записів з ID. У моєму випадку, як і в більшості інших, упорядковують записи за певним полем. Для мого прикладу це 'Next Contact Date Time' із типом даних datetime. Проблема подібних несистемних полів у тому, що на практиці багато записів мають значення поля NULL. Тому записи не можуть бути впорядковані цим полем. Так, при спробі впорядкувати за несистемним полем у порядку зростання записи з полем, значення якого NULL, ймовірно, будуть видаватись першими в загальному списку всіх записів у базі даних.

При впорядкуванні записів по несистемному полю варто розбити всі записи на дві частини: записи з полем, значення якого дорівнює NULL; записи з полем, значення якого не дорівнює NULL.


Для кожного з двох частин загального списку записів об'єкта окремо застосуємо вищезгаданий механізм. Для записів, де значення поля дорівнює NULL, можна використовувати впорядкування по ID, а в другому випадку — по цьому полю.

Окремим різновидом є сторінка, яка включає записи як з полем зі значенням, так і з полем, що дорівнює NULL. Ця сторінка є в більшості випадків у разі поділу всіх записів на сторінки пагінації.

Весь список записів, який може бути вилучений з бази даних, розбивається на три різновиди сторінок за значеннями несистемного поля, за яким відбувається впорядкування записів:

  • сторінки, в яких усі записи лише з полем, що дорівнює NULL;
  • сторінки, в яких всі записи мають значення поля, яке відрізняється від NULL;
  • сторінка, частина записів якої має поле, що дорівнює NULL, а інша частина — інше значення.

Нижче я наведу зміни в коді для методів getData та getRecords, які враховують описані нюанси.

getData метод:

  /** The method that retrieves the IDs of the child records and prepares the part of the input parameters for the method getRecords that retrieves contacts */
public static List < Contact > getData(String recId, List < String > pageRecords, String comparingSign, String order, Integer pageNumber, String sortingOrder){
    List < Id > ids = new List < Id > ();
/* Some custom code that retrieves the IDs of the child records using the filtering logic saved in the record with the id='recId' and adds them to the 'ids' variable*/
/** Instead of the 'recId' and saved logic in the record of some SObject you can pass filtering logic inside JSON object for example. It depends on how you want to build your application */
String idAsString = idSetAsSting(ids); // Conversion of the list of IDs into string
/* The countQuery string is to define the total scope of the contacts that are corresponding to our condition. Here you can use your own condition for the definition of the records total count */
String countQuery = 'SELECT count() FROM  Contact WHERE Id IN ' + idAsString;
    totalRecordsCount = database.countQuery(countQuery);
/** The queryLimit is the required parameter for specifying the number of records per page. This is required because the last page may have a different quantity of records than the other pages have */
Integer queryLimit = findCurrentLimit(totalRecordsCount, pageNumber);
String query = 'SELECT Id, ' + CONTACT_FIELDS + ' FROM Contact' + ' WHERE Id IN ' + idAsString;

/*Counting the number of records with the NULL values according to the applied filters*/
String countNullQuery = 'SELECT count() FROM  Contact WHERE Id IN ' + idAsString + ' AND Next_Contact_Date_Time__c = null';
Integer pageRecordsCount = totalRecordsCount > pageNumber * 50 ? pageNumber * 50 : totalRecordsCount;
Integer nullRecordsCount = database.countQuery(countNullQuery);

/** The previous page Contacts are required to compare the last or the first record ID depending on pagination direction. The difference is in double-select action. This is necessary for dividing the previous page into two parts in the case when the page contains records with a NULL value for the field Next_Contact_Date_Time__c and records with a non-null value for the field Next_Contact_Date_Time__c */

String queryPreviousPageNull = 'SELECT ID,Next_Contact_Date_Time__c FROM Contact WHERE ID IN :pageRecords AND Next_Contact_Date_Time__c = null ORDER BY Id ' + sortingOrder;
    List < Contact > previousContacts = database.query(queryPreviousPageNull);
String queryPreviousPageNotNull = 'SELECT ID,Next_Contact_Date_Time__c FROM Contact WHERE ID IN :pageRecords AND Next_Contact_Date_Time__c != null ORDER BY Next_Contact_Date_Time__c ' + sortingOrder;
    previousContacts.addAll(database.query(queryPreviousPageNotNull));
    List < Contact > contacts;
    /** If there are null and non-null values on the current page then we call the getRecords method twice. Once with the 'Next_Contact_Date_Time__c' field, second time with the 'ID' field */
    if (pageRecordsCount > nullRecordsCount && (pageRecordsCount - 50) <= nullRecordsCount) {
        contacts = getRecords(previousContacts, comparingSign, order, queryLimit, query, 'BOTH', sortingOrder);
    }
    /** When the current page is in the 'Next_Contact_Date_Time__c' field null value area we call the getRecords with the 'ID' field */
    if (pageRecordsCount <= nullRecordsCount) {
        contacts = getRecords(previousContacts, comparingSign, order, queryLimit, query, 'Id', sortingOrder);
    }
/** When the current page is in the 'Next_Contact_Date_Time__c' field non-null value area we call the getRecords with the 'Next_Contact_Date_Time__c' field */	if ((pageRecordsCount - 50) > nullRecordsCount) {
        contacts = getRecords(previousContacts, comparingSign, order, queryLimit, query, 'Next_Contact_Date_Time__c', sortingOrder);
    }
    return contacts;
}


getRecords метод:

public static List < SObject > getRecords(List < SObject > pageRecords, String comparingSign, String order, Integer newLimit, String query, String orderBy, String sortingOrder){
String lastId;
String orderByString = orderBy;
/** For  the second call of the getRecords method when the current page is in NULL and non-null areas we save the initial query into variable firstQuery */
String firstQuery = query;
    /** When  the current page is in NULL and non-null areas we select the field for the first time query; it depends on the direction of the pagination and on the ordering of the records for the data table (ASC or DESC) that we want to see on the screen */
    if (orderBy == 'BOTH' && order == sortingOrder) {
        orderByString = 'Id';
    } else if (orderBy == 'BOTH' && order != sortingOrder) {
        orderByString = 'Next_Contact_Date_Time__c';
    }
    if (pageRecords != null && !pageRecords.isEmpty()) {
        if (orderByString.toLowerCase() == 'id') {
            if (order == sortingOrder) {
                //if records are ordered in ascending order the lastId equals to ID of the last record from the previous page
                lastId = String.valueOf(pageRecords[pageRecords.size() - 1].get(orderByString));
            } else {
                //if records are ordered in descending order the lastId equals to ID of the first record from the previous page
                lastId = String.valueOf(pageRecords[0].get(orderByString));
            }
            lastId = '\'' + lastId + '\'';
        } else if (orderByString.toLowerCase() == 'Next_Contact_Date_Time__c') {
            if (order == sortingOrder) {
                //if records are ordered in ascending order the lastId equals to field of the last record from the previous page
                lastId = Datetime.valueOfGmt(String.valueOf(pageRecords[pageRecords.size() - 1].get(orderByString))).formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSSZ');
            } else {
                //if records are ordered in descending order the lastId equals to field of the first record from the previous page
                lastId = Datetime.valueOfGmt(String.valueOf(pageRecords[0].get(orderByString))).formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSSZ');
            }
        }
    }
    //if the current page is not the first or the last then we need to add a comparison substring to the query
    if (lastId != null && comparingSign != null) {
        //but first we need to check that query contains keyword WHERE
        if (query.toLowerCase().substringAfterLast('from').contains('where')) {
            query = query + ' AND ' + orderByString + ' ' + comparingSign + ' ' + lastId;
        } else {
            query = query + ' WHERE ' + orderByString + ' ' + comparingSign + ' ' + lastId;
        }
    }
String nextContactDateTimeCondition;
    //selecting the field filtering equation
    if (orderByString.toLowerCase() == 'id') {
        /** if I have orderByString variable equals to 'id' it means that the current page inside the NULL value area and I have to select the records with the field with the NULL values only */
        nextContactDateTimeCondition = 'Next_Contact_Date_Time__c = null';
    } else {
        /** If  I have an orderByString variable equal  to 'id' it means that the current page is inside the non-null value area and I have to select the records with the field using the custom filter for this field. If there are no filter s you can use something like 'Next_Contact_Date_Time__c != null' */
        nextContactDateTimeCondition = 'Next_Contact_Date_Time__c < ' + getDateTimeString(datetime.now());
    }
    //adding the field filtering equation to the query
    if (query.toLowerCase().substringAfterLast('from').contains('where')) {
        query = query + ' AND ' + nextContactDateTimeCondition;
    } else {
        query = query + ' WHERE ' + nextContactDateTimeCondition;
    }
    //adding the ordering by the field to the query
    query = query + ' ORDER BY ' + orderByString + ' ' + order + ' LIMIT ' + newLimit;
    //querying the records
    Map < Id, SObject > records = new Map < Id, SObject > ((List < SObject >)Database.query(query));
    List < SObject > recordsToReturn = new List < SObject > ();
    //if there are queried records then sort  them in ascending order
    if (records.size() > 0) recordsToReturn.addAll(sortByIdAndSortingOrder(records, orderByString, sortingOrder));
    /** If the current page contains null and non-null areas then depending on the pagination direction and on the accepted order for pages we call the getRecords method for the second time for the Next_Contact_Date_Time__c field or for the ID field using the initial query marked as firstQuery variable */
    if (orderBy == 'BOTH' && order == sortingOrder) {
Integer nextQueryLimit = newLimit - recordsToReturn.size();
        List < SObject > recordsToAdd = getRecords(pageRecords, comparingSign, order, nextQueryLimit, firstQuery, 'Next_Contact_Date_Time__c', sortingOrder);
        recordsToReturn.addAll(recordsToAdd);
    } else if (orderBy == 'BOTH' && order != sortingOrder) {
Integer nextQueryLimit = newLimit - recordsToReturn.size();
        List < SObject > recordsToAdd = getRecords(pageRecords, comparingSign, order, nextQueryLimit, firstQuery, 'Id', sortingOrder);
        recordsToReturn.addAll(recordsToAdd);
    }
    return recordsToReturn; //the returned records
}

У цій статті я намагався систематизувати свої напрацювання та описав розв'язання задачі із застосуванням рідковживаного способу пагінації — на основі порівняння записів в одному з полів.

На мій погляд, цей спосіб має такі переваги:

  • Швидка обробка запиту при переході на сторінку

Це багато в чому запобігає перевищенню ліміту CPU Time. А у випадках, коли немає вкладених запитів для отримання записів, так вдається повністю уникнути перевищення ліміту.

  • У разі запиту без вкладених запитів є можливість переглядати всі записи, не обмежуючись лімітом Salesforce у 50 000 записів. Особисто я переглядав близько 400 000 записів, але можна ще більше.
  • Більша достовірність даних, що передаються. Користувач отримує всю інформацію про кількість записів, які йому доступні, відповідно до обраних фільтрів.
  • Можливість застосування як LWC, так і Visualforce.

Та в процесі роботи я помітив і деякі недоліки:

За наявності вкладених запитів все-таки доведеться обмежити загальний обсяг записів до 50 000

Для записів, які отримують вкладені запити, краще встановити ліміт із зазором у кількість записів основного об'єкта, що виводяться на сторінку. Таким чином ви не перевищите ліміт у 50 000 записів за одну транзакцію. Бажано використовувати додаткову змінну у методах. Ця змінна буде числом записів на одну сторінку і буде відніматися від ліміту в 50 000 записів.

  • Недостовірність обсягу даних у разі вкладених запитів

Це висновок із попереднього пункту. Якщо обмежуємо кількість записів, що витягуються вкладеними запитами, то цим обмежуємо й зону пошуку записів об'єкта, який цікавить користувача.

  • Більш складна логіка порівняно з традиційними способами пагінації

Зокрема, необхідність зважати на NULL значення несистемних полів.

Як варіант ще більш складної логіки, можна назвати послідовність виконання Apex Batchable класів, де загальним результатом будуть записи, витягнуті для однієї сторінки. При цьому код на стороні клієнта (браузера) має періодично надсилати запити на отримання цих записів. Допоки не виконається код на стороні сервера і не з'являться запитувані записи, сторінку користувача буде заблоковано для подальших дій та/або перемикань. Аналогічну логіку можна реалізувати з урахуванням технології івентів платформи.

Однак, це ще не все. Реалізацію даного методу пагінації на основі батчів я детально розгляну у одній з наступних  статей. А поки що — приємного всім використання описаних напрацювань!


Приєднатися до company logo
Продовжуючи, ти погоджуєшся з умовами Публічної оферти та Політикою конфіденційності.