@mongoosejs/studio 0.5.4 → 0.5.5
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/backend/actions/Model/analyzeSchema.js +202 -0
- package/backend/actions/Model/index.js +2 -0
- package/backend/actions/Model/validateDocument.js +35 -0
- package/backend/authorize.js +2 -0
- package/backend/helpers/validateDocumentWithTimeout.js +39 -0
- package/frontend/public/app.js +639 -99
- package/frontend/public/tw.css +221 -28
- package/frontend/src/api.js +12 -0
- package/frontend/src/array-utils.js +60 -2
- package/frontend/src/detail-array/detail-array.html +98 -14
- package/frontend/src/detail-array/detail-array.js +73 -3
- package/frontend/src/document/document.html +51 -0
- package/frontend/src/document/document.js +13 -0
- package/frontend/src/document-details/document-details.html +10 -3
- package/frontend/src/document-details/document-details.js +28 -0
- package/frontend/src/document-details/document-property/document-property.html +36 -44
- package/frontend/src/document-details/document-property/document-property.js +78 -14
- package/frontend/src/edit-array/edit-array.html +143 -8
- package/frontend/src/edit-array/edit-array.js +104 -2
- package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.html +169 -0
- package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.js +149 -0
- package/frontend/src/models/models.html +25 -13
- package/frontend/src/models/models.js +5 -13
- package/package.json +2 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Archetype = require('archetype');
|
|
4
|
+
const authorize = require('../../authorize');
|
|
5
|
+
const { Buffer } = require('buffer');
|
|
6
|
+
const validateDocumentWithTimeout = require('../../helpers/validateDocumentWithTimeout');
|
|
7
|
+
|
|
8
|
+
const SAMPLE_SIZE = 1000;
|
|
9
|
+
const MAX_SCHEMA_PATH_DEPTH = 4;
|
|
10
|
+
|
|
11
|
+
const AnalyzeSchemaParams = new Archetype({
|
|
12
|
+
model: {
|
|
13
|
+
$type: 'string',
|
|
14
|
+
$required: true
|
|
15
|
+
},
|
|
16
|
+
roles: {
|
|
17
|
+
$type: ['string']
|
|
18
|
+
}
|
|
19
|
+
}).compile('AnalyzeSchemaParams');
|
|
20
|
+
|
|
21
|
+
module.exports = ({ db }) => async function analyzeSchema(params) {
|
|
22
|
+
const { model, roles } = new AnalyzeSchemaParams(params);
|
|
23
|
+
|
|
24
|
+
await authorize('Model.analyzeSchema', roles);
|
|
25
|
+
|
|
26
|
+
const Model = db.models[model];
|
|
27
|
+
if (Model == null) {
|
|
28
|
+
throw new Error(`Model ${model} not found`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const documentCount = await Model.collection.countDocuments();
|
|
32
|
+
const rawDocs = documentCount > SAMPLE_SIZE ?
|
|
33
|
+
await Model.collection.aggregate([{ $sample: { size: SAMPLE_SIZE } }]).toArray() :
|
|
34
|
+
await Model.collection.find({}).toArray();
|
|
35
|
+
|
|
36
|
+
const paths = getSchemaPaths(Model.schema);
|
|
37
|
+
const pathTypeCounts = {};
|
|
38
|
+
const pathValueCounts = {};
|
|
39
|
+
|
|
40
|
+
for (const path of paths) {
|
|
41
|
+
pathTypeCounts[path.path] = {};
|
|
42
|
+
pathValueCounts[path.path] = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const validationResults = await Promise.all(rawDocs.map(rawDoc => validateDocumentWithTimeout(Model.hydrate(rawDoc))));
|
|
46
|
+
const validDocumentCount = validationResults.filter(result => result.valid).length;
|
|
47
|
+
const firstValidDocument = validationResults.find(result => result.valid);
|
|
48
|
+
const firstInvalidDocument = validationResults.find(result => !result.valid);
|
|
49
|
+
|
|
50
|
+
for (const rawDoc of rawDocs) {
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
for (const value of getPathValues(rawDoc, path.path, path.arrayParents)) {
|
|
53
|
+
const types = path.arrayPath ? [getType(value)] : getTypes(value);
|
|
54
|
+
pathValueCounts[path.path] += types.length;
|
|
55
|
+
for (const type of types) {
|
|
56
|
+
pathTypeCounts[path.path][type] = (pathTypeCounts[path.path][type] || 0) + 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
analysis: {
|
|
64
|
+
documentCount,
|
|
65
|
+
sampleSize: rawDocs.length,
|
|
66
|
+
sampled: documentCount > SAMPLE_SIZE,
|
|
67
|
+
validDocumentCount,
|
|
68
|
+
invalidDocumentCount: rawDocs.length - validDocumentCount,
|
|
69
|
+
firstValidDocumentId: firstValidDocument?.documentId || null,
|
|
70
|
+
firstInvalidDocumentId: firstInvalidDocument?.documentId || null,
|
|
71
|
+
invalidDocuments: validationResults.filter(result => !result.valid).map(result => ({
|
|
72
|
+
documentId: result.documentId,
|
|
73
|
+
error: result.error,
|
|
74
|
+
errors: result.errors
|
|
75
|
+
})),
|
|
76
|
+
paths: paths.map(path => ({
|
|
77
|
+
path: path.path,
|
|
78
|
+
valueCount: pathValueCounts[path.path],
|
|
79
|
+
...(path.required ? { required: true } : {}),
|
|
80
|
+
types: Object.keys(pathTypeCounts[path.path]).map(type => ({
|
|
81
|
+
type,
|
|
82
|
+
count: pathTypeCounts[path.path][type]
|
|
83
|
+
})).sort((type1, type2) => type2.count - type1.count)
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function getSchemaPaths(schema, prefix, parentArrayParents) {
|
|
90
|
+
prefix = prefix || '';
|
|
91
|
+
parentArrayParents = parentArrayParents || [];
|
|
92
|
+
|
|
93
|
+
const paths = [];
|
|
94
|
+
for (const path of Object.keys(schema.paths)) {
|
|
95
|
+
const fullPath = prefix ? `${prefix}.${path}` : path;
|
|
96
|
+
const pathDepth = fullPath.split('.').length;
|
|
97
|
+
if (pathDepth > MAX_SCHEMA_PATH_DEPTH) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const schemaType = schema.paths[path];
|
|
101
|
+
const arrayParents = parentArrayParents.concat(getSchemaArrayParents(schemaType, fullPath));
|
|
102
|
+
paths.push({
|
|
103
|
+
path: fullPath,
|
|
104
|
+
arrayParents,
|
|
105
|
+
arrayPath: isSchemaArrayPath(schemaType),
|
|
106
|
+
required: schemaType.options?.required === true
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (schemaType.schema && pathDepth < MAX_SCHEMA_PATH_DEPTH) {
|
|
110
|
+
paths.push(...getSchemaPaths(schemaType.schema, fullPath, arrayParents));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return paths;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getSchemaArrayParents(schemaType, fullPath) {
|
|
117
|
+
if (isSchemaArrayPath(schemaType)) {
|
|
118
|
+
return [fullPath];
|
|
119
|
+
}
|
|
120
|
+
const parent = schemaType.$parentSchemaDocArray;
|
|
121
|
+
if (parent?.path) {
|
|
122
|
+
return [parent.path];
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isSchemaArrayPath(schemaType) {
|
|
128
|
+
return schemaType.$isMongooseDocumentArray || schemaType.$isMongooseArray || schemaType.instance === 'Array';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getPathValues(doc, path, arrayParents) {
|
|
132
|
+
return getPathValuesRecursive(doc, path.split('.'), '', new Set(arrayParents));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getPathValuesRecursive(value, parts, currentPath, arrayParents) {
|
|
136
|
+
if (parts.length === 0) {
|
|
137
|
+
return [value];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
return value.flatMap(element => getPathValuesRecursive(element, parts, currentPath, arrayParents));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (value == null) {
|
|
145
|
+
return [undefined];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const nextPart = parts[0];
|
|
149
|
+
const nextPath = currentPath ? `${currentPath}.${nextPart}` : nextPart;
|
|
150
|
+
const nextValue = getPathPartValue(value, nextPart);
|
|
151
|
+
if (nextValue == null && arrayParents.has(nextPath)) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return getPathValuesRecursive(nextValue, parts.slice(1), nextPath, arrayParents);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getPathPartValue(value, pathPart) {
|
|
159
|
+
if (value instanceof Map) {
|
|
160
|
+
return value.get(pathPart);
|
|
161
|
+
}
|
|
162
|
+
return value[pathPart];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getTypes(value) {
|
|
166
|
+
if (!Array.isArray(value)) {
|
|
167
|
+
return [getType(value)];
|
|
168
|
+
}
|
|
169
|
+
if (value.length === 0) {
|
|
170
|
+
return ['array'];
|
|
171
|
+
}
|
|
172
|
+
const types = new Set();
|
|
173
|
+
for (const element of value) {
|
|
174
|
+
types.add(getType(element));
|
|
175
|
+
}
|
|
176
|
+
return Array.from(types);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getType(value) {
|
|
180
|
+
if (value === undefined) {
|
|
181
|
+
return 'undefined';
|
|
182
|
+
}
|
|
183
|
+
if (value === null) {
|
|
184
|
+
return 'null';
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
return 'array';
|
|
188
|
+
}
|
|
189
|
+
if (value instanceof Date) {
|
|
190
|
+
return 'date';
|
|
191
|
+
}
|
|
192
|
+
if (Buffer.isBuffer(value)) {
|
|
193
|
+
return 'buffer';
|
|
194
|
+
}
|
|
195
|
+
if (value?._bsontype === 'ObjectId') {
|
|
196
|
+
return 'objectId';
|
|
197
|
+
}
|
|
198
|
+
if (value?._bsontype === 'Decimal128') {
|
|
199
|
+
return 'decimal128';
|
|
200
|
+
}
|
|
201
|
+
return typeof value;
|
|
202
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
exports.addField = require('./addField');
|
|
4
|
+
exports.analyzeSchema = require('./analyzeSchema');
|
|
4
5
|
exports.createChatMessage = require('./createChatMessage');
|
|
5
6
|
exports.createDocument = require('./createDocument');
|
|
6
7
|
exports.deleteDocument = require('./deleteDocument');
|
|
@@ -21,3 +22,4 @@ exports.streamDocumentChanges = require('./streamDocumentChanges');
|
|
|
21
22
|
exports.streamChatMessage = require('./streamChatMessage');
|
|
22
23
|
exports.updateDocument = require('./updateDocument');
|
|
23
24
|
exports.updateDocuments = require('./updateDocuments');
|
|
25
|
+
exports.validateDocument = require('./validateDocument');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Archetype = require('archetype');
|
|
4
|
+
const authorize = require('../../authorize');
|
|
5
|
+
const validateDocumentWithTimeout = require('../../helpers/validateDocumentWithTimeout');
|
|
6
|
+
|
|
7
|
+
const ValidateDocumentParams = new Archetype({
|
|
8
|
+
model: {
|
|
9
|
+
$type: 'string',
|
|
10
|
+
$required: true
|
|
11
|
+
},
|
|
12
|
+
documentId: {
|
|
13
|
+
$type: 'string',
|
|
14
|
+
$required: true
|
|
15
|
+
},
|
|
16
|
+
roles: {
|
|
17
|
+
$type: ['string']
|
|
18
|
+
}
|
|
19
|
+
}).compile('ValidateDocumentParams');
|
|
20
|
+
|
|
21
|
+
module.exports = ({ db }) => async function validateDocument(params) {
|
|
22
|
+
const { model, documentId, roles } = new ValidateDocumentParams(params);
|
|
23
|
+
|
|
24
|
+
await authorize('Model.validateDocument', roles);
|
|
25
|
+
|
|
26
|
+
const Model = db.models[model];
|
|
27
|
+
if (Model == null) {
|
|
28
|
+
throw new Error(`Model ${model} not found`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const doc = await Model.findById(documentId).setOptions({ sanitizeFilter: true }).orFail();
|
|
32
|
+
return {
|
|
33
|
+
result: await validateDocumentWithTimeout(doc)
|
|
34
|
+
};
|
|
35
|
+
};
|
package/backend/authorize.js
CHANGED
|
@@ -14,7 +14,9 @@ const actionsToRequiredRoles = {
|
|
|
14
14
|
'Dashboard.getDashboards': ['owner', 'admin', 'member', 'readonly', 'dashboards'],
|
|
15
15
|
'Dashboard.updateDashboard': ['owner', 'admin', 'member'],
|
|
16
16
|
'Model.createDocument': ['owner', 'admin', 'member'],
|
|
17
|
+
'Model.analyzeSchema': ['owner', 'admin', 'member', 'readonly'],
|
|
17
18
|
'Model.updateDocument': ['owner', 'admin', 'member'],
|
|
19
|
+
'Model.validateDocument': ['owner', 'admin', 'member', 'readonly'],
|
|
18
20
|
'Model.deleteDocument': ['owner', 'admin', 'member'],
|
|
19
21
|
'Model.deleteDocuments': ['owner', 'admin', 'member'],
|
|
20
22
|
'Model.dropCollection': ['owner', 'admin'],
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const VALIDATION_TIMEOUT_MS = 1000;
|
|
4
|
+
|
|
5
|
+
module.exports = async function validateDocumentWithTimeout(doc) {
|
|
6
|
+
const documentId = doc._id?.toString() || null;
|
|
7
|
+
try {
|
|
8
|
+
await Promise.race([
|
|
9
|
+
doc.validate(),
|
|
10
|
+
new Promise((resolve, reject) => setTimeout(() => reject(new Error('Validation timed out')), VALIDATION_TIMEOUT_MS))
|
|
11
|
+
]);
|
|
12
|
+
return { valid: true, documentId, error: null, errors: null };
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return {
|
|
15
|
+
valid: false,
|
|
16
|
+
documentId,
|
|
17
|
+
error: err?.message || String(err),
|
|
18
|
+
errors: formatValidationErrors(err)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function formatValidationErrors(err) {
|
|
24
|
+
if (err?.name !== 'ValidationError' || err.errors == null) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const errors = {};
|
|
29
|
+
for (const [path, pathError] of Object.entries(err.errors)) {
|
|
30
|
+
errors[path] = {
|
|
31
|
+
message: pathError?.message || String(pathError),
|
|
32
|
+
name: pathError?.name || null,
|
|
33
|
+
kind: pathError?.kind || null,
|
|
34
|
+
path: pathError?.path || path,
|
|
35
|
+
value: pathError?.value
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return errors;
|
|
39
|
+
}
|