diff --git a/src/index.ts b/src/index.ts index f4c6e44..cac78d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,9 @@ import type { SimplifiedSchemaField, SimplifiedSchema } from './schema-analyzer'; +import { convertInternalToExpanded } from './schema-converters/internalToExpanded'; +import { convertInternalToMongodb } from './schema-converters/internalToMongoDB'; +import { convertInternalToStandard } from './schema-converters/internalToStandard'; import * as schemaStats from './stats'; import { AnyIterable, StandardJSONSchema, MongoDBJSONSchema, ExpandedJSONSchema } from './types'; @@ -28,7 +31,11 @@ async function analyzeDocuments( options?: SchemaParseOptions ): Promise { const internalSchema = (await getCompletedSchemaAnalyzer(source, options)).getResult(); - return new InternalSchemaBasedAccessor(internalSchema); + return new InternalSchemaBasedAccessor(internalSchema, { + internalToStandard: convertInternalToStandard, + internalToMongoDB: convertInternalToMongodb, + internalToExpanded: convertInternalToExpanded + }); } /** diff --git a/src/schema-accessor.ts b/src/schema-accessor.ts index edf6128..96cf19f 100644 --- a/src/schema-accessor.ts +++ b/src/schema-accessor.ts @@ -1,6 +1,5 @@ import { Schema as InternalSchema } from './schema-analyzer'; -import { convertors } from './schema-convertors'; -import { ExpandedJSONSchema, MongoDBJSONSchema, StandardJSONSchema } from './types'; +import { ExpandedJSONSchema, MongoDBJSONSchema, SchemaConverterFn, StandardJSONSchema } from './types'; export interface SchemaAccessor { getStandardJsonSchema: () => Promise; @@ -19,14 +18,22 @@ type Options = { * the others are converted lazily and memoized. * Conversion can be aborted. */ + +export type InternalConverters = { + internalToStandard: SchemaConverterFn, + internalToExpanded: SchemaConverterFn, + internalToMongoDB: SchemaConverterFn, +} export class InternalSchemaBasedAccessor implements SchemaAccessor { private internalSchema: InternalSchema; private standardJSONSchema?: StandardJSONSchema; private mongodbJSONSchema?: MongoDBJSONSchema; - private ExpandedJSONSchema?: ExpandedJSONSchema; + private expandedJSONSchema?: ExpandedJSONSchema; + private converters: InternalConverters; - constructor(internalSchema: InternalSchema) { + constructor(internalSchema: InternalSchema, converters: InternalConverters) { this.internalSchema = internalSchema; + this.converters = converters; } async getInternalSchema(): Promise { @@ -38,20 +45,20 @@ export class InternalSchemaBasedAccessor implements SchemaAccessor { * https://json-schema.org/draft/2020-12/schema */ async getStandardJsonSchema(options: Options = {}): Promise { - return this.standardJSONSchema ??= await convertors.internalSchemaToStandard(this.internalSchema, options); + return this.standardJSONSchema ??= await this.converters.internalToStandard(this.internalSchema, options); } /** * Get MongoDB's $jsonSchema */ async getMongoDBJsonSchema(options: Options = {}): Promise { - return this.mongodbJSONSchema ??= await convertors.internalSchemaToMongoDB(this.internalSchema, options); + return this.mongodbJSONSchema ??= await this.converters.internalToMongoDB(this.internalSchema, options); } /** * Get expanded JSON Schema - with additional properties */ async getExpandedJSONSchema(options: Options = {}): Promise { - return this.ExpandedJSONSchema ??= await convertors.internalSchemaToExpanded(this.internalSchema, options); + return this.expandedJSONSchema ??= await this.converters.internalToExpanded(this.internalSchema, options); } } diff --git a/src/schema-convertors/internalToExpanded.test.ts b/src/schema-converters/internalToExpanded.test.ts similarity index 95% rename from src/schema-convertors/internalToExpanded.test.ts rename to src/schema-converters/internalToExpanded.test.ts index 0999296..dc5fb65 100644 --- a/src/schema-convertors/internalToExpanded.test.ts +++ b/src/schema-converters/internalToExpanded.test.ts @@ -1,6 +1,6 @@ import assert from 'assert'; -import internalSchemaToExpanded from './internalToExpanded'; import { RELAXED_EJSON_DEFINITIONS } from './internalToStandard'; +import { convertInternalToExpanded } from './internalToExpanded'; describe('internalSchemaToExpanded', async function() { describe('Converts: ', async function() { @@ -336,12 +336,20 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + const expectedDefinitions: Partial = { ...RELAXED_EJSON_DEFINITIONS }; + delete expectedDefinitions.BSONSymbol; + delete expectedDefinitions.CodeWScope; + delete expectedDefinitions.DBPointer; + delete expectedDefinitions.DBRef; + delete expectedDefinitions.Date; + delete expectedDefinitions.MinKey; + delete expectedDefinitions.Undefined; + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: expectedDefinitions, properties: { _id: { $ref: '#/$defs/ObjectId', @@ -593,12 +601,15 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + const expectedDefinitions = { + Double: RELAXED_EJSON_DEFINITIONS.Double + }; + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: ['author'], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: expectedDefinitions, properties: { author: { type: 'object', @@ -704,12 +715,12 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { genres: { type: 'array', @@ -867,12 +878,12 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { genres: { type: 'array', @@ -1002,12 +1013,12 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: ['arrayMixedType'], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { arrayMixedType: { type: 'array', @@ -1111,12 +1122,12 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { mixedType: { 'x-metadata': { @@ -1252,12 +1263,12 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { mixedComplexType: { 'x-metadata': { @@ -1360,12 +1371,15 @@ describe('internalSchemaToExpanded', async function() { } ] }; - const standard = await internalSchemaToExpanded(internal); - assert.deepStrictEqual(standard, { + const expanded = await convertInternalToExpanded(internal); + const expectedDefinitions = { + ObjectId: RELAXED_EJSON_DEFINITIONS.ObjectId + }; + assert.deepStrictEqual(expanded, { type: 'object', 'x-bsonType': 'object', required: ['mixedType'], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: expectedDefinitions, properties: { mixedType: { 'x-metadata': { @@ -1507,7 +1521,7 @@ describe('internalSchemaToExpanded', async function() { ] }; const abortController = new AbortController(); - const promise = internalSchemaToExpanded(internal, { signal: abortController.signal }); + const promise = convertInternalToExpanded(internal, { signal: abortController.signal }); abortController.abort(new Error('Too long, didn\'t wait.')); await assert.rejects(promise, { name: 'Error', diff --git a/src/schema-converters/internalToExpanded.ts b/src/schema-converters/internalToExpanded.ts new file mode 100644 index 0000000..fbe8217 --- /dev/null +++ b/src/schema-converters/internalToExpanded.ts @@ -0,0 +1,118 @@ +import { ArraySchemaType, DocumentSchemaType, Schema as InternalSchema, SchemaType, SchemaField } from '../schema-analyzer'; +import { type ExpandedJSONSchema } from '../types'; +import { InternalTypeToStandardTypeMap, RELAXED_EJSON_DEFINITIONS } from './internalToStandard'; +import { InternalTypeToBsonTypeMap } from './internalToMongoDB'; +import { allowAbort } from './util'; + +const createConvertInternalToExpanded = function() { + const usedDefinitions = new Set(); + + function getUsedDefinitions() { + const filteredDefinitions = Object.fromEntries( + Object.entries(RELAXED_EJSON_DEFINITIONS).filter(([key]) => usedDefinitions.has(key)) + ); + return Object.freeze(filteredDefinitions); + } + + function markUsedDefinition(ref: string) { + usedDefinitions.add(ref.split('/')[2]); + } + + function getStandardType(internalType: string) { + const type = InternalTypeToStandardTypeMap[internalType]; + if (!type) throw new Error(`Encountered unknown type: ${internalType}`); + return { ...type }; + } + + function getBsonType(internalType: string) { + const type = InternalTypeToBsonTypeMap[internalType]; + if (!type) throw new Error(`Encountered unknown type: ${internalType}`); + return type; + } + + async function parseType(type: SchemaType, signal?: AbortSignal): Promise { + await allowAbort(signal); + const schema: ExpandedJSONSchema = { + ...getStandardType(type.bsonType), + 'x-bsonType': getBsonType(type.bsonType), + 'x-metadata': getMetadata(type) + }; + if ('values' in type && type.values) { + schema['x-sampleValues'] = type.values; + } + if (schema.$ref) markUsedDefinition(schema.$ref); + switch (type.bsonType) { + case 'Array': + schema.items = await parseTypes((type as ArraySchemaType).types, signal); + break; + case 'Document': + Object.assign(schema, await parseFields((type as DocumentSchemaType).fields, signal)); + break; + } + return schema; + } + + function getMetadata({ + hasDuplicates, + probability, + count + }: TType) { + return { + ...(typeof hasDuplicates === 'boolean' ? { hasDuplicates } : {}), + probability, + count + }; + } + + async function parseTypes(types: SchemaType[], signal?: AbortSignal): Promise { + await allowAbort(signal); + const definedTypes = types.filter(type => type.bsonType.toLowerCase() !== 'undefined'); + const isSingleType = definedTypes.length === 1; + if (isSingleType) { + return parseType(definedTypes[0], signal); + } + const parsedTypes = await Promise.all(definedTypes.map(type => parseType(type, signal))); + return { + anyOf: parsedTypes + }; + } + + async function parseFields( + fields: DocumentSchemaType['fields'], + signal?: AbortSignal + ): Promise<{ required: ExpandedJSONSchema['required']; properties: ExpandedJSONSchema['properties'] }> { + const required = []; + const properties: ExpandedJSONSchema['properties'] = {}; + for (const field of fields) { + if (field.probability === 1) required.push(field.name); + properties[field.name] = { + ...await parseTypes(field.types, signal), + 'x-metadata': getMetadata(field) + }; + } + + return { required, properties }; + } + + return async function convert( + internalSchema: InternalSchema, + options: { signal?: AbortSignal } = {} + ): Promise { + const { required, properties } = await parseFields(internalSchema.fields, options.signal); + const schema: ExpandedJSONSchema = { + type: 'object', + 'x-bsonType': 'object', + required, + properties, + $defs: getUsedDefinitions() + }; + return schema; + }; +}; + +export function convertInternalToExpanded( + internalSchema: InternalSchema, + options: { signal?: AbortSignal } = {} +): Promise { + return createConvertInternalToExpanded()(internalSchema, options); +} diff --git a/src/schema-convertors/internalToMongoDB.test.ts b/src/schema-converters/internalToMongoDB.test.ts similarity index 98% rename from src/schema-convertors/internalToMongoDB.test.ts rename to src/schema-converters/internalToMongoDB.test.ts index 7c2b3d6..4847e2c 100644 --- a/src/schema-convertors/internalToMongoDB.test.ts +++ b/src/schema-converters/internalToMongoDB.test.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import internalSchemaToMongoDB from './internalToMongoDB'; +import { convertInternalToMongodb } from './internalToMongoDB'; describe('internalSchemaToMongoDB', async function() { describe('Converts: ', async function() { @@ -892,8 +892,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: [], properties: { @@ -1105,8 +1105,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: ['author'], properties: { @@ -1190,8 +1190,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: [], properties: { @@ -1335,8 +1335,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: [], properties: { @@ -1429,8 +1429,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: ['arrayMixedType'], properties: { @@ -1507,8 +1507,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: [], properties: { @@ -1623,8 +1623,8 @@ describe('internalSchemaToMongoDB', async function() { } ] }; - const standard = await internalSchemaToMongoDB(internal); - assert.deepStrictEqual(standard, { + const mongodb = await convertInternalToMongodb(internal); + assert.deepStrictEqual(mongodb, { bsonType: 'object', required: [], properties: { @@ -1757,7 +1757,7 @@ describe('internalSchemaToMongoDB', async function() { ] }; const abortController = new AbortController(); - const promise = internalSchemaToMongoDB(internal, { signal: abortController.signal }); + const promise = convertInternalToMongodb(internal, { signal: abortController.signal }); abortController.abort(new Error('Too long, didn\'t wait.')); await assert.rejects(promise, { name: 'Error', diff --git a/src/schema-convertors/internalToMongoDB.ts b/src/schema-converters/internalToMongoDB.ts similarity index 98% rename from src/schema-convertors/internalToMongoDB.ts rename to src/schema-converters/internalToMongoDB.ts index f40586a..e85f9ca 100644 --- a/src/schema-convertors/internalToMongoDB.ts +++ b/src/schema-converters/internalToMongoDB.ts @@ -96,7 +96,7 @@ async function parseFields(fields: DocumentSchemaType['fields'], signal?: AbortS return { required, properties }; } -export default async function internalSchemaToMongodb( +export async function convertInternalToMongodb( internalSchema: InternalSchema, options: { signal?: AbortSignal diff --git a/src/schema-convertors/internalToStandard.test.ts b/src/schema-converters/internalToStandard.test.ts similarity index 97% rename from src/schema-convertors/internalToStandard.test.ts rename to src/schema-converters/internalToStandard.test.ts index 511eca2..94b6570 100644 --- a/src/schema-convertors/internalToStandard.test.ts +++ b/src/schema-converters/internalToStandard.test.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import Ajv2020 from 'ajv/dist/2020'; -import internalSchemaToStandard, { RELAXED_EJSON_DEFINITIONS } from './internalToStandard'; +import { convertInternalToStandard, RELAXED_EJSON_DEFINITIONS } from './internalToStandard'; describe('internalSchemaToStandard', async function() { const ajv = new Ajv2020(); @@ -895,13 +895,18 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); + const expectedDefinitions: any = { + ...RELAXED_EJSON_DEFINITIONS + }; + delete expectedDefinitions.Undefined; + delete expectedDefinitions.DBPointer; assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: expectedDefinitions, properties: { _id: { $ref: '#/$defs/ObjectId' @@ -1111,13 +1116,16 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); + const expectedDefinitions = { + Double: RELAXED_EJSON_DEFINITIONS.Double + }; ajv.validateSchema(standard); assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: ['author'], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: expectedDefinitions, properties: { author: { type: 'object', @@ -1199,13 +1207,13 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { genres: { type: 'array', @@ -1347,13 +1355,13 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { genres: { type: 'array', @@ -1444,13 +1452,13 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: ['arrayMixedType'], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { arrayMixedType: { type: 'array', @@ -1525,13 +1533,13 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { mixedType: { type: ['integer', 'string'] @@ -1644,13 +1652,13 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: [], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: {}, properties: { mixedComplexType: { anyOf: [ @@ -1724,13 +1732,16 @@ describe('internalSchemaToStandard', async function() { } ] }; - const standard = await internalSchemaToStandard(internal); + const standard = await convertInternalToStandard(internal); ajv.validateSchema(standard); + const expectedDefinitions = { + ObjectId: RELAXED_EJSON_DEFINITIONS.ObjectId + }; assert.deepStrictEqual(standard, { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', required: ['mixedType'], - $defs: RELAXED_EJSON_DEFINITIONS, + $defs: expectedDefinitions, properties: { mixedType: { anyOf: [{ @@ -1849,7 +1860,7 @@ describe('internalSchemaToStandard', async function() { ] }; const abortController = new AbortController(); - const promise = internalSchemaToStandard(internal, { signal: abortController.signal }); + const promise = convertInternalToStandard(internal, { signal: abortController.signal }); abortController.abort(new Error('Too long, didn\'t wait.')); await assert.rejects(promise, { name: 'Error', diff --git a/src/schema-convertors/internalToStandard.ts b/src/schema-converters/internalToStandard.ts similarity index 61% rename from src/schema-convertors/internalToStandard.ts rename to src/schema-converters/internalToStandard.ts index fa298d8..a1ec463 100644 --- a/src/schema-convertors/internalToStandard.ts +++ b/src/schema-converters/internalToStandard.ts @@ -5,9 +5,10 @@ import { allowAbort } from './util'; type StandardTypeDefinition = { type: JSONSchema4TypeName, $ref?: never; } | { $ref: string, type?: never }; -export const InternalTypeToStandardTypeMap: Record< - SchemaType['name'] | 'Double' | 'BSONSymbol', StandardTypeDefinition -> = { +type TypeToDefinitionMap = Record< +SchemaType['name'] | 'Double' | 'BSONSymbol', StandardTypeDefinition +>; +export const InternalTypeToStandardTypeMap: TypeToDefinitionMap = { Double: { $ref: '#/$defs/Double' }, Number: { $ref: '#/$defs/Double' }, String: { type: 'string' }, @@ -34,7 +35,7 @@ export const InternalTypeToStandardTypeMap: Record< MaxKey: { $ref: '#/$defs/MaxKey' } }; -export const RELAXED_EJSON_DEFINITIONS = Object.freeze({ +export const RELAXED_EJSON_DEFINITIONS = { ObjectId: { type: 'object', properties: { @@ -244,79 +245,97 @@ export const RELAXED_EJSON_DEFINITIONS = Object.freeze({ required: ['$undefined'], additionalProperties: false } -}); - -const convertInternalType = (internalType: string) => { - const type = { ...InternalTypeToStandardTypeMap[internalType] }; - if (!type) throw new Error(`Encountered unknown type: ${internalType}`); - return type; }; -async function parseType(type: SchemaType, signal?: AbortSignal): Promise { - await allowAbort(signal); - const schema: StandardJSONSchema = convertInternalType(type.bsonType); - switch (type.bsonType) { - case 'Array': - schema.items = await parseTypes((type as ArraySchemaType).types); - break; - case 'Document': - Object.assign(schema, - await parseFields((type as DocumentSchemaType).fields, signal) - ); - break; +const createConvertInternalToStandard = function() { + const usedDefinitions = new Set(); + + function getUsedDefinitions() { + const filteredDefinitions = Object.fromEntries( + Object.entries(RELAXED_EJSON_DEFINITIONS).filter(([key]) => usedDefinitions.has(key)) + ); + return Object.freeze(filteredDefinitions); } - return schema; -} + function markUsedDefinition(ref: string) { + usedDefinitions.add(ref.split('/')[2]); + } -function isPlainTypesOnly(types: StandardJSONSchema[]): types is { type: JSONSchema4TypeName }[] { - return types.every(definition => !!definition.type && Object.keys(definition).length === 1); -} + function convertInternalType(internalType: string) { + const type = InternalTypeToStandardTypeMap[internalType]; + if (!type) throw new Error(`Encountered unknown type: ${internalType}`); + return { ...type }; + } + + async function parseType(type: SchemaType, signal?: AbortSignal): Promise { + await allowAbort(signal); + const schema: StandardJSONSchema = convertInternalType(type.bsonType); + if (schema.$ref) markUsedDefinition(schema.$ref); + switch (type.bsonType) { + case 'Array': + schema.items = await parseTypes((type as ArraySchemaType).types, signal); + break; + case 'Document': + Object.assign(schema, await parseFields((type as DocumentSchemaType).fields, signal)); + break; + } + return schema; + } -async function parseTypes(types: SchemaType[], signal?: AbortSignal): Promise { - await allowAbort(signal); - const definedTypes = types.filter(type => type.bsonType.toLowerCase() !== 'undefined'); - const isSingleType = definedTypes.length === 1; - if (isSingleType) { - return parseType(definedTypes[0], signal); + function isPlainTypesOnly(types: StandardJSONSchema[]): types is { type: JSONSchema4TypeName }[] { + return types.every(definition => !!definition.type && Object.keys(definition).length === 1); } - const parsedTypes = await Promise.all(definedTypes.map(type => parseType(type, signal))); - if (isPlainTypesOnly(parsedTypes)) { + + async function parseTypes(types: SchemaType[], signal?: AbortSignal): Promise { + await allowAbort(signal); + const definedTypes = types.filter(type => type.bsonType.toLowerCase() !== 'undefined'); + const isSingleType = definedTypes.length === 1; + if (isSingleType) { + return parseType(definedTypes[0], signal); + } + const parsedTypes = await Promise.all(definedTypes.map(type => parseType(type, signal))); + if (isPlainTypesOnly(parsedTypes)) { + return { + type: parsedTypes.map(({ type }) => type) + }; + } return { - type: parsedTypes.map(({ type }) => type) + anyOf: parsedTypes }; } - return { - anyOf: parsedTypes - }; -} -async function parseFields(fields: DocumentSchemaType['fields'], signal?: AbortSignal): Promise<{ - required: StandardJSONSchema['required'], - properties: StandardJSONSchema['properties'], -}> { - const required = []; - const properties: StandardJSONSchema['properties'] = {}; - for (const field of fields) { - if (field.probability === 1) required.push(field.name); - properties[field.name] = await parseTypes(field.types, signal); + async function parseFields( + fields: DocumentSchemaType['fields'], + signal?: AbortSignal + ): Promise<{ required: StandardJSONSchema['required']; properties: StandardJSONSchema['properties'] }> { + const required = []; + const properties: StandardJSONSchema['properties'] = {}; + for (const field of fields) { + if (field.probability === 1) required.push(field.name); + properties[field.name] = await parseTypes(field.types, signal); + } + return { required, properties }; } - return { required, properties }; -} + return async function convert( + internalSchema: InternalSchema, + options: { signal?: AbortSignal } = {} + ): Promise { + const { required, properties } = await parseFields(internalSchema.fields, options.signal); + const schema: StandardJSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required, + properties, + $defs: getUsedDefinitions() + }; + return schema; + }; +}; -export default async function internalSchemaToStandard( +export function convertInternalToStandard( internalSchema: InternalSchema, - options: { - signal?: AbortSignal -} = {}): Promise { - const { required, properties } = await parseFields(internalSchema.fields, options.signal); - const schema: StandardJSONSchema = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object', - required, - properties, - $defs: RELAXED_EJSON_DEFINITIONS - }; - return schema; + options: { signal?: AbortSignal } = {} +): Promise { + return createConvertInternalToStandard()(internalSchema, options); } diff --git a/src/schema-convertors/util.ts b/src/schema-converters/util.ts similarity index 100% rename from src/schema-convertors/util.ts rename to src/schema-converters/util.ts diff --git a/src/schema-convertors/index.ts b/src/schema-convertors/index.ts deleted file mode 100644 index 273310d..0000000 --- a/src/schema-convertors/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import internalSchemaToExpanded from './internalToExpanded'; -import internalSchemaToMongoDB from './internalToMongoDB'; -import internalSchemaToStandard from './internalToStandard'; - -export const convertors = { - internalSchemaToStandard, - internalSchemaToMongoDB, - internalSchemaToExpanded -}; diff --git a/src/schema-convertors/internalToExpanded.ts b/src/schema-convertors/internalToExpanded.ts deleted file mode 100644 index be3256e..0000000 --- a/src/schema-convertors/internalToExpanded.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { ArraySchemaType, DocumentSchemaType, Schema as InternalSchema, SchemaType, SchemaField } from '../schema-analyzer'; -import { type ExpandedJSONSchema } from '../types'; -import { InternalTypeToStandardTypeMap, RELAXED_EJSON_DEFINITIONS } from './internalToStandard'; -import { InternalTypeToBsonTypeMap } from './internalToMongoDB'; -import { allowAbort } from './util'; - -const getStandardType = (internalType: string) => { - const type = { ...InternalTypeToStandardTypeMap[internalType] }; - if (!type) throw new Error(`Encountered unknown type: ${internalType}`); - return type; -}; - -const getBsonType = (internalType: string) => { - const type = InternalTypeToBsonTypeMap[internalType]; - if (!type) throw new Error(`Encountered unknown type: ${internalType}`); - return type; -}; - -async function parseType(type: SchemaType, signal?: AbortSignal): Promise { - await allowAbort(signal); - const schema: ExpandedJSONSchema = { - ...getStandardType(type.bsonType), - 'x-bsonType': getBsonType(type.bsonType), - 'x-metadata': getMetadata(type) - }; - if ('values' in type && !!type.values) { - schema['x-sampleValues'] = type.values; - } - switch (type.bsonType) { - case 'Array': - schema.items = await parseTypes((type as ArraySchemaType).types); - break; - case 'Document': - Object.assign(schema, - await parseFields((type as DocumentSchemaType).fields, signal) - ); - break; - } - - return schema; -} - -const getMetadata = ({ - hasDuplicates, - probability, - count -}: TType) => ({ - ...(typeof hasDuplicates === 'boolean' ? { hasDuplicates } : {}), - probability, - count - }); - -async function parseTypes(types: SchemaType[], signal?: AbortSignal): Promise { - await allowAbort(signal); - const definedTypes = types.filter(type => type.bsonType.toLowerCase() !== 'undefined'); - const isSingleType = definedTypes.length === 1; - if (isSingleType) { - return parseType(definedTypes[0], signal); - } - const parsedTypes = await Promise.all(definedTypes.map(type => parseType(type, signal))); - return { - anyOf: parsedTypes - }; -} - -async function parseFields(fields: DocumentSchemaType['fields'], signal?: AbortSignal): Promise<{ - required: ExpandedJSONSchema['required'], - properties: ExpandedJSONSchema['properties'], -}> { - const required = []; - const properties: ExpandedJSONSchema['properties'] = {}; - for (const field of fields) { - if (field.probability === 1) required.push(field.name); - properties[field.name] = { - ...await parseTypes(field.types, signal), - 'x-metadata': getMetadata(field) - }; - } - - return { required, properties }; -} - -export default async function internalSchemaToMongodb( - internalSchema: InternalSchema, - options: { - signal?: AbortSignal -} = {}): Promise { - const { required, properties } = await parseFields(internalSchema.fields, options.signal); - const schema: ExpandedJSONSchema = { - type: 'object', - 'x-bsonType': 'object', - required, - properties, - $defs: RELAXED_EJSON_DEFINITIONS - }; - return schema; -} diff --git a/src/types.ts b/src/types.ts index 272bf06..5c340f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { type JSONSchema4 } from 'json-schema'; +import { InternalSchema } from '.'; export type StandardJSONSchema = JSONSchema4; @@ -23,3 +24,9 @@ export type ExpandedJSONSchema = StandardJSONSchema & { } export type AnyIterable = Iterable | AsyncIterable; + +type AnySchema = InternalSchema | StandardJSONSchema | MongoDBJSONSchema | ExpandedJSONSchema; +export type SchemaConverterFn = ( + input: InputSchema, + options: { signal?: AbortSignal }, +) => Promise; diff --git a/test/analyze-documents.test.ts b/test/analyze-documents.test.ts deleted file mode 100644 index da24545..0000000 --- a/test/analyze-documents.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { analyzeDocuments } from '../src'; -import { convertors } from '../src/schema-convertors'; -import sinon from 'sinon'; -import assert from 'assert'; - -describe('analyzeDocuments', function() { - const docs = [{}]; - - it('Converts lazily', async function() { - const convertSpy = sinon.spy(convertors, 'internalSchemaToStandard'); - const analyzeResults = await analyzeDocuments(docs); - assert.strictEqual(convertSpy.called, false); - await analyzeResults.getStandardJsonSchema(); - assert.strictEqual(convertSpy.calledOnce, true); - }); - - it('Only converts the same format once', async function() { - const convertSpy = sinon.spy(convertors, 'internalSchemaToExpanded'); - const analyzeResults = await analyzeDocuments(docs); - await analyzeResults.getExpandedJSONSchema(); - await analyzeResults.getExpandedJSONSchema(); - await analyzeResults.getExpandedJSONSchema(); - assert.strictEqual(convertSpy.calledOnce, true); - }); -}); diff --git a/test/schema-accessor.test.ts b/test/schema-accessor.test.ts new file mode 100644 index 0000000..bf5cf39 --- /dev/null +++ b/test/schema-accessor.test.ts @@ -0,0 +1,46 @@ +import { type InternalSchema } from '../src'; +import sinon from 'sinon'; +import assert from 'assert'; +import { InternalConverters, InternalSchemaBasedAccessor } from '../src/schema-accessor'; + +describe('analyzeDocuments', function() { + const internalSchema: InternalSchema = { + count: 1, + fields: [] + }; + let sandbox: sinon.SinonSandbox; + let converters: InternalConverters; + + before(() => { + sandbox = sinon.createSandbox(); + }); + + beforeEach(function() { + converters = { + internalToStandard: sandbox.stub(), + internalToMongoDB: sandbox.stub(), + internalToExpanded: sandbox.stub() + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('Converts lazily', async function() { + const schema = new InternalSchemaBasedAccessor(internalSchema, converters as InternalConverters); + assert.strictEqual((converters.internalToStandard as sinon.SinonStub).called, false); + await schema.getStandardJsonSchema(); + assert.strictEqual((converters.internalToStandard as sinon.SinonStub).calledOnce, true); + }); + + it('Only converts the same format once', async function() { + const schema = new InternalSchemaBasedAccessor(internalSchema, converters as InternalConverters); + assert.strictEqual((converters.internalToExpanded as sinon.SinonStub).called, false); + (converters.internalToExpanded as sinon.SinonStub).returns({ abc: 'string' }); + await schema.getExpandedJSONSchema(); + await schema.getExpandedJSONSchema(); + await schema.getExpandedJSONSchema(); + assert.strictEqual((converters.internalToExpanded as sinon.SinonStub).calledOnce, true); + }); +});