Skip to content

Commit

Permalink
Allow negative indices
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Mar 6, 2022
1 parent f72a719 commit 031f997
Show file tree
Hide file tree
Showing 5 changed files with 47 additions and 11 deletions.
9 changes: 7 additions & 2 deletions src/config/normalize/lib/star_dot_path/entries/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { convertIndexInteger, convertIndexString } from '../parsing/path.js'
import {
convertIndexInteger,
convertIndexString,
getArrayIndex,
} from '../parsing/path.js'
import { serialize } from '../parsing/serialize.js'
import { ANY_TOKEN } from '../parsing/special.js'

Expand Down Expand Up @@ -44,7 +48,8 @@ const getAnyEntries = function (value, path) {
const getKeyEntries = function (value, path, token) {
if (Array.isArray(value)) {
const tokenA = convertIndexInteger(token)
return [{ value: value[tokenA], path: [...path, tokenA] }]
const index = getArrayIndex(value, tokenA)
return [{ value: value[index], path: [...path, index] }]
}

if (isRecurseObject(value)) {
Expand Down
24 changes: 20 additions & 4 deletions src/config/normalize/lib/star_dot_path/parsing/parse.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { normalizePath } from './normalize.js'
import { isQueryString, convertIndexInteger } from './path.js'
import { ESCAPE, SEPARATOR, ANY, SPECIAL_CHARS, ANY_TOKEN } from './special.js'
import {
ESCAPE,
SEPARATOR,
ANY,
MINUS,
SPECIAL_CHARS,
ANY_TOKEN,
} from './special.js'

// Parse a query string into an array of tokens.
// Also validate and normalize it.
// This is inspired by JSON paths and JSON pointers.
// Syntax:
// - Dots are used for object properties, e.g. `one.two`
// - Dots are also used for array elements, e.g. `one.5`
// - Negatives indices can be used to get elements at the end, e.g. `one.-2`
// - Including -0 which can be used to append elements, e.g. `one.-0`
// - This can be used deeply, e.g. `one.two.5`
// - Wildcards are used with both objects and arrays to recurse over their
// children, e.g. `one.*`
Expand Down Expand Up @@ -53,6 +62,7 @@ const parseQuery = function (query) {
const path = []
let chars = ''
let hasAny = false
let hasMinus = false
let index = query[0] === SEPARATOR ? 1 : 0

for (; index <= query.length; index += 1) {
Expand All @@ -62,11 +72,12 @@ const parseQuery = function (query) {
chars += getEscapedChar(query, index)
index += 1
} else if (char === SEPARATOR || index === query.length) {
path.push(getToken(chars, query, hasAny))
path.push(getToken(chars, query, hasAny, hasMinus))
chars = ''
hasAny = false
} else {
hasAny = hasAny || char === ANY
hasMinus = hasMinus || char === MINUS
chars += char
}
}
Expand All @@ -85,16 +96,21 @@ const getEscapedChar = function (query, index) {
const validateEscape = function (escapedChar, query, index) {
if (!SPECIAL_CHARS.has(escapedChar)) {
throw new Error(
`Invalid query "${query}": character ${ESCAPE} at index ${index} must be followed by ${SEPARATOR} ${ANY} or ${ESCAPE}`,
`Invalid query "${query}": character ${ESCAPE} at index ${index} must be followed by ${SEPARATOR} ${ANY} ${MINUS} or ${ESCAPE}`,
)
}
}

const getToken = function (chars, query, hasAny) {
// eslint-disable-next-line max-params
const getToken = function (chars, query, hasAny, hasMinus) {
if (hasAny) {
return getAnyToken(chars, query)
}

if (chars[0] === MINUS && !hasMinus) {
return chars
}

return convertIndexInteger(chars)
}

Expand Down
14 changes: 12 additions & 2 deletions src/config/normalize/lib/star_dot_path/parsing/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,25 @@ const isAnyToken = function (token) {
// - When a path was passed, we leave it as is
// - When a query string was used instead, we assume it was meant as an array
// index since those are much more common.
// We allow negative indexes which query from the end
// - Including -0 which can be used to append values
// At evaluation time, with a target value, we transtype correctly.
export const convertIndexInteger = function (token) {
return typeof token === 'string' && POSITIVE_INTEGER_REGEXP.test(token)
return typeof token === 'string' && INTEGER_REGEXP.test(token)
? Number(token)
: token
}

const POSITIVE_INTEGER_REGEXP = /^\d+$/u
const INTEGER_REGEXP = /^-?\d+$/u

export const convertIndexString = function (token) {
return isIndexToken(token) ? String(token) : token
}

// Retrieve an array using a positive or negative index.
// Indices that are out-of-bound return no entries but do not error.
export const getArrayIndex = function (array, token) {
return token > 0 || Object.is(token, +0)
? token
: Math.max(array.length + token, 0)
}
4 changes: 4 additions & 0 deletions src/config/normalize/lib/star_dot_path/parsing/serialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const serializeToken = function (token, index) {
return ANY
}

if (Object.is(token, -0)) {
return '-0'
}

if (isIndexToken(token)) {
return String(token)
}
Expand Down
7 changes: 4 additions & 3 deletions src/config/normalize/lib/star_dot_path/parsing/special.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
export const ESCAPE = '\\'
export const SEPARATOR = '.'
export const ANY = '*'
export const SPECIAL_CHARS = new Set([ESCAPE, SEPARATOR, ANY])
export const MINUS = '-'
export const SPECIAL_CHARS = new Set([ESCAPE, SEPARATOR, ANY, MINUS])
// Tokens for special characters
export const ANY_TOKEN = Symbol.for('*')
// Matches any special characters
export const SPECIAL_CHARS_REGEXP = /[\\.*]/gu
// Special characters which should always be escaped
export const SPECIAL_CHARS_REGEXP = /[\\.*]|^-/gu

0 comments on commit 031f997

Please sign in to comment.