From 33403404cc420ad59218350013fa4f983cb75d6b Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 13 Mar 2025 12:20:05 +0000 Subject: [PATCH 1/4] Generate TypeScript from MongoDBJSONSchema or JSON Schemas --- src/index.ts | 4 +- src/to-typescript.spec.ts | 139 ++++++++++++++++++++++++++++++++++++++ src/to-typescript.ts | 138 +++++++++++++++++++++++++++++++++++++ src/types.ts | 2 +- 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/to-typescript.spec.ts create mode 100644 src/to-typescript.ts diff --git a/src/index.ts b/src/index.ts index a471ae6..aa6ad6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { convertInternalToMongodb } from './schema-converters/internalToMongoDB' import { convertInternalToStandard } from './schema-converters/internalToStandard'; import * as schemaStats from './stats'; import { AnyIterable, StandardJSONSchema, MongoDBJSONSchema, ExpandedJSONSchema } from './types'; +import { toTypescriptTypeDefinition } from './to-typescript'; /** * Analyze documents - schema can be retrieved in different formats. @@ -94,5 +95,6 @@ export { getSchemaPaths, getSimplifiedSchema, SchemaAnalyzer, - schemaStats + schemaStats, + toTypescriptTypeDefinition }; diff --git a/src/to-typescript.spec.ts b/src/to-typescript.spec.ts new file mode 100644 index 0000000..69d1aca --- /dev/null +++ b/src/to-typescript.spec.ts @@ -0,0 +1,139 @@ +import { analyzeDocuments, StandardJSONSchema, toTypescriptTypeDefinition } from '.'; + +import assert from 'assert/strict'; + +import { + BSONRegExp, + Binary, + Code, + DBRef, + Decimal128, + Double, + Int32, + Long, + MaxKey, + MinKey, + ObjectId, + Timestamp, + UUID, + BSONSymbol +} from 'bson'; + +import { inspect } from 'util'; + +const bsonDocuments = [ + { + _id: new ObjectId('642d766b7300158b1f22e972'), + double: new Double(1.2), // Double, 1, double + doubleThatIsAlsoAnInteger: new Double(1), // Double, 1, double + string: 'Hello, world!', // String, 2, string + object: { key: 'value' }, // Object, 3, object + array: [1, 2, 3], // Array, 4, array + binData: new Binary(Buffer.from([1, 2, 3])), // Binary data, 5, binData + // Undefined, 6, undefined (deprecated) + objectId: new ObjectId('642d766c7300158b1f22e975'), // ObjectId, 7, objectId + boolean: true, // Boolean, 8, boolean + date: new Date('2023-04-05T13:25:08.445Z'), // Date, 9, date + null: null, // Null, 10, null + regex: new BSONRegExp('pattern', 'i'), // Regular Expression, 11, regex + // DBPointer, 12, dbPointer (deprecated) + javascript: new Code('function() {}'), // JavaScript, 13, javascript + symbol: new BSONSymbol('symbol'), // Symbol, 14, symbol (deprecated) + javascriptWithScope: new Code('function() {}', { foo: 1, bar: 'a' }), // JavaScript code with scope 15 "javascriptWithScope" Deprecated in MongoDB 4.4. + int: new Int32(12345), // 32-bit integer, 16, "int" + timestamp: new Timestamp(new Long('7218556297505931265')), // Timestamp, 17, timestamp + long: new Long('123456789123456789'), // 64-bit integer, 18, long + decimal: new Decimal128( + Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]) + ), // Decimal128, 19, decimal + minKey: new MinKey(), // Min key, -1, minKey + maxKey: new MaxKey(), // Max key, 127, maxKey + + binaries: { + generic: new Binary(Buffer.from([1, 2, 3]), 0), // 0 + functionData: new Binary(Buffer.from('//8='), 1), // 1 + binaryOld: new Binary(Buffer.from('//8='), 2), // 2 + uuidOld: new Binary(Buffer.from('c//SZESzTGmQ6OfR38A11A=='), 3), // 3 + uuid: new UUID('AAAAAAAA-AAAA-4AAA-AAAA-AAAAAAAAAAAA'), // 4 + md5: new Binary(Buffer.from('c//SZESzTGmQ6OfR38A11A=='), 5), // 5 + encrypted: new Binary(Buffer.from('c//SZESzTGmQ6OfR38A11A=='), 6), // 6 + compressedTimeSeries: new Binary( + Buffer.from( + 'CQCKW/8XjAEAAIfx//////////H/////////AQAAAAAAAABfAAAAAAAAAAEAAAAAAAAAAgAAAAAAAAAHAAAAAAAAAA4AAAAAAAAAAA==', + 'base64' + ), + 7 + ), // 7 + custom: new Binary(Buffer.from('//8='), 128) // 128 + }, + + dbRef: new DBRef('namespace', new ObjectId('642d76b4b7ebfab15d3c4a78')) // not actually a separate type, just a convention + + // TODO: what about arrays of objects or arrays of arrays or heterogynous types in general + } +]; + +// from https://json-schema.org/learn/miscellaneous-examples#complex-object-with-nested-properties +const jsonSchema: StandardJSONSchema = { + $id: 'https://example.com/complex-object.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'Complex Object', + type: 'object', + properties: { + name: { + type: 'string' + }, + age: { + type: 'integer', + minimum: 0 + }, + address: { + type: 'object', + properties: { + street: { + type: 'string' + }, + city: { + type: 'string' + }, + state: { + type: 'string' + }, + postalCode: { + type: 'string', + pattern: '\\d{5}' + } + }, + required: ['street', 'city', 'state', 'postalCode'] + }, + hobbies: { + type: 'array', + items: { + type: 'string' + } + } + }, + required: ['name', 'age'] +}; + +describe.only('toTypescriptTypeDefinition', function() { + it('converts a MongoDB JSON schema to TypeScript', async function() { + const databaseName = 'myDb'; + const collectionName = 'myCollection'; + const analyzedDocuments = await analyzeDocuments(bsonDocuments); + const schema = await analyzedDocuments.getMongoDBJsonSchema(); + + console.log(inspect(schema, { depth: null })); + + assert.equal(toTypescriptTypeDefinition(databaseName, collectionName, schema), ''); + }); + + it('converts a standard JSON schema to TypeScript', function() { + const databaseName = 'myDb'; + const collectionName = 'myCollection'; + + console.log(inspect(jsonSchema, { depth: null })); + + assert.equal(toTypescriptTypeDefinition(databaseName, collectionName, jsonSchema), ''); + }); +}); diff --git a/src/to-typescript.ts b/src/to-typescript.ts new file mode 100644 index 0000000..11ca8aa --- /dev/null +++ b/src/to-typescript.ts @@ -0,0 +1,138 @@ +import assert from 'assert'; +import type { MongoDBJSONSchema } from './types'; + +function getBSONType(property: MongoDBJSONSchema): string | string[] | undefined { + return property.bsonType || property.type; +} + +function isBSONObjectProperty(property: MongoDBJSONSchema): boolean { + return getBSONType(property) === 'object'; +} + +function isBSONArrayProperty(property: MongoDBJSONSchema): boolean { + return getBSONType(property) === 'array'; +} + +function isBSONPrimitive(property: MongoDBJSONSchema): boolean { + return !(isBSONArrayProperty(property) || isBSONObjectProperty(property)); +} + +function toTypeName(type: string): string { + // JSON Schema types + if (type === 'string') { + return 'string'; + } + if (type === 'number' || type === 'integer') { + return 'number'; + } + if (type === 'boolean') { + return 'boolean'; + } + if (type === 'null') { + return 'null'; + } + + // BSON types + // see InternalTypeToBsonTypeMap + if (type === 'double') { + return 'bson.Double'; + } + if (type === 'binData') { + return 'bson.Binary'; + } + if (type === 'objectId') { + return 'bson.ObjectId'; + } + if (type === 'bool') { + return 'boolean'; + } + if (type === 'date') { + return 'bson.Date'; + } + if (type === 'regex') { + return 'bson.BSONRegExp'; + } + if (type === 'symbol') { + return 'bson.BSONSymbol'; + } + if (type === 'javascript' || type === 'javascriptWithScope') { + return 'bson.Code'; + } + if (type === 'int') { + return 'bson.Int32'; + } + if (type === 'timestamp') { + return 'bson.Timestamp'; + } + if (type === 'long') { + return 'bson.Long'; + } + if (type === 'decimal') { + return 'bson.Decimal128'; + } + if (type === 'minKey') { + return 'bson.MinKey'; + } + if (type === 'maxKey') { + return 'bson.MaxKey'; + } + if (type === 'dbPointer') { + return 'bson.DBPointer'; + } + if (type === 'undefined') { + return 'undefined'; + } + + return 'any'; +} + +function uniqueTypes(property: MongoDBJSONSchema): Set { + const type = getBSONType(property); + return new Set(Array.isArray(type) ? type.map((t) => toTypeName(t)) : [toTypeName(type ?? 'any')]); +} + +function indentSpaces(indent: number) { + const spaces = []; + for (let i = 0; i < indent; i++) { + spaces.push(' '); + } + return spaces.join(''); +} + +function arrayType(types: string[]) { + assert(types.length, 'expected types'); + + if (types.length === 1) { + return `${types[0]}[]`; + } + return `${types.join(' | ')})[]`; +} + +function toTypescriptType(properties: Record, indent: number): string { + const eachFieldDefinition = Object.entries(properties).map(([propertyName, schema]) => { + if (isBSONPrimitive(schema)) { + return `${indentSpaces(indent)}${propertyName}?: ${[...uniqueTypes(schema)].join(' | ')}`; + } + + if (isBSONArrayProperty(schema)) { + assert(schema.items, 'expected schema.items'); + return `${indentSpaces(indent)}${propertyName}?: ${arrayType([...uniqueTypes(schema.items)])}`; + } + + if (isBSONObjectProperty(schema)) { + assert(schema.properties, 'expected schema.properties'); + return `${indentSpaces(indent)}${propertyName}?: ${toTypescriptType(schema.properties, indent + 1)}`; + } + + assert(false, 'this should not be possible'); + }); + + return `{\n${eachFieldDefinition.join(';\n')}\n${indentSpaces(indent - 1)}}`; +} +export function toTypescriptTypeDefinition(databaseName: string, collectionName: string, schema: MongoDBJSONSchema): string { + assert(schema.properties, 'expected schama.properties'); + + return `module ${databaseName} { + type ${collectionName} = ${toTypescriptType(schema.properties, 2)}; +};`; +} diff --git a/src/types.ts b/src/types.ts index 5c340f4..b044bd9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ import { InternalSchema } from '.'; export type StandardJSONSchema = JSONSchema4; -export type MongoDBJSONSchema = Pick & { +export type MongoDBJSONSchema = Partial & Pick & { bsonType?: string | string[]; properties?: Record; items?: MongoDBJSONSchema | MongoDBJSONSchema[]; From b8c6d94632cf4aad2b9dec78affede8c5c6bcf59 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 13 Mar 2025 12:28:46 +0000 Subject: [PATCH 2/4] commit the actual results --- src/to-typescript.spec.ts | 56 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/to-typescript.spec.ts b/src/to-typescript.spec.ts index 69d1aca..5a29996 100644 --- a/src/to-typescript.spec.ts +++ b/src/to-typescript.spec.ts @@ -116,7 +116,7 @@ const jsonSchema: StandardJSONSchema = { required: ['name', 'age'] }; -describe.only('toTypescriptTypeDefinition', function() { +describe('toTypescriptTypeDefinition', function() { it('converts a MongoDB JSON schema to TypeScript', async function() { const databaseName = 'myDb'; const collectionName = 'myCollection'; @@ -125,7 +125,45 @@ describe.only('toTypescriptTypeDefinition', function() { console.log(inspect(schema, { depth: null })); - assert.equal(toTypescriptTypeDefinition(databaseName, collectionName, schema), ''); + assert.equal(toTypescriptTypeDefinition(databaseName, collectionName, schema), `module myDb { + type myCollection = { + _id?: bson.ObjectId; + array?: bson.Double[]; + binaries?: { + binaryOld?: bson.Binary; + compressedTimeSeries?: bson.Binary; + custom?: bson.Binary; + encrypted?: bson.Binary; + functionData?: bson.Binary; + generic?: bson.Binary; + md5?: bson.Binary; + uuid?: bson.Binary; + uuidOld?: bson.Binary + }; + binData?: bson.Binary; + boolean?: boolean; + date?: bson.Date; + dbRef?: bson.DBPointer; + decimal?: bson.Decimal128; + double?: bson.Double; + doubleThatIsAlsoAnInteger?: bson.Double; + int?: bson.Int32; + javascript?: bson.Code; + javascriptWithScope?: bson.Code; + long?: bson.Long; + maxKey?: bson.MaxKey; + minKey?: bson.MinKey; + null?: null; + object?: { + key?: string + }; + objectId?: bson.ObjectId; + regex?: bson.BSONRegExp; + string?: string; + symbol?: bson.BSONSymbol; + timestamp?: bson.Timestamp + }; +};`); }); it('converts a standard JSON schema to TypeScript', function() { @@ -134,6 +172,18 @@ describe.only('toTypescriptTypeDefinition', function() { console.log(inspect(jsonSchema, { depth: null })); - assert.equal(toTypescriptTypeDefinition(databaseName, collectionName, jsonSchema), ''); + assert.equal(toTypescriptTypeDefinition(databaseName, collectionName, jsonSchema), `module myDb { + type myCollection = { + name?: string; + age?: number; + address?: { + street?: string; + city?: string; + state?: string; + postalCode?: string + }; + hobbies?: string[] + }; +};`); }); }); From 8f7e72e207759e24e7efd57321cb1111a2b90987 Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 13 Mar 2025 12:37:52 +0000 Subject: [PATCH 3/4] make sure it picks up the test --- package.json | 2 +- src/{to-typescript.spec.ts => to-typescript.test.ts} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{to-typescript.spec.ts => to-typescript.test.ts} (99%) diff --git a/package.json b/package.json index d4045e9..c744e74 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ ".esm-wrapper.mjs" ], "scripts": { - "test": "nyc mocha --timeout 5000 --colors -r ts-node/register test/*.ts test/**/*.ts src/**/*.test.ts", + "test": "nyc mocha --timeout 5000 --colors -r ts-node/register test/*.ts test/**/*.ts src/*.test.ts src/**/*.test.ts", "test-example-parse-from-file": "ts-node examples/parse-from-file.ts", "test-example-parse-schema": "ts-node examples/parse-schema.ts", "test-time": "ts-node ./test/time-testing.ts", diff --git a/src/to-typescript.spec.ts b/src/to-typescript.test.ts similarity index 99% rename from src/to-typescript.spec.ts rename to src/to-typescript.test.ts index 5a29996..81bcb27 100644 --- a/src/to-typescript.spec.ts +++ b/src/to-typescript.test.ts @@ -1,4 +1,4 @@ -import { analyzeDocuments, StandardJSONSchema, toTypescriptTypeDefinition } from '.'; +import { analyzeDocuments, StandardJSONSchema, toTypescriptTypeDefinition } from './index'; import assert from 'assert/strict'; From b3d786eb05fb992574bf689976495153ca32fe9a Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 13 Mar 2025 12:43:37 +0000 Subject: [PATCH 4/4] don't use the assert module --- src/to-typescript.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/to-typescript.ts b/src/to-typescript.ts index 11ca8aa..0828b7e 100644 --- a/src/to-typescript.ts +++ b/src/to-typescript.ts @@ -1,4 +1,3 @@ -import assert from 'assert'; import type { MongoDBJSONSchema } from './types'; function getBSONType(property: MongoDBJSONSchema): string | string[] | undefined { @@ -17,6 +16,12 @@ function isBSONPrimitive(property: MongoDBJSONSchema): boolean { return !(isBSONArrayProperty(property) || isBSONObjectProperty(property)); } +function assertIsDefined(value: T): asserts value is NonNullable { + if (value === undefined || value === null) { + throw new Error(`${value} is not defined`); + } +} + function toTypeName(type: string): string { // JSON Schema types if (type === 'string') { @@ -100,7 +105,7 @@ function indentSpaces(indent: number) { } function arrayType(types: string[]) { - assert(types.length, 'expected types'); + assertIsDefined(types.length); if (types.length === 1) { return `${types[0]}[]`; @@ -115,22 +120,22 @@ function toTypescriptType(properties: Record, indent: } if (isBSONArrayProperty(schema)) { - assert(schema.items, 'expected schema.items'); + assertIsDefined(schema.items); return `${indentSpaces(indent)}${propertyName}?: ${arrayType([...uniqueTypes(schema.items)])}`; } if (isBSONObjectProperty(schema)) { - assert(schema.properties, 'expected schema.properties'); + assertIsDefined(schema.properties); return `${indentSpaces(indent)}${propertyName}?: ${toTypescriptType(schema.properties, indent + 1)}`; } - assert(false, 'this should not be possible'); + throw new Error('We should never get here'); }); return `{\n${eachFieldDefinition.join(';\n')}\n${indentSpaces(indent - 1)}}`; } export function toTypescriptTypeDefinition(databaseName: string, collectionName: string, schema: MongoDBJSONSchema): string { - assert(schema.properties, 'expected schama.properties'); + assertIsDefined(schema.properties); return `module ${databaseName} { type ${collectionName} = ${toTypescriptType(schema.properties, 2)};