forms-angular 0.12.0-beta.32 → 0.12.0-beta.321

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,46 @@
1
- 'use strict';
1
+ "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- // This part of forms-angular borrows _very_ heavily from https://github.com/Alexandre-Strzelewicz/angular-bridge
3
+ exports.FormsAngular = void 0;
4
+ const mongoose_1 = require("mongoose");
5
+ // This part of forms-angular borrows from https://github.com/Alexandre-Strzelewicz/angular-bridge
4
6
  // (now https://github.com/Unitech/angular-bridge
5
- var _ = require('lodash');
6
- var util = require('util');
7
- var extend = require('node.extend');
8
- var async = require('async');
9
- var debug = false;
7
+ const _ = require("lodash");
8
+ const util = require("util");
9
+ const extend = require("node.extend");
10
+ const async = require("async");
11
+ let debug = false;
10
12
  function logTheAPICalls(req, res, next) {
11
- void (res);
12
- console.log('API : ' + req.method + ' ' + req.url + ' [ ' + JSON.stringify(req.body) + ' ]');
13
+ void res;
14
+ console.log("API : " +
15
+ req.method +
16
+ " " +
17
+ req.url +
18
+ " [ " +
19
+ JSON.stringify(req.body) +
20
+ " ]");
13
21
  next();
14
22
  }
