259 lines
7.7 KiB
JavaScript
259 lines
7.7 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
import { get } from '@ember/object';
|
|
import { modelIndexes } from '../services/indexed-db';
|
|
|
|
/**
|
|
* Takes a POJO representing a filter query and builds a query to
|
|
* query indexedDb with. Note that the first filter is important as that
|
|
* will use the actual index in indexedDb while any subsequent ones will be less efficient.
|
|
*
|
|
* @example
|
|
* const filter = queryIndexedDb(indexedDb, type, {
|
|
* search: generated',
|
|
* filters: {
|
|
* scope_id: {
|
|
* logicalOperator: 'and',
|
|
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
|
* },
|
|
* },
|
|
* });
|
|
*/
|
|
export async function queryIndexedDb(indexedDb, resource, query) {
|
|
let { search, filters = {} } = query ?? {};
|
|
let filterCollection = indexedDb[resource];
|
|
|
|
search = getSearchObject(search, resource);
|
|
filters = cleanObject(filters);
|
|
|
|
if (Object.keys(filters).length) {
|
|
// Remove the first item to use as the initial collection and then reduce the remaining ones
|
|
const entries = Object.entries(filters);
|
|
const [firstKey, firstFilterArrayOrObject] = entries.shift();
|
|
|
|
filterCollection = await entries.reduce(
|
|
async (currCollection, [key, filterArrayOrObject]) => {
|
|
// Grab the results from the set of one filters to be `AND` together with the rest of the filters
|
|
const filterResults = await buildInitialWhereClause({
|
|
table: indexedDb[resource],
|
|
key,
|
|
filterArrayOrObject,
|
|
}).primaryKeys();
|
|
const resultIdsSet = new Set(filterResults);
|
|
|
|
return (await currCollection).and((record) => {
|
|
return resultIdsSet.has(record.id);
|
|
});
|
|
},
|
|
buildInitialWhereClause({
|
|
key: firstKey,
|
|
table: indexedDb[resource],
|
|
filterArrayOrObject: firstFilterArrayOrObject,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (search) {
|
|
const { text, fields } = search;
|
|
|
|
filterCollection = filterCollection.filter((record) =>
|
|
fields
|
|
.map((key) => get(record, key))
|
|
.some((element) => {
|
|
if (typeof element === 'string') {
|
|
return element?.toLowerCase().includes(text?.toLowerCase());
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
// 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 }) => {
|
|
const filterValueArray = Array.isArray(filterArrayOrObject)
|
|
? filterArrayOrObject
|
|
: filterArrayOrObject.values;
|
|
|
|
const { logicalOperator } = filterArrayOrObject;
|
|
|
|
// Short circuit if all the filter types were equals or notEquals since indexedDb
|
|
// can more efficiently query if we pass all values at once
|
|
const checkAllFilterType = (type) =>
|
|
filterValueArray.every((obj) => Object.keys(obj)[0] === type);
|
|
if (checkAllFilterType('equals')) {
|
|
return table
|
|
.where(getKey(key))
|
|
.anyOf(filterValueArray.map((obj) => obj['equals']));
|
|
}
|
|
if (checkAllFilterType('notEquals')) {
|
|
return table
|
|
.where(getKey(key))
|
|
.noneOf(filterValueArray.map((obj) => obj['notEquals']));
|
|
}
|
|
|
|
// Remove the first item to use as the initial where clause and then reduce the remaining ones
|
|
const [firstOperation, firstFilterValue] = Object.entries(
|
|
filterValueArray[0],
|
|
)[0];
|
|
|
|
// Not all are filters are equals or notEquals,
|
|
// manually build the initial where clause and resulting collections
|
|
return filterValueArray.slice(1).reduce(
|
|
(result, currObj) => {
|
|
const [operation, value] = Object.entries(currObj)[0];
|
|
|
|
return buildIndexedDbCollection({
|
|
key: getKey(key),
|
|
value,
|
|
collection: result,
|
|
operation,
|
|
logicalOperator,
|
|
});
|
|
},
|
|
buildIndexedDbWhere({
|
|
table,
|
|
key: getKey(key),
|
|
operation: firstOperation,
|
|
value: firstFilterValue,
|
|
}),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Builds the initial where clause for indexed DB to filter on. Note that this makes
|
|
* the first filter important as that will do the real filtering from indexed DB while
|
|
* any subsequent filter has to be done in memory.
|
|
*/
|
|
const buildIndexedDbWhere = ({ table, key, value, operation }) => {
|
|
const baseWhereClause = table.where(key);
|
|
switch (operation) {
|
|
case 'contains':
|
|
// We return an error due to not being able to support contains on first filter due to performance.
|
|
throw new Error('Contains is not supported as the first filter.');
|
|
case 'gt':
|
|
return baseWhereClause.above(value);
|
|
case 'gte':
|
|
return baseWhereClause.aboveOrEqual(value);
|
|
case 'lt':
|
|
return baseWhereClause.below(value);
|
|
case 'lte':
|
|
return baseWhereClause.belowOrEqual(value);
|
|
case 'notEquals':
|
|
return baseWhereClause.notEqual(value);
|
|
default:
|
|
return baseWhereClause.equals(value);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Continues building the queries for indexed DB to filter on. Note that these are not true
|
|
* indexed DB queries and in fact just standard JS filters for logical "and" queries and parallel queries
|
|
* to indexed DB for logical "or" queries that get deduped.
|
|
*/
|
|
const buildIndexedDbCollection = ({
|
|
key,
|
|
value,
|
|
collection,
|
|
operation,
|
|
logicalOperator,
|
|
}) => {
|
|
if (logicalOperator === 'and') {
|
|
switch (operation) {
|
|
case 'contains':
|
|
return collection.and((record) => get(record, key)?.includes(value));
|
|
case 'gt':
|
|
return collection.and((record) => get(record, key) > value);
|
|
case 'gte':
|
|
return collection.and((record) => get(record, key) >= value);
|
|
case 'lt':
|
|
return collection.and((record) => get(record, key) < value);
|
|
case 'lte':
|
|
return collection.and((record) => get(record, key) <= value);
|
|
case 'notEquals':
|
|
return collection.and((record) => get(record, key) !== value);
|
|
default:
|
|
return collection.and((record) => get(record, key) === value);
|
|
}
|
|
}
|
|
|
|
switch (operation) {
|
|
case 'contains':
|
|
// We return an error due to not being able to support contains on this filter due to performance.
|
|
throw new Error(
|
|
'Contains is not supported as a filter option with "or" operator.',
|
|
);
|
|
case 'gt':
|
|
return collection.or(key).above(value);
|
|
case 'gte':
|
|
return collection.or(key).aboveOrEqual(value);
|
|
case 'lt':
|
|
return collection.or(key).below(value);
|
|
case 'lte':
|
|
return collection.or(key).belowOrEqual(value);
|
|
case 'notEquals':
|
|
return collection.or(key).notEqual(value);
|
|
default:
|
|
return collection.or(key).equals(value);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Translates the fields passed in from the UI for resources to the keys indexed DB knows them as.
|
|
* Usually it's the path of the actual normalized model.
|
|
*/
|
|
const getKey = (key) => {
|
|
switch (key) {
|
|
case 'scope_id':
|
|
return `attributes.scope.${key}`;
|
|
case 'id':
|
|
return key;
|
|
default:
|
|
return `attributes.${key}`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Cleans the object to remove any keys that have a value of an empty array. This makes
|
|
* it easier so we don't have to worry about empty arrays getting queried in indexed db.
|
|
*/
|
|
const cleanObject = (obj) => {
|
|
if (!obj) {
|
|
return {};
|
|
}
|
|
|
|
return Object.entries(obj).reduce((result, [key, value]) => {
|
|
if (Array.isArray(value)) {
|
|
if (value.length > 0) {
|
|
result[key] = value;
|
|
}
|
|
} else if (value.values.length > 0) {
|
|
result[key] = value;
|
|
}
|
|
|
|
return result;
|
|
}, {});
|
|
};
|
|
|
|
/**
|
|
* Makes the search object that is expected when a user searches. Uses
|
|
* the indexes we setup in indexed db.
|
|
*/
|
|
const getSearchObject = (text, resource) => {
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text,
|
|
fields: modelIndexes[resource]
|
|
.split(', ')
|
|
.map((index) => index.replace('&', '')),
|
|
};
|
|
};
|