Implement proof of concept for `createQuery` and `executeQuery`, see #857

This commit is contained in:
jos 2019-12-11 17:09:47 +01:00
parent 4eb55bffde
commit d2332dc308
9 changed files with 244 additions and 55 deletions

View File

@ -0,0 +1,99 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<title>JSONEditor | Custom query language</title>
<meta charset="utf-8" />
<link href="../dist/jsoneditor.css" rel="stylesheet" type="text/css">
<script src="../dist/jsoneditor.js"></script>
<script src="https://unpkg.com/lodash@4.17.15/lodash.min.js"></script>
<style type="text/css">
p {
max-width: 500px;
font-family: sans-serif;
font-size: 11pt;
}
code {
font-size: 11pt;
background: #e5e5e5;
}
#jsoneditor {
width: 500px;
height: 500px;
}
.warning {
color: red;
}
</style>
</head>
<body>
<p>
This demo shows how to configure a custom query language.
Click on the "Transform" button and try it out.
</p>
<p>
The example uses lodash functions <code>filter</code>, <code>sort</code>, and <code>pick</code>.
</p>
<p class="warning">
WARNING: this example uses <code>new Function()</code> which can be dangerous when executed with arbitrary code.
</p>
<div id="jsoneditor"></div>
<script>
const container = document.getElementById('jsoneditor')
const options = {
createQuery: function (json, queryOptions) {
console.log('createQuery', queryOptions)
const { filter, sort, projection } = queryOptions
let query = 'data'
if (filter) {
// FIXME: parse filter.value either as string or number
query = `_.filter(${query}, item => _.get(item, '${filter.field}') ${filter.relation} '${filter.value}')`
}
if (sort) {
query = `_.orderBy(${query}, '${sort.field}', '${sort.direction}')`
}
if (projection) {
query = `_.map(${query}, item => _.pick(item, ${JSON.stringify(projection.fields)}))`
}
return query
},
executeQuery: function (json, query) {
console.log('executeQuery', query)
// WARNING: Using new Function() with arbitrary input can be dangerous! Be careful.
const execute = new Function('data', 'return ' + query)
return execute(json)
}
}
const json = []
for (let i = 0; i < 100; i++) {
var longitude = 4 + i / 100
var latitude = 51 + i / 100
json.push({
name: 'Item ' + i,
id: String(i),
index: i,
time: new Date().toISOString(),
location: {
latitude: longitude,
longitude: latitude,
coordinates: [longitude, latitude]
},
random: Math.random()
})
}
const editor = new JSONEditor(container, options, json)
</script>
</body>
</html>

View File

@ -183,7 +183,7 @@ JSONEditor.VALID_OPTIONS = [
'timestampTag', 'timestampFormat',
'escapeUnicode', 'history', 'search', 'mode', 'modes', 'name', 'indentation',
'sortObjectKeys', 'navigationBar', 'statusBar', 'mainMenuBar', 'languages', 'language', 'enableSort', 'enableTransform',
'maxVisibleChilds', 'onValidationError'
'maxVisibleChilds', 'onValidationError', 'createQuery', 'executeQuery'
]
/**

View File

@ -1,6 +1,5 @@
'use strict'
import jmespath from 'jmespath'
import naturalSort from 'javascript-natural-sort'
import { createAbsoluteAnchor } from './createAbsoluteAnchor'
import { ContextMenu } from './ContextMenu'
@ -3309,7 +3308,7 @@ export class Node {
// apply the JMESPath query
const oldValue = this.getValue()
const newValue = jmespath.search(oldValue, query)
const newValue = this.editor.options.executeQuery(oldValue, query)
this.setValue(newValue)
const newInternalValue = this.getInternalValue()
@ -3865,7 +3864,8 @@ export class Node {
const anchor = this.editor.options.modalAnchor || DEFAULT_MODAL_ANCHOR
const json = node.getValue()
showTransformModal(anchor, json, query => {
const { createQuery, executeQuery } = this.editor.options
showTransformModal(anchor, json, createQuery, executeQuery, query => {
node.transform(query)
})
}

75
src/js/jmespathQuery.js Normal file
View File

@ -0,0 +1,75 @@
import jmespath from 'jmespath'
import { get, parsePath, parseString } from './util'
/**
* Build a JMESPath query based on query options coming from the wizard
* @param {JSON} json The JSON document for which to build the query.
* Used for context information like determining
* the type of values (string or number)
* @param {QueryOptions} queryOptions
* @return {string} Returns a query (as string)
*/
export function createQuery (json, queryOptions) {
const { sort, filter, projection } = queryOptions
let query = ''
if (!!filter) {
const examplePath = filter.field !== '@'
? ['0'].concat(parsePath('.' + filter.field))
: ['0']
const exampleValue = get(json, examplePath)
const value1 = typeof exampleValue === 'string'
? filter.value
: parseString(filter.value)
query += '[? ' +
filter.field + ' ' +
filter.relation + ' ' +
'`' + JSON.stringify(value1) + '`' +
']'
} else {
query += Array.isArray(json)
? '[*]'
: '@'
}
if (!!sort) {
if (sort.direction === 'desc') {
query += ' | reverse(sort_by(@, &' + sort.field + '))'
} else {
query += ' | sort_by(@, &' + sort.field + ')'
}
}
if (!!projection) {
if (query[query.length - 1] !== ']') {
query += ' | [*]'
}
if (projection.fields.length === 1) {
query += '.' + 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
}).join(', ') +
'}'
} else { // values.length === 0
// ignore
}
}
return query
}
/**
* Execute a JMESPath query
* @param {JSON} json
* @param {string} query
* @return {JSON} Returns the transformed JSON
*/
export function executeQuery (json, query) {
return jmespath.search(json, query)
}

