Skip to content

Commit 5e9c088

Browse files
authored
fix: update MongoDB schema converter to remove invalid fields, add integration test (#235)
1 parent 7f25c5e commit 5e9c088

File tree

4 files changed

+184
-74
lines changed

4 files changed

+184
-74
lines changed

src/schema-converters/internalToMongoDB.test.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,6 @@ describe('internalSchemaToMongoDB', async function() {
895895
const mongodb = await convertInternalToMongodb(internal);
896896
assert.deepStrictEqual(mongodb, {
897897
bsonType: 'object',
898-
required: [],
899898
properties: {
900899
_id: {
901900
bsonType: 'objectId'
@@ -939,8 +938,7 @@ describe('internalSchemaToMongoDB', async function() {
939938
uuidOld: {
940939
bsonType: 'binData'
941940
}
942-
},
943-
required: []
941+
}
944942
},
945943
boolean: {
946944
bsonType: 'bool'
@@ -984,8 +982,7 @@ describe('internalSchemaToMongoDB', async function() {
984982
key: {
985983
bsonType: 'string'
986984
}
987-
},
988-
required: []
985+
}
989986
},
990987
objectId: {
991988
bsonType: 'objectId'
@@ -1193,7 +1190,6 @@ describe('internalSchemaToMongoDB', async function() {
11931190
const mongodb = await convertInternalToMongodb(internal);
11941191
assert.deepStrictEqual(mongodb, {
11951192
bsonType: 'object',
1196-
required: [],
11971193
properties: {
11981194
genres: {
11991195
bsonType: 'array',
@@ -1338,7 +1334,6 @@ describe('internalSchemaToMongoDB', async function() {
13381334
const mongodb = await convertInternalToMongodb(internal);
13391335
assert.deepStrictEqual(mongodb, {
13401336
bsonType: 'object',
1341-
required: [],
13421337
properties: {
13431338
genres: {
13441339
bsonType: 'array',
@@ -1510,7 +1505,6 @@ describe('internalSchemaToMongoDB', async function() {
15101505
const mongodb = await convertInternalToMongodb(internal);
15111506
assert.deepStrictEqual(mongodb, {
15121507
bsonType: 'object',
1513-
required: [],
15141508
properties: {
15151509
mixedType: {
15161510
bsonType: ['int', 'string']
@@ -1626,7 +1620,6 @@ describe('internalSchemaToMongoDB', async function() {
16261620
const mongodb = await convertInternalToMongodb(internal);
16271621
assert.deepStrictEqual(mongodb, {
16281622
bsonType: 'object',
1629-
required: [],
16301623
properties: {
16311624
mixedComplexType: {
16321625
anyOf: [

src/schema-converters/internalToMongoDB.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,17 @@ async function parseType(type: SchemaType, signal?: AbortSignal): Promise<MongoD
4646
const schema: MongoDBJSONSchema = {
4747
bsonType: convertInternalType(type.bsonType)
4848
};
49-
switch (type.bsonType) {
50-
case 'Array':
51-
schema.items = await parseTypes((type as ArraySchemaType).types);
52-
break;
53-
case 'Document':
54-
Object.assign(schema,
55-
await parseFields((type as DocumentSchemaType).fields, signal)
56-
);
57-
break;
49+
50+
if (type.bsonType === 'Array') {
51+
const items = await parseTypes((type as ArraySchemaType).types);
52+
// Don't include empty bson type arrays (it's invalid).
53+
if (!items.bsonType || items.bsonType?.length > 0) {
54+
schema.items = items;
55+
}
56+
} else if (type.bsonType === 'Document') {
57+
Object.assign(schema,
58+
await parseFields((type as DocumentSchemaType).fields, signal)
59+
);
5860
}
5961

6062
return schema;
@@ -83,17 +85,17 @@ async function parseTypes(types: SchemaType[], signal?: AbortSignal): Promise<Mo
8385
}
8486

8587
async function parseFields(fields: DocumentSchemaType['fields'], signal?: AbortSignal): Promise<{
86-
required: MongoDBJSONSchema['required'],
88+
required?: MongoDBJSONSchema['required'],
8789
properties: MongoDBJSONSchema['properties'],
8890
}> {
89-
const required = [];
91+
const required: string[] = [];
9092
const properties: MongoDBJSONSchema['properties'] = {};
9193
for (const field of fields) {
9294
if (field.probability === 1) required.push(field.name);
9395
properties[field.name] = await parseTypes(field.types, signal);
9496
}
9597

96-
return { required, properties };
98+
return { properties, ...(required.length > 0 ? { required } : {}) };
9799
}
98100

99101
export async function convertInternalToMongodb(
@@ -104,7 +106,8 @@ export async function convertInternalToMongodb(
104106
const { required, properties } = await parseFields(internalSchema.fields, options.signal);
105107
const schema: MongoDBJSONSchema = {
106108
bsonType: 'object',
107-
required,
109+
// Prevent adding an empty required array as it isn't valid.
110+
...((required === undefined) ? {} : { required }),
108111
properties
109112
};
110113
return schema;

test/all-bson-types-fixture.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,34 @@ export const allBSONTypesDoc = {
4949
uuid: new UUID('AAAAAAAA-AAAA-4AAA-AAAA-AAAAAAAAAAAA'), // 4
5050
md5: Binary.createFromBase64('c//SZESzTGmQ6OfR38A11A==', 5), // 5
5151
encrypted: Binary.createFromBase64('c//SZESzTGmQ6OfR38A11A==', 6), // 6
52-
compressedTimeSeries: Binary.createFromBase64('c//SZESzTGmQ6OfR38A11A==', 7), // 7
52+
compressedTimeSeries: new Binary(
53+
Buffer.from(
54+
'CQCKW/8XjAEAAIfx//////////H/////////AQAAAAAAAABfAAAAAAAAAAEAAAAAAAAAAgAAAAAAAAAHAAAAAAAAAA4AAAAAAAAAAA==',
55+
'base64'
56+
),
57+
7
58+
), // 7
5359
custom: Binary.createFromBase64('//8=', 128) // 128
5460
},
5561

5662
dbRef: new DBRef('namespace', new ObjectId('642d76b4b7ebfab15d3c4a78')) // not actually a separate type, just a convention
5763
};
64+
65+
const {
66+
dbRef,
67+
...allValidBSONTypesDoc
68+
} = allBSONTypesDoc;
69+
70+
// Includes some edge cases like empty objects, nested empty arrays, etc.
71+
export const allValidBSONTypesWithEdgeCasesDoc = {
72+
...allValidBSONTypesDoc,
73+
emptyObject: {},
74+
objectWithNestedEmpty: {
75+
nestedEmpty: {}
76+
},
77+
emptyArray: [],
78+
arrayOfEmptyArrays: [[], []],
79+
infinityNum: Infinity,
80+
negativeInfinityNum: -Infinity,
81+
NaNNum: NaN
82+
};
Lines changed: 140 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { analyzeDocuments } from '../../src';
21
import Ajv2020 from 'ajv/dist/2020';
32
import assert from 'assert';
4-
import { ObjectId, Int32, Double, EJSON } from 'bson';
3+
import {
4+
Double,
5+
Int32,
6+
ObjectId,
7+
EJSON
8+
} from 'bson';
59
import { MongoClient, type Db } from 'mongodb';
610
import { mochaTestServer } from '@mongodb-js/compass-test-server';
711

12+
import { allValidBSONTypesWithEdgeCasesDoc } from '../all-bson-types-fixture';
13+
import { analyzeDocuments } from '../../src';
14+
815
const bsonDocuments = [{
916
_id: new ObjectId('67863e82fb817085a6b0ebad'),
1017
title: 'My book',
@@ -47,74 +54,156 @@ describe('Documents -> Generate schema -> Validate Documents against the schema'
4754
});
4855
});
4956

50-
describe('Documents -> Generate schema -> Use schema in validation rule in MongoDB -> Validate documents against the schema', function() {
57+
describe('With a MongoDB Cluster', function() {
5158
let client: MongoClient;
5259
let db: Db;
5360
const cluster = mochaTestServer();
5461

5562
before(async function() {
56-
// Create the schema validation rule.
57-
const analyzedDocuments = await analyzeDocuments(bsonDocuments);
58-
const schema = await analyzedDocuments.getMongoDBJsonSchema();
59-
const validationRule = {
60-
$jsonSchema: schema
61-
};
62-
6363
// Connect to the mongodb instance.
6464
const connectionString = cluster().connectionString;
6565
client = new MongoClient(connectionString);
6666
await client.connect();
6767
db = client.db('test');
68-
69-
// Create a collection with the schema validation in Compass.
70-
await db.createCollection('books', {
71-
validator: validationRule
72-
});
7368
});
69+
7470
after(async function() {
7571
await client?.close();
7672
});
7773

78-
it('allows inserting valid documents', async function() {
79-
await db.collection('books').insertMany(bsonDocuments);
80-
});
74+
describe('Documents -> Generate basic schema -> Use schema in validation rule in MongoDB -> Validate documents against the schema', function() {
75+
before(async function() {
76+
// Create the schema validation rule.
77+
const analyzedDocuments = await analyzeDocuments(bsonDocuments);
78+
const schema = await analyzedDocuments.getMongoDBJsonSchema();
79+
const validationRule = {
80+
$jsonSchema: schema
81+
};
82+
83+
// Create a collection with the schema validation.
84+
await db.createCollection('books', {
85+
validator: validationRule
86+
});
87+
});
88+
89+
it('allows inserting valid documents', async function() {
90+
await db.collection('books').insertMany(bsonDocuments);
91+
});
92+
93+
it('prevents inserting invalid documents', async function() {
94+
const invalidDocs = [{
95+
_id: new ObjectId('67863e82fb817085a6b0ebba'),
96+
title: 'Pineapple 1',
97+
year: new Int32(1983),
98+
genres: [
99+
'crimi',
100+
'comedy',
101+
{
102+
short: 'scifi',
103+
long: 'science fiction'
104+
}
105+
],
106+
number: 'an invalid string'
107+
}, {
108+
_id: new ObjectId('67863eacfb817085a6b0ebbb'),
109+
title: 'Pineapple 2',
110+
year: 'year a string'
111+
}, {
112+
_id: new ObjectId('67863eacfb817085a6b0ebbc'),
113+
title: 123,
114+
year: new Int32('1999')
115+
}, {
116+
_id: new ObjectId('67863eacfb817085a6b0ebbc'),
117+
title: 'No year'
118+
}];
81119

82-
it('prevents inserting invalid documents', async function() {
83-
const invalidDocs = [{
84-
_id: new ObjectId('67863e82fb817085a6b0ebba'),
85-
title: 'Pineapple 1',
86-
year: new Int32(1983),
87-
genres: [
88-
'crimi',
89-
'comedy',
90-
{
91-
short: 'scifi',
92-
long: 'science fiction'
120+
for (const doc of invalidDocs) {
121+
try {
122+
await db.collection('books').insertOne(doc);
123+
124+
throw new Error('This should not be reached');
125+
} catch (e: any) {
126+
const expectedMessage = 'Document failed validation';
127+
assert.ok(e.message.includes(expectedMessage), `Expected error ${e.message} message to include "${expectedMessage}", doc: ${doc._id}`);
93128
}
94-
],
95-
number: 'an invalid string'
96-
}, {
97-
_id: new ObjectId('67863eacfb817085a6b0ebbb'),
98-
title: 'Pineapple 2',
99-
year: 'year a string'
100-
}, {
101-
_id: new ObjectId('67863eacfb817085a6b0ebbc'),
102-
title: 123,
103-
year: new Int32('1999')
104-
}, {
105-
_id: new ObjectId('67863eacfb817085a6b0ebbc'),
106-
title: 'No year'
107-
}];
108-
109-
for (const doc of invalidDocs) {
129+
}
130+
});
131+
});
132+
133+
describe('[All Types] Documents -> Generate basic schema -> Use schema in validation rule in MongoDB -> Validate documents against the schema', function() {
134+
const allTypesCollection = 'allTypes';
135+
136+
before(async function() {
137+
await db.collection(allTypesCollection).insertOne(allValidBSONTypesWithEdgeCasesDoc);
138+
const docsFromCollection = await db.collection(allTypesCollection).find({}, { promoteValues: false }).toArray();
139+
140+
// Create the schema validation rule.
141+
const analyzedDocuments = await analyzeDocuments(docsFromCollection);
142+
const schema = await analyzedDocuments.getMongoDBJsonSchema();
143+
const validationRule = {
144+
$jsonSchema: schema
145+
};
146+
// Update the collection with the schema validation.
147+
await db.command({
148+
collMod: allTypesCollection,
149+
validator: validationRule
150+
});
151+
});
152+
153+
it('allows inserting valid documents (does not error)', async function() {
154+
const docs = [{
155+
...allValidBSONTypesWithEdgeCasesDoc,
156+
_id: new ObjectId()
157+
}, {
158+
...allValidBSONTypesWithEdgeCasesDoc,
159+
_id: new ObjectId()
160+
}];
161+
110162
try {
111-
await db.collection('books').insertOne(doc);
163+
await db.collection(allTypesCollection).insertMany(docs);
164+
} catch (err) {
165+
console.error('Error inserting documents', EJSON.stringify(err, undefined, 2));
166+
throw err;
167+
}
168+
});
169+
170+
it('prevents inserting invalid documents', async function() {
171+
const invalidDocs = [{
172+
_id: new ObjectId('67863e82fb817085a6b0ebba'),
173+
title: 'Pineapple 1',
174+
year: new Int32(1983),
175+
genres: [
176+
'crimi',
177+
'comedy',
178+
{
179+
short: 'scifi',
180+
long: 'science fiction'
181+
}
182+
],
183+
number: 'an invalid string'
184+
}, {
185+
_id: new ObjectId('67863eacfb817085a6b0ebbb'),
186+
title: 'Pineapple 2',
187+
year: 'year a string'
188+
}, {
189+
_id: new ObjectId('67863eacfb817085a6b0ebbc'),
190+
title: 123,
191+
year: new Int32('1999')
192+
}, {
193+
_id: new ObjectId('67863eacfb817085a6b0ebbc'),
194+
title: 'No year'
195+
}];
196+
197+
for (const doc of invalidDocs) {
198+
try {
199+
await db.collection(allTypesCollection).insertOne(doc);
112200

113-
throw new Error('This should not be reached');
114-
} catch (e: any) {
115-
const expectedMessage = 'Document failed validation';
116-
assert.ok(e.message.includes(expectedMessage), `Expected error ${e.message} message to include "${expectedMessage}", doc: ${doc._id}`);
201+
throw new Error('This should not be reached');
202+
} catch (e: any) {
203+
const expectedMessage = 'Document failed validation';
204+
assert.ok(e.message.includes(expectedMessage), `Expected error ${e.message} message to include "${expectedMessage}", doc: ${doc._id}`);
205+
}
117206
}
118-
}
207+
});
119208
});
120209
});

0 commit comments

Comments
 (0)