From 93dae1279eb40b4dfae8f2cdafdb7580a711b28d Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Wed, 24 Jun 2020 11:19:41 +0200 Subject: [PATCH 1/2] Fix integer rendering recursively + JSON export Use the existing `stringifyMod` function to create fake numbers out of Neo4j integers (strings witout quotes). Add tests to lock this functionality. --- e2e_tests/integration/types.spec.js | 4 +- src/browser/modules/Frame/FrameTitlebar.jsx | 13 +-- .../modules/Stream/CypherFrame/AsciiView.jsx | 3 +- .../__snapshots__/relatable-view.test.js.snap | 4 +- .../modules/Stream/CypherFrame/helpers.js | 24 +++-- .../Stream/CypherFrame/helpers.test.js | 29 +++-- .../Stream/CypherFrame/relatable-view.jsx | 100 +++++++----------- src/shared/services/utils.js | 14 ++- 8 files changed, 98 insertions(+), 93 deletions(-) diff --git a/e2e_tests/integration/types.spec.js b/e2e_tests/integration/types.spec.js index 52693713aec..561707c8089 100644 --- a/e2e_tests/integration/types.spec.js +++ b/e2e_tests/integration/types.spec.js @@ -32,16 +32,18 @@ describe('Types in Browser', () => { if (Cypress.config('serverVersion') >= 3.4) { it('presents large integers correctly', () => { cy.executeCommand(':clear') - const query = 'RETURN 2467500000 AS bigNumber' + const query = 'RETURN 2467500000 AS bigNumber, {{}x: 9907199254740991}' cy.executeCommand(query) cy.waitForCommandResult() cy.resultContains('2467500000') + cy.resultContains('9907199254740991') // Go to ascii view cy.get('[data-testid="cypherFrameSidebarAscii"]') .first() .click() cy.resultContains('│2467500000') + cy.resultContains('9907199254740991') }) it('presents the point type correctly', () => { cy.executeCommand(':clear') diff --git a/src/browser/modules/Frame/FrameTitlebar.jsx b/src/browser/modules/Frame/FrameTitlebar.jsx index 28c7ae90aea..5b7c42f495c 100644 --- a/src/browser/modules/Frame/FrameTitlebar.jsx +++ b/src/browser/modules/Frame/FrameTitlebar.jsx @@ -67,10 +67,9 @@ import { transformResultRecordsToResultArray, recordToJSONMapper } from 'browser/modules/Stream/CypherFrame/helpers' -import { csvFormat } from 'services/bolt/cypherTypesFormatting' +import { csvFormat, stringModifier } from 'services/bolt/cypherTypesFormatting' import arrayHasItems from 'shared/utils/array-has-items' - -const JSON_EXPORT_INDENT = 2 +import { stringifyMod } from 'services/utils' class FrameTitlebar extends Component { hasData() { @@ -115,15 +114,11 @@ class FrameTitlebar extends Component { } exportJSON(records) { - const data = JSON.stringify( - map(records, recordToJSONMapper), - null, - JSON_EXPORT_INDENT - ) + const exportData = map(records, recordToJSONMapper) + const data = stringifyMod(exportData, stringModifier, true) const blob = new Blob([data], { type: 'text/plain;charset=utf-8' }) - saveAs(blob, 'records.json') } diff --git a/src/browser/modules/Stream/CypherFrame/AsciiView.jsx b/src/browser/modules/Stream/CypherFrame/AsciiView.jsx index 5cd11e03ab9..cb94ed9161d 100644 --- a/src/browser/modules/Stream/CypherFrame/AsciiView.jsx +++ b/src/browser/modules/Stream/CypherFrame/AsciiView.jsx @@ -87,7 +87,8 @@ export class AsciiViewComponent extends Component { const serializedRows = stringifyResultArray( stringModifier, - transformResultRecordsToResultArray(records, maxFieldItems) + transformResultRecordsToResultArray(records, maxFieldItems), + true ) || [] this.setState({ serializedRows }) const maxColWidth = asciitable.maxColumnWidth(serializedRows) diff --git a/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap b/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap index 8efe1ad00e6..110d6ad84ea 100644 --- a/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap +++ b/src/browser/modules/Stream/CypherFrame/__snapshots__/relatable-view.test.js.snap @@ -72,7 +72,9 @@ exports[`RelatableViews RelatableView does not display bodyMessage if rows, and class="relatable__table-cell relatable__table-body-cell" role="cell" > - "String with HTML <strong>in</strong> it" + + "String with HTML <strong>in</strong> it" + diff --git a/src/browser/modules/Stream/CypherFrame/helpers.js b/src/browser/modules/Stream/CypherFrame/helpers.js index e3fda0b27e8..afef517f681 100644 --- a/src/browser/modules/Stream/CypherFrame/helpers.js +++ b/src/browser/modules/Stream/CypherFrame/helpers.js @@ -37,7 +37,7 @@ import bolt from 'services/bolt/bolt' import * as viewTypes from 'shared/modules/stream/frameViewTypes' import { recursivelyExtractGraphItems } from 'services/bolt/boltMappings' -import { stringifyMod } from 'services/utils' +import { stringifyMod, unescapeDoubleQuotesForDisplay } from 'services/utils' import { stringModifier } from 'services/bolt/cypherTypesFormatting' /** @@ -226,11 +226,16 @@ export const initialView = (props, state = {}) => { * It takes a replacer without enforcing quoting rules to it. * Used so we can have Neo4j integers as string without quotes. */ -export const stringifyResultArray = (formatter = stringModifier, arr = []) => { +export const stringifyResultArray = ( + formatter = stringModifier, + arr = [], + unescapeDoulbeQuotes = false +) => { return arr.map(col => { if (!col) return col return col.map(fVal => { - return stringifyMod(fVal, formatter) + const res = stringifyMod(fVal, formatter) + return unescapeDoulbeQuotes ? unescapeDoubleQuotesForDisplay(res) : res }) }) } @@ -353,13 +358,16 @@ const arrayifyPath = (types = neo4j.types, path) => { /** * Converts a raw Neo4j record into a JSON friendly format, mimicking APOC output + * Note: This preservers Neo4j integers as objects because they can't be guaranteed + * to be converted to numbers and keeping the precision. + * It's up to the serializer to indentify those and write them as fake numbers (strings without quotes) * @param {Record} record * @return {*} */ export function recordToJSONMapper(record) { const keys = get(record, 'keys', []) - return reduce( + const recordObj = reduce( keys, (agg, key) => { const field = record.get(key) @@ -371,6 +379,7 @@ export function recordToJSONMapper(record) { }, {} ) + return recordObj } /** @@ -379,6 +388,10 @@ export function recordToJSONMapper(record) { * @return {*} */ export function mapNeo4jValuesToPlainValues(values) { + if (neo4j.isInt(values)) { + return values + } + if (!isObjectLike(values)) { return values } @@ -425,8 +438,6 @@ function neo4jValueToPlainValue(value) { case neo4j.types.LocalTime: case neo4j.types.Time: return value.toString() - case neo4j.types.Integer: // not exposed in typings but still there - return value.inSafeRange() ? value.toInt() : value.toNumber() default: return value } @@ -445,7 +456,6 @@ function isNeo4jValue(value) { case neo4j.types.LocalDateTime: case neo4j.types.LocalTime: case neo4j.types.Time: - case neo4j.types.Integer: // not exposed in typings but still there return true default: return false diff --git a/src/browser/modules/Stream/CypherFrame/helpers.test.js b/src/browser/modules/Stream/CypherFrame/helpers.test.js index e76b66f5182..aeb18ebeb57 100644 --- a/src/browser/modules/Stream/CypherFrame/helpers.test.js +++ b/src/browser/modules/Stream/CypherFrame/helpers.test.js @@ -639,7 +639,7 @@ describe('helpers', () => { neo4j.isInt, step1 ) - const res = stringifyResultArray(stringModifier, step2) + const res = stringifyResultArray(stringModifier, step2, true) // Then expect(res).toEqual([ ['""neoInt""', '""int""', '""any""', '""backslash""'], @@ -713,7 +713,7 @@ describe('helpers', () => { neo4j.isInt, step1 ) - const res = stringifyResultArray(stringModifier, step2) + const res = stringifyResultArray(stringModifier, step2, true) // Then expect(res).toEqual([ ['""x""', '""y""', '""n""'], @@ -730,7 +730,11 @@ describe('helpers', () => { describe('recordToJSONMapper', () => { describe('Nodes', () => { test('handles integer values', () => { - const node = new neo4j.types.Node(1, ['foo'], { bar: new neo4j.int(3) }) + const node = new neo4j.types.Node(1, ['foo'], { + bar: new neo4j.int(3), + baz: new neo4j.int(1416268800000), + bax: new neo4j.int(9907199254740991) // Larger than Number.MAX_SAFE_INTEGER, but still in 64 bit int range + }) const record = new neo4j.types.Record(['n'], [node]) const expected = { n: { @@ -738,7 +742,9 @@ describe('helpers', () => { elementType: 'node', labels: ['foo'], properties: { - bar: 3 + bar: new neo4j.int(3), + baz: new neo4j.int(1416268800000), + bax: new neo4j.int(9907199254740991) } } } @@ -911,7 +917,7 @@ describe('helpers', () => { elementType: 'relationship', type: 'foo', properties: { - bar: 3 + bar: new neo4j.int(3) } } } @@ -1109,7 +1115,7 @@ describe('helpers', () => { elementType: 'node', labels: ['foo'], properties: { - bar: 3 + bar: new neo4j.int(3) } }, r1: { @@ -1159,7 +1165,7 @@ describe('helpers', () => { elementType: 'node', labels: ['foo'], properties: { - bar: 3 + bar: new neo4j.int(3) } }, end: { @@ -1177,7 +1183,7 @@ describe('helpers', () => { elementType: 'node', labels: ['foo'], properties: { - bar: 3 + bar: new neo4j.int(3) } }, relationship: { @@ -1224,7 +1230,12 @@ describe('helpers', () => { ) const expected = { foo: { - data: [1, 'car', { srid: 1, x: 10, y: 5, z: 15 }, '1970-01-01'] + data: [ + new neo4j.int(1), + 'car', + { srid: 1, x: 10, y: 5, z: 15 }, + '1970-01-01' + ] } } diff --git a/src/browser/modules/Stream/CypherFrame/relatable-view.jsx b/src/browser/modules/Stream/CypherFrame/relatable-view.jsx index 1d036b7d355..82f715e7105 100644 --- a/src/browser/modules/Stream/CypherFrame/relatable-view.jsx +++ b/src/browser/modules/Stream/CypherFrame/relatable-view.jsx @@ -15,25 +15,16 @@ * */ -import React, { useCallback, useMemo } from 'react' +import React, { useMemo } from 'react' +import { isInt } from 'neo4j-driver' import Relatable from '@relate-by-ui/relatable' -import { - entries, - filter, - get, - head, - join, - map, - memoize, - slice -} from 'lodash-es' +import { get, head, map, slice } from 'lodash-es' import { Icon } from 'semantic-ui-react' import { connect } from 'react-redux' import { HTMLEntities } from 'services/santize.utils' import { getBodyAndStatusBarMessages, - mapNeo4jValuesToPlainValues, resultHasTruncatedFields } from './helpers' import arrayHasItems from 'shared/utils/array-has-items' @@ -41,14 +32,14 @@ import { getMaxFieldItems, getMaxRows } from 'shared/modules/settings/settingsDuck' - +import { stringModifier } from 'services/bolt/cypherTypesFormatting' import ClickableUrls, { convertUrlsToHrefTags } from '../../../components/clickable-urls' import { StyledStatsBar, StyledTruncatedMessage } from '../styled' import Ellipsis from '../../../components/Ellipsis' import { RelatableStyleWrapper, StyledJsonPre } from './relatable-view.styled' -import { isPoint, isInt } from 'neo4j-driver' +import { stringifyMod, unescapeDoubleQuotesForDisplay } from 'services/utils' const RelatableView = connect(state => ({ maxRows: getMaxRows(state), @@ -94,57 +85,46 @@ function getColumns(records, maxFieldItems) { function CypherCell({ cell }) { const { value } = cell - const mapper = useCallback( - value => { - const memo = memoize(mapNeo4jValuesToPlainValues, value => { - const { elementType, identity } = value || {} - - return elementType ? `${elementType}:${identity}` : identity - }) - - return memo(value) - }, - [memoize, mapNeo4jValuesToPlainValues] - ) - const mapped = mapper(value) - - if (isInt(value)) { - return value.toString() - } - - if (Number.isInteger(value)) { - return `${value}.0` - } - - if (typeof mapped === 'string') { - return `"${mapped}"` - } - - if (isPoint(value)) { - const pairs = filter( - entries(mapped), - ([, val]) => val !== null && val !== undefined - ) - - return `point({${join( - map(pairs, pair => join(pair, ':')), - ', ' - )}})` - } + return renderCell(value) +} - if (mapped && typeof mapped === 'object') { +const renderCell = entry => { + if (Array.isArray(entry)) { + const children = entry.map((item, index) => ( + + {renderCell(item)} + {index === entry.length - 1 ? null : ', '} + + )) + return [{children}] + } else if (typeof entry === 'object') { + return renderObject(entry) + } else { return ( - ) } - - return +} +const renderObject = entry => { + if (isInt(entry)) return entry.toString() + if (entry === null) return null + return ( + + ) } export function RelatableBodyMessage({ maxRows, result }) { diff --git a/src/shared/services/utils.js b/src/shared/services/utils.js index c870df9cd8f..4d1cb0e7353 100644 --- a/src/shared/services/utils.js +++ b/src/shared/services/utils.js @@ -328,6 +328,10 @@ export const stringifyMod = ( const newLine = prettyLevel ? '\n' : '' const indentation = prettyLevel && !skipOpeningIndentation ? Array(prettyLevel).join(' ') : '' + const nextIndentation = + nextPrettyLevel && !skipOpeningIndentation + ? Array(nextPrettyLevel).join(' ') + : '' const endIndentation = prettyLevel ? Array(prettyLevel).join(' ') : '' const propSpacing = prettyLevel ? ' ' : '' const toString = Object.prototype.toString @@ -337,7 +341,7 @@ export const stringifyMod = ( return toString.call(a) === '[object Array]' } const escMap = { - '"': '"', + '"': '\\"', '\\': '\\', '\b': '\b', '\f': '\f', @@ -381,10 +385,8 @@ export const stringifyMod = ( for (const k in value) { if (value.hasOwnProperty(k)) { tmp.push( - `${stringifyMod( - k, - modFn, - nextPrettyLevel + `${nextIndentation}${JSON.stringify( + k )}:${propSpacing}${stringifyMod( value[k], modFn, @@ -402,6 +404,8 @@ export const stringifyMod = ( return `${indentation}"${value.toString().replace(escRE, escFunc)}"` } +export const unescapeDoubleQuotesForDisplay = str => str.replace(/\\"/g, '"') + export const safetlyAddObjectProp = (obj, prop, val) => { const localObj = escapeReservedProps(obj, prop) localObj[prop] = val From af9bedeeac7900fa7b4860152f4e2c50591d008f Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Thu, 25 Jun 2020 15:33:37 +0200 Subject: [PATCH 2/2] Fix spelling issues --- src/browser/modules/Stream/CypherFrame/helpers.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/modules/Stream/CypherFrame/helpers.js b/src/browser/modules/Stream/CypherFrame/helpers.js index afef517f681..4965d7c9bd0 100644 --- a/src/browser/modules/Stream/CypherFrame/helpers.js +++ b/src/browser/modules/Stream/CypherFrame/helpers.js @@ -229,13 +229,13 @@ export const initialView = (props, state = {}) => { export const stringifyResultArray = ( formatter = stringModifier, arr = [], - unescapeDoulbeQuotes = false + unescapeDoubleQuotes = false ) => { return arr.map(col => { if (!col) return col return col.map(fVal => { const res = stringifyMod(fVal, formatter) - return unescapeDoulbeQuotes ? unescapeDoubleQuotesForDisplay(res) : res + return unescapeDoubleQuotes ? unescapeDoubleQuotesForDisplay(res) : res }) }) } @@ -358,9 +358,9 @@ const arrayifyPath = (types = neo4j.types, path) => { /** * Converts a raw Neo4j record into a JSON friendly format, mimicking APOC output - * Note: This preservers Neo4j integers as objects because they can't be guaranteed + * Note: This preserves Neo4j integers as objects because they can't be guaranteed * to be converted to numbers and keeping the precision. - * It's up to the serializer to indentify those and write them as fake numbers (strings without quotes) + * It's up to the serializer to identify them and write them as fake numbers (strings without quotes) * @param {Record} record * @return {*} */