Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions packages/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ usually do. We repeat this for an increasing number of files.

| code generator | files | bundle size | minified | compressed |
| ------------------- | ----: | ----------: | --------: | ---------: |
| Protobuf-ES | 1 | 134,676 b | 69,551 b | 16,076 b |
| Protobuf-ES | 4 | 136,865 b | 71,058 b | 16,752 b |
| Protobuf-ES | 8 | 139,627 b | 72,829 b | 17,255 b |
| Protobuf-ES | 16 | 150,077 b | 80,810 b | 19,612 b |
| Protobuf-ES | 32 | 177,868 b | 102,828 b | 25,058 b |
| Protobuf-ES | 1 | 134,765 b | 69,600 b | 16,054 b |
| Protobuf-ES | 4 | 136,954 b | 71,107 b | 16,786 b |
| Protobuf-ES | 8 | 139,716 b | 72,878 b | 17,232 b |
| Protobuf-ES | 16 | 150,166 b | 80,859 b | 19,607 b |
| Protobuf-ES | 32 | 177,957 b | 102,877 b | 25,082 b |
| protobuf-javascript | 1 | 314,120 b | 244,024 b | 35,999 b |
| protobuf-javascript | 4 | 340,137 b | 258,996 b | 37,473 b |
| protobuf-javascript | 8 | 360,931 b | 270,573 b | 38,585 b |
Expand Down
12 changes: 6 additions & 6 deletions packages/bundle-size/chart.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions packages/protobuf-test/src/binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { suite, test } from "node:test";
import * as assert from "node:assert";
import {
create,
isFieldSet,
setExtension,
getExtension,
toBinary,
Expand Down Expand Up @@ -45,6 +46,7 @@ import { OneofMessageSchema } from "./gen/ts/extra/msg-oneof_pb.js";
import { JsonNamesMessageSchema } from "./gen/ts/extra/msg-json-names_pb.js";
import { JSTypeProto2NormalMessageSchema } from "./gen/ts/extra/jstype-proto2_pb.js";
import { TestAllTypesProto3Schema } from "./gen/ts/google/protobuf/test_messages_proto3_pb.js";
import { Proto3MessageSchema } from "./gen/ts/extra/proto3_pb.js";
import { compileMessage } from "./helpers.js";
import { BinaryReader, BinaryWriter, WireType } from "@bufbuild/protobuf/wire";

Expand Down Expand Up @@ -525,6 +527,30 @@ void suite("fromBinary recursion limit", () => {
});
});

void suite("negative zero serialization", () => {
test("singular float with -0 is set and round-trips", () => {
const msg = create(Proto3MessageSchema, { singularFloatField: -0 });
assert.strictEqual(
isFieldSet(msg, Proto3MessageSchema.field.singularFloatField),
true,
);
const bytes = toBinary(Proto3MessageSchema, msg);
assert.ok(bytes.length > 0);
const back = fromBinary(Proto3MessageSchema, bytes);
assert.ok(Object.is(back.singularFloatField, -0));
});
test("singular float with +0 is unset and omitted", () => {
const msg = create(Proto3MessageSchema, { singularFloatField: 0 });
assert.ok(!isFieldSet(msg, Proto3MessageSchema.field.singularFloatField));
assert.strictEqual(toBinary(Proto3MessageSchema, msg).length, 0);
});
test("singular int32 with -0 is unset and omitted", () => {
const msg = create(Proto3MessageSchema, { singularInt32Field: -0 });
assert.ok(!isFieldSet(msg, Proto3MessageSchema.field.singularInt32Field));
assert.strictEqual(toBinary(Proto3MessageSchema, msg).length, 0);
});
});

