From b0d26fe1e6d1ae7f4010d5df618271383f53d3bf Mon Sep 17 00:00:00 2001 From: Carlos Alcaide Corvo Date: Tue, 13 May 2025 22:18:04 +0200 Subject: [PATCH] Add sorting foundation as util and using it within indexed-db-handler (#2773) --- .../api/addon/handlers/indexed-db-handler.js | 44 +- addons/api/addon/utils/indexed-db-query.js | 11 +- addons/api/addon/utils/sort-results.js | 73 +++ .../unit/handlers/indexed-db-handler-test.js | 68 +++ .../api/tests/unit/utils/sort-results-test.js | 424 ++++++++++++++++++ 5 files changed, 594 insertions(+), 26 deletions(-) create mode 100644 addons/api/addon/utils/sort-results.js create mode 100644 addons/api/tests/unit/utils/sort-results-test.js diff --git a/addons/api/addon/handlers/indexed-db-handler.js b/addons/api/addon/handlers/indexed-db-handler.js index 83fdef366..d6b9455dc 100644 --- a/addons/api/addon/handlers/indexed-db-handler.js +++ b/addons/api/addon/handlers/indexed-db-handler.js @@ -8,6 +8,7 @@ import { service } from '@ember/service'; import { getOwner, setOwner } from '@ember/application'; import { queryIndexedDb } from '../utils/indexed-db-query'; import { paginateResults } from '../utils/paginate-results'; +import { sortResults } from '../utils/sort-results'; import { hashCode } from '../utils/hash-code'; /** @@ -106,38 +107,45 @@ export default class IndexedDbHandler { await writeToIndexedDbPromise; } + // Results returned in JSON API reponse, but in a format optimized for indexedDb const indexedDbResults = await queryIndexedDb( indexedDb, type, queryObj, ); - const dbRecords = paginateResults(indexedDbResults, page, pageSize).map( - (item) => - this.indexedDb.normalizeData({ - data: item, - cleanData: false, - schema, - serializer, - }), - ); + // Normalize the results and clean up indexedDb format, ready for ember data consumption + const normalizedResults = indexedDbResults.map((item) => { + return this.indexedDb.normalizeData({ + data: item, + cleanData: false, + schema, + serializer, + }); + }); - // Return the raw data if we don't push to the store. - let records = dbRecords.map((record) => ({ - ...record.attributes, - id: record.id, - })); + // Sort the results + const sortedResults = sortResults(normalizedResults, { + querySort: queryObj?.sort, + schema, + }); - if (pushToStore) { - records = store.push({ data: dbRecords }); - } + // Paginate the results + const paginatedResults = paginateResults(sortedResults, page, pageSize); + + // If we are not pushing to the store, use the raw data with id property + const records = pushToStore + ? store.push({ data: paginatedResults }) + : paginatedResults.map((record) => ({ + ...record.attributes, + id: record.id, + })); // Set a meta property on the array to store the total items of the results. // This isn't conventional but is better than returning an ArrayProxy // or EmberArray since the ember store query method asserts it has to be an array // so we can't just return an object. records.meta = { totalItems: indexedDbResults.length }; - return records; } default: diff --git a/addons/api/addon/utils/indexed-db-query.js b/addons/api/addon/utils/indexed-db-query.js index d3a363d6f..2bb93fb9c 100644 --- a/addons/api/addon/utils/indexed-db-query.js +++ b/addons/api/addon/utils/indexed-db-query.js @@ -70,14 +70,9 @@ export async function queryIndexedDb(indexedDb, resource, query) { ); } - // OrderBy has to be used on where clauses while if it's a - // collection already we have to use sortBy. We'll also sort by - // descending order based on created time to match the API as any - // "or" clause can change the order - if (filterCollection.orderBy) { - return filterCollection.orderBy(getKey('created_time')).reverse().toArray(); - } - return filterCollection.reverse().sortBy(getKey('created_time')); + // filterCollection can be Dexie Table or a Dexie Collection. + // This is an important detail you need to keep in mind when working on it. + return filterCollection.toArray(); } const buildInitialWhereClause = ({ filterArrayOrObject, table, key }) => { diff --git a/addons/api/addon/utils/sort-results.js b/addons/api/addon/utils/sort-results.js new file mode 100644 index 000000000..2ec441b6f --- /dev/null +++ b/addons/api/addon/utils/sort-results.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// created_time is not an attribute defined on all models, but is present in all the resources +// coming from API +const SORT_DEFAULT_ATTRIBUTE = 'created_time'; +const SORT_DIRECTION_ASCENDING = 'asc'; +const SORT_DIRECTION_DESCENDING = 'desc'; + +const sortFunctions = { + string: (a, b) => String(a).localeCompare(String(b)), + date: (a, b) => new Date(a).getTime() - new Date(b).getTime(), + number: (a, b) => Number(a) - Number(b), + boolean: (a, b) => Number(a) - Number(b), +}; + +function getSortableValue(schema, record, attribute) { + if (attribute === 'id') { + return record.id; + } + + // all models have a created_time attribute but not all models have it defined + if (attribute === 'created_time') { + return record.attributes.created_time; + } + + if (schema.attributes.has(attribute)) { + return record.attributes[attribute]; + } + + const attributes = Array.from(schema.attributes.keys()); + throw new Error( + `The attribute "${attribute}" does not map to the model definition of type "${record.type}". Supported sortable attributes are 'id', 'created_time', or ${attributes.map((attr) => `'${attr}'`).join(', ')}`, + ); +} + +export const sortResults = (results, { querySort, schema }) => { + querySort = querySort ?? {}; + const sortAttribute = querySort.attribute || SORT_DEFAULT_ATTRIBUTE; + + // Default sort direction is ascending unless we are sorting by `created_time` (default sort attribute) + const defaultSortDirection = + sortAttribute === SORT_DEFAULT_ATTRIBUTE + ? SORT_DIRECTION_DESCENDING + : SORT_DIRECTION_ASCENDING; + const sortDirection = querySort.direction || defaultSortDirection; + + if ( + ![SORT_DIRECTION_ASCENDING, SORT_DIRECTION_DESCENDING].includes( + sortDirection, + ) + ) { + throw new Error('Invalid sort direction'); + } + + const sortAttributeDataType = schema.attributes.get(sortAttribute)?.type; + + const sortFunction = + sortFunctions[sortAttributeDataType] ?? sortFunctions.string; + + return results.toSorted((a, b) => { + // Extract the values to sort and sort them. + const sortValueA = getSortableValue(schema, a, sortAttribute); + const sortValueB = getSortableValue(schema, b, sortAttribute); + const sortResult = sortFunction(sortValueA, sortValueB); + + return sortDirection === SORT_DIRECTION_ASCENDING + ? sortResult + : -1 * sortResult; + }); +}; diff --git a/addons/api/tests/unit/handlers/indexed-db-handler-test.js b/addons/api/tests/unit/handlers/indexed-db-handler-test.js index 8f528ea9c..c06f65720 100644 --- a/addons/api/tests/unit/handlers/indexed-db-handler-test.js +++ b/addons/api/tests/unit/handlers/indexed-db-handler-test.js @@ -238,6 +238,74 @@ module('Unit | Handler | indexed-db-handler', function (hooks) { ['Target 2023', 'Target 2022', 'Target 2021', 'Target 2020'], ); }); + + test('it sorts by ascending (default) `name` using string sorting', async function (assert) { + const target001 = this.server.create('target', { name: 'Target 001' }); + const target002 = this.server.create('target', { name: 'Target 002' }); + const target003 = this.server.create('target', { name: 'Target 003' }); + const target004 = this.server.create('target', { name: 'Target 004' }); + const target005 = this.server.create('target', { name: 'Target 005' }); + + const shuffledTargets = faker.helpers.shuffle([ + target001, + target002, + target003, + target004, + target005, + ]); + + this.server.get( + 'targets', + createPaginatedResponseHandler(shuffledTargets, { + pageSize: 1, + }), + ); + + const results = await store.query('target', { + query: { sort: { attribute: 'name' } }, + }); + + assert.deepEqual( + results.map(({ name }) => name), + ['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'], + ); + }); + + test('it sorts by descending, then ascending `name` using string sorting', async function (assert) { + const alias001 = this.server.create('alias', { name: 'Alias 001' }); + const alias002 = this.server.create('alias', { name: 'Alias 002' }); + const alias003 = this.server.create('alias', { name: 'Alias 003' }); + const alias004 = this.server.create('alias', { name: 'Alias 004' }); + + const shuffledAliases = faker.helpers.shuffle([ + alias001, + alias002, + alias003, + alias004, + ]); + + this.server.get( + 'aliases', + createPaginatedResponseHandler(shuffledAliases, { + pageSize: 1, + }), + ); + + const resultsDesc = await store.query('alias', { + query: { sort: { attribute: 'name', direction: 'desc' } }, + }); + const resultsAsc = await store.query('alias', { + query: { sort: { attribute: 'name', direction: 'asc' } }, + }); + assert.deepEqual( + resultsDesc.map(({ name }) => name), + ['Alias 004', 'Alias 003', 'Alias 002', 'Alias 001'], + ); + assert.deepEqual( + resultsAsc.map(({ name }) => name), + ['Alias 001', 'Alias 002', 'Alias 003', 'Alias 004'], + ); + }); }); module('option: pushToStore', function (hooks) { diff --git a/addons/api/tests/unit/utils/sort-results-test.js b/addons/api/tests/unit/utils/sort-results-test.js new file mode 100644 index 000000000..bc3678cc9 --- /dev/null +++ b/addons/api/tests/unit/utils/sort-results-test.js @@ -0,0 +1,424 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { assert, module, test } from 'qunit'; +import { sortResults } from 'api/utils/sort-results'; +import { faker } from '@faker-js/faker'; +import { setupTest } from 'ember-qunit'; + +function createJsonApiMockRecord(id, type, attributes) { + return { + id, + type, + attributes, + }; +} + +module('Unit | Utility | sortResults', function (hooks) { + setupTest(hooks); + + module('Defaults', function () { + test("throws an error when an attribute is not id, created_time or defined as a schema attribute, even when it exists on the record's attribute object", function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + name: 'Result 01', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + name: 'Result 02', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + name: 'Result 03', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + ]); + + const querySort = { attribute: 'name' }; + + // Note: `name` is not defined as a schema attribute but exists on the record + const schema = { attributes: new Map() }; + schema.attributes.set('date', { type: 'date' }); + + assert.throws( + () => sortResults(shuffledResults, { querySort, schema }), + /Error: The attribute "name" does not map to the model definition of type "target". Supported sortable attributes are 'id', 'created_time', or 'date'/, + ); + }); + + test('throws an error when results do not contain a valid sort direction', function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + name: 'Result 01', + }); + + const querySort = { + attribute: 'name', + direction: 'invalid sort direction', + }; + const schema = { attributes: new Map() }; + schema.attributes.set('name', { type: 'string' }); + assert.throws( + () => sortResults([result01], { querySort, schema }), + /Error: Invalid sort direction/, + ); + }); + + test('when passing no querySort, sorts using default created_time, descending order and string sorting', async function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + created_time: '2021', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + created_time: '2022', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + created_time: '2023', + }); + const result04 = createJsonApiMockRecord('id_04', 'target', { + created_time: '2024', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySort = {}; + const schema = { attributes: new Map() }; + schema.attributes.set('created_time', { type: 'string' }); + const sortedResults = sortResults(shuffledResults, { + querySort, + schema, + }); + assert.deepEqual( + sortedResults.map(({ attributes: { created_time } }) => created_time), + ['2024', '2023', '2022', '2021'], + ); + }); + + test('when sorting by id, without being on the results attributes it sorts as expected ', function () { + const result01 = createJsonApiMockRecord('id_01', 'target', { + name: 'Result 01', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + name: 'Result 02', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + name: 'Result 03', + }); + const result04 = createJsonApiMockRecord('id_04', 'target', { + name: 'Result 04', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySort = { attribute: 'id' }; + const schema = { attributes: new Map() }; + schema.attributes.set('id', { type: 'string' }); + const sortedResults = sortResults(shuffledResults, { querySort, schema }); + + assert.deepEqual( + sortedResults.map(({ id }) => id), + ['id_01', 'id_02', 'id_03', 'id_04'], + ); + }); + }); + + module('string sorting', function () { + test('it sorts by ascending (default) `name` using string sorting', async function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + name: 'Result 01', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + name: 'Result 02', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + name: 'Result 03', + }); + const result04 = createJsonApiMockRecord('id_04', 'target', { + name: 'Result 04', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySort = { attribute: 'name' }; + const schema = { attributes: new Map() }; + schema.attributes.set('name', { type: 'string' }); + const sortedResults = sortResults(shuffledResults, { querySort, schema }); + assert.deepEqual( + sortedResults.map(({ attributes: { name } }) => name), + ['Result 01', 'Result 02', 'Result 03', 'Result 04'], + ); + }); + + test('it sorts by ascending then descending `name` using string sorting', async function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + name: 'Result 01', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + name: 'Result 02', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + name: 'Result 03', + }); + const result04 = createJsonApiMockRecord('id_04', 'target', { + name: 'Result 04', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySortAsc = { attribute: 'name', direction: 'asc' }; + const querySortDesc = { attribute: 'name', direction: 'desc' }; + const schema = { attributes: new Map() }; + schema.attributes.set('name', { type: 'string' }); + const sortedResultsAsc = sortResults(shuffledResults, { + querySort: querySortAsc, + schema, + }); + const sortedResultsDesc = sortResults(shuffledResults, { + querySort: querySortDesc, + schema, + }); + assert.deepEqual( + sortedResultsAsc.map(({ attributes: { name } }) => name), + ['Result 01', 'Result 02', 'Result 03', 'Result 04'], + ); + assert.deepEqual( + sortedResultsDesc.map(({ attributes: { name } }) => name), + ['Result 04', 'Result 03', 'Result 02', 'Result 01'], + ); + }); + }); + + module('date sorting', function () { + test('it sorts by ascending (default) `date` using date sorting', function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + date: '2021', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + date: '2022', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + date: '2023', + }); + const result04 = createJsonApiMockRecord('id_04', 'target', { + date: '2024', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySort = { attribute: 'date' }; + const schema = { attributes: new Map() }; + schema.attributes.set('date', { type: 'date' }); + + const sortedResults = sortResults(shuffledResults, { querySort, schema }); + + assert.deepEqual( + sortedResults.map(({ attributes: { date } }) => date), + ['2021', '2022', '2023', '2024'], + ); + }); + + test('it sorts by ascending then descending `date` using date sorting', function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + date: '2021', + }); + const result02 = createJsonApiMockRecord('id_02', 'target', { + date: '2022', + }); + const result03 = createJsonApiMockRecord('id_03', 'target', { + date: '2023', + }); + const result04 = createJsonApiMockRecord('id_04', 'target', { + date: '2024', + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySortAsc = { attribute: 'date', direction: 'asc' }; + const querySortDesc = { attribute: 'date', direction: 'desc' }; + const schema = { attributes: new Map() }; + schema.attributes.set('date', { type: 'date' }); + + const sortedResultsAsc = sortResults(shuffledResults, { + querySort: querySortAsc, + schema, + }); + const sortedResultsDesc = sortResults(shuffledResults, { + querySort: querySortDesc, + schema, + }); + + assert.deepEqual( + sortedResultsAsc.map(({ attributes: { date } }) => date), + ['2021', '2022', '2023', '2024'], + ); + assert.deepEqual( + sortedResultsDesc.map(({ attributes: { date } }) => date), + ['2024', '2023', '2022', '2021'], + ); + }); + }); + + module('number sorting', function () { + test('it sorts by ascending (default) `sessions`', function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + sessions: 10, + }); + const result02 = createJsonApiMockRecord('id_01', 'target', { + sessions: 200, + }); + const result03 = createJsonApiMockRecord('id_01', 'target', { + sessions: 3333, + }); + const result04 = createJsonApiMockRecord('id_01', 'target', { + sessions: 40444, + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySort = { attribute: 'sessions' }; + const schema = { attributes: new Map() }; + schema.attributes.set('sessions', { type: 'number' }); + + const sortedResults = sortResults(shuffledResults, { querySort, schema }); + + assert.deepEqual( + sortedResults.map(({ attributes: { sessions } }) => sessions), + [10, 200, 3333, 40444], + ); + }); + + test('it sorts by ascending then descending `sessions`', function (assert) { + const result01 = createJsonApiMockRecord('id_01', 'target', { + sessions: 10, + }); + const result02 = createJsonApiMockRecord('id_01', 'target', { + sessions: 200, + }); + const result03 = createJsonApiMockRecord('id_01', 'target', { + sessions: 3333, + }); + const result04 = createJsonApiMockRecord('id_01', 'target', { + sessions: 40444, + }); + + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySortAsc = { attribute: 'sessions', direction: 'asc' }; + const querySortDesc = { attribute: 'sessions', direction: 'desc' }; + const schema = { attributes: new Map() }; + schema.attributes.set('sessions', { type: 'number' }); + + const sortedResultsAsc = sortResults(shuffledResults, { + querySort: querySortAsc, + schema, + }); + const sortedResultsDesc = sortResults(shuffledResults, { + querySort: querySortDesc, + schema, + }); + + assert.deepEqual( + sortedResultsAsc.map(({ attributes: { sessions } }) => sessions), + [10, 200, 3333, 40444], + ); + assert.deepEqual( + sortedResultsDesc.map(({ attributes: { sessions } }) => sessions), + [40444, 3333, 200, 10], + ); + }); + }); + + module('boolean sorting', function () { + test('it sorts by ascending (default) `active`', function (assert) { + const result01 = { attributes: { active: true } }; + const result02 = { attributes: { active: false } }; + const result03 = { attributes: { active: false } }; + const result04 = { attributes: { active: true } }; + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySort = { attribute: 'active' }; + const schema = { attributes: new Map() }; + schema.attributes.set('active', { type: 'boolean' }); + + const sortedResults = sortResults(shuffledResults, { querySort, schema }); + + assert.deepEqual( + sortedResults.map(({ attributes: { active } }) => active), + [false, false, true, true], + ); + }); + + test('it sorts by ascending then descending `active`', function (assert) { + const result01 = { attributes: { active: true } }; + const result02 = { attributes: { active: false } }; + const result03 = { attributes: { active: false } }; + const result04 = { attributes: { active: true } }; + const shuffledResults = faker.helpers.shuffle([ + result01, + result02, + result03, + result04, + ]); + const querySortAsc = { attribute: 'active', direction: 'asc' }; + const querySortDesc = { attribute: 'active', direction: 'desc' }; + const schema = { attributes: new Map() }; + schema.attributes.set('active', { type: 'boolean' }); + + const sortedResultsAsc = sortResults(shuffledResults, { + querySort: querySortAsc, + schema, + }); + const sortedResultsDesc = sortResults(shuffledResults, { + querySort: querySortDesc, + schema, + }); + + assert.deepEqual( + sortedResultsAsc.map(({ attributes: { active } }) => active), + [false, false, true, true], + ); + + assert.deepEqual( + sortedResultsDesc.map(({ attributes: { active } }) => active), + [true, true, false, false], + ); + }); + }); +});