Skip to content

Commit 0a0afc1

Browse files
authored
Merge pull request #619 from oskarhane/3.0-handle-integers-in-frame
Fix display of Neo4j integers in Cypher result frames
2 parents 1dfb1e7 + 73b8cad commit 0a0afc1

File tree

9 files changed

+329
-66
lines changed

9 files changed

+329
-66
lines changed

__mocks__/neo4j.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@
1818
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1919
*/
2020

21+
function integerFn (val) {
22+
this.val = val
23+
}
24+
integerFn.prototype.toString = function () {
25+
return this.val.toString()
26+
}
27+
2128
var out = {
2229
v1: {
23-
isInt: function () {
24-
return false
30+
Int: integerFn,
31+
isInt: function (val) {
32+
return val instanceof integerFn
2533
},
2634
types: {
2735
Node: function Node (id, labels, properties) {

src/browser/modules/Stream/CypherFrame/AsciiView.jsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,12 @@
2020

2121
import { Component } from 'preact'
2222
import asciitable from 'ascii-data-table'
23-
import bolt from 'services/bolt/bolt'
23+
import { v1 as neo4j } from 'neo4j-driver-alias'
2424
import Render from 'browser-components/Render'
2525
import Ellipsis from 'browser-components/Ellipsis'
2626
import { debounce, shallowEquals, deepEquals } from 'services/utils'
2727
import { StyledStatsBar, PaddedDiv, StyledBodyMessage, StyledRightPartial, StyledWidthSliderContainer, StyledWidthSlider } from '../styled'
28-
import { getBodyAndStatusBarMessages, getRecordsToDisplayInTable } from './helpers'
29-
30-
const toTable = (records) => records && records.length ? bolt.recordsToTableArray(records) : undefined
31-
const stringify = bolt.stringifyRows || undefined
28+
import { getBodyAndStatusBarMessages, getRecordsToDisplayInTable, transformResultRecordsToResultArray, stringifyResultArray } from './helpers'
3229

3330
export class AsciiView extends Component {
3431
constructor (props) {
@@ -68,7 +65,7 @@ export class AsciiView extends Component {
6865
this.setState({ bodyMessage })
6966
if (!result || !result.records) return
7067
const records = getRecordsToDisplayInTable(props.result, props.maxRows)
71-
const serializedRows = stringify(toTable(records))
68+
const serializedRows = stringifyResultArray(neo4j.isInt, transformResultRecordsToResultArray(records)) || []
7269
this.setState({ serializedRows })
7370
this.props.setParentState && this.props.setParentState({ _asciiSerializedRows: serializedRows })
7471
}

src/browser/modules/Stream/CypherFrame/TableView.jsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import { v4 } from 'uuid'
2323
import { StyledStatsBar, PaddedTableViewDiv, StyledBodyMessage } from '../styled'
2424
import Ellipsis from 'browser-components/Ellipsis'
2525
import {StyledTable, StyledBodyTr, StyledTh, StyledTd, StyledJsonPre} from 'browser-components/DataTables'
26-
import bolt from 'services/bolt/bolt'
27-
import { deepEquals, shallowEquals } from 'services/utils'
28-
import { getBodyAndStatusBarMessages, getRecordsToDisplayInTable } from './helpers'
26+
import { deepEquals, shallowEquals, stringifyMod } from 'services/utils'
27+
import { v1 as neo4j } from 'neo4j-driver-alias'
28+
import { getBodyAndStatusBarMessages, getRecordsToDisplayInTable, transformResultRecordsToResultArray } from './helpers'
2929

30-
const toTable = (records) => records && records.length ? bolt.recordsToTableArray(records) : undefined
30+
const intToString = (val) => {
31+
if (neo4j.isInt(val)) return val.toString()
32+
}
3133

3234
const renderCell = (entry) => {
3335
if (Array.isArray(entry)) {
@@ -36,14 +38,15 @@ const renderCell = (entry) => {
3638
} else if (typeof entry === 'object') {
3739
return renderObject(entry)
3840
} else {
39-
return JSON.stringify(entry)
41+
return stringifyMod(entry, intToString, true)
4042
}
4143
}
4244
const renderObject = (entry) => {
45+
if (neo4j.isInt(entry)) return entry.toString()
4346
if (Object.keys(entry).length === 0 && entry.constructor === Object) {
4447
return <em>(empty)</em>
4548
} else {
46-
return <StyledJsonPre>{JSON.stringify(entry, null, 2)}</StyledJsonPre>
49+
return <StyledJsonPre>{stringifyMod(entry, intToString, true)}</StyledJsonPre>
4750
}
4851
}
4952
const buildData = (entries) => {
@@ -87,7 +90,7 @@ export class TableView extends Component {
8790
}
8891
makeState (props) {
8992
const records = getRecordsToDisplayInTable(props.result, props.maxRows)
90-
const table = toTable(records) || []
93+
const table = transformResultRecordsToResultArray(records) || []
9194
const data = table ? table.slice() : []
9295
const columns = data.length > 0 ? data.shift() : []
9396
const { bodyMessage } = getBodyAndStatusBarMessages(props.result, props.maxRows)

src/browser/modules/Stream/CypherFrame/helpers.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
*/
2020

2121
import bolt from 'services/bolt/bolt'
22+
import { v1 as neo4j } from 'neo4j-driver-alias'
2223
import * as viewTypes from 'shared/modules/stream/frameViewTypes'
2324
import { recursivelyExtractGraphItems, flattenArray } from 'services/bolt/boltMappings'
25+
import { stringifyMod } from 'services/utils'
2426

2527
export function getBodyAndStatusBarMessages (result, maxRows) {
2628
if (!result || !result.summary || !result.summary.resultAvailableAfter) {
@@ -120,3 +122,102 @@ export const initialView = (props, state = {}) => {
120122
if (resultHasNodes(props.request)) return viewTypes.VISUALIZATION
121123
return viewTypes.TABLE
122124
}
125+
126+
/**
127+
* Takes an array of objects and stringifies it using a
128+
* modified version of JSON.stringify.
129+
* It takes a replacer without enforcing quoting rules to it.
130+
* Used so we can have Neo4j integers as string without quotes.
131+
*/
132+
export const stringifyResultArray = (intChecker = neo4j.isInt, arr = []) => {
133+
return arr.map((col) => {
134+
if (!col) return col
135+
return col.map((fVal) => {
136+
return stringifyMod(fVal, (val) => {
137+
if (intChecker(val)) return val.toString()
138+
})
139+
})
140+
})
141+
}
142+
143+
/**
144+
* Transformes an array of neo4j driver records to an array of objects.
145+
* Flattens graph items so only their props are left.
146+
* Leaves Neo4j Integers as they were.
147+
*/
148+
export const transformResultRecordsToResultArray = (records) => {
149+
return records && records.length
150+
? [records]
151+
.map(extractRecordsToResultArray)
152+
.map(flattenGraphItemsInResultArray.bind(null, neo4j.types, neo4j.isInt))[0]
153+
: undefined
154+
}
155+
156+
/**
157+
* Transformes an array of neo4j driver records to an array of objects.
158+
* Leaves all values as they were, just changing the data structure.
159+
*/
160+
export const extractRecordsToResultArray = (records = []) => {
161+
records = Array.isArray(records) ? records : []
162+
const keys = records[0] ? [records[0].keys] : undefined
163+
return (keys || []).concat(
164+
records.map((record) => {
165+
return record.keys.map((key, i) => record._fields[i])
166+
})
167+
)
168+
}
169+
170+
export const flattenGraphItemsInResultArray = (types = neo4j.types, intChecker = neo4j.isInt, result = []) => {
171+
return result.map(flattenGraphItems.bind(null, types, intChecker))
172+
}
173+
174+
/**
175+
* Recursively looks for graph items and elevates their properties if found.
176+
* Leaves everything else (including neo4j integers) as is
177+
*/
178+
export const flattenGraphItems = (types = neo4j.types, intChecker = neo4j.isInt, item) => {
179+
if (Array.isArray(item)) return item.map(flattenGraphItems.bind(null, types, intChecker))
180+
if (typeof item === 'object' && item !== null && !isGraphItem(types, item) && !intChecker(item)) {
181+
let out = {}
182+
const keys = Object.keys(item)
183+
for (let i = 0; i < keys.length; i++) {
184+
out[keys[i]] = flattenGraphItems(types, intChecker, item[keys[i]])
185+
}
186+
return out
187+
}
188+
if (isGraphItem(types, item)) return extractPropertiesFromGraphItems(types, item)
189+
return item
190+
}
191+
192+
export const isGraphItem = (types = neo4j.types, item) => {
193+
return (
194+
item instanceof types.Node ||
195+
item instanceof types.Relationship ||
196+
item instanceof types.Path ||
197+
item instanceof types.PathSegment
198+
)
199+
}
200+
201+
export function extractPropertiesFromGraphItems (types = neo4j.types, obj) {
202+
if (obj instanceof types.Node || obj instanceof types.Relationship) {
203+
return obj.properties
204+
} else if (obj instanceof types.Path) {
205+
return [].concat.apply([], arrayifyPath(types, obj))
206+
}
207+
return obj
208+
}
209+
210+
const arrayifyPath = (types = neo4j.types, path) => {
211+
let segments = path.segments
212+
// Zero length path. No relationship, end === start
213+
if (!Array.isArray(path.segments) || path.segments.length < 1) {
214+
segments = [{...path, end: null}]
215+
}
216+
return segments.map(function (segment) {
217+
return [
218+
extractPropertiesFromGraphItems(types, segment.start),
219+
extractPropertiesFromGraphItems(types, segment.relationship),
220+
extractPropertiesFromGraphItems(types, segment.end)
221+
].filter((part) => part !== null)
222+
})
223+
}

src/browser/modules/Stream/CypherFrame/helpers.test.js

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import {
2828
resultHasPlan,
2929
resultIsError,
3030
getRecordsToDisplayInTable,
31-
initialView
31+
initialView,
32+
extractRecordsToResultArray,
33+
flattenGraphItemsInResultArray,
34+
stringifyResultArray
3235
} from './helpers'
3336

3437
describe('helpers', () => {
@@ -474,4 +477,129 @@ describe('helpers', () => {
474477
expect(view).toEqual(viewTypes.TABLE)
475478
})
476479
})
480+
describe('record transformations', () => {
481+
test('extractRecordsToResultArray handles empty records', () => {
482+
// Given
483+
const records = null
484+
485+
// When
486+
const res = extractRecordsToResultArray(records)
487+
488+
// Then
489+
expect(res).toEqual([])
490+
})
491+
test('extractRecordsToResultArray handles regular records', () => {
492+
// Given
493+
const start = new neo4j.types.Node(1, ['X'], {x: 1})
494+
const end = new neo4j.types.Node(2, ['Y'], {y: new neo4j.Int(1)})
495+
const rel = new neo4j.types.Relationship(3, 1, 2, 'REL', {rel: 1})
496+
const segments = [new neo4j.types.PathSegment(start, rel, end)]
497+
const path = new neo4j.types.Path(start, end, segments)
498+
499+
const records = [
500+
{
501+
keys: ['"x"', '"y"', '"n"'],
502+
_fields: ['x', 'y', new neo4j.types.Node('1', ['Person'], {prop1: 'prop1'})]
503+
},
504+
{
505+
keys: ['"x"', '"y"', '"n"'],
506+
_fields: ['xx', 'yy', path]
507+
}
508+
]
509+
510+
// When
511+
const res = extractRecordsToResultArray(records)
512+
513+
// Then
514+
expect(res).toEqual([
515+
['"x"', '"y"', '"n"'],
516+
['x', 'y', new neo4j.types.Node('1', ['Person'], {prop1: 'prop1'})],
517+
['xx', 'yy', path]
518+
])
519+
})
520+
test('flattenGraphItemsInResultArray extracts props from graph items', () => {
521+
// Given
522+
const start = new neo4j.types.Node(1, ['X'], {x: 1})
523+
const end = new neo4j.types.Node(2, ['Y'], {y: 1})
524+
const rel = new neo4j.types.Relationship(3, 1, 2, 'REL', {rel: 1})
525+
const segments = [new neo4j.types.PathSegment(start, rel, end)]
526+
const path = new neo4j.types.Path(start, end, segments)
527+
528+
const records = [
529+
{
530+
keys: ['"x"', '"y"', '"n"'],
531+
_fields: ['x', 'y', new neo4j.types.Node('1', ['Person'], {prop1: 'prop1'})]
532+
},
533+
{
534+
keys: ['"x"', '"y"', '"n"'],
535+
_fields: ['xx', 'yy', {prop: path}]
536+
}
537+
]
538+
539+
// When
540+
const step1 = extractRecordsToResultArray(records)
541+
const res = flattenGraphItemsInResultArray(neo4j.types, neo4j.isInt, step1)
542+
543+
// Then
544+
expect(res).toEqual([
545+
['"x"', '"y"', '"n"'],
546+
['x', 'y', {prop1: 'prop1'}],
547+
['xx', 'yy', {prop: [{x: 1}, {rel: 1}, {y: 1}]}]
548+
])
549+
})
550+
test('stringifyResultArray uses stringifyMod to serialize', () => {
551+
// Given
552+
const records = [
553+
{
554+
keys: ['"neoInt"', '"int"', '"any"'],
555+
_fields: [new neo4j.Int('882573709873217509'), 100, 0.5]
556+
},
557+
{
558+
keys: ['"neoInt"', '"int"', '"any"'],
559+
_fields: [new neo4j.Int(300), 100, 'string']
560+
}
561+
]
562+
563+
// When
564+
const step1 = extractRecordsToResultArray(records)
565+
const step2 = flattenGraphItemsInResultArray(neo4j.types, neo4j.isInt, step1)
566+
const res = stringifyResultArray(neo4j.isInt, step2)
567+
// Then
568+
expect(res).toEqual([
569+
[JSON.stringify('"neoInt"'), JSON.stringify('"int"'), JSON.stringify('"any"')],
570+
['882573709873217509', '100', '0.5'],
571+
['300', '100', '"string"']
572+
])
573+
})
574+
test('stringifyResultArray handles neo4j integers nested within graph items', () => {
575+
// Given
576+
const start = new neo4j.types.Node(1, ['X'], {x: 1})
577+
const end = new neo4j.types.Node(2, ['Y'], {y: new neo4j.Int(2)}) // <-- Neo4j integer
578+
const rel = new neo4j.types.Relationship(3, 1, 2, 'REL', {rel: 1})
579+
const segments = [new neo4j.types.PathSegment(start, rel, end)]
580+
const path = new neo4j.types.Path(start, end, segments)
581+
582+
const records = [
583+
{
584+
keys: ['"x"', '"y"', '"n"'],
585+
_fields: ['x', 'y', new neo4j.types.Node('1', ['Person'], {prop1: 'prop1'})]
586+
},
587+
{
588+
keys: ['"x"', '"y"', '"n"'],
589+
_fields: ['xx', 'yy', {prop: path}]
590+
}
591+
]
592+
593+
// When
594+
const step1 = extractRecordsToResultArray(records)
595+
const step2 = flattenGraphItemsInResultArray(neo4j.types, neo4j.isInt, step1)
596+
const res = stringifyResultArray(neo4j.isInt, step2)
597+
// Then
598+
expect(res).toEqual([
599+
[JSON.stringify('"x"'), JSON.stringify('"y"'), JSON.stringify('"n"')],
600+
['"x"', '"y"', JSON.stringify({prop1: 'prop1'})],
601+
['"xx"', '"yy"', JSON.stringify({prop: [{x: 1}, {rel: 1}, {y: 2}]})] // <--
602+
])
603+
})
604+
})
477605
})

src/browser/modules/Stream/CypherFrame/index.jsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { Component } from 'preact'
2323
import FrameTemplate from '../FrameTemplate'
2424
import { CypherFrameButton } from 'browser-components/buttons'
2525
import Centered from 'browser-components/Centered'
26-
import bolt from 'services/bolt/bolt'
26+
import { v1 as neo4j } from 'neo4j-driver-alias'
2727
import { deepEquals } from 'services/utils'
2828
import FrameSidebar from '../FrameSidebar'
2929
import { VisualizationIcon, TableIcon, AsciiIcon, CodeIcon, PlanIcon, AlertIcon, ErrorIcon, Spinner } from 'browser-components/icons/Icons'
@@ -37,7 +37,7 @@ import { VisualizationConnectedBus } from './VisualizationView'
3737
import Render from 'browser-components/Render'
3838
import Display from 'browser-components/Display'
3939
import * as viewTypes from 'shared/modules/stream/frameViewTypes'
40-
import { resultHasRows, resultHasWarnings, resultHasPlan, resultIsError, resultHasNodes, initialView } from './helpers'
40+
import { resultHasRows, resultHasWarnings, resultHasPlan, resultIsError, resultHasNodes, initialView, transformResultRecordsToResultArray, stringifyResultArray } from './helpers'
4141
import { StyledFrameBody, SpinnerContainer, StyledStatsBarContainer } from '../styled'
4242
import { getMaxRows, getInitialNodeDisplay, getMaxNeighbours, shouldAutoComplete } from 'shared/modules/settings/settingsDuck'
4343
import { setRecentView, getRecentView } from 'shared/modules/stream/streamDuck'
@@ -50,6 +50,9 @@ export class CypherFrame extends Component {
5050
exportData: null
5151
}
5252
}
53+
makeExportData (records) {
54+
return stringifyResultArray(neo4j.isInt, transformResultRecordsToResultArray(records))
55+
}
5356
changeView (view) {
5457
this.setState({openView: view})
5558
if (this.props.onRecentViewChanged) {
@@ -74,7 +77,7 @@ export class CypherFrame extends Component {
7477
}
7578
if (this.props.request === undefined || !deepEquals(props.request.result, this.props.request.result)) {
7679
if (props.request.result && props.request.result.records && props.request.result.records.length) {
77-
newState['exportData'] = bolt.recordsToTableArray(props.request.result.records)
80+
newState['exportData'] = this.makeExportData(props.request.result.records)
7881
} else {
7982
newState['exportData'] = null
8083
}
@@ -85,7 +88,7 @@ export class CypherFrame extends Component {
8588
const view = initialView(this.props, this.state)
8689
if (view) this.setState({ openView: view })
8790
if (this.props.request && this.props.request.result && this.props.request.result.records && this.props.request.result.records.length) {
88-
this.setState({ exportData: bolt.recordsToTableArray(this.props.request.result.records) })
91+
this.setState({ exportData: this.makeExportData(this.props.request.result.records) })
8992
}
9093
}
9194
sidebar () {

0 commit comments

Comments
 (0)