13 |
14 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/QM_Example_LWC_CreateTestDataController.cls:
--------------------------------------------------------------------------------
1 | /**
2 | * A controller for the qmExampleCreateTestData LWC
3 | */
4 | public with sharing class QM_Example_LWC_CreateTestDataController {
5 | /**
6 | * Creates account records for use as test data
7 | */
8 | @AuraEnabled
9 | public static void createTestData(Integer numberOfRecordsToCreate) {
10 | List accountsToInsert = new List();
11 |
12 | for(Integer i = 1; i <= numberOfRecordsToCreate; i++) {
13 | accountsToInsert.add(new Account(
14 | Name = 'Test Account #' + i,
15 | NumberOfEmployees = Math.mod(i, 10) + 1
16 | ));
17 | }
18 |
19 | insert accountsToInsert;
20 | }
21 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore.
2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore
3 | # For useful gitignore templates see: https://github.com/github/gitignore
4 |
5 | # Salesforce cache
6 | .sfdx/
7 |
8 | # Logs
9 | logs
10 | *.log
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 |
15 | # Dependency directories
16 | node_modules/
17 |
18 | # Eslint cache
19 | .eslintcache
20 |
21 | # MacOS system files
22 | .DS_Store
23 |
24 | # Windows system files
25 | Thumbs.db
26 | ehthumbs.db
27 | [Dd]esktop.ini
28 | $RECYCLE.BIN/
29 |
30 | # IDE files
31 | IlluminatedCloud
32 | Apex_QueryMore_Table_Example.iml
33 | .vscode
34 | .idea
--------------------------------------------------------------------------------
/force-app/main/default/lwc/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "experimentalDecorators": true,
4 | "baseUrl": ".",
5 | "paths": {
6 | "c/pubsub": [
7 | "pubsub/pubsub.js"
8 | ],
9 | "c/qmExampleCreateTestData": [
10 | "qmExampleCreateTestData/qmExampleCreateTestData.js"
11 | ],
12 | "c/qmExampleDataTable": [
13 | "qmExampleDataTable/qmExampleDataTable.js"
14 | ],
15 | "c/qmExampleDeleteTestData": [
16 | "qmExampleDeleteTestData/qmExampleDeleteTestData.js"
17 | ]
18 | }
19 | },
20 | "include": [
21 | "**/*",
22 | "../../../../.sfdx/typings/lwc/**/*.d.ts"
23 | ],
24 | "typeAcquisition": {
25 | "include": [
26 | "jest"
27 | ]
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/qmExampleDeleteTestData/qmExampleDeleteTestData.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface to remove test data (Accounts)
3 | */
4 |
5 | import {LightningElement, track, wire} from 'lwc';
6 | import { CurrentPageReference } from 'lightning/navigation';
7 | import deleteTestData from '@salesforce/apex/QM_Example_LWC_DeleteTestDataController.deleteTestData';
8 | import {fireEvent} from "c/pubsub";
9 |
10 | export default class QmExampleDeleteTestData extends LightningElement {
11 | @wire(CurrentPageReference) pageRef; // for pubsub
12 |
13 | @track loading = false;
14 |
15 | handleDeleteAllTestDataButtonPress() {
16 | this.loading = true;
17 | deleteTestData().then(() => {
18 | this.loading = false;
19 | this._fireDataChangeEvent();
20 | });
21 | }
22 |
23 | _fireDataChangeEvent() {
24 | fireEvent(this.pageRef, 'qm__testDataChange', {});
25 | }
26 | }
--------------------------------------------------------------------------------
/force-app/main/default/lwc/qmExampleCreateTestData/qmExampleCreateTestData.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/force-app/main/default/flexipages/QueryMore_Data_Table_Example.flexipage-meta.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | region1
5 | Region
6 |
7 |
8 |
9 | qmExampleCreateTestData
10 |
11 |
12 | qmExampleDeleteTestData
13 |
14 | region2
15 | Region
16 |
17 |
18 |
19 | qmExampleDataTable
20 |
21 | region3
22 | Region
23 |
24 | QueryMore Data Table Example
25 |
26 | flexipage:appHomeTemplateHeaderTwoColumnsLeftSidebar
27 |
28 | AppPage
29 |
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Apex QueryMore Example
2 | An example that demonstrates a technique for building "QueryMore" like functionality in Apex.
3 |
4 | The example showcases a table with infinite scrolling capabilities that retrieves records from the Salesforce database without relying on the SOQL OFFSET keyword. The technique allows for querying of records beyong the OFFSET keyword limit of 2000.
5 |
6 | This approach can also be applied to other use cases where large SOQL queries need to be fetched in chunks.
7 |
8 | **See this blog post for more information:** https://sfdc.danielzeidler.com/2019/08/18/building-querymore-functionality-in-apex-a-soql-offset-alternative/
9 |
10 | ## Installation via SFDX
11 |
12 | 1. Create a scratch org:
13 | ```
14 | sfdx force:org:create -s -f config/project-scratch-def.json -a query-more-example
15 | ```
16 |
17 | 2. Push the app to your scratch org:
18 | ```
19 | sfdx force:source:push
20 | ```
21 |
22 | 2. Assign the **QueryMore Data Table Example** permission set to the default user:
23 | ```
24 | sfdx force:user:permset:assign -n QueryMore_Data_Table_Example
25 | ```
26 |
27 | 4. Open the scratch org:
28 | ```
29 | sfdx force:org:open
30 | ```
31 |
32 | 5. In App Launcher, select the **QueryMore Data Table Example** app.
33 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/qmExampleDataTable/qmExampleDataTable.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/force-app/main/default/lwc/qmExampleCreateTestData/qmExampleCreateTestData.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface to create test data (Accounts)
3 | */
4 |
5 | import {LightningElement, track, wire} from 'lwc';
6 | import { fireEvent } from 'c/pubsub';
7 | import { CurrentPageReference } from 'lightning/navigation';
8 | import { ShowToastEvent } from 'lightning/platformShowToastEvent'
9 | import createTestData from '@salesforce/apex/QM_Example_LWC_CreateTestDataController.createTestData';
10 |
11 | export default class QmExampleCreateTestData extends LightningElement {
12 | @wire(CurrentPageReference) pageRef; // for pubsub
13 |
14 | @track loading = false; // used for spinner
15 | numberOfTestRecordsToCreate = 125;
16 |
17 | HandleCreateTestDataButtonPress() {
18 | this.loading = true;
19 | createTestData({numberOfRecordsToCreate : this.numberOfTestRecordsToCreate}).then(() => {
20 | this.loading = false;
21 | this._showSuccessToast();
22 | this._fireDataChangeEvent();
23 | });
24 | }
25 |
26 | handleNumberOfTestRecordsToCreate(event) {
27 | this.numberOfTestRecordsToCreate = event.target.value;
28 | }
29 |
30 | _fireDataChangeEvent() {
31 | fireEvent(this.pageRef, 'qm__testDataChange', {});
32 | }
33 |
34 | _showSuccessToast() {
35 | const event = new ShowToastEvent({
36 | title: 'Success!',
37 | message: `${this.numberOfTestRecordsToCreate} records inserted.`,
38 | variant: 'success'
39 | });
40 | this.dispatchEvent(event);
41 | }
42 | }
--------------------------------------------------------------------------------
/force-app/main/default/lwc/pubsub/pubsub.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A basic pub-sub mechanism for sibling component communication
3 | *
4 | * TODO - adopt standard flexipage sibling communication mechanism when it's available.
5 | */
6 |
7 | const events = {};
8 |
9 | const samePageRef = (pageRef1, pageRef2) => {
10 | const obj1 = pageRef1.attributes;
11 | const obj2 = pageRef2.attributes;
12 | return Object.keys(obj1)
13 | .concat(Object.keys(obj2))
14 | .every(key => {
15 | return obj1[key] === obj2[key];
16 | });
17 | };
18 |
19 | /**
20 | * Registers a callback for an event
21 | * @param {string} eventName - Name of the event to listen for.
22 | * @param {function} callback - Function to invoke when said event is fired.
23 | * @param {object} thisArg - The value to be passed as the this parameter to the callback function is bound.
24 | */
25 | const registerListener = (eventName, callback, thisArg) => {
26 | // Checking that the listener has a pageRef property. We rely on that property for filtering purpose in fireEvent()
27 | if (!thisArg.pageRef) {
28 | throw new Error(
29 | 'pubsub listeners need a "@wire(CurrentPageReference) pageRef" property'
30 | );
31 | }
32 |
33 | if (!events[eventName]) {
34 | events[eventName] = [];
35 | }
36 | const duplicate = events[eventName].find(listener => {
37 | return listener.callback === callback && listener.thisArg === thisArg;
38 | });
39 | if (!duplicate) {
40 | events[eventName].push({ callback, thisArg });
41 | }
42 | };
43 |
44 | /**
45 | * Unregisters a callback for an event
46 | * @param {string} eventName - Name of the event to unregister from.
47 | * @param {function} callback - Function to unregister.
48 | * @param {object} thisArg - The value to be passed as the this parameter to the callback function is bound.
49 | */
50 | const unregisterListener = (eventName, callback, thisArg) => {
51 | if (events[eventName]) {
52 | events[eventName] = events[eventName].filter(
53 | listener =>
54 | listener.callback !== callback || listener.thisArg !== thisArg
55 | );
56 | }
57 | };
58 |
59 | /**
60 | * Unregisters all event listeners bound to an object.
61 | * @param {object} thisArg - All the callbacks bound to this object will be removed.
62 | */
63 | const unregisterAllListeners = thisArg => {
64 | Object.keys(events).forEach(eventName => {
65 | events[eventName] = events[eventName].filter(
66 | listener => listener.thisArg !== thisArg
67 | );
68 | });
69 | };
70 |
71 | /**
72 | * Fires an event to listeners.
73 | * @param {object} pageRef - Reference of the page that represents the event scope.
74 | * @param {string} eventName - Name of the event to fire.
75 | * @param {*} payload - Payload of the event to fire.
76 | */
77 | const fireEvent = (pageRef, eventName, payload) => {
78 | if (events[eventName]) {
79 | const listeners = events[eventName];
80 | listeners.forEach(listener => {
81 | if (samePageRef(pageRef, listener.thisArg.pageRef)) {
82 | try {
83 | listener.callback.call(listener.thisArg, payload);
84 | } catch (error) {
85 | // fail silently
86 | }
87 | }
88 | });
89 | }
90 | };
91 |
92 | export {
93 | registerListener,
94 | unregisterListener,
95 | unregisterAllListeners,
96 | fireEvent
97 | };
98 |
--------------------------------------------------------------------------------
/force-app/main/default/classes/QM_Example_LWC_DataTableController.cls:
--------------------------------------------------------------------------------
1 | /**
2 | * A controller for the qmExampleDataTable LWC
3 | */
4 | public with sharing class QM_Example_LWC_DataTableController {
5 | /**
6 | * Gets a list of up to 50 Account records from the db
7 | *
8 | * @param sortedBy The field to sort the data by. Data will be sorted by Id if null.
9 | * @param sortedDirection Can be "asc" or "desc". Data is sorted by Id in ascending order if null.
10 | */
11 | @AuraEnabled
12 | public static List getTestData(String sortedBy, String sortedDirection) {
13 | if(String.isEmpty(sortedBy) || String.isEmpty(sortedDirection)) {
14 | return queryTestDataSortedById();
15 | } else {
16 | return queryTestDataSortedByAdditionalField(sortedBy, sortedDirection);
17 | }
18 | }
19 |
20 | /**
21 | * Gets up to an additional 25 Accounts from the db.
22 | * The lastId and lastValueOfSortedField parameters are used to determine the starting row offset of the returned dataset.
23 | *
24 | * @param sortedBy The API name of the field to sort by. Data will be sorted by Id if null
25 | * @param sortedDirection Can be "asc" or "desc". Data is sorted by Id in ascending order if null.
26 | * @param lastId The Id of the last row in the current dataset.
27 | * @param lastValueOfSortedField The value of the sortBy field in the last row of the current dataset.
28 | * Set to null if sorting by Id.
29 | * @param sortedFieldIsInteger Set to True if the sortBy field holds an integer, otherwise set to False.
30 | * This parameter is needed to work around an issue where the LWC sends us an
31 | * integer but Apex thinks it's a decimal
32 | */
33 | @AuraEnabled
34 | public static List getMoreTestData(String sortedBy, String sortedDirection, Id lastId, Object lastValueOfSortedField, Boolean sortedFieldIsInteger) {
35 | if(String.isEmpty(sortedBy) || String.isEmpty(sortedDirection)) {
36 | return queryMoreTestDataSortedById(lastId);
37 | } else {
38 | return queryMoreTestDataSortedByAdditionalField(sortedBy, sortedDirection, lastId, lastValueOfSortedField, sortedFieldIsInteger);
39 | }
40 | }
41 |
42 | /**
43 | * Gets the first 50 Account records in the DB sorted by Id
44 | */
45 | private static List queryTestDataSortedById() {
46 | return [
47 | SELECT Name, NumberOfEmployees
48 | FROM Account
49 | ORDER BY Id
50 | LIMIT 50
51 | ];
52 | }
53 |
54 | /**
55 | * Gets up to an additional 25 Accounts from the db sorted by Id.
56 | *
57 | * @param lastId The Id of the last row in the current dataset.
58 | * Used to determine the starting row offset of the returned dataset.
59 | */
60 | private static List queryMoreTestDataSortedById(Id lastId) {
61 | return [
62 | SELECT Name, NumberOfEmployees
63 | FROM Account
64 | WHERE Id > :lastId
65 | ORDER BY Id
66 | LIMIT 25
67 | ];
68 | }
69 |
70 | /**
71 | * Gets up to 50 Account records sorted by a specified field and direction, from the db
72 | *
73 | * @param sortedBy The API name of the field to sort by
74 | * @param sortedDirection Can be "asc" or "desc".
75 | */
76 | private static List queryTestDataSortedByAdditionalField(String sortedBy, String sortedDirection) {
77 | String queryString =
78 | 'SELECT Name, NumberOfEmployees '
79 | + 'FROM Account '
80 | + 'ORDER BY ' + sortedBy + ' ' + sortedDirection + ', Id '
81 | + 'LIMIT 50';
82 |
83 | return Database.query(queryString);
84 | }
85 |
86 | /**
87 | * Gets an additional 25 sorted Account records from the db.
88 | * The lastId and lastValueOfSortedField parameters are used to determine the starting row offset of the returned dataset.
89 | *
90 | * @param sortedBy The API name of the field to sort by.
91 | * @param sortedDirection Can be "asc" or "desc".
92 | * @param lastId The Id of the last row in the current dataset.
93 | * @param lastValueOfSortedField The value of the sortBy field in the last row of the current dataset.
94 | * @param sortedFieldIsInteger Set to True if the sortBy field holds an integer, otherwise set to False.
95 | * This parameter is needed to work around an issue where the LWC sends us an
96 | * integer but Apex thinks it's a decimal
97 | */
98 | private static List queryMoreTestDataSortedByAdditionalField(String sortedBy, String sortedDirection, Id lastId, Object lastValueOfSortedField, Boolean sortedFieldIsInteger) {
99 | String directionOperator = sortedDirection == 'asc' ? '>' : '<';
100 |
101 | // This hack is needed to avoid an issue where Integers sometimes come through as Decimals
102 | lastValueOfSortedField = sortedFieldIsInteger ? Integer.valueOf(lastValueOfSortedField) : lastValueOfSortedField;
103 |
104 | String queryString =
105 | 'SELECT Name, NumberOfEmployees '
106 | + 'FROM Account '
107 | + 'WHERE ' + sortedBy + ' ' + directionOperator + ' :lastValueOfSortedField '
108 | + 'OR (' + sortedBy + ' = :lastValueOfSortedField AND Id > :lastId) '
109 | + 'ORDER BY ' + sortedBy + ' ' + sortedDirection + ', Id LIMIT 25';
110 |
111 | return Database.query(queryString);
112 | }
113 | }
--------------------------------------------------------------------------------
/force-app/main/default/lwc/qmExampleDataTable/qmExampleDataTable.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Displays Account records with the ability to infinitely load additional records and sort by any column
3 | * Account data is synonymous with "test data" in this module
4 | */
5 |
6 | import {LightningElement, track, wire} from 'lwc';
7 | import { CurrentPageReference } from 'lightning/navigation';
8 | import { registerListener, unregisterAllListeners } from 'c/pubsub';
9 | import getTestData from '@salesforce/apex/QM_Example_LWC_DataTableController.getTestData';
10 | import getMoreTestData from '@salesforce/apex/QM_Example_LWC_DataTableController.getMoreTestData';
11 |
12 | import NAME_FIELD from '@salesforce/schema/Account.Name';
13 | import NUMBER_OF_EMPLOYEES_FIELD from '@salesforce/schema/Account.NumberOfEmployees';
14 |
15 | export default class QmExampleDataTable extends LightningElement {
16 | @wire(CurrentPageReference) pageRef; // for pubsub
17 |
18 | /** lightning-datatable properties **/
19 | @track data;
20 | @track columns = [
21 | {
22 | label: 'Account Name',
23 | fieldName: NAME_FIELD.fieldApiName,
24 | type: 'text'
25 | },
26 | {
27 | label: 'Number of Employees',
28 | fieldName: NUMBER_OF_EMPLOYEES_FIELD.fieldApiName,
29 | type: 'number',
30 | cellAttributes: { alignment: 'left' }
31 | }
32 | ];
33 | @track sortedBy;
34 | @track sortedDirection;
35 |
36 | /** other properties **/
37 | @track loadingInProgress = false; // used for turning the spinner on and off
38 | @track moreDataAvailableToLoad = true;
39 |
40 | get isLoadMoreButtonEnabled() {
41 | return !this.moreDataAvailableToLoad;
42 | }
43 |
44 | connectedCallback() {
45 | this._registerTestDataChangeListener();
46 | this._refreshData();
47 | }
48 |
49 | disconnectedCallback() {
50 | unregisterAllListeners(this); // for pubsub
51 | }
52 |
53 | handleLoadMoreDataButtonClick() {
54 | this._loadMoreDataIntoTable();
55 | }
56 |
57 | handleEnableColumnSortCheckboxChange(event) {
58 | if(event.target.checked) {
59 | this._enableColumnSorting();
60 | } else {
61 | this._disableColumnSorting();
62 | }
63 | }
64 |
65 | handleSort(event) {
66 | this.sortedBy = event.detail.fieldName;
67 | this.sortedDirection = event.detail.sortDirection;
68 | this._refreshData();
69 | }
70 |
71 | _registerTestDataChangeListener() {
72 | registerListener(
73 | 'qm__testDataChange',
74 | this._refreshData,
75 | this
76 | );
77 | }
78 |
79 | /**
80 | * Refreshes the table with new data from the server
81 | * @private
82 | */
83 | _refreshData() {
84 | this.loadingInProgress = true;
85 | this._fetchTestData().then((result) => {
86 | this.data = result;
87 | this.loadingInProgress = false;
88 | this.moreDataAvailableToLoad = true;
89 | });
90 | }
91 |
92 | /**
93 | * fetches a list test of data
94 | *
95 | * @return {Promise} - A Promise for the fetched data
96 | *
97 | * @private
98 | */
99 | _fetchTestData() {
100 | return getTestData({
101 | sortedBy: this.sortedBy,
102 | sortedDirection : this.sortedDirection
103 | });
104 | }
105 |
106 | /**
107 | * Appends additional test data to the table
108 | * @private
109 | */
110 | _loadMoreDataIntoTable() {
111 | this.loadingInProgress = true;
112 |
113 | this._fetchMoreTestData().then((newData) => {
114 | if(newData.length === 0) {
115 | // TODO: change criteria when this is set to false. Current criteria leads to bad UX
116 | this.moreDataAvailableToLoad = false;
117 | } else {
118 | this.data = [...this.data, ...newData];
119 | }
120 | this.loadingInProgress = false;
121 | })
122 | }
123 |
124 | /**
125 | * Fetches additional data from the server
126 | *
127 | * @return {Promise} - A Promise for the additional data
128 | * @private
129 | */
130 | _fetchMoreTestData() {
131 | const lastRow = this.data[this.data.length - 1];
132 |
133 | return getMoreTestData({
134 | sortedBy: this.sortedBy,
135 | sortedDirection : this.sortedDirection,
136 | lastId: lastRow.Id,
137 | lastValueOfSortedField: lastRow[this.sortedBy],
138 |
139 | // This hack is needed to avoid an issue with the LWC sending lastValueOfSortedField as a decimal value
140 | // when sorting by the NumberOfEmployees fields
141 | sortedFieldIsInteger: this.sortedBy === 'NumberOfEmployees'
142 | });
143 | }
144 |
145 | _enableColumnSorting() {
146 | this._setColumnSortableProperty(true);
147 |
148 | this.sortedBy = 'Name';
149 | this.sortedDirection = 'asc';
150 |
151 | this._refreshData();
152 | }
153 |
154 | _disableColumnSorting() {
155 | this._setColumnSortableProperty(false);
156 |
157 | this.sortedBy = null;
158 | this.sortedDirection = null;
159 |
160 | this._refreshData();
161 | }
162 |
163 | /**
164 | * Sets all columns in the table and sortable or unsortable
165 | *
166 | * @param {boolean} isSortable - true if the columns should be sortable, false if not
167 | * @private
168 | */
169 | _setColumnSortableProperty(isSortable) {
170 | this.columns = this.columns.map((column) => {
171 | column.sortable = isSortable;
172 | return column
173 | });
174 | }
175 | }
--------------------------------------------------------------------------------