diff --git a/integration/use-optionals-all/optionals-test.ts b/integration/use-optionals-all/optionals-test.ts new file mode 100644 index 000000000..6bde5ca3a --- /dev/null +++ b/integration/use-optionals-all/optionals-test.ts @@ -0,0 +1,101 @@ +import { OptionalsTest, StateEnum } from './test' + +describe('useOptionals=all', () => { + it('has all optional members', () => { + const test: OptionalsTest = {}; + const data = OptionalsTest.encode(test).finish(); + const test2 = OptionalsTest.decode(data); + expect(test2).toEqual({ + id: 0, + child: undefined, + state: StateEnum.UNKNOWN, + long: 0, + truth: false, + description: "", + data: new Uint8Array(0), + + repId: [], + repChild: [], + repState: [], + repLong: [], + repTruth: [], + repDescription: [], + repData: [], + + optChild: undefined, + optId: undefined, + optState: undefined, + optLong: undefined, + optTruth: undefined, + optDescription: undefined, + optData: undefined, + + translations: {}, + }); + }); + + it('allows setting all members, too', () => { + const test: OptionalsTest = { + id: 1, + child: {}, + state: StateEnum.ON, + long: 10, + truth: true, + description: "hello world", + data: Buffer.alloc(2).fill(0x32), + + repId: [1, 2], + repChild: [{}, {}], + repState: [StateEnum.ON, StateEnum.OFF], + repLong: [11, 12], + repTruth: [true, false], + repDescription: ["hello", "world"], + repData: [Buffer.alloc(3).fill(0x33), Buffer.alloc(4).fill(0x34), Buffer.alloc(5).fill(0x35)], + + optChild: {}, + optId: 2, + optState: StateEnum.OFF, + optLong: 13, + optTruth: true, + optDescription: "mumble", + optData: Buffer.alloc(6).fill(0x36), + + translations: { + "hello": "hallo", + "world": "wereld", + }, + }; + const data = OptionalsTest.encode(test).finish(); + const test2 = OptionalsTest.decode(data); + expect(test2).toEqual({ + id: 1, + child: {}, + state: StateEnum.ON, + long: 10, + truth: true, + description: "hello world", + data: Buffer.alloc(2).fill(0x32), + + repId: [1, 2], + repChild: [{}, {}], + repState: [StateEnum.ON, StateEnum.OFF], + repLong: [11, 12], + repTruth: [true, false], + repDescription: ["hello", "world"], + repData: [Buffer.alloc(3).fill(0x33), Buffer.alloc(4).fill(0x34), Buffer.alloc(5).fill(0x35)], + + optChild: {}, + optId: 2, + optState: StateEnum.OFF, + optLong: 13, + optTruth: true, + optDescription: "mumble", + optData: Buffer.alloc(6).fill(0x36), + + translations: { + "hello": "hallo", + "world": "wereld", + }, + }); + }); +}) diff --git a/integration/use-optionals-all/parameters.txt b/integration/use-optionals-all/parameters.txt new file mode 100644 index 000000000..c4b34e132 --- /dev/null +++ b/integration/use-optionals-all/parameters.txt @@ -0,0 +1 @@ +useOptionals=all diff --git a/integration/use-optionals-all/test.bin b/integration/use-optionals-all/test.bin new file mode 100644 index 000000000..11c02597f Binary files /dev/null and b/integration/use-optionals-all/test.bin differ diff --git a/integration/use-optionals-all/test.proto b/integration/use-optionals-all/test.proto new file mode 100644 index 000000000..d752ce261 --- /dev/null +++ b/integration/use-optionals-all/test.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; +package optionalstest; + +message OptionalsTest { + int32 id = 1; + Child child = 2; + StateEnum state = 3; + int64 long = 4; + bool truth = 5; + string description = 6; + bytes data = 7; + + repeated int32 rep_id = 11; + repeated Child rep_child = 12; + repeated StateEnum rep_state = 13; + repeated int64 rep_long = 14; + repeated bool rep_truth = 15; + repeated string rep_description = 16; + repeated bytes rep_data = 17; + + optional int32 opt_id = 21; + optional Child opt_child = 22; + optional StateEnum opt_state = 23; + optional int64 opt_long = 24; + optional bool opt_truth = 25; + optional string opt_description = 26; + optional bytes opt_data = 27; + + map translations = 30; +} + +enum StateEnum { + UNKNOWN = 0; + ON = 2; + OFF = 3; +} + +message Child { +} diff --git a/integration/use-optionals-all/test.ts b/integration/use-optionals-all/test.ts new file mode 100644 index 000000000..018156d7f --- /dev/null +++ b/integration/use-optionals-all/test.ts @@ -0,0 +1,599 @@ +/* eslint-disable */ +import { util, configure, Writer, Reader } from 'protobufjs/minimal'; +import * as Long from 'long'; + +export const protobufPackage = 'optionalstest'; + +export enum StateEnum { + UNKNOWN = 0, + ON = 2, + OFF = 3, + UNRECOGNIZED = -1, +} + +export function stateEnumFromJSON(object: any): StateEnum { + switch (object) { + case 0: + case 'UNKNOWN': + return StateEnum.UNKNOWN; + case 2: + case 'ON': + return StateEnum.ON; + case 3: + case 'OFF': + return StateEnum.OFF; + case -1: + case 'UNRECOGNIZED': + default: + return StateEnum.UNRECOGNIZED; + } +} + +export function stateEnumToJSON(object: StateEnum): string { + switch (object) { + case StateEnum.UNKNOWN: + return 'UNKNOWN'; + case StateEnum.ON: + return 'ON'; + case StateEnum.OFF: + return 'OFF'; + default: + return 'UNKNOWN'; + } +} + +export interface OptionalsTest { + id?: number; + child?: Child; + state?: StateEnum; + long?: number; + truth?: boolean; + description?: string; + data?: Uint8Array; + repId?: number[]; + repChild?: Child[]; + repState?: StateEnum[]; + repLong?: number[]; + repTruth?: boolean[]; + repDescription?: string[]; + repData?: Uint8Array[]; + optId?: number | undefined; + optChild?: Child | undefined; + optState?: StateEnum | undefined; + optLong?: number | undefined; + optTruth?: boolean | undefined; + optDescription?: string | undefined; + optData?: Uint8Array | undefined; + translations?: { [key: string]: string }; +} + +export interface OptionalsTest_TranslationsEntry { + key: string; + value: string; +} + +export interface Child {} + +const baseOptionalsTest: object = { + id: 0, + state: 0, + long: 0, + truth: false, + description: '', + repId: 0, + repState: 0, + repLong: 0, + repTruth: false, + repDescription: '', +}; + +export const OptionalsTest = { + encode(message: OptionalsTest, writer: Writer = Writer.create()): Writer { + if (message.id !== undefined && message.id !== 0) { + writer.uint32(8).int32(message.id); + } + if (message.child !== undefined) { + Child.encode(message.child, writer.uint32(18).fork()).ldelim(); + } + if (message.state !== undefined && message.state !== 0) { + writer.uint32(24).int32(message.state); + } + if (message.long !== undefined && message.long !== 0) { + writer.uint32(32).int64(message.long); + } + if (message.truth === true) { + writer.uint32(40).bool(message.truth); + } + if (message.description !== undefined && message.description !== '') { + writer.uint32(50).string(message.description); + } + if (message.data !== undefined && message.data.length !== 0) { + writer.uint32(58).bytes(message.data); + } + if (message.repId !== undefined && message.repId.length !== 0) { + writer.uint32(90).fork(); + for (const v of message.repId) { + writer.int32(v); + } + writer.ldelim(); + } + if (message.repChild !== undefined && message.repChild.length !== 0) { + for (const v of message.repChild) { + Child.encode(v!, writer.uint32(98).fork()).ldelim(); + } + } + if (message.repState !== undefined && message.repState.length !== 0) { + writer.uint32(106).fork(); + for (const v of message.repState) { + writer.int32(v); + } + writer.ldelim(); + } + if (message.repLong !== undefined && message.repLong.length !== 0) { + writer.uint32(114).fork(); + for (const v of message.repLong) { + writer.int64(v); + } + writer.ldelim(); + } + if (message.repTruth !== undefined && message.repTruth.length !== 0) { + writer.uint32(122).fork(); + for (const v of message.repTruth) { + writer.bool(v); + } + writer.ldelim(); + } + if (message.repDescription !== undefined && message.repDescription.length !== 0) { + for (const v of message.repDescription) { + writer.uint32(130).string(v!); + } + } + if (message.repData !== undefined && message.repData.length !== 0) { + for (const v of message.repData) { + writer.uint32(138).bytes(v!); + } + } + if (message.optId !== undefined) { + writer.uint32(168).int32(message.optId); + } + if (message.optChild !== undefined) { + Child.encode(message.optChild, writer.uint32(178).fork()).ldelim(); + } + if (message.optState !== undefined) { + writer.uint32(184).int32(message.optState); + } + if (message.optLong !== undefined) { + writer.uint32(192).int64(message.optLong); + } + if (message.optTruth !== undefined) { + writer.uint32(200).bool(message.optTruth); + } + if (message.optDescription !== undefined) { + writer.uint32(210).string(message.optDescription); + } + if (message.optData !== undefined) { + writer.uint32(218).bytes(message.optData); + } + Object.entries(message.translations || {}).forEach(([key, value]) => { + OptionalsTest_TranslationsEntry.encode({ key: key as any, value }, writer.uint32(242).fork()).ldelim(); + }); + return writer; + }, + + decode(input: Reader | Uint8Array, length?: number): OptionalsTest { + const reader = input instanceof Reader ? input : new Reader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = { ...baseOptionalsTest } as OptionalsTest; + message.repId = []; + message.repChild = []; + message.repState = []; + message.repLong = []; + message.repTruth = []; + message.repDescription = []; + message.repData = []; + message.translations = {}; + message.data = new Uint8Array(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.id = reader.int32(); + break; + case 2: + message.child = Child.decode(reader, reader.uint32()); + break; + case 3: + message.state = reader.int32() as any; + break; + case 4: + message.long = longToNumber(reader.int64() as Long); + break; + case 5: + message.truth = reader.bool(); + break; + case 6: + message.description = reader.string(); + break; + case 7: + message.data = reader.bytes(); + break; + case 11: + if ((tag & 7) === 2) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.repId.push(reader.int32()); + } + } else { + message.repId.push(reader.int32()); + } + break; + case 12: + message.repChild.push(Child.decode(reader, reader.uint32())); + break; + case 13: + if ((tag & 7) === 2) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.repState.push(reader.int32() as any); + } + } else { + message.repState.push(reader.int32() as any); + } + break; + case 14: + if ((tag & 7) === 2) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.repLong.push(longToNumber(reader.int64() as Long)); + } + } else { + message.repLong.push(longToNumber(reader.int64() as Long)); + } + break; + case 15: + if ((tag & 7) === 2) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.repTruth.push(reader.bool()); + } + } else { + message.repTruth.push(reader.bool()); + } + break; + case 16: + message.repDescription.push(reader.string()); + break; + case 17: + message.repData.push(reader.bytes()); + break; + case 21: + message.optId = reader.int32(); + break; + case 22: + message.optChild = Child.decode(reader, reader.uint32()); + break; + case 23: + message.optState = reader.int32() as any; + break; + case 24: + message.optLong = longToNumber(reader.int64() as Long); + break; + case 25: + message.optTruth = reader.bool(); + break; + case 26: + message.optDescription = reader.string(); + break; + case 27: + message.optData = reader.bytes(); + break; + case 30: + const entry30 = OptionalsTest_TranslationsEntry.decode(reader, reader.uint32()); + if (entry30.value !== undefined) { + message.translations[entry30.key] = entry30.value; + } + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(object: any): OptionalsTest { + const message = { ...baseOptionalsTest } as OptionalsTest; + message.id = object.id !== undefined && object.id !== null ? Number(object.id) : 0; + message.child = object.child !== undefined && object.child !== null ? Child.fromJSON(object.child) : undefined; + message.state = object.state !== undefined && object.state !== null ? stateEnumFromJSON(object.state) : 0; + message.long = object.long !== undefined && object.long !== null ? Number(object.long) : 0; + message.truth = object.truth !== undefined && object.truth !== null ? Boolean(object.truth) : false; + message.description = + object.description !== undefined && object.description !== null ? String(object.description) : ''; + message.data = object.data !== undefined && object.data !== null ? bytesFromBase64(object.data) : new Uint8Array(); + message.repId = (object.repId ?? []).map((e: any) => Number(e)); + message.repChild = (object.repChild ?? []).map((e: any) => Child.fromJSON(e)); + message.repState = (object.repState ?? []).map((e: any) => stateEnumFromJSON(e)); + message.repLong = (object.repLong ?? []).map((e: any) => Number(e)); + message.repTruth = (object.repTruth ?? []).map((e: any) => Boolean(e)); + message.repDescription = (object.repDescription ?? []).map((e: any) => String(e)); + message.repData = (object.repData ?? []).map((e: any) => bytesFromBase64(e)); + message.optId = object.optId !== undefined && object.optId !== null ? Number(object.optId) : undefined; + message.optChild = + object.optChild !== undefined && object.optChild !== null ? Child.fromJSON(object.optChild) : undefined; + message.optState = + object.optState !== undefined && object.optState !== null ? stateEnumFromJSON(object.optState) : undefined; + message.optLong = object.optLong !== undefined && object.optLong !== null ? Number(object.optLong) : undefined; + message.optTruth = object.optTruth !== undefined && object.optTruth !== null ? Boolean(object.optTruth) : undefined; + message.optDescription = + object.optDescription !== undefined && object.optDescription !== null ? String(object.optDescription) : undefined; + message.optData = + object.optData !== undefined && object.optData !== null ? bytesFromBase64(object.optData) : undefined; + message.translations = Object.entries(object.translations ?? {}).reduce<{ [key: string]: string }>( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} + ); + return message; + }, + + toJSON(message: OptionalsTest): unknown { + const obj: any = {}; + message.id !== undefined && (obj.id = Math.round(message.id)); + message.child !== undefined && (obj.child = message.child ? Child.toJSON(message.child) : undefined); + message.state !== undefined && (obj.state = stateEnumToJSON(message.state)); + message.long !== undefined && (obj.long = Math.round(message.long)); + message.truth !== undefined && (obj.truth = message.truth); + message.description !== undefined && (obj.description = message.description); + message.data !== undefined && + (obj.data = base64FromBytes(message.data !== undefined ? message.data : new Uint8Array())); + if (message.repId) { + obj.repId = message.repId.map((e) => Math.round(e)); + } else { + obj.repId = []; + } + if (message.repChild) { + obj.repChild = message.repChild.map((e) => (e ? Child.toJSON(e) : undefined)); + } else { + obj.repChild = []; + } + if (message.repState) { + obj.repState = message.repState.map((e) => stateEnumToJSON(e)); + } else { + obj.repState = []; + } + if (message.repLong) { + obj.repLong = message.repLong.map((e) => Math.round(e)); + } else { + obj.repLong = []; + } + if (message.repTruth) { + obj.repTruth = message.repTruth.map((e) => e); + } else { + obj.repTruth = []; + } + if (message.repDescription) { + obj.repDescription = message.repDescription.map((e) => e); + } else { + obj.repDescription = []; + } + if (message.repData) { + obj.repData = message.repData.map((e) => base64FromBytes(e !== undefined ? e : new Uint8Array())); + } else { + obj.repData = []; + } + message.optId !== undefined && (obj.optId = Math.round(message.optId)); + message.optChild !== undefined && (obj.optChild = message.optChild ? Child.toJSON(message.optChild) : undefined); + message.optState !== undefined && + (obj.optState = message.optState !== undefined ? stateEnumToJSON(message.optState) : undefined); + message.optLong !== undefined && (obj.optLong = Math.round(message.optLong)); + message.optTruth !== undefined && (obj.optTruth = message.optTruth); + message.optDescription !== undefined && (obj.optDescription = message.optDescription); + message.optData !== undefined && + (obj.optData = message.optData !== undefined ? base64FromBytes(message.optData) : undefined); + obj.translations = {}; + if (message.translations) { + Object.entries(message.translations).forEach(([k, v]) => { + obj.translations[k] = v; + }); + } + return obj; + }, + + fromPartial, I>>(object: I): OptionalsTest { + const message = { ...baseOptionalsTest } as OptionalsTest; + message.id = object.id ?? 0; + message.child = object.child !== undefined && object.child !== null ? Child.fromPartial(object.child) : undefined; + message.state = object.state ?? 0; + message.long = object.long ?? 0; + message.truth = object.truth ?? false; + message.description = object.description ?? ''; + message.data = object.data ?? new Uint8Array(); + message.repId = object.repId?.map((e) => e) || []; + message.repChild = object.repChild?.map((e) => Child.fromPartial(e)) || []; + message.repState = object.repState?.map((e) => e) || []; + message.repLong = object.repLong?.map((e) => e) || []; + message.repTruth = object.repTruth?.map((e) => e) || []; + message.repDescription = object.repDescription?.map((e) => e) || []; + message.repData = object.repData?.map((e) => e) || []; + message.optId = object.optId ?? undefined; + message.optChild = + object.optChild !== undefined && object.optChild !== null ? Child.fromPartial(object.optChild) : undefined; + message.optState = object.optState ?? undefined; + message.optLong = object.optLong ?? undefined; + message.optTruth = object.optTruth ?? undefined; + message.optDescription = object.optDescription ?? undefined; + message.optData = object.optData ?? undefined; + message.translations = Object.entries(object.translations ?? {}).reduce<{ [key: string]: string }>( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = String(value); + } + return acc; + }, + {} + ); + return message; + }, +}; + +const baseOptionalsTest_TranslationsEntry: object = { key: '', value: '' }; + +export const OptionalsTest_TranslationsEntry = { + encode(message: OptionalsTest_TranslationsEntry, writer: Writer = Writer.create()): Writer { + if (message.key !== '') { + writer.uint32(10).string(message.key); + } + if (message.value !== '') { + writer.uint32(18).string(message.value); + } + return writer; + }, + + decode(input: Reader | Uint8Array, length?: number): OptionalsTest_TranslationsEntry { + const reader = input instanceof Reader ? input : new Reader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = { ...baseOptionalsTest_TranslationsEntry } as OptionalsTest_TranslationsEntry; + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.key = reader.string(); + break; + case 2: + message.value = reader.string(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(object: any): OptionalsTest_TranslationsEntry { + const message = { ...baseOptionalsTest_TranslationsEntry } as OptionalsTest_TranslationsEntry; + message.key = object.key !== undefined && object.key !== null ? String(object.key) : ''; + message.value = object.value !== undefined && object.value !== null ? String(object.value) : ''; + return message; + }, + + toJSON(message: OptionalsTest_TranslationsEntry): unknown { + const obj: any = {}; + message.key !== undefined && (obj.key = message.key); + message.value !== undefined && (obj.value = message.value); + return obj; + }, + + fromPartial, I>>( + object: I + ): OptionalsTest_TranslationsEntry { + const message = { ...baseOptionalsTest_TranslationsEntry } as OptionalsTest_TranslationsEntry; + message.key = object.key ?? ''; + message.value = object.value ?? ''; + return message; + }, +}; + +const baseChild: object = {}; + +export const Child = { + encode(_: Child, writer: Writer = Writer.create()): Writer { + return writer; + }, + + decode(input: Reader | Uint8Array, length?: number): Child { + const reader = input instanceof Reader ? input : new Reader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = { ...baseChild } as Child; + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(_: any): Child { + const message = { ...baseChild } as Child; + return message; + }, + + toJSON(_: Child): unknown { + const obj: any = {}; + return obj; + }, + + fromPartial, I>>(_: I): Child { + const message = { ...baseChild } as Child; + return message; + }, +}; + +declare var self: any | undefined; +declare var window: any | undefined; +declare var global: any | undefined; +var globalThis: any = (() => { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof window !== 'undefined') return window; + if (typeof global !== 'undefined') return global; + throw 'Unable to locate global object'; +})(); + +const atob: (b64: string) => string = + globalThis.atob || ((b64) => globalThis.Buffer.from(b64, 'base64').toString('binary')); +function bytesFromBase64(b64: string): Uint8Array { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + arr[i] = bin.charCodeAt(i); + } + return arr; +} + +const btoa: (bin: string) => string = + globalThis.btoa || ((bin) => globalThis.Buffer.from(bin, 'binary').toString('base64')); +function base64FromBytes(arr: Uint8Array): string { + const bin: string[] = []; + for (const byte of arr) { + bin.push(String.fromCharCode(byte)); + } + return btoa(bin.join('')); +} + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin + ? P + : P & { [K in keyof P]: Exact } & Record>, never>; + +function longToNumber(long: Long): number { + if (long.gt(Number.MAX_SAFE_INTEGER)) { + throw new globalThis.Error('Value is larger than Number.MAX_SAFE_INTEGER'); + } + return long.toNumber(); +} + +// If you get a compile-error about 'Constructor and ... have no overlap', +// add '--ts_proto_opt=esModuleInterop=true' as a flag when calling 'protoc'. +if (util.Long !== Long) { + util.Long = Long as any; + configure(); +} diff --git a/src/main.ts b/src/main.ts index 92a4690c3..c083080ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { isLongValueType, isMapType, isMessage, + isOptionalProperty, isPrimitive, isRepeated, isScalar, @@ -74,6 +75,18 @@ import { generateGenericServiceDefinition } from './generate-generic-service-def export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [string, Code] { const { options, utils } = ctx; + if (options.useOptionals === false) { + console.warn( + "ts-proto: Passing useOptionals as a boolean option is deprecated and will be removed in a future version. Please pass the string 'none' instead of false." + ); + options.useOptionals = 'none'; + } else if (options.useOptionals === true) { + console.warn( + "ts-proto: Passing useOptionals as a boolean option is deprecated and will be removed in a future version. Please pass the string 'messages' instead of true." + ); + options.useOptionals = 'messages'; + } + // Google's protofiles are organized like Java, where package == the folder the file // is in, and file == a specific service within the package. I.e. you can have multiple // company/foo.proto and company/bar.proto files, where package would be 'company'. @@ -541,11 +554,6 @@ function makeTimestampMethods(options: Options, longs: ReturnType { + Object.entries(message.${fieldName}${optionalAlternative}).forEach(([key, value]) => { ${entryWriteSnippet} }); `); } else if (packedType(field.type) === undefined) { - chunks.push(code` + const listWriteSnippet = code` for (const v of message.${fieldName}) { ${writeSnippet('v!')}; } - `); + `; + if (isOptional) { + chunks.push(code` + if (message.${fieldName} !== undefined && message.${fieldName}.length !== 0) { + ${listWriteSnippet} + } + `); + } else { + chunks.push(listWriteSnippet); + } } else if (isEnum(field) && options.stringEnums) { // This is a lot like the `else` clause, but we wrap `fooToNumber` around it. // Ideally we'd reuse `writeSnippet` here, but `writeSnippet` has the `writer.uint32(tag)` @@ -877,23 +896,41 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP // (i.e. just one tag and multiple values). const tag = ((field.number << 3) | 2) >>> 0; const toNumber = getEnumMethod(ctx, field.typeName, 'ToNumber'); - chunks.push(code` + const listWriteSnippet = code` writer.uint32(${tag}).fork(); for (const v of message.${fieldName}) { writer.${toReaderCall(field)}(${toNumber}(v)); } writer.ldelim(); - `); + `; + if (isOptional) { + chunks.push(code` + if (message.${fieldName} !== undefined && message.${fieldName}.length !== 0) { + ${listWriteSnippet} + } + `); + } else { + chunks.push(listWriteSnippet); + } } else { // Ideally we'd reuse `writeSnippet` but it has tagging embedded inside of it. const tag = ((field.number << 3) | 2) >>> 0; - chunks.push(code` + const listWriteSnippet = code` writer.uint32(${tag}).fork(); for (const v of message.${fieldName}) { writer.${toReaderCall(field)}(v); } writer.ldelim(); - `); + `; + if (isOptional) { + chunks.push(code` + if (message.${fieldName} !== undefined && message.${fieldName}.length !== 0) { + ${listWriteSnippet} + } + `); + } else { + chunks.push(listWriteSnippet); + } } } else if (isWithinOneOfThatShouldBeUnion(options, field)) { let oneofName = maybeSnakeToCamel(messageDesc.oneofDecl[field.oneofIndex].name, options); @@ -917,7 +954,7 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP `); } else if (isScalar(field) || isEnum(field)) { chunks.push(code` - if (${notDefaultCheck(ctx, field, `message.${fieldName}`)}) { + if (${notDefaultCheck(ctx, field, messageDesc.options, `message.${fieldName}`)}) { ${writeSnippet(`message.${fieldName}`)}; } `); diff --git a/src/options.ts b/src/options.ts index 54a4367e2..df38f50bb 100644 --- a/src/options.ts +++ b/src/options.ts @@ -32,7 +32,7 @@ export type Options = { context: boolean; snakeToCamel: Array<'json' | 'keys'>; forceLong: LongOption; - useOptionals: boolean; + useOptionals: boolean | 'none' | 'messages' | 'all'; // boolean is deprecated useDate: DateOption; oneof: OneofOption; esModuleInterop: boolean; @@ -64,7 +64,7 @@ export function defaultOptions(): Options { context: false, snakeToCamel: ['json', 'keys'], forceLong: LongOption.NUMBER, - useOptionals: false, + useOptionals: 'none', useDate: DateOption.DATE, oneof: OneofOption.PROPERTIES, esModuleInterop: false, diff --git a/src/types.ts b/src/types.ts index dec3640de..c3ed344fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ import { FieldDescriptorProto_Label, FieldDescriptorProto_Type, FileDescriptorProto, + MessageOptions, MethodDescriptorProto, ServiceDescriptorProto, } from 'ts-proto-descriptors'; @@ -231,8 +232,15 @@ export function defaultValue(ctx: Context, field: FieldDescriptorProto): any { } /** Creates code that checks that the field is not the default value. Supports scalars and enums. */ -export function notDefaultCheck(ctx: Context, field: FieldDescriptorProto, place: string): Code { +export function notDefaultCheck( + ctx: Context, + field: FieldDescriptorProto, + messageOptions: MessageOptions | undefined, + place: string +): Code { const { typeMap, options } = ctx; + const isOptional = isOptionalProperty(field, messageOptions, options); + const maybeNotUndefinedAnd = isOptional ? `${place} !== undefined && ` : ''; switch (field.type) { case FieldDescriptorProto_Type.TYPE_DOUBLE: case FieldDescriptorProto_Type.TYPE_FLOAT: @@ -241,7 +249,7 @@ export function notDefaultCheck(ctx: Context, field: FieldDescriptorProto, place case FieldDescriptorProto_Type.TYPE_SINT32: case FieldDescriptorProto_Type.TYPE_FIXED32: case FieldDescriptorProto_Type.TYPE_SFIXED32: - return code`${place} !== 0`; + return code`${maybeNotUndefinedAnd} ${place} !== 0`; case FieldDescriptorProto_Type.TYPE_ENUM: // proto3 enforces enums starting at 0, however proto2 does not, so we have // to probe and see if zero is an allowed value. If it's not, pick the first one. @@ -251,9 +259,9 @@ export function notDefaultCheck(ctx: Context, field: FieldDescriptorProto, place const zerothValue = enumProto.value.find((v) => v.number === 0) || enumProto.value[0]; if (options.stringEnums) { const enumType = messageToTypeName(ctx, field.typeName); - return code`${place} !== ${enumType}.${zerothValue.name}`; + return code`${maybeNotUndefinedAnd} ${place} !== ${enumType}.${zerothValue.name}`; } else { - return code`${place} !== ${zerothValue.number}`; + return code`${maybeNotUndefinedAnd} ${place} !== ${zerothValue.number}`; } case FieldDescriptorProto_Type.TYPE_UINT64: case FieldDescriptorProto_Type.TYPE_FIXED64: @@ -261,18 +269,18 @@ export function notDefaultCheck(ctx: Context, field: FieldDescriptorProto, place case FieldDescriptorProto_Type.TYPE_SINT64: case FieldDescriptorProto_Type.TYPE_SFIXED64: if (options.forceLong === LongOption.LONG) { - return code`!${place}.isZero()`; + return code`${maybeNotUndefinedAnd} !${place}.isZero()`; } else if (options.forceLong === LongOption.STRING) { - return code`${place} !== "0"`; + return code`${maybeNotUndefinedAnd} ${place} !== "0"`; } else { - return code`${place} !== 0`; + return code`${maybeNotUndefinedAnd} ${place} !== 0`; } case FieldDescriptorProto_Type.TYPE_BOOL: return code`${place} === true`; case FieldDescriptorProto_Type.TYPE_STRING: - return code`${place} !== ""`; + return code`${maybeNotUndefinedAnd} ${place} !== ""`; case FieldDescriptorProto_Type.TYPE_BYTES: - return code`${place}.length !== 0`; + return code`${maybeNotUndefinedAnd} ${place}.length !== 0`; default: throw new Error('Not implemented for the given type.'); } @@ -325,6 +333,25 @@ export function isScalar(field: FieldDescriptorProto): boolean { return scalarTypes.includes(field.type); } +// When useOptionals='messages', non-scalar fields are translated into optional +// properties. When useOptionals='all', all fields are translated into +// optional properties, with the exception of map Entry key/values, which must +// always be present. +export function isOptionalProperty( + field: FieldDescriptorProto, + messageOptions: MessageOptions | undefined, + options: Options +): boolean { + const optionalMessages = + options.useOptionals === true || options.useOptionals === 'messages' || options.useOptionals === 'all'; + const optionalAll = options.useOptionals === 'all'; + return ( + (optionalMessages && isMessage(field) && !isRepeated(field)) || + (optionalAll && !messageOptions?.mapEntry) || + field.proto3Optional + ); +} + /** This includes all scalars, enums and the [groups type](https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/DescriptorProtos.FieldDescriptorProto.Type.html#TYPE_GROUP) */ export function isPrimitive(field: FieldDescriptorProto): boolean { return !isMessage(field); @@ -491,11 +518,16 @@ export function messageToTypeName( // them to basic built-in types, we union the type with undefined to // indicate the value is optional. Exceptions: // - If the field is repeated, values cannot be undefined. - // - If useOptionals=true, all non-scalar types are already optional - // properties, so there's no need for that union. + // - If useOptionals='messages' or useOptionals='all', all non-scalar types + // are already optional properties, so there's no need for that union. let valueType = valueTypeName(ctx, protoType); if (!typeOptions.keepValueType && valueType) { - if (!!typeOptions.repeated || options.useOptionals) { + if ( + !!typeOptions.repeated || + options.useOptionals === true || + options.useOptionals === 'messages' || + options.useOptionals === 'all' + ) { return valueType; } return code`${valueType} | undefined`; @@ -542,19 +574,22 @@ export function toTypeName(ctx: Context, messageDesc: DescriptorProto, field: Fi return type; } - // By default (useOptionals=false, oneof=properties), non-scalar fields + // By default (useOptionals='none', oneof=properties), non-scalar fields // outside oneofs and all fields within a oneof clause need to be unioned // with `undefined` to indicate the value is optional. // - // When useOptionals=true, non-scalar fields are translated to optional - // properties, so no need for the union with `undefined` here. + // When useOptionals='messages' or useOptionals='all', non-scalar fields are + // translated to optional properties, so no need for the union with + // `undefined` here. // // When oneof=unions, we generate a single property for the entire `oneof` // clause, spelling each option out inside a large type union. No need for // union with `undefined` here, either. const { options } = ctx; if ( - (!isWithinOneOf(field) && isMessage(field) && !options.useOptionals) || + (!isWithinOneOf(field) && + isMessage(field) && + (options.useOptionals === false || options.useOptionals === 'none')) || (isWithinOneOf(field) && options.oneof === OneofOption.PROPERTIES) || (isWithinOneOf(field) && field.proto3Optional) ) { diff --git a/tests/options-test.ts b/tests/options-test.ts index 2a0c36122..6b950bad4 100644 --- a/tests/options-test.ts +++ b/tests/options-test.ts @@ -33,7 +33,7 @@ describe('options', () => { "stringEnums": false, "unrecognizedEnum": true, "useDate": "timestamp", - "useOptionals": false, + "useOptionals": "none", } `); }); @@ -66,4 +66,18 @@ describe('options', () => { outputServices: ServiceOption.GRPC, }); }); + + it('can set useOptionals to boolean', () => { + const options = optionsFromParameter('useOptionals=true'); + expect(options).toMatchObject({ + useOptionals: true, + }); + }); + + it('can set useOptionals to string', () => { + const options = optionsFromParameter('useOptionals=messages'); + expect(options).toMatchObject({ + useOptionals: 'messages', + }); + }); }); diff --git a/tests/types-test.ts b/tests/types-test.ts index 29db03d35..9f8da2c12 100644 --- a/tests/types-test.ts +++ b/tests/types-test.ts @@ -40,6 +40,13 @@ describe('types', () => { options: { ...defaultOptions(), useOptionals: true }, expected: code`string`, }, + { + descr: 'value types (useOptionals="all")', + typeMap: new Map(), + protoType: '.google.protobuf.StringValue', + options: { ...defaultOptions(), useOptionals: 'all' }, + expected: code`string`, + }, ]; testCases.forEach((t) => it(t.descr, async () => {