Repository: jsonata-js/jsonata
Pull request #355 (2019-08-26 11:23:39)

Add $single() function.
Proposed in #348

coveralls: 
[![Coverage Status](https://coveralls.io/builds/25343221/badge)](https://coveralls.io/builds/25343221)

Coverage remained the same at 100.0% when pulling **c65cf90e8d1a7eca06bfbed9b9b43de45cc0c9e3 on elasticio:add-single-function** into **26584f52b52dbea649365b945f7e7e09662f3d37 on jsonata-js:master**.


Patch:
diff --git a/docs/higher-order-functions.md b/docs/higher-order-functions.md
index 80a23fb..598881a 100644
--- a/docs/higher-order-functions.md
+++ b/docs/higher-order-functions.md
@@ -9,13 +9,13 @@ __Signature:__ `$map(array, function)`
 
 Returns an array containing the results of applying the `function` parameter to each value in the `array` parameter.
 
-The function that is supplied as the second parameter must have the following signature:  
+The function that is supplied as the second parameter must have the following signature:
 
-`function(value [, index [, array]])` 
- 
-Each value in the input array is passed in as the first parameter in the supplied function.  The index (position) of that value in the input array is passed in as the second parameter, if specified.  The whole input array is passed in as the third parameter, if specified.  
+`function(value [, index [, array]])`
 
-__Examples__  
+Each value in the input array is passed in as the first parameter in the supplied function.  The index (position) of that value in the input array is passed in as the second parameter, if specified.  The whole input array is passed in as the third parameter, if specified.
+
+__Examples__
 - `$map([1..5], $string)` => `["1", "2", "3", "4", "5"]`
 
 With user-defined (lambda) function:
@@ -41,11 +41,11 @@ __Signature:__ `$filter(array, function)`
 
 Returns an array containing only the values in the `array` parameter that satisfy the `function` predicate (i.e. `function` returns Boolean `true` when passed the value).
 
-The function that is supplied as the second parameter must have the following signature:  
+The function that is supplied as the second parameter must have the following signature:
+
+`function(value [, index [, array]])`
 
-`function(value [, index [, array]])` 
- 
-Each value in the input array is passed in as the first parameter in the supplied function.  The index (position) of that value in the input array is passed in as the second parameter, if specified.  The whole input array is passed in as the third parameter, if specified.  
+Each value in the input array is passed in as the first parameter in the supplied function.  The index (position) of that value in the input array is passed in as the second parameter, if specified.  The whole input array is passed in as the third parameter, if specified.
 
 __Example__  
 The following expression returns all the products whose price is higher than average:
@@ -55,6 +55,25 @@ $filter(Account.Order.Product, function($v, $i, $a) {
 })
 ```
 
+## `$single()`
+__Signature:__ `$single(array, function)`
+
+Returns the one and only one value in the `array` parameter that satisfy the `function` predicate (i.e. `function` returns Boolean `true` when passed the value).  Throws an exception if the number of matching values is not exactly one.
+
+The function that is supplied as the second parameter must have the following signature:
+
+`function(value [, index [, array]])`
+
+Each value in the input array is passed in as the first parameter in the supplied function.  The index (position) of that value in the input array is passed in as the second parameter, if specified.  The whole input array is passed in as the third parameter, if specified.
+
+__Example__  
+The following expression the product in the order whose SKU is `"0406654608"`:
+```
+$single(Account.Order.Product, function($v, $i, $a) {
+  $v.SKU = "0406654608"
+})
+```
+
 ## `$reduce()`
 __Signature:__ `$reduce(array, function [, init])`
 
@@ -82,11 +101,11 @@ Returns an object that contains only the key/value pairs from the `object` param
 
 If `object` is not specified, then the context value is used as the value of `object`.  It is an error if `object` is not an object.
 
-The function that is supplied as the second parameter must have the following signature:  
+The function that is supplied as the second parameter must have the following signature:
+
+`function(value [, key [, object]])`
 
-`function(value [, key [, object]])` 
- 
-Each value in the input object is passed in as the first parameter in the supplied function.  The key (property name) of that value in the input object is passed in as the second parameter, if specified.  The whole input object is passed in as the third parameter, if specified.  
+Each value in the input object is passed in as the first parameter in the supplied function.  The key (property name) of that value in the input object is passed in as the second parameter, if specified.  The whole input object is passed in as the third parameter, if specified.
 
 __Example__
 
diff --git a/src/functions.js b/src/functions.js
index a675d9f..b4754b7 100644
--- a/src/functions.js
+++ b/src/functions.js
@@ -1325,7 +1325,7 @@ const functions = (() => {
 
     /**
      * Helper function to build the arguments to be supplied to the function arg of the
-     * HOFs map, filter, each and sift
+     * HOFs map, filter, each, sift and single
      * @param {function} func - the function to be invoked
      * @param {*} arg1 - the first (required) arg - the value
      * @param {*} arg2 - the second (optional) arg - the position (index or key)
@@ -1398,6 +1398,55 @@ const functions = (() => {
         return result;
     }
 
+    /**
+     * Given an array, find the single element matching a specified condition
+     * Throws an exception if the number of matching elements is not exactly one
+     * @param {Array} [arr] - array to filter
+     * @param {Function} [func] - predicate function
+     * @returns {*} Matching element
+     */
+    function* single(arr, func) { // eslint-disable-line require-yield
+        // undefined inputs always return undefined
+        if (typeof arr === 'undefined') {
+            return undefined;
+        }
+
+        var hasFoundMatch = false;
+        var result;
+
+        for (var i = 0; i < arr.length; i++) {
+            var entry = arr[i];
+            var positiveResult = true;
+            if (typeof func !== 'undefined') {
+                var func_args = hofFuncArgs(func, entry, i, arr);
+                // invoke func
+                var res = yield* func.apply(this, func_args);
+                positiveResult = boolean(res);
+            }
+            if (positiveResult) {
+                if(!hasFoundMatch) {
+                    result = entry;
+                    hasFoundMatch = true;
+                } else {
+                    throw {
+                        stack: (new Error()).stack,
+                        code: "D3138",
+                        index: i
+                    };
+                }
+            }
+        }
+
+        if(!hasFoundMatch) {
+            throw {
+                stack: (new Error()).stack,
+                code: "D3139"
+            };
+        }
+
+        return result;
+    }
+
     /**
      * Convolves (zips) each value from a set of arrays
      * @param {Array} [args] - arrays to zip
@@ -1790,7 +1839,7 @@ const functions = (() => {
         match, contains, replace, split, join,
         formatNumber, formatBase, number, floor, ceil, round, abs, sqrt, power, random,
         boolean, not,
-        map, zip, filter, foldLeft, sift,
+        map, zip, filter, single, foldLeft, sift,
         keys, lookup, append, exists, spread, merge, reverse, each, error, sort, shuffle,
         base64encode, base64decode
     };
diff --git a/src/jsonata.js b/src/jsonata.js
index e96b87d..35ba504 100644
--- a/src/jsonata.js
+++ b/src/jsonata.js
@@ -1689,6 +1689,7 @@ var jsonata = (function() {
     staticFrame.bind('map', defineFunction(fn.map, '<af>'));
     staticFrame.bind('zip', defineFunction(fn.zip, '<a+>'));
     staticFrame.bind('filter', defineFunction(fn.filter, '<af>'));
+    staticFrame.bind('single', defineFunction(fn.single, '<af?>'));
     staticFrame.bind('reduce', defineFunction(fn.foldLeft, '<afj?:j>')); // TODO <f<jj:j>a<j>j?:j>
     staticFrame.bind('sift', defineFunction(fn.sift, '<o-f?:o>'));
     staticFrame.bind('keys', defineFunction(fn.keys, '<x-:a
>'));
@@ -1811,7 +1812,9 @@ var jsonata = (function() {
         "D3134": "The timezone integer format specifier cannot have more than four digits",
         "D3135": "No matching closing bracket ']' in date/time picture string",
         "D3136": "The date/time picture string is missing specifiers required to parse the timestamp",
-        "D3137": "{{{message}}}"
+        "D3137": "{{{message}}}",
+        "D3138": "The $single() function expected exactly 1 matching result.  Instead it matched more.",
+        "D3139": "The $single() function expected exactly 1 matching result.  Instead it matched 0."
     };
 
     /**


Test patch:
diff --git a/test/test-suite/groups/hof-single/case000.json b/test/test-suite/groups/hof-single/case000.json
new file mode 100644
index 0000000..bc8b42f
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case000.json
@@ -0,0 +1,6 @@
+{
+    "expr": "(library.books~>$single(λ($v, $i, $a) {$v.price = $max($a.price)})).isbn",
+    "dataset": "dataset6",
+    "bindings": {},
+    "result": "9780262510875"
+}
diff --git a/test/test-suite/groups/hof-single/case001.json b/test/test-suite/groups/hof-single/case001.json
new file mode 100644
index 0000000..f2423a9
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case001.json
@@ -0,0 +1,6 @@
+{
+    "expr": "nothing~>$single(λ($v, $i, $a) {$v.price = $max($a.price)})",
+    "dataset": "dataset6",
+    "bindings": {},
+    "undefinedResult": true
+}
diff --git a/test/test-suite/groups/hof-single/case002.json b/test/test-suite/groups/hof-single/case002.json
new file mode 100644
index 0000000..4cbb6c6
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case002.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single(data, function($d) { true })",
+    "data": {"data": 5},
+    "bindings": {},
+    "result": 5
+}
diff --git a/test/test-suite/groups/hof-single/case003.json b/test/test-suite/groups/hof-single/case003.json
new file mode 100644
index 0000000..f7d5ea8
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case003.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single([0, 1, 2], $boolean)",
+    "data": {"data": 5},
+    "bindings": {},
+    "code": "D3138"
+}
diff --git a/test/test-suite/groups/hof-single/case004.json b/test/test-suite/groups/hof-single/case004.json
new file mode 100644
index 0000000..db281ab
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case004.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single([0, 1, 2], function($v) {$v = 3})",
+    "data": {"data": 5},
+    "bindings": {},
+    "code": "D3139"
+}
diff --git a/test/test-suite/groups/hof-single/case005.json b/test/test-suite/groups/hof-single/case005.json
new file mode 100644
index 0000000..97843b5
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case005.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single([0, 1, 2])",
+    "data": {"data": 5},
+    "bindings": {},
+    "code": "D3138"
+}
diff --git a/test/test-suite/groups/hof-single/case006.json b/test/test-suite/groups/hof-single/case006.json
new file mode 100644
index 0000000..16196d5
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case006.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single([0])",
+    "data": {"data": 5},
+    "bindings": {},
+    "result": 0
+}
diff --git a/test/test-suite/groups/hof-single/case007.json b/test/test-suite/groups/hof-single/case007.json
new file mode 100644
index 0000000..dd256c9
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case007.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single([])",
+    "data": {"data": 5},
+    "bindings": {},
+    "code": "D3139"
+}
diff --git a/test/test-suite/groups/hof-single/case008.json b/test/test-suite/groups/hof-single/case008.json
new file mode 100644
index 0000000..0e13259
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case008.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single([true, false, true], function($v) {$v=false})",
+    "data": {"data": 5},
+    "bindings": {},
+    "result": false
+}
diff --git a/test/test-suite/groups/hof-single/case009.json b/test/test-suite/groups/hof-single/case009.json
new file mode 100644
index 0000000..5b00228
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case009.json
@@ -0,0 +1,6 @@
+{
+    "expr": "$single(['zero', 'one', 'two'], function($v, $i, $k) {$i=1})",
+    "data": {"data": 5},
+    "bindings": {},
+    "result": "one"
+}
diff --git a/test/test-suite/groups/hof-single/case010.json b/test/test-suite/groups/hof-single/case010.json
new file mode 100644
index 0000000..e2cf996
--- /dev/null
+++ b/test/test-suite/groups/hof-single/case010.json
@@ -0,0 +1,9 @@
+{
+    "expr": "Phone ~> $filter(function($p) {$p.type = 'home'}) ~> $single(function($p) {$p.number = '0203 544 1234'})",
+    "dataset": "dataset1",
+    "bindings": {},
+    "result": {
+        "type": "home",
+        "number": "0203 544 1234"
+    }
+}