-
Notifications
You must be signed in to change notification settings - Fork 133
Expand file tree
/
Copy pathGraphDatabase.coffee
More file actions
652 lines (520 loc) · 23.1 KB
/
GraphDatabase.coffee
File metadata and controls
652 lines (520 loc) · 23.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
$ = require 'underscore'
assert = require 'assert'
Constraint = require './Constraint'
{Error} = require './errors'
Index = require './Index'
lib = require '../package.json'
Node = require './Node'
Relationship = require './Relationship'
Request = require 'request'
Transaction = require './Transaction'
URL = require 'url'
module.exports = class GraphDatabase
## CORE
# Default HTTP headers:
headers:
'User-Agent': "node-neo4j/#{lib.version}"
constructor: (opts={}) ->
if typeof opts is 'string'
opts = {url: opts}
{@url, @auth, @headers, @proxy, @agent} = opts
if not @url
throw new TypeError 'URL to Neo4j required'
# Process auth, whether through option or URL creds or both.
# Option takes precedence, and we clear the URL creds if option given.
uri = URL.parse @url
if uri.auth and @auth?
delete uri.auth
@url = URL.format uri
# We also normalize any given auth to an object or null:
@auth = _normalizeAuth @auth ? uri.auth
# Extend the given headers with our defaults, but clone first:
# TODO: Do we want to special-case User-Agent? Or reject if includes
# reserved headers like Accept, Content-Type, X-Stream?
@headers or= {}
@headers = $(@headers)
.chain()
.clone()
.defaults @constructor::headers
.value()
## HTTP
http: (opts={}, cb) ->
if typeof opts is 'string'
opts = {path: opts}
{method, path, headers, body, raw} = opts
if not path
throw new TypeError 'Path required'
method or= 'GET'
headers or= {}
# Extend the given headers, both with both required and optional
# defaults, but do so without modifying the input object:
headers = $(headers)
.chain()
.clone()
.defaults @headers # These headers can be overridden...
.extend # ...while these can't.
'X-Stream': 'true'
.value()
# TODO: Would be good to test custom proxy and agent, but difficult.
# Same with Neo4j returning gzipped responses (e.g. through an LB).
Request
method: method
url: URL.resolve @url, path
proxy: @proxy
auth: @auth
headers: headers
agent: @agent
json: body ? true
encoding: 'utf8'
gzip: true # This is only for responses: decode if gzipped.
# Important: only pass a callback to Request if a callback was passed
# to us. This prevents Request from buffering the response in memory
# (to parse JSON) if the caller prefers to stream the response instead.
, cb and (err, resp) =>
if err
# TODO: Do we want to wrap or modify native errors?
return cb err
if raw
# TODO: Do we want to return our own Response object?
return cb null, resp
if err = Error._fromResponse resp
return cb err
cb null, _transform resp.body
## AUTH
checkPasswordChangeNeeded: (cb) ->
if not @auth?.username
throw new TypeError 'No `auth` specified in constructor!'
@http
method: 'GET'
path: "/user/#{encodeURIComponent @auth.username}"
, (err, user) ->
if err
return cb err
cb null, user.password_change_required
changePassword: (opts={}, cb) ->
if typeof opts is 'string'
opts = {password: opts}
{password} = opts
if not @auth?.username
throw new TypeError 'No `auth` specified in constructor!'
if not password
throw new TypeError 'Password required'
@http
method: 'POST'
path: "/user/#{encodeURIComponent @auth.username}/password"
body: {password}
, (err) =>
if err
return cb err
# Since successful, update our saved state for subsequent requests:
@auth.password = password
# Void method:
cb null
## CYPHER
# NOTE: This method is fairly complex, in part out of necessity.
# We're okay with it since we test it throughly and emphasize its coverage.
# coffeelint: disable=cyclomatic_complexity
cypher: (opts={}, cb, _tx) ->
# coffeelint: enable=cyclomatic_complexity
if typeof opts is 'string'
opts = {query: opts}
if opts instanceof Array
opts = {queries: opts}
{queries, query, params, headers, lean, commit, rollback} = opts
if not _tx and rollback
throw new Error 'Illegal state: rolling back without a transaction!'
if commit and rollback
throw new Error 'Illegal state: both committing and rolling back!'
if rollback and (query or queries)
throw new Error 'Illegal state: rolling back with query/queries!'
if not _tx and commit is false
throw new TypeError 'Can’t refuse to commit without a transaction!
To begin a new transaction without committing, call
`db.beginTransaction()`, and then call `cypher` on that.'
if not _tx and not (query or queries)
throw new TypeError 'Query or queries required'
if query and queries
throw new TypeError 'Can’t supply both a single query
and a batch of queries! Do you have a bug in your code?'
if queries and params
throw new TypeError 'When batching multiple queries,
params must be supplied with each query, not globally.'
if queries and lean
throw new TypeError 'When batching multiple queries,
`lean` must be specified with each query, not globally.'
if (commit or rollback) and not (query or queries) and not _tx._id
# (Note that we've already required query or queries if no
# transaction present, so this means a transaction is present.)
# This transaction hasn't even been created yet from Neo4j's POV
# (because transactions are created lazily), so nothing to do.
cb null, null
return
method = 'POST'
method = 'DELETE' if rollback
path = '/db/data/transaction'
path += "/#{_tx._id}" if _tx?._id
path += '/commit' if commit or not _tx
# Normalize input query or queries to an array of queries always,
# but remember whether a single query was given (not a batch).
# Also handle the case where no queries were given; this is either a
# void action (e.g. rollback), or legitimately an empty batch.
if query
queries = [{query, params, lean}]
single = true
else
single = not queries # void action, *not* empty [] given
queries or= []
# Generate the request body by transforming each query (which is
# potentially a simple string) into Neo4j's `statement` format.
# We need to remember what result format we requested for each query.
formats = []
body =
statements:
for query in queries
if typeof query is 'string'
query = {query}
if query.headers
throw new TypeError 'When batching multiple queries,
custom request headers cannot be supplied per query;
they must be supplied globally.'
{query, params, lean} = query
# NOTE: Lowercase 'rest' matters here for parsing.
format = if lean is true then 'row' else (lean or 'rest')
formats.push format
# NOTE: Braces needed by CoffeeLint for now.
# https://github.com/clutchski/coffeelint/issues/459
{
statement: query
parameters: params or {}
resultDataContents: [format]
}
# TODO: Support streaming!
#
# NOTE: Specifying `raw: true` to save on parsing work (see `_transform`
# helper at the bottom of this file) if any queries are `lean: true`.
# Easy enough for us to parse ourselves, which we do, when needed.
#
@http {method, path, headers, body, raw: true}, (err, resp) =>
if err
# TODO: Do we want to wrap or modify native errors?
# NOTE: This includes our own errors for non-2xx responses.
return cb err
if err = Error._fromResponse resp
return cb err
_tx?._updateFromResponse resp
{results, errors} = resp.body
# Parse any results first, before errors, in case this is a batch
# request, where we want to return results alongside errors.
# The top-level `results` is an array of results corresponding to
# the `statements` (queries) inputted.
# We want to transform each query's results from Neo4j's complex
# format to a simple array of dictionaries.
results =
for result, i in results
{columns, data} = result
format = formats[i]
# The `data` for each query is an array of rows, but each of
# its elements is actually a dictionary of results keyed by
# response format. We only request one format per query.
# The value of each format is an array of rows, where each
# row is an array of column values. We transform those rows
# into dictionaries keyed by column names. Finally, we also
# parse nodes & relationships into object instances if this
# query didn't request a raw format. Phew!
$(data).pluck(format).map (row) ->
if format is 'graph'
return row
result = {}
for column, j in columns
result[column] = row[j]
if format is 'rest'
result = _transform result
result
# What exactly we return depends on how we were called:
#
# - Batch: if an array of queries were given, we always return an
# array of each query's results.
#
# - Single: if a single query was given, we always return just that
# query's results.
#
# - Void: if neither was given, we explicitly return null.
# This is for transaction actions, e.g. commit, rollback, renew.
#
# We're already set up for the batch case by default, so we only
# need to account for the other cases.
#
if single
# This means a batch of queries was *not* given, but we still
# normalized to an array of queries...
if queries.length
# This means a single query was given:
assert.equal queries.length, 1,
'There should be *exactly* one query given.'
assert results.length <= 1,
'There should be *at most* one set of results.'
results = results[0]
else
# This means no query was given:
assert.equal results.length, 0,
'There should be *no* results.'
results = null
if errors.length
# TODO: Is it possible to get back more than one error?
# If so, is it fine for us to just use the first one?
[error] = errors
err = Error._fromObject error
cb err, results
beginTransaction: ->
new Transaction @
## SCHEMA
getLabels: (cb) ->
# This endpoint returns the array of labels directly:
# http://neo4j.com/docs/stable/rest-api-node-labels.html#rest-api-list-all-labels
# Hence passing the callback directly. `http` handles 4xx, 5xx errors.
@http
method: 'GET'
path: '/db/data/labels'
, cb
getPropertyKeys: (cb) ->
# This endpoint returns the array of property keys directly:
# http://neo4j.com/docs/stable/rest-api-property-values.html#rest-api-list-all-property-keys
# Hence passing the callback directly. `http` handles 4xx, 5xx errors.
@http
method: 'GET'
path: '/db/data/propertykeys'
, cb
getRelationshipTypes: (cb) ->
# This endpoint returns the array of relationship types directly:
# http://neo4j.com/docs/stable/rest-api-relationship-types.html#rest-api-get-relationship-types
# Hence passing the callback directly. `http` handles 4xx, 5xx errors.
@http
method: 'GET'
path: '/db/data/relationship/types'
, cb
## INDEXES
getIndexes: (opts={}, cb) ->
# Support passing no options at all, to mean "across all labels":
if typeof opts is 'function'
cb = opts
opts = {}
# Also support passing a label directory:
if typeof opts is 'string'
opts = {label: opts}
{label} = opts
# Support both querying for a given label, and across all labels:
path = '/db/data/schema/index'
path += "/#{encodeURIComponent label}" if label
@http
method: 'GET'
path: path
, (err, indexes) ->
cb err, indexes?.map Index._fromRaw
hasIndex: (opts={}, cb) ->
{label, property} = opts
if not (label and property)
throw new TypeError \
'Label and property required to query whether an index exists.'
# NOTE: This is just a convenience method; there is no REST API endpoint
# for this directly (surprisingly, since there is for constraints).
# https://github.com/neo4j/neo4j/issues/4214
@getIndexes {label}, (err, indexes) ->
cb err, indexes?.some (index) ->
index.label is label and index.property is property
createIndex: (opts={}, cb) ->
{label, property} = opts
if not (label and property)
throw new TypeError \
'Label and property required to create an index.'
# Passing `raw: true` so we can handle the 409 case below.
@http
method: 'POST'
path: "/db/data/schema/index/#{encodeURIComponent label}"
body: {'property_keys': [property]}
raw: true
, (err, resp) ->
if err
return cb err
# Neo4j returns a 409 error (w/ varying code across versions)
# if this index already exists (including for a constraint).
if resp.statusCode is 409
return cb null, null
# Translate all other error responses as legitimate errors:
if err = Error._fromResponse resp
return cb err
cb err, if resp.body then Index._fromRaw resp.body
dropIndex: (opts={}, cb) ->
{label, property} = opts
if not (label and property)
throw new TypeError 'Label and property required to drop an index.'
# This endpoint is void, i.e. returns nothing:
# http://neo4j.com/docs/stable/rest-api-schema-indexes.html#rest-api-drop-index
# Passing `raw: true` so we can handle the 409 case below.
@http
method: 'DELETE'
path: "/db/data/schema/index\
/#{encodeURIComponent label}/#{encodeURIComponent property}"
raw: true
, (err, resp) ->
if err
return cb err
# Neo4j returns a 404 response (with an empty body)
# if this index doesn't exist (has already been dropped).
if resp.statusCode is 404
return cb null, false
# Translate all other error responses as legitimate errors:
if err = Error._fromResponse resp
return cb err
cb err, true # Index existed and was dropped
## CONSTRAINTS
getConstraints: (opts={}, cb) ->
# Support passing no options at all, to mean "across all labels":
if typeof opts is 'function'
cb = opts
opts = {}
# Also support passing a label directory:
if typeof opts is 'string'
opts = {label: opts}
# TODO: We may need to support querying within a particular `type` too,
# if any other types beyond uniqueness get added.
{label} = opts
# Support both querying for a given label, and across all labels.
#
# NOTE: We're explicitly *not* assuming uniqueness type here, since we
# couldn't achieve consistency with vs. without a label provided.
# (The `/uniqueness` part of the path can only come after a label.)
#
path = '/db/data/schema/constraint'
path += "/#{encodeURIComponent label}" if label
@http
method: 'GET'
path: path
, (err, constraints) ->
cb err, constraints?.map Constraint._fromRaw
hasConstraint: (opts={}, cb) ->
# TODO: We may need to support an additional `type` param too,
# if any other types beyond uniqueness get added.
{label, property} = opts
if not (label and property)
throw new TypeError 'Label and property required to query
whether a constraint exists.'
# NOTE: A REST API endpoint *does* exist to get a specific constraint:
# http://neo4j.com/docs/stable/rest-api-schema-constraints.html
# But it (a) returns an array, and (b) throws a 404 if no constraint.
# https://github.com/neo4j/neo4j/issues/4214
# For those reasons, it's actually easier to just fetch all constraints;
# no error handling needed, and array processing either way.
#
# NOTE: We explicitly *are* assuming uniqueness type here, since we
# also would if we were querying for a specific constraint.
# (The `/uniqueness` part of the path comes before the property.)
#
@http
method: 'GET'
path: "/db/data/schema/constraint\
/#{encodeURIComponent label}/uniqueness"
, (err, constraints) ->
if err
cb err
else
cb null, constraints.some (constraint) ->
constraint = Constraint._fromRaw constraint
constraint.label is label and
constraint.property is property
createConstraint: (opts={}, cb) ->
# TODO: We may need to support an additional `type` param too,
# if any other types beyond uniqueness get added.
{label, property} = opts
if not (label and property)
throw new TypeError \
'Label and property required to create a constraint.'
# NOTE: We explicitly *are* assuming uniqueness type here, since
# that's our only option today for creating constraints.
# NOTE: Passing `raw: true` so we can handle the 409 case below.
@http
method: 'POST'
path: "/db/data/schema/constraint\
/#{encodeURIComponent label}/uniqueness"
body: {'property_keys': [property]}
raw: true
, (err, resp) ->
if err
return cb err
# Neo4j returns a 409 error (w/ varying code across versions)
# if this constraint already exists.
if resp.statusCode is 409
return cb null, null
# Translate all other error responses as legitimate errors:
if err = Error._fromResponse resp
return cb err
cb err, if resp.body then Constraint._fromRaw resp.body
dropConstraint: (opts={}, cb) ->
# TODO: We may need to support an additional `type` param too,
# if any other types beyond uniqueness get added.
{label, property} = opts
if not (label and property)
throw new TypeError \
'Label and property required to drop a constraint.'
# This endpoint is void, i.e. returns nothing:
# http://neo4j.com/docs/stable/rest-api-schema-constraints.html#rest-api-drop-constraint
# Passing `raw: true` so we can handle the 409 case below.
@http
method: 'DELETE'
path: "/db/data/schema/constraint/#{encodeURIComponent label}\
/uniqueness/#{encodeURIComponent property}"
raw: true
, (err, resp) ->
if err
return cb err
# Neo4j returns a 404 response (with an empty body)
# if this constraint doesn't exist (has already been dropped).
if resp.statusCode is 404
return cb null, false
# Translate all other error responses as legitimate errors:
if err = Error._fromResponse resp
return cb err
cb err, true # Constraint existed and was dropped
# TODO: Legacy indexing
## HELPERS
#
# Normalizes the given auth value, which can be a 'username:password' string
# or a {username, password} object, to an object or null always.
#
_normalizeAuth = (auth) ->
# Support empty string for no auth:
return null if not auth
# Parse string if given, being robust to colons in the password:
if typeof auth is 'string'
[username, passwordParts...] = auth.split ':'
password = passwordParts.join ':'
auth = {username, password}
# Support empty object for no auth also:
return null if (Object.keys auth).length is 0
auth
#
# Deep inspects the given object -- which could be a simple primitive, a map,
# an array with arbitrary other objects, etc. -- and transforms any objects that
# look like nodes and relationships into Node and Relationship instances.
# Returns the transformed object, and does not mutate the input object.
#
_transform = (obj) ->
# Nothing to transform for primitives and null:
if (not obj) or (typeof obj isnt 'object')
return obj
# Process arrays:
# (NOTE: Not bothering to detect arrays made in other JS contexts.)
if obj instanceof Array
return obj.map _transform
# Feature-detect (AKA "duck-type") Node & Relationship objects, by simply
# trying to parse them as such.
# Important: check relationships first, for precision/specificity.
# TODO: If we add a Path class, we'll need to check for that here too.
if rel = Relationship._fromRaw obj
return rel
if node = Node._fromRaw obj
return node
# Otherwise, process as a dictionary/map:
map = {}
for key, val of obj
map[key] = _transform val
map