Merge branch 'feature/custom_query_language' into develop

# Conflicts:
#	HISTORY.md
This commit is contained in:
jos 2020-02-05 08:50:12 +01:00
commit c5c64bcde3
11 changed files with 413 additions and 102 deletions

View File

@ -3,6 +3,14 @@
https://github.com/josdejong/jsoneditor
## not yet published, version 8.5.0
- Implemented support for customizing the query language used in the
Transform modal. New options `createQuery`, `executeQuery`, and
`queryDescription` are available for this now. An example is available
in `examples/23_custom_query_language.html`. See #857, #871.
## 2020-01-25, version 8.4.1
- Fix `console.log` in production code. Oopsie.

View File

@ -589,6 +589,55 @@ Constructs a new JSONEditor.
- `{Number} maxVisibleChilds`
Number of children allowed for a given node before the "show more / show all" message appears (in 'tree', 'view', or 'form' modes). `100` by default.
- `{ function(json: JSON, queryOptions: QueryOptions) -> string } createQuery`
Create a query string based on query options filled in the Transform Wizard in the Transform modal.
Normally used in combination with `executeQuery`.
The input for the function are the entered query options and the current JSON, and the output
must be a string containing the query. This query will be executed using `executeQuery`.
The query options have the following structure:
```
interface QueryOptions {
filter?: {
field: string | '@'
relation: '==' | '!=' | '<' | '<=' | '>' | '>='
value: string
}
sort?: {
field: string | '@'
direction: 'asc' | 'desc'
}
projection?: {
fields: string[]
}
}
```
Note that there is a special case `'@'` for `filter.field` and `sort.field`.
It means that the field itself is selected, for example when having an array containing numbers.
A usage example can be found in `examples/23_custom_query_language.html`.
- `{ function(json: JSON, query: string) -> JSON } executeQuery`
Replace the build-in query language used in the Transform modal with a custom language.
Normally used in combination with `createQuery`.
The input for the function is the current JSON and a query string, and output must be the transformed JSON.
A usage example can be found in `examples/23_custom_query_language.html`.
- `{string} queryDescription`
A text description displayed on top of the Transform modal.
Can be used to explain a custom query language implemented via `createQuery` and `executeQuery`.
The text can contain HTML code like a link to a web page.
A usage example can be found in `examples/23_custom_query_language.html`.
### Methods

View File

