salty.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. const _ = require('lodash');
  2. // Install shim for Object.hasOwn() if necessary.
  3. /* istanbul ignore next */
  4. if (!Object.hasOwn) {
  5. Object.hasOwn = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
  6. }
  7. const IS_UNDEFINED = {
  8. isUndefined: true,
  9. };
  10. function addToSelection(salty, item, i) {
  11. salty._selectedItems.push(item);
  12. salty._selectedIndexes.push(i);
  13. }
  14. function selectAllItems(salty) {
  15. salty._selectedItems = salty._items.slice();
  16. salty._selectedIndexes = [...Array(salty._items.length).keys()];
  17. }
  18. function createSelection(salty) {
  19. salty._selectedIndexes = [];
  20. salty._selectedItems = [];
  21. }
  22. function eraseSelection(salty) {
  23. salty._selectedIndexes = null;
  24. salty._selectedItems = null;
  25. }
  26. function finderWithFunction(salty, func) {
  27. createSelection(salty);
  28. salty._items.forEach((item, i) => {
  29. if (func.bind(item)()) {
  30. addToSelection(salty, item, i);
  31. }
  32. });
  33. return salty;
  34. }
  35. function finderWithMatcher(salty, ...args) {
  36. let item;
  37. let matches;
  38. const len = salty._items.length;
  39. const matcher = _.defaults({}, ...args);
  40. const matcherKeys = Object.keys(matcher);
  41. let matcherValue;
  42. // Fast path if matcher object was empty or missing; that means "select all."
  43. if (matcherKeys.length === 0) {
  44. selectAllItems(salty);
  45. } else {
  46. createSelection(salty);
  47. for (let i = 0; i < len; i++) {
  48. matches = true;
  49. item = salty._items[i];
  50. for (const key of matcherKeys) {
  51. matcherValue = matcher[key];
  52. if (_.isMatch(matcherValue, IS_UNDEFINED)) {
  53. matches = _.isUndefined(item[key]);
  54. } else if (Array.isArray(matcherValue)) {
  55. if (!matcherValue.includes(item[key])) {
  56. matches = false;
  57. }
  58. } else if (matcherValue !== item[key]) {
  59. matches = false;
  60. }
  61. if (matches === false) {
  62. break;
  63. }
  64. }
  65. if (matches) {
  66. addToSelection(salty, item, i);
  67. }
  68. }
  69. }
  70. return salty;
  71. }
  72. function finder(salty, ...args) {
  73. if (args.length && _.isFunction(args[0])) {
  74. return finderWithFunction(salty, args[0]);
  75. } else {
  76. return finderWithMatcher(salty, ...args);
  77. }
  78. }
  79. function isNullOrUndefined(value) {
  80. return _.isNull(value) || _.isUndefined(value);
  81. }
  82. function sorter(items, keys) {
  83. keys = keys.split(',').map((key) => key.trim());
  84. keys.reverse();
  85. for (const key of keys) {
  86. // TaffyDB has an unusual approach to sorting that can yield surprising results. Rather than
  87. // duplicate that approach, we use the following sort order, which is close enough to TaffyDB
  88. // and is easier to reason about:
  89. //
  90. // 1. Non-null, non-undefined values, in standard sort order
  91. // 2. Null values
  92. // 3. Explicitly undefined values: key is present, value is undefined
  93. // 4. Implicitly undefined values: key is not present
  94. items.sort((a, b) => {
  95. const aValue = a[key];
  96. const bValue = b[key];
  97. if (isNullOrUndefined(aValue) || isNullOrUndefined(bValue)) {
  98. // Null and undefined come after all other values.
  99. if (!isNullOrUndefined(aValue)) {
  100. return -1;
  101. }
  102. if (!isNullOrUndefined(bValue)) {
  103. return 1;
  104. }
  105. // Null comes before undefined.
  106. if (_.isNull(aValue) && _.isUndefined(bValue)) {
  107. return -1;
  108. }
  109. if (_.isUndefined(aValue) && _.isNull(bValue)) {
  110. return 1;
  111. }
  112. // Explicitly undefined comes before implicitly undefined.
  113. if (_.isUndefined(aValue) && _.isUndefined(bValue)) {
  114. if (Object.hasOwn(a, key)) {
  115. return -1;
  116. }
  117. if (Object.hasOwn(b, key)) {
  118. return 1;
  119. }
  120. }
  121. // Both values are null, or both values are undefined.
  122. return 0;
  123. }
  124. // Neither value is null or undefined, so just use standard sort order.
  125. if (aValue < bValue) {
  126. return -1;
  127. }
  128. if (aValue > bValue) {
  129. return 1;
  130. }
  131. return 0;
  132. });
  133. }
  134. return true;
  135. }
  136. function makeDb(salty) {
  137. /*
  138. Selections are persisted a bit differently in TaffyDB and Salty. Consider the following:
  139. ```js
  140. let db2 = db({ a: 1 });
  141. db2.remove();
  142. db().get;
  143. db2.get();
  144. ```
  145. In TaffyDB, `db` and `db2` track selected items separately. `db().get()` returns all items;
  146. `db2.get()` returns an empty array.
  147. In Salty, `db` and `db2` are the same object, so they share information about selected items.
  148. `db().get()` returns all items; `db2.get()` also returns all items, because the selection from
  149. `db().get()` remains active.
  150. */
  151. const db = (...args) => finder(salty, ...args);
  152. db.sort = (keys) => sorter(salty._items, keys);
  153. return db;
  154. }
  155. class Salty {
  156. constructor(items) {
  157. this._items = items ? items.slice() : [];
  158. eraseSelection(this);
  159. return makeDb(this);
  160. }
  161. each(func) {
  162. this._selectedItems.forEach((item, i) => func(item, i));
  163. return this;
  164. }
  165. get() {
  166. return this._selectedItems.slice();
  167. }
  168. remove() {
  169. let removedItems = 0;
  170. if (this._selectedIndexes && this._selectedIndexes.length) {
  171. removedItems = this._selectedIndexes.length;
  172. this._items = this._items.filter((_item, i) => !this._selectedIndexes.includes(i));
  173. }
  174. // Make the selection empty so that calling `get()` returns an empty array.
  175. createSelection(this);
  176. return removedItems;
  177. }
  178. }
  179. module.exports = Salty;