function testBinary<Desc extends DescMessage>(
desc: Desc,
init: MessageInitShape<Desc>,
Expand Down
123 changes: 123 additions & 0 deletions packages/protobuf-test/src/reflect/scalar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright 2021-2026 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { suite, test } from "node:test";
import * as assert from "node:assert";
import {
isScalarZeroValue,
scalarEquals,
scalarZeroValue,
type ScalarValue,
} from "@bufbuild/protobuf/reflect";
import { protoInt64, ScalarType } from "@bufbuild/protobuf";

void suite("isScalarZeroValue()", () => {
const cases: { type: ScalarType; value: unknown; want: boolean }[] = [
// Scalars with a single zero value
{ type: ScalarType.BOOL, value: false, want: true },
{ type: ScalarType.BOOL, value: true, want: false },
{ type: ScalarType.STRING, value: "", want: true },
{ type: ScalarType.STRING, value: "a", want: false },
{ type: ScalarType.BYTES, value: new Uint8Array(0), want: true },
{ type: ScalarType.BYTES, value: new Uint8Array([0]), want: false },
{ type: ScalarType.INT32, value: 0, want: true },
{ type: ScalarType.INT32, value: 1, want: false },
// -0 is distinct from 0 for float and double, but not for integers
{ type: ScalarType.DOUBLE, value: 0, want: true },
{ type: ScalarType.DOUBLE, value: -0, want: false },
{ type: ScalarType.FLOAT, value: -0, want: false },
{ type: ScalarType.INT32, value: -0, want: true },
{ type: ScalarType.INT64, value: -0, want: true },
// NaN is never a zero value
{ type: ScalarType.DOUBLE, value: NaN, want: false },
// 64-bit integers accept number, string, and bigint representations
{ type: ScalarType.INT64, value: BigInt(0), want: true },
{ type: ScalarType.INT64, value: "0", want: true },
{ type: ScalarType.UINT64, value: BigInt(0), want: true },
];
for (const { type, value, want } of cases) {
test(`${ScalarType[type]} ${repr(value)} -> ${want}`, () => {
assert.strictEqual(isScalarZeroValue(type, value), want);
});
}
});

void suite("scalarEquals()", () => {
const cases: {
type: ScalarType;
a: ScalarValue;
b: ScalarValue;
want: boolean;
}[] = [
// float and double follow IEEE value semantics
{ type: ScalarType.DOUBLE, a: 1, b: 1, want: true },
{ type: ScalarType.DOUBLE, a: 1, b: 2, want: false },
// -0 equals 0 here, even though isScalarZeroValue treats them differently
{ type: ScalarType.DOUBLE, a: -0, b: 0, want: true },
// NaN never equals NaN
{ type: ScalarType.DOUBLE, a: NaN, b: NaN, want: false },
// bytes are compared element-wise, including length
{
type: ScalarType.BYTES,
a: new Uint8Array([1, 2]),
b: new Uint8Array([1, 2]),
want: true,
},
{
type: ScalarType.BYTES,
a: new Uint8Array([1, 2]),
b: new Uint8Array([1, 3]),
want: false,
},
{
type: ScalarType.BYTES,
a: new Uint8Array([1]),
b: new Uint8Array([1, 2]),
want: false,
},
// 64-bit integers compare number, string, and bigint representations loosely
{ type: ScalarType.INT64, a: BigInt(0), b: "0", want: true },
{ type: ScalarType.INT64, a: BigInt(1), b: "1", want: true },
{ type: ScalarType.INT64, a: BigInt(1), b: "2", want: false },
// other scalars use strict comparison
{ type: ScalarType.STRING, a: "a", b: "a", want: true },
{ type: ScalarType.STRING, a: "a", b: "b", want: false },
{ type: ScalarType.BOOL, a: true, b: true, want: true },
{ type: ScalarType.BOOL, a: true, b: false, want: false },
];
for (const { type, a, b, want } of cases) {
test(`${ScalarType[type]} ${repr(a)} == ${repr(b)} -> ${want}`, () => {
assert.strictEqual(scalarEquals(type, a, b), want);
});
}
});

void test("scalarZeroValue()", () => {
assert.ok(Object.is(scalarZeroValue(ScalarType.DOUBLE, false), 0));
assert.ok(Object.is(scalarZeroValue(ScalarType.FLOAT, false), 0));
assert.ok(
Object.is(scalarZeroValue(ScalarType.INT64, false), protoInt64.zero),
);
assert.ok(Object.is(scalarZeroValue(ScalarType.INT64, true), "0"));
});

// Renders a scalar value for use in a subtest name. Distinguishes -0 from 0,
// quotes strings, and marks bigint with an "n" suffix.
function repr(value: unknown): string {
if (typeof value === "bigint") return `${value}n`;
if (typeof value === "string") return JSON.stringify(value);
if (value instanceof Uint8Array) return `[${Array.from(value).join(",")}]`;
if (Object.is(value, -0)) return "-0";
return String(value);
}
11 changes: 8 additions & 3 deletions packages/protobuf/src/equals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ interface EqualsOptions {
}

/**
* Compare two messages of the same type.
* Compare two messages of the same type for equality.
*
* Note that this function disregards extensions and unknown fields, and that
* NaN is not equal NaN, following the IEEE standard.
* Fields are compared one by one, but only when set: a field set in one message
* but not the other makes them unequal, while fields set in both are compared
* by value. Extensions and unknown fields are disregarded by default.
*
* Float and double values are compared with IEEE semantics: NaN does not equal
* NaN, and -0 equals 0 (with the exception for singular fields with implicit
* presence: -0 is set, +0 is unset).
*/
export function equals<Desc extends DescMessage>(
schema: Desc,
Expand Down
39 changes: 33 additions & 6 deletions packages/protobuf/src/reflect/scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export type ScalarValue<

/**
* Returns true if both scalar values are equal.
*
* For float and double, values are compared following IEEE semantics: -0
* equals 0, and NaN does not equal NaN. This is value equality, not identity.
*
* It deliberately differs from isScalarZeroValue, which treats -0 as distinct
* from 0. A value can equal the zero value without being a zero value
* (scalarEquals(DOUBLE, -0, 0) is true while isScalarZeroValue(DOUBLE, -0) is
* false) so this function must not be used to derive implicit presence.
*/
export function scalarEquals(
type: ScalarType,
Expand Down Expand Up @@ -83,7 +91,13 @@ export function scalarEquals(
}

/**
* Returns the zero value for the given scalar type.
* Returns the zero value for the given scalar type, the value a field of this
* type has when unset: 0 for numeric types, "" for strings, false for
* booleans, and an empty Uint8Array for bytes. For 64-bit integer types, the
* result is "0" when longAsString is true, otherwise 0n.
*
* This is the type's zero value, not a proto2 custom field default. For float
* and double it is +0; isScalarZeroValue treats only +0, not -0, as this value.
*/
export function scalarZeroValue<
T extends ScalarType,
Expand Down Expand Up @@ -116,11 +130,19 @@ export function scalarZeroValue<
}

/**
* Returns true for a zero-value. For example, an integer has the zero-value `0`,
* a boolean is `false`, a string is `""`, and bytes is an empty Uint8Array.
* Returns true if the value is the zero value for the given scalar type: `0`
* for numeric types, `false` for booleans, `""` for strings, and an empty
* Uint8Array for bytes.
*
* In proto3, zero-values are not written to the wire, unless the field is
* optional or repeated.
* This is the implicit-presence default check. A singular field with implicit
* presence is treated as unset, and omitted from the wire, when its value is
* the zero value. With explicit presence, or in repeated and map fields,
* presence is structural and this function does not apply.
*
* Note that -0 is NOT a zero value for float and double: under implicit
* presence, +0 is omitted from the wire but -0 is written, following the
* proto3 specification. As a result this can disagree with scalarEquals, which
* compares by value and treats -0 as equal to 0.
*/
export function isScalarZeroValue(type: ScalarType, value: unknown): boolean {
switch (type) {
Expand All @@ -130,7 +152,12 @@ export function isScalarZeroValue(type: ScalarType, value: unknown): boolean {
return value === "";
case ScalarType.BYTES:
return value instanceof Uint8Array && !value.byteLength;
case ScalarType.DOUBLE:
case ScalarType.FLOAT:
// Object.is distinguishes -0 from 0.
return Object.is(value, 0);
default:
return value == 0; // Loose comparison matches 0n, 0 and "0"
// Loose comparison matches 0n, 0 and "0".
return value == 0;
}
}
Loading