Skip to content

[WIP] feat: generate TypeScript from MongoDBJSONSchema or JSON Schemas MONGOSH-2058 #236

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -94,5 +95,6 @@ export {
getSchemaPaths,
getSimplifiedSchema,
SchemaAnalyzer,
schemaStats
schemaStats,
toTypescriptTypeDefinition
};
189 changes: 189 additions & 0 deletions src/to-typescript.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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('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), `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() {
const databaseName = 'myDb';
const collectionName = 'myCollection';

console.log(inspect(jsonSchema, { depth: null }));

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[]
};
};`);
});
});
138 changes: 138 additions & 0 deletions src/to-typescript.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, MongoDBJSONSchema>, 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)};
};`;
}
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { InternalSchema } from '.';

export type StandardJSONSchema = JSONSchema4;

export type MongoDBJSONSchema = Pick<StandardJSONSchema, 'title' | 'required' | 'description'> & {
export type MongoDBJSONSchema = Partial<StandardJSONSchema> & Pick<StandardJSONSchema, 'title' | 'required' | 'description'> & {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still allowing all JSON Schema fields so that things like JSON Schema's type can be used.

bsonType?: string | string[];
properties?: Record<string, MongoDBJSONSchema>;
items?: MongoDBJSONSchema | MongoDBJSONSchema[];
Expand Down
Loading