From 573f63e761beef3981539cfbe29f786374186923 Mon Sep 17 00:00:00 2001 From: Mohsen Rahmani Date: Sat, 30 Mar 2024 15:57:23 -0700 Subject: [PATCH] feat: added useNullAsOptional option (#1017) Changes: - with this flag, undefined types will be replaced with null. - fields with optional label in proto files, will implicitly accept undefined too. this feature is needed when we wanna have better type alignment with ORMs ( drizzle, typeorm, prisma ) or other services such as Firestore, since they mostly ignore `undefined` in their types. Note: @stephenh as you wanted I made simple, small changes, this works well for my `nestjs` project, I'm not very familiar with other implementations and frameworks, please check it out and let me know what you think. if any changes are required please let me know, I will work them out. thanks for this amazing library :pray: closes #869 --- README.markdown | 52 +++ .../use-null-as-optional/parameters.txt | 1 + .../use-null-as-optional.bin | Bin 0 -> 989 bytes .../use-null-as-optional.proto | 23 ++ .../use-null-as-optional.ts | 297 ++++++++++++++++++ .../google/protobuf/empty.ts | 19 ++ .../use-null-nestjs-simple/parameters.txt | 1 + integration/use-null-nestjs-simple/user.bin | Bin 0 -> 5051 bytes integration/use-null-nestjs-simple/user.proto | 54 ++++ integration/use-null-nestjs-simple/user.ts | 71 +++++ src/main.ts | 54 ++-- src/options.ts | 2 + src/types.ts | 19 +- src/utils.ts | 23 ++ tests/options-test.ts | 9 + tests/types-test.ts | 7 + 16 files changed, 604 insertions(+), 28 deletions(-) create mode 100644 integration/use-null-as-optional/parameters.txt create mode 100644 integration/use-null-as-optional/use-null-as-optional.bin create mode 100644 integration/use-null-as-optional/use-null-as-optional.proto create mode 100644 integration/use-null-as-optional/use-null-as-optional.ts create mode 100644 integration/use-null-nestjs-simple/google/protobuf/empty.ts create mode 100644 integration/use-null-nestjs-simple/parameters.txt create mode 100644 integration/use-null-nestjs-simple/user.bin create mode 100644 integration/use-null-nestjs-simple/user.proto create mode 100644 integration/use-null-nestjs-simple/user.ts diff --git a/README.markdown b/README.markdown index 7e811efe7..1b7e8bfc2 100644 --- a/README.markdown +++ b/README.markdown @@ -547,6 +547,58 @@ Generated code will be placed in the Gradle build directory. - With `--ts_proto_opt=comments=false`, comments won't be copied from the proto files to the generated code. +- With `--ts_proto_opt=useNullAsOptional=true`, `undefined` values will be converted to `null`, and if you use `optional` label in your `.proto` file, the field will have `undefined` type as well. for example: + +```protobuf +message ProfileInfo { + int32 id = 1; + string bio = 2; + string phone = 3; +} + +message Department { + int32 id = 1; + string name = 2; +} + +message User { + int32 id = 1; + string username = 2; + /* + ProfileInfo will be optional in typescript, the type will be ProfileInfo | null | undefined + this is needed in cases where you don't wanna provide any value for the profile. + */ + optional ProfileInfo profile = 3; + + /* + Department only accepts a Department type or null, so this means you have to pass it null if there is no value available. + */ + Department department = 4; +} +``` + +the generated interfaces will be: + +```typescript +export interface ProfileInfo { + id: number; + bio: string; + phone: string; +} + +export interface Department { + id: number; + name: string; +} + +export interface User { + id: number; + username: string; + profile?: ProfileInfo | null | undefined; // check this one + department: Department | null; // check this one +} +``` + ### NestJS Support We have a great way of working together with [nestjs](https://docs.nestjs.com/microservices/grpc). `ts-proto` generates `interfaces` and `decorators` for you controller, client. For more information see the [nestjs readme](NESTJS.markdown). diff --git a/integration/use-null-as-optional/parameters.txt b/integration/use-null-as-optional/parameters.txt new file mode 100644 index 000000000..9fde71303 --- /dev/null +++ b/integration/use-null-as-optional/parameters.txt @@ -0,0 +1 @@ +useNullAsOptional=true \ No newline at end of file diff --git a/integration/use-null-as-optional/use-null-as-optional.bin b/integration/use-null-as-optional/use-null-as-optional.bin new file mode 100644 index 0000000000000000000000000000000000000000..c3036fb4b42592c89b41d9943f5ea6e02dc117ab GIT binary patch literal 989 zcmaKqL2uJQ5QTTwYkMYV(N@sG%`UQo^Z%4OY(edH@UcUKn;ZE727kuaZgh+jI z`T}F@_>*sEt3M+gf1v2JY|Q(z`C5H0H5Ptd)vNQmw72@fUuO1J`N2Pm1$*;TQlKSc z`CXY+)j$ceme;OS?p4PY;Djj27)Y}WNsS555*HF6Vg)m zakvlxDC#ZkfZ@Sm-wBi9iva)c&OHAGkUojG literal 0 HcmV?d00001 diff --git a/integration/use-null-as-optional/use-null-as-optional.proto b/integration/use-null-as-optional/use-null-as-optional.proto new file mode 100644 index 000000000..07b8eaa16 --- /dev/null +++ b/integration/use-null-as-optional/use-null-as-optional.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package useNullAsOptional; + +message ProfileInfo { + int32 id = 1; + string bio = 2; + string phone = 3; +} + +message User { + int32 id = 1; + string username = 2; + optional ProfileInfo profile = 3; +} + +message UserById { + int32 id = 1; +} + +service HeroService { + rpc FindOneHero (UserById) returns (User) {} +} diff --git a/integration/use-null-as-optional/use-null-as-optional.ts b/integration/use-null-as-optional/use-null-as-optional.ts new file mode 100644 index 000000000..999350987 --- /dev/null +++ b/integration/use-null-as-optional/use-null-as-optional.ts @@ -0,0 +1,297 @@ +/* eslint-disable */ +import * as _m0 from "protobufjs/minimal"; + +export const protobufPackage = "useNullAsOptional"; + +export interface ProfileInfo { + id: number; + bio: string; + phone: string; +} + +export interface User { + id: number; + username: string; + profile?: ProfileInfo | null | undefined; +} + +export interface UserById { + id: number; +} + +function createBaseProfileInfo(): ProfileInfo { + return { id: 0, bio: "", phone: "" }; +} + +export const ProfileInfo = { + encode(message: ProfileInfo, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== 0) { + writer.uint32(8).int32(message.id); + } + if (message.bio !== "") { + writer.uint32(18).string(message.bio); + } + if (message.phone !== "") { + writer.uint32(26).string(message.phone); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProfileInfo { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProfileInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.id = reader.int32(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.bio = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.phone = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProfileInfo { + return { + id: isSet(object.id) ? globalThis.Number(object.id) : 0, + bio: isSet(object.bio) ? globalThis.String(object.bio) : "", + phone: isSet(object.phone) ? globalThis.String(object.phone) : "", + }; + }, + + toJSON(message: ProfileInfo): unknown { + const obj: any = {}; + if (message.id !== 0) { + obj.id = Math.round(message.id); + } + if (message.bio !== "") { + obj.bio = message.bio; + } + if (message.phone !== "") { + obj.phone = message.phone; + } + return obj; + }, + + create, I>>(base?: I): ProfileInfo { + return ProfileInfo.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ProfileInfo { + const message = createBaseProfileInfo(); + message.id = object.id ?? 0; + message.bio = object.bio ?? ""; + message.phone = object.phone ?? ""; + return message; + }, +}; + +function createBaseUser(): User { + return { id: 0, username: "", profile: null }; +} + +export const User = { + encode(message: User, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== 0) { + writer.uint32(8).int32(message.id); + } + if (message.username !== "") { + writer.uint32(18).string(message.username); + } + if (message.profile !== undefined && message.profile !== null) { + ProfileInfo.encode(message.profile, writer.uint32(26).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): User { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUser(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.id = reader.int32(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.username = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.profile = ProfileInfo.decode(reader, reader.uint32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): User { + return { + id: isSet(object.id) ? globalThis.Number(object.id) : 0, + username: isSet(object.username) ? globalThis.String(object.username) : "", + profile: isSet(object.profile) ? ProfileInfo.fromJSON(object.profile) : null, + }; + }, + + toJSON(message: User): unknown { + const obj: any = {}; + if (message.id !== 0) { + obj.id = Math.round(message.id); + } + if (message.username !== "") { + obj.username = message.username; + } + if (message.profile !== undefined && message.profile !== null) { + obj.profile = ProfileInfo.toJSON(message.profile); + } + return obj; + }, + + create, I>>(base?: I): User { + return User.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): User { + const message = createBaseUser(); + message.id = object.id ?? 0; + message.username = object.username ?? ""; + message.profile = (object.profile !== undefined && object.profile !== null) + ? ProfileInfo.fromPartial(object.profile) + : undefined; + return message; + }, +}; + +function createBaseUserById(): UserById { + return { id: 0 }; +} + +export const UserById = { + encode(message: UserById, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== 0) { + writer.uint32(8).int32(message.id); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): UserById { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUserById(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.id = reader.int32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): UserById { + return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 }; + }, + + toJSON(message: UserById): unknown { + const obj: any = {}; + if (message.id !== 0) { + obj.id = Math.round(message.id); + } + return obj; + }, + + create, I>>(base?: I): UserById { + return UserById.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UserById { + const message = createBaseUserById(); + message.id = object.id ?? 0; + return message; + }, +}; + +export interface HeroService { + FindOneHero(request: UserById): Promise; +} + +export const HeroServiceServiceName = "useNullAsOptional.HeroService"; +export class HeroServiceClientImpl implements HeroService { + private readonly rpc: Rpc; + private readonly service: string; + constructor(rpc: Rpc, opts?: { service?: string }) { + this.service = opts?.service || HeroServiceServiceName; + this.rpc = rpc; + this.FindOneHero = this.FindOneHero.bind(this); + } + FindOneHero(request: UserById): Promise { + const data = UserById.encode(request).finish(); + const promise = this.rpc.request(this.service, "FindOneHero", data); + return promise.then((data) => User.decode(_m0.Reader.create(data))); + } +} + +interface Rpc { + request(service: string, method: string, data: Uint8Array): Promise; +} + +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 {} ? { [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/use-null-nestjs-simple/google/protobuf/empty.ts b/integration/use-null-nestjs-simple/google/protobuf/empty.ts new file mode 100644 index 000000000..642a965dc --- /dev/null +++ b/integration/use-null-nestjs-simple/google/protobuf/empty.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ + +export const protobufPackage = "google.protobuf"; + +/** + * A generic empty message that you can re-use to avoid defining duplicated + * empty messages in your APIs. A typical example is to use it as the request + * or the response type of an API method. For instance: + * + * service Foo { + * rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty); + * } + * + * The JSON representation for `Empty` is empty JSON object `{}`. + */ +export interface Empty { +} + +export const GOOGLE_PROTOBUF_PACKAGE_NAME = "google.protobuf"; diff --git a/integration/use-null-nestjs-simple/parameters.txt b/integration/use-null-nestjs-simple/parameters.txt new file mode 100644 index 000000000..68f2cc820 --- /dev/null +++ b/integration/use-null-nestjs-simple/parameters.txt @@ -0,0 +1 @@ +nestJs=true,globalThisPolyfill=true,useNullAsOptional=true diff --git a/integration/use-null-nestjs-simple/user.bin b/integration/use-null-nestjs-simple/user.bin new file mode 100644 index 0000000000000000000000000000000000000000..bd75d84307553b7f848ae61bd660a6013fac26df GIT binary patch literal 5051 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 I6eH$;0qVp!1poj5 literal 0 HcmV?d00001 diff --git a/integration/use-null-nestjs-simple/user.proto b/integration/use-null-nestjs-simple/user.proto new file mode 100644 index 000000000..c53da53d3 --- /dev/null +++ b/integration/use-null-nestjs-simple/user.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +package user; + + +/** + Most ORMs ignore the `undefined` value, either we have the value from DB or it's null. + with this flag you can have better alignment with your ORM ( drizzle, TypeOrm, PrismaJS, etc) Entities. + Or when you wanna share the grpc interfaces with NextJS `getStaticProps` and `getServerSideProps` in which they won't accept `undefined` ( it will cause runtime error ) + please check https://sdorra.dev/posts/2023-03-20-typescript-undefined-to-null for more info + let's say on querying the database with relations, there might be a value for the relation or not (it's null). + with this flag the generated interfaces will be aligned with it. + now let's say you have a grpc method which might return user's profile as well or not ( depending on querying the relation or not for example ) + with `optional` modifier you can replicate this as well. the optional field will have `ProfileInfo | null | undefined` +**/ + +service UserService { + rpc AddOneUser (User) returns (google.protobuf.Empty) {} + rpc FindOneUser (UserById) returns (User) {} + rpc FindManyUser (stream UserById) returns (stream User) {} +} + +message ProfileInfo { + int32 id = 1; + string bio = 2; + string phone = 3; +} + +message Department { + int32 id = 1; + string name = 2; +} + +message User { + int32 id = 1; + string username = 2; + /* + ProfileInfo will be optional in typescript, the type will be ProfileInfo | null | undefined + this is needed in cases where you don't wanna provide any value for the profile. + */ + optional ProfileInfo profile = 3; + + /* + Department only accepts a Department type or null, so this means you have to pass it null if there is no value available. + */ + Department department = 4; +} + +message UserById { + int32 id = 1; +} + diff --git a/integration/use-null-nestjs-simple/user.ts b/integration/use-null-nestjs-simple/user.ts new file mode 100644 index 000000000..5bd1ca30b --- /dev/null +++ b/integration/use-null-nestjs-simple/user.ts @@ -0,0 +1,71 @@ +/* eslint-disable */ +import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices"; +import { Observable } from "rxjs"; +import { Empty } from "./google/protobuf/empty"; + +export const protobufPackage = "user"; + +export interface ProfileInfo { + id: number; + bio: string; + phone: string; +} + +export interface Department { + id: number; + name: string; +} + +export interface User { + id: number; + username: string; + /** + * ProfileInfo will be optional in typescript, the type will be ProfileInfo | null | undefined + * this is needed in cases where you don't wanna provide any value for the profile. + */ + profile?: + | ProfileInfo + | null + | undefined; + /** Department only accepts a Department type or null, so this means you have to pass it null if there is no value available. */ + department: Department | null; +} + +export interface UserById { + id: number; +} + +export const USER_PACKAGE_NAME = "user"; + +export interface UserServiceClient { + addOneUser(request: User): Observable; + + findOneUser(request: UserById): Observable; + + findManyUser(request: Observable): Observable; +} + +export interface UserServiceController { + addOneUser(request: User): void; + + findOneUser(request: UserById): Promise | Observable | User; + + findManyUser(request: Observable): Observable; +} + +export function UserServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = ["addOneUser", "findOneUser"]; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcMethod("UserService", method)(constructor.prototype[method], method, descriptor); + } + const grpcStreamMethods: string[] = ["findManyUser"]; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcStreamMethod("UserService", method)(constructor.prototype[method], method, descriptor); + } + }; +} + +export const USER_SERVICE_NAME = "UserService"; diff --git a/src/main.ts b/src/main.ts index a2ad6aa87..97d7ecc93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -96,8 +96,13 @@ import { impFile, impProto, maybeAddComment, + nullOrUndefined, maybePrefixPackage, safeAccessor, + withOrMaybeCheckIsNull, + withAndMaybeCheckIsNotNull, + withOrMaybeCheckIsNotNull, + withAndMaybeCheckIsNull, } from "./utils"; import { visit, visitServices } from "./visit"; @@ -1007,7 +1012,7 @@ function generateOneofProperty( ); const name = maybeSnakeToCamel(messageDesc.oneofDecl[oneofIndex].name, options); - return code`${mbReadonly}${name}?: ${unionType} | undefined,`; + return code`${mbReadonly}${name}?: ${unionType} | ${nullOrUndefined(options)},`; /* // Ideally we'd put the comments for each oneof field next to the anonymous @@ -1053,7 +1058,7 @@ function generateBaseInstanceFactory( const name = options.useJsonName ? getFieldName(field, options) : maybeSnakeToCamel(messageDesc.oneofDecl[oneofIndex].name, ctx.options); - fields.push(code`${safeAccessor(name)}: undefined`); + fields.push(code`${safeAccessor(name)}: ${nullOrUndefined(options)}`); } continue; } @@ -1067,7 +1072,7 @@ function generateBaseInstanceFactory( const fieldKey = safeAccessor(getFieldName(field, options)); const val = isWithinOneOf(field) - ? "undefined" + ? nullOrUndefined(options) : isMapType(ctx, messageDesc, field) ? shouldGenerateJSMapType(ctx, messageDesc, field) ? "new Map()" @@ -1228,14 +1233,14 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP } const initializerSnippet = initializerNecessary ? ` - if (${messageProperty} === undefined) { + if (${messageProperty} === undefined ${withOrMaybeCheckIsNull(options, messageProperty)}) { ${messageProperty} = ${generateMapType ? "new Map()" : "{}"}; }` : ""; chunks.push(code` ${tagCheck} const ${varName} = ${readSnippet}; - if (${varName}.value !== undefined) { + if (${varName}.value !== undefined ${withAndMaybeCheckIsNotNull(options, `${varName}.value`)}) { ${initializerSnippet} ${valueSetterSnippet}; } @@ -1243,7 +1248,7 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP } else { const initializerSnippet = initializerNecessary ? ` - if (${messageProperty} === undefined) { + if (${messageProperty} === undefined ${withOrMaybeCheckIsNull(options, messageProperty)}) { ${messageProperty} = []; }` : ""; @@ -1311,7 +1316,7 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP if (!options.initializeFieldsAsUndefined) { unknownFieldsInitializerSnippet = ` - if (message._unknownFields === undefined) { + if (message._unknownFields === undefined ${withOrMaybeCheckIsNull(options, `message._unknownFields`)}) { message._unknownFields = {}; } `; @@ -1325,7 +1330,7 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP ${unknownFieldsInitializerSnippet} const list = message._unknownFields${maybeNonNullAssertion}[tag]; - if (list === undefined) { + if (list === undefined ${withOrMaybeCheckIsNull(options, `message._unknownFields`)}) { message._unknownFields${maybeNonNullAssertion}[tag] = [buf]; } else { list.push(buf); @@ -1455,7 +1460,7 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP const maybeTypeField = addTypeToMessages(options) ? `$type: '${field.typeName.slice(1)}',` : ""; const entryWriteSnippet = isValueType(ctx, valueType) ? code` - if (value !== undefined) { + if (value !== undefined ${withOrMaybeCheckIsNotNull(options, `value`)}) { ${writeSnippet(`{ ${maybeTypeField} key: key as any, value }`)}; } ` @@ -1566,7 +1571,10 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP } if (isOptional) { chunks.push(code` - if (${messageProperty} !== undefined && ${messageProperty}.length !== 0) { + if (${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull( + options, + messageProperty, + )} && ${messageProperty}.length !== 0) { ${listWriteSnippet} } `); @@ -1594,13 +1602,13 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP } else if (isWithinOneOf(field)) { // Oneofs don't have a default value check b/c they need to denote which-oneof presence chunks.push(code` - if (${messageProperty} !== undefined) { + if (${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull(options, messageProperty)}) { ${writeSnippet(`${messageProperty}`)}; } `); } else if (isMessage(field)) { chunks.push(code` - if (${messageProperty} !== undefined) { + if (${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull(options, messageProperty)}) { ${writeSnippet(`${messageProperty}`)}; } `); @@ -2035,7 +2043,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, const i = convertFromObjectKey(ctx, messageDesc, field, "key"); if (shouldGenerateJSMapType(ctx, messageDesc, field)) { - const fallback = noDefaultValue ? "undefined" : "new Map()"; + const fallback = noDefaultValue ? nullOrUndefined(options) : "new Map()"; chunks.push(code` ${fieldKey}: ${ctx.utils.isObject}(${jsonProperty}) @@ -2046,7 +2054,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, : ${fallback}, `); } else { - const fallback = noDefaultValue ? "undefined" : "{}"; + const fallback = noDefaultValue ? nullOrUndefined(options) : "{}"; chunks.push(code` ${fieldKey}: ${ctx.utils.isObject}(${jsonProperty}) @@ -2058,7 +2066,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, `); } } else { - const fallback = noDefaultValue ? "undefined" : "[]"; + const fallback = noDefaultValue ? nullOrUndefined(options) : "[]"; const readValueSnippet = readSnippet("e"); if (readValueSnippet.toString() === code`e`.toString()) { @@ -2087,27 +2095,27 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, chunks.push(code`${ternaryIf} ? ${ternaryThen}} : `); if (field === lastCase) { - chunks.push(code`undefined,`); + chunks.push(code`${nullOrUndefined(options)},`); } } else if (isAnyValueType(field)) { chunks.push(code`${fieldKey}: ${ctx.utils.isSet}(${jsonPropertyOptional}) ? ${readSnippet(`${jsonProperty}`)} - : undefined, + : ${nullOrUndefined(options)}, `); } else if (isStructType(field)) { chunks.push( code`${fieldKey}: ${ctx.utils.isObject}(${jsonProperty}) ? ${readSnippet(`${jsonProperty}`)} - : undefined,`, + : ${nullOrUndefined(options)},`, ); } else if (isListValueType(field)) { chunks.push(code` ${fieldKey}: ${ctx.utils.globalThis}.Array.isArray(${jsonProperty}) ? ${readSnippet(`${jsonProperty}`)} - : undefined, + : ${nullOrUndefined(options)}, `); } else { - const fallback = isWithinOneOf(field) || noDefaultValue ? "undefined" : defaultValue(ctx, field); + const fallback = isWithinOneOf(field) || noDefaultValue ? nullOrUndefined(options) : defaultValue(ctx, field); chunks.push(code` ${fieldKey}: ${ctx.utils.isSet}(${jsonProperty}) ? ${readSnippet(`${jsonProperty}`)} @@ -2124,10 +2132,10 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, function generateCanonicalToJson( fullName: string, fullProtobufTypeName: string, - { useOptionals }: Options, + { useOptionals, useNullAsOptional }: Options, ): Code | undefined { if (isFieldMaskTypeName(fullProtobufTypeName)) { - const returnType = useOptionals === "all" ? "string | undefined" : "string"; + const returnType = useOptionals === "all" ? `string | ${nullOrUndefined({ useNullAsOptional })}` : "string"; const pathModifier = useOptionals === "all" ? "?" : ""; return code` @@ -2289,7 +2297,7 @@ function generateToJson( const check = (isScalar(field) || isEnum(field)) && !(isWithinOneOf(field) || emitDefaultValuesForJson) ? notDefaultCheck(ctx, field, messageDesc.options, `${messageProperty}`) - : `${messageProperty} !== undefined`; + : `${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull(options, messageProperty)}`; chunks.push(code` if (${check}) { diff --git a/src/options.ts b/src/options.ts index 91d985848..b50191cef 100644 --- a/src/options.ts +++ b/src/options.ts @@ -98,6 +98,7 @@ export type Options = { comments: boolean; disableProto2Optionals: boolean; disableProto2DefaultValues: boolean; + useNullAsOptional: boolean; }; export function defaultOptions(): Options { @@ -161,6 +162,7 @@ export function defaultOptions(): Options { comments: true, disableProto2Optionals: false, disableProto2DefaultValues: false, + useNullAsOptional: false, }; } diff --git a/src/types.ts b/src/types.ts index 41d56f8dc..3d986a33c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,14 @@ import { import { code, Code, imp, Import } from "ts-poet"; import { DateOption, EnvOption, LongOption, OneofOption, Options } from "./options"; import { visit } from "./visit"; -import { fail, FormattedMethodDescriptor, impProto, maybePrefixPackage } from "./utils"; +import { + fail, + FormattedMethodDescriptor, + impProto, + nullOrUndefined, + maybePrefixPackage, + withAndMaybeCheckIsNotNull, +} from "./utils"; import SourceInfo from "./sourceInfo"; import { uncapitalize } from "./case"; import { BaseContext, Context } from "./context"; @@ -247,7 +254,7 @@ export function defaultValue(ctx: Context, field: FieldDescriptorProto): any { case FieldDescriptorProto_Type.TYPE_MESSAGE: case FieldDescriptorProto_Type.TYPE_GROUP: default: - return "undefined"; + return nullOrUndefined(options); } } @@ -260,7 +267,9 @@ export function notDefaultCheck( ): Code { const { typeMap, options, currentFile } = ctx; const isOptional = isOptionalProperty(field, messageOptions, options, currentFile.isProto3Syntax); - const maybeNotUndefinedAnd = isOptional ? `${place} !== undefined && ` : ""; + const maybeNotUndefinedAnd = isOptional + ? `${place} !== undefined ${withAndMaybeCheckIsNotNull(options, place)} &&` + : ""; switch (field.type) { case FieldDescriptorProto_Type.TYPE_DOUBLE: case FieldDescriptorProto_Type.TYPE_FLOAT: @@ -587,7 +596,7 @@ export function messageToTypeName( if (typeOptions.repeated ?? false) { return valueType; } - return code`${valueType} | undefined`; + return code`${valueType} | ${nullOrUndefined(options)}`; } // Look for other special prototypes like Timestamp that aren't technically wrapper types if (!typeOptions.keepValueType && protoType === ".google.protobuf.Timestamp") { @@ -627,7 +636,7 @@ export function toTypeName( ): Code { function finalize(type: Code, isOptional: boolean) { if (isOptional) { - return code`${type} | undefined`; + return code`${type} | ${nullOrUndefined(ctx.options, field.proto3Optional)}`; } return type; } diff --git a/src/utils.ts b/src/utils.ts index 9983c8f9e..d938d347c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -303,3 +303,26 @@ export function arrowFunction(params: string, body: Code | string, isOneLine: bo } return code`(${params}) => { ${body} }`; } + +export function nullOrUndefined(options: Pick, hasProto3Optional: boolean = false) { + return options.useNullAsOptional ? `null ${hasProto3Optional ? "| undefined" : ""}` : "undefined"; +} +export function maybeCheckIsNotNull(options: Pick, typeName: string, prefix?: string) { + return options.useNullAsOptional ? ` ${prefix} ${typeName} !== null` : ""; +} +export function maybeCheckIsNull(options: Pick, typeName: string, prefix?: string) { + return options.useNullAsOptional ? ` ${prefix} ${typeName} === null` : ""; +} + +export function withOrMaybeCheckIsNotNull(options: Pick, typeName: string) { + return maybeCheckIsNotNull(options, typeName, "||"); +} +export function withOrMaybeCheckIsNull(options: Pick, typeName: string) { + return maybeCheckIsNull(options, typeName, "||"); +} +export function withAndMaybeCheckIsNotNull(options: Pick, typeName: string) { + return maybeCheckIsNotNull(options, typeName, "&&"); +} +export function withAndMaybeCheckIsNull(options: Pick, typeName: string) { + return maybeCheckIsNotNull(options, typeName, "&&"); +} diff --git a/tests/options-test.ts b/tests/options-test.ts index 11fb1e066..e6e7b2118 100644 --- a/tests/options-test.ts +++ b/tests/options-test.ts @@ -61,6 +61,7 @@ describe("options", () => { "useJsonWireFormat": false, "useMapType": false, "useMongoObjectId": false, + "useNullAsOptional": false, "useNumericEnumForJson": false, "useOptionals": "none", "usePrototypeForDefaults": false, @@ -195,4 +196,12 @@ describe("options", () => { outputServices: [ServiceOption.DEFAULT, ServiceOption.GENERIC], }); }); + + it("allow use 'null' instead of 'undefined'", () => { + const options = optionsFromParameter("useNullAsOptional=true"); + expect(options).toMatchObject({ + useNullAsOptional: true, + // outputServices: [ServiceOption.DEFAULT, ServiceOption.GENERIC], + }); + }); }); diff --git a/tests/types-test.ts b/tests/types-test.ts index a373762e5..7223ad48a 100644 --- a/tests/types-test.ts +++ b/tests/types-test.ts @@ -54,6 +54,13 @@ describe("types", () => { options: { ...defaultOptions(), useOptionals: "all" }, expected: code`string | undefined`, }, + { + descr: 'use "null" value instead of "undefined" (useNullAsOptional=true)', + typeMap: new Map(), + protoType: ".google.protobuf.StringValue", + options: { ...defaultOptions(), useNullAsOptional: true }, + expected: code`string | null`, + }, ]; testCases.forEach((t) => it(t.descr, async () => {