1.1.5 • Published 3 months ago

jsxpath v1.1.5

Weekly downloads
1
License
MIT
Repository
github
Last release
3 months ago

JSXPath

JSXPath is an adaptation of XPath, a querying language for XML documents, to query JSON object.

If you are already familiar with the construct of XPath, using this should be a breeze.

version

Latest version: v1.1.5

Why JSXPath?

  1. Powerful, yet simple to use to perform complex query on JSON data with just a string expression.
  2. Packed full of features out of the box.
  const json = { "a": 1, "b": 2, "c": "pass"}

  /*----------
  without JSXPath
  ----------*/
  function sum(pa, pb) {
    if (!isNumber(pa) || isNumber(pb)) {
      throw new Error("an argument is not a number");
    }
    return pa + pb;
  }

  function isNumber(num) {
    return !isNaN(num) && isFinite(num);
  }

  const result = sum(js.a, js.b) === 3 ? js.c : null;
  // result => 'pass'


  /*----------
  with JSXPath
  ----------*/
  import { runPath } from 'jsxpath';

  const result = runPath('/c[sum(/a | /b) = 3]', { json });
  // result => ['pass']

INSTALL

npm install jsxpath

USE

  const json = {
    a: {
        links: [
          { id: 3, type: 'b' },
          { id: 1, type: 'c' }
        ],
        value: 'master'
    },
    b: [
      { id: 1, value: 'one' },
      { id: 2, value: 'two' },
      { id: 3, value: 'three' }
    ]
  };
  import { runPath } from 'jsxpath';

  // Get the value of "b" that is linked back to "a" by "id"
  const path = '/b/*[id = /a/links/*[type="b"]/id]/value';

  const result = runPath(path, {json}); // ["three"]


  // callback version
  //-------------------------
  runPath({
    path: path,
    then: ({ path, error, value }) => {
      // value => ['three'];

      // run your custom code
    } 
  }, {json});


  // path looks complicated? 
  // use variables.
  //-------------------------
  const result = runPath( '/b/*[id = $aLinkToB_id]/value', 
    { 
      json, variables: { aLinkToB_id: '$root/a/links/*[type="b"]/id' } 
    }
  ); // ["three"]

Evaluating multiple paths within one run

  const json = {
    c: [
      { id: 1, type: 'TypeA', value: 'car'},
      { id: 2, type: 'TypeB', value: 'house'},
      { id: 3, type: 'TypeA', value: 'boat'}
    ]
  }
  import { runPaths } from 'jsxpath';

  const pathsResult = {
    entities: null,
    typeAs: null
  };

  const pathsAndCallbacks: tPathWithCallBack[] = [
    { 
      path: '/c/*/value',
      then: (result) => {
        // custom code
        if (result.value.length) {
          pathsResult.entities = result.value;
        }
      }
    },
    {
      path: '/c/*[type="TypeA"]',
      description: 'type "A" objects in "c"',
      then: ({value, error}) => {
        // custom code
        if (!error) {
          pathsResult.typeAs = value;
        }
      }
    }
  ];

  runPaths( pathsAndCallbacks, { json });

  /*
  * pathsResult => {
  *   entities: ['car', 'house', 'boat'],
  *   typeAs: [
  *     { id: 1, type: 'TypeA', value: 'car'},
  *     { id: 3, type: 'TypeA', value: 'boat'}
  *   ]
  * }
  */

API

These are the 3 methods to run the path expression

  runPath( path: string, inputProps: tRunPathsInput )
  1. path: path expression
  2. inputProps: tRunPathsInput
type tRunPathsInput {
  json: object,
  functions?: {
    [functionName: string]: (...args: tStack[]) => tStack
  },
  variables?: {
    [variableName: string]: any
  }
};
  runPath( pathProp: tPathWithCallBack, inputProps: tRunPathsInput )
  1. pathProp: tPathWithCallBack
  • path: path expression
  • description?: describe what this path expression is about
  • then: a callback function that accepts the result of executing the path expression.

    type tPathWithCallBack = {
      path: string,
      description?: string,
      active?: boolean,
      then(result: tRunPathResult),
      [key: string]: any
    };
    
    type tRunPathResult = {
      path: string,
      value: any,
      error?: string
    };
  1. inputProps: tRunPathsInput
  • json: the json object to interrogate
  • functions?: see custom functions
  • variables?: see variables
  • outputOptions?: flags to determine extra optionally return values
