@bedrockio/model 0.1.0

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +932 -0
  3. package/babel.config.cjs +41 -0
  4. package/dist/cjs/access.js +66 -0
  5. package/dist/cjs/assign.js +50 -0
  6. package/dist/cjs/const.js +16 -0
  7. package/dist/cjs/errors.js +17 -0
  8. package/dist/cjs/include.js +222 -0
  9. package/dist/cjs/index.js +62 -0
  10. package/dist/cjs/load.js +40 -0
  11. package/dist/cjs/package.json +1 -0
  12. package/dist/cjs/references.js +104 -0
  13. package/dist/cjs/schema.js +277 -0
  14. package/dist/cjs/search.js +266 -0
  15. package/dist/cjs/serialization.js +55 -0
  16. package/dist/cjs/slug.js +47 -0
  17. package/dist/cjs/soft-delete.js +192 -0
  18. package/dist/cjs/testing.js +33 -0
  19. package/dist/cjs/utils.js +73 -0
  20. package/dist/cjs/validation.js +313 -0
  21. package/dist/cjs/warn.js +13 -0
  22. package/jest-mongodb-config.js +10 -0
  23. package/jest.config.js +8 -0
  24. package/package.json +53 -0
  25. package/src/access.js +60 -0
  26. package/src/assign.js +45 -0
  27. package/src/const.js +9 -0
  28. package/src/errors.js +9 -0
  29. package/src/include.js +209 -0
  30. package/src/index.js +5 -0
  31. package/src/load.js +37 -0
  32. package/src/references.js +101 -0
  33. package/src/schema.js +286 -0
  34. package/src/search.js +263 -0
  35. package/src/serialization.js +49 -0
  36. package/src/slug.js +45 -0
  37. package/src/soft-delete.js +234 -0
  38. package/src/testing.js +29 -0
  39. package/src/utils.js +63 -0
  40. package/src/validation.js +329 -0
  41. package/src/warn.js +7 -0
  42. package/test/assign.test.js +225 -0
  43. package/test/definitions/custom-model.json +9 -0
  44. package/test/definitions/special-category.json +18 -0
  45. package/test/include.test.js +896 -0
  46. package/test/load.test.js +47 -0
  47. package/test/references.test.js +71 -0
  48. package/test/schema.test.js +919 -0
  49. package/test/search.test.js +652 -0
  50. package/test/serialization.test.js +748 -0
  51. package/test/setup.js +27 -0
  52. package/test/slug.test.js +112 -0
  53. package/test/soft-delete.test.js +333 -0
  54. package/test/validation.test.js +1925 -0
