227 lines
5.3 KiB
JavaScript

const _ = require('lodash');
// Install shim for Object.hasOwn() if necessary.
/* istanbul ignore next */
if (!Object.hasOwn) {
Object.hasOwn = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
}
const IS_UNDEFINED = {
isUndefined: true,
};
function addToSelection(salty, item, i) {
salty._selectedItems.push(item);
salty._selectedIndexes.push(i);
}
function selectAllItems(salty) {
salty._selectedItems = salty._items.slice();
salty._selectedIndexes = [...Array(salty._items.length).keys()];
}
function createSelection(salty) {
salty._selectedIndexes = [];
salty._selectedItems = [];
}
function eraseSelection(salty) {
salty._selectedIndexes = null;
salty._selectedItems = null;
}
function finderWithFunction(salty, func) {
createSelection(salty);
salty._items.forEach((item, i) => {
if (func.bind(item)()) {
addToSelection(salty, item, i);
}
});
return salty;
}
function finderWithMatcher(salty, ...args) {
let item;
let matches;
const len = salty._items.length;
const matcher = _.defaults({}, ...args);
const matcherKeys = Object.keys(matcher);
let matcherValue;
// Fast path if matcher object was empty or missing; that means "select all."
if (matcherKeys.length === 0) {
selectAllItems(salty);
} else {
createSelection(salty);
for (let i = 0; i < len; i++) {
matches = true;
item = salty._items[i];
for (const key of matcherKeys) {
matcherValue = matcher[key];
if (_.isMatch(matcherValue, IS_UNDEFINED)) {
matches = _.isUndefined(item[key]);
} else if (Array.isArray(matcherValue)) {
if (!matcherValue.includes(item[key])) {
matches = false;
}
} else if (matcherValue !== item[key]) {
matches = false;
}
if (matches === false) {
break;
}
}
if (matches) {
addToSelection(salty, item, i);
}
}
}
return salty;
}
function finder(salty, ...args) {
if (args.length && _.isFunction(args[0])) {
return finderWithFunction(salty, args[0]);
} else {
return finderWithMatcher(salty, ...args);
}
}
function isNullOrUndefined(value) {
return _.isNull(value) || _.isUndefined(value);
}
function sorter(items, keys) {
keys = keys.split(',').map((key) => key.trim());
keys.reverse();
for (const key of keys) {
// TaffyDB has an unusual approach to sorting that can yield surprising results. Rather than
// duplicate that approach, we use the following sort order, which is close enough to TaffyDB
// and is easier to reason about:
//
// 1. Non-null, non-undefined values, in standard sort order
// 2. Null values
// 3. Explicitly undefined values: key is present, value is undefined
// 4. Implicitly undefined values: key is not present
items.sort((a, b) => {
const aValue = a[key];
const bValue = b[key];
if (isNullOrUndefined(aValue) || isNullOrUndefined(bValue)) {
// Null and undefined come after all other values.
if (!isNullOrUndefined(aValue)) {
return -1;
}
if (!isNullOrUndefined(bValue)) {
return 1;
}
// Null comes before undefined.
if (_.isNull(aValue) && _.isUndefined(bValue)) {
return -1;
}
if (_.isUndefined(aValue) && _.isNull(bValue)) {
return 1;
}
// Explicitly undefined comes before implicitly undefined.
if (_.isUndefined(aValue) && _.isUndefined(bValue)) {
if (Object.hasOwn(a, key)) {
return -1;
}
if (Object.hasOwn(b, key)) {
return 1;
}
}
// Both values are null, or both values are undefined.
return 0;
}
// Neither value is null or undefined, so just use standard sort order.
if (aValue < bValue) {
return -1;
}
if (aValue > bValue) {
return 1;
}
return 0;
});
}
return true;
}
function makeDb(salty) {
/*
Selections are persisted a bit differently in TaffyDB and Salty. Consider the following:
```js
let db2 = db({ a: 1 });
db2.remove();
db().get;
db2.get();
```
In TaffyDB, `db` and `db2` track selected items separately. `db().get()` returns all items;
`db2.get()` returns an empty array.
In Salty, `db` and `db2` are the same object, so they share information about selected items.
`db().get()` returns all items; `db2.get()` also returns all items, because the selection from
`db().get()` remains active.
*/
const db = (...args) => finder(salty, ...args);
db.sort = (keys) => sorter(salty._items, keys);
return db;
}
class Salty {
constructor(items) {
this._items = items ? items.slice() : [];
eraseSelection(this);
return makeDb(this);
}
each(func) {
this._selectedItems.forEach((item, i) => func(item, i));
return this;
}
get() {
return this._selectedItems.slice();
}
remove() {
let removedItems = 0;
if (this._selectedIndexes && this._selectedIndexes.length) {
removedItems = this._selectedIndexes.length;
this._items = this._items.filter((_item, i) => !this._selectedIndexes.includes(i));
}
// Make the selection empty so that calling `get()` returns an empty array.
createSelection(this);
return removedItems;
}
}
module.exports = Salty;