type tRunPathsInput {
  json: object,
  functions?: {
    [functionName: string]: (...args: tStack[]) => tStack
  },
  variables?: {
    [variableName: string]: any
  },
  outputOptions?: {
    nodes?: boolean
  }
};
  runPaths( pathProp: tPathWithCallBack[], inputProps: tRunPathsInput )
  1. pathProp: tPathWithCallBack[]
  • path: path expression
  • description?: describe what this path expression is about
  • then: a callback function that accepts the result of executing the path expression.

    type tPathWithCallBack = {
      path: string,
      description?: string,
      active?: boolean,
      then(result: tRunPathResult),
      [key: string]: any
    };
    
    type tRunPathResult = {
      path: string,
      value: any,
      error?: string
    };
  1. inputProps: tRunPathsInput
  • json: the json object to interrogate
  • functions?: see custom functions
  • variables?: see variables
  • outputOptions?: flags to determine extra optionally return values

    type tRunPathsInput {
      json: object,
      functions?: {
        [functionName: string]: (...args: tStack[]) => tStack
      },
      variables?: {
        [variableName: string]: any
      },
      outputOptions?: {
        nodes?: boolean
      }
    };

Differences & Limitations

There are some notable differences and limiations between xml and json that the query langauge do not support.

  • The '@' symbol is not used in JSXPath expression since JSON only consists of key value pair. '@' in XML denotes an attribute.
  • The axis 'preceding', 'preceding-sibling', 'following', and 'following-sibling' is not supported. JSON is a hash map, the keys are not always returned in a particular order. Instead JSXPath supports 'sibling' that looks for key value within the same object.
  • The operator token keywords are reserved. This means that the keys in the json cannot contain the following symbols (|,/,+, -, %, *, =, >, <) and spaces

FEATURES

Operators

Operators
|Unary
+Addition
-Subtraction
*Multiplication
divDivision
=Equal
!=Not Equal
<Less Than
<=Less Than or Equal to
>Greater Than
>=Greater Than or Equal to
orOr
andAnd
modModulus

Axes

Select
.Current
..Parent from Current
/Root or Child from Current
//Descendants from Current
*Any child nodes from Current
parentParent from Current
ancestor::(node-name)Ancestors from Current
ancestor-or-self::(node-name)Ancestors + Current from Current
child::(node-name)Child from Current
descendant::(node-name)Descendants from Current
descendant-or-self::(node-name)Descendants + Current from Current
sibling::(node-name)Sibling of Current (key name within the same object)

Functions

Built in functions

NameExampleResultComment
abspath = "abs(-1)"1
booleanpath = 'boolean("string")'trueif arg is node list, returns true if list is not empty
ceilingpath = 'ceiling(1.2)'2
choosepath = 'choose(1=1, "abc", "def")'"abc"if first arg evaluates to true, return 2nd arg otherwise return 3rd arg
concatpath= 'concat("ab", " ", "cd", ": ", 1, " is ", true)'"ab cd: 1 is true"Converts number or boolean type to a string, if it is a node type will interrogate and return the value of the first node item
containspath = 'contains("needle haystack", "hay")'true
countpath = 'count(//a)'count the number of nodes with key value of "a" starting from root node
falsepath = 'false = false()'true
firstpath = '/a/*first()'first() returns the first position of the node listindex is 1 base
floorpath = 'floor(1.2)'1
lastpath = '/a/*last()'last() returns the last position of the node listindex is 1 base
local-namepath = '//*local-name() = "abc"'The key name of the nodesee example
namepath = '//*name() = "abc"'equivalent to local-name
notpath = '/a/*not(b > 1)'return all child nodes of a whose b value is less than or equal to 1
numberpath = 'number(/a)'return the number value of the first node of aif it's a string or boolean type, it will try to convert it to a number value, otherwise return NAN. Throws an error if the passed in type is not a string, number, or boolean.
roundpath = 'round(4.4)', 'round(4.5)'4 , 5round the number to the nearest integer value
stringpath = 'string(1.1)''1.1'convert and return a string value. Throws an error if the passed in type is not a string, number, or boolean.
substring-afterpath = 'string-after("haystack", "st")"ack"
substring-beforepath = 'string-before("haystack", "st")"hay"
sumpath = 'sum(/a/*/b)'sum all value of bThrows an error if the value is not node and is of number type
truepath = 'true = true()'true