View File

@ -1,6 +1,5 @@
'use strict'
import jmespath from 'jmespath'
import { translate } from './i18n'
import { ModeSwitcher } from './ModeSwitcher'
import { ErrorTable } from './ErrorTable'
@ -23,6 +22,7 @@ import {
sortObjectKeys
} from './util'
import { History } from './History'
import { createQuery, executeQuery } from './jmespathQuery'
const textmode = textModeMixins[0].mixin
@ -44,6 +44,8 @@ previewmode.create = function (container, options = {}) {
options.mainMenuBar = options.mainMenuBar !== false
options.enableSort = options.enableSort !== false
options.enableTransform = options.enableTransform !== false
options.createQuery = options.createQuery || createQuery
options.executeQuery = options.executeQuery || executeQuery
this.options = options
@ -390,11 +392,13 @@ previewmode._showTransformModal = function () {
this.executeWithBusyMessage(() => {
const anchor = me.options.modalAnchor || DEFAULT_MODAL_ANCHOR
const json = me.get()
const { createQuery, executeQuery } = this.editor.options
me._renderPreview() // update array count
showTransformModal(anchor, json, query => {
showTransformModal(anchor, json, createQuery, executeQuery, query => {
me.executeWithBusyMessage(() => {
const updatedJson = jmespath.search(json, query)
const updatedJson = this.options.executeQuery(json, query)
me._setAndFireOnChange(updatedJson)
}, 'transforming...')
})

View File

@ -1,9 +1,8 @@
import jmespath from 'jmespath'
import picoModal from 'picomodal'
import Selectr from './assets/selectr/selectr'
import { translate } from './i18n'
import { stringifyPartial } from './jsonUtils'
import { getChildPaths, get, parsePath, parseString, debounce } from './util'
import { getChildPaths, debounce } from './util'
import { MAX_PREVIEW_CHARACTERS } from './constants'
/**
@ -11,10 +10,13 @@ import { MAX_PREVIEW_CHARACTERS } from './constants'
* @param {HTMLElement} container The container where to center
* the modal and create an overlay
* @param {JSON} json The json data to be transformed
* @param {function} createQuery Function called to create a query
* from the wizard form
* @param {function} executeQuery Execute a query for the preview pane
* @param {function} onTransform Callback invoked with the created
* query as callback
*/
export function showTransformModal (container, json, onTransform) {
export function showTransformModal (container, json, createQuery, executeQuery, onTransform) {
const value = json
const content = '<label class="pico-modal-contents">' +
@ -176,7 +178,8 @@ export function showTransformModal (container, json, onTransform) {
}
}
query.value = Array.isArray(value) ? '[*]' : '@'
// initialize with empty query
query.value = createQuery(json, {})
function preprocessPath (path) {
return (path === '')
@ -187,68 +190,45 @@ export function showTransformModal (container, json, onTransform) {
}
function generateQueryFromWizard () {
if (filterField.value && filterRelation.value && filterValue.value) {
const field1 = filterField.value
const examplePath = field1 !== '@'
? ['0'].concat(parsePath('.' + field1))
: ['0']
const exampleValue = get(value, examplePath)
const value1 = typeof exampleValue === 'string'
? filterValue.value
: parseString(filterValue.value)
const queryOptions = {}
query.value = '[? ' +
field1 + ' ' +
filterRelation.value + ' ' +
'`' + JSON.stringify(value1) + '`' +
']'
} else {
query.value = '[*]'
if (filterField.value && filterRelation.value && filterValue.value) {
queryOptions.filter = {
field: filterField.value,
relation: filterRelation.value,
value: filterValue.value
}
}
if (sortField.value && sortOrder.value) {
const field2 = sortField.value
if (sortOrder.value === 'desc') {
query.value += ' | reverse(sort_by(@, &' + field2 + '))'
} else {
query.value += ' | sort_by(@, &' + field2 + ')'
queryOptions.sort = {
field: sortField.value,
direction: sortOrder.value
}
}
if (selectFields.value) {
const values = []
const fields = []
for (let i = 0; i < selectFields.options.length; i++) {
if (selectFields.options[i].selected) {
const selectedValue = selectFields.options[i].value
values.push(selectedValue)
const selectedField = selectFields.options[i].value
fields.push(selectedField)
}
}
if (query.value[query.value.length - 1] !== ']') {
query.value += ' | [*]'
}
if (values.length === 1) {
query.value += '.' + values[0]
} else if (values.length > 1) {
query.value += '.{' +
values.map(value => {
const parts = value.split('.')
const last = parts[parts.length - 1]
return last + ': ' + value
}).join(', ') +
'}'
} else { // values.length === 0
// ignore
queryOptions.projection = {
fields
}
}
query.value = createQuery(json, queryOptions)
debouncedUpdatePreview()
}
function updatePreview () {
try {
const transformed = jmespath.search(value, query.value)
const transformed = executeQuery(value, query.value)
preview.className = 'jsoneditor-transform-preview'
preview.value = stringifyPartial(transformed, 2, MAX_PREVIEW_CHARACTERS)

View File

@ -1,7 +1,6 @@
'use strict'
import ace from './ace'
import jmespath from 'jmespath'
import { translate } from './i18n'
import { ModeSwitcher } from './ModeSwitcher'
import { ErrorTable } from './ErrorTable'
@ -26,6 +25,7 @@ import {
} from './util'
import { DEFAULT_MODAL_ANCHOR } from './constants'
import { tryRequireThemeJsonEditor } from './tryRequireThemeJsonEditor'
import { createQuery, executeQuery } from './jmespathQuery'
// create a mixin with the functions for text mode
const textmode = {}
@ -47,6 +47,8 @@ textmode.create = function (container, options = {}) {
options.mainMenuBar = options.mainMenuBar !== false
options.enableSort = options.enableSort !== false
options.enableTransform = options.enableTransform !== false
options.createQuery = options.createQuery || createQuery
options.executeQuery = options.executeQuery || executeQuery
this.options = options
@ -428,8 +430,9 @@ textmode._showTransformModal = function () {
const me = this
const anchor = this.options.modalAnchor || DEFAULT_MODAL_ANCHOR
const json = this.get()
showTransformModal(anchor, json, query => {
const updatedJson = jmespath.search(json, query)
const { createQuery, executeQuery } = this.editor.options
showTransformModal(anchor, json, createQuery, executeQuery, query => {
const updatedJson = this.options.executeQuery(json, query)
me.set(updatedJson)
})
}

View File

@ -29,6 +29,7 @@ import {
} from './util'
import { autocomplete } from './autocomplete'
import { setLanguage, setLanguages, translate } from './i18n'
import { createQuery, executeQuery } from './jmespathQuery'
// create a mixin with the functions for tree mode
const treemode = {}
@ -157,6 +158,8 @@ treemode._setOptions = function (options) {
},
timestampTag: true,
timestampFormat: null,
createQuery,
executeQuery,
onEvent: null,
enableSort: true,
enableTransform: true

25
src/js/types.js Normal file
View File

@ -0,0 +1,25 @@
/**
* @typedef {object} QueryOptions
* @property {FilterOptions} [filter]
* @property {SortOptions} [sort]
* @property {ProjectionOptions} [projection]
*/
/**
* @typedef {object} FilterOptions
* @property {string} field
* @property {string} relation Can be '==', '<', etc
* @property {string} value
*/
/**
* @typedef {object} SortOptions
* @property {string} field
* @property {string} direction Can be 'asc' or 'desc'
*/
/**
* @typedef {object} ProjectionOptions
* @property {string[]} fields
*/