diff --git a/e2e_tests/integration/params.spec.js b/e2e_tests/integration/params.spec.js new file mode 100644 index 00000000000..856c28ac09c --- /dev/null +++ b/e2e_tests/integration/params.spec.js @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2002-2018 "Neo4j, Inc" + * Network Engine for Objects in Lund AB [http://neotechnology.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 . + */ + +/* global Cypress, cy, test, expect */ + +describe(':param in Browser', () => { + it('handles :param without web worker', () => { + cy.executeCommand(':config userCypherThread: false').then(() => { + cy.executeCommand(':clear') + return runTests() + }) + }) + it('handles :param WITH web worker', () => { + cy.executeCommand(':config userCypherThread: true').then(() => { + cy.executeCommand(':clear') + return runTests() + }) + }) +}) + +function runTests () { + let setParamQ + let getParamQ + // it('can connect', () => { + cy.executeCommand(':server disconnect') + cy.executeCommand(':clear') + cy.executeCommand(':server connect') + const password = Cypress.env('BROWSER_NEW_PASSWORD') || 'newpassword' + cy.connect(password) + // }) + // it(':param x => 1+1', () => { + // Set param + cy.executeCommand(':clear') + setParamQ = ':param x => 1+1' + cy.executeCommand(setParamQ) + cy.resultContains('"x": 2') + + // return param + cy.executeCommand(':clear') + getParamQ = 'RETURN $x' + cy.executeCommand(getParamQ) + cy.waitForCommandResult() + cy.resultContains('2') + // }) + + // it(':param x => 1.0', () => { + // Set param + cy.executeCommand(':clear') + setParamQ = ':param x => 1.0' + cy.executeCommand(setParamQ) + cy.resultContains('"x": 1.0') + + // return param + cy.executeCommand(':clear') + getParamQ = 'RETURN $x' + cy.executeCommand(getParamQ) + cy.waitForCommandResult() + cy.resultContains('1.0') + // }) + + // it(":param x => point({crs: 'wgs-84', latitude: 57.7346, longitude: 12.9082})", () => { + cy.executeCommand(':clear') + let query = + ":param x => point({{}crs: 'wgs-84', latitude: 57.7346, longitude: 12.9082})" + cy.executeCommand(query) + + cy.get('[data-test-id="main"]', { timeout: 20000 }).then(contents => { + // Check for point type support + if ( + contents.find('[data-test-id="errorBanner"]', { timeout: 20000 }).length < + 1 + ) { + cy + .get('[data-test-id="rawParamData"]', { timeout: 20000 }) + .first() + .should('contain', '"x": point({srid:4326, x:12.9082, y:57.7346})') + getParamQ = 'RETURN $x' + cy.executeCommand(getParamQ) + cy.waitForCommandResult() + cy + .get('[data-test-id="rawParamData"]', { timeout: 20000 }) + .first() + .should('contain', 'point({srid:4326, x:12.9082, y:57.7346})') + } else { + cy + .get('[data-test-id="errorBanner"]', { timeout: 20000 }) + .should('contain', 'wgs') + } + }) + // }) +} diff --git a/src/browser/modules/Main/Main.jsx b/src/browser/modules/Main/Main.jsx index e1ea39aa6a8..760fb42c82d 100644 --- a/src/browser/modules/Main/Main.jsx +++ b/src/browser/modules/Main/Main.jsx @@ -39,7 +39,7 @@ import SyncConsentBanner from './SyncConsentBanner' const Main = props => { return ( - + @@ -50,7 +50,9 @@ const Main = props => { - {props.errorMessage} + + {props.errorMessage} + diff --git a/src/browser/modules/Stream/ParamsFrame.jsx b/src/browser/modules/Stream/ParamsFrame.jsx index 3031923a6e2..8d84ed8720a 100644 --- a/src/browser/modules/Stream/ParamsFrame.jsx +++ b/src/browser/modules/Stream/ParamsFrame.jsx @@ -19,16 +19,22 @@ */ import Render from 'browser-components/Render' -import FrameTemplate from './FrameTemplate' import { ExclamationTriangleIcon } from 'browser-components/icons/Icons' import Ellipsis from 'browser-components/Ellipsis' +import { stringFormat } from 'services/bolt/cypherTypesFormatting' +import { stringifyMod } from 'services/utils' +import FrameTemplate from './FrameTemplate' import { PaddedDiv, ErrorText, SuccessText, StyledStatsBar } from './styled' +import { applyGraphTypes } from 'services/bolt/boltMappings' -const ParamsFrame = ({ frame, params }) => { +const ParamsFrame = ({ frame }) => { + const params = applyGraphTypes(frame.params) const contents = ( - {JSON.stringify(frame.params, null, 2)} + + {stringifyMod(params, stringFormat, true)} + ) diff --git a/src/shared/modules/commands/commandsDuck.test.js b/src/shared/modules/commands/commandsDuck.test.js index bc36d22c41f..cd3589b093e 100644 --- a/src/shared/modules/commands/commandsDuck.test.js +++ b/src/shared/modules/commands/commandsDuck.test.js @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -/* global describe, afterEach, test, expect, beforeAll */ +/* global jest, describe, afterEach, test, expect, beforeAll */ import configureMockStore from 'redux-mock-store' import { createEpicMiddleware } from 'redux-observable' import { createBus, createReduxMiddleware } from 'suber' @@ -43,6 +43,10 @@ import { replace as replaceSettings } from 'shared/modules/settings/settingsDuck' import { cleanCommand, getInterpreter } from 'services/commandUtils' +import bolt from 'services/bolt/bolt' + +jest.unmock('services/bolt/bolt') +const originalRoutedWriteTransaction = bolt.routedWriteTransaction const bus = createBus() const epicMiddleware = createEpicMiddleware(commands.handleCommandsEpic) @@ -54,6 +58,9 @@ const mockStore = configureMockStore([ describe('commandsDuck', () => { let store const maxHistory = 20 + beforeEach(() => { + bolt.routedWriteTransaction = originalRoutedWriteTransaction + }) beforeAll(() => { store = mockStore({ settings: { @@ -137,6 +144,7 @@ describe('commandsDuck', () => { const cmdString = cmd + ' x: 2' const id = 1 const action = commands.executeCommand(cmdString, id) + bus.take('NOOP', currentAction => { // Then expect(store.getActions()).toEqual([ @@ -161,6 +169,42 @@ describe('commandsDuck', () => { // Then // See above }) + test('does the right thing for :param x => 2', done => { + // Given + const cmd = store.getState().settings.cmdchar + 'param' + const cmdString = cmd + ' x => 2' + const id = 1 + const action = commands.executeCommand(cmdString, id) + bolt.routedWriteTransaction = jest.fn(() => + Promise.resolve({ + records: [{ get: () => 2 }] + }) + ) + + bus.take('frames/ADD', currentAction => { + // Then + expect(store.getActions()).toEqual([ + action, + addHistory(cmdString, maxHistory), + { type: commands.KNOWN_COMMAND }, + updateParams({ x: 2 }), + { type: 'NOOP' }, + frames.add({ + ...action, + success: true, + type: 'param', + params: { x: 2 } + }) + ]) + done() + }) + + // When + store.dispatch(action) + + // Then + // See above + }) test('does the right thing for :params {x: 2, y: 3}', done => { // Given const cmd = store.getState().settings.cmdchar + 'params' diff --git a/src/shared/modules/commands/helpers/cypher.js b/src/shared/modules/commands/helpers/cypher.js index d80397074a5..b4034d37038 100644 --- a/src/shared/modules/commands/helpers/cypher.js +++ b/src/shared/modules/commands/helpers/cypher.js @@ -19,6 +19,8 @@ */ import bolt from 'services/bolt/bolt' +import { applyGraphTypes } from 'services/bolt/boltMappings' +import { arrayToObject } from 'services/utils' import { send } from 'shared/modules/requests/requestsDuck' export const handleCypherCommand = ( @@ -27,11 +29,18 @@ export const handleCypherCommand = ( params = {}, shouldUseCypherThread = false ) => { - const [id, request] = bolt.routedWriteTransaction(action.cmd, params, { - useCypherThread: shouldUseCypherThread, - requestId: action.requestId, - cancelable: true - }) + const paramsToNeo4jType = Object.keys(params).map(k => ({ + [k]: applyGraphTypes(params[k]) + })) + const [id, request] = bolt.routedWriteTransaction( + action.cmd, + arrayToObject(paramsToNeo4jType), + { + useCypherThread: shouldUseCypherThread, + requestId: action.requestId, + cancelable: true + } + ) put(send('cypher', id)) return [id, request] } diff --git a/src/shared/modules/commands/helpers/params.js b/src/shared/modules/commands/helpers/params.js index 59a459eee69..c6715aa24d2 100644 --- a/src/shared/modules/commands/helpers/params.js +++ b/src/shared/modules/commands/helpers/params.js @@ -18,10 +18,57 @@ * along with this program. If not, see . */ import jsonic from 'jsonic' -import { splitStringOnFirst } from 'services/commandUtils' +import bolt from 'services/bolt/bolt' +import { recursivelyTypeGraphItems } from 'services/bolt/boltMappings' +import { + splitStringOnFirst, + mapParamToCypherStatement +} from 'services/commandUtils' import { update, replace } from 'shared/modules/params/paramsDuck' -export const handleParamsCommand = (action, cmdchar, put, store) => { +export const extractParams = param => { + const matchParam = param.match(/^(".*"|'.*'|\S+)\s?(:|=>)\s(.*)/) + if (!matchParam) return {} + const [, paramName, delimiter, paramValue] = matchParam + try { + const json = + '{' + + paramName + + (paramName.endsWith(':') ? ' ' : ': ') + + paramValue + + '}' + const res = jsonic(json) + const key = Object.keys(res)[0] + const value = res[key] + return { + key, + value, + isFn: delimiter ? delimiter.includes('=>') : false, + originalParamValue: paramValue + } + } catch (e) { + return { + key: paramName, + value: paramValue, + isFn: delimiter ? delimiter.includes('=>') : false, + originalParamValue: paramValue + } + } +} + +const resolveAndStoreJsonValue = (param, put, resolve, reject) => { + try { + const json = `{${param}}` + const res = jsonic(json) + put(update(res)) + return resolve({ result: res, type: 'param' }) + } catch (e) { + return reject( + new Error('Could not parse input. Usage: `:param "x": 2`. ' + e) + ) + } +} +export const handleParamsCommand = (action, cmdchar, put) => { const strippedCmd = action.cmd.substr(cmdchar.length) const parts = splitStringOnFirst(strippedCmd, ' ') const param = parts[1].trim() @@ -41,15 +88,38 @@ export const handleParamsCommand = (action, cmdchar, put, store) => { } } else { // Single param + const { key, value, isFn, originalParamValue } = extractParams(param) + + if (!isFn || !key || !value) { + return resolveAndStoreJsonValue(param, put, resolve, reject) + } try { - const json = '{' + param + '}' - const res = jsonic(json) - put(update(res)) - return resolve({ result: res, type: 'param' }) - } catch (e) { - return reject( - new Error('Could not parse input. Usage: `:param "x": 2`. ' + e) + 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)) } } }) diff --git a/src/shared/modules/commands/helpers/params.test.js b/src/shared/modules/commands/helpers/params.test.js index c45750109c4..f6fc1a32e22 100644 --- a/src/shared/modules/commands/helpers/params.test.js +++ b/src/shared/modules/commands/helpers/params.test.js @@ -23,6 +23,12 @@ import * as params from './params' import { update, replace } from 'shared/modules/params/paramsDuck' +jest.mock('services/bolt/bolt', () => ({ + routedWriteTransaction: jest.fn(() => { + return Promise.resolve({ records: [{ get: () => 2 }] }) + }) +})) + describe('commandsDuck params helper', () => { test('fails on :param x x x and shows error hint', () => { // Given @@ -72,21 +78,6 @@ describe('commandsDuck params helper', () => { expect(put).toHaveBeenCalledWith(update({ x: 2 })) }) }) - test('handles :param "x y": 2 and calls the update action creator', () => { - // Given - const action = { cmd: ':param "x y": 2' } - const cmdchar = ':' - const put = jest.fn() - - // When - const p = params.handleParamsCommand(action, cmdchar, put) - - // Then - return p.then(res => { - expect(res.result).toEqual({ 'x y': 2 }) - expect(put).toHaveBeenCalledWith(update({ 'x y': 2 })) - }) - }) test('handles :params {"hej": "ho", "let\'s": "go"} and calls the replace action creator', () => { // Given const action = { cmd: ':params {"hej": "ho", "let\'s": "go"}' } @@ -117,4 +108,30 @@ describe('commandsDuck params helper', () => { expect(put).toHaveBeenCalledWith(replace({ x: 1, y: 2 })) }) }) + describe('extract key/value from params', () => { + test(': ', () => { + expect(params.extractParams('foo: bar')).toEqual({ + key: 'foo', + value: 'bar', + originalParamValue: 'bar', + isFn: false + }) + }) + test(': ', () => { + expect(params.extractParams('"f o o": bar')).toEqual({ + key: 'f o o', + value: 'bar', + originalParamValue: 'bar', + isFn: false + }) + }) + test('=>', () => { + expect(params.extractParams('foo => 2')).toEqual({ + key: 'foo', + value: 2, + originalParamValue: '2', + isFn: true + }) + }) + }) }) diff --git a/src/shared/modules/params/paramsDuck.js b/src/shared/modules/params/paramsDuck.js index 92aff244a9c..8b17b2151a8 100644 --- a/src/shared/modules/params/paramsDuck.js +++ b/src/shared/modules/params/paramsDuck.js @@ -39,7 +39,7 @@ export default function reducer (state = initialState, action) { case UPDATE: return { ...state, ...action.params } case REPLACE: - return action.params + return { ...action.params } default: return state } diff --git a/src/shared/services/bolt/bolt.js b/src/shared/services/bolt/bolt.js index eff40abd0d1..cd00ffed54b 100644 --- a/src/shared/services/bolt/bolt.js +++ b/src/shared/services/bolt/bolt.js @@ -78,7 +78,7 @@ function routedWriteTransaction (input, parameters, requestMetaData = {}) { const id = requestId || v4() const workFn = runCypherMessage( input, - parameters, + mappings.recursivelyTypeGraphItems(parameters), boltConnection.ROUTED_WRITE_CONNECTION, id, cancelable, @@ -110,7 +110,7 @@ function routedReadTransaction (input, parameters, requestMetaData = {}) { const id = requestId || v4() const workFn = runCypherMessage( input, - parameters, + mappings.recursivelyTypeGraphItems(parameters), boltConnection.ROUTED_READ_CONNECTION, id, cancelable, @@ -142,7 +142,7 @@ function directTransaction (input, parameters, requestMetaData = {}) { const id = requestId || v4() const workFn = runCypherMessage( input, - parameters, + mappings.recursivelyTypeGraphItems(parameters), boltConnection.DIRECT_CONNECTION, id, cancelable, @@ -163,6 +163,22 @@ function directTransaction (input, parameters, requestMetaData = {}) { } } +const addTypesAsField = result => { + const records = result.records.map(record => { + const typedRecord = new neo4j.types.Record( + record.keys, + record._fields, + record._fieldLookup + ) + if (typedRecord._fields) { + typedRecord._fields = mappings.applyGraphTypes(typedRecord._fields) + } + return typedRecord + }) + const summary = mappings.applyGraphTypes(result.summary) + return { summary, records } +} + function setupBoltWorker (id, workFn, onLostConnection = () => {}) { const boltWorker = new BoltWorkerModule() const onFinished = registerBoltWorker(id, boltWorker) @@ -178,20 +194,8 @@ function setupBoltWorker (id, workFn, onLostConnection = () => {}) { onFinished(boltWorker) reject(msg.data.error) } else if (msg.data.type === CYPHER_RESPONSE_MESSAGE) { - let records = msg.data.result.records.map(record => { - const typedRecord = new neo4j.types.Record( - record.keys, - record._fields, - record._fieldLookup - ) - if (typedRecord._fields) { - typedRecord._fields = mappings.applyGraphTypes(typedRecord._fields) - } - return typedRecord - }) - let summary = mappings.applyGraphTypes(msg.data.result.summary) onFinished(boltWorker) - resolve({ summary, records }) + resolve(addTypesAsField(msg.data.result)) } else if (msg.data.type === POST_CANCEL_TRANSACTION_MESSAGE) { onFinished(boltWorker) } @@ -280,5 +284,6 @@ export default { intConverter: val => val.toNumber(), objectConverter: mappings.extractFromNeoObjects }), - neo4j: neo4j + neo4j: neo4j, + addTypesAsField } diff --git a/src/shared/services/bolt/boltMappings.js b/src/shared/services/bolt/boltMappings.js index 7e3aa88b649..cd22e260e7b 100644 --- a/src/shared/services/bolt/boltMappings.js +++ b/src/shared/services/bolt/boltMappings.js @@ -286,14 +286,15 @@ export const flattenProperties = rows => { ) } -export const applyGraphTypes = (item, types = neo4j.types) => { - if (item === null || item === undefined) { - return item - } else if (Array.isArray(item)) { - return item.map(i => applyGraphTypes(i, types)) +export const applyGraphTypes = (rawItem, types = neo4j.types) => { + if (rawItem === null || rawItem === undefined) { + return rawItem + } else if (Array.isArray(rawItem)) { + return rawItem.map(i => applyGraphTypes(i, types)) } else if ( - Object.prototype.hasOwnProperty.call(item, reservedTypePropertyName) + Object.prototype.hasOwnProperty.call(rawItem, reservedTypePropertyName) ) { + const item = { ...rawItem } const className = item[reservedTypePropertyName] const tmpItem = safetlyRemoveObjectProp(item, reservedTypePropertyName) switch (className) { @@ -385,15 +386,15 @@ export const applyGraphTypes = (item, types = neo4j.types) => { default: return item } - } else if (typeof item === 'object') { + } else if (typeof rawItem === 'object') { let typedObject = {} - Object.keys(item).forEach(key => { - typedObject[key] = applyGraphTypes(item[key], types) + Object.keys(rawItem).forEach(key => { + typedObject[key] = applyGraphTypes(rawItem[key], types) }) typedObject = unEscapeReservedProps(typedObject, reservedTypePropertyName) return typedObject } else { - return item + return rawItem } } diff --git a/src/shared/services/bolt/boltWorker.js b/src/shared/services/bolt/boltWorker.js index d553ee296f4..d30298407f7 100644 --- a/src/shared/services/bolt/boltWorker.js +++ b/src/shared/services/bolt/boltWorker.js @@ -39,6 +39,7 @@ import { RUN_CYPHER_MESSAGE, CANCEL_TRANSACTION_MESSAGE } from './boltWorkerMessages' +import { applyGraphTypes } from 'services/bolt/boltMappings' const connectionTypeMap = { [ROUTED_WRITE_CONNECTION]: { @@ -73,7 +74,7 @@ const onmessage = function (message) { .then(() => { const res = connectionTypeMap[connectionType].create( input, - parameters, + applyGraphTypes(parameters), requestId, cancelable ) diff --git a/src/shared/services/commandUtils.js b/src/shared/services/commandUtils.js index 8013a483f8f..c46500459d7 100644 --- a/src/shared/services/commandUtils.js +++ b/src/shared/services/commandUtils.js @@ -94,3 +94,22 @@ export const transformCommandToHelpTopic = inputStr => { .map(prependUnderscore) return res[0] } + +const quotedRegex = /^"(.*)"|'(.*)'/ +const arrowFunctionRegex = /.*=>*.(.*)/ + +export const isArrowFunction = param => arrowFunctionRegex.test(param) + +export const mapParamToCypherStatement = (key, param) => { + const quotedKey = key.match(quotedRegex) + const cleanKey = quotedKey + ? '`' + quotedKey[1] + '`' + : typeof key !== 'string' ? '`' + key + '`' : key + const returnAs = value => `RETURN ${value} as ${cleanKey}` + + const matchParamFunction = param.toString().match(arrowFunctionRegex) + if (matchParamFunction) { + return returnAs(matchParamFunction[1]) + } + return returnAs(param) +} diff --git a/src/shared/services/commandUtils.test.js b/src/shared/services/commandUtils.test.js index d0571b4dda5..ef1fb528ea8 100644 --- a/src/shared/services/commandUtils.test.js +++ b/src/shared/services/commandUtils.test.js @@ -165,4 +165,37 @@ describe('commandutils', () => { expect(utils.transformCommandToHelpTopic(inp.test)).toEqual(inp.expect) }) }) + + describe('mapParamToCypherStatement', () => { + test('should map string to cypher', () => { + expect(utils.mapParamToCypherStatement('foo', '"bar"')).toEqual( + 'RETURN "bar" as foo' + ) + }) + test('should map number to cypher', () => { + expect(utils.mapParamToCypherStatement('foo', '1337')).toEqual( + 'RETURN 1337 as foo' + ) + }) + test('should map fn with string to cypher', () => { + expect(utils.mapParamToCypherStatement('foo', '=> "bar"')).toEqual( + 'RETURN "bar" as foo' + ) + }) + test('should map fn with numbers to cypher', () => { + expect(utils.mapParamToCypherStatement('foo', '=> 1 + 1')).toEqual( + 'RETURN 1 + 1 as foo' + ) + }) + test('should wrap quoted string with backticks', () => { + expect(utils.mapParamToCypherStatement('"f o o"', '=> 1 + 1')).toEqual( + 'RETURN 1 + 1 as `f o o`' + ) + }) + test('should wrap quoted string with backticks', () => { + expect(utils.mapParamToCypherStatement('"f o o"', '=> 1 + 1')).toEqual( + 'RETURN 1 + 1 as `f o o`' + ) + }) + }) }) diff --git a/src/shared/services/utils.js b/src/shared/services/utils.js index 24f71a0a124..d8c143efeb6 100644 --- a/src/shared/services/utils.js +++ b/src/shared/services/utils.js @@ -242,6 +242,14 @@ export const parseTimeMillis = timeWithOrWithoutUnit => { } } +export const arrayToObject = array => + array.reduce((obj, item) => { + const key = Object.keys(item)[0] + const value = Object.values(item)[0] + obj[key] = value + return obj + }, {}) + export const stringifyMod = ( value, modFn = null, diff --git a/src/shared/services/utils.test.js b/src/shared/services/utils.test.js index 654e52dec35..681e006c573 100644 --- a/src/shared/services/utils.test.js +++ b/src/shared/services/utils.test.js @@ -508,4 +508,7 @@ describe('Object props manipulation', () => { expect(res).toEqual({ ...start2 }) }) }) + test('arrayToObject', () => { + expect(utils.arrayToObject([{ foo: 'bar' }])).toEqual({ foo: 'bar' }) + }) })
{JSON.stringify(frame.params, null, 2)}
+ {stringifyMod(params, stringFormat, true)} +