Examples

  import {runPaths} from 'jsxpath';

  const budget = {
    incomes: [
      { id: 1, type: 'salary', display: 'salary', value: 2000.30, frequency: 'monthly'},
      { id: 2, type: 'rent', display: 'rent', value: 300.95, frequency: 'fortnightly'},
      { id: 3, type: 'share', display: 'shares', value: 0.20, frequency: 'monthly'}
    ],
    expenses: [
      { id: 1, type: 'transport', display: 'car', value: -200.70, frequency: 'fortnightly' },
      { id: 2, type: 'household', display: 'grocery', value: -400.20, frequency: 'monthly' },
      { id: 3, type: 'transport', display: 'train', value: -200.10, frequency: 'monthly' },
      { id: 4, type: 'household', display: 'gardening', value: -20.10, frequency: 'monthly' }
    ]
  }
  /* 
  * evaluate a series of paths to extract required expenses
  * and incomes to calculate the net income per month
  */
  let totalIncomePerMonth = 0;
  runPaths([
    {
      path: 'sum(/incomes/*[frequency="monthly"][floor(value) >= 1]/value)',
      then: ({value}) => {
        // returns salary value
        totalIncomePerMonth += value;
      }
    },
    {
      path: 'sum(/incomes/*[frequency="fortnightly"]/value) * 2',
      then: ({value}) => {
        // returns rent value
        totalIncomePerMonth += value;
      }
    },
    {
      path: 'sum(/expenses/*[frequency="monthly"][abs(value) > 25]/value)',
      then: ({value}) => {
        // returns grocery and train sum value
        totalIncomePerMonth += value;
      }
    },
    {
      path: 'sum(/expenses/*[frequency="fortnightly"]/value) * 2',
      then: ({value}) => {
        // returns car value
        totalIncomePerMonth += value;
      }
    }
  ], {json: budget});

  // totalIncomePerMonth = 1600.5

Other function examples

  runPaths([
    {
      path: '/incomes/*[first()]/display',
      description: 'first() eg',
      then: ({value}) => {
        // value => ['salary']
      }
    },
    {
      path: '/incomes/*[last()]',
      description: 'last() eg',
      then: ({value}) => {
        // value => [{ id: 3, type: 'share', display: 'shares', value: 0.20, frequency: 'monthly'}]
      }
    },
    {
      path: 'count(/incomes/*[frequency = "monthly"])',
      description: 'count() eg',
      then: ({value}) => {
        // value => 2;
      }
    },
    {
      path: '//*[local-name()="id"][sibling::type = "transport"]',
      description: 'local-name() eg',
      then: ({value}) => {
        // value => [1, 3];
      }
    },
    {
      path: '/expenses/*[concat(type, ":", display) = "transport:train"]/value',
      description: 'concat() eg',
      then: ({value}) => {
        // value => [-200.10]
      }
    }
  ], { json: budget });

Custom functions

JSXPath supports the ability for you to write your own custom functions and be able to refer to it in the path expression. It is passed into the runPath or runPaths as part of the second argument and has the signature of:

  type tRunPathsInput {
    json: object,
    functions?: {
      [functionName: string]: (...args: tStack[]) => tStack
    },
    ...
  };

Functions accepts a list of tStack arguments and expected to return a tStack value

  // for the purpose of functions, we would expect 
  // tStack to typically have the following definition
  type tStack {
    type: 'nodes' | 'boolean' | 'string' | 'number', 
    value: tNode[] | boolean | string | number
  };

Example: custom function to get maximum value in a list of nodes

  import { runPath, KEYS } from './index';

  const json = {
    a: [
      {value: 3},
      {value: '90'},
      {value: 16}
    ],
    b: [
      {value: -1},
      {value: 30},
      {value: true}
    ]
  };

  // defining custom functions
  functions = {
    max: (item: tStack): tStack => {
      if (item.type !== 'nodes') {
        throw new Error('[functions.max], invalid arg type. Was expecting nodes');
      }
      // loop through the node list, check if the type is a number
      // and has a value greater than the current max value
      const value = item.value.reduce((maxNumber, node) => {
        if (node[KEYS.valueType] === 'number' && node[KEYS.value] > maxNumber) {
          maxNumber = node[KEYS.value];
        }
        return maxNumber;
      }, 0);
      
      return { type: 'number', value };
    },
    //... more functions
  };

  const maxPlus10 = runPath('max( /a/*/value | /b/*/value ) + 10', { json, functions });
  // maxPlus10 => 40

Refer to about JSXPath nodes to see how JSXPath converts JSON object into nodes.

Variables

A powerful!! feature to link to another value that can be used as part of path expressions.

  • Variables in path expression starts with $ sign followed by the name of the key passed in the variable object.
  • Variable does not have to be actual values, it can also be a path expression in itself.

