Skip to content

Commit 59569c1

Browse files
Merge pull request #2011 from martin-traverse/fix/typescript_optional_support
Fix / Handle nullability for optional fields
2 parents 0a0cdb6 + c478e1f commit 59569c1

File tree

7 files changed

+228
-19
lines changed

7 files changed

+228
-19
lines changed

cli/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ Translates between file formats and generates static code.
6868
--force-long Enforces the use of 'Long' for s-/u-/int64 and s-/fixed64 fields.
6969
--force-number Enforces the use of 'number' for s-/u-/int64 and s-/fixed64 fields.
7070
--force-message Enforces the use of message instances instead of plain objects.
71+
72+
--null-defaults Default value for optional fields is null instead of zero value.
73+
--null-semantics Make nullable fields match protobuf semantics (overrides --null-defaults).
7174
7275
usage: pbjs [options] file1.proto file2.json ... (or pipe) other | pbjs [options] -
7376
```

cli/pbjs.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ exports.main = function main(args, callback) {
4141
"force-message": "strict-message"
4242
},
4343
string: [ "target", "out", "path", "wrap", "dependency", "root", "lint", "filter" ],
44-
boolean: [ "create", "encode", "decode", "verify", "convert", "delimited", "typeurl", "beautify", "comments", "service", "es6", "sparse", "keep-case", "alt-comment", "force-long", "force-number", "force-enum-string", "force-message", "null-defaults" ],
44+
boolean: [ "create", "encode", "decode", "verify", "convert", "delimited", "typeurl", "beautify", "comments", "service", "es6", "sparse", "keep-case", "alt-comment", "force-long", "force-number", "force-enum-string", "force-message", "null-defaults", "null-semantics"],
4545
default: {
4646
target: "json",
4747
create: true,
@@ -63,6 +63,7 @@ exports.main = function main(args, callback) {
6363
"force-enum-string": false,
6464
"force-message": false,
6565
"null-defaults": false,
66+
"null-semantics": false
6667
}
6768
});
6869

@@ -148,6 +149,7 @@ exports.main = function main(args, callback) {
148149
" --force-message Enforces the use of message instances instead of plain objects.",
149150
"",
150151
" --null-defaults Default value for optional fields is null instead of zero value.",
152+
" --null-semantics Make nullable fields match protobuf semantics (overrides --null-defaults).",
151153
"",
152154
"usage: " + chalk.bold.green("pbjs") + " [options] file1.proto file2.json ..." + chalk.gray(" (or pipe) ") + "other | " + chalk.bold.green("pbjs") + " [options] -",
153155
""

cli/targets/static.js

+133-18
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,15 @@ function buildFunction(type, functionName, gen, scope) {
311311
push("};");
312312
}
313313

314-
function toJsType(field) {
314+
function toJsType(field, parentIsInterface = false) {
315315
var type;
316316

317+
// With null semantics, interfaces are composed from interfaces and messages from messages
318+
// Without null semantics, child types depend on the --force-message flag
319+
var asInterface = config["null-semantics"]
320+
? parentIsInterface && !(field.resolvedType instanceof protobuf.Enum)
321+
: !(field.resolvedType instanceof protobuf.Enum || config.forceMessage);
322+
317323
switch (field.type) {
318324
case "double":
319325
case "float":
@@ -341,10 +347,12 @@ function toJsType(field) {
341347
type = "Uint8Array";
342348
break;
343349
default:
344-
if (field.resolve().resolvedType)
345-
type = exportName(field.resolvedType, !(field.resolvedType instanceof protobuf.Enum || config.forceMessage));
346-
else
350+
if (field.resolve().resolvedType) {
351+
type = exportName(field.resolvedType, asInterface);
352+
}
353+
else {
347354
type = "*"; // should not happen
355+
}
348356
break;
349357
}
350358
if (field.map)
@@ -354,8 +362,72 @@ function toJsType(field) {
354362
return type;
355363
}
356364

365+
function syntaxForType(type) {
366+
367+
var syntax = null;
368+
var namespace = type;
369+
370+
while (syntax === null && namespace !== null) {
371+
if (namespace.options != null && "syntax" in namespace.options) {
372+
syntax = namespace.options["syntax"];
373+
}
374+
else {
375+
namespace = namespace.parent;
376+
}
377+
}
378+
379+
return syntax !== null ? syntax : "proto2";
380+
}
381+
382+
function isExplicitPresence(field, syntax) {
383+
384+
// In proto3, optional fields are explicit
385+
if (syntax === "proto3") {
386+
return field.options != null && field.options["proto3_optional"] === true;
387+
}
388+
389+
// In proto2, fields are explicitly optional if they are not part of a map, array or oneOf group
390+
if (syntax === "proto2") {
391+
return field.optional && !(field.partOf || field.repeated || field.map);
392+
}
393+
394+
throw new Error("Unknown proto syntax: [" + syntax + "]");
395+
}
396+
397+
function isImplicitPresence(field, syntax) {
398+
399+
// In proto3, everything not marked optional has implicit presence (including maps and repeated fields)
400+
if (syntax === "proto3") {
401+
return field.options == null || field.options["proto3_optional"] !== true;
402+
}
403+
404+
// In proto2, nothing has implicit presence
405+
if (syntax === "proto2") {
406+
return false;
407+
}
408+
409+
throw new Error("Unknown proto syntax: [" + syntax + "]");
410+
}
411+
412+
function isOptionalOneOf(oneof, syntax) {
413+
414+
if (syntax === "proto2") {
415+
return false;
416+
}
417+
418+
if (oneof.fieldsArray == null || oneof.fieldsArray.length !== 1) {
419+
return false;
420+
}
421+
422+
var field = oneof.fieldsArray[0];
423+
424+
return field.options != null && field.options["proto3_optional"] === true;
425+
}
426+
357427
function buildType(ref, type) {
358428

429+
var syntax = syntaxForType(type);
430+
359431
if (config.comments) {
360432
var typeDef = [
361433
"Properties of " + aOrAn(type.name) + ".",
@@ -365,10 +437,30 @@ function buildType(ref, type) {
365437
type.fieldsArray.forEach(function(field) {
366438
var prop = util.safeProp(field.name); // either .name or ["name"]
367439
prop = prop.substring(1, prop.charAt(0) === "[" ? prop.length - 1 : prop.length);
368-
var jsType = toJsType(field);
369-
if (field.optional)
370-
jsType = jsType + "|null";
371-
typeDef.push("@property {" + jsType + "} " + (field.optional ? "[" + prop + "]" : prop) + " " + (field.comment || type.name + " " + field.name));
440+
var jsType = toJsType(field, /* parentIsInterface = */ true);
441+
var nullable = false;
442+
if (config["null-semantics"]) {
443+
// With semantic nulls, only explicit optional fields and one-of members can be set to null
444+
// Implicit fields (proto3), maps and lists can be omitted, but if specified must be non-null
445+
// Implicit fields will take their default value when the message is constructed
446+
if (isExplicitPresence(field, syntax) || field.partOf) {
447+
jsType = jsType + "|null|undefined";
448+
nullable = true;
449+
}
450+
else if (isImplicitPresence(field, syntax) || field.repeated || field.map) {
451+
jsType = jsType + "|undefined";
452+
nullable = true;
453+
}
454+
}
455+
else {
456+
// Without semantic nulls, everything is optional in proto3
457+
// Do not allow |undefined to keep backwards compatibility
458+
if (field.optional) {
459+
jsType = jsType + "|null";
460+
nullable = true;
461+
}
462+
}
463+
typeDef.push("@property {" + jsType + "} " + (nullable ? "[" + prop + "]" : prop) + " " + (field.comment || type.name + " " + field.name));
372464
});
373465
push("");
374466
pushComment(typeDef);
@@ -393,9 +485,22 @@ function buildType(ref, type) {
393485
var prop = util.safeProp(field.name);
394486
if (config.comments) {
395487
push("");
396-
var jsType = toJsType(field);
397-
if (field.optional && !field.map && !field.repeated && (field.resolvedType instanceof Type || config["null-defaults"]) || field.partOf)
398-
jsType = jsType + "|null|undefined";
488+
var jsType = toJsType(field, /* parentIsInterface = */ false);
489+
if (config["null-semantics"]) {
490+
// With semantic nulls, fields are nullable if they are explicitly optional or part of a one-of
491+
// Maps, repeated values and fields with implicit defaults are never null after construction
492+
// Members are never undefined, at a minimum they are initialized to null
493+
if (isExplicitPresence(field, syntax) || field.partOf) {
494+
jsType = jsType + "|null";
495+
}
496+
}
497+
else {
498+
// Without semantic nulls, everything is optional in proto3
499+
// Keep |undefined for backwards compatibility
500+
if (field.optional && !field.map && !field.repeated && (field.resolvedType instanceof Type || config["null-defaults"]) || field.partOf) {
501+
jsType = jsType + "|null|undefined";
502+
}
503+
}
399504
pushComment([
400505
field.comment || type.name + " " + field.name + ".",
401506
"@member {" + jsType + "} " + field.name,
@@ -406,11 +511,16 @@ function buildType(ref, type) {
406511
push("");
407512
firstField = false;
408513
}
514+
// With semantic nulls, only explict optional fields and one-of members are null by default
515+
// Otherwise use field.optional, which doesn't consider proto3, maps, repeated fields etc.
516+
var nullDefault = config["null-semantics"]
517+
? isExplicitPresence(field, syntax)
518+
: field.optional && config["null-defaults"];
409519
if (field.repeated)
410520
push(escapeName(type.name) + ".prototype" + prop + " = $util.emptyArray;"); // overwritten in constructor
411521
else if (field.map)
412522
push(escapeName(type.name) + ".prototype" + prop + " = $util.emptyObject;"); // overwritten in constructor
413-
else if (field.partOf || field.optional && config["null-defaults"])
523+
else if (field.partOf || nullDefault)
414524
push(escapeName(type.name) + ".prototype" + prop + " = null;"); // do not set default value for oneof members
415525
else if (field.long)
416526
push(escapeName(type.name) + ".prototype" + prop + " = $util.Long ? $util.Long.fromBits("
@@ -436,12 +546,17 @@ function buildType(ref, type) {
436546
}
437547
oneof.resolve();
438548
push("");
439-
pushComment([
440-
oneof.comment || type.name + " " + oneof.name + ".",
441-
"@member {" + oneof.oneof.map(JSON.stringify).join("|") + "|undefined} " + escapeName(oneof.name),
442-
"@memberof " + exportName(type),
443-
"@instance"
444-
]);
549+
if (isOptionalOneOf(oneof, syntax)) {
550+
push("// Virtual OneOf for proto3 optional field");
551+
}
552+
else {
553+
pushComment([
554+
oneof.comment || type.name + " " + oneof.name + ".",
555+
"@member {" + oneof.oneof.map(JSON.stringify).join("|") + "|undefined} " + escapeName(oneof.name),
556+
"@memberof " + exportName(type),
557+
"@instance"
558+
]);
559+
}
445560
push("Object.defineProperty(" + escapeName(type.name) + ".prototype, " + JSON.stringify(oneof.name) +", {");
446561
++indent;
447562
push("get: $util.oneOfGetter($oneOfFields = [" + oneof.oneof.map(JSON.stringify).join(", ") + "]),");

src/parse.js

+4
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ function parse(source, root, options) {
263263
if (!isProto3 && syntax !== "proto2")
264264
throw illegal(syntax, "syntax");
265265

266+
// Syntax is needed to understand the meaning of the optional field rule
267+
// Otherwise the meaning is ambiguous between proto2 and proto3
268+
root.setOption("syntax", syntax);
269+
266270
skip(";");
267271
}
268272

tests/cli.js

+72
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,78 @@ tape.test("with null-defaults, absent optional fields have null values", functio
165165
});
166166

167167

168+
tape.test("with --null-semantics, optional fields are handled correctly in proto2", function(test) {
169+
cliTest(test, function() {
170+
var root = protobuf.loadSync("tests/data/cli/null-defaults.proto");
171+
root.resolveAll();
172+
173+
var staticTarget = require("../cli/targets/static");
174+
175+
staticTarget(root, {
176+
create: true,
177+
decode: true,
178+
encode: true,
179+
convert: true,
180+
comments: true,
181+
"null-semantics": true,
182+
}, function(err, jsCode) {
183+
184+
test.error(err, 'static code generation worked');
185+
186+
test.ok(jsCode.includes("@property {OptionalFields.ISubMessage|null|undefined} [a] OptionalFields a"), "Property for a should use an interface")
187+
test.ok(jsCode.includes("@member {OptionalFields.SubMessage|null} a"), "Member for a should use a message type")
188+
test.ok(jsCode.includes("OptionalFields.prototype.a = null;"), "Initializer for a should be null")
189+
190+
test.ok(jsCode.includes("@property {number|null|undefined} [c] OptionalFields c"), "Property for c should be nullable")
191+
test.ok(jsCode.includes("@member {number|null} c"), "Member for c should be nullable")
192+
test.ok(jsCode.includes("OptionalFields.prototype.c = null;"), "Initializer for c should be null")
193+
194+
test.ok(jsCode.includes("@property {number} d OptionalFields d"), "Property for d should not be nullable")
195+
test.ok(jsCode.includes("@member {number} d"), "Member for d should not be nullable")
196+
test.ok(jsCode.includes("OptionalFields.prototype.d = 0;"), "Initializer for d should be zero")
197+
198+
test.end();
199+
});
200+
});
201+
});
202+
203+
204+
tape.test("with --null-semantics, optional fields are handled correctly in proto3", function(test) {
205+
cliTest(test, function() {
206+
var root = protobuf.loadSync("tests/data/cli/null-defaults-proto3.proto");
207+
root.resolveAll();
208+
209+
var staticTarget = require("../cli/targets/static");
210+
211+
staticTarget(root, {
212+
create: true,
213+
decode: true,
214+
encode: true,
215+
convert: true,
216+
comments: true,
217+
"null-semantics": true,
218+
}, function(err, jsCode) {
219+
220+
test.error(err, 'static code generation worked');
221+
222+
test.ok(jsCode.includes("@property {OptionalFields.ISubMessage|null|undefined} [a] OptionalFields a"), "Property for a should use an interface")
223+
test.ok(jsCode.includes("@member {OptionalFields.SubMessage|null} a"), "Member for a should use a message type")
224+
test.ok(jsCode.includes("OptionalFields.prototype.a = null;"), "Initializer for a should be null")
225+
226+
test.ok(jsCode.includes("@property {number|null|undefined} [c] OptionalFields c"), "Property for c should be nullable")
227+
test.ok(jsCode.includes("@member {number|null} c"), "Member for c should be nullable")
228+
test.ok(jsCode.includes("OptionalFields.prototype.c = null;"), "Initializer for c should be null")
229+
230+
test.ok(jsCode.includes("@property {number|undefined} [d] OptionalFields d"), "Property for d should be optional but not nullable")
231+
test.ok(jsCode.includes("@member {number} d"), "Member for d should not be nullable")
232+
test.ok(jsCode.includes("OptionalFields.prototype.d = 0;"), "Initializer for d should be zero")
233+
234+
test.end();
235+
});
236+
});
237+
});
238+
239+
168240
tape.test("pbjs generates static code with message filter", function (test) {
169241
cliTest(test, function () {
170242
var root = protobuf.loadSync("tests/data/cli/test-filter.proto");
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
syntax = "proto3";
2+
3+
message OptionalFields {
4+
message SubMessage {
5+
string a = 1;
6+
}
7+
8+
optional SubMessage a = 1;
9+
optional string b = 2;
10+
optional uint32 c = 3;
11+
uint32 d = 4;
12+
}

tests/data/cli/null-defaults.proto

+1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ message OptionalFields {
88
optional SubMessage a = 1;
99
optional string b = 2;
1010
optional uint32 c = 3;
11+
required uint32 d = 4;
1112
}

0 commit comments

Comments
 (0)