diff --git a/array.libsonnet b/array.libsonnet new file mode 100644 index 0000000..f000c87 --- /dev/null +++ b/array.libsonnet @@ -0,0 +1,37 @@ +local d = import 'doc-util/main.libsonnet'; + +{ + '#': d.pkg( + name='array', + url='github.com/jsonnet-libs/xtd/array.libsonnet', + help='`array` implements helper functions for processing arrays.', + ), + + '#slice':: d.fn( + '`slice` works the same as `std.slice` but with support for negative index/end.', + [ + d.arg('indexable', d.T.array), + d.arg('index', d.T.number), + d.arg('end', d.T.number, default='null'), + d.arg('step', d.T.number, default=1), + ] + ), + slice(indexable, index, end=null, step=1): + local invar = { + index: + if index != null + then + if index < 0 + then std.length(indexable) + index + else index + else 0, + end: + if end != null + then + if end < 0 + then std.length(indexable) + end + else end + else std.length(indexable), + }; + indexable[invar.index:invar.end:step], +} diff --git a/docs/README.md b/docs/README.md index e8eea63..0fbf376 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,8 +17,11 @@ in the future, but also provides a place for less general, yet useful utilities. ## Subpackages * [aggregate](aggregate.md) +* [array](array.md) * [ascii](ascii.md) * [camelcase](camelcase.md) * [date](date.md) * [inspect](inspect.md) +* [jsonpath](jsonpath.md) +* [string](string.md) * [url](url.md) \ No newline at end of file diff --git a/docs/array.md b/docs/array.md new file mode 100644 index 0000000..1d9e4a8 --- /dev/null +++ b/docs/array.md @@ -0,0 +1,25 @@ +--- +permalink: /array/ +--- + +# package array + +```jsonnet +local array = import "github.com/jsonnet-libs/xtd/array.libsonnet" +``` + +`array` implements helper functions for processing arrays. + +## Index + +* [`fn slice(indexable, index, end='null', step=1)`](#fn-slice) + +## Fields + +### fn slice + +```ts +slice(indexable, index, end='null', step=1) +``` + +`slice` works the same as `std.slice` but with support for negative index/end. \ No newline at end of file diff --git a/docs/jsonpath.md b/docs/jsonpath.md new file mode 100644 index 0000000..f498803 --- /dev/null +++ b/docs/jsonpath.md @@ -0,0 +1,53 @@ +--- +permalink: /jsonpath/ +--- + +# package jsonpath + +```jsonnet +local jsonpath = import "github.com/jsonnet-libs/xtd/jsonpath.libsonnet" +``` + +`jsonpath` implements helper functions to use JSONPath expressions. + +## Index + +* [`fn convertBracketToDot(path)`](#fn-convertbrackettodot) +* [`fn getJSONPath(source, path)`](#fn-getjsonpath) +* [`fn parseFilterExpr(path)`](#fn-parsefilterexpr) + +## Fields + +### fn convertBracketToDot + +```ts +convertBracketToDot(path) +``` + +`convertBracketToDot` converts the bracket notation to dot notation. + +This function does not support escaping brackets/quotes in path keys. + + +### fn getJSONPath + +```ts +getJSONPath(source, path) +``` + +`getJSONPath` gets the value at `path` from `source` where path is a JSONPath. + +This is a rudimentary implementation supporting the slice operator `[0:3:2]` and +partially supporting filter expressions `?(@.attr==value)`. + + +### fn parseFilterExpr + +```ts +parseFilterExpr(path) +``` + +`parseFilterExpr` returns a filter function `f(x)` for a filter expression `expr`. + + It supports comparisons (<, <=, >, >=) and equality checks (==, !=). If it doesn't + have an operator, it will check if the `expr` value exists. diff --git a/docs/string.md b/docs/string.md new file mode 100644 index 0000000..17b75b5 --- /dev/null +++ b/docs/string.md @@ -0,0 +1,26 @@ +--- +permalink: /string/ +--- + +# package string + +```jsonnet +local string = import "github.com/jsonnet-libs/xtd/string.libsonnet" +``` + +`string` implements helper functions for processing strings. + +## Index + +* [`fn splitEscape(str, c, escape='\\')`](#fn-splitescape) + +## Fields + +### fn splitEscape + +```ts +splitEscape(str, c, escape='\\') +``` + +`split` works the same as `std.split` but with support for escaping the dividing +string `c`. diff --git a/jsonpath.libsonnet b/jsonpath.libsonnet new file mode 100644 index 0000000..9fef7dd --- /dev/null +++ b/jsonpath.libsonnet @@ -0,0 +1,140 @@ +local xtd = import './main.libsonnet'; +local d = import 'doc-util/main.libsonnet'; + +{ + '#': d.pkg( + name='jsonpath', + url='github.com/jsonnet-libs/xtd/jsonpath.libsonnet', + help='`jsonpath` implements helper functions to use JSONPath expressions.', + ), + + + '#getJSONPath':: d.fn( + ||| + `getJSONPath` gets the value at `path` from `source` where path is a JSONPath. + + This is a rudimentary implementation supporting the slice operator `[0:3:2]` and + partially supporting filter expressions `?(@.attr==value)`. + |||, + [ + d.arg('source', d.T.any), + d.arg('path', d.T.string,), + d.arg('default', d.T.any, default='null'), + ] + ), + getJSONPath(source, path, default=null): + local _path = self.convertBracketToDot(path); + std.foldl( + function(acc, key) + get(acc, key, default), + xtd.string.splitEscape(_path, '.'), + source, + ), + + '#convertBracketToDot':: d.fn( + ||| + `convertBracketToDot` converts the bracket notation to dot notation. + + This function does not support escaping brackets/quotes in path keys. + |||, + [ + d.arg('path', d.T.string,), + ] + ), + convertBracketToDot(path): + if std.length(std.findSubstr('[', path)) > 0 + then + local split = std.split(path, '['); + std.join('.', [ + local a = std.stripChars(i, "[]'"); + std.strReplace(a, '@.', '@\\.') + for i in split + ]) + else path, + + local get(source, key, default) = + if key == '' + || key == '$' + || key == '*' + then source + else if std.isArray(source) + then getFromArray(source, key) + else std.get(source, key, default), + + local getFromArray(arr, key) = + if std.startsWith(key, '?(@\\.') + then + std.filter( + self.parseFilterExpr(std.stripChars(key, '?(@\\.)')), + arr + ) + else if std.length(std.findSubstr(':', key)) >= 1 + then + local split = std.splitLimit(key, ':', 2); + local step = + if std.length(split) < 3 + then 1 + else parseIntOrNull(split[2]); + xtd.array.slice( + arr, + parseIntOrNull(split[0]), + parseIntOrNull(split[1]), + step, + ) + else + arr[std.parseInt(key)], + + local parseIntOrNull(str) = + if str == '' + then null + else std.parseInt(str), + + '#parseFilterExpr':: d.fn( + ||| + `parseFilterExpr` returns a filter function `f(x)` for a filter expression `expr`. + + It supports comparisons (<, <=, >, >=) and equality checks (==, !=). If it doesn't + have an operator, it will check if the `expr` value exists. + |||, + [ + d.arg('path', d.T.string,), + ] + ), + parseFilterExpr(expr): + local operandFunctions = { + '=='(a, b): a == b, + '!='(a, b): a != b, + '<='(a, b): a <= b, + '>='(a, b): a >= b, + '<'(a, b): a < b, + '>'(a, b): a > b, + }; + + local findOperands = std.filter( + function(op) std.length(std.findSubstr(op, expr)) > 0, + std.reverse( // reverse to match '<=' before '<' + std.objectFields(operandFunctions) + ) + ); + + if std.length(findOperands) > 0 + then + local op = findOperands[0]; + local s = [ + std.stripChars(i, ' ') + for i in std.splitLimit(expr, op, 1) + ]; + function(x) + if s[0] in x + then + local left = x[s[0]]; + local right = + if std.isNumber(left) + then std.parseInt(s[1]) // Only parse if comparing numbers + else s[1]; + operandFunctions[op](left, right) + else false + else + // Default to key matching + function(x) (expr in x), +} diff --git a/main.libsonnet b/main.libsonnet index 2ecd26f..59c4034 100644 --- a/main.libsonnet +++ b/main.libsonnet @@ -13,9 +13,12 @@ local d = import 'doc-util/main.libsonnet'; ), aggregate: (import './aggregate.libsonnet'), + array: (import './array.libsonnet'), ascii: (import './ascii.libsonnet'), - date: (import './date.libsonnet'), camelcase: (import './camelcase.libsonnet'), + date: (import './date.libsonnet'), inspect: (import './inspect.libsonnet'), + jsonpath: (import './jsonpath.libsonnet'), + string: (import './string.libsonnet'), url: (import './url.libsonnet'), } diff --git a/string.libsonnet b/string.libsonnet new file mode 100644 index 0000000..5514cde --- /dev/null +++ b/string.libsonnet @@ -0,0 +1,35 @@ +local d = import 'doc-util/main.libsonnet'; + +{ + '#': d.pkg( + name='string', + url='github.com/jsonnet-libs/xtd/string.libsonnet', + help='`string` implements helper functions for processing strings.', + ), + + // BelRune is a string of the Ascii character BEL which made computers ring in ancient times. + // We use it as "magic" char to temporarily replace an escaped string as it is a non printable + // character and thereby will unlikely be in a valid key by accident. Only when we include it. + local BelRune = std.char(7), + + '#splitEscape':: d.fn( + ||| + `split` works the same as `std.split` but with support for escaping the dividing + string `c`. + |||, + [ + d.arg('str', d.T.string), + d.arg('c', d.T.string), + d.arg('escape', d.T.string, default='\\'), + ] + ), + splitEscape(str, c, escape='\\'): + std.map( + function(i) + std.strReplace(i, BelRune, escape + c), + std.split( + std.strReplace(str, escape + c, BelRune), + c, + ) + ), +} diff --git a/test/array_test.jsonnet b/test/array_test.jsonnet new file mode 100644 index 0000000..65aef8a --- /dev/null +++ b/test/array_test.jsonnet @@ -0,0 +1,83 @@ +local array = import '../array.libsonnet'; +local test = import 'github.com/jsonnet-libs/testonnet/main.libsonnet'; + +local arr = std.range(0, 10); + +test.new(std.thisFile) + ++ test.case.new( + name='first two', + test=test.expect.eq( + actual=array.slice( + arr, + index=0, + end=2, + ), + expected=[0, 1], + ) +) ++ test.case.new( + name='last two', + test=test.expect.eq( + actual=array.slice( + arr, + index=1, + end=3, + ), + expected=[1, 2], + ) +) ++ test.case.new( + name='until end', + test=test.expect.eq( + actual=array.slice( + arr, + index=1 + ), + expected=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ) +) ++ test.case.new( + name='from beginning', + test=test.expect.eq( + actual=array.slice( + arr, + index=0, + end=2 + ), + expected=[0, 1], + ) +) ++ test.case.new( + name='negative start', + test=test.expect.eq( + actual=array.slice( + arr, + index=-2 + ), + expected=[9, 10], + ) +) ++ test.case.new( + name='negative end', + test=test.expect.eq( + actual=array.slice( + arr, + index=0, + end=-1 + ), + expected=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + ) +) ++ test.case.new( + name='step', + test=test.expect.eq( + actual=array.slice( + arr, + index=0, + end=5, + step=2 + ), + expected=[0, 2, 4], + ) +) diff --git a/test/jsonpath_test.jsonnet b/test/jsonpath_test.jsonnet new file mode 100644 index 0000000..8c1106b --- /dev/null +++ b/test/jsonpath_test.jsonnet @@ -0,0 +1,305 @@ +local jsonpath = import '../jsonpath.libsonnet'; +local test = import 'github.com/jsonnet-libs/testonnet/main.libsonnet'; + +test.new(std.thisFile) + +// Root ++ test.case.new( + name='root $', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, '$'), + expected={ key: 'content' }, + ) +) ++ test.case.new( + name='root (empty path)', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, ''), + expected={ key: 'content' }, + ) +) ++ test.case.new( + name='root .', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, '.'), + expected={ key: 'content' }, + ) +) + +// Single key ++ test.case.new( + name='path without dot prefix', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, 'key'), + expected='content', + ) +) ++ test.case.new( + name='single key', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, '.key'), + expected='content', + ) +) ++ test.case.new( + name='single bracket key', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, '[key]'), + expected='content', + ) +) ++ test.case.new( + name='single bracket key with $', + test=test.expect.eq( + actual=jsonpath.getJSONPath({ key: 'content' }, '$[key]'), + expected='content', + ) +) ++ test.case.new( + name='single array index', + test=test.expect.eq( + actual=jsonpath.getJSONPath(['content'], '.[0]'), + expected='content', + ) +) ++ test.case.new( + name='single array index without dot prefix', + test=test.expect.eq( + actual=jsonpath.getJSONPath(['content'], '[0]'), + expected='content', + ) +) + +// Nested ++ test.case.new( + name='nested key', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: { key3: 'content' } } }, + '.key1.key2.key3' + ), + expected='content', + ) +) ++ test.case.new( + name='nested bracket key', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: { key3: 'content' } } }, + '.key1.key2[key3]' + ), + expected='content', + ) +) ++ test.case.new( + name='nested bracket key (quoted)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: { key3: 'content' } } }, + ".key1.key2['key3']" + ), + expected='content', + ) +) ++ test.case.new( + name='nested bracket star key', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: { key3: 'content' } } }, + '.key1.key2[*]' + ), + expected={ key3: 'content' }, + ) +) ++ test.case.new( + name='nested array index', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: ['content1', 'content2'] } }, + '.key1.key2[1]' + ), + expected='content2', + ) +) ++ test.case.new( + name='nested array index with $', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: ['content1', 'content2'] } }, + '$.key1.key2[1]' + ), + expected='content2', + ) +) ++ test.case.new( + name='nested array index without brackets', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: ['content1', 'content2'] } }, + '.key1.key2.1' + ), + expected='content2', + ) +) ++ test.case.new( + name='nested array star index', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: ['content1', 'content2'] } }, + '.key1.key2[*]' + ), + expected=['content1', 'content2'], + ) +) ++ test.case.new( + name='nested bracket keys and array index combo', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: ['content1', 'content2'] } }, + '$.[key1][key2][1]' + ), + expected='content2', + ) +) ++ test.case.new( + name='all keys in bracket and quoted', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key1: { key2: ['content1', 'content2'] } }, + "$['key1']['key2']" + ), + expected=['content1', 'content2'], + ) +) + +// index range/slice ++ test.case.new( + name='array with index range (first two)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: ['content1', 'content2', 'content3'] }, + 'key[0:2]' + ), + expected=['content1', 'content2'], + ) +) ++ test.case.new( + name='array with index range (last two)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: ['content1', 'content2', 'content3'] }, + 'key[1:3]' + ), + expected=['content2', 'content3'], + ) +) ++ test.case.new( + name='array with index range (until end)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: ['content1', 'content2', 'content3'] }, + 'key[1:]' + ), + expected=['content2', 'content3'], + ) +) ++ test.case.new( + name='array with index range (from beginning)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: ['content1', 'content2', 'content3'] }, + 'key[:2]' + ), + expected=['content1', 'content2'], + ) +) ++ test.case.new( + name='array with index range (negative start)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: ['content1', 'content2', 'content3'] }, + 'key[-2:]' + ), + expected=['content2', 'content3'], + ) +) ++ test.case.new( + name='array with index range (negative end)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: ['content1', 'content2', 'content3'] }, + 'key[:-1]' + ), + expected=['content1', 'content2'], + ) +) ++ test.case.new( + name='array with index range (step)', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: [ + 'content%s' % i + for i in std.range(1, 10) + ] }, + 'key[:5:2]' + ), + expected=['content1', 'content3', 'content5'], + ) +) + +// filter expr ++ test.case.new( + name='array with filter expression - string', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: [ + { + key: 'content%s' % i, + } + for i in std.range(1, 10) + ] }, + '.key[?(@.key==content2)]' + ), + expected=[{ + key: 'content2', + }], + ) +) ++ test.case.new( + name='array with filter expression - number', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: [ + { + count: i, + } + for i in std.range(1, 10) + ] }, + '.key[?(@.count<=2)]' + ), + expected=[{ + count: 1, + }, { + count: 2, + }], + ) +) ++ test.case.new( + name='array with filter expression - has key', + test=test.expect.eq( + actual=jsonpath.getJSONPath( + { key: [ + { + key1: 'value', + }, + { + key2: 'value', + }, + ] }, + '.key[?(@.key1)]' + ), + expected=[{ + key1: 'value', + }], + ) +)