package/src/schema.js ADDED
@@ -0,0 +1,286 @@
1
+ import mongoose from 'mongoose';
2
+ import { pick, isPlainObject, capitalize, camelCase } from 'lodash';
3
+
4
+ import { isSchemaTypedef } from './utils';
5
+
6
+ import { serializeOptions } from './serialization';
7
+ import { applySlug } from './slug';
8
+ import { applySearch } from './search';
9
+ import { applyAssign } from './assign';
10
+ import { applyInclude } from './include';
11
+ import { applyReferences } from './references';
12
+ import { applySoftDelete } from './soft-delete';
13
+ import {
14
+ applyValidation,
15
+ getNamedValidator,
16
+ getTupleValidator,
17
+ } from './validation';
18
+
19
+ export const RESERVED_FIELDS = [
20
+ 'createdAt',
21
+ 'updatedAt',
22
+ 'deletedAt',
23
+ 'deleted',
24
+ ];
25
+
26
+ export function createSchema(definition, options = {}) {
27
+ const schema = new mongoose.Schema(
28
+ attributesToMongoose(
29
+ normalizeAttributes({
30
+ ...definition.attributes,
31
+
32
+ // Although timestamps are being set below, we still need to add
33
+ // them to the schema so that validation can be generated for them,
34
+ // namely in getSearchValidation.
35
+ createdAt: 'Date',
36
+ updatedAt: 'Date',
37
+ deletedAt: 'Date',
38
+ deleted: { type: 'Boolean', default: false },
39
+ })
40
+ ),
41
+ {
42
+ timestamps: true,
43
+ toJSON: serializeOptions,
44
+ toObject: serializeOptions,
45
+ ...options,
46
+ }
47
+ );
48
+
49
+ applySoftDelete(schema, definition);
50
+ applyValidation(schema, definition);
51
+ applyReferences(schema, definition);
52
+ applyInclude(schema, definition);
53
+ applySearch(schema, definition);
54
+ applyAssign(schema, definition);
55
+ applySlug(schema, definition);
56
+
57
+ return schema;
58
+ }
59
+
60
+ export function normalizeAttributes(arg, path = []) {
61
+ if (arg instanceof mongoose.Schema) {
62
+ return arg;
63
+ } else if (typeof arg === 'function') {
64
+ throw new Error('Native functions are not allowed as types.');
65
+ } else if (typeof arg === 'string') {
66
+ return normalizeSchemaTypedef({ type: arg }, path);
67
+ } else if (Array.isArray(arg)) {
68
+ const type = normalizeArrayAttributes(arg, path);
69
+ return normalizeSchemaTypedef({ type }, path);
70
+ } else if (typeof arg === 'object') {
71
+ assertRefs(arg, path);
72
+
73
+ if (isSchemaTypedef(arg)) {
74
+ return normalizeSchemaTypedef(arg, path);
75
+ }
76
+
77
+ const attributes = {};
78
+ for (let [key, val] of Object.entries(arg)) {
79
+ attributes[key] = normalizeAttributes(val, [...path, key]);
80
+ }
81
+ return attributes;
82
+ }
83
+ }
84
+
85
+ function normalizeSchemaTypedef(typedef, path) {
86
+ const { type } = typedef;
87
+
88
+ if (Array.isArray(type)) {
89
+ typedef.type = normalizeArrayAttributes(type, path);
90
+ } else if (typeof type === 'object') {
91
+ typedef.type = normalizeAttributes(type, path);
92
+ } else {
93
+ assertSchemaType(type, path);
94
+ }
95
+
96
+ return typedef;
97
+ }
98
+
99
+ function normalizeArrayAttributes(arr, path) {
100
+ return arr.map((el, i) => {
101
+ return normalizeAttributes(el, [...path, i]);
102
+ });
103
+ }
104
+
105
+ function attributesToMongoose(attributes) {
106
+ if (typeof attributes === 'string') {
107
+ return attributes;
108
+ } else if (Array.isArray(attributes)) {
109
+ return attributes.map(attributesToMongoose);
110
+ }
111
+
112
+ let definition = {};
113
+
114
+ const isTypedef = isSchemaTypedef(attributes);
115
+
116
+ for (let [key, val] of Object.entries(attributes)) {
117
+ const type = typeof val;
118
+ if (isTypedef) {
119
+ if (key === 'type' && type !== 'function') {
120
+ val = attributesToMongoose(val);
121
+ } else if (key === 'match' && type === 'string') {
122
+ // Convert match field to RegExp that cannot be expressed in JSON.
123
+ val = parseRegExp(val);
124
+ } else if (key === 'validate' && type === 'string') {
125
+ // Allow custom mongoose validation function that derives from the schema.
126
+ val = getNamedValidator(val);
127
+ }
128
+ } else if (isPlainObject(val)) {
129
+ if (isScopeExtension(val)) {
130
+ applyScopeExtension(val, definition);
131
+ continue;
132
+ } else {
133
+ val = attributesToMongoose(val);
134
+ }
135
+ }
136
+ definition[key] = val;
137
+ }
138
+
139
+ if (isTypedef) {
140
+ applyExtensions(definition);
141
+ }
142
+
143
+ return definition;
144
+ }
145
+
146
+ function assertSchemaType(type, path) {
147
+ if (type === 'Mixed') {
148
+ throw new Error('Type "Mixed" is not allowed. Use "Object" instead.');
149
+ }
150
+
151
+ if (typeof type === 'string') {
152
+ if (!isMongooseType(type)) {
153
+ const p = path.join('.');
154
+ const upper = camelUpper(type);
155
+ if (isMongooseType(upper)) {
156
+ throw new Error(`Type "${type}" in "${p}" should be "${upper}".`);
157
+ } else if (type !== 'Scope') {
158
+ throw new Error(`Invalid type "${type}" for "${p}".`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ function assertRefs(field, path) {
165
+ const { type, ref, refPath } = field;
166
+ const p = path.join('.');
167
+ if (isObjectIdType(type) && !ref && !refPath) {
168
+ throw new Error(`Ref must be passed for "${p}".`);
169
+ } else if (ref && !isMongooseType(ref) && !isObjectIdType(type)) {
170
+ throw new Error(`Ref field "${p}" must be type "ObjectId".`);
171
+ }
172
+ }
173
+
174
+ function camelUpper(str) {
175
+ return capitalize(camelCase(str));
176
+ }
177
+
178
+ function isObjectIdType(type) {
179
+ return type === 'ObjectId' || type === mongoose.Schema.Types.ObjectId;
180
+ }
181
+
182
+ function isMongooseType(type) {
183
+ return !!mongoose.Schema.Types[type];
184
+ }
185
+
186
+ function applyExtensions(typedef) {
187
+ applySyntaxExtensions(typedef);
188
+ applyTupleExtension(typedef);
189
+ }
190
+
191
+ function applySyntaxExtensions(typedef) {
192
+ const { type, attributes } = typedef;
193
+ if (isExtendedSyntax(typedef)) {
194
+ typedef.type = new mongoose.Schema(attributes);
195
+ if (type === 'Array') {
196
+ typedef.type = [typedef.type];
197
+ }
198
+ }
199
+ if (Array.isArray(typedef.type)) {
200
+ applyArrayValidators(typedef);
201
+ applyOptionHoisting(typedef);
202
+ }
203
+ }
204
+
205
+ // Hoist read/write scopes from a nested element.
206
+ // See the readme for more.
207
+ function applyOptionHoisting(typedef) {
208
+ Object.assign(typedef, pick(typedef.type[0], 'readAccess', 'writeAccess'));
209
+ }
210
+
211
+ function isExtendedSyntax(typedef) {
212
+ const { type, attributes } = typedef;
213
+ return attributes && (type === 'Object' || type === 'Array');
214
+ }
215
+
216
+ function isScopeExtension(obj) {
217
+ return isSchemaTypedef(obj) && obj.type === 'Scope';
218
+ }
219
+
220
+ function applyScopeExtension(scope, definition) {
221
+ const { type, attributes, ...options } = scope;
222
+ for (let [key, val] of Object.entries(normalizeAttributes(attributes))) {
223
+ definition[key] = {
224
+ ...val,
225
+ ...options,
226
+ };
227
+ }
228
+ }
229
+
230
+ // Extended tuple syntax. Return mixed type and set validator.
231
+ function applyTupleExtension(typedef) {
232
+ const { type } = typedef;
233
+ if (Array.isArray(type) && type.length > 1) {
234
+ typedef.type = ['Mixed'];
235
+ typedef.validate = getTupleValidator(type);
236
+ }
237
+ }
238
+
239
+ function applyArrayValidators(typedef) {
240
+ let { minLength, maxLength, validate } = typedef;
241
+ if (minLength) {
242
+ validate = chain(validate, validateMinLength(minLength));
243
+ }
244
+ if (maxLength) {
245
+ validate = chain(validate, validateMaxLength(maxLength));
246
+ }
247
+ if (validate) {
248
+ typedef.validate = validate;
249
+ }
250
+ }
251
+
252
+ function validateMinLength(min) {
253
+ const s = min === 1 ? '' : 's';
254
+ return (arr) => {
255
+ if (arr.length < min) {
256
+ throw new Error(`Must have at least ${min} element${s}.`);
257
+ }
258
+ };
259
+ }
260
+
261
+ function validateMaxLength(max) {
262
+ const s = max === 1 ? '' : 's';
263
+ return (arr) => {
264
+ if (arr.length > max) {
265
+ throw new Error(`Cannot have more than ${max} element${s}.`);
266
+ }
267
+ };
268
+ }
269
+
270
+ function chain(fn1, fn2) {
271
+ return (...args) => {
272
+ fn1?.(...args);
273
+ fn2?.(...args);
274
+ };
275
+ }
276
+
277
+ const REG_MATCH = /^\/(.+)\/(\w+)$/;
278
+
279
+ function parseRegExp(str) {
280
+ const match = str.match(REG_MATCH);
281
+ if (!match) {
282
+ throw new Error('Could not parse regex.');
283
+ }
284
+ const [, source, flags] = match;
285
+ return RegExp(source, flags);
286
+ }
package/src/search.js ADDED
@@ -0,0 +1,263 @@
1
+ import yd from '@bedrockio/yada';
2
+ import mongoose from 'mongoose';
3
+ import { pick, isEmpty, isPlainObject } from 'lodash';
4
+
5
+ import { isDateField, isNumberField, resolveField } from './utils';
6
+ import { SEARCH_DEFAULTS } from './const';
7
+
8
+ import warn from './warn';
9
+
10
+ const { ObjectId } = mongoose.Types;
11
+
12
+ const SORT_SCHEMA = yd.object({
13
+ field: yd.string().required(),
14
+ order: yd.string().allow('desc', 'asc').required(),
15
+ });
16
+
17
+ export function applySearch(schema, definition) {
18
+ validateDefinition(definition);
19
+
20
+ schema.static('search', function search(body = {}) {
21
+ const options = {
22
+ ...SEARCH_DEFAULTS,
23
+ ...definition.search,
24
+ ...body,
25
+ };
26
+
27
+ const { ids, keyword, skip = 0, limit, sort, fields, ...rest } = options;
28
+
29
+ const query = {};
30
+
31
+ if (ids?.length) {
32
+ query._id = { $in: ids };
33
+ }
34
+
35
+ if (keyword) {
36
+ Object.assign(query, buildKeywordQuery(keyword, fields));
37
+ }
38
+
39
+ Object.assign(query, normalizeQuery(rest, schema.obj));
40
+
41
+ const mQuery = this.find(query)
42
+ .sort(resolveSort(sort))
43
+ .skip(skip)
44
+ .limit(limit);
45
+
46
+ // The following construct is awkward but it allows the mongoose query
47
+ // object to be returned while still ultimately resolving with metadata
48
+ // so that this method can behave like other find methods and importantly
49
+ // allow custom population with the same API.
50
+
51
+ const runQuery = mQuery.then.bind(mQuery);
52
+
53
+ mQuery.then = async (resolve, reject) => {
54
+ try {
55
+ const [data, total] = await Promise.all([
56
+ runQuery(),
57
+ this.countDocuments(query),
58
+ ]);
59
+ resolve({
60
+ data,
61
+ meta: {
62
+ total,
63
+ skip,
64
+ limit,
65
+ },
66
+ });
67
+ } catch (err) {
68
+ reject(err);
69
+ }
70
+ };
71
+
72
+ return mQuery;
73
+ });
74
+ }
75
+
76
+ export function searchValidation(definition, options = {}) {
77
+ const { limit, sort, ...rest } = {
78
+ ...SEARCH_DEFAULTS,
79
+ ...pick(definition.search, 'limit', 'sort'),
80
+ ...options,
81
+ };
82
+
83
+ return {
84
+ ids: yd.array(yd.string().mongo()),
85
+ keyword: yd.string(),
86
+ include: yd.string(),
87
+ skip: yd.number().default(0),
88
+ sort: yd.allow(SORT_SCHEMA, yd.array(SORT_SCHEMA)).default(sort),
89
+ limit: yd.number().positive().default(limit),
90
+ ...rest,
91
+ };
92
+ }
93
+
94
+ function validateDefinition(definition) {
95
+ if (Array.isArray(definition.search)) {
96
+ warn(
97
+ [
98
+ '"search" field on model definition must not be an array.',
99
+ 'Use "search.fields" to define fields for keyword queries.',
100
+ ].join('\n')
101
+ );
102
+ throw new Error('Invalid model definition.');
103
+ }
104
+ }
105
+
106
+ function resolveSort(sort) {
107
+ if (!Array.isArray(sort)) {
108
+ sort = [sort];
109
+ }
110
+ return sort.map(({ field, order }) => {
111
+ return [field, order === 'desc' ? -1 : 1];
112
+ });
113
+ }
114
+
115
+ // Keyword queries
116
+ //
117
+ // Mongo supports text indexes, however search operations do not support partial
118
+ // word matches except for stemming rules (eg: "taste", "tastes", and "tasteful").
119
+ //
120
+ // Text indexes are preferred for performance, diacritic handling and more, however
121
+ // for smaller collections partial matches can be manually enabled by specifying an
122
+ // array of "search" fields on the definition:
123
+ //
124
+ // {
125
+ // "attributes": {
126
+ // "name": {
127
+ // "type": "String",
128
+ // "required": true,
129
+ // "trim": true
130
+ // },
131
+ // },
132
+ // "search": [
133
+ // "name",
134
+ // "description"
135
+ // ]
136
+ // },
137
+ //
138
+ // Be aware that this may impact performance in which case moving to a text index
139
+ // may be preferable, however partial word matches will stop working. Support for
140
+ // ngram based text search appears to be coming but has no landing date yet.
141
+ //
142
+ // References:
143
+ // https://stackoverflow.com/questions/44833817/mongodb-full-and-partial-text-search
144
+ // https://jira.mongodb.org/browse/SERVER-15090
145
+
146
+ function buildKeywordQuery(keyword, fields) {
147
+ if (fields) {
148
+ return buildRegexQuery(keyword, fields);
149
+ } else {
150
+ return buildTextIndexQuery(keyword);
151
+ }
152
+ }
153
+
154
+ function buildRegexQuery(keyword, fields) {
155
+ const queries = fields.map((field) => {
156
+ const regexKeyword = keyword.replace(/\+/g, '\\+');
157
+ return {
158
+ [field]: {
159
+ $regex: `${regexKeyword}`,
160
+ $options: 'i',
161
+ },
162
+ };
163
+ });
164
+ if (ObjectId.isValid(keyword)) {
165
+ queries.push({ _id: keyword });
166
+ }
167
+ return { $or: queries };
168
+ }
169
+
170
+ function buildTextIndexQuery(keyword) {
171
+ if (ObjectId.isValid(keyword)) {
172
+ return {
173
+ $or: [{ $text: { $search: keyword } }, { _id: keyword }],
174
+ };
175
+ } else {
176
+ return {
177
+ $text: {
178
+ $search: keyword,
179
+ },
180
+ };
181
+ }
182
+ }
183
+
184
+ // Normalizes mongo queries. Flattens plain nested paths
185
+ // to dot syntax while preserving mongo operators and
186
+ // handling specialed query syntax:
187
+ // ranges:
188
+ // path: { min: n, max n }
189
+ // regex:
190
+ // path: "/reg/"
191
+ // array:
192
+ // path; [1,2,3]
193
+ function normalizeQuery(query, schema, root = {}, rootPath = []) {
194
+ for (let [key, value] of Object.entries(query)) {
195
+ const path = [...rootPath, key];
196
+ if (isRangeQuery(schema, key, value)) {
197
+ if (!isEmpty(value)) {
198
+ root[path.join('.')] = mapOperatorQuery(value);
199
+ }
200
+ } else if (isNestedQuery(key, value)) {
201
+ normalizeQuery(value, resolveField(schema, key), root, path);
202
+ } else if (isRegexQuery(key, value)) {
203
+ root[path.join('.')] = parseRegexQuery(value);
204
+ } else if (isArrayQuery(key, value)) {
205
+ root[path.join('.')] = { $in: value };
206
+ } else {
207
+ root[path.join('.')] = value;
208
+ }
209
+ }
210
+ return root;
211
+ }
212
+
213
+ function isNestedQuery(key, value) {
214
+ if (isMongoOperator(key) || !isPlainObject(value)) {
215
+ return false;
216
+ }
217
+ return Object.keys(value).every((key) => {
218
+ return !isMongoOperator(key);
219
+ });
220
+ }
221
+
222
+ function isArrayQuery(key, value) {
223
+ return !isMongoOperator(key) && Array.isArray(value);
224
+ }
225
+
226
+ function isRangeQuery(schema, key, value) {
227
+ // Range queries only allowed on Date and Number fields.
228
+ if (!isDateField(schema, key) && !isNumberField(schema, key)) {
229
+ return false;
230
+ }
231
+ return typeof value === 'object';
232
+ }
233
+
234
+ function mapOperatorQuery(obj) {
235
+ const query = {};
236
+ for (let [key, val] of Object.entries(obj)) {
237
+ query[`$${key}`] = val;
238
+ }
239
+ return query;
240
+ }
241
+
242
+ function isMongoOperator(str) {
243
+ return str.startsWith('$');
244
+ }
245
+
246
+ // Regex queries
247
+
248
+ const REGEX_QUERY = /^\/(.+)\/(\w*)$/;
249
+
250
+ function isRegexQuery(key, value) {
251
+ return REGEX_QUERY.test(value);
252
+ }
253
+
254
+ function parseRegexQuery(str) {
255
+ // Note that using the $options syntax allows for PCRE features
256
+ // that aren't supported in Javascript as compared to RegExp(...):
257
+ // https://docs.mongodb.com/manual/reference/operator/query/regex/#pcre-vs-javascript
258
+ const [, $regex, $options] = str.match(REGEX_QUERY);
259
+ return {
260
+ $regex,
261
+ $options,
262
+ };
263
+ }
@@ -0,0 +1,49 @@
1
+ import { isPlainObject } from 'lodash';
2
+
3
+ import { checkSelects } from './include';
4
+ import { hasReadAccess } from './access';
5
+ import { resolveField } from './utils';
6
+
7
+ export const serializeOptions = {
8
+ getters: true,
9
+ versionKey: false,
10
+ transform: (doc, ret, options) => {
11
+ options.document = doc;
12
+ checkSelects(doc, ret);
13
+ transformField(ret, doc.schema.obj, options);
14
+ },
15
+ };
16
+
17
+ function transformField(obj, field, options) {
18
+ if (Array.isArray(obj)) {
19
+ for (let el of obj) {
20
+ transformField(el, field, options);
21
+ }
22
+ } else if (isPlainObject(obj)) {
23
+ for (let [key, val] of Object.entries(obj)) {
24
+ if (!isAllowedField(key, field, options)) {
25
+ delete obj[key];
26
+ } else {
27
+ transformField(val, resolveField(field, key), options);
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ function isAllowedField(key, field, options) {
34
+ if (key[0] === '_') {
35
+ // Strip internal keys like _id and __v
36
+ return false;
37
+ } else if (key === 'deleted') {
38
+ // Strip "deleted" field which defaults
39
+ // to false and should not be exposed.
40
+ return false;
41
+ } else {
42
+ const { readAccess } = resolveField(field, key);
43
+ try {
44
+ return hasReadAccess(readAccess, options);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+ }
package/src/slug.js ADDED
@@ -0,0 +1,45 @@
1
+ import mongoose from 'mongoose';
2
+
3
+ const { ObjectId } = mongoose.Types;
4
+
5
+ export function applySlug(schema) {
6
+ schema.static('findByIdOrSlug', function findByIdOrSlug(str, ...args) {
7
+ return find(this, str, args);
8
+ });
9
+
10
+ schema.static(
11
+ 'findByIdOrSlugDeleted',
12
+ function findByIdOrSlugDeleted(str, ...args) {
13
+ return find(this, str, args, {
14
+ deleted: true,
15
+ });
16
+ }
17
+ );
18
+
19
+ schema.static(
20
+ 'findByIdOrSlugWithDeleted',
21
+ function findByIdOrSlugWithDeleted(str, ...args) {
22
+ return find(this, str, args, {
23
+ deleted: { $in: [true, false] },
24
+ });
25
+ }
26
+ );
27
+ }
28
+
29
+ function find(Model, str, args, query) {
30
+ const isObjectId = str.length === 24 && ObjectId.isValid(str);
31
+ // There is a non-zero chance of a slug colliding with an ObjectId but
32
+ // is exceedingly rare (run of exactly 24 [a-f0-9] chars together
33
+ // without a hyphen) so this should be acceptable.
34
+ if (!query && isObjectId) {
35
+ return Model.findById(str, ...args);
36
+ } else {
37
+ query = { ...query };
38
+ if (isObjectId) {
39
+ query._id = str;
40
+ } else {
41
+ query.slug = str;
42
+ }
43
+ return Model.findOne(query, ...args);
44
+ }
45
+ }