From a9996168238d00d84733a88edb32c186c5892652 Mon Sep 17 00:00:00 2001 From: Hugo Bove Date: Tue, 27 Aug 2019 15:51:56 +0200 Subject: [PATCH] Added support for explicit lambdas - Added new dep @neo4j/browser-lambda-parser - Updated existing cmd utils to use parser for all things lambda - Standardized and cleaned up error frames appearance regardless of source - Added new help text for :param - Standardized appearance of :help buttons and Error frames --- package.json | 2 + src/browser/components/Directives.jsx | 4 +- src/browser/modules/Help/html/param.html | 6 + src/browser/modules/Main/Main.jsx | 22 +-- .../modules/Stream/CypherFrame/ErrorsView.jsx | 6 +- .../modules/Stream/CypherFrame/index.jsx | 5 +- src/browser/modules/Stream/ErrorFrame.jsx | 22 ++- src/browser/modules/Stream/ParamsFrame.jsx | 9 +- .../modules/Stream/auto-exec-button.jsx | 65 ++++++++ src/browser/modules/Stream/styled.jsx | 4 + src/shared/modules/commands/commandsDuck.js | 1 + .../modules/commands/helpers/lambdas.js | 152 ++++++++++++++++++ src/shared/modules/commands/helpers/params.js | 91 +++++------ .../services/commandInterpreterHelper.js | 25 ++- yarn.lock | 5 + 15 files changed, 328 insertions(+), 91 deletions(-) create mode 100644 src/browser/modules/Stream/auto-exec-button.jsx create mode 100644 src/shared/modules/commands/helpers/lambdas.js diff --git a/package.json b/package.json index 43fb31352a0..9671eb89e56 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "xml2js": "^0.4.19" }, "dependencies": { + "@neo4j/browser-lambda-parser": "1.0.0", "ascii-data-table": "^2.1.1", "classnames": "^2.2.5", "codemirror": "^5.29.0", @@ -136,6 +137,7 @@ "firebase": "^5.8.3", "isomorphic-fetch": "^2.2.1", "jsonic": "^0.3.0", + "lodash-es": "^4.17.15", "mockdate": "^2.0.5", "neo4j-driver": "^1.7.5", "react": "^16.9.0", diff --git a/src/browser/components/Directives.jsx b/src/browser/components/Directives.jsx index 86599966577..d338c095028 100644 --- a/src/browser/components/Directives.jsx +++ b/src/browser/components/Directives.jsx @@ -106,9 +106,7 @@ export const Directives = props => { const elems = elem.querySelectorAll(directive.selector) Array.from(elems).forEach(e => { if (e.firstChild.nodeName !== 'I') { - directive.selector === '[help-topic]' - ? prependHelpIcon(e) - : prependPlayIcon(e) + prependPlayIcon(e) } e.onclick = () => { diff --git a/src/browser/modules/Help/html/param.html b/src/browser/modules/Help/html/param.html index 0453dc4b1c3..bdf823637ef 100644 --- a/src/browser/modules/Help/html/param.html +++ b/src/browser/modules/Help/html/param.html @@ -18,6 +18,12 @@ :param x => 1 and to set it as a float, do :param x => 1.0.

+

+ If you need more fine-grained control or advanced Cypher queries, you can use the explicit syntax: x => { ... RETURN 1 as foo } +
Explicit returns yield a list of records, matching that of your Cypher query: x => { RETURN 1 as foo } yields $x = [{foo: 1}] +
You can pick out individual values from your result using destructuring: [{foo}] => { RETURN 1 as foo } yields $foo = 1 +
You can also rename destructured params: [{foo: bar}] => { RETURN 1 as foo } yields $bar = 1 +

Cypher query example with a param:  MATCH (n:Person) WHERE n.name = $name

diff --git a/src/browser/modules/Main/Main.jsx b/src/browser/modules/Main/Main.jsx index 4d057e1b6e8..324bd9ca0ac 100644 --- a/src/browser/modules/Main/Main.jsx +++ b/src/browser/modules/Main/Main.jsx @@ -26,19 +26,17 @@ import { import Editor from '../Editor/Editor' import Stream from '../Stream/Stream' import Render from 'browser-components/Render' -import ClickToCode from '../ClickToCode' import { StyledMain, WarningBanner, ErrorBanner, - NotAuthedBanner, - StyledCodeBlockAuthBar, - StyledCodeBlockErrorBar + NotAuthedBanner } from './styled' import SyncReminderBanner from './SyncReminderBanner' import SyncConsentBanner from './SyncConsentBanner' import ErrorBoundary from 'browser-components/ErrorBoundary' import { useSlowConnectionState } from './main.hooks' +import AutoExecButton from '../Stream/auto-exec-button' const Main = React.memo(function Main (props) { const [past5Sec, past10Sec] = useSlowConnectionState(props) @@ -51,11 +49,8 @@ const Main = React.memo(function Main (props) { Type  - - {props.cmdchar} - help commands - -   for a list of available commands. + +  for a list of available commands. @@ -66,13 +61,10 @@ const Main = React.memo(function Main (props) { Database access not available. Please use  - - {props.cmdchar} - server connect - + />   to establish connection. There's a graph waiting for you. diff --git a/src/browser/modules/Stream/CypherFrame/ErrorsView.jsx b/src/browser/modules/Stream/CypherFrame/ErrorsView.jsx index ab790ebdffb..7f0a309358e 100644 --- a/src/browser/modules/Stream/CypherFrame/ErrorsView.jsx +++ b/src/browser/modules/Stream/CypherFrame/ErrorsView.jsx @@ -35,7 +35,7 @@ import { errorMessageFormater } from './../errorMessageFormater' import { StyledCypherErrorMessage, StyledHelpContent, - StyledH4, + StyledErrorH4, StyledPreformattedArea, StyledHelpDescription, StyledDiv, @@ -54,14 +54,14 @@ export class ErrorsView extends Component { if (!error || !error.code) { return null } - const fullError = errorMessageFormater(error.code, error.message) + const fullError = errorMessageFormater(null, error.message) return ( ERROR - {error.code} + {error.code} {fullError.message} diff --git a/src/browser/modules/Stream/CypherFrame/index.jsx b/src/browser/modules/Stream/CypherFrame/index.jsx index df024f63142..46a82f538b3 100644 --- a/src/browser/modules/Stream/CypherFrame/index.jsx +++ b/src/browser/modules/Stream/CypherFrame/index.jsx @@ -373,13 +373,14 @@ export class CypherFrame extends Component { this.getFrameContents(request, result, query) ) const statusBar = - this.state.openView !== viewTypes.VISUALIZATION + this.state.openView !== viewTypes.VISUALIZATION && + requestStatus !== 'error' ? this.getStatusbar(result) : null return ( . */ import React from 'react' + import FrameTemplate from './FrameTemplate' import * as e from 'services/exceptionMessages' -import { createErrorObject } from 'services/exceptions' +import { createErrorObject, UnknownCommandError } from 'services/exceptions' import { errorMessageFormater } from './errorMessageFormater' import { StyledCypherErrorMessage, StyledHelpContent, - StyledH4, + StyledErrorH4, StyledPreformattedArea, StyledHelpDescription, StyledDiv, StyledHelpFrame } from './styled' +import AutoExecButton from './auto-exec-button' export const ErrorView = ({ frame }) => { if (!frame) return null @@ -41,18 +43,30 @@ export const ErrorView = ({ frame }) => { const eObj = createErrorObject(errorCode, error) errorContents = eObj.message } - const fullError = errorMessageFormater(errorCode, errorContents) + const fullError = errorMessageFormater(null, errorContents) return ( ERROR - {errorCode} + {errorCode} {fullError.message} + {frame.showHelpForCmd ? ( + <> + Use for more + information. + + ) : null} + {errorCode === UnknownCommandError.name ? ( + <> + Use to list available + commands. + + ) : null} ) } diff --git a/src/browser/modules/Stream/ParamsFrame.jsx b/src/browser/modules/Stream/ParamsFrame.jsx index 334a506bc60..449a71ad237 100644 --- a/src/browser/modules/Stream/ParamsFrame.jsx +++ b/src/browser/modules/Stream/ParamsFrame.jsx @@ -26,7 +26,7 @@ import { stringifyMod } from 'services/utils' import FrameTemplate from './FrameTemplate' import { PaddedDiv, ErrorText, SuccessText, StyledStatsBar } from './styled' import { applyGraphTypes } from 'services/bolt/boltMappings' -import ClickToCode from 'browser/modules/ClickToCode' +import AutoExecButton from './auto-exec-button' const ParamsFrame = ({ frame }) => { const params = applyGraphTypes(frame.params) @@ -38,11 +38,8 @@ const ParamsFrame = ({ frame }) => {
- See{' '} - - :help param - {' '} - for usage of the :param command. + See for usage of the{' '} + :param command.
) diff --git a/src/browser/modules/Stream/auto-exec-button.jsx b/src/browser/modules/Stream/auto-exec-button.jsx new file mode 100644 index 00000000000..51dddc1e1a0 --- /dev/null +++ b/src/browser/modules/Stream/auto-exec-button.jsx @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2002-2019 "Neo4j," + * Neo4j Sweden AB [http://neo4j.com] + * This file is part of Neo4j. + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +import React, { useCallback } from 'react' +import { connect } from 'react-redux' +import { withBus } from 'react-suber' +import styled from 'styled-components' + +import { executeCommand } from '../../../shared/modules/commands/commandsDuck' + +const StyledAutoExecButton = styled.button` + border-radius: 3px; + border: 1px solid #dadada; + display: inline-block; + font-family: Monaco, 'Courier New', Terminal, monospace; + font-size: 12px; + line-height: 18px; + padding: 0 4px; + color: #428bca; + cursor: pointer; + text-decoration: none; + background-color: #f8f8f8; + outline: transparent; +` + +function AutoExecButtonComponent ({ bus, cmd, cmdChar }) { + const onClick = useCallback( + () => { + const action = executeCommand(`${cmdChar}${cmd}`) + + bus.send(action.type, action) + }, + [cmd] + ) + + return ( + + {cmdChar} + {cmd} + + ) +} + +const mapStateToProps = ({ settings }) => ({ + cmdChar: settings.cmdchar +}) +const AutoExecButton = withBus( + connect(mapStateToProps)(AutoExecButtonComponent) +) + +export default AutoExecButton diff --git a/src/browser/modules/Stream/styled.jsx b/src/browser/modules/Stream/styled.jsx index 7b6f3bd56fe..bcff93bb2a7 100644 --- a/src/browser/modules/Stream/styled.jsx +++ b/src/browser/modules/Stream/styled.jsx @@ -246,6 +246,10 @@ export const StyledInfoMessage = styled(StyledCypherMessage)` export const StyledH4 = styled.h4`` +export const StyledErrorH4 = styled.h4` + display: inline-block; +` + export const StyledBr = styled.br`` export const StyledPreformattedArea = styled.pre` diff --git a/src/shared/modules/commands/commandsDuck.js b/src/shared/modules/commands/commandsDuck.js index 6dea2f7ad6a..b9b747e294e 100644 --- a/src/shared/modules/commands/commandsDuck.js +++ b/src/shared/modules/commands/commandsDuck.js @@ -122,6 +122,7 @@ export const showErrorMessage = errorMessage => ({ type: SHOW_ERROR_MESSAGE, errorMessage: errorMessage }) + export const clearErrorMessage = () => ({ type: CLEAR_ERROR_MESSAGE }) diff --git a/src/shared/modules/commands/helpers/lambdas.js b/src/shared/modules/commands/helpers/lambdas.js new file mode 100644 index 00000000000..a12b6ade608 --- /dev/null +++ b/src/shared/modules/commands/helpers/lambdas.js @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2002-2019 "Neo4j," + * Neo4j Sweden AB [http://neo4j.com] + * This file is part of Neo4j. + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +import { + assign, + head, + join, + map, + reduce, + slice, + split, + tail, + trim +} from 'lodash-es' +import { parseLambda } from '@neo4j/browser-lambda-parser' + +import bolt from '../../../services/bolt/bolt' +import { recursivelyTypeGraphItems } from '../../../services/bolt/boltMappings' +import arrayHasItems from '../../../utils/array-has-items' + +const FAT_ARROW = '=>' +const TOKEN = 'token' +const ARRAY = 'array' +const IMPLICIT = 'implicit' +const NEARLEY_ERROR_SPLIT = + 'Instead, I was expecting to see one of the following:' + +export function parseLambdaStatement (lambda) { + return Promise.resolve() + .then(() => { + const ast = parseLambda(trim(lambda)) + + if (!arrayHasItems(ast)) { + throw new Error( + `Unrecognized input. Sorry we couldn't be more specific.` + ) + } + + const [ + { + parameters, + variant, + body: { returnValues } + } + ] = ast + const statement = trim(join(tail(split(lambda, FAT_ARROW)), FAT_ARROW)) + const query = + variant === IMPLICIT + ? `RETURN ${statement}` + : statement.slice(1, statement.length - 1) + + return { + parameters, + query, + variant, + returnValues + } + }) + .catch(e => { + throw new Error(head(split(e, NEARLEY_ERROR_SPLIT))) + }) +} + +export function collectLambdaValues ( + { parameters, query, variant, returnValues }, + requestId +) { + return bolt + .routedWriteTransaction( + query, + {}, + { + useCypherThread: false, + requestId, + cancelable: false + } + ) + .then(({ records }) => { + if (variant === IMPLICIT) { + const firstResult = head(records) + const firstReturn = head(returnValues) + + return firstResult + ? recursivelyTypeGraphItems({ + [parameters.value]: firstResult.get( + firstReturn.alias || firstReturn.value + ) + }) + : null + } + + if (parameters.type === TOKEN) { + const extractedRecords = map(records, record => + reduce( + record.keys, + (agg, next) => + assign(agg, { + [next]: record.get(next) + }), + {} + ) + ) + + return { + [parameters.value]: map(extractedRecords, record => + recursivelyTypeGraphItems(record) + ) + } + } + + // future proofing + if (parameters.type !== ARRAY) return null + + const { items } = parameters + const extractedRecords = map( + slice(records, 0, items.length), + (record, index) => { + const item = items[index] + const keys = item.type === TOKEN ? [item] : item.keys // item.type === OBJECT + + return reduce( + keys, + (agg, next) => + assign(agg, { + [next.alias || next.value]: record.get(next.value) + }), + {} + ) + } + ) + + return reduce( + extractedRecords, + (agg, record) => assign(agg, recursivelyTypeGraphItems(record)), + {} + ) + }) +} diff --git a/src/shared/modules/commands/helpers/params.js b/src/shared/modules/commands/helpers/params.js index e61477c2381..3a3f42ab7d1 100644 --- a/src/shared/modules/commands/helpers/params.js +++ b/src/shared/modules/commands/helpers/params.js @@ -18,15 +18,18 @@ * along with this program. If not, see . */ import jsonic from 'jsonic' -import bolt from 'services/bolt/bolt' -import { recursivelyTypeGraphItems } from 'services/bolt/boltMappings' -import { - splitStringOnFirst, - mapParamToCypherStatement -} from 'services/commandUtils' +import { splitStringOnFirst } from 'services/commandUtils' import { update, replace } from 'shared/modules/params/paramsDuck' +import { collectLambdaValues, parseLambdaStatement } from './lambdas' export const extractParams = param => { + // early bail, now handled by parser + if (param.includes('=>')) { + return { + isFn: true + } + } + const matchParam = param.match(/^(".*"|'.*'|\S+)\s?(:|=>)\s([^$]*)$/) if (!matchParam) return {} const [, paramName, delimiter, paramValue] = matchParam @@ -56,72 +59,56 @@ export const extractParams = param => { } } -const resolveAndStoreJsonValue = (param, put, resolve, reject) => { +const resolveAndStoreJsonValue = (param, put) => { try { const json = `{${param}}` const res = jsonic(json) put(update(res)) - return resolve({ result: res, type: 'param' }) + return { result: res, type: 'param' } } catch (e) { - return reject( - new Error('Could not parse input. Usage: `:param x => 2`. ' + e) - ) + throw new Error('Could not parse input. Usage: `:param x => 2`. ' + e) } } + +export const getParamName = (input, cmdchar) => { + const strippedCmd = input.cmd.substr(cmdchar.length) + const parts = splitStringOnFirst(strippedCmd, ' ') + + return parts[0].trim() +} + export const handleParamsCommand = (action, cmdchar, put) => { const strippedCmd = action.cmd.substr(cmdchar.length) const parts = splitStringOnFirst(strippedCmd, ' ') const param = parts[1].trim() - const p = new Promise((resolve, reject) => { + + return Promise.resolve().then(() => { if (/^"?\{.*\}"?$/.test(param)) { // JSON object string {"x": 2, "y":"string"} try { const res = jsonic(param.replace(/^"/, '').replace(/"$/, '')) // Remove any surrounding quotes put(replace(res)) - return resolve({ result: res, type: 'params' }) + return { result: res, type: 'params' } } catch (e) { - return reject( - new Error( - 'Could not parse input. Usage: `:params {"x":1,"y":"string"}`. ' + e - ) + throw new Error( + 'Could not parse input. Usage: `:params {"x":1,"y":"string"}`. ' + e ) } - } else { - // Single param - const { key, value, isFn, originalParamValue } = extractParams(param) + } - if (!isFn || !key || value === undefined) { - return resolveAndStoreJsonValue(param, put, resolve, reject) - } - try { - const cypherStatement = mapParamToCypherStatement( - key, - originalParamValue - ) - bolt - .routedWriteTransaction( - cypherStatement, - {}, - { - useCypherThread: false, - requestId: action.requestId, - cancelable: false - } - ) - .then(res => { - let obj = {} - res.records.forEach(record => { - obj[key] = record.get(key) - }) - const result = recursivelyTypeGraphItems(obj) - put(update(result)) - resolve({ result, type: 'param' }) - }) - .catch(e => reject(e)) - } catch (e) { - reject(new Error('Could not parse input. Usage: `:param x => 2`. ' + e)) - } + // Single param + const { key, value, isFn } = extractParams(param) + + if (!isFn && (!key || value === undefined)) { + return resolveAndStoreJsonValue(param, put) } + + return parseLambdaStatement(param) + .then(ast => collectLambdaValues(ast, action.requestId)) + .then(result => { + put(update(result)) + + return { result, type: 'param' } + }) }) - return p } diff --git a/src/shared/services/commandInterpreterHelper.js b/src/shared/services/commandInterpreterHelper.js index 8a227a3417b..ec6ff2c0399 100644 --- a/src/shared/services/commandInterpreterHelper.js +++ b/src/shared/services/commandInterpreterHelper.js @@ -48,7 +48,10 @@ import { unsuccessfulCypher, SINGLE_COMMAND_QUEUED } from 'shared/modules/commands/commandsDuck' -import { handleParamsCommand } from 'shared/modules/commands/helpers/params' +import { + getParamName, + handleParamsCommand +} from 'shared/modules/commands/helpers/params' import { handleGetConfigCommand, handleUpdateConfigCommand @@ -117,13 +120,23 @@ const availableCommands = [ put(updateQueryResult(action.requestId, res, 'success')) return true }) - .catch(e => { - // Don't show error message bar if it's a sub command + .catch(error => { + // Don't show error message if it's a sub command if (!action.parentId) { - put(showErrorMessage(e.message)) + put( + frames.add({ + ...action, + error: { + type: 'Syntax Error', + message: error.message.substring('Error: '.length) + }, + showHelpForCmd: getParamName(action, cmdchar), + type: 'error' + }) + ) } - put(updateQueryResult(action.requestId, e, 'error')) - throw e + put(updateQueryResult(action.requestId, error, 'error')) + throw error }) } }, diff --git a/yarn.lock b/yarn.lock index e2e4a574026..70d77ff09f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6591,6 +6591,11 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +lodash-es@^4.17.15: + version "4.17.15" + resolved "https://neo.jfrog.io/neo/api/npm/npm/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" + integrity sha1-Ib2Wg5NUQS8j16EDQOXqxu5FXXg= + lodash-es@^4.2.1: version "4.17.11" resolved "https://neo.jfrog.io/neo/api/npm/npm/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0"