PredicateCore.js

  1. /**
  2. * Rules
  3. * @module core
  4. * @namespace core
  5. * @since 1.0.0
  6. * @note rules are 100% tested from PredicateCore.test.js
  7. */
  8. const {
  9. merge,
  10. find,
  11. curry,
  12. prop,
  13. tap,
  14. pipe,
  15. filter,
  16. map,
  17. over,
  18. lens,
  19. lensPath,
  20. takeLast,
  21. clone,
  22. keys,
  23. startsWith,
  24. set,
  25. differenceWith,
  26. partition,
  27. lensProp,
  28. insert,
  29. } = require('ramda');
  30. const option = require('option');
  31. const { EventEmitter } = require('events');
  32. function head(list) {
  33. return option.fromNullable(list[0]).value();
  34. }
  35. module.exports = function({ dataclasses, invariants, errors, rules }) {
  36. const {
  37. CompoundPredicate,
  38. ComparisonPredicate,
  39. Predicate,
  40. Target,
  41. LogicalType,
  42. } = dataclasses;
  43. /**
  44. * Get a type by its type_id
  45. * @param {array} types
  46. * @param {string} type_id type id name
  47. * @return {?Type} a Type
  48. * @private
  49. * @since 1.0.0
  50. */
  51. const _getTypeById = (types, type_id) =>
  52. option.fromNullable(find(type => type.type_id == type_id, types));
  53. /**
  54. * Get a target by its target_id
  55. * @param {array} targets
  56. * @param {string} target_id target id name
  57. * @return {?dataclasses.Target}
  58. * @private
  59. * @since 1.0.0
  60. */
  61. const _getTargetById = (targets, target_id) =>
  62. option.fromNullable(find(target => target.target_id == target_id, targets));
  63. /**
  64. * Get a logical type by its logicalType_id
  65. * @param {array} logicalTypes
  66. * @param {string} logicalType_id logicalType id name
  67. * @return {?dataclasses.Target}
  68. * @private
  69. * @since 1.0.0
  70. */
  71. const _getLogicalTypeById = (logicalTypes, logicalType_id) =>
  72. option.fromNullable(
  73. find(
  74. logicalType => logicalType.logicalType_id == logicalType_id,
  75. logicalTypes
  76. )
  77. );
  78. /**
  79. * _getOperatorsByIds
  80. * @param {Object} columns
  81. * @param {string[]} operator_ids
  82. * @return {Array<dataclasses.operator>}
  83. * @private
  84. * @since 1.0.0
  85. */
  86. const _getOperatorsByIds = curry((operators, operator_ids) =>
  87. pipe(filter(({ operator_id }) => operator_ids.includes(operator_id)))(
  88. operators
  89. )
  90. );
  91. const _set$operatorsToType = curry((columns, type) => {
  92. type.$operators = _getOperatorsByIds(columns.operators, type.operator_ids);
  93. return type;
  94. });
  95. const _set$typeToTarget = curry((columns, target) => {
  96. const typeOption = _getTypeById(columns.types, target.type_id);
  97. return invariants
  98. .TargetMustReferToADefinedType(typeOption, target)
  99. .then(type => {
  100. target.$type = type;
  101. return target;
  102. });
  103. });
  104. /**
  105. * Tap for Promise
  106. * @param {Function} f
  107. * @return {Function}
  108. * @private
  109. */
  110. const _tapPromise = f => {
  111. return function(promise) {
  112. return promise.then(result => {
  113. f();
  114. return result;
  115. });
  116. };
  117. };
  118. /**
  119. * Run `fAfter()` (without any arguments) after `fBefore`, it will yield the promise yield from fBefore
  120. * @param {Function} fBefore
  121. * @param {Function} fAfter
  122. * @return {Promise} promise from fBefore
  123. * @private
  124. */
  125. const _afterPromise = (fBefore, fAfter) => pipe(fBefore, _tapPromise(fAfter));
  126. // columns => Promise[columns]
  127. const initializeColumns = columns => {
  128. // at first I used lenses, but the code was way harder to read so it's better that way :)
  129. // wrap operators
  130. columns.operators = map(dataclasses.Operator, columns.operators);
  131. // wrap logicalTypes
  132. columns.logicalTypes = map(dataclasses.LogicalType, columns.logicalTypes);
  133. // wrap types and set `$operators` attribute on each type
  134. const wrapType = pipe(dataclasses.Type, _set$operatorsToType(columns));
  135. columns.types = map(wrapType, columns.types);
  136. // wrap targets and set `$type` attribut on each target
  137. const wrapTarget = pipe(dataclasses.Target, _set$typeToTarget(columns));
  138. return Promise.all(map(wrapTarget, columns.targets)).then(targets => {
  139. columns.targets = targets;
  140. return columns;
  141. });
  142. };
  143. /**
  144. * Create a new PredicateCore
  145. * @param {?dataclasses.CompoundPredicate} [data=PredicateCore.defaults.options.getDefaultData]
  146. * @param {Object} [columns=PredicateCore.defaults.columns]
  147. * @param {Object} [options=PredicateCore.defaults.options]
  148. * @return {Promise<core.PredicateCoreAPI>}
  149. * @memberof core
  150. */
  151. function PredicateCore({ data, columns, options } = {}) {
  152. return initializeColumns(columns || PredicateCore.defaults.columns).then(
  153. _columns => {
  154. let _root;
  155. let _api;
  156. const _events = new EventEmitter();
  157. const _options = merge(PredicateCore.defaults.options, options);
  158. /**
  159. * Loop through the predicate tree and update flags (e.g. $canBeRemoved)
  160. * @private
  161. */
  162. function _apply$flags() {
  163. const canRemoveAnyPredicate = !rules.predicateToRemoveIsTheLastComparisonPredicate(
  164. _root,
  165. CompoundPredicate,
  166. ComparisonPredicate
  167. );
  168. CompoundPredicate.forEach(_root, function(predicate) {
  169. predicate.$canBeRemoved =
  170. canRemoveAnyPredicate &&
  171. !rules.predicateToRemoveIsRootPredicate(_root, predicate);
  172. });
  173. }
  174. function _emitChangedEvent() {
  175. _events.emit('changed', _api);
  176. }
  177. const _afterWrite = pipe(_apply$flags, _emitChangedEvent);
  178. /**
  179. * Set PredicateCore data
  180. * @param {dataclasses.CompoundPredicate} root CompoundPredicate
  181. * @return {Promise<undefined, errors.RootPredicateMustBeACompoundPredicate>} resolved promise yield nothing, rejected promise yield RootPredicateMustBeACompoundPredicate error
  182. * @since 1.0.0
  183. * @memberof core.api
  184. */
  185. function setData(root) {
  186. return invariants
  187. .RootPredicateMustBeACompoundPredicate(root, CompoundPredicate)
  188. .then(() => {
  189. _root = root;
  190. });
  191. }
  192. /**
  193. * Add a ComparisonPredicate or CompoundPredicate
  194. * @param {Object} option
  195. * @param {string} options.type what type of Predicate to add
  196. * @param {string} [options.how=after] should we insert it before, after or instead of? (currently only after is supported)
  197. * @param {dataclasses.Predicate} options.where current element
  198. * @return {Promise<dataclasses.Predicate>} inserted predicate
  199. * @since 1.0.0
  200. * @memberof core.api
  201. */
  202. function add({ where, how = 'after', type }) {
  203. // currently only after is supported
  204. return (
  205. Promise.resolve()
  206. .then(() => invariants.AddOnlySupportsAfter(how))
  207. .then(() =>
  208. invariants.PredicateTypeMustBeValid(type, Predicate.Types)
  209. )
  210. // generate the Predicates
  211. .then(() => _options[`getDefault${type}`](_columns, _options))
  212. // then add it
  213. .then(predicate => {
  214. const isComparisonPredicate = ComparisonPredicate.is(where);
  215. if (isComparisonPredicate || CompoundPredicate.is(where)) {
  216. if (isComparisonPredicate) {
  217. // it's a comparisonpredicate
  218. // first find predicates array that contains the element
  219. const path = _find(where);
  220. // we are starting from a ComparisonPredicate that always live inside a CompoundPredicate.predicates array
  221. const [compoundpredicate, [_, index]] = takeLast(2, path);
  222. compoundpredicate.predicates = insert(
  223. index + 1,
  224. predicate,
  225. compoundpredicate.predicates
  226. );
  227. } else {
  228. // it's a compoundpredicate
  229. // we want to add a CompoundPredicate after a compound predicate
  230. // so we need to add it as its first .predicates entry
  231. where.predicates.unshift(predicate);
  232. }
  233. return predicate;
  234. }
  235. return Promise.reject(
  236. new errors.CannotAddSomethingElseThanACompoundPredicateOrAComparisonPredicate()
  237. );
  238. })
  239. );
  240. }
  241. /**
  242. * Remove a ComparisonPredicate or CompoundPredicate
  243. * @param {(dataclasses.ComparisonPredicate|dataclasses.CompoundPredicate)} predicate
  244. * @return {Promise<dataclasses.Predicate>} yield the removed predicate, will reject the promise if remove was called with the root CompoundPredicate or the last ComparisonPredicate of the root CompoundPredicate
  245. * @since 1.0.0
  246. * @memberof core.api
  247. */
  248. function remove(predicate) {
  249. return Promise.resolve()
  250. .then(() =>
  251. invariants.RemovePredicateMustDifferFromRootPredicate(
  252. _root,
  253. predicate
  254. )
  255. )
  256. .then(() =>
  257. invariants.RemovePredicateCannotBeTheLastComparisonPredicate(
  258. _root,
  259. predicate,
  260. CompoundPredicate,
  261. ComparisonPredicate
  262. )
  263. )
  264. .then(() => {
  265. if (
  266. CompoundPredicate.is(predicate) ||
  267. ComparisonPredicate.is(predicate)
  268. ) {
  269. const path = _find(predicate);
  270. // we are starting from a ComparisonPredicate that always live
  271. // inside a CompoundPredicate.predicates array
  272. const [parentCompoundpredicate, [_, index]] = takeLast(2, path);
  273. parentCompoundpredicate.predicates.splice(index, 1);
  274. if (parentCompoundpredicate.predicates.length === 0) {
  275. // if there are not any more predicates
  276. // inside the parentCompoundpredicate, we should also remove it
  277. return remove(parentCompoundpredicate);
  278. }
  279. return predicate;
  280. }
  281. return Promise.reject(
  282. new errors.CannotRemoveSomethingElseThanACompoundPredicateOrAComparisonPredicate()
  283. );
  284. });
  285. }
  286. /**
  287. * Change a CompoundPredicate logical
  288. * @param {dataclasses.CompoundPredicate} predicate
  289. * @param {string} newLogicalType_id
  290. * @return {Promise<undefined, errors.PredicateMustBeACompoundPredicate>} yield nothing if everything went right, otherwise yield a reject promise with the PredicateMustBeACompoundPredicate error
  291. * @since 1.0.0
  292. * @memberof core.api
  293. */
  294. function setPredicateLogicalType_id(predicate, newLogicalType_id) {
  295. return invariants
  296. .PredicateMustBeACompoundPredicate(predicate, CompoundPredicate)
  297. .then(() => {
  298. // first change the logical type
  299. return _getLogicalTypeById(
  300. _columns.logicalTypes,
  301. newLogicalType_id
  302. );
  303. })
  304. .then(logicalTypeOption =>
  305. invariants.LogicalType_idMustReferToADefinedLogicalType(
  306. logicalTypeOption
  307. )
  308. )
  309. .then(logicalTypeOption => {
  310. predicate.logic = logicalTypeOption.value(); // safe
  311. });
  312. }
  313. /**
  314. * Change a predicate's target
  315. * @param {dataclasses.ComparisonPredicate} predicate
  316. * @param {string} newTarget_id
  317. * @return {Promise} yield nothing if everything went right, otherwise yield a reject promise with the PredicateMustBeAComparisonPredicate error
  318. * @since 1.0.0
  319. * @memberof core.api
  320. */
  321. function setPredicateTarget_id(predicate, newTarget_id) {
  322. return invariants
  323. .PredicateMustBeAComparisonPredicate(predicate, ComparisonPredicate)
  324. .then(() => {
  325. // first change the target
  326. return _getTargetById(_columns.targets, newTarget_id);
  327. })
  328. .then(targetOption =>
  329. invariants.Target_idMustReferToADefinedTarget(targetOption)
  330. )
  331. .then(targetOption => {
  332. predicate.target = targetOption.value(); // safe
  333. // then change the operator to the first operator for this target
  334. return setPredicateOperator_id(
  335. predicate,
  336. head(predicate.target.$type.$operators).operator_id
  337. );
  338. });
  339. }
  340. /**
  341. * Change a predicate's operator
  342. * @param {dataclasses.ComparisonPredicate} predicate
  343. * @param {string} newTarget_id
  344. * @return {Promise<undefined, errors.PredicateMustBeAComparisonPredicate>} yield nothing if everything went right, otherwise yield a reject promise with the PredicateMustBeAComparisonPredicate error
  345. * @since 1.0.0
  346. * @memberof core.api
  347. */
  348. function setPredicateOperator_id(predicate, newOperator_id) {
  349. return (
  350. Promise.resolve()
  351. // find operator
  352. .then(() =>
  353. option.fromNullable(
  354. predicate.target.$type.$operators.find(
  355. operator => operator.operator_id === newOperator_id
  356. )
  357. )
  358. )
  359. .then(operatorOption =>
  360. invariants.Operator_idMustReferToADefinedOperator(
  361. operatorOption
  362. )
  363. )
  364. // change the operator
  365. .then(operatorOption => {
  366. predicate.operator = operatorOption.value(); // safe
  367. // then reset arguments to array
  368. predicate.arguments = [];
  369. })
  370. );
  371. }
  372. /**
  373. * Compute the JSON pointer path the element
  374. * @param {Object} element (http://jsonpatch.com/)
  375. * @return {?Array} null if not found
  376. * @readonly
  377. * @since 1.0.0
  378. */
  379. function _find(element) {
  380. return CompoundPredicate.reduce(
  381. _root,
  382. (acc, predicate, parents) => {
  383. return element === predicate ? parents : acc;
  384. },
  385. null
  386. );
  387. }
  388. /**
  389. * Yield a serializable object without internal flags ($xxx properties)
  390. * @example console.log(JSON.stringify(ctrl, null, 2)); // will call ctrl.toJSON() underneath
  391. * @return {Object} a serializable object
  392. * @since 1.0.0
  393. * @memberof core.api
  394. */
  395. function toJSON() {
  396. const root = clone(_root);
  397. const toString = Object.prototype.toString;
  398. const isObject = mixed => toString.call(mixed) === '[object Object]';
  399. // we just need to go one-level deep
  400. function remove$flagsFromObj(obj, level = 1) {
  401. /* istanbul ignore if */
  402. if (level < 0) {
  403. return;
  404. }
  405. keys(obj).forEach(key => {
  406. if (key === 'predicates') {
  407. return;
  408. }
  409. if (startsWith('$', key)) {
  410. delete obj[key];
  411. }
  412. if (isObject(obj[key])) {
  413. remove$flagsFromObj(obj[key], level - 1);
  414. }
  415. });
  416. }
  417. CompoundPredicate.forEach(root, remove$flagsFromObj);
  418. return root;
  419. }
  420. /**
  421. * Adds the `listener` function to the end of the listeners array for the event named `eventName`.
  422. * No checks are made to see if the `listener` has already been added. Multiple calls passing the same combination of eventName and listener will result in the listener being added, and called, multiple times.
  423. * @param {string} eventName available event names are : (`changed`, api)
  424. * @param {function} listener
  425. * @since 1.0.0
  426. * @memberof core.api
  427. */
  428. function on(eventName, listener) {
  429. _events.on(eventName, listener);
  430. }
  431. /**
  432. * Adds a *one-time* `listener` function for the event named `eventName`. The next time `eventName` is triggered, this `listener` is removed and then invoked.
  433. * @param {string} eventName see {@link core.api.on} for available event names
  434. * @param {function} listener
  435. * @see core.api.on
  436. * @since 1.0.0
  437. * @memberof core.api
  438. */
  439. function once(eventName, listener) {
  440. _events.once(eventName, listener);
  441. }
  442. /**
  443. * Remove listener(s)
  444. * If off() will remove every listeners
  445. * If off(eventName) will only remove listeners to this specific eventName
  446. * If off(eventName, listener) will remove the `listener` to the `eventName`
  447. * @param {?string} eventName see {@link core.api.on} for available event names
  448. * @param {?function} listener
  449. * @since 1.0.0
  450. * @memberof core.api
  451. */
  452. function off(eventName, listener) {
  453. if (!eventName) {
  454. // because removeAllListeners treat "undefined" as an event name :facepalm:
  455. _events.removeAllListeners();
  456. } else if (!listener) {
  457. _events.removeAllListeners(eventName);
  458. } else {
  459. _events.removeListener(eventName, listener);
  460. }
  461. }
  462. // get data for initialization
  463. return (
  464. (data
  465. ? Promise.resolve(data)
  466. : _options.getDefaultData(_columns, _options)
  467. )
  468. // setup PredicateCore data
  469. .then(_afterPromise(setData, _afterWrite))
  470. // expose public API
  471. .then(() => {
  472. /**
  473. * ui-predicate core public API
  474. * @typedef {object} PredicateCoreAPI
  475. * @namespace core.api
  476. */
  477. _api = {
  478. on: on,
  479. once: once,
  480. off: off,
  481. setData: _afterPromise(setData, _afterWrite),
  482. add: _afterPromise(add, _afterWrite),
  483. remove: _afterPromise(remove, _afterWrite),
  484. setPredicateTarget_id: _afterPromise(
  485. setPredicateTarget_id,
  486. _afterWrite
  487. ),
  488. setPredicateOperator_id: _afterPromise(
  489. setPredicateOperator_id,
  490. _afterWrite
  491. ),
  492. setPredicateLogicalType_id: _afterPromise(
  493. setPredicateLogicalType_id,
  494. _afterWrite
  495. ),
  496. /**
  497. * Get root CompoundPredicate
  498. * @return {dataclasses.CompoundPredicate}
  499. * @memberof core.api
  500. */
  501. get root() {
  502. return _root;
  503. },
  504. toJSON,
  505. // used for testing
  506. get columns() {
  507. return _columns;
  508. },
  509. // used for testing
  510. get options() {
  511. return _options;
  512. },
  513. };
  514. return _api;
  515. })
  516. );
  517. }
  518. );
  519. }
  520. /**
  521. * Defaults configuration of PredicateCore
  522. * @type {Object}
  523. * @namespace core.defaults
  524. */
  525. PredicateCore.defaults = {
  526. options: {
  527. /**
  528. * When data is not set at construction time PredicateCore default behavior will be to use the first target and its first operator with empty argument
  529. * @param {Object} dataclasses every necessary data class
  530. * @param {Object} columns every necessary data class
  531. * @param {Object} options PredicateCore available options
  532. * @return {Promise<dataclasses.CompoundPredicate>} root CompoundPredicate
  533. * @since 1.0.0
  534. * @memberof core.defaults
  535. */
  536. getDefaultData(columns, options) {
  537. return options
  538. .getDefaultComparisonPredicate(columns, options)
  539. .then(comparisonPredicate => {
  540. return options.getDefaultCompoundPredicate(columns, options, [
  541. comparisonPredicate,
  542. ]);
  543. });
  544. },
  545. /**
  546. * Default compount predicate to use
  547. *
  548. * This function is called whenever a new CompoundPredicate is added to the UIPredicate
  549. * @param {Array<dataclasses.Predicate>} predicates
  550. * @param {Object} columns specified columns
  551. * @param {Object} options PredicateCore available options
  552. * @return {Promise<dataclasses.CompoundPredicate>} a CompoundPredicate
  553. * @since 1.0.0
  554. * @memberof core.defaults
  555. */
  556. getDefaultCompoundPredicate(columns, options, predicates) {
  557. return (!Array.isArray(predicates) || predicates.length === 0
  558. ? options
  559. .getDefaultComparisonPredicate(columns, options)
  560. .then(comparisonPredicate => [comparisonPredicate])
  561. : Promise.resolve(predicates)
  562. ).then(predicates =>
  563. options
  564. .getDefaultLogicalType(predicates, columns, options)
  565. .then(logicalType => CompoundPredicate(logicalType, predicates))
  566. );
  567. },
  568. /**
  569. * Default comparison predicate to use
  570. *
  571. * This function is called whenever a new ComparisonPredicate is added to the UIPredicate
  572. * @param {Object} columns specified columns
  573. * @param {Object} [options=PredicateCore.defaults.options] PredicateCore available options
  574. * @return {Promise<dataclasses.ComparisonPredicate>} a Comparison
  575. * @since 1.0.0
  576. * @memberof core.defaults
  577. */
  578. getDefaultComparisonPredicate(columns, options) {
  579. const firstTarget = head(columns.targets);
  580. return ComparisonPredicate(
  581. firstTarget,
  582. head(firstTarget.$type.$operators),
  583. []
  584. );
  585. },
  586. /**
  587. * Default logical type to use when a new comparison predicate is created
  588. *
  589. * This function is called whenever a new ComparisonPredicate is added to the UIPredicate
  590. * @param {Array<dataclasses.Predicate>} predicates specified columns
  591. * @param {Object} columns specified columns
  592. * @param {Object} [options=PredicateCore.defaults.options] PredicateCore available options
  593. * @return {Promise<dataclasses.LogicalType>} a logical type
  594. * @since 1.0.0
  595. * @memberof core.defaults
  596. */
  597. getDefaultLogicalType(predicates, columns, options) {
  598. return Promise.resolve(head(columns.logicalTypes));
  599. },
  600. },
  601. columns: {
  602. // besides array list names, everything else follows convention https://github.com/FGRibreau/sql-convention
  603. operators: [
  604. {
  605. operator_id: 'is',
  606. label: 'is',
  607. },
  608. {
  609. operator_id: 'contains',
  610. label: 'contains',
  611. },
  612. {
  613. operator_id: 'isLowerThan',
  614. label: '<',
  615. },
  616. {
  617. operator_id: 'isEqualTo',
  618. label: '=',
  619. },
  620. {
  621. operator_id: 'isHigherThan',
  622. label: '>',
  623. },
  624. {
  625. operator_id: 'isBetween',
  626. label: 'is between',
  627. },
  628. ],
  629. types: [
  630. {
  631. type_id: 'int',
  632. operator_ids: ['isLowerThan', 'isEqualTo', 'isHigherThan'],
  633. },
  634. {
  635. type_id: 'string',
  636. operator_ids: ['is', 'contains'],
  637. },
  638. {
  639. type_id: 'datetime',
  640. operator_ids: ['is', 'isBetween'],
  641. },
  642. ],
  643. targets: [
  644. {
  645. target_id: 'title',
  646. label: 'Title',
  647. type_id: 'string',
  648. },
  649. {
  650. target_id: 'videoCount',
  651. label: 'Video count',
  652. type_id: 'int',
  653. },
  654. {
  655. target_id: 'publishedAt',
  656. label: 'Created at',
  657. type_id: 'datetime',
  658. },
  659. ],
  660. logicalTypes: [
  661. {
  662. logicalType_id: 'any',
  663. label: 'Any',
  664. },
  665. {
  666. logicalType_id: 'all',
  667. label: 'All',
  668. },
  669. {
  670. logicalType_id: 'none',
  671. label: 'None',
  672. },
  673. ],
  674. },
  675. };
  676. return PredicateCore;
  677. };