@bedrockio/model 0.11.3 → 0.12.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.12.1
2
+
3
+ - Allow range-based search on string fields.
4
+
5
+ ## 0.12.0
6
+
7
+ - Handle aggregate pipelines in search.
8
+
1
9
  ## 0.11.3
2
10
 
3
11
  - Added warning when id field not passed for unique check.
package/dist/cjs/const.js CHANGED
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.SEARCH_DEFAULTS = exports.POPULATE_MAX_DEPTH = void 0;
7
7
  const SEARCH_DEFAULTS = exports.SEARCH_DEFAULTS = {
8
+ skip: 0,
8
9
  limit: 50,
9
10
  sort: {
10
11
  field: '_id',
@@ -25,44 +25,12 @@ function applySearch(schema, definition) {
25
25
  const {
26
26
  search: config = {}
27
27
  } = definition;
28
- schema.static('search', function search(body = {}) {
29
- const options = mergeOptions(_const.SEARCH_DEFAULTS, config.query, body);
30
- const {
31
- ids,
32
- keyword,
33
- skip = 0,
34
- limit,
35
- sort,
36
- ...rest
37
- } = options;
38
- let query = normalizeQuery(rest, schema.obj);
39
- if (ids?.length) {
40
- query = {
41
- ...query,
42
- _id: {
43
- $in: ids
44
- }
45
- };
46
- }
47
- if (keyword) {
48
- const keywordQuery = buildKeywordQuery(schema, keyword, config);
49
- query = (0, _query.mergeQuery)(query, keywordQuery);
50
- }
51
- if (_env.debug) {
52
- _logger.default.info(`Search query for ${this.modelName}:\n`, JSON.stringify(query, null, 2));
28
+ schema.static('search', function search(...args) {
29
+ if (Array.isArray(args[0])) {
30
+ return searchPipeline(this, args[0], args[1]);
31
+ } else {
32
+ return searchQuery(this, args[0], config);
53
33
  }
54
- const mQuery = this.find(query).sort(resolveSort(sort, schema)).skip(skip).limit(limit);
55
- return (0, _query.wrapQuery)(mQuery, async promise => {
56
- const [data, total] = await Promise.all([promise, this.countDocuments(query)]);
57
- return {
58
- data,
59
- meta: {
60
- total,
61
- skip,
62
- limit
63
- }
64
- };
65
- });
66
34
  });
67
35
  }
68
36
  function searchValidation(options = {}) {
@@ -89,6 +57,90 @@ function searchValidation(options = {}) {
89
57
  ...appendSchema
90
58
  });
91
59
  }
60
+ function searchQuery(Model, options, config) {
61
+ const {
62
+ schema
63
+ } = Model;
64
+ options = mergeOptions(_const.SEARCH_DEFAULTS, options);
65
+ let {
66
+ ids,
67
+ keyword,
68
+ skip,
69
+ limit,
70
+ sort,
71
+ ...rest
72
+ } = options;
73
+ sort = resolveSort(sort, schema);
74
+ let query = normalizeQuery(rest, schema.obj);
75
+ if (ids?.length) {
76
+ query = (0, _query.mergeQuery)(query, {
77
+ _id: {
78
+ $in: ids
79
+ }
80
+ });
81
+ }
82
+ if (keyword) {
83
+ const keywordQuery = buildKeywordQuery(schema, keyword, config);
84
+ query = (0, _query.mergeQuery)(query, keywordQuery);
85
+ }
86
+ if (_env.debug) {
87
+ _logger.default.info(`Search query for ${Model.modelName}:\n`, JSON.stringify(query, null, 2));
88
+ }
89
+ const mQuery = Model.find(query).sort(sort).skip(skip).limit(limit);
90
+ return (0, _query.wrapQuery)(mQuery, async promise => {
91
+ const [data, total] = await Promise.all([promise, Model.countDocuments(query)]);
92
+ return {
93
+ data,
94
+ meta: {
95
+ total,
96
+ skip,
97
+ limit
98
+ }
99
+ };
100
+ });
101
+ }
102
+ function searchPipeline(Model, pipeline, options) {
103
+ const {
104
+ schema
105
+ } = Model;
106
+ options = mergeOptions(_const.SEARCH_DEFAULTS, options);
107
+ let {
108
+ skip,
109
+ limit,
110
+ sort
111
+ } = options;
112
+ sort = resolveSort(sort, schema);
113
+ if (_env.debug) {
114
+ _logger.default.info(`Search pipeline for ${Model.modelName}:\n`, JSON.stringify(pipeline, null, 2));
115
+ }
116
+ const aggregate = Model.aggregate([...pipeline, {
117
+ $facet: {
118
+ data: [{
119
+ $sort: sort
120
+ }, {
121
+ $skip: skip
122
+ }, {
123
+ $limit: limit
124
+ }],
125
+ meta: [{
126
+ $count: 'total'
127
+ }]
128
+ }
129
+ }]);
130
+ return (0, _query.wrapQuery)(aggregate, async promise => {
131
+ const result = await promise;
132
+ const data = result[0].data;
133
+ const total = result[0].meta[0]?.total ?? 0;
134
+ return {
135
+ data,
136
+ meta: {
137
+ skip,
138
+ limit,
139
+ total
140
+ }
141
+ };
142
+ });
143
+ }
92
144
  function getSortSchema(sort) {
93
145
  const schema = _yada.default.object({
94
146
  field: _yada.default.string().required(),
@@ -104,13 +156,18 @@ function validateDefinition(definition) {
104
156
  }
105
157
  function resolveSort(sort, schema) {
106
158
  if (!sort) {
107
- sort = [];
108
- } else if (!Array.isArray(sort)) {
159
+ return {
160
+ _id: 1
161
+ };
162
+ }
163
+ const result = {};
164
+ if (!Array.isArray(sort)) {
109
165
  sort = [sort];
110
166
  }
111
167
  for (let {
112
168
  name,
113
- field
169
+ field,
170
+ order
114
171
  } of sort) {
115
172
  if (name) {
116
173
  throw new Error('Sort property "name" is not allowed. Use "field" instead.');
@@ -118,13 +175,9 @@ function resolveSort(sort, schema) {
118
175
  if (!field.startsWith('$') && !schema.path(field)) {
119
176
  throw new Error(`Unknown sort field "${field}".`);
120
177
  }
178
+ result[field] = order === 'desc' ? -1 : 1;
121
179
  }
122
- return sort.map(({
123
- field,
124
- order
125
- }) => {
126
- return [field, order === 'desc' ? -1 : 1];
127
- });
180
+ return result;
128
181
  }
129
182
 
130
183
  // Keyword queries
@@ -307,11 +360,12 @@ function isEmptyArrayQuery(schema, key, value) {
307
360
  return !isMongoOperator(key) && (0, _utils.isArrayField)(schema, key) && value === null;
308
361
  }
309
362
  function isRangeQuery(schema, key, value) {
310
- // Range queries only allowed on Date and Number fields.
311
- if (!(0, _utils.isDateField)(schema, key) && !(0, _utils.isNumberField)(schema, key)) {
363
+ if (!(0, _lodash.isPlainObject)(value)) {
312
364
  return false;
313
365
  }
314
- return typeof value === 'object' && !!value;
366
+
367
+ // Range queries allowed on Date, Number, and String fields.
368
+ return (0, _utils.isDateField)(schema, key) || (0, _utils.isNumberField)(schema, key) || (0, _utils.isStringField)(schema, key);
315
369
  }
316
370
  function mapOperatorQuery(obj) {
317
371
  const query = {};
package/dist/cjs/utils.js CHANGED
@@ -12,6 +12,7 @@ exports.isMongooseSchema = isMongooseSchema;
12
12
  exports.isNumberField = isNumberField;
13
13
  exports.isReferenceField = isReferenceField;
14
14
  exports.isSchemaTypedef = isSchemaTypedef;
15
+ exports.isStringField = isStringField;
15
16
  exports.resolveRefPath = resolveRefPath;
16
17
  var _mongoose = _interopRequireDefault(require("mongoose"));
17
18
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -50,6 +51,9 @@ function isDateField(obj, path) {
50
51
  function isNumberField(obj, path) {
51
52
  return isType(obj, path, 'Number');
52
53
  }
54
+ function isStringField(obj, path) {
55
+ return isType(obj, path, 'String');
56
+ }
53
57
  function isArrayField(obj, path) {
54
58
  const field = getField(obj, path);
55
59
  return Array.isArray(field?.type);
@@ -23,7 +23,7 @@ const DATE_TAGS = {
23
23
  'x-schema': 'DateTime',
24
24
  'x-description': 'A `string` in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format.'
25
25
  };
26
- const OBJECT_ID_SCHEMA = exports.OBJECT_ID_SCHEMA = _yada.default.string().mongo().message('Must be an id.').tag({
26
+ const OBJECT_ID_SCHEMA = exports.OBJECT_ID_SCHEMA = _yada.default.string().mongo().message('Must be a valid object id.').tag({
27
27
  'x-schema': 'ObjectId',
28
28
  'x-description': 'A 24 character hexadecimal string representing a Mongo [ObjectId](https://bit.ly/3YPtGlU).'
29
29
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrockio/model",
3
- "version": "0.11.3",
3
+ "version": "0.12.1",
4
4
  "description": "Bedrock utilities for model creation.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-env": "^7.26.0",
40
40
  "@bedrockio/eslint-plugin": "^1.1.7",
41
41
  "@bedrockio/prettier-config": "^1.0.2",
42
- "@bedrockio/yada": "^1.4.1",
42
+ "@bedrockio/yada": "^1.4.2",
43
43
  "@shelf/jest-mongodb": "^5.1.0",
44
44
  "eslint": "^9.19.0",
45
45
  "jest": "^29.7.0",
package/src/const.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export const SEARCH_DEFAULTS = {
2
+ skip: 0,
2
3
  limit: 50,
3
4
  sort: {
4
5
  field: '_id',
package/src/search.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  isArrayField,
9
9
  isDateField,
10
10
  isNumberField,
11
+ isStringField,
11
12
  resolveRefPath,
12
13
  } from './utils';
13
14
 
@@ -26,51 +27,12 @@ export function applySearch(schema, definition) {
26
27
 
27
28
  const { search: config = {} } = definition;
28
29
 
29
- schema.static('search', function search(body = {}) {
30
- const options = mergeOptions(SEARCH_DEFAULTS, config.query, body);
31
-
32
- const { ids, keyword, skip = 0, limit, sort, ...rest } = options;
33
-
34
- let query = normalizeQuery(rest, schema.obj);
35
-
36
- if (ids?.length) {
37
- query = {
38
- ...query,
39
- _id: { $in: ids },
40
- };
41
- }
42
-
43
- if (keyword) {
44
- const keywordQuery = buildKeywordQuery(schema, keyword, config);
45
- query = mergeQuery(query, keywordQuery);
46
- }
47
-
48
- if (debug) {
49
- logger.info(
50
- `Search query for ${this.modelName}:\n`,
51
- JSON.stringify(query, null, 2),
52
- );
30
+ schema.static('search', function search(...args) {
31
+ if (Array.isArray(args[0])) {
32
+ return searchPipeline(this, args[0], args[1]);
33
+ } else {
34
+ return searchQuery(this, args[0], config);
53
35
  }
54
-
55
- const mQuery = this.find(query)
56
- .sort(resolveSort(sort, schema))
57
- .skip(skip)
58
- .limit(limit);
59
-
60
- return wrapQuery(mQuery, async (promise) => {
61
- const [data, total] = await Promise.all([
62
- promise,
63
- this.countDocuments(query),
64
- ]);
65
- return {
66
- data,
67
- meta: {
68
- total,
69
- skip,
70
- limit,
71
- },
72
- };
73
- });
74
36
  });
75
37
  }
76
38
 
@@ -101,6 +63,105 @@ export function searchValidation(options = {}) {
101
63
  });
102
64
  }
103
65
 
66
+ function searchQuery(Model, options, config) {
67
+ const { schema } = Model;
68
+
69
+ options = mergeOptions(SEARCH_DEFAULTS, options);
70
+ let { ids, keyword, skip, limit, sort, ...rest } = options;
71
+
72
+ sort = resolveSort(sort, schema);
73
+
74
+ let query = normalizeQuery(rest, schema.obj);
75
+
76
+ if (ids?.length) {
77
+ query = mergeQuery(query, {
78
+ _id: { $in: ids },
79
+ });
80
+ }
81
+
82
+ if (keyword) {
83
+ const keywordQuery = buildKeywordQuery(schema, keyword, config);
84
+ query = mergeQuery(query, keywordQuery);
85
+ }
86
+
87
+ if (debug) {
88
+ logger.info(
89
+ `Search query for ${Model.modelName}:\n`,
90
+ JSON.stringify(query, null, 2),
91
+ );
92
+ }
93
+
94
+ const mQuery = Model.find(query).sort(sort).skip(skip).limit(limit);
95
+
96
+ return wrapQuery(mQuery, async (promise) => {
97
+ const [data, total] = await Promise.all([
98
+ promise,
99
+ Model.countDocuments(query),
100
+ ]);
101
+ return {
102
+ data,
103
+ meta: {
104
+ total,
105
+ skip,
106
+ limit,
107
+ },
108
+ };
109
+ });
110
+ }
111
+
112
+ function searchPipeline(Model, pipeline, options) {
113
+ const { schema } = Model;
114
+ options = mergeOptions(SEARCH_DEFAULTS, options);
115
+
116
+ let { skip, limit, sort } = options;
117
+ sort = resolveSort(sort, schema);
118
+
119
+ if (debug) {
120
+ logger.info(
121
+ `Search pipeline for ${Model.modelName}:\n`,
122
+ JSON.stringify(pipeline, null, 2),
123
+ );
124
+ }
125
+
126
+ const aggregate = Model.aggregate([
127
+ ...pipeline,
128
+ {
129
+ $facet: {
130
+ data: [
131
+ {
132
+ $sort: sort,
133
+ },
134
+ {
135
+ $skip: skip,
136
+ },
137
+ {
138
+ $limit: limit,
139
+ },
140
+ ],
141
+ meta: [
142
+ {
143
+ $count: 'total',
144
+ },
145
+ ],
146
+ },
147
+ },
148
+ ]);
149
+
150
+ return wrapQuery(aggregate, async (promise) => {
151
+ const result = await promise;
152
+ const data = result[0].data;
153
+ const total = result[0].meta[0]?.total ?? 0;
154
+ return {
155
+ data,
156
+ meta: {
157
+ skip,
158
+ limit,
159
+ total,
160
+ },
161
+ };
162
+ });
163
+ }
164
+
104
165
  function getSortSchema(sort) {
105
166
  const schema = yd
106
167
  .object({
@@ -125,11 +186,16 @@ function validateDefinition(definition) {
125
186
 
126
187
  function resolveSort(sort, schema) {
127
188
  if (!sort) {
128
- sort = [];
129
- } else if (!Array.isArray(sort)) {
189
+ return { _id: 1 };
190
+ }
191
+
192
+ const result = {};
193
+
194
+ if (!Array.isArray(sort)) {
130
195
  sort = [sort];
131
196
  }
132
- for (let { name, field } of sort) {
197
+
198
+ for (let { name, field, order } of sort) {
133
199
  if (name) {
134
200
  throw new Error(
135
201
  'Sort property "name" is not allowed. Use "field" instead.',
@@ -138,10 +204,10 @@ function resolveSort(sort, schema) {
138
204
  if (!field.startsWith('$') && !schema.path(field)) {
139
205
  throw new Error(`Unknown sort field "${field}".`);
140
206
  }
207
+
208
+ result[field] = order === 'desc' ? -1 : 1;
141
209
  }
142
- return sort.map(({ field, order }) => {
143
- return [field, order === 'desc' ? -1 : 1];
144
- });
210
+ return result;
145
211
  }
146
212
 
147
213
  // Keyword queries
@@ -342,11 +408,16 @@ function isEmptyArrayQuery(schema, key, value) {
342
408
  }
343
409
 
344
410
  function isRangeQuery(schema, key, value) {
345
- // Range queries only allowed on Date and Number fields.
346
- if (!isDateField(schema, key) && !isNumberField(schema, key)) {
411
+ if (!isPlainObject(value)) {
347
412
  return false;
348
413
  }
349
- return typeof value === 'object' && !!value;
414
+
415
+ // Range queries allowed on Date, Number, and String fields.
416
+ return (
417
+ isDateField(schema, key) ||
418
+ isNumberField(schema, key) ||
419
+ isStringField(schema, key)
420
+ );
350
421
  }
351
422
 
352
423
  function mapOperatorQuery(obj) {
package/src/utils.js CHANGED
@@ -38,6 +38,10 @@ export function isNumberField(obj, path) {
38
38
  return isType(obj, path, 'Number');
39
39
  }
40
40
 
41
+ export function isStringField(obj, path) {
42
+ return isType(obj, path, 'String');
43
+ }
44
+
41
45
  export function isArrayField(obj, path) {
42
46
  const field = getField(obj, path);
43
47
  return Array.isArray(field?.type);
package/src/validation.js CHANGED
@@ -19,7 +19,7 @@ const DATE_TAGS = {
19
19
  export const OBJECT_ID_SCHEMA = yd
20
20
  .string()
21
21
  .mongo()
22
- .message('Must be an id.')
22
+ .message('Must be a valid object id.')
23
23
  .tag({
24
24
  'x-schema': 'ObjectId',
25
25
  'x-description':
package/types/const.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export namespace SEARCH_DEFAULTS {
2
+ let skip: number;
2
3
  let limit: number;
3
4
  namespace sort {
4
5
  let field: string;
@@ -1 +1 @@
1
- {"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../src/const.js"],"names":[],"mappings":";;;;;;;AAQA,iCAAkC,CAAC,CAAC"}
1
+ {"version":3,"file":"const.d.ts","sourceRoot":"","sources":["../src/const.js"],"names":[],"mappings":";;;;;;;;AASA,iCAAkC,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAsBA,gEAoDC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAYa,CAAC;;;;;;;;;;;;;;;;;EAab"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAuBA,gEAaC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eA4CwC,CAAC;;;;;;;;;;;;;;;;;EAnBxC"}
package/types/utils.d.ts CHANGED
@@ -3,6 +3,7 @@ export function isMongooseSchema(obj: any): obj is mongoose.Schema<any, any, any
3
3
  export function isReferenceField(obj: any, path: any): boolean;
4
4
  export function isDateField(obj: any, path: any): boolean;
5
5
  export function isNumberField(obj: any, path: any): boolean;
6
+ export function isStringField(obj: any, path: any): boolean;
6
7
  export function isArrayField(obj: any, path: any): boolean;
7
8
  export function isSchemaTypedef(arg: any): boolean;
8
9
  export function getField(obj: any, path: any): any;
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.js"],"names":[],"mappings":"AAQA,iDAcC;AAED,gHAEC;AAED,+DAEC;AAED,0DAEC;AAED,4DAEC;AAED,2DAGC;AAOD,mDAGC;AAuBD,mDAYC;AAKD;;;;EAoBC;AAKD,wDAEC;qBAxHoB,UAAU"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.js"],"names":[],"mappings":"AAQA,iDAcC;AAED,gHAEC;AAED,+DAEC;AAED,0DAEC;AAED,4DAEC;AAED,4DAEC;AAED,2DAGC;AAOD,mDAGC;AAuBD,mDAYC;AAKD;;;;EAoBC;AAKD,wDAEC;qBA5HoB,UAAU"}
@@ -1 +1 @@
1
- {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.js"],"names":[],"mappings":"AAoFA,kDAEC;AAED,oEAkFC;AAsBD,wEAoBC;AAgWD;;;EAEC;AAED;;;EAOC;AA7iBD;;;;;;;;;;;;;;;;eAwBoB,CAAC;;;;;;;;;;;;iBAenB,CAAA;kBAEA,CAAF;kBAA4B,CAAC;oBACA,CAAC;oBAE5B,CAAC;;;wBAkBc,CAAC;8BAIjB,CAAC;oBAA+B,CAAC;oBACV,CAAC;oCAGG,CAAC;uBACpB,CAAC;8BAEH,CAAC;uBAAmC,CAAA;iBACrB,CAAC;;;mBAkBX,CAAC;yBAAoC,CAAC;0BACpB,CAAC;yBAEvB,CAAN;sBACW,CAAC;yBAEN,CAAJ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eA9CC,CAAC;;;;;;;;;;;;;;;;;;EA5CD"}
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.js"],"names":[],"mappings":"AAoFA,kDAEC;AAED,oEAkFC;AAsBD,wEAoBC;AAgWD;;;EAEC;AAED;;;EAOC;AA7iBD;;;;;;;;;;;;;;;;eAwBQ,CAAC;;;;;;;;;;;;iBAcmC,CAAC;kBAC3B,CAAC;kBAEH,CAAC;oBACA,CAAC;oBACF,CAAC;;;wBAmBZ,CAAC;8BACe,CAAC;oBAGD,CAAC;oBACV,CAAC;oCAGG,CAAC;uBAAmC,CAAA;8BAE9B,CAAC;uBACO,CAAC;iBACrB,CAAC;;;mBAkBL,CAAL;yBAAoC,CAAC;0BACpB,CAAC;yBACT,CAAC;sBAEH,CAAN;yBACc,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eA9CQ,CAAC;;;;;;;;;;;;;;;;;;EA3CrB"}