-
Notifications
You must be signed in to change notification settings - Fork 73k
Expand file tree
/
Copy pathquery.js
More file actions
223 lines (197 loc) · 7.13 KB
/
query.js
File metadata and controls
223 lines (197 loc) · 7.13 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
'use strict';
const traverse = require('traverse');
const ObjectID = require('mongodb').ObjectID;
const moment = require('moment');
const TWO_DAYS = 172800000;
/**
* @module query utilities
* Assist in translating objects from query-string representation into
* mongo-style queries by performing type translation.
*/
/**
* Options for query.
* Interpret and return the options to use for building our query.
*
* @returns {Object} Options for create, below.
```
* { deltaAgo: <ms> // ms ago to constrain queries missing any query body
, dateField: "date" // name of field to ensure there is a valid query body
, walker: <walker-spec> // a mapping of names to types
}
```
*/
function default_options (opts) {
opts = opts || { };
if (opts) {
var keys = [null].concat(Object.keys(opts));
// default at least TWO_DAYS of data
// TODO: discuss/consensus on right value/ENV?
if (keys.indexOf('deltaAgo') < 1) {
opts.deltaAgo = ( TWO_DAYS * 2 );
}
// default at `date` and `sgv` properties are both int-types.
if (keys.indexOf('walker') < 1) {
opts.walker = { date: parseInt, sgv: parseInt };
}
// The default field to constrain is called 'date' for entries module.
// Allow other models/backends to use other fields names.
opts.dateField = opts.dateField || 'date';
}
return opts;
}
/**
* Enforce rule that says that the query must express some constraint on the
* configured `dateField` or against the field named `dateString`. If the
* configured option `useEpoch` is set, the naive JS epoch is used, otherwise
* ISO 8601 is used. The rule ensures that records must have a date field
* with a date and time greater than or equal to the configured `deltaAgo`
* option, (`opts.deltaAgo`).
*/
function enforceDateFilter (query, opts) {
var dateValue = query[opts.dateField];
// rewrite dates to ISO UTC strings so queries work as expected
if (dateValue) {
Object.keys(dateValue).forEach(function(key) {
let dateString = dateValue[key];
if (isNaN(dateString)) {
dateString = dateString.replace(' ', '+'); // some clients don't excape the plus
const validDate = moment(dateString).isValid();
if (!validDate) {
console.error('API request using an invalid date:', dateString);
throw new Error('Cannot parse ' + dateString + ' as a valid ISO-8601 date');
}
const d = moment.parseZone(dateString);
dateValue[key] = d.toISOString();
}
});
}
if (!dateValue && !query.dateString && true !== opts.noDateFilter) {
var minDate = Date.now( ) - opts.deltaAgo;
query[opts.dateField] = {
$gte: opts.useEpoch ? minDate : new Date(minDate).toISOString()
};
}
}
// A MongoDB ObjectID is exactly 24 hexadecimal characters.
const OBJECT_ID_PATTERN = /^[a-fA-F0-9]{24}$/;
/**
* Helper to set ObjectID type for `_id` queries.
* Only converts strings that match the 24-character hexadecimal ObjectID
* format; non-ObjectID strings (e.g. UUIDs) are left unchanged.
*/
function updateIdQuery (query) {
if (query._id && typeof query._id === 'string' && OBJECT_ID_PATTERN.test(query._id)) {
query._id = ObjectID(query._id);
}
}
/**
* @param QueryParams params Object returned by qs.parse or https://github.com/hapijs/qs
* @param BuilderOpts opts Options for how to translate types.
*
* Allows performing logic described by a model's attributes.
* Specifically, we try to ensure that all queries have some kind of query
* body to filter the rows mongodb will spool. The defaults, such as name and
* representation of a date field can be configured via the `opts` passed in.
*
* @returns Object An object which can be passed to `mongodb.find( )`
*/
function create (params, opts) {
// setup default options for what/how to do things
opts = default_options(opts);
// Build the iterator, pass it our initial params to et the results.
var finder = walker(opts.walker)(params);
// Get the final query to pass to mongodb.
var query = finder && finder.find ? finder.find : { };
// Ensure some kind of sane date constraint tied to an index is expressed in the query.
// unless an ID is provided, in which case assume the user knows what they are doing.
if (! query._id ) {
enforceDateFilter(query, opts);
}
// Help queries for _id.
updateIdQuery(query);
//console.info('query:', query);
// Ready for mongodb.find( ) and friends.
return query;
}
/**
* Configure a single iterator given a specification of named mapped to types.
* @params Object spec A simple mapping of field names to function to create that type.
*
* Example spec: { sgv: parseInt }
* @returns function Function will translate types expressed in query.
*/
function walker (spec) {
// empty queue
var fns = [ ];
// for each key/value pair in the spec
var keys = Object.keys(spec);
keys.forEach(function config (prop) {
var typer = spec[prop];
// add function from walk_prop to the queue
fns.push(walk_prop(prop, typer));
});
/**
* Execute all configured mappings in single step.
* @param Object obj QueryString object
* @returns Object for mongodb queries, with fields set to appropriate type
described by previous mapping.
*/
function exec (obj) {
var fn;
// for each mapping in the queue
while (fns.length > 0) {
fn = fns.shift( );
// do each mapping
obj = fn(obj);
}
return obj;
}
// return a function that can execute the configured queue of translations
return exec;
}
/**
* Given a name and a type, return a function which will transform any value
* on a leaf-node into that type.
* @param String prop Property name to to translate.
* @param function typer Function to convert to type, eg `parseInt`
*/
function walk_prop (prop, typer) {
function iter (opts) {
// This is specifically configured to match the `find` convention in our REST API.
// Query parameters are the ones attached to the `find` object.
if (opts && opts.find && opts.find[prop]) {
if (typeof opts.find[prop] === 'string') {
//simple string property, no need to traverse
opts.find[prop] = typer(opts.find[prop]);
} else {
// Traverse any query elements associated with this property.
traverse(opts.find[prop]).forEach(function (x) {
// In Mongo queries, the leaf nodes are always the values to search for.
// Ignore any interstitial arrays/objects to represent
// greater-than-or-equal-to, etc.
if (this.isLeaf) {
// Leaf nodes should be converted to this type.
this.update(typer(x));
}
});
}
}
// Return opts after modifying in place.
return opts;
}
return iter;
}
function parseRegEx (str) {
var regtest = /\/(.*)\/(.*)/.exec(str);
if (regtest) {
return new RegExp(regtest[1],regtest[2]);
}
return str;
}
// attach helpers and utilities to main function for testing
walker.walk_prop = walk_prop;
create.walker = walker;
create.parseRegEx = parseRegEx;
create.default_options = default_options;
// expose module as single high level function
exports = module.exports = create;