Skip to content

Commit

Permalink
fix: write unit tests and fixes for all query languages
Browse files Browse the repository at this point in the history
  • Loading branch information
josdejong committed Nov 14, 2021
1 parent 6568afe commit a6af472
Show file tree
Hide file tree
Showing 9 changed files with 589 additions and 144 deletions.
4 changes: 2 additions & 2 deletions src/lib/plugins/query/javascriptQueryLanguage.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ export const javascriptQueryLanguage = {
}

/**
* Turn a path like
* Turn a path like:
*
* ['location', 'latitude']
*
* into a JavaScript selector
* into a JavaScript selector (string) like:
*
* '?.["location"]?.["latitude"]'
*
Expand Down
201 changes: 201 additions & 0 deletions src/lib/plugins/query/javascriptQueryLanguage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import assert from 'assert'
import { javascriptQueryLanguage } from './javascriptQueryLanguage.js'
import { cloneDeep } from 'lodash-es'

const { createQuery, executeQuery } = javascriptQueryLanguage

const user1 = { _id: '1', user: { name: 'Stuart', age: 6 } }
const user3 = { _id: '3', user: { name: 'Kevin', age: 8 } }
const user2 = { _id: '2', user: { name: 'Bob', age: 7 } }

const users = [user1, user3, user2]
const originalUsers = cloneDeep([user1, user3, user2])

describe('javascriptQueryLanguage', () => {
describe('createQuery and executeQuery', () => {
it('should create a and execute an empty query', () => {
const query = createQuery(users, {})
const result = executeQuery(users, query)
assert.deepStrictEqual(query, 'function query (data) {\n return data\n}')
assert.deepStrictEqual(result, users)
assert.deepStrictEqual(users, originalUsers) // must not touch the original users
})

it('should create and execute a filter query for a nested property', () => {
const query = createQuery(users, {
filter: {
field: ['user', 'name'],
relation: '==',
value: 'Bob'
}
})
assert.deepStrictEqual(
query,
'function query (data) {\n' +
' data = data.filter(item => item?.["user"]?.["name"] == \'Bob\')\n' +
' return data\n' +
'}'
)

const result = executeQuery(users, query)
assert.deepStrictEqual(result, [user2])
assert.deepStrictEqual(users, originalUsers) // must not touch the original data
})

it('should create and execute a filter query for the whole array item', () => {
const data = [2, 3, 1]
const originalData = cloneDeep(data)
const query = createQuery(data, {
filter: {
field: [],
relation: '==',
value: '1'
}
})
assert.deepStrictEqual(
query,
'function query (data) {\n' +
" data = data.filter(item => item == '1')\n" +
' return data\n' +
'}'
)

const result = executeQuery(data, query)
assert.deepStrictEqual(result, [1])
assert.deepStrictEqual(data, originalData) // must not touch the original data
})

it('should create and execute a sort query in ascending direction', () => {
const query = createQuery(users, {
sort: {
field: ['user', 'age'],
direction: 'asc'
}
})
assert.deepStrictEqual(
query,
'function query (data) {\n' +
' data = data.slice().sort((a, b) => {\n' +
' // sort ascending\n' +
' const valueA = a?.["user"]?.["age"]\n' +
' const valueB = b?.["user"]?.["age"]\n' +
' return valueA > valueB ? 1 : valueA < valueB ? -1 : 0\n' +
' })\n' +
' return data\n' +
'}'
)

const result = executeQuery(users, query)
assert.deepStrictEqual(result, [user1, user2, user3])
assert.deepStrictEqual(users, originalUsers) // must not touch the original users
})

it('should create and execute a sort query in descending direction', () => {
const query = createQuery(users, {
sort: {
field: ['user', 'age'],
direction: 'desc'
}
})
assert.deepStrictEqual(
query,
'function query (data) {\n' +
' data = data.slice().sort((a, b) => {\n' +
' // sort descending\n' +
' const valueA = a?.["user"]?.["age"]\n' +
' const valueB = b?.["user"]?.["age"]\n' +
' return valueA > valueB ? -1 : valueA < valueB ? 1 : 0\n' +
' })\n' +
' return data\n' +
'}'
)

const result = executeQuery(users, query)
assert.deepStrictEqual(result, [user3, user2, user1])
assert.deepStrictEqual(users, originalUsers) // must not touch the original users
})

it('should create and execute a project query for a single property', () => {
const query = createQuery(users, {
projection: {
fields: [['user', 'name']]
}
})

assert.deepStrictEqual(
query,
'function query (data) {\n' +
' data = data.map(item => item?.["user"]?.["name"])\n' +
' return data\n' +
'}'
)

const result = executeQuery(users, query)
assert.deepStrictEqual(result, ['Stuart', 'Kevin', 'Bob'])
assert.deepStrictEqual(users, originalUsers) // must not touch the original users
})

it('should create and execute a project query for a multiple properties', () => {
const query = createQuery(users, {
projection: {
fields: [['user', 'name'], ['_id']]
}
})

assert.deepStrictEqual(
query,
'function query (data) {\n' +
' data = data.map(item => ({\n' +
' "name": item?.["user"]?.["name"],\n' +
' "_id": item?.["_id"]})\n' +
' )\n' +
' return data\n' +
'}'
)

const result = executeQuery(users, query)
assert.deepStrictEqual(result, [
{ name: 'Stuart', _id: '1' },
{ name: 'Kevin', _id: '3' },
{ name: 'Bob', _id: '2' }
])
assert.deepStrictEqual(users, originalUsers) // must not touch the original users
})

it('should create and execute a query with filter, sort and project', () => {
const query = createQuery(users, {
filter: {
field: ['user', 'age'],
relation: '<=',
value: '7'
},
sort: {
field: ['user', 'name'],
direction: 'asc'
},
projection: {
fields: [['user', 'name']]
}
})

assert.deepStrictEqual(
query,
'function query (data) {\n' +
' data = data.filter(item => item?.["user"]?.["age"] <= \'7\')\n' +
' data = data.slice().sort((a, b) => {\n' +
' // sort ascending\n' +
' const valueA = a?.["user"]?.["name"]\n' +
' const valueB = b?.["user"]?.["name"]\n' +
' return valueA > valueB ? 1 : valueA < valueB ? -1 : 0\n' +
' })\n' +
' data = data.map(item => item?.["user"]?.["name"])\n' +
' return data\n' +
'}'
)

const result = executeQuery(users, query)
assert.deepStrictEqual(result, ['Bob', 'Stuart'])
assert.deepStrictEqual(users, originalUsers) // must not touch the original users
})
})
})
134 changes: 46 additions & 88 deletions src/lib/plugins/query/jmespathQueryLanguage.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,34 @@ export const jmespathQueryLanguage = {
* @param {QueryLanguageOptions} queryOptions
* @return {string} Returns a query (as string)
*/
export function createQuery(json, queryOptions) {
function createQuery(json, queryOptions) {
const { sort, filter, projection } = queryOptions
let query = ''

if (filter) {
const examplePath = filter.field !== '@' ? ['0'].concat(parsePath('.' + filter.field)) : ['0']
const examplePath = ['0'].concat(filter.field)
const exampleValue = getIn(json, examplePath)
const value1 = typeof exampleValue === 'string' ? filter.value : parseString(filter.value)

query +=
'[? ' + filter.field + ' ' + filter.relation + ' ' + '`' + JSON.stringify(value1) + '`' + ']'
'[? ' +
stringifyPathForJmespath(filter.field) +
' ' +
filter.relation +
' ' +
'`' +
JSON.stringify(value1) +
'`' +
']'
} else {
query += Array.isArray(json) ? '[*]' : '@'
}

if (sort) {
if (sort.direction === 'desc') {
query += ' | reverse(sort_by(@, &' + sort.field + '))'
query += ' | reverse(sort_by(@, &' + stringifyPathForJmespath(sort.field) + '))'
} else {
query += ' | sort_by(@, &' + sort.field + ')'
query += ' | sort_by(@, &' + stringifyPathForJmespath(sort.field) + ')'
}
}

Expand All @@ -57,15 +65,14 @@ export function createQuery(json, queryOptions) {
}

if (projection.fields.length === 1) {
query += '.' + projection.fields[0]
query += '.' + stringifyPathForJmespath(projection.fields[0])
} else if (projection.fields.length > 1) {
query +=
'.{' +
projection.fields
.map((value) => {
const parts = value.split('.')
const last = parts[parts.length - 1]
return last + ': ' + value
.map((field) => {
const name = field[field.length - 1]
return name + ': ' + stringifyPathForJmespath(field)
})
.join(', ') +
'}'
Expand All @@ -84,87 +91,10 @@ export function createQuery(json, queryOptions) {
* @param {string} query
* @return {JSON} Returns the transformed JSON
*/
export function executeQuery(json, query) {
function executeQuery(json, query) {
return jmespath.search(json, query)
}

// TODO: move parsePath to pathUtils.js?
/**
* Parse a JSON path like '.items[3].name' into an array
* @param {string} jsonPath
* @return {Array}
*/
export function parsePath(jsonPath) {
const path = []
let i = 0

function parseProperty() {
let prop = ''
while (jsonPath[i] !== undefined && /[\w$]/.test(jsonPath[i])) {
prop += jsonPath[i]
i++
}

if (prop === '') {
throw new Error('Invalid JSON path: property name expected at index ' + i)
}

return prop
}

function parseIndex(end) {
let name = ''
while (jsonPath[i] !== undefined && jsonPath[i] !== end) {
name += jsonPath[i]
i++
}

if (jsonPath[i] !== end) {
throw new Error('Invalid JSON path: unexpected end, character ' + end + ' expected')
}

return name
}

while (jsonPath[i] !== undefined) {
if (jsonPath[i] === '.') {
i++
path.push(parseProperty())
} else if (jsonPath[i] === '[') {
i++

if (jsonPath[i] === "'" || jsonPath[i] === '"') {
const end = jsonPath[i]
i++

path.push(parseIndex(end))

if (jsonPath[i] !== end) {
throw new Error("Invalid JSON path: closing quote ' expected at index " + i)
}
i++
} else {
let index = parseIndex(']').trim()
if (index.length === 0) {
throw new Error('Invalid JSON path: array value expected at index ' + i)
}
// Coerce numeric indices to numbers, but ignore star
index = index === '*' ? index : JSON.parse(index)
path.push(index)
}

if (jsonPath[i] !== ']') {
throw new Error('Invalid JSON path: closing bracket ] expected at index ' + i)
}
i++
} else {
throw new Error('Invalid JSON path: unexpected character "' + jsonPath[i] + '" at index ' + i)
}
}

return path
}

/**
* Cast contents of a string to the correct type.
* This can be a string, a number, a boolean, etc
Expand Down Expand Up @@ -196,3 +126,31 @@ export function parseString(str) {

return str
}

/**
* @param {string[]} path
* @returns {string}
*/
// TODO: unit test stringifyPathForJmespath
// TODO: Isn't there a helper function exposed by the JMESPath library?
export function stringifyPathForJmespath(path) {
if (path.length === 0) {
return '@'
}

const str = path
.map((prop) => {
if (typeof prop === 'number') {
return '[' + prop + ']'
} else if (typeof prop === 'string' && prop.match(/^[A-Za-z0-9_$]+$/)) {
return '.' + prop
} else {
return '."' + prop + '"'
}
})
.join('')

return str[0] === '.'
? str.slice(1) // remove first dot
: str
}

0 comments on commit a6af472

Please sign in to comment.