diff --git a/README.markdown b/README.markdown index b8b7fb353..6d4f19b3a 100644 --- a/README.markdown +++ b/README.markdown @@ -603,7 +603,7 @@ export interface User { } ``` -- With `--ts_proto_opt=noDefaultsForOptionals=true`, `undefined` primitive values will not be defaulted as per the protobuf spec. Additionally unlike the standard behavior, when a field is set to it's standard default value, it *will* be encoded allowing it to be sent over the wire and distinguished from undefined values. For example if a message does not set a boolean value, ordinarily this would be defaulted to `false` which is different to it being undefined. +- With `--ts_proto_opt=noDefaultsForOptionals=true`, `undefined` primitive values will not be defaulted as per the protobuf spec. Additionally unlike the standard behavior, when a field is set to it's standard default value, it *will* be encoded allowing it to be sent over the wire and distinguished from undefined values. For example if a message does not set a boolean value, ordinarily this would be defaulted to `false` which is different to it being undefined. This option allows the library to act in a compatible way with the [Wire implementation](https://square.github.io/wire/) maintained and used by Square/Block. Note: this option should only be used in combination with other client/server code generated using Wire or ts-proto with this option enabled. @@ -752,7 +752,7 @@ ts-protoc --ts_proto_out=./output -I=./protos ./protoc/*.proto # Todo - Support the string-based encoding of duration in `fromJSON`/`toJSON` -- Make `oneof=unions` the default behavior in 2.0 +- Make `oneof=unions-value` the default behavior in 2.0 - Probably change `forceLong` default in 2.0, should default to `forceLong=long` - Make `esModuleInterop=true` the default in 2.0 @@ -770,11 +770,11 @@ Will generate a `Foo` type with two fields: `field_a: string | undefined;` and ` With this output, you'll have to check both `if object.field_a` and `if object.field_b`, and if you set one, you'll have to remember to unset the other. -Instead, we recommend using the `oneof=unions` option, which will change the output to be an Abstract Data Type/ADT like: +Instead, we recommend using the `oneof=unions-value` option, which will change the output to be an Abstract Data Type/ADT like: ```typescript interface YourMessage { - eitherField?: { $case: "field_a"; field_a: string } | { $case: "field_b"; field_b: string }; + eitherField?: { $case: "field_a"; value: string } | { $case: "field_b"; value: string }; } ``` @@ -782,11 +782,21 @@ As this will automatically enforce only one of `field_a` or `field_b` "being set (Note that `eitherField` is optional b/c `oneof` in Protobuf means "at most one field" is set, and does not mean one of the fields _must_ be set.) -In ts-proto's currently-unscheduled 2.x release, `oneof=unions` will become the default behavior. +In ts-proto's currently-unscheduled 2.x release, `oneof=unions-value` will become the default behavior. + +There is also a `oneof=unions` option, which generates a union where the field names are included in each option: + +```typescript +interface YourMessage { + eitherField?: { $case: "field_a"; field_a: string } | { $case: "field_b"; field_b: string }; +} +``` + +This is no longer recommended as it can be difficult to write code and types to handle multiple oneof options: ## OneOf Type Helpers -The following helper types may make it easier to work with the types generated from `oneof=unions`: +The following helper types may make it easier to work with the types generated from `oneof=unions`, though they are generally not needed if you use `oneof=unions-value`: ```ts /** Extracts all the case names from a oneOf field. */ @@ -797,19 +807,45 @@ type OneOfValues = T extends { $case: infer U extends string; [key: string]: /** Extracts the specific type of a oneOf case based on its field name */ type OneOfCase> = T extends { + $case: K; + [key: string]: unknown; +} + ? T + : never; + +/** Extracts the specific type of a value type from a oneOf field */ +type OneOfValue> = T extends { $case: infer U extends K; [key: string]: unknown; } ? T[U] : never; +``` -/** Extracts the specific type of a value type from a oneOf field */ -export type OneOfValue> = T extends { +For comparison, the equivalents for `oneof=unions-value`: + +```ts +/** Extracts all the case names from a oneOf field. */ +type OneOfCases = T['$case']; + +/** Extracts a union of all the value types from a oneOf field */ +type OneOfValues = T['value']; + +/** Extracts the specific type of a oneOf case based on its field name */ +type OneOfCase> = T extends { $case: K; [key: string]: unknown; } ? T : never; + +/** Extracts the specific type of a value type from a oneOf field */ +type OneOfValue> = T extends { + $case: infer U extends K; + value: unknown; +} + ? T[U] + : never; ``` # Default values and unset fields diff --git a/integration/oneof-unions-value/google/protobuf/struct.ts b/integration/oneof-unions-value/google/protobuf/struct.ts new file mode 100644 index 000000000..993adf6e4 --- /dev/null +++ b/integration/oneof-unions-value/google/protobuf/struct.ts @@ -0,0 +1,539 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// source: google/protobuf/struct.proto + +/* eslint-disable */ +import * as _m0 from "protobufjs/minimal"; + +export const protobufPackage = "google.protobuf"; + +/** + * `NullValue` is a singleton enumeration to represent the null value for the + * `Value` type union. + * + * The JSON representation for `NullValue` is JSON `null`. + */ +export enum NullValue { + /** NULL_VALUE - Null value. */ + NULL_VALUE = 0, + UNRECOGNIZED = -1, +} + +export function nullValueFromJSON(object: any): NullValue { + switch (object) { + case 0: + case "NULL_VALUE": + return NullValue.NULL_VALUE; + case -1: + case "UNRECOGNIZED": + default: + return NullValue.UNRECOGNIZED; + } +} + +export function nullValueToJSON(object: NullValue): string { + switch (object) { + case NullValue.NULL_VALUE: + return "NULL_VALUE"; + case NullValue.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +/** + * `Struct` represents a structured data value, consisting of fields + * which map to dynamically typed values. In some languages, `Struct` + * might be supported by a native representation. For example, in + * scripting languages like JS a struct is represented as an + * object. The details of that representation are described together + * with the proto support for the language. + * + * The JSON representation for `Struct` is JSON object. + */ +export interface Struct { + /** Unordered map of dynamically typed values. */ + fields: { [key: string]: any | undefined }; +} + +export interface Struct_FieldsEntry { + key: string; + value: any | undefined; +} + +/** + * `Value` represents a dynamically typed value which can be either + * null, a number, a string, a boolean, a recursive struct value, or a + * list of values. A producer of value is expected to set one of these + * variants. Absence of any variant indicates an error. + * + * The JSON representation for `Value` is JSON value. + */ +export interface Value { + kind?: + | { $case: "nullValue"; value: NullValue } + | { $case: "numberValue"; value: number } + | { $case: "stringValue"; value: string } + | { $case: "boolValue"; value: boolean } + | { $case: "structValue"; value: { [key: string]: any } | undefined } + | { $case: "listValue"; value: Array | undefined } + | undefined; +} + +/** + * `ListValue` is a wrapper around a repeated field of values. + * + * The JSON representation for `ListValue` is JSON array. + */ +export interface ListValue { + /** Repeated field of dynamically typed values. */ + values: any[]; +} + +function createBaseStruct(): Struct { + return { fields: {} }; +} + +export const Struct = { + encode(message: Struct, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + Object.entries(message.fields).forEach(([key, value]) => { + if (value !== undefined) { + Struct_FieldsEntry.encode({ key: key as any, value }, writer.uint32(10).fork()).ldelim(); + } + }); + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): Struct { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStruct(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + const entry1 = Struct_FieldsEntry.decode(reader, reader.uint32()); + if (entry1.value !== undefined) { + message.fields[entry1.key] = entry1.value; + } + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): Struct { + return { + fields: isObject(object.fields) + ? Object.entries(object.fields).reduce<{ [key: string]: any | undefined }>((acc, [key, value]) => { + acc[key] = value as any | undefined; + return acc; + }, {}) + : {}, + }; + }, + + toJSON(message: Struct): unknown { + const obj: any = {}; + if (message.fields) { + const entries = Object.entries(message.fields); + if (entries.length > 0) { + obj.fields = {}; + entries.forEach(([k, v]) => { + obj.fields[k] = v; + }); + } + } + return obj; + }, + + create, I>>(base?: I): Struct { + return Struct.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Struct { + const message = createBaseStruct(); + message.fields = Object.entries(object.fields ?? {}).reduce<{ [key: string]: any | undefined }>( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + return acc; + }, + {}, + ); + return message; + }, + + wrap(object: { [key: string]: any } | undefined): Struct { + const struct = createBaseStruct(); + + if (object !== undefined) { + for (const key of Object.keys(object)) { + struct.fields[key] = object[key]; + } + } + return struct; + }, + + unwrap(message: Struct): { [key: string]: any } { + const object: { [key: string]: any } = {}; + if (message.fields) { + for (const key of Object.keys(message.fields)) { + object[key] = message.fields[key]; + } + } + return object; + }, +}; + +function createBaseStruct_FieldsEntry(): Struct_FieldsEntry { + return { key: "", value: undefined }; +} + +export const Struct_FieldsEntry = { + encode(message: Struct_FieldsEntry, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== undefined) { + Value.encode(Value.wrap(message.value), writer.uint32(18).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): Struct_FieldsEntry { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStruct_FieldsEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.value = Value.unwrap(Value.decode(reader, reader.uint32())); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): Struct_FieldsEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object?.value) ? object.value : undefined, + }; + }, + + toJSON(message: Struct_FieldsEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== undefined) { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): Struct_FieldsEntry { + return Struct_FieldsEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Struct_FieldsEntry { + const message = createBaseStruct_FieldsEntry(); + message.key = object.key ?? ""; + message.value = object.value ?? undefined; + return message; + }, +}; + +function createBaseValue(): Value { + return { kind: undefined }; +} + +export const Value = { + encode(message: Value, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + switch (message.kind?.$case) { + case "nullValue": + writer.uint32(8).int32(message.kind.value); + break; + case "numberValue": + writer.uint32(17).double(message.kind.value); + break; + case "stringValue": + writer.uint32(26).string(message.kind.value); + break; + case "boolValue": + writer.uint32(32).bool(message.kind.value); + break; + case "structValue": + Struct.encode(Struct.wrap(message.kind.value), writer.uint32(42).fork()).ldelim(); + break; + case "listValue": + ListValue.encode(ListValue.wrap(message.kind.value), writer.uint32(50).fork()).ldelim(); + break; + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): Value { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseValue(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.kind = { $case: "nullValue", value: reader.int32() as any }; + continue; + case 2: + if (tag !== 17) { + break; + } + + message.kind = { $case: "numberValue", value: reader.double() }; + continue; + case 3: + if (tag !== 26) { + break; + } + + message.kind = { $case: "stringValue", value: reader.string() }; + continue; + case 4: + if (tag !== 32) { + break; + } + + message.kind = { $case: "boolValue", value: reader.bool() }; + continue; + case 5: + if (tag !== 42) { + break; + } + + message.kind = { $case: "structValue", value: Struct.unwrap(Struct.decode(reader, reader.uint32())) }; + continue; + case 6: + if (tag !== 50) { + break; + } + + message.kind = { $case: "listValue", value: ListValue.unwrap(ListValue.decode(reader, reader.uint32())) }; + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): Value { + return { + kind: isSet(object.nullValue) + ? { $case: "nullValue", value: nullValueFromJSON(object.nullValue) } + : isSet(object.numberValue) + ? { $case: "numberValue", value: globalThis.Number(object.numberValue) } + : isSet(object.stringValue) + ? { $case: "stringValue", value: globalThis.String(object.stringValue) } + : isSet(object.boolValue) + ? { $case: "boolValue", value: globalThis.Boolean(object.boolValue) } + : isSet(object.structValue) + ? { $case: "structValue", value: object.structValue } + : isSet(object.listValue) + ? { $case: "listValue", value: [...object.listValue] } + : undefined, + }; + }, + + toJSON(message: Value): unknown { + const obj: any = {}; + if (message.kind?.$case === "nullValue") { + obj.nullValue = nullValueToJSON(message.kind.value); + } + if (message.kind?.$case === "numberValue") { + obj.numberValue = message.kind.value; + } + if (message.kind?.$case === "stringValue") { + obj.stringValue = message.kind.value; + } + if (message.kind?.$case === "boolValue") { + obj.boolValue = message.kind.value; + } + if (message.kind?.$case === "structValue") { + obj.structValue = message.kind.value; + } + if (message.kind?.$case === "listValue") { + obj.listValue = message.kind.value; + } + return obj; + }, + + create, I>>(base?: I): Value { + return Value.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Value { + const message = createBaseValue(); + if (object.kind?.$case === "nullValue" && object.kind?.value !== undefined && object.kind?.value !== null) { + message.kind = { $case: "nullValue", value: object.kind.value }; + } + if (object.kind?.$case === "numberValue" && object.kind?.value !== undefined && object.kind?.value !== null) { + message.kind = { $case: "numberValue", value: object.kind.value }; + } + if (object.kind?.$case === "stringValue" && object.kind?.value !== undefined && object.kind?.value !== null) { + message.kind = { $case: "stringValue", value: object.kind.value }; + } + if (object.kind?.$case === "boolValue" && object.kind?.value !== undefined && object.kind?.value !== null) { + message.kind = { $case: "boolValue", value: object.kind.value }; + } + if (object.kind?.$case === "structValue" && object.kind?.value !== undefined && object.kind?.value !== null) { + message.kind = { $case: "structValue", value: object.kind.value }; + } + if (object.kind?.$case === "listValue" && object.kind?.value !== undefined && object.kind?.value !== null) { + message.kind = { $case: "listValue", value: object.kind.value }; + } + return message; + }, + + wrap(value: any): Value { + const result = createBaseValue(); + if (value === null) { + result.kind = { $case: "nullValue", value }; + } else if (typeof value === "boolean") { + result.kind = { $case: "boolValue", value }; + } else if (typeof value === "number") { + result.kind = { $case: "numberValue", value }; + } else if (typeof value === "string") { + result.kind = { $case: "stringValue", value }; + } else if (globalThis.Array.isArray(value)) { + result.kind = { $case: "listValue", value }; + } else if (typeof value === "object") { + result.kind = { $case: "structValue", value }; + } else if (typeof value !== "undefined") { + throw new globalThis.Error("Unsupported any value type: " + typeof value); + } + return result; + }, + + unwrap(message: Value): string | number | boolean | Object | null | Array | undefined { + return message.kind?.value; + }, +}; + +function createBaseListValue(): ListValue { + return { values: [] }; +} + +export const ListValue = { + encode(message: ListValue, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + for (const v of message.values) { + Value.encode(Value.wrap(v!), writer.uint32(10).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ListValue { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseListValue(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.values.push(Value.unwrap(Value.decode(reader, reader.uint32()))); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ListValue { + return { values: globalThis.Array.isArray(object?.values) ? [...object.values] : [] }; + }, + + toJSON(message: ListValue): unknown { + const obj: any = {}; + if (message.values?.length) { + obj.values = message.values; + } + return obj; + }, + + create, I>>(base?: I): ListValue { + return ListValue.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ListValue { + const message = createBaseListValue(); + message.values = object.values?.map((e) => e) || []; + return message; + }, + + wrap(array: Array | undefined): ListValue { + const result = createBaseListValue(); + result.values = array ?? []; + return result; + }, + + unwrap(message: ListValue): Array { + if (message?.hasOwnProperty("values") && globalThis.Array.isArray(message.values)) { + return message.values; + } else { + return message as any; + } + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends { $case: string; value: unknown } ? { $case: T["$case"]; value?: DeepPartial } + : 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 } & { [K in Exclude>]: never }; + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} diff --git a/integration/oneof-unions-value/oneof-unions-value-test.ts b/integration/oneof-unions-value/oneof-unions-value-test.ts new file mode 100644 index 000000000..4fa59fc95 --- /dev/null +++ b/integration/oneof-unions-value/oneof-unions-value-test.ts @@ -0,0 +1,140 @@ +import { PleaseChoose } from './oneof'; +import * as pbjs from "./pbjs"; +import pbjsValue = pbjs.google.protobuf.Value; + +describe('oneof=unions-value', () => { + it('generates types correctly', () => { + const alice: PleaseChoose = { + name: 'Alice', + age: 42, + signature: new Uint8Array([0xab, 0xcd]), + value: 'Alice' + }; + const bob: PleaseChoose = { + name: 'Bob', + age: 42, + choice: { $case: 'aNumber', value: 132 }, + signature: new Uint8Array([0xab, 0xcd]), + value: 'Bob' + }; + const charlie: PleaseChoose = { + name: 'Charlie', + age: 42, + choice: { $case: 'aMessage', value: { name: 'charlie' } }, + signature: new Uint8Array([0xab, 0xcd]), + value: 'Charlie' + }; + }); + + it('decode', () => { + let encoded = pbjs.oneof.PleaseChoose.encode( + new pbjs.oneof.PleaseChoose({ + name: 'Debbie', + aBool: true, + age: 37, + or: 'perhaps not', + value: new pbjsValue({ stringValue: 'Debbie' }) + })).finish(); + let decoded = PleaseChoose.decode(encoded); + expect(decoded).toEqual({ + name: 'Debbie', + age: 37, + choice: { $case: 'aBool', value: true }, + eitherOr: { $case: 'or', value: 'perhaps not' }, + signature: new Uint8Array(0), + value: 'Debbie' + }); + }); + + it('encode', () => { + let encoded = PleaseChoose.encode({ + name: 'Debbie', + age: 37, + choice: { $case: 'aBool', value: true }, + eitherOr: { $case: 'or', value: 'perhaps not' }, + signature: new Uint8Array([0xab, 0xcd]), + value: 'Debbie' + }).finish(); + let decoded = pbjs.oneof.PleaseChoose.decode(encoded); + expect(decoded).toEqual({ + name: 'Debbie', + aBool: true, + age: 37, + or: 'perhaps not', + signature: Buffer.from([0xab, 0xcd]), + value: new pbjsValue({ stringValue: 'Debbie' }) + }); + }); + + it('fromPartial', () => { + let empty = PleaseChoose.fromPartial({}); + expect(empty).toEqual({ + name: '', + age: 0, + signature: new Uint8Array(0), + }); + + let partial = PleaseChoose.fromPartial({ + name: 'Debbie', + age: 37, + choice: { $case: 'aBool', value: true }, + eitherOr: { $case: 'or', value: 'perhaps not' }, + signature: new Uint8Array([0xab, 0xcd]), + }); + expect(partial).toEqual({ + name: 'Debbie', + age: 37, + choice: { $case: 'aBool', value: true }, + eitherOr: { $case: 'or', value: 'perhaps not' }, + signature: new Uint8Array([0xab, 0xcd]), + }); + }); + + it('toJSON', () => { + let debbie: PleaseChoose = { + name: 'Debbie', + age: 37, + choice: { $case: 'aBool', value: true }, + eitherOr: { $case: 'or', value: 'perhaps not' }, + signature: new Uint8Array([0xab, 0xcd]), + value: undefined + }; + let pbjsJson = pbjs.oneof.PleaseChoose.decode(PleaseChoose.encode(debbie).finish()).toJSON(); + let json = PleaseChoose.toJSON(debbie); + expect(json).toEqual(pbjsJson); + }); + + it('fromJSON', () => { + let empty = PleaseChoose.fromJSON({}); + expect(empty).toEqual({ age: 0, name: '', signature: new Uint8Array(0) }); + + let debbie: PleaseChoose = { + name: 'Debbie', + age: 37, + choice: { $case: 'aBool', value: true }, + eitherOr: { $case: 'or', value: 'perhaps not' }, + signature: new Uint8Array([0xab, 0xcd]), + value: 'Debbie' + }; + let pbjsJson = pbjs.oneof.PleaseChoose.decode(PleaseChoose.encode(debbie).finish()).toJSON(); + + // workaround because protobuf.js does not unwrap Value when decoding + pbjsJson.value = pbjsJson.value.stringValue + + let fromJson = PleaseChoose.fromJSON(pbjsJson); + expect(fromJson).toEqual(debbie); + }); + + it('roundtrip', () => { + let obj: PleaseChoose = { + name: 'Debbie', + age: 37, + choice: { $case: 'aNumber', value: 42 }, + signature: Buffer.from([0xab, 0xcd]), + value: 'Debbie' + }; + let encoded = PleaseChoose.encode(obj).finish(); + let decoded = PleaseChoose.decode(encoded); + expect(decoded).toEqual(obj); + }); +}); diff --git a/integration/oneof-unions-value/oneof.proto b/integration/oneof-unions-value/oneof.proto new file mode 100644 index 000000000..57ace0b17 --- /dev/null +++ b/integration/oneof-unions-value/oneof.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; +package oneof; + +import "google/protobuf/struct.proto"; + +message PleaseChoose { + + string name = 1; + + message Submessage { + string name = 1; + } + + enum StateEnum { + UNKNOWN = 0; + ON = 2; + OFF = 3; + } + + // Please to be choosing one of the fields within this oneof clause. + // This text exists to ensure we transpose comments correctly. + oneof choice { + + // Use this if you want a number. Numbers are great. Who doesn't + // like them? + double a_number = 2; + + // Use this if you want a string. Strings are also nice. Not as + // nice as numbers, but what are you going to do... + string a_string = 3; + + Submessage a_message = 4; + + // We also added a bool option! This was added after the 'age' + // field, so it has a higher number. + bool a_bool = 6; + + bytes buncha_bytes = 10; + + StateEnum anEnum = 11; + } + + uint32 age = 5; + + oneof either_or { + string either = 7; + string or = 8; + string third_option = 9; + } + + bytes signature = 12; + + google.protobuf.Value value = 13; +} + +/** For testing proto3's field presence feature. */ +message SimpleButOptional { + optional string name = 1; + optional int32 age = 2; +} diff --git a/integration/oneof-unions-value/oneof.ts b/integration/oneof-unions-value/oneof.ts new file mode 100644 index 000000000..79dbe1a14 --- /dev/null +++ b/integration/oneof-unions-value/oneof.ts @@ -0,0 +1,527 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// source: oneof.proto + +/* eslint-disable */ +import * as _m0 from "protobufjs/minimal"; +import { Value } from "./google/protobuf/struct"; + +export const protobufPackage = "oneof"; + +export interface PleaseChoose { + name: string; + choice?: + | { $case: "aNumber"; value: number } + | { $case: "aString"; value: string } + | { $case: "aMessage"; value: PleaseChoose_Submessage } + | { $case: "aBool"; value: boolean } + | { $case: "bunchaBytes"; value: Uint8Array } + | { $case: "anEnum"; value: PleaseChoose_StateEnum } + | undefined; + age: number; + eitherOr?: { $case: "either"; value: string } | { $case: "or"; value: string } | { + $case: "thirdOption"; + value: string; + } | undefined; + signature: Uint8Array; + value: any | undefined; +} + +export enum PleaseChoose_StateEnum { + UNKNOWN = 0, + ON = 2, + OFF = 3, + UNRECOGNIZED = -1, +} + +export function pleaseChoose_StateEnumFromJSON(object: any): PleaseChoose_StateEnum { + switch (object) { + case 0: + case "UNKNOWN": + return PleaseChoose_StateEnum.UNKNOWN; + case 2: + case "ON": + return PleaseChoose_StateEnum.ON; + case 3: + case "OFF": + return PleaseChoose_StateEnum.OFF; + case -1: + case "UNRECOGNIZED": + default: + return PleaseChoose_StateEnum.UNRECOGNIZED; + } +} + +export function pleaseChoose_StateEnumToJSON(object: PleaseChoose_StateEnum): string { + switch (object) { + case PleaseChoose_StateEnum.UNKNOWN: + return "UNKNOWN"; + case PleaseChoose_StateEnum.ON: + return "ON"; + case PleaseChoose_StateEnum.OFF: + return "OFF"; + case PleaseChoose_StateEnum.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +export interface PleaseChoose_Submessage { + name: string; +} + +/** For testing proto3's field presence feature. */ +export interface SimpleButOptional { + name?: string | undefined; + age?: number | undefined; +} + +function createBasePleaseChoose(): PleaseChoose { + return { name: "", choice: undefined, age: 0, eitherOr: undefined, signature: new Uint8Array(0), value: undefined }; +} + +export const PleaseChoose = { + encode(message: PleaseChoose, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + switch (message.choice?.$case) { + case "aNumber": + writer.uint32(17).double(message.choice.value); + break; + case "aString": + writer.uint32(26).string(message.choice.value); + break; + case "aMessage": + PleaseChoose_Submessage.encode(message.choice.value, writer.uint32(34).fork()).ldelim(); + break; + case "aBool": + writer.uint32(48).bool(message.choice.value); + break; + case "bunchaBytes": + writer.uint32(82).bytes(message.choice.value); + break; + case "anEnum": + writer.uint32(88).int32(message.choice.value); + break; + } + if (message.age !== 0) { + writer.uint32(40).uint32(message.age); + } + switch (message.eitherOr?.$case) { + case "either": + writer.uint32(58).string(message.eitherOr.value); + break; + case "or": + writer.uint32(66).string(message.eitherOr.value); + break; + case "thirdOption": + writer.uint32(74).string(message.eitherOr.value); + break; + } + if (message.signature.length !== 0) { + writer.uint32(98).bytes(message.signature); + } + if (message.value !== undefined) { + Value.encode(Value.wrap(message.value), writer.uint32(106).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): PleaseChoose { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePleaseChoose(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + case 2: + if (tag !== 17) { + break; + } + + message.choice = { $case: "aNumber", value: reader.double() }; + continue; + case 3: + if (tag !== 26) { + break; + } + + message.choice = { $case: "aString", value: reader.string() }; + continue; + case 4: + if (tag !== 34) { + break; + } + + message.choice = { $case: "aMessage", value: PleaseChoose_Submessage.decode(reader, reader.uint32()) }; + continue; + case 6: + if (tag !== 48) { + break; + } + + message.choice = { $case: "aBool", value: reader.bool() }; + continue; + case 10: + if (tag !== 82) { + break; + } + + message.choice = { $case: "bunchaBytes", value: reader.bytes() }; + continue; + case 11: + if (tag !== 88) { + break; + } + + message.choice = { $case: "anEnum", value: reader.int32() as any }; + continue; + case 5: + if (tag !== 40) { + break; + } + + message.age = reader.uint32(); + continue; + case 7: + if (tag !== 58) { + break; + } + + message.eitherOr = { $case: "either", value: reader.string() }; + continue; + case 8: + if (tag !== 66) { + break; + } + + message.eitherOr = { $case: "or", value: reader.string() }; + continue; + case 9: + if (tag !== 74) { + break; + } + + message.eitherOr = { $case: "thirdOption", value: reader.string() }; + continue; + case 12: + if (tag !== 98) { + break; + } + + message.signature = reader.bytes(); + continue; + case 13: + if (tag !== 106) { + break; + } + + message.value = Value.unwrap(Value.decode(reader, reader.uint32())); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): PleaseChoose { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + choice: isSet(object.aNumber) + ? { $case: "aNumber", value: globalThis.Number(object.aNumber) } + : isSet(object.aString) + ? { $case: "aString", value: globalThis.String(object.aString) } + : isSet(object.aMessage) + ? { $case: "aMessage", value: PleaseChoose_Submessage.fromJSON(object.aMessage) } + : isSet(object.aBool) + ? { $case: "aBool", value: globalThis.Boolean(object.aBool) } + : isSet(object.bunchaBytes) + ? { $case: "bunchaBytes", value: bytesFromBase64(object.bunchaBytes) } + : isSet(object.anEnum) + ? { $case: "anEnum", value: pleaseChoose_StateEnumFromJSON(object.anEnum) } + : undefined, + age: isSet(object.age) ? globalThis.Number(object.age) : 0, + eitherOr: isSet(object.either) + ? { $case: "either", value: globalThis.String(object.either) } + : isSet(object.or) + ? { $case: "or", value: globalThis.String(object.or) } + : isSet(object.thirdOption) + ? { $case: "thirdOption", value: globalThis.String(object.thirdOption) } + : undefined, + signature: isSet(object.signature) ? bytesFromBase64(object.signature) : new Uint8Array(0), + value: isSet(object?.value) ? object.value : undefined, + }; + }, + + toJSON(message: PleaseChoose): unknown { + const obj: any = {}; + if (message.name !== "") { + obj.name = message.name; + } + if (message.choice?.$case === "aNumber") { + obj.aNumber = message.choice.value; + } + if (message.choice?.$case === "aString") { + obj.aString = message.choice.value; + } + if (message.choice?.$case === "aMessage") { + obj.aMessage = PleaseChoose_Submessage.toJSON(message.choice.value); + } + if (message.choice?.$case === "aBool") { + obj.aBool = message.choice.value; + } + if (message.choice?.$case === "bunchaBytes") { + obj.bunchaBytes = base64FromBytes(message.choice.value); + } + if (message.choice?.$case === "anEnum") { + obj.anEnum = pleaseChoose_StateEnumToJSON(message.choice.value); + } + if (message.age !== 0) { + obj.age = Math.round(message.age); + } + if (message.eitherOr?.$case === "either") { + obj.either = message.eitherOr.value; + } + if (message.eitherOr?.$case === "or") { + obj.or = message.eitherOr.value; + } + if (message.eitherOr?.$case === "thirdOption") { + obj.thirdOption = message.eitherOr.value; + } + if (message.signature.length !== 0) { + obj.signature = base64FromBytes(message.signature); + } + if (message.value !== undefined) { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): PleaseChoose { + return PleaseChoose.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): PleaseChoose { + const message = createBasePleaseChoose(); + message.name = object.name ?? ""; + if (object.choice?.$case === "aNumber" && object.choice?.value !== undefined && object.choice?.value !== null) { + message.choice = { $case: "aNumber", value: object.choice.value }; + } + if (object.choice?.$case === "aString" && object.choice?.value !== undefined && object.choice?.value !== null) { + message.choice = { $case: "aString", value: object.choice.value }; + } + if (object.choice?.$case === "aMessage" && object.choice?.value !== undefined && object.choice?.value !== null) { + message.choice = { $case: "aMessage", value: PleaseChoose_Submessage.fromPartial(object.choice.value) }; + } + if (object.choice?.$case === "aBool" && object.choice?.value !== undefined && object.choice?.value !== null) { + message.choice = { $case: "aBool", value: object.choice.value }; + } + if (object.choice?.$case === "bunchaBytes" && object.choice?.value !== undefined && object.choice?.value !== null) { + message.choice = { $case: "bunchaBytes", value: object.choice.value }; + } + if (object.choice?.$case === "anEnum" && object.choice?.value !== undefined && object.choice?.value !== null) { + message.choice = { $case: "anEnum", value: object.choice.value }; + } + message.age = object.age ?? 0; + if ( + object.eitherOr?.$case === "either" && object.eitherOr?.value !== undefined && object.eitherOr?.value !== null + ) { + message.eitherOr = { $case: "either", value: object.eitherOr.value }; + } + if (object.eitherOr?.$case === "or" && object.eitherOr?.value !== undefined && object.eitherOr?.value !== null) { + message.eitherOr = { $case: "or", value: object.eitherOr.value }; + } + if ( + object.eitherOr?.$case === "thirdOption" && + object.eitherOr?.value !== undefined && + object.eitherOr?.value !== null + ) { + message.eitherOr = { $case: "thirdOption", value: object.eitherOr.value }; + } + message.signature = object.signature ?? new Uint8Array(0); + message.value = object.value ?? undefined; + return message; + }, +}; + +function createBasePleaseChoose_Submessage(): PleaseChoose_Submessage { + return { name: "" }; +} + +export const PleaseChoose_Submessage = { + encode(message: PleaseChoose_Submessage, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): PleaseChoose_Submessage { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePleaseChoose_Submessage(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): PleaseChoose_Submessage { + return { name: isSet(object.name) ? globalThis.String(object.name) : "" }; + }, + + toJSON(message: PleaseChoose_Submessage): unknown { + const obj: any = {}; + if (message.name !== "") { + obj.name = message.name; + } + return obj; + }, + + create, I>>(base?: I): PleaseChoose_Submessage { + return PleaseChoose_Submessage.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): PleaseChoose_Submessage { + const message = createBasePleaseChoose_Submessage(); + message.name = object.name ?? ""; + return message; + }, +}; + +function createBaseSimpleButOptional(): SimpleButOptional { + return { name: undefined, age: undefined }; +} + +export const SimpleButOptional = { + encode(message: SimpleButOptional, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.name !== undefined) { + writer.uint32(10).string(message.name); + } + if (message.age !== undefined) { + writer.uint32(16).int32(message.age); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): SimpleButOptional { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseSimpleButOptional(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + case 2: + if (tag !== 16) { + break; + } + + message.age = reader.int32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): SimpleButOptional { + return { + name: isSet(object.name) ? globalThis.String(object.name) : undefined, + age: isSet(object.age) ? globalThis.Number(object.age) : undefined, + }; + }, + + toJSON(message: SimpleButOptional): unknown { + const obj: any = {}; + if (message.name !== undefined) { + obj.name = message.name; + } + if (message.age !== undefined) { + obj.age = Math.round(message.age); + } + return obj; + }, + + create, I>>(base?: I): SimpleButOptional { + return SimpleButOptional.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SimpleButOptional { + const message = createBaseSimpleButOptional(); + message.name = object.name ?? undefined; + message.age = object.age ?? undefined; + return message; + }, +}; + +function bytesFromBase64(b64: string): Uint8Array { + if ((globalThis as any).Buffer) { + return Uint8Array.from(globalThis.Buffer.from(b64, "base64")); + } else { + const bin = globalThis.atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + arr[i] = bin.charCodeAt(i); + } + return arr; + } +} + +function base64FromBytes(arr: Uint8Array): string { + if ((globalThis as any).Buffer) { + return globalThis.Buffer.from(arr).toString("base64"); + } else { + const bin: string[] = []; + arr.forEach((byte) => { + bin.push(globalThis.String.fromCharCode(byte)); + }); + return globalThis.btoa(bin.join("")); + } +} + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends { $case: string; value: unknown } ? { $case: T["$case"]; value?: DeepPartial } + : 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 } & { [K in Exclude>]: never }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} diff --git a/integration/oneof-unions-value/parameters.txt b/integration/oneof-unions-value/parameters.txt new file mode 100644 index 000000000..8aab1678b --- /dev/null +++ b/integration/oneof-unions-value/parameters.txt @@ -0,0 +1 @@ +oneof=unions-value diff --git a/integration/oneof-unions-value/simple-but-optional-test.ts b/integration/oneof-unions-value/simple-but-optional-test.ts new file mode 100644 index 000000000..27ebdaa1d --- /dev/null +++ b/integration/oneof-unions-value/simple-but-optional-test.ts @@ -0,0 +1,47 @@ +import { SimpleButOptional } from "./oneof"; + +describe("simple-but-optional", () => { + it("can encode", () => { + const s1: SimpleButOptional = { + name: "Joe", + age: 17, + }; + + const mockWriter = { + uint32: jest.fn().mockImplementation(function (this: any) { + return this; + }), + string: jest.fn(), + int32: jest.fn(), + fork: jest.fn(), + }; + SimpleButOptional.encode(s1, mockWriter as any); + + expect(mockWriter.string).toHaveBeenCalledWith("Joe"); + expect(mockWriter.int32).toHaveBeenCalledWith(17); + }); + + it("can encode to json", () => { + const s1: SimpleButOptional = { + name: "", + age: 0, + }; + + expect(SimpleButOptional.toJSON(s1)).toMatchInlineSnapshot(` + { + "age": 0, + "name": "", + } + `); + }); + + it("has optional-by-default keys", () => { + // usually leaving off age requires useOptionals + const s1: SimpleButOptional = { name: "" }; + expect(SimpleButOptional.toJSON(s1)).toMatchInlineSnapshot(` + { + "name": "", + } + `); + }); +}); diff --git a/integration/pbjs.sh b/integration/pbjs.sh index d6051ec80..05723df94 100755 --- a/integration/pbjs.sh +++ b/integration/pbjs.sh @@ -100,6 +100,11 @@ if match "oneof-unions"; then yarn run pbts --no-comments -o integration/oneof-unions/pbjs.d.ts integration/oneof-unions/pbjs.js fi +if match "oneof-unions-value"; then + yarn run pbjs --force-message --force-number -t static-module -o integration/oneof-unions-value/pbjs.js integration/oneof-unions-value/oneof.proto + yarn run pbts --no-comments -o integration/oneof-unions-value/pbjs.d.ts integration/oneof-unions-value/pbjs.js +fi + if match "struct"; then yarn run pbjs --force-message --force-number -t static-module -o integration/struct/pbjs.js integration/struct/struct.proto yarn run pbts --no-comments -o integration/struct/pbjs.d.ts integration/struct/pbjs.js diff --git a/src/generate-struct-wrappers.ts b/src/generate-struct-wrappers.ts index b08e08459..465af21cd 100644 --- a/src/generate-struct-wrappers.ts +++ b/src/generate-struct-wrappers.ts @@ -212,6 +212,26 @@ export function generateWrapShallow(ctx: Context, fullProtoTypeName: string, fie } return result; }`); + } else if (ctx.options.oneof === OneofOption.UNIONS_VALUE) { + chunks.push(code`wrap(value: any): Value { + const result = createBaseValue()${maybeAsAny(ctx.options)}; + if (value === null) { + result.kind = {$case: '${fieldNames.nullValue}', value }; + } else if (typeof value === 'boolean') { + result.kind = {$case: '${fieldNames.boolValue}', value }; + } else if (typeof value === 'number') { + result.kind = {$case: '${fieldNames.numberValue}', value }; + } else if (typeof value === 'string') { + result.kind = {$case: '${fieldNames.stringValue}', value }; + } else if (${ctx.utils.globalThis}.Array.isArray(value)) { + result.kind = {$case: '${fieldNames.listValue}', value }; + } else if (typeof value === 'object') { + result.kind = {$case: '${fieldNames.structValue}', value }; + } else if (typeof value !== 'undefined') { + throw new ${ctx.utils.globalThis}.Error('Unsupported any value type: ' + typeof value); + } + return result; + }`); } else { chunks.push(code`wrap(value: any): Value { const result = createBaseValue()${maybeAsAny(ctx.options)}; @@ -305,6 +325,10 @@ export function generateUnwrapShallow(ctx: Context, fullProtoTypeName: string, f return undefined; } }`); + } else if (ctx.options.oneof === OneofOption.UNIONS_VALUE) { + chunks.push(code`unwrap(message: Value): string | number | boolean | Object | null | Array | undefined { + return message.kind?.value; + }`); } else { chunks.push(code`unwrap(message: any): string | number | boolean | Object | null | Array | undefined { if (message.${fieldNames.stringValue} !== undefined) { diff --git a/src/main.ts b/src/main.ts index c18ac67e5..3cbe7ea0e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -101,6 +101,7 @@ import { maybeAddComment, maybePrefixPackage, nullOrUndefined, + oneofValueName, safeAccessor, withAndMaybeCheckIsNotNull, withOrMaybeCheckIsNotNull, @@ -645,6 +646,11 @@ function makeDeepPartial(options: Options, longs: ReturnType]?: DeepPartial } & { ${maybeReadonly(options)}$case: T['$case'] } `; + } else if (options.oneof === OneofOption.UNIONS_VALUE) { + oneofCase = ` + : T extends { ${maybeReadonly(options)}$case: string; value: unknown; } + ? { ${maybeReadonly(options)}$case: T['$case']; value?: DeepPartial; } + `; } const maybeExport = options.exportCommonSymbols ? "export" : ""; @@ -1010,7 +1016,8 @@ function generateOneofProperty( fields.map((f) => { let fieldName = maybeSnakeToCamel(f.name, options); let typeName = toTypeName(ctx, messageDesc, f); - return code`{ ${mbReadonly}$case: '${fieldName}', ${mbReadonly}${fieldName}: ${typeName} }`; + let valueName = oneofValueName(fieldName, options); + return code`{ ${mbReadonly}$case: '${fieldName}', ${mbReadonly}${valueName}: ${typeName} }`; }), { on: " | " }, ); @@ -1311,9 +1318,10 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP const oneofNameWithMessage = options.useJsonName ? messageProperty : getPropertyAccessor("message", maybeSnakeToCamel(messageDesc.oneofDecl[field.oneofIndex].name, options)); + const valueName = oneofValueName(fieldName, options); chunks.push(code` ${tagCheck} - ${oneofNameWithMessage} = { $case: '${fieldName}', ${fieldName}: ${readSnippet} }; + ${oneofNameWithMessage} = { $case: '${fieldName}', ${valueName}: ${readSnippet} }; `); } else { chunks.push(code` @@ -1617,8 +1625,9 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP for (const oneOfField of oneOfFieldsDict[field.oneofIndex]) { const writeSnippet = getEncodeWriteSnippet(ctx, oneOfField); const oneOfFieldName = maybeSnakeToCamel(oneOfField.name, ctx.options); + const valueName = oneofValueName(oneOfFieldName, ctx.options); chunks.push(code`case "${oneOfFieldName}": - ${writeSnippet(`${oneofNameWithMessage}.${oneOfFieldName}`)}; + ${writeSnippet(`${oneofNameWithMessage}.${valueName}`)}; break;`); } chunks.push(code`}`); @@ -2127,8 +2136,9 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, chunks.push(code`${fieldName}: `); } + const valueName = oneofValueName(fieldKey, options); const ternaryIf = code`${ctx.utils.isSet}(${jsonProperty})`; - const ternaryThen = code`{ $case: '${fieldName}', ${fieldKey}: ${readSnippet(`${jsonProperty}`)}`; + const ternaryThen = code`{ $case: '${fieldName}', ${valueName}: ${readSnippet(`${jsonProperty}`)}`; chunks.push(code`${ternaryIf} ? ${ternaryThen}} : `); if (field === lastCase) { @@ -2334,9 +2344,10 @@ function generateToJson( const oneofNameWithMessage = options.useJsonName ? messageProperty : getPropertyAccessor("message", maybeSnakeToCamel(messageDesc.oneofDecl[field.oneofIndex].name, options)); + const valueName = oneofValueName(fieldName, options); chunks.push(code` if (${oneofNameWithMessage}?.$case === '${fieldName}') { - ${jsonProperty} = ${readSnippet(`${oneofNameWithMessage}.${fieldName}`)}; + ${jsonProperty} = ${readSnippet(`${oneofNameWithMessage}.${valueName}`)}; } `); } else { @@ -2511,14 +2522,15 @@ function generateFromPartial(ctx: Context, fullName: string, messageDesc: Descri const oneofName = maybeSnakeToCamel(messageDesc.oneofDecl[field.oneofIndex].name, options); const oneofNameWithMessage = getPropertyAccessor("message", oneofName); const oneofNameWithObject = getPropertyAccessor("object", oneofName); - const v = readSnippet(`${oneofNameWithObject}.${fieldName}`); + const valueName = oneofValueName(fieldName, options); + const v = readSnippet(`${oneofNameWithObject}.${valueName}`); chunks.push(code` if ( ${oneofNameWithObject}?.$case === '${fieldName}' - && ${oneofNameWithObject}?.${fieldName} !== undefined - && ${oneofNameWithObject}?.${fieldName} !== null + && ${oneofNameWithObject}?.${valueName} !== undefined + && ${oneofNameWithObject}?.${valueName} !== null ) { - ${oneofNameWithMessage} = { $case: '${fieldName}', ${fieldName}: ${v} }; + ${oneofNameWithMessage} = { $case: '${fieldName}', ${valueName}: ${v} }; } `); } else if (readSnippet(`x`).toCodeString([]) == "x") { diff --git a/src/options.ts b/src/options.ts index 1ca9afda1..89b653046 100644 --- a/src/options.ts +++ b/src/options.ts @@ -28,6 +28,7 @@ export enum EnvOption { export enum OneofOption { PROPERTIES = "properties", UNIONS = "unions", + UNIONS_VALUE = "unions-value", } export enum ServiceOption { diff --git a/src/types.ts b/src/types.ts index fbbadd150..c15f05c5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -462,7 +462,11 @@ export function isWithinOneOf(field: FieldDescriptorProto): boolean { } export function isWithinOneOfThatShouldBeUnion(options: Options, field: FieldDescriptorProto): boolean { - return isWithinOneOf(field) && options.oneof === OneofOption.UNIONS && !field.proto3Optional; + return ( + isWithinOneOf(field) && + (options.oneof === OneofOption.UNIONS || options.oneof === OneofOption.UNIONS_VALUE) && + !field.proto3Optional + ); } export function isRepeated(field: FieldDescriptorProto): boolean { diff --git a/src/utils.ts b/src/utils.ts index 7f87dd318..cfc2afcdc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,7 +9,7 @@ import { } from "ts-proto-descriptors"; import ReadStream = NodeJS.ReadStream; import { SourceDescription } from "./sourceInfo"; -import { Options, ServiceOption } from "./options"; +import { OneofOption, Options, ServiceOption } from "./options"; import { camelCaseGrpc, maybeSnakeToCamel, snakeToCamel } from "./case"; export function protoFilesToGenerate(request: CodeGeneratorRequest): FileDescriptorProto[] { @@ -307,6 +307,10 @@ export function maybeCheckIsNull(options: Pick, ty return options.useNullAsOptional ? ` ${prefix} ${typeName} === null` : ""; } +export function oneofValueName(fieldName: string, options: Pick) { + return options.oneof === OneofOption.UNIONS ? fieldName : "value"; +} + export function withOrMaybeCheckIsNotNull(options: Pick, typeName: string) { return maybeCheckIsNotNull(options, typeName, "||"); }