@ -0,0 +1,123 @@
<!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>
This basic example uses lodash functions <code>filter</code>, <code>sort</code>, and <code>pick</code>,
but you can run any JavaScript code.
</p>
<p class="warning">
WARNING: this example uses <code>new Function()</code> which can be dangerous when executed with arbitrary code.
Don't use it in production.
</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) {
// Note that the comparisons embrace type coercion,
// so a filter value like '5' (text) will match numbers like 5 too.
const getActualValue = filter.field !== '@'
? `item => _.get(item, '${filter.field}')`
: `item => item`
query = `_.filter(${query}, ${getActualValue} ${filter.relation} '${filter.value}')`
}
if (sort) {
// The '@' field name is a special case,
// which means that the field itself is selected.
// For example when we have an array containing numbers.
query = sort.field !== '@'
? `_.orderBy(${query}, '${sort.field}', '${sort.direction}')`
: `_.sortBy(${query}, '${sort.direction}')`
}
if (projection) {
// It is possible to make a util function "pickFlat"
// and use that when building the query to make it more readable.
if (projection.fields.length > 1) {
const fields = projection.fields.map(field => {
const name = _.last(field.split('.'))
return ` '${name}': _.get(item, '${field}')`
})
query = `_.map(${query}, item => ({\n${fields.join(',\n')}})\n)`
} else {
const field = projection.fields[0]
query = `_.map(${query}, item => _.get(item, '${field}'))`
}
}
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)
},
queryDescription: 'Enter a JavaScript query to filter, sort, or transform the JSON data.<br/>' +
'The <a href="https://lodash.com/" target="_blank">Lodash</a> library is available via <code>_</code> to facilitate this.'
}
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

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

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'
@ -3331,7 +3330,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()
@ -3883,12 +3882,16 @@ export class Node {
* Show transform modal
*/
showTransformModal () {
const node = this
const { modalAnchor, createQuery, executeQuery, queryDescription } = this.editor.options
const json = this.getValue()
const anchor = this.editor.options.modalAnchor || DEFAULT_MODAL_ANCHOR
const json = node.getValue()
showTransformModal(anchor, json, query => {
node.transform(query)
showTransformModal({
anchor: modalAnchor || DEFAULT_MODAL_ANCHOR,
json,
queryDescription, // can be undefined
createQuery,
executeQuery,
onTransform: query => { this.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
@ -385,18 +387,24 @@ previewmode._showSortModal = function () {
* @private
*/
previewmode._showTransformModal = function () {
const me = this
this.executeWithBusyMessage(() => {
const anchor = me.options.modalAnchor || DEFAULT_MODAL_ANCHOR
const json = me.get()
me._renderPreview() // update array count
const { createQuery, executeQuery, modalAnchor, queryDescription } = this.options
const json = this.get()
showTransformModal(anchor, json, query => {
me.executeWithBusyMessage(() => {
const updatedJson = jmespath.search(json, query)
me._setAndFireOnChange(updatedJson)
}, 'transforming...')
this._renderPreview() // update array count
showTransformModal({
anchor: modalAnchor || DEFAULT_MODAL_ANCHOR,
json,
queryDescription, // can be undefined
createQuery,
executeQuery,
onTransform: query => {
this.executeWithBusyMessage(() => {
const updatedJson = executeQuery(json, query)
this._setAndFireOnChange(updatedJson)
}, 'transforming...')
}
})
}, 'parsing...')
}

View File

@ -1,28 +1,43 @@
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'
const DEFAULT_DESCRIPTION =
'Enter a <a href="http://jmespath.org" target="_blank">JMESPath</a> query to filter, sort, or transform the JSON data.<br/>' +
'To learn JMESPath, go to <a href="http://jmespath.org/tutorial.html" target="_blank">the interactive tutorial</a>.'
/**
* Show advanced filter and transform modal using JMESPath
* @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} onTransform Callback invoked with the created
* query as callback
* @param {Object} params
* @property {HTMLElement} container The container where to center
* the modal and create an overlay
* @property {JSON} json The json data to be transformed
* @property {string} [queryDescription] Optional custom description explaining
* the transform functionality
* @property {function} createQuery Function called to create a query
* from the wizard form
* @property {function} executeQuery Execute a query for the preview pane
* @property {function} onTransform Callback invoked with the created
* query as callback
*/
export function showTransformModal (container, json, onTransform) {
export function showTransformModal (
{
container,
json,
queryDescription = DEFAULT_DESCRIPTION,
createQuery,
executeQuery,
onTransform
}
) {
const value = json
const content = '<label class="pico-modal-contents">' +
'<div class="pico-modal-header">' + translate('transform') + '</div>' +
'<p>' +
'Enter a <a href="http://jmespath.org" target="_blank">JMESPath</a> query to filter, sort, or transform the JSON data.<br/>' +
'To learn JMESPath, go to <a href="http://jmespath.org/tutorial.html" target="_blank">the interactive tutorial</a>.' +
'</p>' +
'<p>' + queryDescription + '</p>' +
'<div class="jsoneditor-jmespath-label">' + translate('transformWizardLabel') + ' </div>' +
'<div id="wizard" class="jsoneditor-jmespath-block jsoneditor-jmespath-wizard">' +
' <table class="jsoneditor-jmespath-wizard-table">' +
@ -176,8 +191,6 @@ export function showTransformModal (container, json, onTransform) {
}
}
query.value = Array.isArray(value) ? '[*]' : '@'
function preprocessPath (path) {
return (path === '')
? '@'
@ -186,69 +199,9 @@ export function showTransformModal (container, json, onTransform) {
: path
}
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)
query.value = '[? ' +
field1 + ' ' +
filterRelation.value + ' ' +
'`' + JSON.stringify(value1) + '`' +
']'
} else {
query.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 + ')'
}
}
if (selectFields.value) {
const values = []
for (let i = 0; i < selectFields.options.length; i++) {
if (selectFields.options[i].selected) {
const selectedValue = selectFields.options[i].value
values.push(selectedValue)
}
}
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
}
}
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)
@ -261,10 +214,61 @@ export function showTransformModal (container, json, onTransform) {
}
}
var debouncedUpdatePreview = debounce(updatePreview, 300)
const debouncedUpdatePreview = debounce(updatePreview, 300)
function tryCreateQuery (json, queryOptions) {
try {
query.value = createQuery(json, queryOptions)
ok.disabled = false
debouncedUpdatePreview()
} catch (err) {
const message = 'Error: an error happened when executing "createQuery": ' + (err.message || err.toString())
query.value = ''
ok.disabled = true
preview.className = 'jsoneditor-transform-preview jsoneditor-error'
preview.value = message
}
}
function generateQueryFromWizard () {
const queryOptions = {}
if (filterField.value && filterRelation.value && filterValue.value) {
queryOptions.filter = {
field: filterField.value,
relation: filterRelation.value,
value: filterValue.value
}
}
if (sortField.value && sortOrder.value) {
queryOptions.sort = {
field: sortField.value,
direction: sortOrder.value
}
}
if (selectFields.value) {
const fields = []
for (let i = 0; i < selectFields.options.length; i++) {
if (selectFields.options[i].selected) {
const selectedField = selectFields.options[i].value
fields.push(selectedField)
}
}
queryOptions.projection = {
fields
}
}
tryCreateQuery(json, queryOptions)
}
query.oninput = debouncedUpdatePreview
debouncedUpdatePreview()
ok.onclick = event => {
event.preventDefault()
@ -275,6 +279,9 @@ export function showTransformModal (container, json, onTransform) {
onTransform(query.value)
}
// initialize with empty query
tryCreateQuery(json, {})
setTimeout(() => {
query.select()
query.focus()

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
@ -425,12 +427,19 @@ textmode._showSortModal = function () {
* @private
*/
textmode._showTransformModal = function () {
const me = this
const anchor = this.options.modalAnchor || DEFAULT_MODAL_ANCHOR
const { modalAnchor, createQuery, executeQuery, queryDescription } = this.options
const json = this.get()
showTransformModal(anchor, json, query => {
const updatedJson = jmespath.search(json, query)
me.set(updatedJson)
showTransformModal({
anchor: modalAnchor || DEFAULT_MODAL_ANCHOR,
json,
queryDescription, // can be undefined
createQuery,
executeQuery,
onTransform: query => {
const updatedJson = executeQuery(json, query)
this.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 = {}
@ -156,6 +157,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
*/