23
+ var entityMap = {
24
+ "&": "&",
25
+ "<": "&lt;",
26
+ ">": "&gt;",
27
+ '"': "&quot;",
28
+ "'": "&#39;",
29
+ "/": "&#x2F;",
30
+ "`": "&#x60;",
31
+ "=": "&#x3D;",
32
+ };
33
+ function escapeHtml(string) {
34
+ return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap(s) {
35
+ return entityMap[s];
36
+ });
37
+ }
15
38
  function processArgs(options, array) {
16
39
  if (options.authentication) {
17
- var authArray = _.isArray(options.authentication) ? options.authentication : [options.authentication];
18
- for (var i = authArray.length - 1; i >= 0; i--) {
40
+ let authArray = _.isArray(options.authentication)
41
+ ? options.authentication
42
+ : [options.authentication];
43
+ for (let i = authArray.length - 1; i >= 0; i--) {
19
44
  array.splice(1, 0, authArray[i]);
20
45
  }
21
46
  }
@@ -25,248 +50,501 @@ function processArgs(options, array) {
25
50
  array[0] = options.urlPrefix + array[0];
26
51
  return array;
27
52
  }
28
- var DataForm = function (mongoose, app, options) {
29
- this.app = app;
30
- app.locals.formsAngular = app.locals.formsAngular || [];
31
- app.locals.formsAngular.push(this);
32
- this.mongoose = mongoose;
33
- mongoose.set('debug', debug);
34
- mongoose.Promise = global.Promise;
35
- this.options = _.extend({
36
- urlPrefix: '/api/'
37
- }, options || {});
38
- this.resources = [];
39
- this.searchFunc = async.forEach;
40
- this.registerRoutes();
41
- this.app.get.apply(this.app, processArgs(this.options, ['search', this.searchAll()]));
42
- for (var pluginName in this.options.plugins) {
43
- var pluginObj = this.options.plugins[pluginName];
44
- this[pluginName] = pluginObj.plugin(this, processArgs, pluginObj.options);
53
+ class FormsAngular {
54
+ constructor(mongoose, app, options) {
55
+ this.mongoose = mongoose;
56
+ this.app = app;
57
+ app.locals.formsAngular = app.locals.formsAngular || [];
58
+ app.locals.formsAngular.push(this);
59
+ mongoose.set("debug", debug);
60
+ mongoose.Promise = global.Promise;
61
+ this.options = _.extend({
62
+ urlPrefix: "/api/",
63
+ }, options || {});
64
+ this.resources = [];
65
+ this.searchFunc = async.forEach;
66
+ // Do the plugin routes first
67
+ for (let pluginName in this.options.plugins) {
68
+ if (this.options.plugins.hasOwnProperty(pluginName)) {
69
+ let pluginObj = this.options.plugins[pluginName];
70
+ this.options.plugins[pluginName] = Object.assign(this.options.plugins[pluginName], pluginObj.plugin(this, processArgs, pluginObj.options));
71
+ }
72
+ }
73
+ const search = "search/", schema = "schema/", report = "report/", resourceName = ":resourceName", id = "/:id", formName = "/:formName", newClarifier = "/new";
74
+ this.app.get.apply(this.app, processArgs(this.options, ["models", this.models()]));
75
+ this.app.get.apply(this.app, processArgs(this.options, [search + resourceName, this.search()]));
76
+ this.app.get.apply(this.app, processArgs(this.options, [schema + resourceName, this.schema()]));
77
+ this.app.get.apply(this.app, processArgs(this.options, [
78
+ schema + resourceName + formName,
79
+ this.schema(),
80
+ ]));
81
+ this.app.get.apply(this.app, processArgs(this.options, [report + resourceName, this.report()]));
82
+ this.app.get.apply(this.app, processArgs(this.options, [
83
+ report + resourceName + "/:reportName",
84
+ this.report(),
85
+ ]));
86
+ this.app.get.apply(this.app, processArgs(this.options, [resourceName, this.collectionGet()]));
87
+ // return the List attributes for all records. two endpoints that go through the same handler so permissions
88
+ // can be applied differently for the two use cases. /listAll is intended for listing records on a page;
89
+ // /picklistAll for listing them in a <select> or similar - used by record-handler's setUpLookupOptions() method, for cases
90
+ // where there's a lookup that doesn't use the fngajax option
91
+ this.app.get.apply(this.app, processArgs(this.options, [
92
+ resourceName + "/listAll",
93
+ this.entityListAll(),
94
+ ]));
95
+ this.app.get.apply(this.app, processArgs(this.options, [
96
+ resourceName + "/picklistAll",
97
+ this.entityListAll(),
98
+ ]));
99
+ // return the List attributes for a record - used by fng-ui-select
100
+ this.app.get.apply(this.app, processArgs(this.options, [
101
+ resourceName + id + "/list",
102
+ this.entityList(),
103
+ ]));
104
+ // 2x get, with and without formName
105
+ this.app.get.apply(this.app, processArgs(this.options, [resourceName + id, this.entityGet()]));
106
+ this.app.get.apply(this.app, processArgs(this.options, [
107
+ resourceName + formName + id,
108
+ this.entityGet(),
109
+ ])); // We don't use the form name, but it can optionally be included so it can be referenced by the permissions check
110
+ // 3x post (for creating a new record), with and without formName, and in the case of without, with or without /new (which isn't needed if there's no formName)
111
+ this.app.post.apply(this.app, processArgs(this.options, [resourceName, this.collectionPost()]));
112
+ this.app.post.apply(this.app, processArgs(this.options, [
113
+ resourceName + newClarifier,
114
+ this.collectionPost(),
115
+ ]));
116
+ this.app.post.apply(this.app, processArgs(this.options, [
117
+ resourceName + formName + newClarifier,
118
+ this.collectionPost(),
119
+ ]));
120
+ // 2x post and 2x put (for saving modifications to existing record), with and without formName
121
+ // (You can POST or PUT to update data)
122
+ this.app.post.apply(this.app, processArgs(this.options, [resourceName + id, this.entityPut()]));
123
+ this.app.post.apply(this.app, processArgs(this.options, [
124
+ resourceName + formName + id,
125
+ this.entityPut(),
126
+ ]));
127
+ this.app.put.apply(this.app, processArgs(this.options, [resourceName + id, this.entityPut()]));
128
+ this.app.put.apply(this.app, processArgs(this.options, [
129
+ resourceName + formName + id,
130
+ this.entityPut(),
131
+ ]));
132
+ // 2x delete, with and without formName
133
+ this.app.delete.apply(this.app, processArgs(this.options, [resourceName + id, this.entityDelete()]));
134
+ this.app.delete.apply(this.app, processArgs(this.options, [
135
+ resourceName + formName + id,
136
+ this.entityDelete(),
137
+ ]));
138
+ this.app.get.apply(this.app, processArgs(this.options, ["search", this.searchAll()]));
45
139
  }
46
- };
47
- module.exports = exports = DataForm;
48
- DataForm.prototype.extractTimestampFromMongoID = function (record) {
49
- var timestamp = record.toString().substring(0, 8);
50
- return new Date(parseInt(timestamp, 16) * 1000);
51
- };
52
- DataForm.prototype.getListFields = function (resource, doc, cb) {
53
- function getFirstMatchingField(keyList, type) {
54
- for (var i = 0; i < keyList.length; i++) {
55
- var fieldDetails = resource.model.schema['tree'][keyList[i]];
56
- if (fieldDetails.type && (!type || fieldDetails.type.name === type) && keyList[i] !== '_id') {
140
+ getFirstMatchingField(resource, doc, keyList, type) {
141
+ for (let i = 0; i < keyList.length; i++) {
142
+ let fieldDetails = resource.model.schema["tree"][keyList[i]];
143
+ if (fieldDetails.type &&
144
+ (!type || fieldDetails.type.name === type) &&
145
+ keyList[i] !== "_id") {
57
146
  resource.options.listFields = [{ field: keyList[i] }];
58
- return doc[keyList[i]];
147
+ return doc ? doc[keyList[i]] : keyList[i];
59
148
  }
60
149
  }
61
150
  }
62
- var that = this;
63
- var display = '';
64
- var listFields = resource.options.listFields;
65
- if (listFields) {
66
- async.map(listFields, function (aField, cbm) {
67
- if (typeof doc[aField.field] !== 'undefined') {
68
- if (aField.params) {
69
- if (aField.params.ref) {
70
- var fieldOptions = resource.model.schema['paths'][aField.field].options;
71
- if (typeof fieldOptions.ref === 'string') {
72
- var lookupResource_1 = that.getResource(fieldOptions.ref);
73
- if (lookupResource_1) {
74
- var hiddenFields = that.generateHiddenFields(lookupResource_1, false);
75
- hiddenFields.__v = 0;
76
- lookupResource_1.model.findOne({ _id: doc[aField.field] }).select(hiddenFields).exec(function (err, doc2) {
77
- if (err) {
151
+ getListFields(resource, doc, cb) {
152
+ const that = this;
153
+ let display = "";
154
+ let listFields = resource.options.listFields;
155
+ if (listFields) {
156
+ async.map(listFields, function (aField, cbm) {
157
+ if (typeof doc[aField.field] !== "undefined") {
158
+ if (aField.params) {
159
+ if (aField.params.ref) {
160
+ let fieldOptions = resource.model.schema["paths"][aField.field].options;
161
+ if (typeof fieldOptions.ref === "string") {
162
+ let lookupResource = that.getResource(fieldOptions.ref);
163
+ if (lookupResource) {
164
+ let hiddenFields = that.generateHiddenFields(lookupResource, false);
165
+ lookupResource.model
166
+ .findOne({ _id: doc[aField.field] })
167
+ .select(hiddenFields)
168
+ .exec()
169
+ .then((doc2) => {
170
+ that.getListFields(lookupResource, doc2, cbm);
171
+ })
172
+ .catch((err) => {
78
173
  cbm(err);
79
- }
80
- else {
81
- that.getListFields(lookupResource_1, doc2, cbm);
82
- }
83
- });
174
+ });
175
+ }
176
+ }
177
+ else {
178
+ throw new Error("No support for ref type " + aField.params.ref.type);
179
+ }
180
+ }
181
+ else if (aField.params.params === "timestamp") {
182
+ let date = that.extractTimestampFromMongoID(doc[aField.field]);
183
+ cbm(null, date.toLocaleDateString() + " " + date.toLocaleTimeString());
184
+ }
185
+ else if (!aField.params.params) {
186
+ throw new Error(`Missing idIsList params for resource ${resource.resourceName}: ${JSON.stringify(aField.params)}`);
187
+ }
188
+ else if (typeof doc[aField.params.params] === "function") {
189
+ const resultOrPromise = doc[aField.params.params]();
190
+ if (typeof resultOrPromise.then === "function") {
191
+ resultOrPromise.then((result) => cbm(null, result));
192
+ }
193
+ else {
194
+ cbm(null, resultOrPromise);
84
195
  }
85
196
  }
86
197
  else {
87
- throw new Error('No support for ref type ' + aField.params.ref.type);
198
+ throw new Error(`No support for idIsList params for resource ${resource.resourceName}: ${JSON.stringify(aField.params)}`);
88
199
  }
89
200
  }
90
- else if (aField.params.params === 'timestamp') {
91
- var date = this.extractTimestampFromMongoID(doc[aField.field]);
92
- cbm(null, date.toLocaleDateString() + ' ' + date.toLocaleTimeString());
201
+ else {
202
+ cbm(null, doc[aField.field]);
93
203
  }
94
204
  }
95
205
  else {
96
- cbm(null, doc[aField.field]);
206
+ cbm(null, "");
97
207
  }
98
- }
99
- else {
100
- cbm(null, '');
101
- }
102
- }, function (err, results) {
103
- if (err) {
104
- cb(err);
105
- }
106
- else {
107
- if (results) {
108
- cb(err, results.join(' ').trim());
208
+ }, function (err, results) {
209
+ if (err) {
210
+ cb(err);
109
211
  }
110
212
  else {
111
- console.log('No results ' + listFields);
213
+ if (results) {
214
+ cb(err, results.join(" ").trim());
215
+ }
216
+ else {
217
+ console.log("No results " + listFields);
218
+ }
112
219
  }
113
- }
114
- });
115
- }
116
- else {
117
- var keyList = Object.keys(resource.model.schema['tree']);
118
- // No list field specified - use the first String field,
119
- display = getFirstMatchingField(keyList, 'String') ||
120
- // and if there aren't any then just take the first field
121
- getFirstMatchingField(keyList);
122
- cb(null, display.trim());
123
- }
124
- };
125
- /**
126
- * Registers all REST routes with the provided `app` object.
127
- */
128
- DataForm.prototype.registerRoutes = function () {
129
- var search = 'search/', schema = 'schema/', report = 'report/', resourceName = ':resourceName', id = '/:id';
130
- this.app.get.apply(this.app, processArgs(this.options, ['models', this.models()]));
131
- this.app.get.apply(this.app, processArgs(this.options, [search + resourceName, this.search()]));
132
- this.app.get.apply(this.app, processArgs(this.options, [schema + resourceName, this.schema()]));
133
- this.app.get.apply(this.app, processArgs(this.options, [schema + resourceName + '/:formName', this.schema()]));
134
- this.app.get.apply(this.app, processArgs(this.options, [report + resourceName, this.report()]));
135
- this.app.get.apply(this.app, processArgs(this.options, [report + resourceName + '/:reportName', this.report()]));
136
- this.app.get.apply(this.app, processArgs(this.options, [resourceName, this.collectionGet()]));
137
- this.app.post.apply(this.app, processArgs(this.options, [resourceName, this.collectionPost()]));
138
- this.app.get.apply(this.app, processArgs(this.options, [resourceName + id, this.entityGet()]));
139
- this.app.post.apply(this.app, processArgs(this.options, [resourceName + id, this.entityPut()])); // You can POST or PUT to update data
140
- this.app.put.apply(this.app, processArgs(this.options, [resourceName + id, this.entityPut()]));
141
- this.app.delete.apply(this.app, processArgs(this.options, [resourceName + id, this.entityDelete()]));
142
- // return the List attributes for a record - used by select2
143
- this.app.get.apply(this.app, processArgs(this.options, [resourceName + id + '/list', this.entityList()]));
144
- };
145
- DataForm.prototype.newResource = function (model, options) {
146
- options = options || {};
147
- options.suppressDeprecatedMessage = true;
148
- var passModel = model;
149
- if (typeof model !== 'function') {
150
- passModel = model.model;
151
- }
152
- this.addResource(passModel.modelName, passModel, options);
153
- };
154
- // Add a resource, specifying the model and any options.
155
- // Models may include their own options, which means they can be passed through from the model file
156
- DataForm.prototype.addResource = function (resourceName, model, options) {
157
- var resource = {
158
- resourceName: resourceName,
159
- options: options || {}
160
- };
161
- if (!resource.options.suppressDeprecatedMessage) {
162
- console.log('addResource is deprecated - see https://github.com/forms-angular/forms-angular/issues/39');
163
- }
164
- if (typeof model === 'function') {
165
- resource.model = model;
220
+ });
221
+ }
222
+ else {
223
+ const keyList = Object.keys(resource.model.schema["tree"]);
224
+ // No list field specified - use the first String field,
225
+ display =
226
+ this.getFirstMatchingField(resource, doc, keyList, "String") ||
227
+ // and if there aren't any then just take the first field
228
+ this.getFirstMatchingField(resource, doc, keyList);
229
+ cb(null, display.trim());
230
+ }
166
231
  }
167
- else {
168
- resource.model = model.model;
169
- for (var prop in model) {
170
- if (model.hasOwnProperty(prop) && prop !== 'model') {
171
- resource.options[prop] = model[prop];
232
+ // generate a Mongo projection that can be used to restrict a query to return only those fields from the given
233
+ // resource that are identified as "list" fields (i.e., ones that should appear whenever records of that type are
234
+ // displayed in a list)
235
+ generateListFieldProjection(resource) {
236
+ const projection = {};
237
+ const listFields = resource.options?.listFields;
238
+ // resource.options.listFields will identify all of the fields from resource that have a value for .list.
239
+ // generally, that value will be "true", identifying the corresponding field as one which should be
240
+ // included whenever records of that type appear in a list.
241
+ // occasionally, it will instead be "{ ref: true }"", which means something entirely different -
242
+ // this means that the field requires a lookup translation before it can be displayed on a form.
243
+ // for our purposes, we're interested in only the first of these two cases, so we'll ignore anything where
244
+ // field.params.ref has a truthy value
245
+ if (listFields) {
246
+ for (const field of listFields) {
247
+ if (!field.params?.ref) {
248
+ projection[field.field] = 1;
249
+ }
172
250
  }
173
251
  }
252
+ else {
253
+ const keyList = Object.keys(resource.model.schema["tree"]);
254
+ const firstField =
255
+ // No list field specified - use the first String field,
256
+ this.getFirstMatchingField(resource, undefined, keyList, "String") ||
257
+ // and if there aren't any then just take the first field
258
+ this.getFirstMatchingField(resource, undefined, keyList);
259
+ projection[firstField] = 1;
260
+ }
261
+ return projection;
174
262
  }
175
- extend(resource.options, this.preprocess(resource, resource.model.schema['paths'], null));
176
- if (resource.options.searchImportance) {
177
- this.searchFunc = async.forEachSeries;
263
+ newResource(model, options) {
264
+ options = options || {};
265
+ options.suppressDeprecatedMessage = true;
266
+ let passModel = model;
267
+ if (typeof model !== "function") {
268
+ passModel = model.model;
269
+ }
270
+ this.addResource(passModel.modelName, passModel, options);
178
271
  }
179
- if (this.searchFunc === async.forEachSeries) {
180
- this.resources.splice(_.sortedIndexBy(this.resources, resource, function (obj) {
181
- return obj.options.searchImportance || 99;
182
- }), 0, resource);
272
+ // Add a resource, specifying the model and any options.
273
+ // Models may include their own options, which means they can be passed through from the model file
274
+ addResource(resourceName, model, options) {
275
+ let resource = {
276
+ resourceName: resourceName,
277
+ resourceNameLower: resourceName.toLowerCase(),
278
+ options: options || {},
279
+ };
280
+ if (!resource.options.suppressDeprecatedMessage) {
281
+ console.log("addResource is deprecated - see https://github.com/forms-angular/forms-angular/issues/39");
282
+ }
283
+ // Check all the synonyms are lower case
284
+ resource.options.synonyms?.forEach((s) => {
285
+ s.name = s.name.toLowerCase();
286
+ });
287
+ if (typeof model === "function") {
288
+ resource.model = model;
289
+ }
290
+ else {
291
+ resource.model = model.model;
292
+ for (const prop in model) {
293
+ if (model.hasOwnProperty(prop) && prop !== "model") {
294
+ resource.options[prop] = model[prop];
295
+ }
296
+ }
297
+ }
298
+ extend(resource.options, this.preprocess(resource, resource.model.schema.paths));
299
+ resource.options.searchFields = [];
300
+ // commenting this out, as we used to do this in a place where the type of resource.model.schema was any,
301
+ // so it was allowed, despite the fact that _indexes is not a known property of a mongoose schema.
302
+ // changing it to indexes does compile, but this might not be what was intended
303
+ //
304
+ // for (let j = 0; j < resource.model.schema._indexes.length; j++) {
305
+ // let attributes = resource.model.schema._indexes[j][0];
306
+ // let field = Object.keys(attributes)[0];
307
+ // if (resource.options.searchFields.indexOf(field) === -1) {
308
+ // resource.options.searchFields.push(field);
309
+ // }
310
+ // }
311
+ function addSearchFields(schema, pathSoFar) {
312
+ for (let path in schema.paths) {
313
+ if (path !== "_id" && schema.paths.hasOwnProperty(path)) {
314
+ const qualifiedPath = pathSoFar ? pathSoFar + "." + path : path;
315
+ if (schema.paths[path].options.index &&
316
+ !schema.paths[path].options.noSearch) {
317
+ if (resource.options.searchFields.indexOf(qualifiedPath) === -1) {
318
+ resource.options.searchFields.push(qualifiedPath);
319
+ }
320
+ }
321
+ else if (schema.paths[path].schema) {
322
+ addSearchFields(schema.paths[path].schema, qualifiedPath);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ addSearchFields(resource.model.schema, "");
328
+ if (resource.options.searchImportance) {
329
+ this.searchFunc = async.forEachSeries;
330
+ }
331
+ if (this.searchFunc === async.forEachSeries) {
332
+ this.resources.splice(_.sortedIndexBy(this.resources, resource, function (obj) {
333
+ return obj.options.searchImportance || 99;
334
+ }), 0, resource);
335
+ }
336
+ else {
337
+ this.resources.push(resource);
338
+ }
183
339
  }
184
- else {
185
- this.resources.push(resource);
340
+ getResource(name) {
341
+ return _.find(this.resources, function (resource) {
342
+ return (resource.resourceName === name || resource.options.resourceName === name);
343
+ });
186
344
  }
187
- };
188
- DataForm.prototype.getResource = function (name) {
189
- return _.find(this.resources, function (resource) {
190
- return resource.resourceName === name;
191
- });
192
- };
193
- DataForm.prototype.getResourceFromCollection = function (name) {
194
- return _.find(this.resources, function (resource) {
195
- return resource.model.collection.collectionName === name;
196
- });
197
- };
198
- DataForm.prototype.internalSearch = function (req, resourcesToSearch, includeResourceInResults, limit, callback) {
199
- if (typeof req.query === 'undefined') {
200
- req.query = {};
345
+ getResourceFromCollection(name) {
346
+ return _.find(this.resources, function (resource) {
347
+ return resource.model.collection.collectionName === name;
348
+ });
201
349
  }
202
- var searches = [], resourceCount = resourcesToSearch.length, searchFor = req.query.q || '', filter = req.query.f;
203
- function translate(string, array, context) {
204
- if (array) {
205
- var translation = _.find(array, function (fromTo) {
206
- return fromTo.from === string && (!fromTo.context || fromTo.context === context);
350
+ // Using the given (already-populated) AmbiguousRecordStore, generate text suitable for
351
+ // disambiguation of each ambiguous record, and pass that to the given disambiguateItemCallback
352
+ // so our caller can decorate the ambiguous record in whatever way it deems appropriate.
353
+ //
354
+ // The ambiguousRecordStore provided to this function (generated either by a call to
355
+ // buildSingleResourceAmbiguousRecordStore() or buildMultiResourceAmbiguousRecordStore()) will
356
+ // already be grouping records by the resource that should be used to disambiguate them, with
357
+ // the name of that resource being the primary index property of the store.
358
+ //
359
+ // The disambiguation text will be the concatenation (space-seperated) of the list fields for
360
+ // the doc from that resource whose _id matches the value of record[disambiguationField].
361
+ //
362
+ // allRecords should include all of the ambiguous records (also held by AmbiguousRecordStore)
363
+ // as well as those found not to be ambiguous. The final act of this function will be to delete
364
+ // the disambiguation field from those records - it is only going to be there for the purpose
365
+ // of disambiguation, and should not be returned by our caller once disambiguation is complete.
366
+ //
367
+ // The scary-looking templating used here ensures that the objects in allRecords (and also
368
+ // ambiguousRecordStore) include an (optional) string property with the name identified by
369
+ // disambiguationField. For the avoidance of doubt, "prop" here could be anything - "foo in f"
370
+ // would achieve the same result.
371
+ disambiguate(allRecords, ambiguousRecordStore, disambiguationField, disambiguateItemCallback, completionCallback) {
372
+ const that = this;
373
+ async.map(Object.keys(ambiguousRecordStore), function (resourceName, cbm) {
374
+ const resource = that.getResource(resourceName);
375
+ const projection = that.generateListFieldProjection(resource);
376
+ resource.model
377
+ .find({
378
+ _id: {
379
+ $in: ambiguousRecordStore[resourceName].map((sr) => sr[disambiguationField]),
380
+ },
381
+ })
382
+ .select(projection)
383
+ .lean()
384
+ .then((disambiguationRecs) => {
385
+ for (const ambiguousResult of ambiguousRecordStore[resourceName]) {
386
+ const disambiguator = disambiguationRecs.find((d) => d._id.toString() ===
387
+ ambiguousResult[disambiguationField].toString());
388
+ if (disambiguator) {
389
+ let suffix = "";
390
+ for (const listField in projection) {
391
+ if (disambiguator[listField]) {
392
+ if (suffix) {
393
+ suffix += " ";
394
+ }
395
+ suffix += disambiguator[listField];
396
+ }
397
+ }
398
+ if (suffix) {
399
+ disambiguateItemCallback(ambiguousResult, suffix);
400
+ }
401
+ }
402
+ }
403
+ cbm(null);
404
+ })
405
+ .catch((err) => {
406
+ cbm(err);
207
407
  });
208
- if (translation) {
209
- string = translation.to;
408
+ }, (err) => {
409
+ for (const record of allRecords) {
410
+ delete record[disambiguationField];
210
411
  }
211
- }
212
- return string;
412
+ completionCallback(err);
413
+ });
213
414
  }
214
- // return a string that determines the sort order of the resultObject
215
- function calcResultValue(obj) {
216
- function padLeft(score, reqLength, str) {
217
- if (str === void 0) { str = '0'; }
218
- return new Array(1 + reqLength - String(score).length).join(str) + score;
415
+ internalSearch(req, resourcesToSearch, includeResourceInResults, limit, callback) {
416
+ if (typeof req.query === "undefined") {
417
+ req.query = {};
219
418
  }
220
- var sortString = '';
221
- sortString += padLeft(obj.addHits || 9, 1);
222
- sortString += padLeft(obj.searchImportance || 99, 2);
223
- sortString += padLeft(obj.weighting || 9999, 4);
224
- sortString += obj.text;
225
- return sortString;
226
- }
227
- if (filter) {
228
- filter = JSON.parse(filter);
229
- }
230
- for (var i = 0; i < resourceCount; i++) {
231
- var resource = resourcesToSearch[i];
232
- if (resourceCount === 1 || resource.options.searchImportance !== false) {
233
- var schema = resource.model.schema;
234
- var indexedFields = [];
235
- for (var j = 0; j < schema._indexes.length; j++) {
236
- var attributes = schema._indexes[j][0];
237
- var field = Object.keys(attributes)[0];
238
- if (indexedFields.indexOf(field) === -1) {
239
- indexedFields.push(field);
240
- }
241
- }
242
- for (var path in schema.paths) {
243
- if (path !== '_id' && schema.paths.hasOwnProperty(path)) {
244
- if (schema.paths[path]._index && !schema.paths[path].options.noSearch) {
245
- if (indexedFields.indexOf(path) === -1) {
246
- indexedFields.push(path);
247
- }
419
+ const timestamps = {
420
+ sentAt: req.query.sentAt,
421
+ startedAt: new Date().valueOf(),
422
+ completedAt: undefined,
423
+ };
424
+ let searches = [], resourceCount = resourcesToSearch.length, searchFor = req.query.q || "", filter = req.query.f;
425
+ function translate(string, array, context) {
426
+ if (array) {
427
+ let translation = _.find(array, function (fromTo) {
428
+ return (fromTo.from === string &&
429
+ (!fromTo.context || fromTo.context === context));
430
+ });
431
+ if (translation) {
432
+ string = translation.to;
433
+ }
434
+ }
435
+ return string;
436
+ }
437
+ // return a string that determines the sort order of the resultObject
438
+ function calcResultValue(obj) {
439
+ function padLeft(score, reqLength, str = "0") {
440
+ return (new Array(1 + reqLength - String(score).length).join(str) + score);
441
+ }
442
+ let sortString = "";
443
+ sortString += padLeft(obj.addHits || 9, 1);
444
+ sortString += padLeft(obj.searchImportance || 99, 2);
445
+ sortString += padLeft(obj.weighting || 9999, 4);
446
+ sortString += obj.text;
447
+ return sortString;
448
+ }
449
+ if (filter) {
450
+ filter = JSON.parse(filter);
451
+ }
452
+ // See if we are narrowing down the resources
453
+ let collectionName;
454
+ let collectionNameLower;
455
+ let colonPos = searchFor.indexOf(":");
456
+ switch (colonPos) {
457
+ case -1:
458
+ // Original behaviour = do nothing different
459
+ break;
460
+ case 0:
461
+ // "Special search" - yet to be implemented
462
+ break;
463
+ default:
464
+ collectionName = searchFor.slice(0, colonPos);
465
+ collectionNameLower = collectionName.toLowerCase();
466
+ searchFor = searchFor.slice(colonPos + 1, 999).trim();
467
+ if (searchFor === "") {
468
+ searchFor = "?";
469
+ }
470
+ break;
471
+ }
472
+ for (let i = 0; i < resourceCount; i++) {
473
+ let resource = resourcesToSearch[i];
474
+ if (resourceCount === 1 ||
475
+ (resource.options.searchImportance !== false &&
476
+ (!collectionName ||
477
+ collectionNameLower === resource.resourceNameLower ||
478
+ resource.options?.synonyms?.find((s) => s.name === collectionNameLower)))) {
479
+ let searchFields = resource.options.searchFields;
480
+ if (searchFields.length === 0) {
481
+ console.log("ERROR: Searching on a collection with no indexes " +
482
+ resource.resourceName);
483
+ }
484
+ let synonymObj = resource.options?.synonyms?.find((s) => s.name.toLowerCase() === collectionNameLower);
485
+ const synonymFilter = synonymObj?.filter;
486
+ for (let m = 0; m < searchFields.length; m++) {
487
+ let searchObj = {
488
+ resource: resource,
489
+ field: searchFields[m],
490
+ };
491
+ if (synonymFilter) {
492
+ searchObj.filter = synonymFilter;
248
493
  }
494
+ searches.push(searchObj);
249
495
  }
250
496
  }
251
- if (indexedFields.length === 0) {
252
- console.log('ERROR: Searching on a collection with no indexes ' + resource.resourceName);
497
+ }
498
+ const that = this;
499
+ let results = [];
500
+ let moreCount = 0;
501
+ let searchCriteria;
502
+ let searchStrings;
503
+ let multiMatchPossible = false;
504
+ if (searchFor === "?") {
505
+ // interpret this as a wildcard (so there is no way to search for ?
506
+ searchCriteria = null;
507
+ }
508
+ else {
509
+ // Support for searching anywhere in a field by starting with *
510
+ let startAnchor = "^";
511
+ if (searchFor.slice(0, 1) === "*") {
512
+ startAnchor = "";
513
+ searchFor = searchFor.slice(1);
253
514
  }
254
- for (var m = 0; m < indexedFields.length; m++) {
255
- searches.push({ resource: resource, field: indexedFields[m] });
515
+ // THe snippet to escape the special characters comes from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
516
+ searchFor = searchFor.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
517
+ multiMatchPossible = searchFor.includes(" ");
518
+ if (multiMatchPossible) {
519
+ searchStrings = searchFor.split(" ");
256
520
  }
521
+ let modifiedSearchStr = multiMatchPossible
522
+ ? searchStrings.join("|")
523
+ : searchFor;
524
+ searchFor = searchFor.toLowerCase(); // For later case-insensitive comparison
525
+ // Removed the logic that preserved spaces when collection was specified because Louise asked me to.
526
+ searchCriteria = {
527
+ $regex: `${startAnchor}(${modifiedSearchStr})`,
528
+ $options: "i",
529
+ };
257
530
  }
258
- }
259
- var that = this;
260
- var results = [], moreCount = 0, searchCriteria, multiMatchPossible = searchFor.includes(' '), modifiedSearchStr = multiMatchPossible ? searchFor.split(' ').join('|') : searchFor;
261
- // Removed the logic that preserved spaces when collection was specified because Louise asked me to.
262
- searchCriteria = { $regex: '^(' + modifiedSearchStr + ')', $options: 'i' };
263
- var handleSearchResultsFromIndex = function (err, docs, item, cb) {
264
- if (!err && docs && docs.length > 0) {
265
- async.map(docs, function (aDoc, cbdoc) {
266
- // Do we already have them in the list?
267
- var thisId = aDoc._id.toString(), resultObject, resultPos;
531
+ let handleSearchResultsFromIndex = function (err, docs, item, cb) {
532
+ function handleSingleSearchResult(aDoc, cbdoc) {
533
+ let thisId = aDoc._id.toString(), resultObject, resultPos;
268
534
  function handleResultsInList() {
269
- resultObject.searchImportance = item.resource.options.searchImportance || 99;
535
+ if (multiMatchPossible) {
536
+ resultObject.matched = resultObject.matched || [];
537
+ // record the index of string that matched, so we don't count it against another field
538
+ for (let i = 0; i < searchStrings.length; i++) {
539
+ if (aDoc[item.field]?.toLowerCase().indexOf(searchStrings[i]) === 0) {
540
+ resultObject.matched.push(i);
541
+ break;
542
+ }
543
+ }
544
+ }
545
+ resultObject.resourceCollection = item.resource.resourceName;
546
+ resultObject.searchImportance =
547
+ item.resource.options.searchImportance || 99;
270
548
  if (item.resource.options.localisationData) {
271
549
  resultObject.resource = translate(resultObject.resource, item.resource.options.localisationData, "resource");
272
550
  resultObject.resourceText = translate(resultObject.resourceText, item.resource.options.localisationData, "resourceText");
@@ -275,32 +553,58 @@ DataForm.prototype.internalSearch = function (req, resourcesToSearch, includeRes
275
553
  results.splice(_.sortedIndexBy(results, resultObject, calcResultValue), 0, resultObject);
276
554
  cbdoc(null);
277
555
  }
556
+ // Do we already have them in the list?
278
557
  for (resultPos = results.length - 1; resultPos >= 0; resultPos--) {
279
- if (results[resultPos].id.toString() === thisId) {
558
+ // check for matching id and resource
559
+ if (results[resultPos].id.toString() === thisId && results[resultPos].resourceCollection === item.resource.resourceName) {
280
560
  break;
281
561
  }
282
562
  }
283
563
  if (resultPos >= 0) {
284
- resultObject = {};
285
- extend(resultObject, results[resultPos]);
564
+ resultObject = Object.assign({}, results[resultPos]);
286
565
  // If they have already matched then improve their weighting
287
- // TODO: if the search string is B F currently Benjamin Barker scores same as Benjamin Franklin)
288
566
  if (multiMatchPossible) {
289
- resultObject.addHits = Math.max((resultObject.addHits || 9) - 1, 1);
567
+ // record the index of string that matched, so we don't count it against another field
568
+ for (let i = 0; i < searchStrings.length; i++) {
569
+ if (!resultObject.matched.includes(i) &&
570
+ aDoc[item.field]?.toLowerCase().indexOf(searchStrings[i]) === 0) {
571
+ resultObject.matched.push(i);
572
+ resultObject.addHits = Math.max((resultObject.addHits || 9) - 1, 0);
573
+ // remove it from current position
574
+ results.splice(resultPos, 1);
575
+ // and re-insert where appropriate
576
+ results.splice(_.sortedIndexBy(results, resultObject, calcResultValue), 0, resultObject);
577
+ break;
578
+ }
579
+ }
290
580
  }
291
- // remove it from current position
292
- results.splice(resultPos, 1);
293
- // and re-insert where appropriate
294
- results.splice(_.sortedIndexBy(results, resultObject, calcResultValue), 0, resultObject);
295
581
  cbdoc(null);
296
582
  }
297
583
  else {
298
584
  // Otherwise add them new...
585
+ let addHits;
586
+ if (multiMatchPossible) {
587
+ // If they match the whole search phrase in one index they get smaller addHits (so they sort higher)
588
+ if (aDoc[item.field]?.toLowerCase().indexOf(searchFor) === 0) {
589
+ addHits = 7;
590
+ }
591
+ }
592
+ let disambiguationId;
593
+ const opts = item.resource.options;
594
+ const disambiguationResource = opts.disambiguation?.resource;
595
+ if (disambiguationResource) {
596
+ disambiguationId = aDoc[opts.disambiguation.field]?.toString();
597
+ }
299
598
  // Use special listings format if defined
300
- var specialListingFormat = item.resource.options.searchResultFormat;
599
+ let specialListingFormat = item.resource.options.searchResultFormat;
301
600
  if (specialListingFormat) {
302
- resultObject = specialListingFormat.apply(aDoc);
303
- handleResultsInList();
601
+ specialListingFormat.apply(aDoc, [req]).then((resultObj) => {
602
+ resultObject = resultObj;
603
+ resultObject.addHits = addHits;
604
+ resultObject.disambiguationResource = disambiguationResource;
605
+ resultObject.disambiguationId = disambiguationId;
606
+ handleResultsInList();
607
+ });
304
608
  }
305
609
  else {
306
610
  that.getListFields(item.resource, aDoc, function (err, description) {
@@ -311,430 +615,782 @@ DataForm.prototype.internalSearch = function (req, resourcesToSearch, includeRes
311
615
  resultObject = {
312
616
  id: aDoc._id,
313
617
  weighting: 9999,
314
- text: description
618
+ addHits,
619
+ disambiguationResource,
620
+ disambiguationId,
621
+ text: description,
315
622
  };
316
623
  if (resourceCount > 1 || includeResourceInResults) {
317
- resultObject.resource = resultObject.resourceText = item.resource.resourceName;
624
+ resultObject.resource = resultObject.resourceText =
625
+ item.resource.resourceName;
318
626
  }
319
627
  handleResultsInList();
320
628
  }
321
629
  });
322
630
  }
323
631
  }
324
- }, function (err) {
632
+ }
633
+ if (!err && docs && docs.length > 0) {
634
+ async.map(docs, handleSingleSearchResult, cb);
635
+ }
636
+ else {
325
637
  cb(err);
326
- });
327
- }
328
- else {
329
- cb(err);
330
- }
331
- };
332
- this.searchFunc(searches, function (item, cb) {
333
- var searchDoc = {};
334
- if (filter) {
335
- that.hackVariables(filter);
336
- extend(searchDoc, filter);
337
- if (filter[item.field]) {
338
- delete searchDoc[item.field];
339
- var obj1 = {}, obj2 = {};
340
- obj1[item.field] = filter[item.field];
341
- obj2[item.field] = searchCriteria;
342
- searchDoc['$and'] = [obj1, obj2];
638
+ }
639
+ };
640
+ this.searchFunc(searches, function (item, cb) {
641
+ let searchDoc = {};
642
+ let searchFilter = filter || item.filter;
643
+ if (searchFilter) {
644
+ that.hackVariables(searchFilter);
645
+ extend(searchDoc, searchFilter);
646
+ if (searchFilter[item.field]) {
647
+ delete searchDoc[item.field];
648
+ let obj1 = {}, obj2 = {};
649
+ obj1[item.field] = searchFilter[item.field];
650
+ obj2[item.field] = searchCriteria;
651
+ searchDoc["$and"] = [obj1, obj2];
652
+ }
653
+ else {
654
+ if (searchCriteria) {
655
+ searchDoc[item.field] = searchCriteria;
656
+ }
657
+ }
343
658
  }
344
659
  else {
345
- searchDoc[item.field] = searchCriteria;
660
+ if (searchCriteria) {
661
+ searchDoc[item.field] = searchCriteria;
662
+ }
346
663
  }
347
- }
348
- else {
349
- searchDoc[item.field] = searchCriteria;
350
- }
351
- // The +60 in the next line is an arbitrary safety zone for situations where items that match the string
352
- // in more than one index get filtered out.
353
- // TODO : Figure out a better way to deal with this
354
- if (item.resource.options.searchFunc) {
355
- item.resource.options.searchFunc(item.resource, req, null, searchDoc, item.resource.options.searchOrder, limit + 60, null, function (err, docs) {
356
- handleSearchResultsFromIndex(err, docs, item, cb);
357
- });
358
- }
359
- else {
360
- that.filteredFind(item.resource, req, null, searchDoc, item.resource.options.searchOrder, limit + 60, null, function (err, docs) {
361
- handleSearchResultsFromIndex(err, docs, item, cb);
362
- });
363
- }
364
- }, function (err) {
365
- if (err) {
366
- callback(err);
367
- }
368
- else {
369
- // Strip weighting from the results
370
- results = _.map(results, function (aResult) {
371
- delete aResult.weighting;
372
- return aResult;
373
- });
374
- if (results.length > limit) {
375
- moreCount += results.length - limit;
376
- results.splice(limit);
664
+ /*
665
+ The +200 below line is an (imperfect) arbitrary safety zone for situations where items that match the string in more than one index get filtered out.
666
+ An example where it fails is searching for "e c" which fails to get a old record Emily Carpenter in a big dataset sorted by date last accessed as they
667
+ are not returned within the first 200 in forenames so don't get the additional hit score and languish outside the visible results, though those visible
668
+ results end up containing people who only match either c or e (but have been accessed much more recently).
669
+
670
+ Increasing the number would be a short term fix at the cost of slowing down the search.
671
+ */
672
+ // TODO : Figure out a better way to deal with this
673
+ if (item.resource.options.searchFunc) {
674
+ item.resource.options.searchFunc(item.resource, req, null, searchDoc, item.resource.options.searchOrder, limit ? limit + 200 : 0, null, function (err, docs) {
675
+ handleSearchResultsFromIndex(err, docs, item, cb);
676
+ });
377
677
  }
378
- callback(null, { results: results, moreCount: moreCount });
379
- }
380
- });
381
- };
382
- DataForm.prototype.wrapInternalSearch = function (req, res, resourcesToSearch, includeResourceInResults, limit) {
383
- this.internalSearch(req, resourcesToSearch, includeResourceInResults, limit, function (err, resultsObject) {
384
- if (err) {
385
- res.status(400, err);
386
- }
387
- else {
388
- res.send(resultsObject);
389
- }
390
- });
391
- };
392
- DataForm.prototype.search = function () {
393
- return _.bind(function (req, res, next) {
394
- if (!(req.resource = this.getResource(req.params.resourceName))) {
395
- return next();
396
- }
397
- this.wrapInternalSearch(req, res, [req.resource], false, 10);
398
- }, this);
399
- };
400
- DataForm.prototype.searchAll = function () {
401
- return _.bind(function (req, res) {
402
- this.wrapInternalSearch(req, res, this.resources, true, 10);
403
- }, this);
404
- };
405
- DataForm.prototype.models = function () {
406
- var that = this;
407
- return function (req, res) {
408
- // TODO: Make this less wasteful - we only need to send the resourceNames of the resources
409
- // Check for optional modelFilter and call it with the request and current list. Otherwise just return the list.
410
- res.send(that.options.modelFilter ? that.options.modelFilter.call(null, req, that.resources) : that.resources);
411
- };
412
- };
413
- DataForm.prototype.renderError = function (err, redirectUrl, req, res) {
414
- if (typeof err === 'string') {
415
- res.send(err);
416
- }
417
- else {
418
- res.send(err.message);
419
- }
420
- };
421
- DataForm.prototype.redirect = function (address, req, res) {
422
- res.send(address);
423
- };
424
- DataForm.prototype.applySchemaSubset = function (vanilla, schema) {
425
- var outPath;
426
- if (schema) {
427
- outPath = {};
428
- for (var fld in schema) {
429
- if (schema.hasOwnProperty(fld)) {
430
- if (!vanilla[fld]) {
431
- throw new Error('No such field as ' + fld + '. Is it part of a sub-doc? If so you need the bit before the period.');
432
- }
433
- outPath[fld] = vanilla[fld];
434
- if (vanilla[fld].schema) {
435
- outPath[fld].schema = this.applySchemaSubset(outPath[fld].schema, schema[fld].schema);
436
- }
437
- outPath[fld].options = outPath[fld].options || {};
438
- for (var override in schema[fld]) {
439
- if (schema[fld].hasOwnProperty(override)) {
440
- if (!outPath[fld].options.form) {
441
- outPath[fld].options.form = {};
678
+ else {
679
+ that.filteredFind(item.resource, req, null, searchDoc, null, item.resource.options.searchOrder, limit ? limit + 200 : 0, null, function (err, docs) {
680
+ handleSearchResultsFromIndex(err, docs, item, cb);
681
+ });
682
+ }
683
+ }, function (err) {
684
+ if (err) {
685
+ callback(err);
686
+ }
687
+ else {
688
+ // Strip weighting from the results
689
+ results = _.map(results, function (aResult) {
690
+ delete aResult.weighting;
691
+ return aResult;
692
+ });
693
+ if (limit && results.length > limit) {
694
+ moreCount += results.length - limit;
695
+ results.splice(limit);
696
+ }
697
+ that.disambiguate(results, that.buildMultiResourceAmbiguousRecordStore(results, ["text"], "disambiguationResource"), "disambiguationId", (item, disambiguationText) => {
698
+ item.text += ` (${disambiguationText})`;
699
+ }, (err) => {
700
+ if (err) {
701
+ callback(err);
702
+ }
703
+ else {
704
+ // the disambiguate() call will have deleted the disambiguationIds but we're responsible for
705
+ // the disambiguationResources, which we shouldn't be returning to the client
706
+ for (const result of results) {
707
+ delete result.disambiguationResource;
442
708
  }
443
- outPath[fld].options.form[override] = schema[fld][override];
709
+ timestamps.completedAt = new Date().valueOf();
710
+ callback(null, { results, moreCount, timestamps });
444
711
  }
445
- }
712
+ });
446
713
  }
447
- }
714
+ });
715
+ }
716
+ wrapInternalSearch(req, res, resourcesToSearch, includeResourceInResults, limit) {
717
+ this.internalSearch(req, resourcesToSearch, includeResourceInResults, limit, function (err, resultsObject) {
718
+ if (err) {
719
+ res.status(400, err);
720
+ }
721
+ else {
722
+ res.send(resultsObject);
723
+ }
724
+ });
448
725
  }
449
- else {
450
- outPath = vanilla;
726
+ search() {
727
+ return _.bind(function (req, res, next) {
728
+ if (!(req.resource = this.getResource(req.params.resourceName))) {
729
+ return next();
730
+ }
731
+ this.wrapInternalSearch(req, res, [req.resource], false, 0);
732
+ }, this);
451
733
  }
452
- return outPath;
453
- };
454
- DataForm.prototype.preprocess = function (resource, paths, formSchema) {
455
- var outPath = {}, hiddenFields = [], listFields = [];
456
- if (resource && resource.options && resource.options.idIsList) {
457
- paths['_id'].options = paths['_id'].options || {};
458
- paths['_id'].options.list = resource.options.idIsList;
734
+ searchAll() {
735
+ return _.bind(function (req, res) {
736
+ this.wrapInternalSearch(req, res, this.resources, true, 10);
737
+ }, this);
459
738
  }
460
- for (var element in paths) {
461
- if (paths.hasOwnProperty(element) && element !== '__v') {
462
- // check for schemas
463
- if (paths[element].schema) {
464
- var subSchemaInfo = this.preprocess(null, paths[element].schema.paths);
465
- outPath[element] = { schema: subSchemaInfo.paths };
466
- if (paths[element].options.form) {
467
- outPath[element].options = { form: extend(true, {}, paths[element].options.form) };
468
- }
739
+ models() {
740
+ const that = this;
741
+ return function (req, res) {
742
+ // Check for optional modelFilter and call it with the request and current list. Otherwise just return the list.
743
+ let resources = that.options.modelFilter
744
+ ? that.options.modelFilter.call(null, req, that.resources)
745
+ : that.resources;
746
+ if (req.query?.resourceNamesOnly) {
747
+ resources = resources.map((r) => r.resourceName);
469
748
  }
470
- else {
471
- // check for arrays
472
- var realType = paths[element].caster ? paths[element].caster : paths[element];
473
- if (!realType.instance) {
474
- if (realType.options.type) {
475
- var type = realType.options.type(), typeType = typeof type;
476
- if (typeType === 'string') {
477
- realType.instance = (!isNaN(Date.parse(type))) ? 'Date' : 'String';
749
+ res.send(resources);
750
+ };
751
+ }
752
+ renderError(err, redirectUrl, req, res) {
753
+ res.statusMessage = err?.message || err;
754
+ res.status(400).end(err?.message || err);
755
+ }
756
+ redirect(address, req, res) {
757
+ res.send(address);
758
+ }
759
+ applySchemaSubset(vanilla, schema) {
760
+ let outPath;
761
+ if (schema) {
762
+ outPath = {};
763
+ for (let fld in schema) {
764
+ if (schema.hasOwnProperty(fld)) {
765
+ if (vanilla[fld]) {
766
+ outPath[fld] = _.cloneDeep(vanilla[fld]);
767
+ if (vanilla[fld].schema) {
768
+ outPath[fld].schema = this.applySchemaSubset(outPath[fld].schema, schema[fld].schema);
769
+ }
770
+ }
771
+ else {
772
+ if (fld.slice(0, 8) === "_bespoke") {
773
+ outPath[fld] = {
774
+ path: fld,
775
+ instance: schema[fld]._type,
776
+ };
478
777
  }
479
778
  else {
480
- realType.instance = typeType;
779
+ throw new Error("No such field as " +
780
+ fld +
781
+ ". Is it part of a sub-doc? If so you need the bit before the period.");
481
782
  }
482
783
  }
483
- }
484
- outPath[element] = extend(true, {}, paths[element]);
485
- if (paths[element].options.secure) {
486
- hiddenFields.push(element);
487
- }
488
- if (paths[element].options.match) {
489
- outPath[element].options.match = paths[element].options.match.source;
490
- }
491
- var schemaListInfo = paths[element].options.list;
492
- if (schemaListInfo) {
493
- var listFieldInfo = { field: element };
494
- if (typeof schemaListInfo === 'object' && Object.keys(schemaListInfo).length > 0) {
495
- listFieldInfo.params = schemaListInfo;
784
+ outPath[fld].options = outPath[fld].options || {};
785
+ for (const override in schema[fld]) {
786
+ if (schema[fld].hasOwnProperty(override)) {
787
+ if (override.slice(0, 1) !== "_") {
788
+ if (schema[fld].hasOwnProperty(override)) {
789
+ if (!outPath[fld].options.form) {
790
+ outPath[fld].options.form = {};
791
+ }
792
+ outPath[fld].options.form[override] = schema[fld][override];
793
+ }
794
+ }
795
+ }
496
796
  }
497
- listFields.push(listFieldInfo);
498
797
  }
499
798
  }
500
799
  }
501
- }
502
- outPath = this.applySchemaSubset(outPath, formSchema);
503
- var returnObj = { paths: outPath };
504
- if (hiddenFields.length > 0) {
505
- returnObj.hide = hiddenFields;
506
- }
507
- if (listFields.length > 0) {
508
- returnObj.listFields = listFields;
509
- }
510
- return returnObj;
511
- };
512
- DataForm.prototype.schema = function () {
513
- return _.bind(function (req, res) {
514
- if (!(req.resource = this.getResource(req.params.resourceName))) {
515
- return res.status(404).end();
516
- }
517
- var formSchema = null;
518
- if (req.params.formName) {
519
- formSchema = req.resource.model.schema.statics['form'](req.params.formName);
520
- }
521
- var paths = this.preprocess(req.resource, req.resource.model.schema.paths, formSchema).paths;
522
- res.send(paths);
523
- }, this);
524
- };
525
- DataForm.prototype.report = function () {
526
- return _.bind(function (req, res, next) {
527
- if (!(req.resource = this.getResource(req.params.resourceName))) {
528
- return next();
529
- }
530
- var self = this;
531
- if (typeof req.query === 'undefined') {
532
- req.query = {};
800
+ else {
801
+ outPath = vanilla;
533
802
  }
534
- var reportSchema;
535
- if (req.params.reportName) {
536
- reportSchema = req.resource.model.schema.statics['report'](req.params.reportName, req);
803
+ return outPath;
804
+ }
805
+ preprocess(resource, paths, formName, formSchema) {
806
+ function processInternalObject(obj) {
807
+ return Object.keys(obj).reduce((acc, cur) => {
808
+ const curType = typeof obj[cur];
809
+ if ((!["$", "_"].includes(cur.charAt(0)) ||
810
+ [
811
+ "$in",
812
+ "$nin",
813
+ "$eq",
814
+ "$ne",
815
+ "$gt",
816
+ "$gte",
817
+ "$lt",
818
+ "$lte",
819
+ "$regex",
820
+ "$or",
821
+ "$exists",
822
+ ].includes(cur)) &&
823
+ curType !== "function") {
824
+ const val = obj[cur];
825
+ if (val) {
826
+ if (Array.isArray(val)) {
827
+ if (val.length > 0) {
828
+ acc[cur] = val;
829
+ }
830
+ }
831
+ else if (curType === "object" &&
832
+ !(val instanceof Date) &&
833
+ !(val instanceof RegExp)) {
834
+ acc[cur] = processInternalObject(obj[cur]);
835
+ }
836
+ else {
837
+ acc[cur] = obj[cur];
838
+ }
839
+ }
840
+ }
841
+ return acc;
842
+ }, {});
537
843
  }
538
- else if (req.query.r) {
539
- switch (req.query.r[0]) {
540
- case '[':
541
- reportSchema = { pipeline: JSON.parse(req.query.r) };
542
- break;
543
- case '{':
544
- reportSchema = JSON.parse(req.query.r);
545
- break;
546
- default:
547
- return self.renderError(new Error('Invalid "r" parameter'), null, req, res, next);
548
- }
844
+ let outPath = {}, hiddenFields = [], listFields = [];
845
+ if (resource &&
846
+ !resource.options?.doNotCacheSchema &&
847
+ resource.preprocessed &&
848
+ resource.preprocessed[formName || "__default"]) {
849
+ return resource.preprocessed[formName || "__default"].paths;
549
850
  }
550
851
  else {
551
- var fields = {};
552
- for (var key in req.resource.model.schema.paths) {
553
- if (req.resource.model.schema.paths.hasOwnProperty(key)) {
554
- if (key !== '__v' && !req.resource.model.schema.paths[key].options.secure) {
555
- if (key.indexOf('.') === -1) {
556
- fields[key] = 1;
852
+ if (resource && resource.options && resource.options.idIsList) {
853
+ paths["_id"].options = paths["_id"].options || {};
854
+ paths["_id"].options.list = resource.options.idIsList;
855
+ }
856
+ for (let element in paths) {
857
+ if (paths.hasOwnProperty(element) && element !== "__v") {
858
+ // check for schemas
859
+ if (paths[element].schema) {
860
+ let subSchemaInfo = this.preprocess(null, paths[element].schema.paths);
861
+ outPath[element] = { schema: subSchemaInfo.paths };
862
+ if (paths[element].options.form) {
863
+ outPath[element].options = {
864
+ form: extend(true, {}, paths[element].options.form),
865
+ };
866
+ }
867
+ // this provides support for entire nested schemas that wish to remain hidden
868
+ if (paths[element].options.secure) {
869
+ hiddenFields.push(element);
870
+ }
871
+ // to support hiding individual properties of nested schema would require us
872
+ // to do something with subSchemaInfo.hide here
873
+ }
874
+ else {
875
+ // check for arrays
876
+ let realType = paths[element].caster
877
+ ? paths[element].caster
878
+ : paths[element];
879
+ if (!realType.instance) {
880
+ if (realType.options.type) {
881
+ let type = realType.options.type(), typeType = typeof type;
882
+ if (typeType === "string") {
883
+ realType.instance = !isNaN(Date.parse(type))
884
+ ? "Date"
885
+ : "String";
886
+ }
887
+ else {
888
+ realType.instance = typeType;
889
+ }
890
+ }
891
+ }
892
+ outPath[element] = processInternalObject(paths[element]);
893
+ if (paths[element].options.secure) {
894
+ hiddenFields.push(element);
895
+ }
896
+ if (paths[element].options.match) {
897
+ outPath[element].options.match =
898
+ paths[element].options.match.source ||
899
+ paths[element].options.match;
900
+ }
901
+ let schemaListInfo = paths[element].options.list;
902
+ if (schemaListInfo) {
903
+ let listFieldInfo = { field: element };
904
+ if (typeof schemaListInfo === "object" &&
905
+ Object.keys(schemaListInfo).length > 0) {
906
+ listFieldInfo.params = schemaListInfo;
907
+ }
908
+ listFields.push(listFieldInfo);
557
909
  }
558
910
  }
559
911
  }
560
912
  }
561
- reportSchema = {
562
- pipeline: [
563
- { $project: fields }
564
- ], drilldown: req.params.resourceName + '/|_id|/edit'
565
- };
566
- }
567
- // Replace parameters in pipeline
568
- var schemaCopy = {};
569
- extend(schemaCopy, reportSchema);
570
- schemaCopy.params = schemaCopy.params || [];
571
- self.reportInternal(req, req.resource, schemaCopy, function (err, result) {
572
- if (err) {
573
- self.renderError(err, null, req, res, next);
913
+ outPath = this.applySchemaSubset(outPath, formSchema);
914
+ let returnObj = { paths: outPath };
915
+ if (hiddenFields.length > 0) {
916
+ returnObj.hide = hiddenFields;
574
917
  }
575
- else {
576
- res.send(result);
918
+ if (listFields.length > 0) {
919
+ returnObj.listFields = listFields;
577
920
  }
578
- });
579
- }, this);
580
- };
581
- DataForm.prototype.hackVariablesInPipeline = function (runPipeline) {
582
- for (var pipelineSection = 0; pipelineSection < runPipeline.length; pipelineSection++) {
583
- if (runPipeline[pipelineSection]['$match']) {
584
- this.hackVariables(runPipeline[pipelineSection]['$match']);
921
+ if (resource && !resource.options?.doNotCacheSchema) {
922
+ resource.preprocessed = resource.preprocessed || {};
923
+ resource.preprocessed[formName || "__default"] = returnObj;
924
+ }
925
+ return returnObj;
585
926
  }
586
927
  }
587
- };
588
- DataForm.prototype.hackVariables = function (obj) {
589
- // Replace variables that cannot be serialised / deserialised. Bit of a hack, but needs must...
590
- // Anything formatted 1800-01-01T00:00:00.000Z or 1800-01-01T00:00:00.000+0000 is converted to a Date
591
- // Only handles the cases I need for now
592
- // TODO: handle arrays etc
593
- for (var prop in obj) {
594
- if (obj.hasOwnProperty(prop)) {
595
- if (typeof obj[prop] === 'string') {
596
- var dateTest = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3})(Z|[+ -]\d{4})$/.exec(obj[prop]);
597
- if (dateTest) {
598
- obj[prop] = new Date(dateTest[1] + 'Z');
599
- }
600
- else {
601
- var objectIdTest = /^([0-9a-fA-F]{24})$/.exec(obj[prop]);
602
- if (objectIdTest) {
603
- obj[prop] = new this.mongoose.Types.ObjectId(objectIdTest[1]);
604
- }
605
- }
928
+ schema() {
929
+ return _.bind(function (req, res) {
930
+ if (!(req.resource = this.getResource(req.params.resourceName))) {
931
+ return res.status(404).end();
606
932
  }
607
- else if (_.isObject(obj[prop])) {
608
- this.hackVariables(obj[prop]);
933
+ let formSchema = null;
934
+ const formName = req.params.formName;
935
+ if (req.resource.preprocessed?.[formName || "__default"]) {
936
+ res.send(req.resource.preprocessed[formName || "__default"].paths);
609
937
  }
610
- }
611
- }
612
- };
613
- DataForm.prototype.reportInternal = function (req, resource, schema, callback) {
614
- var runPipelineStr;
615
- var runPipelineObj;
616
- var self = this;
617
- if (typeof req.query === 'undefined') {
618
- req.query = {};
619
- }
620
- self.doFindFunc(req, resource, function (err, queryObj) {
621
- if (err) {
622
- return 'There was a problem with the findFunc for model';
623
- }
624
- else {
625
- runPipelineStr = JSON.stringify(schema.pipeline);
626
- for (var param in req.query) {
627
- if (req.query[param]) {
628
- if (param !== 'r') { // we don't want to copy the whole report schema (again!)
629
- schema.params[param].value = req.query[param];
938
+ else {
939
+ if (formName) {
940
+ try {
941
+ formSchema = req.resource.model.schema.statics["form"](escapeHtml(formName), req);
942
+ }
943
+ catch (e) {
944
+ return res.status(404).send(e.message);
630
945
  }
631
946
  }
947
+ let paths = this.preprocess(req.resource, req.resource.model.schema.paths, formName, formSchema).paths;
948
+ res.send(paths);
632
949
  }
633
- // Replace parameters with the value
634
- if (runPipelineStr) {
635
- runPipelineStr = runPipelineStr.replace(/\"\(.+?\)\"/g, function (match) {
636
- var sparam = schema.params[match.slice(2, -2)];
637
- if (sparam.type === 'number') {
638
- return sparam.value;
639
- }
640
- else if (_.isObject(sparam.value)) {
641
- return JSON.stringify(sparam.value);
642
- }
643
- else if (sparam.value[0] === '{') {
644
- return sparam.value;
645
- }
646
- else {
647
- return '"' + sparam.value + '"';
648
- }
649
- });
950
+ }, this);
951
+ }
952
+ report() {
953
+ return _.bind(async function (req, res, next) {
954
+ if (!(req.resource = this.getResource(req.params.resourceName))) {
955
+ return next();
956
+ }
957
+ const self = this;
958
+ req._isReport = true; // Can be used to modify findFunc behaviour
959
+ if (typeof req.query === "undefined") {
960
+ req.query = {};
961
+ }
962
+ let reportSchema;
963
+ if (req.params.reportName) {
964
+ try {
965
+ reportSchema = await req.resource.model.schema.statics["report"](req.params.reportName, req);
966
+ }
967
+ catch (e) {
968
+ res.send({ success: false, error: e.message || e });
969
+ }
970
+ }
971
+ else if (req.query.r) {
972
+ switch (req.query.r[0]) {
973
+ case "[":
974
+ reportSchema = { pipeline: JSON.parse(req.query.r) };
975
+ break;
976
+ case "{":
977
+ reportSchema = JSON.parse(req.query.r);
978
+ break;
979
+ default:
980
+ return self.renderError(new Error('Invalid "r" parameter'), null, req, res);
981
+ }
650
982
  }
651
- // Don't send the 'secure' fields
652
- var hiddenFields = self.generateHiddenFields(resource, false);
653
- for (var hiddenField in hiddenFields) {
654
- if (hiddenFields.hasOwnProperty(hiddenField)) {
655
- if (runPipelineStr.indexOf(hiddenField) !== -1) {
656
- return callback('You cannot access ' + hiddenField);
983
+ else {
984
+ let fields = {};
985
+ for (let key in req.resource.model.schema.paths) {
986
+ if (req.resource.model.schema.paths.hasOwnProperty(key)) {
987
+ if (key !== "__v" &&
988
+ !req.resource.model.schema.paths[key].options.secure) {
989
+ if (key.indexOf(".") === -1) {
990
+ fields[key] = 1;
991
+ }
992
+ }
657
993
  }
658
994
  }
995
+ reportSchema = {
996
+ pipeline: [{ $project: fields }],
997
+ drilldown: req.params.resourceName + "/|_id|/edit",
998
+ };
659
999
  }
660
- runPipelineObj = JSON.parse(runPipelineStr);
661
- if (!_.isArray(runPipelineObj)) {
662
- runPipelineObj = [runPipelineObj];
1000
+ // Replace parameters in pipeline
1001
+ let schemaCopy = {};
1002
+ extend(schemaCopy, reportSchema);
1003
+ schemaCopy.params = schemaCopy.params || [];
1004
+ self.reportInternal(req, req.resource, schemaCopy, function (err, result) {
1005
+ if (err) {
1006
+ res.send({ success: false, error: err.message || err });
1007
+ }
1008
+ else {
1009
+ res.send(result);
1010
+ }
1011
+ });
1012
+ }, this);
1013
+ }
1014
+ hackVariablesInPipeline(runPipeline) {
1015
+ for (let pipelineSection = 0; pipelineSection < runPipeline.length; pipelineSection++) {
1016
+ let sectionType = Object.keys(runPipeline[pipelineSection])[0];
1017
+ if (["$match", "$group", "$project"].includes(sectionType)) {
1018
+ this.hackVariables(runPipeline[pipelineSection][sectionType]);
663
1019
  }
664
- self.hackVariablesInPipeline(runPipelineObj);
665
- // Add the findFunc query to the pipeline
666
- if (queryObj) {
667
- runPipelineObj.unshift({ $match: queryObj });
1020
+ }
1021
+ }
1022
+ hackVariables(obj) {
1023
+ // Replace variables that cannot be serialised / deserialised. Bit of a hack, but needs must...
1024
+ // Anything formatted 1800-01-01T00:00:00.000Z or 1800-01-01T00:00:00.000+0000 is converted to a Date
1025
+ // Only handles the cases I need for now
1026
+ // TODO: handle arrays etc
1027
+ for (const prop in obj) {
1028
+ if (obj.hasOwnProperty(prop)) {
1029
+ if (typeof obj[prop] === "string") {
1030
+ const dateTest = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{3})?)(Z|[+ -]\d{2}:?\d{2})$/.exec(obj[prop]);
1031
+ if (dateTest) {
1032
+ obj[prop] = new Date(dateTest[1] + "Z");
1033
+ }
1034
+ else if (prop !== "$regex") {
1035
+ const objectIdTest = /^([0-9a-fA-F]{24})$/.exec(obj[prop]);
1036
+ if (objectIdTest) {
1037
+ obj[prop] = new mongoose_1.Types.ObjectId(objectIdTest[1]);
1038
+ }
1039
+ }
1040
+ }
1041
+ else if (_.isObject(obj[prop])) {
1042
+ this.hackVariables(obj[prop]);
1043
+ }
668
1044
  }
669
- var toDo = {
670
- runAggregation: function (cb) {
671
- resource.model.aggregate(runPipelineObj, cb);
1045
+ }
1046
+ }
1047
+ async sanitisePipeline(aggregationParam, hiddenFields, findFuncQry, req) {
1048
+ let that = this;
1049
+ let array = Array.isArray(aggregationParam)
1050
+ ? aggregationParam
1051
+ : [aggregationParam];
1052
+ let retVal = [];
1053
+ let doneHiddenFields = false;
1054
+ if (findFuncQry) {
1055
+ retVal.unshift({ $match: findFuncQry });
1056
+ }
1057
+ async function sanitiseLookupPipeline(stage) {
1058
+ // can't think of a way to exploit this (if you add a findFunc) but it is the responsibility of the user
1059
+ // to ensure that the pipeline returns fields used by the findFunc
1060
+ console.log(`In some scenarios $lookups that use pipelines may not provide all the fields used by the 'findFunc' of a collection.
1061
+ If you get no results returned this might be the explanation.`);
1062
+ /* Sanitise the pipeline we are doing a lookup with, removing hidden fields from that collection */
1063
+ const lookupCollectionName = stage.$lookup.from;
1064
+ const lookupResource = that.getResourceFromCollection(lookupCollectionName);
1065
+ let lookupHiddenLookupFields = {};
1066
+ if (lookupResource) {
1067
+ if (lookupResource.options?.hide?.length > 0) {
1068
+ lookupHiddenLookupFields = that.generateHiddenFields(lookupResource, false);
672
1069
  }
673
- };
674
- var translations_1 = []; // array of form {ref:'lookupname',translations:[{value:xx, display:' '}]}
675
- // if we need to do any column translations add the function to the tasks list
676
- if (schema.columnTranslations) {
677
- toDo.applyTranslations = ['runAggregation', function (results, cb) {
678
- function doATranslate(column, theTranslation) {
679
- results['runAggregation'].forEach(function (resultRow) {
680
- var valToTranslate = resultRow[column.field];
681
- valToTranslate = (valToTranslate ? valToTranslate.toString() : '');
682
- var thisTranslation = _.find(theTranslation.translations, function (option) {
683
- return valToTranslate === option.value.toString();
684
- });
685
- resultRow[column.field] = thisTranslation ? thisTranslation.display : ' * Missing columnTranslation * ';
1070
+ }
1071
+ stage.$lookup.pipeline = await that.sanitisePipeline(stage.$lookup.pipeline, lookupHiddenLookupFields, findFuncQry, req);
1072
+ }
1073
+ for (let pipelineSection = 0; pipelineSection < array.length; pipelineSection++) {
1074
+ let stage = array[pipelineSection];
1075
+ let keys = Object.keys(stage);
1076
+ if (keys.length !== 1) {
1077
+ throw new Error("Invalid pipeline instruction");
1078
+ }
1079
+ switch (keys[0]) {
1080
+ case "$project":
1081
+ case "$addFields":
1082
+ case "$count":
1083
+ case "$group":
1084
+ case "$unset":
1085
+ case "$limit":
1086
+ case "$replaceRoot":
1087
+ case "$replaceWith":
1088
+ case "$sort":
1089
+ case "$unwind":
1090
+ // We don't care about these - they are all (as far as we know) safe
1091
+ break;
1092
+ case "$unionWith":
1093
+ /*
1094
+ Sanitise the pipeline we are doing a union with, removing hidden fields from that collection
1095
+ */
1096
+ if (!stage.$unionWith.coll) {
1097
+ stage.$unionWith = { coll: stage.$unionWith, pipeline: [] };
1098
+ }
1099
+ const unionCollectionName = stage.$unionWith.coll;
1100
+ const unionResource = that.getResourceFromCollection(unionCollectionName);
1101
+ let unionHiddenLookupFields = {};
1102
+ if (unionResource) {
1103
+ if (unionResource.options?.hide?.length > 0) {
1104
+ unionHiddenLookupFields = this.generateHiddenFields(unionResource, false);
1105
+ }
1106
+ }
1107
+ stage.$unionWith.pipeline = await that.sanitisePipeline(stage.$unionWith.pipeline, unionHiddenLookupFields, findFuncQry, req);
1108
+ break;
1109
+ case "$match":
1110
+ this.hackVariables(array[pipelineSection]["$match"]);
1111
+ retVal.push(array[pipelineSection]);
1112
+ if (!doneHiddenFields &&
1113
+ Object.keys(hiddenFields) &&
1114
+ Object.keys(hiddenFields).length > 0) {
1115
+ // We can now project out the hidden fields (we wait for the $match to make sure we don't break
1116
+ // a select that uses a hidden field
1117
+ retVal.push({ $project: hiddenFields });
1118
+ doneHiddenFields = true;
1119
+ }
1120
+ stage = null;
1121
+ break;
1122
+ case "$lookup":
1123
+ case "$graphLookup":
1124
+ let needFindFunc = true;
1125
+ if (keys[0] === "$lookup") {
1126
+ let lookupProps = Object.keys(stage.$lookup);
1127
+ // First deal with simple $lookups with a single join field equality
1128
+ if ((lookupProps.length === 4 || lookupProps.length === 5) &&
1129
+ lookupProps.includes("from") &&
1130
+ lookupProps.includes("localField") &&
1131
+ lookupProps.includes("foreignField") &&
1132
+ lookupProps.includes("as")) {
1133
+ // If we are doing a lookup using an _id (so not fishing) we don't need to do the findFunc (see tkt #12399)
1134
+ if (lookupProps.length === 4) {
1135
+ if (stage.$lookup.foreignField === "_id" || stage.$lookup.localField === "_id") {
1136
+ needFindFunc = false;
1137
+ }
1138
+ }
1139
+ else if (lookupProps.length === 5 && lookupProps.includes("pipeline")) {
1140
+ await sanitiseLookupPipeline.call(this, stage);
1141
+ }
1142
+ else {
1143
+ throw new Error("Unsupported $lookup format");
1144
+ }
1145
+ }
1146
+ else if (lookupProps.length === 4 &&
1147
+ lookupProps.includes("from") &&
1148
+ lookupProps.includes("let") &&
1149
+ lookupProps.includes("pipeline") &&
1150
+ lookupProps.includes("as")) {
1151
+ await sanitiseLookupPipeline.call(this, stage);
1152
+ }
1153
+ else {
1154
+ throw new Error(`No support for $lookup of with properties ${lookupProps.join(', ')}`);
1155
+ }
1156
+ }
1157
+ // hide any hiddenfields in the lookup collection
1158
+ const collectionName = stage[keys[0]].from;
1159
+ const lookupField = stage[keys[0]].as;
1160
+ if ((collectionName + lookupField).indexOf("$") !== -1) {
1161
+ throw new Error('No support for lookups where the "from" or "as" is anything other than a simple string');
1162
+ }
1163
+ const resource = that.getResourceFromCollection(collectionName);
1164
+ if (resource) {
1165
+ retVal.push(stage);
1166
+ stage = null;
1167
+ if (resource.options?.hide?.length > 0) {
1168
+ const hiddenLookupFields = this.generateHiddenFields(resource, false);
1169
+ let hiddenFieldsObj = {};
1170
+ Object.keys(hiddenLookupFields).forEach((hf) => {
1171
+ hiddenFieldsObj[`${lookupField}.${hf}`] = false;
686
1172
  });
1173
+ retVal.push({ $project: hiddenFieldsObj });
687
1174
  }
688
- schema.columnTranslations.forEach(function (columnTranslation) {
689
- if (columnTranslation.translations) {
690
- doATranslate(columnTranslation, columnTranslation);
1175
+ // Now we need to make sure that we restrict the lookup to documents we have access to (or can provide the _id of)
1176
+ if (needFindFunc && resource.options.findFunc) {
1177
+ let allowNulls = false;
1178
+ // If the next stage is an $unwind
1179
+ let nextStageIsUnwind = false;
1180
+ if (array.length >= pipelineSection) {
1181
+ const nextStage = array[pipelineSection + 1];
1182
+ let nextKeys = Object.keys(nextStage);
1183
+ if (nextKeys.length !== 1) {
1184
+ throw new Error("Invalid pipeline instruction");
1185
+ }
1186
+ if (nextKeys[0] === "$unwind") {
1187
+ if (nextStage["$unwind"] === "$" + lookupField) {
1188
+ nextStageIsUnwind = true;
1189
+ }
1190
+ if (nextStage["$unwind"] &&
1191
+ nextStage["$unwind"].path === "$" + lookupField) {
1192
+ nextStageIsUnwind = true;
1193
+ if (nextStage["$unwind"].preserveNullAndEmptyArrays) {
1194
+ allowNulls = true;
1195
+ }
1196
+ }
1197
+ }
691
1198
  }
692
- if (columnTranslation.ref) {
693
- var theTranslation = _.find(translations_1, function (translation) {
694
- return (translation.ref === columnTranslation.ref);
695
- });
696
- if (theTranslation) {
697
- doATranslate(columnTranslation, theTranslation);
1199
+ if (!nextStageIsUnwind) {
1200
+ throw new Error("No support for $lookup where the next stage is not an $unwind and the resources has a findFunc");
1201
+ }
1202
+ // Push the $unwind, add our own findFunc, and increment the pipelineStage counter
1203
+ retVal.push(array[pipelineSection + 1]);
1204
+ let lookedUpFindQry = await this.doFindFuncPromise(req, resource);
1205
+ if (lookedUpFindQry) {
1206
+ // Now we need to put the lookup base into the criteria
1207
+ for (const prop in lookedUpFindQry) {
1208
+ if (lookedUpFindQry.hasOwnProperty(prop)) {
1209
+ lookedUpFindQry[`${lookupField}.${prop}`] =
1210
+ lookedUpFindQry[prop];
1211
+ delete lookedUpFindQry[prop];
1212
+ }
1213
+ }
1214
+ if (allowNulls) {
1215
+ lookedUpFindQry = {
1216
+ $or: [
1217
+ lookedUpFindQry,
1218
+ { [lookupField]: { $exists: false } },
1219
+ ],
1220
+ };
1221
+ }
1222
+ retVal.push({ $match: lookedUpFindQry });
1223
+ }
1224
+ pipelineSection++;
1225
+ }
1226
+ }
1227
+ break;
1228
+ default:
1229
+ // anything else is either known to be dangerous, not yet needed or we don't know what it is
1230
+ throw new Error("Unsupported pipeline instruction " + keys[0]);
1231
+ }
1232
+ if (stage) {
1233
+ retVal.push(stage);
1234
+ }
1235
+ }
1236
+ if (!doneHiddenFields &&
1237
+ Object.keys(hiddenFields) &&
1238
+ Object.keys(hiddenFields).length > 0) {
1239
+ // If there was no $match we still need to hide the hidden fields
1240
+ retVal.unshift({ $project: hiddenFields });
1241
+ }
1242
+ return retVal;
1243
+ }
1244
+ reportInternal(req, resource, schema, callback) {
1245
+ let runPipelineStr;
1246
+ let runPipelineObj;
1247
+ let self = this;
1248
+ if (typeof req.query === "undefined") {
1249
+ req.query = {};
1250
+ }
1251
+ self.doFindFunc(req, resource, function (err, queryObj) {
1252
+ if (err) {
1253
+ return "There was a problem with the findFunc for model";
1254
+ }
1255
+ else {
1256
+ runPipelineStr = JSON.stringify(schema.pipeline);
1257
+ for (let param in req.query) {
1258
+ if (param !== "noinput" && req.query.hasOwnProperty(param)) {
1259
+ if (req.query[param]) {
1260
+ if (param !== "r") {
1261
+ // we don't want to copy the whole report schema (again!)
1262
+ if (schema.params[param] !== undefined) {
1263
+ schema.params[param].value = req.query[param];
698
1264
  }
699
1265
  else {
700
- cb('Invalid ref property of ' + columnTranslation.ref + ' in columnTranslations ' + columnTranslation.field);
1266
+ return callback(`No such parameter as ${param} - try one of ${Object.keys(schema.params).join()}`);
701
1267
  }
702
1268
  }
703
- });
704
- cb(null, null);
705
- }];
706
- var callFuncs = false;
707
- for (var i = 0; i < schema.columnTranslations.length; i++) {
708
- var thisColumnTranslation = schema.columnTranslations[i];
709
- if (thisColumnTranslation.field) {
710
- // if any of the column translations are adhoc funcs, set up the tasks to perform them
711
- if (thisColumnTranslation.fn) {
712
- callFuncs = true;
713
1269
  }
714
- // if this column translation is a "ref", set up the tasks to look up the values and populate the translations
715
- if (thisColumnTranslation.ref) {
716
- var lookup = self.getResource(thisColumnTranslation.ref);
717
- if (lookup) {
718
- if (!toDo[thisColumnTranslation.ref]) {
719
- var getFunc = function (ref) {
720
- var lookup = ref;
721
- return function (cb) {
722
- var translateObject = { ref: lookup.resourceName, translations: [] };
723
- translations_1.push(translateObject);
724
- lookup.model.find({}, {}, { lean: true }, function (err, findResults) {
725
- if (err) {
726
- cb(err);
727
- }
728
- else {
729
- // TODO - this ref func can probably be done away with now that list fields can have ref
730
- var j_1 = 0;
731
- async.whilst(function () {
732
- return j_1 < findResults.length;
1270
+ }
1271
+ }
1272
+ // Replace parameters with the value
1273
+ if (runPipelineStr) {
1274
+ runPipelineStr = runPipelineStr.replace(/"\(.+?\)"/g, function (match) {
1275
+ let sparam = schema.params[match.slice(2, -2)];
1276
+ if (sparam !== undefined) {
1277
+ if (sparam.type === "number") {
1278
+ return sparam.value;
1279
+ }
1280
+ else if (_.isObject(sparam.value)) {
1281
+ return JSON.stringify(sparam.value);
1282
+ }
1283
+ else if (sparam.value[0] === "{") {
1284
+ return sparam.value;
1285
+ }
1286
+ else {
1287
+ return '"' + sparam.value + '"';
1288
+ }
1289
+ }
1290
+ else {
1291
+ return callback(`No such parameter as ${match.slice(2, -2)} - try one of ${Object.keys(schema.params).join()}`);
1292
+ }
1293
+ });
1294
+ }
1295
+ runPipelineObj = JSON.parse(runPipelineStr);
1296
+ self.hackVariablesInPipeline(runPipelineObj);
1297
+ let hiddenFields = self.generateHiddenFields(resource, false);
1298
+ let toDo = {
1299
+ runAggregation: function (cb) {
1300
+ self
1301
+ .sanitisePipeline(runPipelineObj, hiddenFields, queryObj, req)
1302
+ .then((runPipelineObj) => {
1303
+ resource.model
1304
+ .aggregate(runPipelineObj)
1305
+ .then((results) => {
1306
+ cb(null, results);
1307
+ })
1308
+ .catch((err) => {
1309
+ cb(err);
1310
+ });
1311
+ })
1312
+ .catch((err) => {
1313
+ throw new Error("Error in sanitisePipeline " + err);
1314
+ });
1315
+ },
1316
+ };
1317
+ let translations = []; // array of form {ref:'lookupname',translations:[{value:xx, display:' '}]}
1318
+ // if we need to do any column translations add the function to the tasks list
1319
+ if (schema.columnTranslations) {
1320
+ toDo.applyTranslations = [
1321
+ "runAggregation",
1322
+ function (results, cb) {
1323
+ function doATranslate(column, theTranslation) {
1324
+ results["runAggregation"].forEach(function (resultRow) {
1325
+ let valToTranslate = resultRow[column.field];
1326
+ valToTranslate = valToTranslate
1327
+ ? valToTranslate.toString()
1328
+ : "";
1329
+ let thisTranslation = _.find(theTranslation.translations, function (option) {
1330
+ return valToTranslate === option.value.toString();
1331
+ });
1332
+ resultRow[column.field] = thisTranslation
1333
+ ? thisTranslation.display
1334
+ : " * Missing columnTranslation * ";
1335
+ });
1336
+ }
1337
+ schema.columnTranslations.forEach(function (columnTranslation) {
1338
+ if (columnTranslation.translations) {
1339
+ doATranslate(columnTranslation, columnTranslation);
1340
+ }
1341
+ if (columnTranslation.ref) {
1342
+ let theTranslation = _.find(translations, function (translation) {
1343
+ return translation.ref === columnTranslation.ref;
1344
+ });
1345
+ if (theTranslation) {
1346
+ doATranslate(columnTranslation, theTranslation);
1347
+ }
1348
+ else {
1349
+ cb("Invalid ref property of " +
1350
+ columnTranslation.ref +
1351
+ " in columnTranslations " +
1352
+ columnTranslation.field);
1353
+ }
1354
+ }
1355
+ });
1356
+ cb(null, null);
1357
+ },
1358
+ ];
1359
+ let callFuncs = false;
1360
+ for (let i = 0; i < schema.columnTranslations.length; i++) {
1361
+ let thisColumnTranslation = schema.columnTranslations[i];
1362
+ if (thisColumnTranslation.field) {
1363
+ // if any of the column translations are adhoc funcs, set up the tasks to perform them
1364
+ if (thisColumnTranslation.fn) {
1365
+ callFuncs = true;
1366
+ }
1367
+ // if this column translation is a "ref", set up the tasks to look up the values and populate the translations
1368
+ if (thisColumnTranslation.ref) {
1369
+ let lookup = self.getResource(thisColumnTranslation.ref);
1370
+ if (lookup) {
1371
+ if (!toDo[thisColumnTranslation.ref]) {
1372
+ let getFunc = function (ref) {
1373
+ let lookup = ref;
1374
+ return function (cb) {
1375
+ let translateObject = {
1376
+ ref: lookup.resourceName,
1377
+ translations: [],
1378
+ };
1379
+ translations.push(translateObject);
1380
+ lookup.model
1381
+ .find({}, {})
1382
+ .lean()
1383
+ .exec()
1384
+ .then((findResults) => {
1385
+ let j = 0;
1386
+ async.whilst(function (cbtest) {
1387
+ cbtest(null, j < findResults.length);
733
1388
  }, function (cbres) {
734
- var theResult = findResults[j_1];
735
- translateObject.translations[j_1] = translateObject.translations[j_1] || {};
736
- var theTranslation = translateObject.translations[j_1];
737
- j_1++;
1389
+ let theResult = findResults[j];
1390
+ translateObject.translations[j] =
1391
+ translateObject.translations[j] || {};
1392
+ let theTranslation = translateObject.translations[j];
1393
+ j++;
738
1394
  self.getListFields(lookup, theResult, function (err, description) {
739
1395
  if (err) {
740
1396
  cbres(err);
@@ -746,59 +1402,74 @@ DataForm.prototype.reportInternal = function (req, resource, schema, callback) {
746
1402
  }
747
1403
  });
748
1404
  }, cb);
749
- }
750
- });
1405
+ })
1406
+ .catch((err) => {
1407
+ cb(err);
1408
+ });
1409
+ };
751
1410
  };
752
- };
753
- toDo[thisColumnTranslation.ref] = getFunc(lookup);
754
- toDo.applyTranslations.unshift(thisColumnTranslation.ref); // Make sure we populate lookup before doing translation
1411
+ toDo[thisColumnTranslation.ref] = getFunc(lookup);
1412
+ toDo.applyTranslations.unshift(thisColumnTranslation.ref); // Make sure we populate lookup before doing translation
1413
+ }
1414
+ }
1415
+ else {
1416
+ return callback("Invalid ref property of " +
1417
+ thisColumnTranslation.ref +
1418
+ " in columnTranslations " +
1419
+ thisColumnTranslation.field);
755
1420
  }
756
1421
  }
757
- else {
758
- return callback('Invalid ref property of ' + thisColumnTranslation.ref + ' in columnTranslations ' + thisColumnTranslation.field);
1422
+ if (!thisColumnTranslation.translations &&
1423
+ !thisColumnTranslation.ref &&
1424
+ !thisColumnTranslation.fn) {
1425
+ return callback("A column translation needs a ref, fn or a translations property - " +
1426
+ thisColumnTranslation.field +
1427
+ " has neither");
759
1428
  }
760
1429
  }
761
- if (!thisColumnTranslation.translations && !thisColumnTranslation.ref && !thisColumnTranslation.fn) {
762
- return callback('A column translation needs a ref, fn or a translations property - ' + thisColumnTranslation.field + ' has neither');
1430
+ else {
1431
+ return callback("A column translation needs a field property");
763
1432
  }
764
1433
  }
765
- else {
766
- return callback('A column translation needs a field property');
767
- }
768
- }
769
- if (callFuncs) {
770
- toDo['callFunctions'] = ['runAggregation', function (results, cb) {
771
- async.each(results.runAggregation, function (row, cb) {
772
- for (var i = 0; i < schema.columnTranslations.length; i++) {
773
- var thisColumnTranslation = schema.columnTranslations[i];
774
- if (thisColumnTranslation.fn) {
775
- thisColumnTranslation.fn(row, cb);
1434
+ if (callFuncs) {
1435
+ toDo["callFunctions"] = [
1436
+ "runAggregation",
1437
+ function (results, cb) {
1438
+ async.each(results.runAggregation, function (row, cb) {
1439
+ for (let i = 0; i < schema.columnTranslations.length; i++) {
1440
+ let thisColumnTranslation = schema.columnTranslations[i];
1441
+ if (thisColumnTranslation.fn) {
1442
+ thisColumnTranslation.fn(row, cb);
1443
+ }
776
1444
  }
777
- }
778
- }, function () {
779
- cb(null);
780
- });
781
- }];
782
- toDo.applyTranslations.unshift('callFunctions'); // Make sure we do function before translating its result
1445
+ }, function () {
1446
+ cb(null);
1447
+ });
1448
+ },
1449
+ ];
1450
+ toDo.applyTranslations.unshift("callFunctions"); // Make sure we do function before translating its result
1451
+ }
783
1452
  }
1453
+ async.auto(toDo, function (err, results) {
1454
+ if (err) {
1455
+ callback(err);
1456
+ }
1457
+ else {
1458
+ callback(null, {
1459
+ success: true,
1460
+ schema: schema,
1461
+ report: results.runAggregation,
1462
+ paramsUsed: schema.params,
1463
+ });
1464
+ }
1465
+ });
784
1466
  }
785
- async.auto(toDo, function (err, results) {
786
- if (err) {
787
- callback(err);
788
- }
789
- else {
790
- // TODO: Could loop through schema.params and just send back the values
791
- callback(null, { success: true, schema: schema, report: results.runAggregation, paramsUsed: schema.params });
792
- }
793
- });
794
- }
795
- });
796
- };
797
- DataForm.prototype.saveAndRespond = function (req, res, hiddenFields) {
798
- function internalSave(doc) {
799
- doc.save(function (err, doc2) {
800
- if (err) {
801
- var err2 = { status: 'err' };
1467
+ });
1468
+ }
1469
+ saveAndRespond(req, res, hiddenFields) {
1470
+ function internalSave(doc) {
1471
+ function decorateError(err) {
1472
+ let err2 = { status: "err" };
802
1473
  if (!err.errors) {
803
1474
  err2.message = err.message;
804
1475
  }
@@ -806,18 +1477,21 @@ DataForm.prototype.saveAndRespond = function (req, res, hiddenFields) {
806
1477
  extend(err2, err);
807
1478
  }
808
1479
  if (debug) {
809
- console.log('Error saving record: ' + JSON.stringify(err2));
1480
+ console.log("Error saving record: " + JSON.stringify(err2));
810
1481
  }
811
1482
  res.status(400).send(err2);
812
1483
  }
813
- else {
814
- doc2 = doc2.toObject();
815
- for (var hiddenField in hiddenFields) {
816
- if (hiddenFields.hasOwnProperty(hiddenField) && hiddenFields[hiddenField]) {
817
- var parts = hiddenField.split('.');
818
- var lastPart = parts.length - 1;
819
- var target = doc2;
820
- for (var i = 0; i < lastPart; i++) {
1484
+ doc
1485
+ .save()
1486
+ .then((saved) => {
1487
+ saved = saved.toObject();
1488
+ for (const hiddenField in hiddenFields) {
1489
+ if (hiddenFields.hasOwnProperty(hiddenField) &&
1490
+ hiddenFields[hiddenField]) {
1491
+ let parts = hiddenField.split(".");
1492
+ let lastPart = parts.length - 1;
1493
+ let target = saved;
1494
+ for (let i = 0; i < lastPart; i++) {
821
1495
  if (target.hasOwnProperty(parts[i])) {
822
1496
  target = target[parts[i]];
823
1497
  }
@@ -827,360 +1501,741 @@ DataForm.prototype.saveAndRespond = function (req, res, hiddenFields) {
827
1501
  }
828
1502
  }
829
1503
  }
830
- res.send(doc2);
831
- }
832
- });
833
- }
834
- var doc = req.doc;
835
- if (typeof req.resource.options.onSave === 'function') {
836
- req.resource.options.onSave(doc, req, function (err) {
837
- if (err) {
838
- throw err;
839
- }
840
- internalSave(doc);
841
- });
842
- }
843
- else {
844
- internalSave(doc);
845
- }
846
- };
847
- /**
848
- * All entities REST functions have to go through this first.
849
- */
850
- DataForm.prototype.processCollection = function (req) {
851
- req.resource = this.getResource(req.params.resourceName);
852
- };
853
- /**
854
- * Renders a view with the list of docs, which may be modified by query parameters
855
- */
856
- DataForm.prototype.collectionGet = function () {
857
- return _.bind(function (req, res, next) {
858
- this.processCollection(req);
859
- if (!req.resource) {
860
- return next();
861
- }
862
- if (typeof req.query === 'undefined') {
863
- req.query = {};
864
- }
865
- try {
866
- var aggregationParam = req.query.a ? JSON.parse(req.query.a) : null;
867
- var findParam = req.query.f ? JSON.parse(req.query.f) : {};
868
- var limitParam = req.query.l ? JSON.parse(req.query.l) : 0;
869
- var skipParam = req.query.s ? JSON.parse(req.query.s) : 0;
870
- var orderParam = req.query.o ? JSON.parse(req.query.o) : req.resource.options.listOrder;
871
- // Dates in aggregation must be Dates
872
- if (aggregationParam) {
873
- this.hackVariablesInPipeline(aggregationParam);
874
- }
875
- var self_1 = this;
876
- this.filteredFind(req.resource, req, aggregationParam, findParam, orderParam, limitParam, skipParam, function (err, docs) {
877
- if (err) {
878
- return self_1.renderError(err, null, req, res, next);
1504
+ if (doc.__toClient) {
1505
+ /* Use this to pass anything back to the client that is not saved in the record
1506
+ Possible use case - sent a message saying that since this data has changed in a
1507
+ particular way the user should consider doing something else
1508
+ */
1509
+ saved.__toClient = doc.__toClient;
1510
+ }
1511
+ res.send(saved);
1512
+ })
1513
+ .catch((err) => {
1514
+ if (req.resource.options.onSaveError) {
1515
+ req.resource.options.onSaveError(err, req, res)
1516
+ .then(() => {
1517
+ decorateError(err);
1518
+ });
879
1519
  }
880
1520
  else {
881
- res.send(docs);
1521
+ decorateError(err);
882
1522
  }
883
1523
  });
884
1524
  }
885
- catch (e) {
886
- res.send(e);
887
- }
888
- }, this);
889
- };
890
- DataForm.prototype.doFindFunc = function (req, resource, cb) {
891
- if (resource.options.findFunc) {
892
- resource.options.findFunc(req, cb);
893
- }
894
- else {
895
- cb(null);
896
- }
897
- };
898
- DataForm.prototype.filteredFind = function (resource, req, aggregationParam, findParam, sortOrder, limit, skip, callback) {
899
- var that = this;
900
- var hiddenFields = this.generateHiddenFields(resource, false);
901
- var stashAggregationResults;
902
- function doAggregation(cb) {
903
- if (aggregationParam) {
904
- resource.model.aggregate(aggregationParam, function (err, aggregationResults) {
1525
+ let doc = req.doc;
1526
+ if (typeof req.resource.options.onSave === "function") {
1527
+ req.resource.options.onSave(doc, req, function (err) {
905
1528
  if (err) {
906
1529
  throw err;
907
1530
  }
908
- else {
909
- stashAggregationResults = aggregationResults;
910
- cb(_.map(aggregationResults, function (obj) {
911
- return obj._id;
912
- }));
913
- }
1531
+ internalSave(doc);
914
1532
  });
915
1533
  }
916
1534
  else {
917
- cb([]);
1535
+ internalSave(doc);
918
1536
  }
919
1537
  }
920
- that.doFindFunc(req, resource, function (err, queryObj) {
921
- if (err) {
922
- callback(err);
923
- }
924
- else {
925
- if (queryObj && aggregationParam) {
926
- var match = aggregationParam.find(function (o) { return o.hasOwnProperty('$match'); });
927
- if (match) {
928
- _.extend(match.$match, queryObj);
929
- }
930
- else {
931
- aggregationParam.unshift({ $match: queryObj });
1538
+ /**
1539
+ * All entities REST functions have to go through this first.
1540
+ */
1541
+ processCollection(req) {
1542
+ req.resource = this.getResource(req.params.resourceName);
1543
+ }
1544
+ /**
1545
+ * Renders a view with the list of docs, which may be modified by query parameters
1546
+ */
1547
+ collectionGet() {
1548
+ return _.bind(function (req, res, next) {
1549
+ this.processCollection(req);
1550
+ if (!req.resource) {
1551
+ return next();
1552
+ }
1553
+ if (typeof req.query === "undefined") {
1554
+ req.query = {};
1555
+ }
1556
+ try {
1557
+ const aggregationParam = req.query.a ? JSON.parse(req.query.a) : null;
1558
+ const findParam = req.query.f ? JSON.parse(req.query.f) : {};
1559
+ const projectParam = req.query.p ? JSON.parse(req.query.p) : {};
1560
+ const limitParam = req.query.l ? JSON.parse(req.query.l) : 0;
1561
+ const skipParam = req.query.s ? JSON.parse(req.query.s) : 0;
1562
+ const orderParam = req.query.o
1563
+ ? JSON.parse(req.query.o)
1564
+ : req.resource.options.listOrder;
1565
+ // Dates in aggregation must be Dates
1566
+ if (aggregationParam) {
1567
+ this.hackVariablesInPipeline(aggregationParam);
932
1568
  }
1569
+ const self = this;
1570
+ this.filteredFind(req.resource, req, aggregationParam, findParam, projectParam, orderParam, limitParam, skipParam, function (err, docs) {
1571
+ if (err) {
1572
+ return self.renderError(err, null, req, res);
1573
+ }
1574
+ else {
1575
+ res.send(docs);
1576
+ }
1577
+ });
1578
+ }
1579
+ catch (e) {
1580
+ res.status(400).send(e.message);
1581
+ }
1582
+ }, this);
1583
+ }
1584
+ generateProjection(hiddenFields, projectParam) {
1585
+ let type;
1586
+ function setSelectType(typeChar, checkChar) {
1587
+ if (type === checkChar) {
1588
+ throw new Error("Cannot mix include and exclude fields in select");
933
1589
  }
934
- doAggregation(function (idArray) {
935
- if (aggregationParam && idArray.length === 0) {
936
- callback(null, []);
1590
+ else {
1591
+ type = typeChar;
1592
+ }
1593
+ }
1594
+ let retVal = hiddenFields;
1595
+ if (projectParam) {
1596
+ let projection = Object.keys(projectParam);
1597
+ if (projection.length > 0) {
1598
+ projection.forEach((p) => {
1599
+ if (projectParam[p] === 0) {
1600
+ setSelectType("E", "I");
1601
+ }
1602
+ else if (projectParam[p] === 1) {
1603
+ setSelectType("I", "E");
1604
+ }
1605
+ else {
1606
+ throw new Error("Invalid projection: " + projectParam);
1607
+ }
1608
+ });
1609
+ if (type && type === "E") {
1610
+ // We are excluding fields - can just merge with hiddenFields
1611
+ Object.assign(retVal, projectParam, hiddenFields);
937
1612
  }
938
1613
  else {
939
- var query = resource.model.find(queryObj);
940
- if (idArray.length > 0) {
941
- query = query.where('_id').in(idArray);
942
- }
943
- if (findParam) {
944
- query = query.find(findParam);
945
- }
946
- query = query.select(hiddenFields);
947
- if (limit) {
948
- query = query.limit(limit);
949
- }
950
- if (skip) {
951
- query = query.skip(skip);
952
- }
953
- if (sortOrder) {
954
- query = query.sort(sortOrder);
955
- }
956
- query.exec(function (err, docs) {
957
- if (!err && stashAggregationResults) {
958
- docs.forEach(function (obj) {
959
- // Add any fields from the aggregation results whose field name starts __ to the mongoose Document
960
- var aggObj = stashAggregationResults.find(function (a) { return a._id.toString() === obj._id.toString(); });
961
- Object.keys(aggObj).forEach(function (k) {
962
- if (k.slice(0, 2) === '__') {
963
- obj[k] = aggObj[k];
964
- }
965
- });
966
- });
1614
+ // We are selecting fields - make sure none are hidden
1615
+ retVal = projectParam;
1616
+ for (let h in hiddenFields) {
1617
+ if (hiddenFields.hasOwnProperty(h)) {
1618
+ delete retVal[h];
967
1619
  }
968
- callback(err, docs);
969
- });
1620
+ }
970
1621
  }
971
- });
972
- }
973
- });
974
- };
975
- DataForm.prototype.collectionPost = function () {
976
- return _.bind(function (req, res, next) {
977
- this.processCollection(req);
978
- if (!req.resource) {
979
- next();
980
- return;
981
- }
982
- if (!req.body) {
983
- throw new Error('Nothing submitted.');
1622
+ }
984
1623
  }
985
- var cleansedBody = this.cleanseRequest(req);
986
- req.doc = new req.resource.model(cleansedBody);
987
- this.saveAndRespond(req, res);
988
- }, this);
989
- };
990
- /**
991
- * Generate an object of fields to not expose
992
- **/
993
- DataForm.prototype.generateHiddenFields = function (resource, state) {
994
- var hiddenFields = {};
995
- if (resource.options['hide'] !== undefined) {
996
- resource.options.hide.forEach(function (dt) {
997
- hiddenFields[dt] = state;
998
- });
1624
+ return retVal;
999
1625
  }
1000
- return hiddenFields;
1001
- };
1002
- /** Sec issue
1003
- * Cleanse incoming data to avoid overwrite and POST request forgery
1004
- * (name may seem weird but it was in French, so it is some small improvement!)
1005
- */
1006
- DataForm.prototype.cleanseRequest = function (req) {
1007
- var reqData = req.body, resource = req.resource;
1008
- delete reqData.__v; // Don't mess with Mongoose internal field (https://github.com/LearnBoost/mongoose/issues/1933)
1009
- if (typeof resource.options['hide'] === 'undefined') {
1010
- return reqData;
1011
- }
1012
- var hiddenFields = resource.options.hide;
1013
- _.each(reqData, function (num, key) {
1014
- _.each(hiddenFields, function (fi) {
1015
- if (fi === key) {
1016
- delete reqData[key];
1017
- }
1018
- });
1019
- });
1020
- return reqData;
1021
- };
1022
- DataForm.prototype.generateQueryForEntity = function (req, resource, id, cb) {
1023
- var hiddenFields = this.generateHiddenFields(resource, false);
1024
- hiddenFields.__v = 0;
1025
- this.doFindFunc(req, resource, function (err, queryObj) {
1026
- if (err) {
1027
- cb(err);
1626
+ doFindFunc(req, resource, cb) {
1627
+ // filter out records the user has no access to unless we are just asking for list attributes
1628
+ if (resource.options.findFunc &&
1629
+ req?.route?.path !== "/api/:resourceName/:id/list") {
1630
+ resource.options.findFunc(req, cb);
1028
1631
  }
1029
1632
  else {
1030
- var idSel = { _id: id };
1031
- var crit = queryObj ? extend(queryObj, idSel) : idSel;
1032
- cb(null, resource.model.findOne(crit).select(hiddenFields));
1633
+ cb(null);
1033
1634
  }
1034
- });
1035
- };
1036
- /*
1037
- * Entity request goes there first
1038
- * It retrieves the resource
1039
- */
1040
- DataForm.prototype.processEntity = function (req, res, next) {
1041
- if (!(req.resource = this.getResource(req.params.resourceName))) {
1042
- next();
1043
- return;
1044
1635
  }
1045
- this.generateQueryForEntity(req, req.resource, req.params.id, function (err, query) {
1046
- if (err) {
1047
- return res.send({
1048
- success: false,
1049
- err: util.inspect(err)
1050
- });
1051
- }
1052
- else {
1053
- query.exec(function (err, doc) {
1636
+ async doFindFuncPromise(req, resource) {
1637
+ return new Promise((resolve, reject) => {
1638
+ this.doFindFunc(req, resource, (err, queryObj) => {
1054
1639
  if (err) {
1055
- return res.send({
1056
- success: false,
1057
- err: util.inspect(err)
1058
- });
1640
+ reject(err);
1059
1641
  }
1060
- else if (doc == null) {
1061
- return res.send({
1062
- success: false,
1063
- err: 'Record not found'
1064
- });
1642
+ else {
1643
+ resolve(queryObj);
1065
1644
  }
1066
- req.doc = doc;
1067
- next();
1068
1645
  });
1069
- }
1070
- });
1071
- };
1072
- /**
1073
- * Gets a single entity
1074
- *
1075
- * @return {Function} The function to use as route
1076
- */
1077
- DataForm.prototype.entityGet = function () {
1078
- return _.bind(function (req, res, next) {
1079
- this.processEntity(req, res, function () {
1080
- if (!req.resource) {
1081
- return next();
1082
- }
1083
- if (req.resource.options.onAccess) {
1084
- req.resource.options.onAccess(req, function () {
1085
- return res.send(req.doc);
1646
+ });
1647
+ }
1648
+ async filteredFind(resource, req, aggregationParam, findParam, projectParam, sortOrder, limit, skip, callback) {
1649
+ const that = this;
1650
+ let hiddenFields = this.generateHiddenFields(resource, false);
1651
+ let stashAggregationResults;
1652
+ async function doAggregation(queryObj, cb) {
1653
+ if (aggregationParam) {
1654
+ aggregationParam = await that.sanitisePipeline(aggregationParam, hiddenFields, queryObj, req);
1655
+ resource.model
1656
+ .aggregate(aggregationParam)
1657
+ .then((aggregationResults) => {
1658
+ stashAggregationResults = aggregationResults;
1659
+ cb(_.map(aggregationResults, function (obj) {
1660
+ return obj._id;
1661
+ }));
1662
+ })
1663
+ .catch((err) => {
1664
+ throw err;
1086
1665
  });
1087
1666
  }
1088
1667
  else {
1089
- return res.send(req.doc);
1668
+ cb([]);
1090
1669
  }
1091
- });
1092
- }, this);
1093
- };
1094
- DataForm.prototype.replaceHiddenFields = function (record, data) {
1095
- var self = this;
1096
- if (record) {
1097
- record._replacingHiddenFields = true;
1098
- _.each(data, function (value, name) {
1099
- if (_.isObject(value)) {
1100
- self.replaceHiddenFields(record[name], value);
1670
+ }
1671
+ that.doFindFunc(req, resource, function (err, queryObj) {
1672
+ if (err) {
1673
+ callback(err);
1101
1674
  }
1102
1675
  else {
1103
- record[name] = value;
1676
+ doAggregation(queryObj, function (idArray) {
1677
+ if (aggregationParam && idArray.length === 0) {
1678
+ callback(null, []);
1679
+ }
1680
+ else {
1681
+ let query = resource.model.find(queryObj);
1682
+ if (idArray.length > 0) {
1683
+ query = query.where("_id").in(idArray);
1684
+ }
1685
+ if (findParam) {
1686
+ query = query.find(findParam);
1687
+ }
1688
+ query = query.select(that.generateProjection(hiddenFields, projectParam));
1689
+ if (limit) {
1690
+ query = query.limit(limit);
1691
+ }
1692
+ if (skip) {
1693
+ query = query.skip(skip);
1694
+ }
1695
+ if (sortOrder) {
1696
+ query = query.sort(sortOrder);
1697
+ }
1698
+ query
1699
+ .exec()
1700
+ .then((docs) => {
1701
+ if (stashAggregationResults) {
1702
+ for (const obj of docs) {
1703
+ // Add any fields from the aggregation results whose field name starts __ to the mongoose Document
1704
+ let aggObj = stashAggregationResults.find((a) => a._id.toString() === obj._id.toString());
1705
+ for (const k of Object.keys(aggObj).filter((k) => k.startsWith("__"))) {
1706
+ obj[k] = aggObj[k];
1707
+ }
1708
+ }
1709
+ }
1710
+ callback(null, docs);
1711
+ })
1712
+ .catch((err) => {
1713
+ callback(err);
1714
+ });
1715
+ }
1716
+ });
1104
1717
  }
1105
1718
  });
1106
- delete record._replacingHiddenFields;
1107
1719
  }
1108
- };
1109
- DataForm.prototype.entityPut = function () {
1110
- return _.bind(function (req, res, next) {
1111
- var that = this;
1112
- this.processEntity(req, res, function () {
1720
+ collectionPost() {
1721
+ return _.bind(function (req, res, next) {
1722
+ this.processCollection(req);
1113
1723
  if (!req.resource) {
1114
1724
  next();
1115
1725
  return;
1116
1726
  }
1117
1727
  if (!req.body) {
1118
- throw new Error('Nothing submitted.');
1728
+ throw new Error("Nothing submitted.");
1119
1729
  }
1120
- var cleansedBody = that.cleanseRequest(req);
1121
- // Merge
1122
- _.each(cleansedBody, function (value, name) {
1123
- req.doc[name] = (value === '') ? undefined : value;
1730
+ let cleansedBody = this.cleanseRequest(req);
1731
+ req.doc = new req.resource.model(cleansedBody);
1732
+ this.saveAndRespond(req, res);
1733
+ }, this);
1734
+ }
1735
+ /**
1736
+ * Generate an object of fields to not expose
1737
+ **/
1738
+ generateHiddenFields(resource, state) {
1739
+ let hiddenFields = {};
1740
+ if (resource.options["hide"] !== undefined) {
1741
+ resource.options.hide.forEach(function (dt) {
1742
+ hiddenFields[dt] = state;
1124
1743
  });
1125
- if (req.resource.options.hide !== undefined) {
1126
- var hiddenFields_1 = that.generateHiddenFields(req.resource, true);
1127
- hiddenFields_1._id = false;
1128
- req.resource.model.findById(req.doc._id, hiddenFields_1, { lean: true }, function (err, data) {
1129
- that.replaceHiddenFields(req.doc, data);
1130
- that.saveAndRespond(req, res, hiddenFields_1);
1744
+ }
1745
+ return hiddenFields;
1746
+ }
1747
+ /** Sec issue
1748
+ * Cleanse incoming data to avoid overwrite and POST request forgery
1749
+ * (name may seem weird but it was in French, so it is some small improvement!)
1750
+ */
1751
+ cleanseRequest(req) {
1752
+ let reqData = req.body, resource = req.resource;
1753
+ if (typeof resource.options["hide"] === "undefined") {
1754
+ return reqData;
1755
+ }
1756
+ let hiddenFields = resource.options.hide;
1757
+ _.each(reqData, function (num, key) {
1758
+ _.each(hiddenFields, function (fi) {
1759
+ if (fi === key) {
1760
+ delete reqData[key];
1761
+ }
1762
+ });
1763
+ });
1764
+ return reqData;
1765
+ }
1766
+ generateQueryForEntity(req, resource, id, cb) {
1767
+ let that = this;
1768
+ let hiddenFields = this.generateHiddenFields(resource, false);
1769
+ that.doFindFunc(req, resource, function (err, queryObj) {
1770
+ if (err) {
1771
+ cb(err);
1772
+ }
1773
+ else {
1774
+ const idSel = { _id: id };
1775
+ let crit;
1776
+ if (queryObj) {
1777
+ if (queryObj._id) {
1778
+ crit = { $and: [idSel, { _id: queryObj._id }] };
1779
+ delete queryObj._id;
1780
+ if (Object.keys(queryObj).length > 0) {
1781
+ crit = extend(crit, queryObj);
1782
+ }
1783
+ }
1784
+ else {
1785
+ crit = extend(queryObj, idSel);
1786
+ }
1787
+ }
1788
+ else {
1789
+ crit = idSel;
1790
+ }
1791
+ cb(null, resource.model
1792
+ .findOne(crit)
1793
+ .select(that.generateProjection(hiddenFields, req.query?.p)));
1794
+ }
1795
+ });
1796
+ }
1797
+ /*
1798
+ * Entity request goes here first
1799
+ * It retrieves the resource
1800
+ */
1801
+ processEntity(req, res, next) {
1802
+ if (!(req.resource = this.getResource(req.params.resourceName))) {
1803
+ return next();
1804
+ }
1805
+ this.generateQueryForEntity(req, req.resource, req.params.id, function (err, query) {
1806
+ if (err) {
1807
+ return res.status(500).send({
1808
+ success: false,
1809
+ err: util.inspect(err),
1131
1810
  });
1132
1811
  }
1133
1812
  else {
1134
- that.saveAndRespond(req, res);
1813
+ query
1814
+ .exec()
1815
+ .then((doc) => {
1816
+ if (doc) {
1817
+ req.doc = doc;
1818
+ return next();
1819
+ }
1820
+ else {
1821
+ return res.status(404).send({
1822
+ success: false,
1823
+ err: "Record not found",
1824
+ });
1825
+ }
1826
+ })
1827
+ .catch((err) => {
1828
+ return res.status(400).send({
1829
+ success: false,
1830
+ err: util.inspect(err),
1831
+ });
1832
+ });
1135
1833
  }
1136
1834
  });
1137
- }, this);
1138
- };
1139
- DataForm.prototype.entityDelete = function () {
1140
- return _.bind(function (req, res, next) {
1141
- function internalRemove(doc) {
1142
- doc.remove(function (err) {
1143
- if (err) {
1144
- return res.send({ success: false });
1835
+ }
1836
+ /**
1837
+ * Gets a single entity
1838
+ *
1839
+ * @return {Function} The function to use as route
1840
+ */
1841
+ entityGet() {
1842
+ return _.bind(function (req, res, next) {
1843
+ this.processEntity(req, res, function () {
1844
+ if (!req.resource) {
1845
+ return next();
1846
+ }
1847
+ if (req.resource.options.onAccess) {
1848
+ req.resource.options.onAccess(req, function () {
1849
+ return res.status(200).send(req.doc);
1850
+ });
1851
+ }
1852
+ else {
1853
+ return res.status(200).send(req.doc);
1145
1854
  }
1146
- return res.send({ success: true });
1147
1855
  });
1856
+ }, this);
1857
+ }
1858
+ replaceHiddenFields(record, data) {
1859
+ const self = this;
1860
+ if (record) {
1861
+ record._replacingHiddenFields = true;
1862
+ _.each(data, function (value, name) {
1863
+ if (_.isObject(value) && !Array.isArray(value)) {
1864
+ self.replaceHiddenFields(record[name], value);
1865
+ }
1866
+ else if (!record[name]) {
1867
+ record[name] = value;
1868
+ }
1869
+ });
1870
+ delete record._replacingHiddenFields;
1148
1871
  }
1149
- this.processEntity(req, res, function () {
1150
- if (!req.resource) {
1151
- next();
1152
- return;
1872
+ }
1873
+ entityPut() {
1874
+ return _.bind(function (req, res, next) {
1875
+ const that = this;
1876
+ this.processEntity(req, res, function () {
1877
+ if (!req.resource) {
1878
+ next();
1879
+ return;
1880
+ }
1881
+ if (!req.body) {
1882
+ throw new Error("Nothing submitted.");
1883
+ }
1884
+ let cleansedBody = that.cleanseRequest(req);
1885
+ // Merge
1886
+ for (let prop in cleansedBody) {
1887
+ if (cleansedBody.hasOwnProperty(prop)) {
1888
+ req.doc.set(prop, cleansedBody[prop] === "" ? undefined : cleansedBody[prop]);
1889
+ }
1890
+ }
1891
+ if (req.resource.options.hide !== undefined) {
1892
+ let hiddenFields = that.generateHiddenFields(req.resource, true);
1893
+ hiddenFields._id = false;
1894
+ req.resource.model
1895
+ .findById(req.doc._id, hiddenFields)
1896
+ .lean()
1897
+ .exec()
1898
+ .then((data) => {
1899
+ that.replaceHiddenFields(req.doc, data);
1900
+ that.saveAndRespond(req, res, hiddenFields);
1901
+ })
1902
+ .catch((err) => {
1903
+ throw err; // not sure if this is the right thing to do - didn't have any error-handing here in earlier version
1904
+ });
1905
+ }
1906
+ else {
1907
+ that.saveAndRespond(req, res);
1908
+ }
1909
+ });
1910
+ }, this);
1911
+ }
1912
+ generateDependencyList(resource) {
1913
+ if (resource.options.dependents === undefined) {
1914
+ let that = this;
1915
+ resource.options.dependents = that.resources.reduce(function (acc, r) {
1916
+ function searchPaths(schema, prefix) {
1917
+ let fldList = [];
1918
+ for (let fld in schema.paths) {
1919
+ if (schema.paths.hasOwnProperty(fld)) {
1920
+ const parts = fld.split(".");
1921
+ let schemaType = schema.tree;
1922
+ while (parts.length > 0) {
1923
+ schemaType = schemaType[parts.shift()];
1924
+ }
1925
+ if (schemaType.type) {
1926
+ if (schemaType.type.name === "ObjectId" &&
1927
+ schemaType.ref === resource.resourceName) {
1928
+ fldList.push(prefix + fld);
1929
+ }
1930
+ else if (_.isArray(schemaType.type)) {
1931
+ schemaType.type.forEach(function (t) {
1932
+ searchPaths(t, prefix + fld + ".");
1933
+ });
1934
+ }
1935
+ }
1936
+ }
1937
+ }
1938
+ if (fldList.length > 0) {
1939
+ acc.push({ resource: r, keys: fldList });
1940
+ }
1941
+ }
1942
+ searchPaths(r.model.schema, "");
1943
+ return acc;
1944
+ }, []);
1945
+ for (let pluginName in that.options.plugins) {
1946
+ let thisPlugin = that.options.plugins[pluginName];
1947
+ if (thisPlugin.dependencyChecks &&
1948
+ thisPlugin.dependencyChecks[resource.resourceName]) {
1949
+ resource.options.dependents = resource.options.dependents.concat(thisPlugin.dependencyChecks[resource.resourceName]);
1950
+ }
1153
1951
  }
1154
- var doc = req.doc;
1155
- if (typeof req.resource.options.onRemove === 'function') {
1156
- req.resource.options.onRemove(doc, req, function (err) {
1157
- if (err) {
1158
- throw err;
1952
+ }
1953
+ }
1954
+ async getDependencies(resource, id) {
1955
+ this.generateDependencyList(resource);
1956
+ let promises = [];
1957
+ let foreignKeyList = [];
1958
+ resource.options.dependents.forEach((collection) => {
1959
+ collection.keys.forEach((key) => {
1960
+ promises.push({
1961
+ p: collection.resource.model
1962
+ .find({ [key]: id })
1963
+ .limit(1)
1964
+ .exec(),
1965
+ collection,
1966
+ key,
1967
+ });
1968
+ });
1969
+ });
1970
+ return Promise.all(promises.map((p) => p.p)).then((results) => {
1971
+ results.forEach((r, i) => {
1972
+ if (r.length > 0) {
1973
+ foreignKeyList.push({
1974
+ resourceName: promises[i].collection.resource.resourceName,
1975
+ key: promises[i].key,
1976
+ id: r[0]._id,
1977
+ });
1978
+ }
1979
+ });
1980
+ return foreignKeyList;
1981
+ });
1982
+ }
1983
+ entityDelete() {
1984
+ let that = this;
1985
+ return _.bind(async function (req, res, next) {
1986
+ async function removeDoc(doc, resource) {
1987
+ switch (resource.options.handleRemove) {
1988
+ case "allow":
1989
+ // old behaviour - no attempt to maintain data integrity
1990
+ return doc.deleteOne();
1991
+ case "cascade":
1992
+ res.status(400).send('"cascade" option not yet supported');
1993
+ break;
1994
+ default:
1995
+ return that
1996
+ .getDependencies(resource, doc._id)
1997
+ .then((dependencies) => {
1998
+ if (dependencies.length > 0) {
1999
+ throw new ForeignKeyError(resource.resourceName, dependencies);
2000
+ }
2001
+ return doc.deleteOne();
2002
+ });
2003
+ }
2004
+ }
2005
+ async function runDeletion(doc, resource) {
2006
+ return new Promise((resolve) => {
2007
+ if (resource.options.onRemove) {
2008
+ resource.options.onRemove(doc, req, async function (err) {
2009
+ if (err) {
2010
+ throw err;
2011
+ }
2012
+ resolve(removeDoc(doc, resource));
2013
+ });
2014
+ }
2015
+ else {
2016
+ resolve(removeDoc(doc, resource));
1159
2017
  }
1160
- internalRemove(doc);
1161
2018
  });
1162
2019
  }
2020
+ this.processEntity(req, res, async function () {
2021
+ if (!req.resource) {
2022
+ next();
2023
+ return;
2024
+ }
2025
+ let doc = req.doc;
2026
+ try {
2027
+ void (await runDeletion(doc, req.resource));
2028
+ res.status(200).send();
2029
+ }
2030
+ catch (e) {
2031
+ if (e instanceof ForeignKeyError) {
2032
+ res.status(400).send(e.message);
2033
+ }
2034
+ else {
2035
+ res.status(500).send(e.message);
2036
+ }
2037
+ }
2038
+ });
2039
+ }, this);
2040
+ }
2041
+ entityList() {
2042
+ return _.bind(function (req, res, next) {
2043
+ const that = this;
2044
+ this.processEntity(req, res, function () {
2045
+ if (!req.resource) {
2046
+ return next();
2047
+ }
2048
+ const returnRawParam = req.query?.returnRaw
2049
+ ? !!JSON.parse(req.query.returnRaw)
2050
+ : false;
2051
+ if (returnRawParam) {
2052
+ const result = { _id: req.doc._id };
2053
+ for (const field of req.resource.options.listFields) {
2054
+ result[field.field] = req.doc[field.field];
2055
+ }
2056
+ return res.send(result);
2057
+ }
2058
+ else {
2059
+ that.getListFields(req.resource, req.doc, function (err, display) {
2060
+ if (err) {
2061
+ return res.status(500).send(err);
2062
+ }
2063
+ else {
2064
+ return res.send({ list: display });
2065
+ }
2066
+ });
2067
+ }
2068
+ });
2069
+ }, this);
2070
+ }
2071
+ // To disambiguate the contents of items - assumed to be the results of a single resource lookup or search -
2072
+ // pass the result of this function as the second argument to the disambiguate() function.
2073
+ // equalityProps should identify the property(s) of the items that must ALL be equal for two items to
2074
+ // be considered ambiguous.
2075
+ // disambiguationResourceName should identify the resource whose list field(s) should be used to generate
2076
+ // the disambiguation text for ambiguous results later.
2077
+ buildSingleResourceAmbiguousRecordStore(items, equalityProps, disambiguationResourceName) {
2078
+ const ambiguousResults = [];
2079
+ for (let i = 0; i < items.length - 1; i++) {
2080
+ for (let j = i + 1; j < items.length; j++) {
2081
+ if (!equalityProps.some((p) => items[i][p] !== items[j][p])) {
2082
+ if (!ambiguousResults.includes(items[i])) {
2083
+ ambiguousResults.push(items[i]);
2084
+ }
2085
+ if (!ambiguousResults.includes(items[j])) {
2086
+ ambiguousResults.push(items[j]);
2087
+ }
2088
+ }
2089
+ }
2090
+ }
2091
+ return { [disambiguationResourceName]: ambiguousResults };
2092
+ }
2093
+ // An alternative to buildSingleResourceAmbiguousRecordStore() for use when disambiguating the results of a
2094
+ // multi-resource lookup or search. In this case, all items need to include the name of the resource that
2095
+ // will be used (when necessary) to yield their disambiguation text later. The property of items that holds
2096
+ // that resource name should be identified by the disambiguationResourceNameProp parameter.
2097
+ // The scary-looking templating used here ensures that the items really do all have an (optional) string
2098
+ // property with the name identified by disambiguationResourceNameProp. For the avoidance of doubt, "prop"
2099
+ // here could be anything - "foo in f" would achieve the same result.
2100
+ buildMultiResourceAmbiguousRecordStore(items, equalityProps, disambiguationResourceNameProp) {
2101
+ const store = {};
2102
+ for (let i = 0; i < items.length - 1; i++) {
2103
+ for (let j = i + 1; j < items.length; j++) {
2104
+ if (items[i][disambiguationResourceNameProp] &&
2105
+ items[i][disambiguationResourceNameProp] ===
2106
+ items[j][disambiguationResourceNameProp] &&
2107
+ !equalityProps.some((p) => items[i][p] !== items[j][p])) {
2108
+ if (!store[items[i][disambiguationResourceNameProp]]) {
2109
+ store[items[i][disambiguationResourceNameProp]] = [];
2110
+ }
2111
+ if (!store[items[i][disambiguationResourceNameProp]].includes(items[i])) {
2112
+ store[items[i][disambiguationResourceNameProp]].push(items[i]);
2113
+ }
2114
+ if (!store[items[i][disambiguationResourceNameProp]].includes(items[j])) {
2115
+ store[items[i][disambiguationResourceNameProp]].push(items[j]);
2116
+ }
2117
+ }
2118
+ }
2119
+ }
2120
+ return store;
2121
+ }
2122
+ // return just the id and list fields for all of the records from req.resource.
2123
+ // list fields are those whose schema entry has a value for the "list" attribute (except where this is { ref: true })
2124
+ // if the resource has no explicit list fields identified, the first string field will be returned. if the resource
2125
+ // doesn't have any string fields either, the first (non-id) field will be returned.
2126
+ // usually, we will respond with an array of ILookupItem objects, where the .text property of those objects is the concatenation
2127
+ // of all of the document's list fields (space-seperated).
2128
+ // to request the documents without this transformation applied, include "c=true" in the query string.
2129
+ // the query string can also be used to filter and order the response, by providing values for "f" (find), "l" (limit),
2130
+ // "s" (skip) and/or "o" (order).
2131
+ // results will be disambiguated if req.resource includes disambiguation parameters in its resource options.
2132
+ // where c=true, the disambiguation will be added as a suffix to the .text property of the returned ILookupItem objects.
2133
+ // otherwise, if the resource has just one list field, the disambiguation will be appended to the values of that field, and if
2134
+ // it has multiple list fields, it will be returned as an additional property of the returned (untransformed) objects
2135
+ internalEntityListAll(req, callback) {
2136
+ const projection = this.generateListFieldProjection(req.resource);
2137
+ const listFields = Object.keys(projection);
2138
+ const aggregationParam = req.query.a ? JSON.parse(req.query.a) : null;
2139
+ const findParam = req.query.f ? JSON.parse(req.query.f) : {};
2140
+ const limitParam = req.query.l ? JSON.parse(req.query.l) : 0;
2141
+ const skipParam = req.query.s ? JSON.parse(req.query.s) : 0;
2142
+ const orderParam = req.query.o
2143
+ ? JSON.parse(req.query.o)
2144
+ : req.resource.options.listOrder;
2145
+ const concatenateParam = req.query.c ? JSON.parse(req.query.c) : true;
2146
+ const resOpts = req.resource.options;
2147
+ let disambiguationField;
2148
+ let disambiguationResourceName;
2149
+ if (resOpts?.disambiguation) {
2150
+ disambiguationField = resOpts.disambiguation.field;
2151
+ if (disambiguationField) {
2152
+ projection[disambiguationField] = 1;
2153
+ disambiguationResourceName = resOpts.disambiguation.resource;
2154
+ }
2155
+ }
2156
+ const that = this;
2157
+ this.filteredFind(req.resource, req, aggregationParam, findParam, projection, orderParam, limitParam, skipParam, function (err, docs) {
2158
+ if (err) {
2159
+ return callback(err);
2160
+ }
1163
2161
  else {
1164
- internalRemove(doc);
2162
+ docs = docs.map((d) => d.toObject());
2163
+ if (concatenateParam) {
2164
+ const transformed = docs.map((doc) => {
2165
+ let text = "";
2166
+ for (const field of listFields) {
2167
+ if (doc[field]) {
2168
+ if (text !== "") {
2169
+ text += " ";
2170
+ }
2171
+ text += doc[field];
2172
+ }
2173
+ }
2174
+ const disambiguationId = disambiguationField
2175
+ ? doc[disambiguationField]
2176
+ : undefined;
2177
+ return { id: doc._id, text, disambiguationId };
2178
+ });
2179
+ if (disambiguationResourceName) {
2180
+ that.disambiguate(transformed, that.buildSingleResourceAmbiguousRecordStore(transformed, ["text"], disambiguationResourceName), "disambiguationId", (item, disambiguationText) => {
2181
+ item.text += ` (${disambiguationText})`;
2182
+ }, (err) => {
2183
+ callback(err, transformed);
2184
+ });
2185
+ }
2186
+ else {
2187
+ return callback(null, transformed);
2188
+ }
2189
+ }
2190
+ else {
2191
+ if (disambiguationResourceName) {
2192
+ that.disambiguate(docs, that.buildSingleResourceAmbiguousRecordStore(docs, listFields, disambiguationResourceName), disambiguationField, (item, disambiguationText) => {
2193
+ if (listFields.length === 1) {
2194
+ item[listFields[0]] += ` (${disambiguationText})`;
2195
+ }
2196
+ else {
2197
+ // store the text against hard-coded property name "disambiguation", rather than (say) using
2198
+ // item[disambiguationResourceName], because if disambiguationResourceName === disambiguationField,
2199
+ // that value would end up being deleted again when the this.disambiguate() call (which we have
2200
+ // been called from) does its final tidy-up and deletes [disambiguationField] from all of the items in docs
2201
+ item.disambiguation = disambiguationText;
2202
+ }
2203
+ }, (err) => {
2204
+ callback(err, docs);
2205
+ });
2206
+ }
2207
+ else {
2208
+ return callback(null, docs);
2209
+ }
2210
+ }
1165
2211
  }
1166
2212
  });
1167
- }, this);
1168
- };
1169
- DataForm.prototype.entityList = function () {
1170
- return _.bind(function (req, res, next) {
1171
- var that = this;
1172
- this.processEntity(req, res, function () {
1173
- if (!req.resource) {
2213
+ }
2214
+ entityListAll() {
2215
+ return _.bind(function (req, res, next) {
2216
+ if (!(req.resource = this.getResource(req.params.resourceName))) {
1174
2217
  return next();
1175
2218
  }
1176
- that.getListFields(req.resource, req.doc, function (err, display) {
2219
+ this.internalEntityListAll(req, function (err, resultsObject) {
1177
2220
  if (err) {
1178
- return res.status(500).send(err);
2221
+ res.status(400, err);
1179
2222
  }
1180
2223
  else {
1181
- return res.send({ list: display });
2224
+ res.send(resultsObject);
1182
2225
  }
1183
2226
  });
1184
- });
1185
- }, this);
1186
- };
2227
+ }, this);
2228
+ }
2229
+ extractTimestampFromMongoID(record) {
2230
+ let timestamp = record.toString().substring(0, 8);
2231
+ return new Date(parseInt(timestamp, 16) * 1000);
2232
+ }
2233
+ }
2234
+ exports.FormsAngular = FormsAngular;
2235
+ class ForeignKeyError extends global.Error {
2236
+ constructor(resourceName, foreignKeys) {
2237
+ super(`Cannot delete this ${resourceName}, as it is: ${foreignKeys.map((d) => ` the ${d.key} on ${d.resourceName} ${d.id}`).join("; ")}`);
2238
+ this.name = "ForeignKeyError";
2239
+ this.stack = new global.Error("").stack;
2240
+ }
2241
+ }