forms-angular 0.12.0-beta.23 → 0.12.0-beta.231

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