Consider the budget example set up in the functions section above. We can simplify it using variable paths expressions.

  import {runPath} from 'jsxpath';

  const budget = {
    incomes: [
      { id: 1, type: 'salary', display: 'salary', value: 2000.30, frequency: 'monthly'},
      { id: 2, type: 'rent', display: 'rent', value: 300.95, frequency: 'fortnightly'},
      { id: 3, type: 'share', display: 'shares', value: 0.20, frequency: 'monthly'}
    ],
    expenses: [
      { id: 1, type: 'transport', display: 'car', value: -200.70, frequency: 'fortnightly' },
      { id: 2, type: 'household', display: 'grocery', value: -400.20, frequency: 'monthly' },
      { id: 3, type: 'transport', display: 'train', value: -200.10, frequency: 'monthly' },
      { id: 4, type: 'household', display: 'gardening', value: -20.10, frequency: 'monthly' }
    ]
  }
  const totalIncomePerMonth = runPath(
    '$incomeMonthlyTypes + $incomeFortnightlyTypes + $expenseMonthlyTypes + $expenseFortnightlyTypes', 
    {
      json: budget,
      variables: {
        incomeMonthlyTypes: 'sum($root/incomes/*[frequency="monthly"][floor(value) >= 1]/value)',
        incomeFortnightlyTypes: 'sum($root/incomes/*[frequency="fortnightly"]/value) * 2',
        expenseMonthlyTypes: 'sum($root/expenses/*[frequency="monthly"][abs(value) > 25]/value)',
        expenseFortnightlyTypes: 'sum($root/expenses/*[frequency="fortnightly"]/value) * 2'
      }
    }
  );
  
  // totalIncomePerMonth = 1600.5

Note that on initialization, the passed in json is stored as a variable with the name $root. $root can be referenced in both the main path or variable path expression

Example below shows additional ways of using variables

  • tuitionType is a literal object value
  • tuitionAmount is a path expression referencing $tuitionType
  • $tuitionAmount is referenced in the main path expression
  runPath({
    path: 'abs($tuitionAmount) > abs(/expenses/*[display="gardening"]/value)',
    description: 'Is tuition fee cost more than the amount spent on gardening?',
    then: ({value}) => {
      // custom code here
      const message = value === true ?
        'Tuition is overly expensive. We should spend our time on gardening':
        'Tuition is still dirt cheap.'
      console.log(message);
    }
  }, {
    json: budget,
    variables: {
      tuitionType: { id: 100, type: 'education', display: 'tuition', value: -80, frequency: 'monthly' },
      tuitionAmount: '$tuitionType/value'
    }
  });

  // console.log => 'Tuition is overly expensive. We should spend our time on gardening'

About JSXPath nodes

In JSXPath, we natively deconstruct the passed in JSON object into a series of nodes in order to make it easier to traverse and perform a deep comparisons without relying on third party libraries. Before returning the result, JSXPath will reconstruct the filtered down node(s) as an array of JSON object.

Node have the following shape

  type tNode = [
    id: number,
    depth: number,
    group: number|string,
    arrayPosition: number|string,
    key: string,
    value: any,
    valueType: string,
    links: {
      parentId?: number,
      childrenIds?: number[],
      descendantIds?: number[],
      ancestorIds?: number[],
      siblings?: number[]
    }
  ];

Given the passed in JSON is

  {
    a: [
      0,
      {b: 'c', d: 1},
      'efg'
    ]
  }

it is deconstructed into a list of array nodes and linked together as shown below diagramatically.

classDiagram
root --> a : child/parent
a --> 0: child/parent
a --> `$ao`: child/parent
b <..> d: sibling
`$ao` --> b: child/parent
`$ao` --> d: child/parent
a --> efg: child/parent

class root {
  + id: 0
  + key: $r
  + value: $o
  + valueType: object
}
class a {
  + id: 1
  + key: a
  + value: $a
  + valueType: array
}
class 0 {
  + id: 2
  + key: $v
  + value: 0
  + valueType: number
}

class `$ao`["$ao (object in an array)"] {
  + id: 3
  + key: $ao
  + value: $o
  + valueType: object
}

class b {
  + id: 4
  + key: b
  + value: c
  + valueType: string
}
class d {
  + id: 5
  + key: d
  + value: 1
  + valueType: number
}
class efg {
  + id: 6
  + key: $v,
  + value: efg,
  + valueType: string
}

apart from child/parent/sibling relationship, each node have links to ancestors and descendants

Helper functions are provided that can be used in custom functions

  import { KEYS, nodesOps } from 'jsxpath';

  // Actual node properties can be accessed via the KEYS object
  nodeA[KEYS.valueType] //object, array, string,...
  nodeA[KEYS.value]
  nodeA[KEYS.links].childrenIds // [1, 4, ...]

  // Get the linked nodes
  nodesOps.get.ancestors(currentNode: tNode, nodeName?: string): tNode[];
  nodesOps.get.children(currentNode: tNode, nodeName?: string): tNode[];
  nodesOps.get.descendants(currentNode: tNode, nodeName?: string): tNode[];
  nodesOps.get.parent(currentNode: tNode, nodeName?: string): tNode;
  nodesOps.get.siblings(currentNode: tNode, nodeName?: string): tNode[];

  // convert the node into its' JSON value
  nodesOps.reconstruct(nodes: tNode[]): (iJSONArray|tJSONObject)[];

  // test to see if two nodes are equal (deep)
  nodesOps.test.isEqual(node1: tNode, node2: tNode): boolean;

ROADMAP

  • better error handling overall
  • introduce date functions