Repository: ViacomInc/data-point (js)

Issue:
withDefault reducer helper
**Problem description**:

There is no good way to give a reducer a fallback value.

**Suggested solution**:

Add a `withDefault` reducer helper (or maybe call it`defaultTo`?).

```js
const { withDefault } = require('data-point').helpers

{
  'transform:example': {
    a: withDefault('$a', 'DEFAULT_VALUE')
  }
}
```

This helper takes two parameters: a reducer and a value. If the reducer resolves to `undefined`, `null`, or `NaN` at run time, the helper returns the default value instead. Otherwise it returns the resolved value of the reducer.




Pull request:
feat(data-point/reducer-default: Add the ReducerDefault type
**What**: Adds the `ReducerDefault` type (closes #169)

**Why**: This will add default values to any reducer.

**How**: Instead of actually creating a new reducer type, this just adds a `default` property to any existing reducer. The property uses a symbol to avoid any future conflicts.

<!-- Have you done all of these things?  -->
**Checklist**:
<!-- add "N/A" to the end of each line that's irrelevant to your changes -->
<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->
- [ ] Has Breaking changes
- [x] Documentation
- [x] Tests
- [x] Ready to be merged

Patch:
diff --git a/packages/data-point/README.md b/packages/data-point/README.md
index 19833be..ac58e7f 100644
--- a/packages/data-point/README.md
+++ b/packages/data-point/README.md
@@ -38,6 +38,7 @@ npm install --save data-point
   - [find](#reducer-find)
   - [constant](#reducer-constant)
   - [parallel](#reducer-parallel)
+  - [withDefault](#reducer-default)
 - [Entities](#entities)
   - [dataPoint.addEntities](#api-data-point-add-entities)
   - [Built-in entities](#built-in-entities)
@@ -1228,6 +1229,50 @@ parallel(reducers:Array<Reducer>):Array
   ```
 </details>
 
+### <a name="reducer-default">withDefault</a>
+
+The **withDefault** reducer adds a default value to any reducer type. If the reducer resolves to `null`, `undefined`, `NaN`, or `''`,
+the default is returned instead.
+
+**SYNOPSIS**
+
+```js
+withDefault(source:*, value:*):*
+```
+
+**Reducer's arguments**
+
+| Argument | Type | Description |
+|:---|:---|:---|
+| *source* | * | Source data for creating a [reducer](#reducers)  |
+| *value* | * | The default value to use (or a function that returns the default value) |
+
+The default value is not cloned before it's returned, so it's good practice to wrap any Objects in a function.
+
+**EXAMPLE:**
+
+```js
+const { withDefault } = DataPoint.helpers
+
+const input = {
+  a: undefined
+}
+
+// adds a default to a PathReducer
+const r1 = withDefault('$a', 50)
+
+dataPoint.resolve(r1, input) // => 50
+
+// passing a function is useful when the default value is
+// an object, because it returns a new object every time
+const r2 = withDefault('$a', () => {
+  return { b: 1 }
+})
+
+dataPoint.resolve(r2, input) // => { b: 1 }
+
+```
+
 ## <a name="entities">Entities</a>
 
 Entities are artifacts that transform data. An entity is represented by a data structure (spec) that defines how the entity behaves. 
diff --git a/packages/data-point/lib/helpers/helpers.js b/packages/data-point/lib/helpers/helpers.js
index 4ae35d0..153736e 100644
--- a/packages/data-point/lib/helpers/helpers.js
+++ b/packages/data-point/lib/helpers/helpers.js
@@ -15,6 +15,7 @@ module.exports.helpers = {
   omit: stubFactories.omit,
   parallel: stubFactories.parallel,
   pick: stubFactories.pick,
+  withDefault: stubFactories.withDefault,
   isString: typeCheckFunctionReducers.isString,
   isNumber: typeCheckFunctionReducers.isNumber,
   isBoolean: typeCheckFunctionReducers.isBoolean,
diff --git a/packages/data-point/lib/reducer-types/factory.js b/packages/data-point/lib/reducer-types/factory.js
index 940080a..097c673 100644
--- a/packages/data-point/lib/reducer-types/factory.js
+++ b/packages/data-point/lib/reducer-types/factory.js
@@ -1,7 +1,7 @@
 const _ = require('lodash')
 const util = require('util')
 
-const REDUCER_SYMBOL = require('./reducer-symbol')
+const { IS_REDUCER, DEFAULT_VALUE } = require('./reducer-symbols')
 
 const ReducerEntity = require('./reducer-entity')
 const ReducerFunction = require('./reducer-function')
@@ -24,7 +24,7 @@ const reducerTypes = [
  * @returns {boolean}
  */
 function isReducer (item) {
-  return !!(item && item[REDUCER_SYMBOL])
+  return !!(item && item[IS_REDUCER])
 }
 
 module.exports.isReducer = isReducer
@@ -34,7 +34,7 @@ module.exports.isReducer = isReducer
  * @param {*} source
  * @returns {Array<reducer>|reducer}
  */
-function dealWithPipeOperators (source) {
+function normalizeInput (source) {
   let result = ReducerList.parse(source)
   if (result.length === 1) {
     // do not create a ReducerList that only contains a single reducer
@@ -47,11 +47,12 @@ function dealWithPipeOperators (source) {
 /**
  * parse reducer
  * @param {*} source
+ * @param {Object} options
  * @throws if source is not a valid type for creating a reducer
  * @return {reducer}
  */
-function createReducer (source) {
-  source = dealWithPipeOperators(source)
+function createReducer (source, options = {}) {
+  source = normalizeInput(source)
   const reducerType = reducerTypes.find(r => r.isType(source))
 
   if (_.isUndefined(reducerType)) {
@@ -68,7 +69,11 @@ function createReducer (source) {
 
   // NOTE: recursive call
   const reducer = reducerType.create(createReducer, source)
-  reducer[REDUCER_SYMBOL] = true
+  reducer[IS_REDUCER] = true
+  if (_.has(options, 'default')) {
+    reducer[DEFAULT_VALUE] = { value: options.default }
+  }
+
   return Object.freeze(reducer)
 }
 
diff --git a/packages/data-point/lib/reducer-types/reducer-helpers/index.js b/packages/data-point/lib/reducer-types/reducer-helpers/index.js
index 64587e9..824872e 100644
--- a/packages/data-point/lib/reducer-types/reducer-helpers/index.js
+++ b/packages/data-point/lib/reducer-types/reducer-helpers/index.js
@@ -2,6 +2,7 @@ const createStub = require('./reducer-stub').create
 
 const reducerAssign = require('./reducer-assign')
 const reducerConstant = require('./reducer-constant')
+const reducerDefault = require('./reducer-default')
 const reducerFilter = require('./reducer-filter')
 const reducerFind = require('./reducer-find')
 const reducerMap = require('./reducer-map')
@@ -33,6 +34,7 @@ module.exports.isType = require('./reducer-stub').isType
 const reducers = {
   [reducerAssign.type]: reducerAssign,
   [reducerConstant.type]: reducerConstant,
+  [reducerDefault.type]: reducerDefault,
   [reducerFilter.type]: reducerFilter,
   [reducerFind.type]: reducerFind,
   [reducerMap.type]: reducerMap,
@@ -50,6 +52,7 @@ function bindStubFunction (reducerType) {
 const stubFactories = {
   [reducerAssign.name]: bindStubFunction(reducerAssign.type),
   [reducerConstant.name]: bindStubFunction(reducerConstant.type),
+  [reducerDefault.name]: bindStubFunction(reducerDefault.type),
   [reducerFilter.name]: bindStubFunction(reducerFilter.type),
   [reducerFind.name]: bindStubFunction(reducerFind.type),
   [reducerMap.name]: bindStubFunction(reducerMap.type),
diff --git a/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/factory.js b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/factory.js
new file mode 100644
index 0000000..b9510c9
--- /dev/null
+++ b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/factory.js
@@ -0,0 +1,22 @@
+const REDUCER_DEFAULT = 'ReducerDefault'
+
+module.exports.type = REDUCER_DEFAULT
+
+const HELPER_NAME = 'withDefault'
+
+module.exports.name = HELPER_NAME
+
+/**
+ * this is used as a decorator
+ * it attaches a default value
+ * to an existing reducer type
+ * @param {Function} createReducer
+ * @param {*} source - raw source for a reducer
+ * @param {*} value
+ * @return {reducer}
+ */
+function create (createReducer, source, value) {
+  return createReducer(source, { default: value })
+}
+
+module.exports.create = create
diff --git a/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/index.js b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/index.js
new file mode 100644
index 0000000..fafee9c
--- /dev/null
+++ b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/index.js
@@ -0,0 +1,9 @@
+const factory = require('./factory')
+const resolve = require('./resolve').resolve
+
+module.exports = {
+  create: factory.create,
+  type: factory.type,
+  name: factory.name,
+  resolve: resolve
+}
diff --git a/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/resolve.js b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/resolve.js
new file mode 100644
index 0000000..2b4990e
--- /dev/null
+++ b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/resolve.js
@@ -0,0 +1,30 @@
+const utils = require('../../../utils')
+
+/**
+ * @param {*} value
+ * @return {boolean}
+ */
+function isFalsy (value) {
+  return (
+    value === null ||
+    typeof value === 'undefined' ||
+    Number.isNaN(value) ||
+    value === ''
+  )
+}
+
+/**
+ * @param {Accumulator} accumulator
+ * @param {*} _default
+ * @returns {Accumulator}
+ */
+function resolve (accumulator, _default) {
+  let value = accumulator.value
+  if (isFalsy(value)) {
+    value = typeof _default === 'function' ? _default() : _default
+  }
+
+  return utils.assign(accumulator, { value })
+}
+
+module.exports.resolve = resolve
diff --git a/packages/data-point/lib/reducer-types/reducer-symbol.js b/packages/data-point/lib/reducer-types/reducer-symbol.js
deleted file mode 100644
index 1d459f8..0000000
--- a/packages/data-point/lib/reducer-types/reducer-symbol.js
+++ /dev/null
@@ -1,1 +0,0 @@
-module.exports = Symbol('reducer')
diff --git a/packages/data-point/lib/reducer-types/reducer-symbols.js b/packages/data-point/lib/reducer-types/reducer-symbols.js
new file mode 100644
index 0000000..a6682de
--- /dev/null
+++ b/packages/data-point/lib/reducer-types/reducer-symbols.js
@@ -0,0 +1,3 @@
+module.exports.IS_REDUCER = Symbol('reducer')
+
+module.exports.DEFAULT_VALUE = Symbol('default')
diff --git a/packages/data-point/lib/reducer-types/resolve.js b/packages/data-point/lib/reducer-types/resolve.js
index dc2deca..777b0a9 100644
--- a/packages/data-point/lib/reducer-types/resolve.js
+++ b/packages/data-point/lib/reducer-types/resolve.js
@@ -1,11 +1,12 @@
 const Promise = require('bluebird')
+
 const ReducerEntity = require('./reducer-entity')
 const ReducerFunction = require('./reducer-function')
 const ReducerList = require('./reducer-list')
 const ReducerObject = require('./reducer-object')
 const ReducerPath = require('./reducer-path')
-
 const ReducerHelpers = require('./reducer-helpers').reducers
+const { DEFAULT_VALUE } = require('./reducer-symbols')
 
 const reducers = Object.assign({}, ReducerHelpers, {
   [ReducerEntity.type]: ReducerEntity,
@@ -15,6 +16,30 @@ const reducers = Object.assign({}, ReducerHelpers, {
   [ReducerPath.type]: ReducerPath
 })
 
+/**
+ * @param {Reducer} reducer
+ * @return {boolean}
+ */
+function hasDefault (reducer) {
+  return !!reducer[DEFAULT_VALUE]
+}
+
+module.exports.hasDefault = hasDefault
+
+/**
+ * @param {Reducer} reducer
+ * @throws if reducer is not valid
+ * @return {Function}
+ */
+function getResolveFunction (reducer) {
+  const reducerType = reducers[reducer.type]
+  if (reducerType) {
+    return reducerType.resolve
+  }
+
+  throw new Error(`Reducer type '${reducer.type}' was not recognized`)
+}
+
 /**
  * apply a Reducer to an accumulator
  * @param {Object} manager
@@ -22,7 +47,7 @@ const reducers = Object.assign({}, ReducerHelpers, {
  * @param {Reducer} reducer
  * @returns {Promise<Accumulator>}
  */
-function resolve (manager, accumulator, reducer) {
+function resolveReducer (manager, accumulator, reducer) {
   // this conditional is here because BaseEntity#resolve
   // does not check that lifecycle methods are defined
   // before trying to resolve them
@@ -30,13 +55,16 @@ function resolve (manager, accumulator, reducer) {
     return Promise.resolve(accumulator)
   }
 
-  const reducerType = reducers[reducer.type]
-  if (!reducerType) {
-    throw new Error(`Reducer type '${reducer.type}' was not recognized`)
+  const resolve = getResolveFunction(reducer)
+  // NOTE: recursive call
+  const result = resolve(manager, resolveReducer, accumulator, reducer)
+  if (hasDefault(reducer)) {
+    const _default = reducer[DEFAULT_VALUE].value
+    const resolveDefault = reducers.ReducerDefault.resolve
+    return result.then(acc => resolveDefault(acc, _default))
   }
 
-  // NOTE: recursive call
-  return reducerType.resolve(manager, resolve, accumulator, reducer)
+  return result
 }
 
-module.exports.resolve = resolve
+module.exports.resolve = resolveReducer


Test patch:
diff --git a/packages/data-point/lib/__snapshots__/index.test.js.snap b/packages/data-point/lib/__snapshots__/index.test.js.snap
index bc08161..ac64b38 100644
--- a/packages/data-point/lib/__snapshots__/index.test.js.snap
+++ b/packages/data-point/lib/__snapshots__/index.test.js.snap
@@ -21,6 +21,7 @@ Object {
     "omit": [Function],
     "parallel": [Function],
     "pick": [Function],
+    "withDefault": [Function],
   },
   "reducify": [Function],
   "reducifyAll": [Function],
diff --git a/packages/data-point/lib/helpers/__snapshots__/helpers.test.js.snap b/packages/data-point/lib/helpers/__snapshots__/helpers.test.js.snap
index 22a99f0..434f917 100644
--- a/packages/data-point/lib/helpers/__snapshots__/helpers.test.js.snap
+++ b/packages/data-point/lib/helpers/__snapshots__/helpers.test.js.snap
@@ -22,6 +22,7 @@ Object {
     "omit": [Function],
     "parallel": [Function],
     "pick": [Function],
+    "withDefault": [Function],
   },
   "isReducer": [Function],
   "mockReducer": [Function],
diff --git a/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/factory.test.js b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/factory.test.js
new file mode 100644
index 0000000..9fa5127
--- /dev/null
+++ b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/factory.test.js
@@ -0,0 +1,21 @@
+/* eslint-env jest */
+
+const Factory = require('./factory')
+const createReducer = require('../../index').create
+const { IS_REDUCER, DEFAULT_VALUE } = require('../../reducer-symbols')
+
+describe('ReducerDefault#factory', () => {
+  test('create with ReducerPath and string as default', () => {
+    const reducer = Factory.create(createReducer, '$a', 'DEFAULT_VALUE')
+    expect(reducer[IS_REDUCER]).toBe(true)
+    expect(reducer[DEFAULT_VALUE]).toEqual({ value: 'DEFAULT_VALUE' })
+    expect(reducer.type).toBe('ReducerPath')
+  })
+  test('create with ReducerPath and function as default', () => {
+    const _default = () => 'DEFAULT_VALUE'
+    const reducer = Factory.create(createReducer, '$a', _default)
+    expect(reducer[IS_REDUCER]).toBe(true)
+    expect(reducer[DEFAULT_VALUE]).toEqual({ value: _default })
+    expect(reducer.type).toBe('ReducerPath')
+  })
+})
diff --git a/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/resolve.test.js b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/resolve.test.js
new file mode 100644
index 0000000..ddc3bc2
--- /dev/null
+++ b/packages/data-point/lib/reducer-types/reducer-helpers/reducer-default/resolve.test.js
@@ -0,0 +1,24 @@
+/* eslint-env jest */
+
+const { resolve } = require('./resolve')
+
+describe('ReducerDefault#resolve', () => {
+  test('resolve with input value', () => {
+    expect(resolve({ value: 0 }, 5)).toEqual({ value: 0 })
+    expect(resolve({ value: false }, 5)).toEqual({ value: false })
+    expect(resolve({ value: 'value' }, 5)).toEqual({ value: 'value' })
+    expect(resolve({ value: { a: 1 } }, 5)).toEqual({ value: { a: 1 } })
+  })
+  test('resolve with default value', () => {
+    expect(resolve({ value: '' }, 5)).toEqual({ value: 5 })
+    expect(resolve({ value: undefined }, 5)).toEqual({ value: 5 })
+    expect(resolve({ value: null }, 5)).toEqual({ value: 5 })
+    expect(resolve({ value: NaN }, 5)).toEqual({ value: 5 })
+    expect(resolve({ value: undefined }, undefined)).toEqual({
+      value: undefined
+    })
+  })
+  test('resolve with default value function', () => {
+    expect(resolve({ value: undefined }, () => 5)).toEqual({ value: 5 })
+  })
+})
diff --git a/packages/data-point/lib/reducer-types/resolve.test.js b/packages/data-point/lib/reducer-types/resolve.test.js
index 4a34282..eacff4d 100644
--- a/packages/data-point/lib/reducer-types/resolve.test.js
+++ b/packages/data-point/lib/reducer-types/resolve.test.js
@@ -16,14 +16,18 @@ beforeAll(() => {
   manager = fixtureStore.create()
 })
 
+function resolve (source, options, value) {
+  const reducer = Factory.create(source, options)
+  const accumulator = AccumulatorFactory.create({ value })
+  return Resolve.resolve(manager, accumulator, reducer).catch(e => e)
+}
+
 describe('reducer#resolve', () => {
   test('It should work for valid input', () => {
-    const accumulator = AccumulatorFactory.create({
-      value: testData.a.b.c
-    })
-
-    const reducer = Factory.create(reducers.addCollectionValues())
-    return Resolve.resolve(manager, accumulator, reducer).then(result => {
+    const source = reducers.addCollectionValues()
+    const value = testData.a.b.c
+    const options = {}
+    return resolve(source, options, value).then(result => {
       expect(result.value).toEqual(6)
     })
   })
@@ -38,4 +42,48 @@ describe('reducer#resolve', () => {
       Resolve.resolve(manager, accumulator, reducer)
     }).toThrowErrorMatchingSnapshot()
   })
+
+  test('It should return undefined when no default is provided', () => {
+    const source = '$a'
+    const value = { a: undefined }
+    const options = {}
+    return resolve(source, options, value).then(result => {
+      expect(result.value).toBeUndefined()
+    })
+  })
+})
+
+describe('reducer#resolve with default value', () => {
+  test('do not overwrite false', () => {
+    const source = '$a'
+    const value = { a: false }
+    const options = { default: 500 }
+    return resolve(source, options, value).then(result => {
+      expect(result.value).toBe(false)
+    })
+  })
+  test('do not overwrite true', () => {
+    const source = '$a'
+    const value = { a: true }
+    const options = { default: 500 }
+    return resolve(source, options, value).then(result => {
+      expect(result.value).toBe(true)
+    })
+  })
+  test('overwrite undefined', () => {
+    const source = '$a'
+    const value = { a: undefined }
+    const options = { default: 500 }
+    return resolve(source, options, value).then(result => {
+      expect(result.value).toBe(500)
+    })
+  })
+  test('overwrite undefined with function as default', () => {
+    const source = '$a'
+    const value = { a: undefined }
+    const options = { default: () => 500 }
+    return resolve(source, options, value).then(result => {
+      expect(result.value).toBe(500)
+    })
+  })
 })