src/lens.js

  1. import { isFunction, isUndefined } from 'underscore';
  2. import { cloneImpl, isLensClass } from '../src-cjs/constants.js';
  3. import CustomStep from './custom_step.js';
  4. import fusion from './fusion.js';
  5. import Optic from './optic.js';
  6. import OpticArray from './optic_array.js';
  7. import { getIterator, handleNoniterableValue, index_maybe, isLens } from './utils.js';
  8. // Polyfill support for lenses to standard JavaScript types
  9. import './stdlib_support/object.js';
  10. import './stdlib_support/array.js';
  11. import './stdlib_support/map.js';
  12. /**
  13. * @typedef {Object} OptionalThrow
  14. * @property {*} [orThrow] The value to `throw` in case of an error.
  15. */
  16. /**
  17. * @typedef {Object} FallbackBindingResult
  18. * @property {Function} [or] The function to return if the slot does not contain a function.
  19. */
  20. /**
  21. * @typedef {Object} Maybe
  22. * @property {*} [just] The contained value
  23. * @see Haskell's "Maybe" data type
  24. *
  25. * @description
  26. * The presence of `just` as a property indicates the "Just" construction of
  27. * the Maybe monad — the presence of a value (even if `undefined`). A Maybe
  28. * without a `just` property is the "Nothing" construction.
  29. */
  30. /**
  31. * @extends Optic
  32. * @property {Array.<*>} keys Indexing/subscripting values to be applied successively to subjects of this lens
  33. */
  34. class Lens extends Optic {
  35. /**
  36. * @summary Class for operating immutably on a specific "slot" in complex data
  37. * @extends Optic
  38. * @param {...*} key A value to use in an application of subscripting (i.e. square bracket operator)
  39. *
  40. * @description
  41. * A Lens constructed as `let l = new Lens('address', 'street', 0)` represents
  42. * a certain slot within a complex subject value. If we had a record
  43. * ```js
  44. * const record = {
  45. * address: {
  46. * street: ["123 Somewhere Ln.", "Apt. 42"]
  47. * }
  48. * };
  49. * ```
  50. * then applying our lens with `l.get(record)` would evaluate to "123 Somewhere Ln.".
  51. *
  52. * **NOTE:** If a *key* of a Lens is a negative number and
  53. * the container accessed at that level is an Array, the negtive index works
  54. * like `Array.prototype.slice`, counting from the end of the Array.
  55. *
  56. * But Lenses offer more functionality than retrieval of values from a deeply
  57. * structured value — they can create a minimally cloned value deeply equal
  58. * to the subject but for the slot targeted by the lens and strictly equal
  59. * but for the slot targeted by the lens and the nodes in the input subject
  60. * traversed to reach that slot in the subject's tree.
  61. *
  62. * When constructing a modified clone, it is possible that some step of the
  63. * Lens will target a slot within a container-not-existing-in-subject. In this
  64. * case, the container to be created is intuited from the key that would
  65. * access it: an Array if the key is a number, otherwise an Object. Alternative
  66. * container construction can be specified via {@link Factory} or {@link Step}.
  67. *
  68. * Typically, instances are constructed by calling the Function exported
  69. * from `natural-lenses` (if `require`d) or its default export (if `import`ed),
  70. * conventionally named `lens`.
  71. */
  72. constructor(...keys) {
  73. super();
  74. this.keys = keys;
  75. }
  76. /**
  77. * @inheritdoc
  78. */
  79. thence(...keys) {
  80. return new Lens(...this.keys, ...keys);
  81. }
  82. /**
  83. * @summary Get a combination of presence and value of this slot
  84. * @param {*} subject The data to query
  85. * @param {...*} tail Additional subject for repeated application
  86. * @return {Maybe.<*>} Empty Object if this slot is not present in *subject*,
  87. * otherwise Object with `just` property containing value of this slot in *subject*
  88. *
  89. * @description
  90. * This implements the Maybe monad (familiar from Haskell), where Nothing is
  91. * represented as an empty Object and Just is represented as an Object with a
  92. * `just` property containing the value in the slot.
  93. *
  94. * If *tail* is given and getting this slot from from *subject* yields a
  95. * Lens (as indicated by a truthy `lens.isLens` property), then `#get_maybe()`
  96. * is called on that Lens, passing the spread of *tail*. If the value of
  97. * this slot is *not* a Lens, the result is an empty Object.
  98. */
  99. get_maybe(subject, ...tail) {
  100. let cur = subject;
  101. for (let i = 0; i < this.keys.length; i++) {
  102. const k = this.keys[i];
  103. const next_maybe = (function() {
  104. if (k instanceof CustomStep) {
  105. return k.get_maybe(cur);
  106. } else {
  107. return index_maybe(cur, k);
  108. }
  109. }());
  110. if ('just' in next_maybe) {
  111. cur = next_maybe.just;
  112. } else {
  113. return {}
  114. }
  115. }
  116. if (tail.length > 0) {
  117. return isLens(cur) ? cur.get_maybe(...tail) : {};
  118. }
  119. return {just: cur};
  120. }
  121. /**
  122. * @template T
  123. * @summary Clone *subject*, setting the value of this slot within the clone
  124. * @param {T} subject The input structured data
  125. * @param {*} newVal The new value to inject into the slot identified by this lens
  126. * @return {T} A minimally changed clone of *subject* with *newVal* in this slot
  127. *
  128. * @description
  129. * "Minimally changed" means that reference-copies are used wherever possible
  130. * while leaving subject unchanged, and that setting the slot to the strict-
  131. * equal value it already has results in returning subject.
  132. */
  133. setInClone(subject, newVal) {
  134. const slots = new Array(this.keys.length);
  135. let cur = subject;
  136. for (let i = 0; i < this.keys.length; i++) {
  137. const k = this.keys[i];
  138. const slot = slots[i] = makeSlot(cur, k);
  139. const next_maybe = slot.get_maybe();
  140. if ('just' in next_maybe) {
  141. cur = next_maybe.just;
  142. } else if (i + 1 < this.keys.length) {
  143. cur = this._constructFor(i + 1);
  144. }
  145. }
  146. if (slots[slots.length - 1].get() === newVal) {
  147. return subject;
  148. }
  149. cur = newVal;
  150. for (let i = slots.length - 1; i >= 0; i--) {
  151. cur = slots[i].cloneAndSet(cur);
  152. }
  153. return cur;
  154. }
  155. /**
  156. * @template T
  157. * @summary Clone the input, transforming the value within this slot with a function
  158. * @param {T} subject The input structured data
  159. * @param {function(*): *} fn The function that transforms the slot value
  160. * @param {Object} [opts]
  161. * @param {Boolean} opts.addMissinng=false Whether to add the slot if missing in subject
  162. * @return {T} A minimally changed clone of *subject* with *fn* applied to the value in this slot
  163. *
  164. * @description
  165. * If this slot is missing in subject, fn will not be called unless *addMissing*
  166. * is true, in which case fn will be called with undefined.
  167. *
  168. * "Minimally changed" means that reference-copies are used wherever possible
  169. * while leaving subject unchanged, and that setting the slot to the strict-
  170. * equal value it already has results in returning subject.
  171. */
  172. xformInClone(subject, fn, {addMissing = false} = {}) {
  173. const slots = new Array(this.keys.length);
  174. let cur = subject;
  175. for (let i = 0; i < this.keys.length; i++) {
  176. const k = this.keys[i];
  177. const slot = slots[i] = makeSlot(cur, k);
  178. const next_maybe = slot.get_maybe();
  179. if ('just' in next_maybe) {
  180. cur = next_maybe.just;
  181. } else if (addMissing) {
  182. if (i + 1 < this.keys.length) {
  183. cur = this._constructFor(i + 1);
  184. }
  185. } else {
  186. return subject;
  187. }
  188. }
  189. if (slots.length) {
  190. const prevVal = slots[slots.length - 1].get();
  191. cur = fn(prevVal);
  192. if (cur === prevVal) {
  193. return subject;
  194. }
  195. for (let i = slots.length - 1; i >= 0; i--) {
  196. cur = slots[i].cloneAndSet(cur);
  197. }
  198. } else {
  199. cur = fn(subject);
  200. }
  201. return cur;
  202. }
  203. /**
  204. * @template T
  205. * @summary Clone the input, transforming or deleting the Maybe value of this slot with a function
  206. * @param {T} subject The input structured data
  207. * @param {function(Maybe): Maybe} fn The function transforming the {@link Maybe} value of the slot
  208. * @return {T} A minimally changed clone of *subject* with this slot transformed per *fn*
  209. *
  210. * @description
  211. * The value given to *fn* will be the result of {@link Lens#get_maybe}, which
  212. * indicates absence of this slot in *subject* by omitting the `just` property,
  213. * which otherwise contains the value of this slot in *subject*. The value
  214. * returned by *fn* is the result that should be returned by calling
  215. * {@link Lens#get_maybe} of this slot on the modified clone: if no `just`
  216. * property is returned by *fn*, the slot is deleted from the clone, and
  217. * the value of the `just` property is otherwise set for this slot in the
  218. * clone.
  219. *
  220. * This implements what would, in Haskell, be described as:
  221. *
  222. * Json -> (Maybe Json -> Maybe Json) -> Json
  223. *
  224. * "Minimally changed" means that reference-copies are used wherever possible
  225. * while leaving subject unchanged, and that setting the slot to the strict-
  226. * equal value it already has results in returning subject.
  227. */
  228. xformInClone_maybe(subject, fn) {
  229. const slots = new Array(this.keys.length);
  230. let cur = subject, present = true;
  231. for (let i = 0; i < this.keys.length; i++) {
  232. const k = this.keys[i];
  233. const slot = slots[i] = makeSlot(cur, k);
  234. const next_maybe = slot.get_maybe();
  235. if ('just' in next_maybe) {
  236. cur = next_maybe.just;
  237. } else {
  238. present = false;
  239. if (i + 1 < this.keys.length) {
  240. cur = this._constructFor(i + 1);
  241. }
  242. }
  243. }
  244. if (slots.length) {
  245. const prevVal = slots[slots.length - 1].get();
  246. const maybe_val = fn(present ? {just: prevVal} : {});
  247. const setting = 'just' in maybe_val;
  248. if (present && !setting) {
  249. cur = slots[slots.length - 1].cloneOmitting();
  250. if (cur === slots[slots.length - 1].subject) {
  251. return subject;
  252. }
  253. for (let i = slots.length - 2; i >= 0; i--) {
  254. cur = slots[i].cloneAndSet(cur);
  255. }
  256. } else if (setting) {
  257. if (present && prevVal === maybe_val.just) {
  258. return subject;
  259. }
  260. cur = maybe_val.just;
  261. for (let i = slots.length - 1; i >= 0; i--) {
  262. cur = slots[i].cloneAndSet(cur);
  263. }
  264. } else {
  265. return subject;
  266. }
  267. } else {
  268. cur = fn({just: subject}).just;
  269. }
  270. return cur;
  271. }
  272. /**
  273. * @summary DRYly bind a Function to the Object from which it was obtained
  274. * @param {*} subject The input structured data
  275. * @param {Object} [options]
  276. * @param {boolean} [options.bindNow=true] Bind to the target of this lens with *subject* now rather than when the result function is invoked
  277. * @param {*} [options.orThrow] {@link OptionalThrow} if the slot referenced does not contain a Function; has precedence over *or*
  278. * @param {Function} [options.or] {@link FallbackBindingResult}, a Function to return if the slot referenced does not contain a Function
  279. * @return {Function} A Function bound to the previous object in the chain used to access the Function
  280. *
  281. * @description
  282. * Use this to avoid the dreaded Javascript binding repetition of
  283. * `x.y.z.fn.bind(x.y.z)`. Instead, use `lens('y', 'z', 'fn').bound(x)`.
  284. */
  285. bound(subject, {bindNow = true, orThrow, or} = {}) {
  286. const lens = this;
  287. function lookUpPlayers() {
  288. const lCopy = new Lens(...lens.keys), mname = lCopy.keys.pop();
  289. const mSubj = lCopy.get(subject), fn = (function() {
  290. try {return mSubj[mname];} catch (e) {}
  291. }());
  292. return {mSubj, fn};
  293. }
  294. if (bindNow) {
  295. const {mSubj, fn} = lookUpPlayers();
  296. if (isFunction(fn)) {
  297. return fn.bind(mSubj);
  298. }
  299. if (orThrow) {
  300. throw orThrow;
  301. } else if (!isUndefined(or)) {
  302. return or;
  303. }
  304. return function() {};
  305. } else return function (...args) {
  306. const {mSubj, fn} = lookUpPlayers();
  307. if (isFunction(fn)) {
  308. return fn.apply(mSubj, args);
  309. }
  310. if (orThrow) {
  311. throw orThrow;
  312. } else if (!isUndefined(or)) {
  313. return or.apply(undefined, args);
  314. }
  315. };
  316. }
  317. /**
  318. * Combine the effects of multiple Lenses
  319. *
  320. * It is preferred to use {@link module:natural-lenses#fuse}
  321. */
  322. static fuse(...lenses) {
  323. if (!lenses.every(l => l.constructor === Lens)) {
  324. throw "Expected all arguments to be exactly Lens (no derived classes)";
  325. }
  326. return new Lens(...lenses.flatMap(l => l.keys));
  327. }
  328. /*
  329. * @package
  330. * @summary Construct a container for a clone given the depth
  331. */
  332. _constructFor(depth) {
  333. const key = this.keys[depth];
  334. if (key instanceof CustomStep) {
  335. return key.construct();
  336. }
  337. return (typeof key === 'number') ? [] : {};
  338. }
  339. }
  340. class Slot {
  341. constructor(target, key) {
  342. this.target = target;
  343. this.key = key;
  344. }
  345. get() {
  346. return this.get_maybe().just;
  347. }
  348. get_maybe() {
  349. return index_maybe(this.target, this.key);
  350. }
  351. cloneAndSet(val) {
  352. const rval = this.cloneTarget({set: [this.key, val]});
  353. return rval;
  354. }
  355. cloneOmitting() {
  356. const rval = this.cloneTarget({spliceOut: this.key});
  357. return rval;
  358. }
  359. cloneTarget(opDesc /* {set, spliceOut} */ = {}) {
  360. let target = this.target;
  361. if (!target) {
  362. target = (typeof key === 'number') ? [] : {};
  363. }
  364. return target[cloneImpl](opDesc);
  365. }
  366. }
  367. class CSSlot {
  368. constructor(target, customStep) {
  369. this.target = target;
  370. this.customStep = customStep;
  371. }
  372. get() {
  373. return this.get_maybe().just;
  374. }
  375. get_maybe() {
  376. return this.customStep.get_maybe(this.target);
  377. }
  378. cloneAndSet(val) {
  379. return this.customStep.updatedClone(this.target, {just: val});
  380. }
  381. cloneOmitting() {
  382. return this.customStep.updatedClone(this.target, {});
  383. }
  384. }
  385. // Monkey-patch Optic here with `thence` to avoid cyclic dependency in optic.js
  386. let fuse = null;
  387. /**
  388. * @function Optic#thence
  389. * @summary Build a "deeper" lens/optic
  390. * @param {...*} key A value to use in an application of subscripting (i.e. square bracket operator)
  391. * @returns {Lens|OpticArray} An Optic that fuses this optic with a Lens looking at finer detail
  392. *
  393. * @description
  394. * This method creates a new Optic that looks at finer detail than the Optic
  395. * on which it was called. It either actually or effectively fuses a new
  396. * {@link Lens} to the right of this optic, as with [fuse]{@link module:natural-lenses#fuse}.
  397. */
  398. Optic.prototype.thence = function(...keys) {
  399. fuse = fuse || fusion({ Lens, OpticArray});
  400. return fuse(this, new Lens(...keys));
  401. }
  402. function makeSlot(cur, k) {
  403. return new ((k instanceof CustomStep) ? CSSlot : Slot)(cur, k);
  404. }
  405. export default Lens;