forked from cozy/cozy-client-js
-
Notifications
You must be signed in to change notification settings - Fork 0
/
mango.js
310 lines (272 loc) · 8.53 KB
/
mango.js
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
import { warn, createPath, sleep } from './utils'
import { normalizeDoctype } from './doctypes'
import { cozyFetchJSON, cozyFetchRawJSON } from './fetch'
export function defineIndex(cozy, doctype, fields) {
return cozy.isV2().then(isV2 => {
doctype = normalizeDoctype(cozy, isV2, doctype)
if (!Array.isArray(fields) || fields.length === 0) {
throw new Error('defineIndex fields should be a non-empty array')
}
if (isV2) {
return defineIndexV2(cozy, doctype, fields)
} else {
return defineIndexV3(cozy, doctype, fields)
}
})
}
export function query(cozy, indexRef, options) {
return cozy.isV2().then(isV2 => {
if (!indexRef) {
throw new Error('query should be passed the indexRef')
}
if (isV2) {
return queryV2(cozy, indexRef, options)
} else {
return queryV3(cozy, indexRef, options)
}
})
}
export function queryFiles(cozy, indexRef, options) {
const opts = getV3Options(indexRef, options)
return cozyFetchRawJSON(cozy, 'POST', '/files/_find', opts).then(
response => (options.wholeResponse ? response : response.docs)
)
}
// Internals
const VALUEOPERATORS = ['$eq', '$gt', '$gte', '$lt', '$lte']
const LOGICOPERATORS = ['$or', '$and', '$not']
/* eslint-disable */
const MAP_TEMPLATE = function(doc) {
if (doc.docType.toLowerCase() === 'DOCTYPEPLACEHOLDER') {
emit(FIELDSPLACEHOLDER, doc)
}
}
.toString()
.replace(/ /g, '')
.replace(/\n/g, '')
const COUCHDB_INFINITY = { '\uFFFF': '\uFFFF' }
const COUCHDB_LOWEST = null
/* eslint-enable */
// defineIndexV2 is equivalent to defineIndex but only works for V2.
// It transforms the index fields into a map reduce view.
function defineIndexV2(cozy, doctype, fields) {
let indexName = 'by' + fields.map(capitalize).join('')
let indexDefinition = {
map: makeMapFunction(doctype, fields),
reduce: '_count'
}
let path = `/request/${doctype}/${indexName}/`
return cozyFetchJSON(cozy, 'PUT', path, indexDefinition).then(() => ({
doctype: doctype,
type: 'mapreduce',
name: indexName,
fields: fields
}))
}
function defineIndexV3(cozy, doctype, fields) {
let path = createPath(cozy, false, doctype, '_index')
let indexDefinition = { index: { fields } }
return cozyFetchJSON(cozy, 'POST', path, indexDefinition).then(response => {
const indexResult = {
doctype: doctype,
type: 'mango',
name: response.id,
fields
}
if (response.result === 'exists') return indexResult
// indexes might not be usable right after being created; so we delay the resolving until they are
const selector = {}
selector[fields[0]] = { $gt: null }
const opts = getV3Options(indexResult, { selector: selector })
let path = createPath(cozy, false, indexResult.doctype, '_find')
return cozyFetchJSON(cozy, 'POST', path, opts)
.then(() => indexResult)
.catch(() => {
// one retry
return sleep(1000)
.then(() => cozyFetchJSON(cozy, 'POST', path, opts))
.then(() => indexResult)
.catch(() => {
return sleep(500).then(() => indexResult)
})
})
})
}
// queryV2 is equivalent to query but only works for V2.
// It transforms the query into a _views call using makeMapReduceQuery
function queryV2(cozy, indexRef, options) {
if (indexRef.type !== 'mapreduce') {
throw new Error(
'query indexRef should be the return value of defineIndexV2'
)
}
if (options.fields) {
warn('query fields will be ignored on v2')
}
let path = `/request/${indexRef.doctype}/${indexRef.name}/`
let opts = makeMapReduceQuery(indexRef, options)
return cozyFetchJSON(cozy, 'POST', path, opts).then(response =>
response.map(r => r.value)
)
}
// queryV3 is equivalent to query but only works for V3
function queryV3(cozy, indexRef, options) {
const opts = getV3Options(indexRef, options)
let path = createPath(cozy, false, indexRef.doctype, '_find')
return cozyFetchJSON(cozy, 'POST', path, opts).then(
response => (options.wholeResponse ? response : response.docs)
)
}
function getV3Options(indexRef, options) {
if (indexRef.type !== 'mango') {
throw new Error('indexRef should be the return value of defineIndexV3')
}
let opts = {
use_index: indexRef.name,
fields: options.fields,
selector: options.selector,
limit: options.limit,
skip: options.skip,
since: options.since,
sort: options.sort
}
if (options.descending) {
opts.sort = indexRef.fields.map(f => ({ [f]: 'desc' }))
}
return opts
}
// misc
function capitalize(name) {
return name.charAt(0).toUpperCase() + name.slice(1)
}
function makeMapFunction(doctype, fields) {
fields = '[' + fields.map(name => 'doc.' + name).join(',') + ']'
return MAP_TEMPLATE.replace(
'DOCTYPEPLACEHOLDER',
doctype.toLowerCase()
).replace('FIELDSPLACEHOLDER', fields)
}
// parseSelector takes a mango selector and returns it as an array of filter
// a filter is [path, operator, value] array
// a path is an array of field names
// This function is only exported so it can be unit tested.
// Example :
// parseSelector({"test":{"deep": {"$gt": 3}}})
// [[['test', 'deep'], '$gt', 3 ]]
export function parseSelector(selector, path = [], operator = '$eq') {
if (typeof selector !== 'object') {
return [[path, operator, selector]]
}
let keys = Object.keys(selector)
if (keys.length === 0) {
throw new Error('empty selector')
} else {
return keys.reduce(function(acc, k) {
if (LOGICOPERATORS.indexOf(k) !== -1) {
throw new Error('cozy-client-js does not support mango logic ops')
} else if (VALUEOPERATORS.indexOf(k) !== -1) {
return acc.concat(parseSelector(selector[k], path, k))
} else {
return acc.concat(parseSelector(selector[k], path.concat(k), '$eq'))
}
}, [])
}
}
// normalizeSelector takes a mango selector and returns it as an object
// normalized.
// This function is only exported so it can be unit tested.
// Example :
// parseSelector({"test":{"deep": {"$gt": 3}}})
// {"test.deep": {"$gt": 3}}
export function normalizeSelector(selector) {
var filters = parseSelector(selector)
return filters.reduce(function(acc, filter) {
let [path, op, value] = filter
let field = path.join('.')
acc[field] = acc[field] || {}
acc[field][op] = value
return acc
}, {})
}
// applySelector takes the normalized selector for the current field
// and append the proper values to opts.startkey, opts.endkey
function applySelector(selector, opts) {
let value = selector['$eq']
let lower = COUCHDB_LOWEST
let upper = COUCHDB_INFINITY
let inclusiveEnd
if (value) {
opts.startkey.push(value)
opts.endkey.push(value)
return false
}
value = selector['$gt']
if (value) {
throw new Error('operator $gt (strict greater than) not supported')
}
value = selector['$gte']
if (value) {
lower = value
}
value = selector['$lte']
if (value) {
upper = value
inclusiveEnd = true
}
value = selector['$lt']
if (value) {
upper = value
inclusiveEnd = false
}
opts.startkey.push(lower)
opts.endkey.push(upper)
if (inclusiveEnd !== undefined) opts.inclusive_end = inclusiveEnd
return true
}
// makeMapReduceQuery takes a mango query and generate _views call parameters
// to obtain same results depending on fields in the passed indexRef.
export function makeMapReduceQuery(indexRef, query) {
let mrquery = {
startkey: [],
endkey: [],
reduce: false
}
let firstFreeValueField = null
let normalizedSelector = normalizeSelector(query.selector)
indexRef.fields.forEach(function(field) {
let selector = normalizedSelector[field]
if (selector && firstFreeValueField != null) {
throw new Error(
'Selector on field ' +
field +
', but not on ' +
firstFreeValueField +
' which is higher in index fields.'
)
} else if (selector) {
selector.used = true
let isFreeValue = applySelector(selector, mrquery)
if (isFreeValue) firstFreeValueField = field
} else if (firstFreeValueField == null) {
firstFreeValueField = field
mrquery.endkey.push(COUCHDB_INFINITY)
}
})
Object.keys(normalizedSelector).forEach(function(field) {
if (!normalizedSelector[field].used) {
throw new Error(
'Cant apply selector on ' + field + ', it is not in index'
)
}
})
if (query.descending) {
mrquery = {
descending: true,
reduce: false,
startkey: mrquery.endkey,
endkey: mrquery.startkey,
inclusive_end: mrquery.inclusive_end
}
}
return mrquery
}