Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handle nullability for optional fields #2011

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b38a668
Fixes optional handling for class-level comments (JS) and interfaces …
martin-traverse Jul 27, 2022
358d336
Hide the fix for PB3 optional type declarations behind the flag --pb3…
martin-traverse Oct 18, 2022
94556f9
Do not use null-defaults to decide type signature for pb3-optionals (…
martin-traverse Oct 18, 2022
2bd0b43
New implementation that explicitly respects the optional keyword
martin-traverse Jul 24, 2024
4793755
Fix options list in pbjs
martin-traverse Jul 24, 2024
f2a69a5
Add tests for handling optional fields in both proto2 and proto3 syntax
martin-traverse Jul 24, 2024
996e445
Explicit handling of proto3 syntax in the parser and static generator
martin-traverse Jul 24, 2024
aa61793
Only look up proto syntax once per type in the static generator
martin-traverse Jul 24, 2024
f2ed6e0
Fix lint warnings
martin-traverse Jul 25, 2024
856af47
Use field options in proto3 instead of adding an extra property
martin-traverse Jul 25, 2024
ccd640e
Do not require repeated or map fields in the object properties (i.e. …
martin-traverse Jul 25, 2024
09f577a
Do not generate doc comments for virtual oneOfs
martin-traverse Jul 25, 2024
6204b2d
Address codestyle comment (braces for if/else)
martin-traverse Jul 26, 2024
31f85e9
Address comment for default syntax
martin-traverse Jul 26, 2024
ed5980c
Change config flag to --null-semantics and update comments
martin-traverse Jul 26, 2024
bee997d
Update PBJS and README for new --null-semantics flag
martin-traverse Jul 26, 2024
dee3083
Update tests for --null-semantics flag
martin-traverse Jul 26, 2024
50255d0
Fix one lint warning
martin-traverse Jul 26, 2024
623477b
Include tests for interface / message types under --null-semantics
martin-traverse Jul 26, 2024
d5700a5
Implement propagation of interface / message types under --null-seman…
martin-traverse Jul 26, 2024
9bcc006
Update CLI options help and README
martin-traverse Jul 26, 2024
fda51db
Allow for other syntax options than "proto2" or "proto3"
martin-traverse Jul 26, 2024
8e0027a
Make parentIsInterface default to false in toJsType()
martin-traverse Jul 26, 2024
94bfbbc
Allow undefined values (but not nulls) for implicit presence fields i…
martin-traverse Jul 26, 2024
258679c
Update tests for proto3 to let implicit members be optional but not n…
martin-traverse Jul 26, 2024
c478e1f
Add braces to if/else clauses for readability on all code touched by …
Jul 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Translates between file formats and generates static code.
--force-long Enforces the use of 'Long' for s-/u-/int64 and s-/fixed64 fields.
--force-number Enforces the use of 'number' for s-/u-/int64 and s-/fixed64 fields.
--force-message Enforces the use of message instances instead of plain objects.
--force-optional Enforces nullable types for fields marked as optional (proto2 and proto3)

usage: pbjs [options] file1.proto file2.json ... (or pipe) other | pbjs [options] -
```
Expand Down
8 changes: 5 additions & 3 deletions cli/pbjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ exports.main = function main(args, callback) {
"force-message": "strict-message"
},
string: [ "target", "out", "path", "wrap", "dependency", "root", "lint", "filter" ],
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" ],
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", "force-optional", "null-defaults"],
default: {
target: "json",
create: true,
Expand All @@ -62,7 +62,8 @@ exports.main = function main(args, callback) {
"force-number": false,
"force-enum-string": false,
"force-message": false,
"null-defaults": false,
"force-optional": false,
"null-defaults": false
}
});

Expand Down Expand Up @@ -146,8 +147,9 @@ exports.main = function main(args, callback) {
" --force-long Enforces the use of 'Long' for s-/u-/int64 and s-/fixed64 fields.",
" --force-number Enforces the use of 'number' for s-/u-/int64 and s-/fixed64 fields.",
" --force-message Enforces the use of message instances instead of plain objects.",
" --force-optional Enforces nullable types for fields marked as optional (proto2 and proto3)",
"",
" --null-defaults Default value for optional fields is null instead of zero value.",
" --null-defaults Default value for optional fields is null instead of zero value (deprecated, use --force-optional instead)",
"",
"usage: " + chalk.bold.green("pbjs") + " [options] file1.proto file2.json ..." + chalk.gray(" (or pipe) ") + "other | " + chalk.bold.green("pbjs") + " [options] -",
""
Expand Down
100 changes: 88 additions & 12 deletions cli/targets/static.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,48 @@
return type;
}

function syntaxForType(type) {
martin-traverse marked this conversation as resolved.
Show resolved Hide resolved

var syntax = null;
var namespace = type;

while (syntax === null && namespace !== null) {
if (namespace.options != null && "syntax" in namespace.options)
martin-traverse marked this conversation as resolved.
Show resolved Hide resolved
syntax = namespace.options["syntax"];
else
namespace = namespace.parent;
}

return (syntax === "proto3") ? "proto3" : "proto2";

Check warning on line 369 in cli/targets/static.js

View workflow job for this annotation

GitHub Actions / lint

Unnecessary parentheses around expression
martin-traverse marked this conversation as resolved.
Show resolved Hide resolved
}

function isOptional(field, syntax) {

// In proto3, optional fields are explicit
if (syntax === "proto3")
return field.options != null && field.options["proto3_optional"] === true;

// In proto2, fields are explicitly optional if they are not part of a map, array or oneOf group
return field.optional && !(field.partOf || field.repeated || field.map);
martin-traverse marked this conversation as resolved.
Show resolved Hide resolved
}

function isOptionalOneOf(oneof, syntax) {

if (syntax !== "proto3")
return false;

if (oneof.fieldsArray == null || oneof.fieldsArray.length !== 1)
return false;

var field = oneof.fieldsArray[0];

return field.options != null && field.options["proto3_optional"] === true;
}

function buildType(ref, type) {

var syntax = syntaxForType(type);

if (config.comments) {
var typeDef = [
"Properties of " + aOrAn(type.name) + ".",
Expand All @@ -366,9 +406,24 @@
var prop = util.safeProp(field.name); // either .name or ["name"]
prop = prop.substring(1, prop.charAt(0) === "[" ? prop.length - 1 : prop.length);
var jsType = toJsType(field);
if (field.optional)
jsType = jsType + "|null";
typeDef.push("@property {" + jsType + "} " + (field.optional ? "[" + prop + "]" : prop) + " " + (field.comment || type.name + " " + field.name));
var nullable = false;

// New behaviour - respect explicit optional semantics in both proto2 and proto3
if (config["force-optional"]) {
if (isOptional(field, syntax) || field.partOf || field.repeated || field.map) {
jsType = jsType + "|null";
nullable = true;
}
}
// Old behaviour - field.optional is true for all fields in proto3
else {
if (field.optional) {
jsType = jsType + "|null";
nullable = true;
}
}

typeDef.push("@property {" + jsType + "} " + (nullable ? "[" + prop + "]" : prop) + " " + (field.comment || type.name + " " + field.name));
});
push("");
pushComment(typeDef);
Expand All @@ -394,8 +449,19 @@
if (config.comments) {
push("");
var jsType = toJsType(field);
if (field.optional && !field.map && !field.repeated && (field.resolvedType instanceof Type || config["null-defaults"]) || field.partOf)
jsType = jsType + "|null|undefined";

// New behaviour - fields explicitly marked as optional and members of a one-of are nullable
martin-traverse marked this conversation as resolved.
Show resolved Hide resolved
// Maps and repeated fields are not nullable, they default to empty instances
if (config["force-optional"]) {
if (isOptional(field, syntax) || field.partOf)
jsType = jsType + "|null|undefined";
}
// Old behaviour - field.optional is true for all fields in proto3
else {
if (field.optional && !field.map && !field.repeated && (field.resolvedType instanceof Type || config["null-defaults"]) || field.partOf)
jsType = jsType + "|null|undefined";
}

pushComment([
field.comment || type.name + " " + field.name + ".",
"@member {" + jsType + "} " + field.name,
Expand All @@ -406,11 +472,16 @@
push("");
firstField = false;
}
// New behaviour sets a null default when the optional keyword is used explicitly
// Old behaviour considers all proto3 fields optional and uses the null-defaults config flag
var nullDefault = config["force-optional"]
? isOptional(field, syntax)
: field.optional && config["null-defaults"];
if (field.repeated)
push(escapeName(type.name) + ".prototype" + prop + " = $util.emptyArray;"); // overwritten in constructor
else if (field.map)
push(escapeName(type.name) + ".prototype" + prop + " = $util.emptyObject;"); // overwritten in constructor
else if (field.partOf || field.optional && config["null-defaults"])
else if (field.partOf || nullDefault)
push(escapeName(type.name) + ".prototype" + prop + " = null;"); // do not set default value for oneof members
else if (field.long)
push(escapeName(type.name) + ".prototype" + prop + " = $util.Long ? $util.Long.fromBits("
Expand All @@ -436,12 +507,17 @@
}
oneof.resolve();
push("");
pushComment([
oneof.comment || type.name + " " + oneof.name + ".",
"@member {" + oneof.oneof.map(JSON.stringify).join("|") + "|undefined} " + escapeName(oneof.name),
"@memberof " + exportName(type),
"@instance"
]);
if (isOptionalOneOf(oneof, syntax)) {
push("// Virtual OneOf for proto3 optional field");
}
else {
pushComment([
oneof.comment || type.name + " " + oneof.name + ".",
"@member {" + oneof.oneof.map(JSON.stringify).join("|") + "|undefined} " + escapeName(oneof.name),
"@memberof " + exportName(type),
"@instance"
]);
}
push("Object.defineProperty(" + escapeName(type.name) + ".prototype, " + JSON.stringify(oneof.name) +", {");
++indent;
push("get: $util.oneOfGetter($oneOfFields = [" + oneof.oneof.map(JSON.stringify).join(", ") + "]),");
Expand Down
4 changes: 4 additions & 0 deletions src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ function parse(source, root, options) {
if (!isProto3 && syntax !== "proto2")
throw illegal(syntax, "syntax");

// Syntax is needed to understand the meaning of the optional field rule
// Otherwise the meaning is ambiguous between proto2 and proto3
root.setOption("syntax", syntax);

skip(";");
}

Expand Down
66 changes: 66 additions & 0 deletions tests/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,72 @@ tape.test("with null-defaults, absent optional fields have null values", functio
});


tape.test("with force-optional, optional fields are handled correctly in proto2", function(test) {
cliTest(test, function() {
var root = protobuf.loadSync("tests/data/cli/null-defaults.proto");
root.resolveAll();

var staticTarget = require("../cli/targets/static");

staticTarget(root, {
create: true,
decode: true,
encode: true,
convert: true,
comments: true,
"force-optional": true,
}, function(err, jsCode) {

test.error(err, 'static code generation worked');

test.ok(jsCode.includes("@property {number|null|undefined} [c] OptionalFields c"), "Property for c should be nullable")
test.ok(jsCode.includes("@member {number|null|undefined} c"), "Member for c should be nullable")
test.ok(jsCode.includes("OptionalFields.prototype.c = null;"), "Initializer for c should be null")

test.ok(jsCode.includes("@property {number} d OptionalFields d"), "Property for d should not be nullable")
test.ok(jsCode.includes("@member {number} d"), "Member for d should not be nullable")
test.ok(jsCode.includes("OptionalFields.prototype.d = 0;"), "Initializer for d should be zero")

test.end();
});
});
});


tape.test("with force-optional, optional fields are handled correctly in proto3", function(test) {
cliTest(test, function() {
var root = protobuf.loadSync("tests/data/cli/null-defaults-proto3.proto");
root.resolveAll();

var staticTarget = require("../cli/targets/static");

staticTarget(root, {
create: true,
decode: true,
encode: true,
convert: true,
comments: true,
"force-optional": true,
}, function(err, jsCode) {

console.log(jsCode);

test.error(err, 'static code generation worked');

test.ok(jsCode.includes("@property {number|null|undefined} [c] OptionalFields c"), "Property for c should be nullable")
test.ok(jsCode.includes("@member {number|null|undefined} c"), "Member for c should be nullable")
test.ok(jsCode.includes("OptionalFields.prototype.c = null;"), "Initializer for c should be null")

test.ok(jsCode.includes("@property {number} d OptionalFields d"), "Property for d should not be nullable")
test.ok(jsCode.includes("@member {number} d"), "Member for d should not be nullable")
test.ok(jsCode.includes("OptionalFields.prototype.d = 0;"), "Initializer for d should be zero")

test.end();
});
});
});


tape.test("pbjs generates static code with message filter", function (test) {
cliTest(test, function () {
var root = protobuf.loadSync("tests/data/cli/test-filter.proto");
Expand Down
12 changes: 12 additions & 0 deletions tests/data/cli/null-defaults-proto3.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
syntax = "proto3";

message OptionalFields {
message SubMessage {
string a = 1;
}

optional SubMessage a = 1;
optional string b = 2;
optional uint32 c = 3;
uint32 d = 4;
}
1 change: 1 addition & 0 deletions tests/data/cli/null-defaults.proto
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ message OptionalFields {
optional SubMessage a = 1;
optional string b = 2;
optional uint32 c = 3;
required uint32 d = 4;
}
Loading