@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.
- package/CHANGELOG.md +60 -0
- package/README.md +932 -0
- package/babel.config.cjs +41 -0
- package/dist/cjs/access.js +66 -0
- package/dist/cjs/assign.js +50 -0
- package/dist/cjs/const.js +16 -0
- package/dist/cjs/errors.js +17 -0
- package/dist/cjs/include.js +222 -0
- package/dist/cjs/index.js +62 -0
- package/dist/cjs/load.js +40 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/references.js +104 -0
- package/dist/cjs/schema.js +277 -0
- package/dist/cjs/search.js +266 -0
- package/dist/cjs/serialization.js +55 -0
- package/dist/cjs/slug.js +47 -0
- package/dist/cjs/soft-delete.js +192 -0
- package/dist/cjs/testing.js +33 -0
- package/dist/cjs/utils.js +73 -0
- package/dist/cjs/validation.js +313 -0
- package/dist/cjs/warn.js +13 -0
- package/jest-mongodb-config.js +10 -0
- package/jest.config.js +8 -0
- package/package.json +53 -0
- package/src/access.js +60 -0
- package/src/assign.js +45 -0
- package/src/const.js +9 -0
- package/src/errors.js +9 -0
- package/src/include.js +209 -0
- package/src/index.js +5 -0
- package/src/load.js +37 -0
- package/src/references.js +101 -0
- package/src/schema.js +286 -0
- package/src/search.js +263 -0
- package/src/serialization.js +49 -0
- package/src/slug.js +45 -0
- package/src/soft-delete.js +234 -0
- package/src/testing.js +29 -0
- package/src/utils.js +63 -0
- package/src/validation.js +329 -0
- package/src/warn.js +7 -0
- package/test/assign.test.js +225 -0
- package/test/definitions/custom-model.json +9 -0
- package/test/definitions/special-category.json +18 -0
- package/test/include.test.js +896 -0
- package/test/load.test.js +47 -0
- package/test/references.test.js +71 -0
- package/test/schema.test.js +919 -0
- package/test/search.test.js +652 -0
- package/test/serialization.test.js +748 -0
- package/test/setup.js +27 -0
- package/test/slug.test.js +112 -0
- package/test/soft-delete.test.js +333 -0
- package/test/validation.test.js +1925 -0
package/src/access.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ImplementationError } from './errors';
|
|
2
|
+
import warn from './warn';
|
|
3
|
+
|
|
4
|
+
export function hasReadAccess(allowed, options) {
|
|
5
|
+
return hasAccess('read', allowed, options);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasWriteAccess(allowed, options) {
|
|
9
|
+
return hasAccess('write', allowed, options);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function hasAccess(type, allowed = 'all', options = {}) {
|
|
13
|
+
if (allowed === 'all') {
|
|
14
|
+
return true;
|
|
15
|
+
} else if (allowed === 'none') {
|
|
16
|
+
return false;
|
|
17
|
+
} else {
|
|
18
|
+
const { document, authUser } = options;
|
|
19
|
+
if (!Array.isArray(allowed)) {
|
|
20
|
+
allowed = [allowed];
|
|
21
|
+
}
|
|
22
|
+
const scopes = resolveScopes(options);
|
|
23
|
+
return allowed.some((token) => {
|
|
24
|
+
if (token === 'self') {
|
|
25
|
+
assertOptions(type, token, options);
|
|
26
|
+
return document.id == authUser.id;
|
|
27
|
+
} else if (token === 'user') {
|
|
28
|
+
assertOptions(type, token, options);
|
|
29
|
+
return document.user?.id == authUser.id;
|
|
30
|
+
} else if (token === 'owner') {
|
|
31
|
+
assertOptions(type, token, options);
|
|
32
|
+
return document.owner?.id == authUser.id;
|
|
33
|
+
} else {
|
|
34
|
+
return scopes?.includes(token);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveScopes(options) {
|
|
41
|
+
if (!options.scope && !options.scopes) {
|
|
42
|
+
warn('Scopes were requested but not provided.');
|
|
43
|
+
}
|
|
44
|
+
const { scope, scopes = [] } = options;
|
|
45
|
+
return scope ? [scope] : scopes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertOptions(type, token, options) {
|
|
49
|
+
if (!options.authUser || !options.document) {
|
|
50
|
+
if (type === 'read') {
|
|
51
|
+
throw new ImplementationError(
|
|
52
|
+
`Read access "${token}" requires .toObject({ authUser }).`
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
throw new ImplementationError(
|
|
56
|
+
`Write access "${token}" requires passing { document, authUser } to the validator.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/assign.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isPlainObject } from 'lodash';
|
|
2
|
+
|
|
3
|
+
import { isReferenceField, resolveField } from './utils';
|
|
4
|
+
|
|
5
|
+
export function applyAssign(schema) {
|
|
6
|
+
schema.method('assign', function assign(fields) {
|
|
7
|
+
unsetReferenceFields(fields, schema.obj);
|
|
8
|
+
for (let [path, value] of Object.entries(flattenObject(fields))) {
|
|
9
|
+
if (value === null) {
|
|
10
|
+
this.set(path, undefined);
|
|
11
|
+
this.markModified(path);
|
|
12
|
+
} else {
|
|
13
|
+
this.set(path, value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Sets falsy reference fields to undefined to signal
|
|
20
|
+
// removal. Passing attributes through this function
|
|
21
|
+
// normalizes falsy values so they are not saved to the db.
|
|
22
|
+
function unsetReferenceFields(fields, schema = {}) {
|
|
23
|
+
for (let [key, value] of Object.entries(fields)) {
|
|
24
|
+
if (!value && isReferenceField(schema, key)) {
|
|
25
|
+
fields[key] = undefined;
|
|
26
|
+
} else if (value && typeof value === 'object') {
|
|
27
|
+
unsetReferenceFields(value, resolveField(schema, key));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Flattens nested objects to a dot syntax.
|
|
33
|
+
// Effectively the inverse of lodash get:
|
|
34
|
+
// { foo: { bar: 3 } } -> { 'foo.bar': 3 }
|
|
35
|
+
function flattenObject(obj, root = {}, rootPath = []) {
|
|
36
|
+
for (let [key, val] of Object.entries(obj)) {
|
|
37
|
+
const path = [...rootPath, key];
|
|
38
|
+
if (isPlainObject(val)) {
|
|
39
|
+
flattenObject(val, root, path);
|
|
40
|
+
} else {
|
|
41
|
+
root[path.join('.')] = val;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return root;
|
|
45
|
+
}
|
package/src/const.js
ADDED
package/src/errors.js
ADDED
package/src/include.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { escapeRegExp } from 'lodash';
|
|
3
|
+
|
|
4
|
+
import { resolveInnerField } from './utils';
|
|
5
|
+
import { POPULATE_MAX_DEPTH } from './const';
|
|
6
|
+
|
|
7
|
+
// Overloading mongoose Query prototype to
|
|
8
|
+
// allow an "include" method for queries.
|
|
9
|
+
mongoose.Query.prototype.include = function include(paths) {
|
|
10
|
+
const filter = this.getFilter();
|
|
11
|
+
filter.include = paths;
|
|
12
|
+
return this;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function applyInclude(schema) {
|
|
16
|
+
schema.virtual('include').set(function (include) {
|
|
17
|
+
this.$locals.include = include;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
schema.pre(/^find/, function (next) {
|
|
21
|
+
const filter = this.getFilter();
|
|
22
|
+
if (filter.include) {
|
|
23
|
+
const { select, populate } = getQueryIncludes(this, filter.include);
|
|
24
|
+
this.select(select);
|
|
25
|
+
this.populate(populate);
|
|
26
|
+
delete filter.include;
|
|
27
|
+
}
|
|
28
|
+
return next();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
schema.pre('save', function () {
|
|
32
|
+
const { include } = this.$locals;
|
|
33
|
+
if (include) {
|
|
34
|
+
let { select, populate } = getDocumentIncludes(this, include);
|
|
35
|
+
const modifiedPaths = this.modifiedPaths();
|
|
36
|
+
populate = populate.filter((p) => {
|
|
37
|
+
return modifiedPaths.includes(p.path);
|
|
38
|
+
});
|
|
39
|
+
this.$locals.select = select;
|
|
40
|
+
this.$locals.populate = populate;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
schema.post('save', async function () {
|
|
45
|
+
const { populate } = this.$locals;
|
|
46
|
+
if (populate) {
|
|
47
|
+
await this.populate(populate);
|
|
48
|
+
delete this.$locals.populate;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// "Selected" keys virtually project documents so that
|
|
54
|
+
// they do not return the entire document down the wire.
|
|
55
|
+
// This is to maintain parity with query projection used
|
|
56
|
+
// with the includes feature.
|
|
57
|
+
export function checkSelects(doc, ret) {
|
|
58
|
+
let { select } = doc.$locals;
|
|
59
|
+
if (select?.length) {
|
|
60
|
+
const includes = {};
|
|
61
|
+
const excludes = {};
|
|
62
|
+
select = [...select, 'id'];
|
|
63
|
+
let hasExcludes = false;
|
|
64
|
+
for (let path of select) {
|
|
65
|
+
if (path.startsWith('-')) {
|
|
66
|
+
excludes[path.slice(1)] = true;
|
|
67
|
+
hasExcludes = true;
|
|
68
|
+
} else {
|
|
69
|
+
includes[path] = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (let key of Object.keys(ret)) {
|
|
73
|
+
// Always select populated fields.
|
|
74
|
+
if (doc.populated(key)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Fields are either explicitly excluded with "-"
|
|
78
|
+
// or implicitly excluded by having only includes.
|
|
79
|
+
const implicitExclude = !hasExcludes && !includes[key];
|
|
80
|
+
if (excludes[key] || implicitExclude) {
|
|
81
|
+
delete ret[key];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Exported for testing.
|
|
88
|
+
export function getIncludes(modelName, arg) {
|
|
89
|
+
const paths = Array.isArray(arg) ? arg : [arg];
|
|
90
|
+
const node = pathsToNode(paths, modelName);
|
|
91
|
+
return nodeToPopulates(node);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getQueryIncludes(query, arg) {
|
|
95
|
+
return getIncludes(query.model.modelName, arg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getDocumentIncludes(doc, arg) {
|
|
99
|
+
return getIncludes(doc.constructor.modelName, arg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Note that:
|
|
103
|
+
// - An empty array for "select" will select all.
|
|
104
|
+
// - Entries in the "populate" array will select the
|
|
105
|
+
// field even if not included in "select".
|
|
106
|
+
function nodeToPopulates(node) {
|
|
107
|
+
const select = [];
|
|
108
|
+
const populate = [];
|
|
109
|
+
for (let [key, value] of Object.entries(node)) {
|
|
110
|
+
if (value) {
|
|
111
|
+
populate.push({
|
|
112
|
+
path: key,
|
|
113
|
+
...nodeToPopulates(value),
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
select.push(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
select,
|
|
121
|
+
populate,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pathsToNode(paths, modelName) {
|
|
126
|
+
const node = {};
|
|
127
|
+
for (let str of paths) {
|
|
128
|
+
let exclude = false;
|
|
129
|
+
if (str.startsWith('-')) {
|
|
130
|
+
exclude = true;
|
|
131
|
+
str = str.slice(1);
|
|
132
|
+
}
|
|
133
|
+
setNodePath(node, {
|
|
134
|
+
path: str.split('.'),
|
|
135
|
+
modelName,
|
|
136
|
+
exclude,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return node;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setNodePath(node, options) {
|
|
143
|
+
const { path, modelName, exclude, depth = 0 } = options;
|
|
144
|
+
if (depth > POPULATE_MAX_DEPTH) {
|
|
145
|
+
throw new Error(`Cannot populate more than ${POPULATE_MAX_DEPTH} levels.`);
|
|
146
|
+
}
|
|
147
|
+
const schema = mongoose.models[modelName]?.schema;
|
|
148
|
+
if (!schema) {
|
|
149
|
+
throw new Error(`Could not derive schema for ${modelName}.`);
|
|
150
|
+
}
|
|
151
|
+
const parts = [];
|
|
152
|
+
for (let part of path) {
|
|
153
|
+
parts.push(part);
|
|
154
|
+
const str = parts.join('.');
|
|
155
|
+
const isExact = parts.length === path.length;
|
|
156
|
+
let halt = false;
|
|
157
|
+
|
|
158
|
+
for (let [key, type] of resolvePaths(schema, str)) {
|
|
159
|
+
if (type === 'real') {
|
|
160
|
+
const field = resolveInnerField(schema.obj, key);
|
|
161
|
+
// Only exclude the field if the match is exact, ie:
|
|
162
|
+
// -name - Exclude "name"
|
|
163
|
+
// -user.name - Implies population of "user" but exclude "user.name",
|
|
164
|
+
// so continue traversing into object when part is "user".
|
|
165
|
+
if (isExact && exclude) {
|
|
166
|
+
node['-' + key] = null;
|
|
167
|
+
} else if (field.ref) {
|
|
168
|
+
node[key] ||= {};
|
|
169
|
+
setNodePath(node[key], {
|
|
170
|
+
modelName: field.ref,
|
|
171
|
+
path: path.slice(parts.length),
|
|
172
|
+
depth: depth + 1,
|
|
173
|
+
exclude,
|
|
174
|
+
});
|
|
175
|
+
halt = true;
|
|
176
|
+
} else {
|
|
177
|
+
node[key] = null;
|
|
178
|
+
}
|
|
179
|
+
} else if (type === 'virtual') {
|
|
180
|
+
node[key] = {};
|
|
181
|
+
} else if (type !== 'nested') {
|
|
182
|
+
throw new Error(`Unknown path on ${modelName}: ${key}.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (halt) {
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolvePaths(schema, str) {
|
|
193
|
+
let paths;
|
|
194
|
+
if (str.includes('*')) {
|
|
195
|
+
let source = escapeRegExp(str);
|
|
196
|
+
source = source.replaceAll('\\*\\*', '.+');
|
|
197
|
+
source = source.replaceAll('\\*', '[^.]+');
|
|
198
|
+
source = `^${source}$`;
|
|
199
|
+
const reg = RegExp(source);
|
|
200
|
+
paths = Object.keys(schema.paths || {}).filter((path) => {
|
|
201
|
+
return !path.startsWith('_') && reg.test(path);
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
paths = [str];
|
|
205
|
+
}
|
|
206
|
+
return paths.map((path) => {
|
|
207
|
+
return [path, schema.pathType(path)];
|
|
208
|
+
});
|
|
209
|
+
}
|
package/src/index.js
ADDED
package/src/load.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import mongoose from 'mongoose';
|
|
5
|
+
import { startCase } from 'lodash';
|
|
6
|
+
|
|
7
|
+
import { createSchema } from './schema';
|
|
8
|
+
|
|
9
|
+
export function loadModel(definition, name) {
|
|
10
|
+
if (!definition.attributes) {
|
|
11
|
+
throw new Error(`Invalid model definition for ${name}, need attributes`);
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const schema = createSchema(definition);
|
|
15
|
+
return mongoose.model(name, schema);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
throw new Error(`${err.message} (loading ${name})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadModelDir(dirPath) {
|
|
22
|
+
const files = fs.readdirSync(dirPath);
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const basename = path.basename(file, '.json');
|
|
25
|
+
if (file.match(/\.json$/)) {
|
|
26
|
+
const filePath = path.join(dirPath, file);
|
|
27
|
+
const data = fs.readFileSync(filePath);
|
|
28
|
+
const definition = JSON.parse(data);
|
|
29
|
+
const modelName =
|
|
30
|
+
definition.modelName || startCase(basename).replace(/\s/g, '');
|
|
31
|
+
if (!mongoose.models[modelName]) {
|
|
32
|
+
loadModel(definition, modelName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return mongoose.models;
|
|
37
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
|
|
3
|
+
import { ReferenceError } from './errors';
|
|
4
|
+
|
|
5
|
+
const { ObjectId: SchemaObjectId } = mongoose.Schema.Types;
|
|
6
|
+
|
|
7
|
+
export function applyReferences(schema) {
|
|
8
|
+
schema.method(
|
|
9
|
+
'assertNoReferences',
|
|
10
|
+
async function assertNoReferences(options = {}) {
|
|
11
|
+
const { except = [] } = options;
|
|
12
|
+
const { modelName } = this.constructor;
|
|
13
|
+
|
|
14
|
+
assertExceptions(except);
|
|
15
|
+
|
|
16
|
+
const references = getAllReferences(modelName);
|
|
17
|
+
const results = [];
|
|
18
|
+
|
|
19
|
+
for (let { model, paths } of references) {
|
|
20
|
+
const isAllowed = except.some((e) => {
|
|
21
|
+
return e === model || e === model.modelName;
|
|
22
|
+
});
|
|
23
|
+
if (isAllowed) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const query = {
|
|
28
|
+
$or: paths.map((path) => {
|
|
29
|
+
return { [path]: this.id };
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const docs = await model.find(
|
|
34
|
+
query,
|
|
35
|
+
{
|
|
36
|
+
_id: 1,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
lean: true,
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
if (docs.length > 0) {
|
|
43
|
+
const ids = docs.map((doc) => {
|
|
44
|
+
return String(doc._id);
|
|
45
|
+
});
|
|
46
|
+
results.push({
|
|
47
|
+
ids,
|
|
48
|
+
model,
|
|
49
|
+
count: ids.length,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (results.length) {
|
|
55
|
+
throw new ReferenceError('Refusing to delete.', results);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertExceptions(except) {
|
|
62
|
+
for (let val of except) {
|
|
63
|
+
if (typeof val === 'string' && !mongoose.models[val]) {
|
|
64
|
+
throw new Error(`Unknown model "${val}".`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getAllReferences(targetName) {
|
|
70
|
+
return Object.values(mongoose.models)
|
|
71
|
+
.map((model) => {
|
|
72
|
+
const paths = getModelReferences(model, targetName);
|
|
73
|
+
return { model, paths };
|
|
74
|
+
})
|
|
75
|
+
.filter(({ paths }) => {
|
|
76
|
+
return paths.length > 0;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getModelReferences(model, targetName) {
|
|
81
|
+
const paths = [];
|
|
82
|
+
model.schema.eachPath((schemaPath, schemaType) => {
|
|
83
|
+
if (schemaType instanceof SchemaObjectId && schemaPath[0] !== '_') {
|
|
84
|
+
const { ref, refPath } = schemaType.options;
|
|
85
|
+
let refs;
|
|
86
|
+
if (ref) {
|
|
87
|
+
refs = [ref];
|
|
88
|
+
} else if (refPath) {
|
|
89
|
+
refs = model.schema.path(refPath).options.enum;
|
|
90
|
+
} else {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Cannot derive refs for ${model.modelName}#${schemaPath}.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (refs.includes(targetName)) {
|
|
96
|
+
paths.push(schemaPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return paths;
|
|
101
|
+
}
|