diff --git a/src/index.ts b/src/index.ts index 853830f..382e740 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { isLogicalType, isMapType, isRecordType, + NameOrType, RecordType, Schema, Type, @@ -49,16 +50,21 @@ export function convertPrimitive(avroType: string): string { export function avroToTypeScript(schema: Schema, opts: ConversionOptions = {}): string { const output: string[] = []; if (isEnumType(schema)) convertEnum(schema, output); - else if (isRecordType(schema)) convertRecord(schema, output, opts); + else if (isRecordType(schema)) convertRecord(schema, output, opts, schema.namespace); else throw "Unknown top level type " + (schema as unknown)["type"]; return output.join("\n"); } /** Convert an Avro Record type. Return the name, but add the definition to the file */ -export function convertRecord(recordType: RecordType, fileBuffer: string[], opts: ConversionOptions): string { +export function convertRecord( + recordType: RecordType, + fileBuffer: string[], + opts: ConversionOptions, + namespace: string | undefined, +): string { let buffer = `export interface ${recordType.name} {\n`; for (let field of recordType.fields) { - buffer += convertFieldDec(field, fileBuffer, opts) + "\n"; + buffer += convertFieldDec(field, fileBuffer, opts, namespace) + "\n"; } buffer += "}\n"; fileBuffer.push(buffer); @@ -72,23 +78,28 @@ export function convertEnum(enumType: EnumType, fileBuffer: string[]): string { return enumType.name; } -export function convertType(type: Type, buffer: string[], opts: ConversionOptions): string { +export function convertType( + type: Type, + buffer: string[], + opts: ConversionOptions, + namespace: string | undefined, +): string { // if it's just a name, then use that if (typeof type === "string") { return convertPrimitive(type) || type; } else if (type instanceof Array) { // array means a Union. Use the names and call recursively - return type.map((t) => convertType(t, buffer, opts)).join(" | "); + return convertUnion(type, buffer, opts, namespace); } else if (isRecordType(type)) { //} type)) { // record, use the name and add to the buffer - return convertRecord(type, buffer, opts); + return convertRecord(type, buffer, opts, namespace); } else if (isArrayType(type)) { // array, call recursively for the array element type - return convertType(type.items, buffer, opts) + "[]"; + return convertType(type.items, buffer, opts, namespace) + "[]"; } else if (isMapType(type)) { // Dictionary of types, string as key - return `{ [index:string]:${convertType(type.values, buffer, opts)} }`; + return `{ [index:string]:${convertType(type.values, buffer, opts, namespace)} }`; } else if (isEnumType(type)) { // array, call recursively for the array element type return convertEnum(type, buffer); @@ -96,7 +107,7 @@ export function convertType(type: Type, buffer: string[], opts: ConversionOption if (opts.logicalTypes && opts.logicalTypes[type.logicalType]) { return opts.logicalTypes[type.logicalType]; } - return convertType(type.type, buffer, opts); + return convertType(type.type, buffer, opts, namespace); } else if (isFixedType(type)) { return "Uint8Array"; } else { @@ -105,7 +116,105 @@ export function convertType(type: Type, buffer: string[], opts: ConversionOption } } -export function convertFieldDec(field: Field, buffer: string[], opts: ConversionOptions): string { - let typeName = convertType(field.type, buffer, opts); +export function convertUnion( + type: NameOrType[], + buffer: string[], + opts: ConversionOptions, + namespace: string | undefined, +): string { + // Wrapped unions is all-or-nothing, even if some values are unambiguous within the union. + // For example, with "int" | "double" | "string", the numbers are ambigous but the string + // should be unambiguous. Nevertheless, at least with avsc it treats ambiguity as + // all-or-nothing. + if (shouldWrapUnionMembers(type, opts)) { + return type + .map((t) => { + // Null is a special case. Even when using wrapped unions, plain null is allowed + // as a value in addition to {"null": null}. + const nullPrefix = t === "null" ? "null | " : ""; + return `${nullPrefix}{ "${keyForUnionElement(t, namespace)}": ${convertType(t, buffer, opts, namespace)} }`; + }) + .join(" | "); + } else { + return type.map((t) => convertType(t, buffer, opts, namespace)).join(" | "); + } +} + +function shouldWrapUnionMembers(type: NameOrType[], opts: ConversionOptions): boolean { + switch (opts.wrapUnions) { + case "always": + return true; + case "never": + return false; + case "auto": { + return hasAmbiguousUnionMembers(type); + } + default: + return opts.wrapUnions ?? false; + } +} + +function hasAmbiguousUnionMembers(type: NameOrType[]): boolean { + // If there is more than one union member that shares any JS type, it'll be considered + // ambiguous and must be wrapped. + const result: Record = {}; + for (const t of type) { + const typeName = jsTypeForAvroType(t); + if (result[typeName]) { + return true; + } + result[typeName] = true; + } + return false; +} + +function jsTypeForAvroType(t: NameOrType): string { + const avroType = typeof t === "string" ? t : t.type; + switch (avroType) { + case "long": + case "int": + case "double": + case "float": + return "number"; + case "bytes": + case "fixed": + return "buffer"; + case "null": + return "null"; + case "boolean": + return "boolean"; + case "string": + case "enum": + return "string"; + case "record": + case "map": + return "object"; + case "array": + return "array"; + default: + return avroType; + } +} + +export function keyForUnionElement(type: NameOrType, namespace: string | undefined): string { + if (typeof type === "string") { + return type; + } + if (isRecordType(type) || isFixedType(type) || isEnumType(type)) { + if (namespace) { + return `${namespace}.${type.name}`; + } + return type.name; + } + return type.type; +} + +export function convertFieldDec( + field: Field, + buffer: string[], + opts: ConversionOptions, + namespace: string | undefined, +): string { + let typeName = convertType(field.type, buffer, opts, namespace); return `\t${field.name}: ${typeName};`; } diff --git a/src/model.ts b/src/model.ts index ea71f13..e6e97a0 100644 --- a/src/model.ts +++ b/src/model.ts @@ -3,6 +3,7 @@ export type Schema = RecordType | EnumType; export interface ConversionOptions { logicalTypes?: { [type: string]: string }; + wrapUnions?: boolean | "always" | "never" | "auto"; } export type Type = NameOrType | NameOrType[]; @@ -22,6 +23,7 @@ export interface BaseType { export interface RecordType extends BaseType { type: "record"; name: string; + namespace?: string; fields: Field[]; } diff --git a/test/__snapshots__/avtsc.test.ts.snap b/test/__snapshots__/avtsc.test.ts.snap index 0d9ba34..a70971b 100644 --- a/test/__snapshots__/avtsc.test.ts.snap +++ b/test/__snapshots__/avtsc.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`avroToTypeScript it should generate an interface 1`] = ` "export enum numberEnumType { ONE = 'ONE', TWO = 'TWO' }; @@ -9,6 +9,17 @@ export interface AllocsType { allocQty: null | number; } +export interface Option1 { + id: string; + refKey: string; +} + +export interface Option2 { + id: string; + orderNumber: string; + quantity: number; +} + export interface ExampleType { unionEnum: null | string | numberEnumType; mandatoryString: string; @@ -16,6 +27,7 @@ export interface ExampleType { astring: null | string; amap: null | string | { [index:string]:numberEnumType }; recordArray: null | AllocsType[]; + unionRecord: null | Option1 | Option2; processCode: null | string; named: null | AllocsType; fixedField: Uint8Array; @@ -23,6 +35,76 @@ export interface ExampleType { " `; +exports[`avroToTypeScript it should generate an interface with auto-wrapped unions 1`] = ` +"export enum numberEnumType { ONE = 'ONE', TWO = 'TWO' }; + +export interface AllocsType { + allocAccount: null | string; + noNestedPartyIDs: null | number; + allocQty: null | number; +} + +export interface Option1 { + id: string; + refKey: string; +} + +export interface Option2 { + id: string; + orderNumber: string; + quantity: number; +} + +export interface ExampleType { + unionEnum: null | { "null": null } | { "string": string } | { "avro-typescript.numberEnumType": numberEnumType }; + mandatoryString: string; + adouble: null | number; + astring: null | string; + amap: null | string | { [index:string]:numberEnumType }; + recordArray: null | AllocsType[]; + unionRecord: null | { "null": null } | { "avro-typescript.Option1": Option1 } | { "avro-typescript.Option2": Option2 }; + processCode: null | string; + named: null | AllocsType; + fixedField: Uint8Array; +} +" +`; + +exports[`avroToTypeScript it should generate an interface with wrapped unions 1`] = ` +"export enum numberEnumType { ONE = 'ONE', TWO = 'TWO' }; + +export interface AllocsType { + allocAccount: null | { "null": null } | { "string": string }; + noNestedPartyIDs: null | { "null": null } | { "long": number }; + allocQty: null | { "null": null } | { "double": number }; +} + +export interface Option1 { + id: string; + refKey: string; +} + +export interface Option2 { + id: string; + orderNumber: string; + quantity: number; +} + +export interface ExampleType { + unionEnum: null | { "null": null } | { "string": string } | { "avro-typescript.numberEnumType": numberEnumType }; + mandatoryString: string; + adouble: null | { "null": null } | { "double": number }; + astring: null | { "null": null } | { "string": string }; + amap: null | { "null": null } | { "string": string } | { "map": { [index:string]:numberEnumType } }; + recordArray: null | { "null": null } | { "array": AllocsType[] }; + unionRecord: null | { "null": null } | { "avro-typescript.Option1": Option1 } | { "avro-typescript.Option2": Option2 }; + processCode: null | { "null": null } | { "string": string }; + named: null | { "null": null } | { "AllocsType": AllocsType }; + fixedField: Uint8Array; +} +" +`; + exports[`avroToTypeScript it should support overriding logical types 1`] = ` "export interface logicalOverrides { eventDate: Date; diff --git a/test/avtsc.test.ts b/test/avtsc.test.ts index d9d9e80..8167a54 100644 --- a/test/avtsc.test.ts +++ b/test/avtsc.test.ts @@ -9,6 +9,18 @@ describe("avroToTypeScript", () => { expect(avroToTypeScript(schema as RecordType)).toMatchSnapshot(); }); + test("it should generate an interface with wrapped unions", () => { + const schemaText = fs.readFileSync(__dirname + "/example.avsc", "utf-8"); + const schema = JSON.parse(schemaText); + expect(avroToTypeScript(schema as RecordType, { wrapUnions: true })).toMatchSnapshot(); + }); + + test("it should generate an interface with auto-wrapped unions", () => { + const schemaText = fs.readFileSync(__dirname + "/example.avsc", "utf-8"); + const schema = JSON.parse(schemaText); + expect(avroToTypeScript(schema as RecordType, { wrapUnions: "auto" })).toMatchSnapshot(); + }); + test("it should correctly type strings with logicalType", () => { const schema: Schema = { type: "record", diff --git a/test/example.avsc b/test/example.avsc index 4b77943..a744aff 100644 --- a/test/example.avsc +++ b/test/example.avsc @@ -1,5 +1,6 @@ { "type": "record", + "namespace": "avro-typescript", "name": "ExampleType", "fields": [ { @@ -53,6 +54,30 @@ } ] }, + { + "name": "unionRecord", + "default": null, + "type": [ + "null", + { + "type": "record", + "name": "Option1", + "fields": [ + { "name": "id", "type": "string" }, + { "name": "refKey", "type": "string" } + ] + }, + { + "type": "record", + "name": "Option2", + "fields": [ + { "name": "id", "type": "string" }, + { "name": "orderNumber", "type": "string" }, + { "name": "quantity", "type": "long" } + ] + } + ] + }, { "name": "processCode", "default": null, "type": ["null", "string"] }, { "name": "named", "default": null, "type": ["null", "AllocsType"] }, {