Add sorting foundation as util and using it within indexed-db-handler (#2773)

This commit is contained in:
Carlos Alcaide Corvo authored and GitHub committed 2025-05-13 16:18:04 -04:00
1 parent e96131a933
commit b0d26fe1e6
5 files changed
+594 -26

No files matched your search

+26 -18
View File
@@ -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:
+3 -8
View File
@@ -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 }) => {
+73
View File
@@ -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;
});
};
@@ -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) {
@@ -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],
);
});
});
});