spark-test commented on code in PR #59669:
URL: https://github.com/apache/spark/pull/59669#discussion_r4492390781
##########
common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java:
##########
@@ -205,6 +205,229 @@ public static Variant pickAtPaths(Variant v, PickNode
tree) {
return builder.result();
}
+ /**
+ * Return {@code v} in canonical form, or {@code v} itself if it is already
canonical.
+ * <p>
+ * The variant binary encoding allows the same logical value to be encoded
in several ways
+ * (dictionary order, unused keys, integer and offset widths, decimal scale,
...). Canonical form
+ * picks one encoding per value, so that values which are equal under the
variant equality rules
+ * have identical {@code value} and {@code metadata} bytes and can be
compared, hashed and
+ * grouped bytewise. It is a Spark-internal normalization, not part of the
variant spec, and
+ * nothing outside Spark is expected to produce or require it.
+ * <p>
+ * A variant is canonical when all of the following hold.
+ * <ul>
+ * <li>Metadata header: version {@link VariantUtil#VERSION}, the minimal
offset size for
+ * {@code max(lastOffset, numKeys)}, i.e. 1, 2, 3 or 4 bytes as given
by
+ * {@code getIntegerSize}, and {@code sorted_strings} and the unused
bit 5 clear.
+ * {@code sorted_strings} stays clear although canonical keys are
sorted, so that builder
+ * output, which never sets it, is already canonical.</li>
+ * <li>Metadata dictionary: the first offset is 0, offsets are
non-decreasing, the key bytes
+ * end exactly at the last offset (no trailing bytes), keys are
strictly increasing under
+ * unsigned byte comparison (so they are unique), and every key is
referenced by at least
+ * one object field. Key bytes are preserved as is, including invalid
UTF-8.</li>
+ * <li>Value root: starts at position 0 and spans exactly {@code
value.length} bytes.</li>
+ * <li>Object: field ids strictly increasing (fields are stored in key
order and no key is
+ * repeated), field data stored contiguously in field order, the end
offset equal to the
+ * data size, and the header using the minimal id size for the largest
id, the minimal
+ * offset size for the data size, {@code is_large} only for more than
255 fields, and the
+ * reserved bit 7 clear.</li>
+ * <li>Array: element order preserved, element data stored contiguously in
element order, the
+ * end offset equal to the data size, and the header using the minimal
offset size for the
+ * data size, {@code is_large} only for more than 255 elements, and
the reserved bits 5 to
+ * 7 clear.</li>
+ * <li>Integer: the smallest of INT1, INT2, INT4 and INT8 that holds the
value.</li>
+ * <li>Decimal: trailing zeros stripped and a negative scale raised to 0.
An integral decimal
+ * that fits in a signed 64-bit integer is stored as an integer
instead. Otherwise the
+ * smallest of DECIMAL4 (precision and scale at most 9), DECIMAL8 (at
most 18) and
+ * DECIMAL16 that holds the value.</li>
+ * <li>Float and double: -0.0 is stored as 0.0 and NaN as the canonical
NaN bit pattern of
+ * {@link Float#floatToIntBits} and {@link Double#doubleToLongBits}.
The type is kept, so
+ * a float and a double are never identical.</li>
+ * <li>String: at most {@link VariantUtil#MAX_SHORT_STR_SIZE} bytes uses
the short-string
+ * encoding, anything longer the long-string encoding. Bytes are
preserved as is, including
+ * invalid UTF-8.</li>
+ * <li>Every other type (null, boolean, date, timestamps, binary, uuid,
...) has a single
+ * encoding in the spec and is copied unchanged.</li>
+ * </ul>
+ * {@code isCanonical} checks exactly these rules and {@code doCanonicalize}
produces them; the
+ * two must be changed together when a rule changes or a type with
alternative encodings is
+ * added to the spec.
+ */
+ public static Variant canonicalize(Variant v) {
+ if (isCanonical(v)) {
+ return v;
+ }
+ return doCanonicalize(v);
+ }
+
+ // Check whether `v` already satisfies every rule listed in `canonicalize`.
Return false at the
+ // first non-canonical encoding. Malformed bytes that the check can reject
without decoding them
+ // return false, and the rebuild then decides whether they fail. Malformed
bytes that the check
+ // has to decode throw instead, for example `MALFORMED_VARIANT` for an
unreadable metadata table,
+ // a container whose data runs past the value, or a decimal whose precision
or scale is out of
Review Comment:
**Nit (P3):** The `isCanonical` comment lists "a container whose data runs
past the value" among the inputs that make the check throw `MALFORMED_VARIANT`,
but the check does not throw for that case. `valueSize` takes a container's
size from its header and final offset without reading the data, so for an array
or object whose declared data runs past the value, `isCanonical` returns false
(line 310 for the root, line 327 when nested). It throws for a container only
when the size field or offset list itself can't be read. The rebuild never
reads the final offset, so `canonicalize` then succeeds whenever the referenced
children are inside the buffer. For example, `[3, 1, 0, 100, 12, 1]`, an array
whose final offset declares 100 data bytes in a 6-byte value, canonicalizes to
`[3, 1, 0, 2, 12, 1]`, although `is_valid_variant` rejects it. The suite's
malformed `[3, 1, 5, 6, 0]` case does fail with `MALFORMED_VARIANT`, but in the
rebuild, because its element offset points past the value.
The next sentence, "`canonicalize` fails with the same error either way",
also holds only for inputs with a single defect. With `dictionary("a")` and the
object `[2, 2, 0, 5, 0, 1, 2, 68, 0]` (field 0 holds unknown type 17, field 1
has key ID 5), `canonicalize` throws `UNKNOWN_PRIMITIVE_TYPE_IN_VARIANT` from
the check, while `doCanonicalize` on the same bytes throws `MALFORMED_VARIANT`
from key collection.
The lenient rebuild matches how the other rebuild paths read only what the
offsets reach, so it is the comment that needs to change. Replace that example
with a container whose size field or offset list is truncated, and say that a
container whose declared data runs past the value makes the check return false
and leaves the outcome to the rebuild. Replace "the same error either way" with
"when the check throws, `canonicalize` fails with that error", and pin the
over-long container case in a test.
This extends an [existing
discussion](https://github.com/apache/spark/pull/59669#discussion_r4481813549).
**Verification:**
- **Inspection:** Each malformed-input example in the isCanonical comment
matches the branch that handles it: examples described as throwing reach a read
or decode that raises the named error, and examples described as returning
false are rejected from bytes the check has already read.
- **Regression:** For arrays and objects, at the root and nested in another
container, whose declared data size runs past the end of the value while their
referenced children lie inside the buffer, isCanonical returns false without
throwing and canonicalize returns the canonical rebuilt value that the
corrected comment describes. The tests fail if the check starts throwing or
canonicalize starts rejecting or reshaping these inputs.
- **Behavior:** The existing malformed-input cases still fail with
MALFORMED_VARIANT or UNKNOWN_PRIMITIVE_TYPE_IN_VARIANT exactly as before, and
canonical and non-canonical valid inputs produce unchanged bytes.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]