-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.js
429 lines (427 loc) · 16.5 KB
/
index.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
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
// (c) Mathieu Bruyen - http://mais-h.eu/
// License: MIT (http://www.opensource.org/licenses/mit-license.php)
var nano = require('nano');
var when = require('when');
var apply = require('when/apply');
// # CouchDB pagination
//
// [Connect](http://www.senchalabs.org/connect/) middleware for paginated [CouchDB](https://couchdb.apache.org/) views.
// Inspired from [CouchDB guide](http://guide.couchdb.org/draft/recipes.html#pagination), but left the dealbreaker part
// out (expects keys to be unique, either emitted by only one document or reduced).
//
// Source [repository](https://github.com/mathbruyen/couchdb-paginate).
//
// ## Examples
//
// ### Reduced values
//
// View with both a `map` and a reduce function:
//
// var paginate = require('couchdb-paginate');
// app.get('/list/:start', paginate({
// couchURI: 'http://localhost:5984',
// database: 'dbname',
// design: 'mydesign',
// view: 'myview'
// }), function (req, res, next) { /* display */ });
//
// ### Indexed documents
//
// View with only a `map` function like `function (doc) { emit(doc.key, null); }`:
//
// var paginate = require('couchdb-paginate');
// app.get('/list/:start', paginate({
// couchURI: 'http://localhost:5984',
// database: 'dbname',
// design: 'mydesign',
// view: 'myview',
// useDocuments: true
// }), function (req, res, next) { /* display */ });
//
// ### API access (JSON)
//
// View with only a `map` function like `function (doc) { emit(doc.key, null); }`:
//
// var paginate = require('couchdb-paginate');
// app.get('/api/list/:start', paginate({
// couchURI: 'http://localhost:5984',
// database: 'dbname',
// design: 'mydesign',
// view: 'myview',
// asJson: true
// }));
//
// ### View displaying
//
// View with only a `map` function like `function (doc) { emit(doc.key, null); }`:
//
// var paginate = require('couchdb-paginate');
// app.get('/list/:start', paginate({
// couchURI: 'http://localhost:5984',
// database: 'dbname',
// design: 'mydesign',
// view: 'myview',
// renderView: 'myview.jade'
// }));
//
// ### Complex keys
//
// Blog posts example: comments attached to posts, organized by timestamp.
//
// Document example:
//
// { postId: "abcdef", timestamp: 1234, author: "Foo", comment: "Bar" }
//
// Map function (no reduce function):
//
// function (doc) { emit([doc.postId, doc.timestamp], null); }
//
// Pagination of comments for a given post exposed as an JSON API:
//
// var paginate = require('couchdb-paginate');
// app.get('/comments/:post/:startTimestamp', paginate({
// couchURI: 'http://localhost:5984',
// database: 'dbname',
// design: 'blogposts',
// view: 'comments_by_post',
// useDocuments: true
// asJson: true,
// getBounds: function (req) {
// return [
// [req.params.post, 0],
// [req.params.post, req.params.startTimestamp],
// [req.params.post, 9007199254740992]
// ];
// }
// }));
module.exports = function (config) {
// ## Configuration options
//
// ### Connection to the database
//
// * `couchURI`: URI of the CouchDB store
// * `database`: CouchDB database name
// * `design`: design name in couchdb
// * `view`: view name in design
if (typeof config.couchURI != 'string') {
throw new TypeError('"couchURI" is not a string');
}
if (typeof config.database != 'string') {
throw new TypeError('"database" is not a string');
}
var db = nano(config.couchURI).use(config.database);
var design = config.design;
if (typeof design != 'string') {
throw new TypeError('"design" is not a string');
}
var view = config.view;
if (typeof view != 'string') {
throw new TypeError('"view" is not a string');
}
// ### Bounds and navigation
//
// * `getStartKey` (default: fetch from query parameter called `start`): how to get requested start key from the request
// * `getBounds` (default: no limits and start key from `getStartKey`): how to get navigation bounds from the request (if set then `getStartKey` will be ignored)
//
// Get bounds is expected to return an array (or a promise that will resolve an array) with three elements:
// * the lowest key to display
// * the current page start key
// * the highest key to display
// By default it uses no limits and take start key from request parameter `start`. Can be used to have more complex
// keys.
var getBounds;
if (typeof config.getBounds == 'function') {
getBounds = config.getBounds;
} else if (typeof config.getBounds == 'undefined') {
if (typeof config.getStartKey == 'function') {
var getStartKey = config.getStartKey;
getBounds = function (req) { return [undefined, getStartKey(req), undefined]; };
} else if (typeof config.getBounds == 'undefined') {
getBounds = function (req) { return [undefined, req.params.start, undefined]; };
} else {
throw new TypeError('"getStartKey" is not a function');
}
} else {
throw new TypeError('"getBounds" is not a function');
}
// ### Content to display
//
// * `allowEmpty` (default: `false`): do not fail on empty list of documents
// * `pageSize` (default: `20`): number of items per page
// * `nextNumber` (default: `1`): number of next links to compute
// * `prevNumber` (default: the value of `nextNumber`): number of previous links to compute
// * `useDocuments` (default: `false`): use documents instead of reduced values
// * `reduce` (default: opposite of `useDocuments`): informs that there is a reduce function in the view
//
// By default it assumes that the view is a complete one with a reduce function and uses the reduced value as
// content. By setting `useDocuments` to `false`, it works on indexing views with no emitted value
// (`emit(doc.myKey, null)`) and in that case it assumes there is no reduce function. By explicitely setting
// `reduce` to `false`, it allows to work with views that emit a value but do not use a reduce function.
var allowEmpty = config.allowEmpty || false;
if (typeof allowEmpty != 'boolean') {
throw new TypeError('"allowEmpty" is not a boolean');
}
var pageSize = config.pageSize || 20;
if (typeof pageSize != 'number' || isNaN(pageSize) || Math.floor(pageSize) !== pageSize || pageSize <= 0) {
throw new TypeError('"pageSize" is not a strictly positive integer');
}
var nextNumber = config.nextNumber || 1;
if (typeof nextNumber != 'number' || isNaN(nextNumber) || Math.floor(nextNumber) !== nextNumber || nextNumber < 0) {
throw new TypeError('"nextNumber" is not a positive integer');
}
var prevNumber = config.prevNumber || nextNumber;
if (typeof prevNumber != 'number' || isNaN(prevNumber) || Math.floor(prevNumber) !== prevNumber || prevNumber < 0) {
throw new TypeError('"prevNumber" is not a positive integer');
}
var useDocuments = config.useDocuments || false;
if (typeof useDocuments != 'boolean') {
throw new TypeError('"useDocuments" is not a boolean');
}
var reduce;
if (typeof config.reduce == 'undefined') {
reduce = !useDocuments;
} else if (typeof config.reduce == 'boolean') {
reduce = config.reduce;
}
else {
throw new TypeError('"reduce" is not a boolean');
}
// ### Way to display content
//
// * `asJson` (default: `false`): sends the content as JSON (if set to `true` then `renderView` will be ignored)
// * `renderView`: view to render with data (ignored if `asJson` is set to `true`)
// * `documentsExportKey` (default: `documents`): key in the content that holds the array of elements to display
// * `nextExportKey` (default: `nextIds`): key in the content that holds the array of next start identifiers
// * `previousExportKey` (default: `previousIds`): key in the content that holds the array of previous start identifiers
//
// By default content is simply added to the request object at specified keys, and next middleware can handle it.
// However there are two possible shortcuts: directly send the JSON content, or render a view with content.
var asJson = config.asJson || false;
if (typeof asJson != 'boolean') {
throw new TypeError('"asJson" is not a boolean');
}
var renderView = config.renderView || null;
if (renderView !== null && typeof renderView != 'string') {
throw new TypeError('"renderView" is not a string');
}
var documentsExportKey = config.documentsExportKey || 'documents';
if (typeof documentsExportKey != 'string') {
throw new TypeError('"documentsExportKey" is not a string');
}
var nextExportKey = config.nextExportKey || 'nextIds';
if (typeof nextExportKey != 'string') {
throw new TypeError('"nextExportKey" is not a string');
}
var previousExportKey = config.previousExportKey || 'previousIds';
if (typeof previousExportKey != 'string') {
throw new TypeError('"previousExportKey" is not a string');
}
// ## Helper to query the database
function query(startkey, endkey, limit, include_docs, descending) {
// General data, allowing not to specify `include_docs` or `descending`.
var obj = {
limit: limit
};
if (reduce) {
obj.group = true;
}
if (include_docs) {
obj.include_docs = true;
}
if (descending) {
obj.descending = true;
}
// In case the start key is undefined (start page with no lowest key), do not include it in the request.
if (startkey !== undefined) {
obj.startkey = startkey;
}
// In case the end key is undefined (no lowest key or no uppermost key), do not include it in the request.
if (endkey !== undefined) {
obj.endkey = endkey;
}
// Do the query and return a promise that holds the body.
var deferred = when.defer();
db.view(design, view, obj, function (err, body) {
if (err) {
deferred.reject(err);
} else {
deferred.resolve(body);
}
});
return deferred.promise;
}
var fetchPrev;
// ## Prepare the method used to fetch current and next pages.
var fetchCurrent = null;
var fetchNext = null;
var fetchCurrentAndNext = null;
// When fetching documents, use different requests to get next pages start keys and documents to limit response
// size. Otherzise the content would be fetched anyway so get documents and next pages start keys in one request.
if (useDocuments) {
fetchCurrent = function (startKey, uppermostKey) {
var documentsDef = when.defer();
query(startKey, uppermostKey, pageSize, true).then(function (body) {
// Prevent empty pages.
if ((!allowEmpty) && (body.rows.length === 0)) {
documentsDef.reject('No document found');
} else {
// Select documents.
documentsDef.resolve(body.rows.map(function (item) { return item.doc; }));
}
}, function (err) {
documentsDef.reject(err);
});
return documentsDef.promise;
};
fetchNext = function (startKey, uppermostKey) {
var nextDef = when.defer();
query(startKey, uppermostKey, (pageSize * nextNumber) + 1).then(function (body) {
var pages = [];
for (var i = pageSize; i < body.rows.length; i += pageSize) {
pages.push(body.rows[i].key);
}
nextDef.resolve(pages);
}, function (err) {
nextDef.reject(err);
});
return nextDef.promise;
};
// If no next pages are requested no need to query extra documents at all.
} else if (nextNumber === 0) {
fetchNext = function () {
return [];
};
fetchCurrent = function (startKey, uppermostKey) {
var documentsDef = when.defer();
query(startKey, uppermostKey, pageSize).then(function (body) {
// Prevent empty pages.
if ((!allowEmpty) && (body.rows.length === 0)) {
documentsDef.reject('No document found');
} else {
// Select reduced values.
documentsDef.resolve(body.rows.map(function (item) { return item.value; }));
}
}, function (err) {
documentsDef.reject(err);
});
return documentsDef.promise;
};
} else {
// When not using documents, content is fetched anyway thus only one request is issued to get both at once.
fetchCurrentAndNext = function (startKey, uppermostKey) {
var documentsDef = when.defer();
var nextDef = when.defer();
query(startKey, uppermostKey, (pageSize * nextNumber) + 1).then(function (body) {
// Prevent empty pages.
if ((!allowEmpty) && (body.rows.length === 0)) {
documentsDef.reject('No document found');
nextDef.reject('No document found');
} else {
var documents = [];
var pages = [];
var i;
// Select values only for the page range.
for (i = 0; i < pageSize && i < body.rows.length; i++) {
documents.push(body.rows[i].value);
}
// Select start page indexes.
for (i = pageSize; i < body.rows.length; i += pageSize) {
pages.push(body.rows[i].key);
}
documentsDef.resolve(documents);
nextDef.resolve(pages);
}
}, function(err) {
documentsDef.reject(err);
nextDef.reject(err);
});
return [documentsDef.promise, nextDef.promise];
};
}
// ## Prepare the method used to fetch previous pages.
//
// No need to query for previous pages if no page requested.
if (prevNumber === 0) {
fetchPrev = function () {
return [];
};
} else {
fetchPrev = function (startKey, lowestKey) {
// No need to query for previous pages if on the start page.
if (startKey === lowestKey) {
return [];
// In general case fetch the number of elements in previous pages plus two (one because the start document is
// included in the results and one to detect if the last fetched page is actually the first one) and record the
// keys of start keys, with additional start page detection.
} else {
var deferred = when.defer();
query(startKey, lowestKey, (prevNumber * pageSize) + 2, false, true).then(function (body) {
var pages = [];
for (var i = pageSize; i < body.rows.length && pages.length < prevNumber; i += pageSize) {
pages.push(body.rows[i].key);
}
// If the response contains less items than expected then start page is included.
if (body.rows.length > 1 && body.rows.length !== (prevNumber * pageSize) + 2) {
// Start page is actually the last one recorded (do not test for equality with lowestKey as the later may
// not exist at all).
if (body.rows[body.rows.length - 1].key === pages[pages.length - 1]) {
pages[pages.length - 1] = null;
// Start page is an additional one if there is more than this page first element in the response.
} else {
pages.push(null);
}
}
deferred.resolve(pages);
}, function (err) {
deferred.reject(err);
});
return deferred.promise;
}
};
}
// ## Retrieves the array of promises
var getAllPromises;
if (fetchCurrentAndNext !== null) {
getAllPromises = function (lowestKey, startKey, uppermostKey) {
var tmp = fetchCurrentAndNext(startKey, uppermostKey);
return when.all([fetchPrev(startKey, lowestKey), tmp[0], tmp[1]]);
};
} else {
getAllPromises = function (lowestKey, startKey, uppermostKey) {
return when.all([fetchPrev(startKey, lowestKey), fetchCurrent(startKey, uppermostKey), fetchNext(startKey, uppermostKey)]);
};
}
// ## Actual middleware
return function (req, res, next) {
when(getBounds(req)).then(apply(function (lowestKey, startKey, uppermostKey) {
var nextDef = when.defer();
var documentsDef = when.defer();
// If start key is not provided, then use the lowermost one to get start page.
if (startKey === undefined) {
startKey = lowestKey;
}
return getAllPromises(lowestKey, startKey, uppermostKey).then(function (resolved) {
// Build the output.
var result;
if (asJson || renderView) {
result = {};
} else {
result = req;
}
result[previousExportKey] = resolved[0];
result[documentsExportKey] = resolved[1];
result[nextExportKey] = resolved[2];
// Terminate middleware working.
if (asJson) {
res.json(result);
} else if (renderView) {
res.render(renderView, result);
} else {
next();
}
});
})).otherwise(function (error) {
next(error);
});
};
};