This is an automated email from the ASF dual-hosted git repository.
sdf-jkl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new f9e02ba76a perf(variant): resolve borrowed field names without
searching the metadata dictionary (#10882)
f9e02ba76a is described below
commit f9e02ba76ad11e1380b559735ac912c602b604fb
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Thu Sep 10 07:28:44 2026 -0500
perf(variant): resolve borrowed field names without searching the metadata
dictionary (#10882)
# Which issue does this PR close?
- Closes #10881.
# Rationale for this change
`VariantMetadata::get_entry` resolves a field name to a field id by
searching the
dictionary, decoding and comparing dictionary strings as it goes (linear
for an
unsorted dictionary, logarithmic for a large sorted one).
Several paths copy fields out of a variant object and back into a
builder that
shares that object's metadata dictionary. `shred_variant` writes every
field the
shredding schema does not cover into the leftover `value` column, and
projection
paths do the same. In all of them the field name handed to the builder
came from
`VariantObject::iter`, which produced it by looking up a field id in
that very
dictionary. Searching for it by name is a round trip: the id was already
known,
and the search spends string comparisons recovering it.
`ReadOnlyMetadataBuilder` has a `known_field_names` cache intended to
absorb this
cost, but it cannot help here. `VariantValueArrayBuilder::builder_ext`
constructs
a fresh `ReadOnlyMetadataBuilder` per value, so in a per-row builder the
cache is
populated and dropped again on every row, never serving a lookup, and
each row
pays to hash names it will never see again.
In a CPU profile of the `shred_variant_unmatched_object_8k_rows`
benchmark added
here, `ReadOnlyMetadataBuilder::try_upsert_field_name` accounted for
about 57% of
`shred_variant`. After this change it accounts for about 20%, and
`get_entry` no
longer appears in the hot path.
# What changes are included in this PR?
- `VariantMetadata::borrowed_field_id` (crate-private). A field name
that is a
slice of the dictionary's own value region already encodes its field id:
it
belongs to the entry whose offset equals the name's distance from the
start of
that region. This finds the entry with a binary search over the offset
array,
comparing integers instead of decoding dictionary strings, and confirms
the hit
by comparing lengths rather than bytes.
A candidate is accepted only when it starts at the name's address and
has the
name's length, which makes the entry's bytes and the name's bytes the
same
bytes. Anything else, including a name that borrows from elsewhere, a
slice of
an entry, or metadata with arbitrary offsets, falls back to the existing
search. Addresses are only ever compared as integers, never
dereferenced.
This is attempted only for a **sorted** dictionary; see "Why sorted
only"
below.
- `VariantMetadata::get_entry` tries the above first, so all callers
benefit.
Cost for callers this cannot help: `get_entry` is public, and a name
looked up
against a sorted dictionary that does not borrow from it now runs the
address
range check before the existing search. That check short circuits on a
failed
comparison, so such a caller pays a few integer operations and nothing
else.
The one case that pays more is a name pointing into the value region
without
starting an entry, for example a substring of one: that costs a binary
search
over the offset array before falling back. Against an unsorted
dictionary the
added cost is a single boolean test. All three are bounded, but I would
rather
state them than have them found in review.
- `ReadOnlyMetadataBuilder::try_upsert_field_name` tries it before
consulting
`known_field_names`, so the paths described above do no hashing at all.
The
cache still serves field names that do not borrow from the dictionary,
and all
field names when the dictionary is unsorted.
- `shred_variant` reuses one scratch buffer to track which shredded
fields a row
supplied, instead of allocating a `HashSet` per row.
- Two new benchmarks in
`parquet-variant-compute/benches/variant_kernels.rs`
covering objects that the shredding schema matches partially and not at
all.
## Why sorted only
Thanks to @sdf-jkl for catching this; an earlier revision of this PR did
not
restrict the fast path, and was wrong.
[The spec][spec] requires dictionary entries to be unique only when
`sorted_strings` is set: "If the value is set to 0, readers may not make
any
assumptions about string order or uniqueness." So an unsorted dictionary
may
legally hold the same string at more than one field id, and this crate
already
relies on that — `with_full_validation` checks uniqueness only in the
sorted
branch, and `test_object_rejects_duplicate_field_names` covers exactly
such a
dictionary.
For such a dictionary, resolving a borrowed name by the id it came from
disagrees with resolving the same string by name. That is not a cosmetic
difference in which id gets picked, because `ObjectBuilder` detects
duplicate
fields by comparing field ids: two ids naming the same string defeat
`validate_unique_fields`, and with validation off they build an object
whose
field names are not unique, which `Variant::try_new` then rejects with
"field
names not sorted".
Restricting `borrowed_field_id` to sorted dictionaries removes that
entirely,
because validation rejects a sorted dictionary with duplicate entries,
so the id
a name was borrowed from is necessarily the id a search by name returns.
An
unsorted dictionary goes back to the `known_field_names` cache and the
existing
name search, exactly as before this PR.
The cost is that the fast path no longer applies to unsorted
dictionaries, where
`get_entry` can only search linearly. The benchmarks here build a sorted
dictionary (300 entries, `is_sorted() == true`), so the profile numbers
above are
unaffected by the restriction; recovering the unsorted case would need a
duplicate-free flag computed during validation, which I would rather
measure
separately than fold in here.
[spec]:
https://github.com/apache/parquet-format/blob/master/VariantEncoding.md#metadata-encoding-grammar
# Are these changes tested?
Yes. New unit tests cover sorted and unsorted dictionaries, agreement
between the
borrowed lookup, `get_entry`, and lookups by an owned (non-borrowed)
copy of the
same name, names borrowed from a different dictionary that must not be
resolved
against this one, a slice of an entry that shares its start offset
without being
equal to it, an unsorted dictionary holding the same string twice, and
empty
field names in both a sorted and an unsorted dictionary.
Two regression tests build objects through a `ReadOnlyMetadataBuilder`
over an
unsorted `["a", "a"]` dictionary: one asserts that
`validate_unique_fields`
rejects the second insert, and one asserts that a borrowed name and an
owned copy
of it collapse to a single field rather than producing a value that
fails
validation. Both fail if the sortedness restriction is removed.
The existing `parquet-variant`, `parquet-variant-compute`,
`parquet-variant-json`,
proptest fuzz, and `variant_interop` suites pass unchanged.
The two new benchmarks cover 8192 rows over a 300-entry dictionary with
15-field
objects. I am deliberately not posting timings yet. The machine
available to me is
heavily contended, and a paired interleaved probe there produced a 65%
spread
within a single invocation on identical work, so any speedup figure from
it would
be indistinguishable from noise. I will follow up with numbers from a
quiet
machine, measured with interleaved arms and with unaffected control
benchmarks
used to certify that the run is valid.
# Are there any user-facing changes?
No. There are no public API changes, and `get_entry` returns exactly
what it
returned before this PR for every input, including a dictionary that
holds the
same string at more than one field id.
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Kosta Tarasov <[email protected]>
---
parquet-variant-compute/benches/variant_kernels.rs | 55 +++-
parquet-variant-compute/src/shred_variant.rs | 30 ++-
parquet-variant/src/builder/metadata.rs | 13 +
parquet-variant/src/builder/object.rs | 62 +++++
parquet-variant/src/variant/metadata.rs | 294 +++++++++++++++++++++
5 files changed, 444 insertions(+), 10 deletions(-)
diff --git a/parquet-variant-compute/benches/variant_kernels.rs
b/parquet-variant-compute/benches/variant_kernels.rs
index 7fec1ed786..e06c0e0b1e 100644
--- a/parquet-variant-compute/benches/variant_kernels.rs
+++ b/parquet-variant-compute/benches/variant_kernels.rs
@@ -21,7 +21,7 @@ use arrow_schema::{DataType, Field, FieldRef, Fields};
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use parquet_variant::{EMPTY_VARIANT_METADATA_BYTES, Variant, VariantBuilder,
VariantPath};
use parquet_variant_compute::{
- GetOptions, VariantArray, VariantArrayBuilder, json_to_variant,
variant_get,
+ GetOptions, VariantArray, VariantArrayBuilder, json_to_variant,
shred_variant, variant_get,
};
use parquet_variant_json::append_json;
use rand::RngExt;
@@ -34,6 +34,7 @@ use std::sync::Arc;
const VARIANT_GET_UNSHREDDED_OBJECT_ROWS: usize = 262_144;
const VARIANT_ARRAY_BUILD_ROWS: usize = 262_144;
+const SHRED_VARIANT_OBJECT_ROWS: usize = 8_192;
fn variant_array_builder_build_bench(c: &mut Criterion) {
c.bench_function("variant_array_builder_build_262k_small_values", |b| {
@@ -201,11 +202,63 @@ pub fn variant_get_unshredded_object_path_bench(c: &mut
Criterion) {
});
}
+/// Shreds objects whose fields only partially match the requested shredding
schema.
+///
+/// Every field that is *not* covered by the schema is copied into the
leftover `value` column,
+/// which requires the builder to resolve that field's name back to its id in
the row's metadata
+/// dictionary. The source array's dictionary holds 300 field names, so the
cost of that name
+/// lookup is visible.
+pub fn shred_variant_partial_object_bench(c: &mut Criterion) {
+ let variant_array =
create_unshredded_object_variant_array(SHRED_VARIANT_OBJECT_ROWS);
+
+ // The source objects have 15 fields (`attr.000`, `attr.020`, ...
`attr.280`). Shred the first
+ // 5 of them, leaving the other 10 to be written to the leftover `value`
column.
+ let shredded_fields = (0..300)
+ .step_by(20)
+ .take(5)
+ .map(|index| {
+ Arc::new(Field::new(
+ format!("attr.{index:03}"),
+ DataType::Int32,
+ true,
+ ))
+ })
+ .collect::<Vec<FieldRef>>();
+ let as_type = DataType::Struct(Fields::from(shredded_fields));
+
+ c.bench_function("shred_variant_partial_object_8k_rows", |b| {
+ b.iter(|| std::hint::black_box(shred_variant(&variant_array,
&as_type).unwrap()))
+ });
+}
+
+/// Same as [`shred_variant_partial_object_bench`], but no field of the source
objects is covered
+/// by the shredding schema, so all 15 fields per row take the leftover
`value` column path.
+pub fn shred_variant_unmatched_object_bench(c: &mut Criterion) {
+ let variant_array =
create_unshredded_object_variant_array(SHRED_VARIANT_OBJECT_ROWS);
+
+ let shredded_fields = (0..5)
+ .map(|index| {
+ Arc::new(Field::new(
+ format!("missing.{index}"),
+ DataType::Int32,
+ true,
+ ))
+ })
+ .collect::<Vec<FieldRef>>();
+ let as_type = DataType::Struct(Fields::from(shredded_fields));
+
+ c.bench_function("shred_variant_unmatched_object_8k_rows", |b| {
+ b.iter(|| std::hint::black_box(shred_variant(&variant_array,
&as_type).unwrap()))
+ });
+}
+
criterion_group!(
benches,
variant_get_bench,
variant_get_shredded_utf8_bench,
variant_get_unshredded_object_path_bench,
+ shred_variant_partial_object_bench,
+ shred_variant_unmatched_object_bench,
variant_array_builder_build_bench,
benchmark_batch_json_string_to_variant
);
diff --git a/parquet-variant-compute/src/shred_variant.rs
b/parquet-variant-compute/src/shred_variant.rs
index 751d926d4a..6c4af06d51 100644
--- a/parquet-variant-compute/src/shred_variant.rs
+++ b/parquet-variant-compute/src/shred_variant.rs
@@ -359,6 +359,9 @@ pub(crate) struct
VariantToShreddedObjectVariantRowBuilder<'a> {
typed_value_nulls: NullBufferBuilder,
nulls: NullBufferBuilder,
null_value: NullValue,
+ /// Scratch space marking which of `typed_value_builders` the current row
supplied a value for,
+ /// indexed the same way as `typed_value_builders`. Reused across rows.
+ seen: Vec<bool>,
}
impl<'a> VariantToShreddedObjectVariantRowBuilder<'a> {
@@ -379,9 +382,11 @@ impl<'a> VariantToShreddedObjectVariantRowBuilder<'a> {
)?;
Ok((field.name().as_str(), builder))
});
+ let typed_value_builders: IndexMap<_, _> =
typed_value_builders.collect::<Result<_>>()?;
Ok(Self {
value_builder: VariantValueArrayBuilder::new(capacity),
- typed_value_builders: typed_value_builders.collect::<Result<_>>()?,
+ seen: vec![false; typed_value_builders.len()],
+ typed_value_builders,
typed_value_nulls: NullBufferBuilder::new(capacity),
nulls: NullBufferBuilder::new(capacity),
null_value,
@@ -411,15 +416,21 @@ impl<'a> VariantToShreddedObjectVariantRowBuilder<'a> {
};
// Route the object's fields by name as either shredded or unshredded
- let mut builder = self.value_builder.builder_ext(value.metadata());
+ let Self {
+ value_builder,
+ typed_value_builders,
+ seen,
+ ..
+ } = self;
+ seen.fill(false);
+ let mut builder = value_builder.builder_ext(value.metadata());
let mut object_builder = builder.try_new_object()?;
- let mut seen = std::collections::HashSet::new();
let mut partially_shredded = false;
for (field_name, value) in obj.iter() {
- match self.typed_value_builders.get_mut(field_name) {
- Some(typed_value_builder) => {
+ match typed_value_builders.get_full_mut(field_name) {
+ Some((index, _, typed_value_builder)) => {
typed_value_builder.append_value(value)?;
- seen.insert(field_name);
+ seen[index] = true;
}
None => {
object_builder.insert_bytes(field_name, value);
@@ -429,8 +440,8 @@ impl<'a> VariantToShreddedObjectVariantRowBuilder<'a> {
}
// Handle missing fields
- for (field_name, typed_value_builder) in &mut
self.typed_value_builders {
- if !seen.contains(field_name) {
+ for (index, (_, typed_value_builder)) in
typed_value_builders.iter_mut().enumerate() {
+ if !seen[index] {
typed_value_builder.append_null()?;
}
}
@@ -440,7 +451,8 @@ impl<'a> VariantToShreddedObjectVariantRowBuilder<'a> {
object_builder.finish();
} else {
drop(object_builder);
- self.value_builder.append_null();
+ drop(builder);
+ value_builder.append_null();
}
self.typed_value_nulls.append_non_null();
diff --git a/parquet-variant/src/builder/metadata.rs
b/parquet-variant/src/builder/metadata.rs
index 70b5d92167..8cde95e943 100644
--- a/parquet-variant/src/builder/metadata.rs
+++ b/parquet-variant/src/builder/metadata.rs
@@ -86,6 +86,8 @@ pub struct ReadOnlyMetadataBuilder<'m> {
metadata: &'m VariantMetadata<'m>,
// A cache that tracks field names this builder has already seen, because
finding the field id
// for a given field name is expensive -- O(n) for a large and unsorted
metadata dictionary.
+ // Field names borrowed from a sorted `metadata` bypass this cache; see
+ // `VariantMetadata::borrowed_field_id`.
known_field_names: HashMap<&'m str, u32>,
}
@@ -101,6 +103,17 @@ impl<'m> ReadOnlyMetadataBuilder<'m> {
impl MetadataBuilder for ReadOnlyMetadataBuilder<'_> {
fn try_upsert_field_name(&mut self, field_name: &str) -> Result<u32,
ArrowError> {
+ // Callers that copy fields out of an object and back into the same
metadata dictionary
+ // (unshredding, shredding, and projection all do this) pass field
names that are slices of
+ // the dictionary itself. For a sorted dictionary those resolve
without hashing or any
+ // string comparison, which matters because this builder is often
created per row and so
+ // its `known_field_names` cache would otherwise be populated and
discarded without ever
+ // serving a lookup. An unsorted dictionary may hold duplicate
entries, so it keeps using
+ // the cache and the name-based search below.
+ if let Some(field_id) = self.metadata.borrowed_field_id(field_name) {
+ return Ok(field_id);
+ }
+
if let Some(field_id) = self.known_field_names.get(field_name) {
return Ok(*field_id);
}
diff --git a/parquet-variant/src/builder/object.rs
b/parquet-variant/src/builder/object.rs
index 0f6138d07d..907cc47d00 100644
--- a/parquet-variant/src/builder/object.rs
+++ b/parquet-variant/src/builder/object.rs
@@ -539,6 +539,68 @@ mod tests {
assert_eq!(obj.get("active"), Some(Variant::from(true)));
}
+ /// A dictionary that is not marked sorted may legally hold the same
string at more than one
+ /// field id. A field name must still resolve to a single id, or an object
builder sharing that
+ /// dictionary would emit two fields with the same name.
+ ///
+ /// Metadata dictionary `["a", "a"]`, unsorted:
+ const DUPLICATE_ENTRY_METADATA: &[u8] = &[
+ 0b0000_0001, // header: offset_size_minus_one=0, sorted=0, version=1
+ 2, // dictionary_size
+ 0x00,
+ 0x01,
+ 0x02,
+ b'a',
+ b'a',
+ ];
+
+ #[test]
+ fn
test_read_only_metadata_builder_duplicate_dictionary_entries_are_detected() {
+ let metadata =
VariantMetadata::try_new(DUPLICATE_ENTRY_METADATA).unwrap();
+ assert!(!metadata.is_sorted());
+
+ let mut metadata_builder = ReadOnlyMetadataBuilder::new(&metadata);
+ let mut value_builder = ValueBuilder::new();
+ let state = ParentState::variant(&mut value_builder, &mut
metadata_builder);
+ let mut obj = ObjectBuilder::new(state, true);
+
+ // Both dictionary entries name "a", so inserting both must be
reported as a duplicate
+ // field name rather than producing an object with two fields named
"a".
+ obj.insert(metadata.get(0).unwrap(), 1i8);
+ let err = obj
+ .try_insert(metadata.get(1).unwrap(), 2i8)
+ .expect_err("duplicate field name should be rejected");
+ assert!(
+ err.to_string().contains("Duplicate field name"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn
test_read_only_metadata_builder_duplicate_dictionary_entries_build_valid_object()
{
+ let metadata =
VariantMetadata::try_new(DUPLICATE_ENTRY_METADATA).unwrap();
+
+ let mut metadata_builder = ReadOnlyMetadataBuilder::new(&metadata);
+ let mut value_builder = ValueBuilder::new();
+ {
+ let state = ParentState::variant(&mut value_builder, &mut
metadata_builder);
+ let mut obj = ObjectBuilder::new(state, false);
+
+ // A name borrowed from the dictionary and an owned copy of that
same name must resolve
+ // to the same field id, so that the last write wins instead of
both being emitted.
+ obj.insert(metadata.get(1).unwrap(), 1i8);
+ let owned = String::from("a");
+ obj.insert(owned.as_str(), 2i8);
+ obj.finish();
+ }
+
+ let value = value_builder.into_inner();
+ let variant = Variant::try_new(DUPLICATE_ENTRY_METADATA,
&value).unwrap();
+ let obj = variant.as_object().unwrap();
+ assert_eq!(obj.len(), 1);
+ assert_eq!(obj.get("a"), Some(Variant::Int8(2)));
+ }
+
// matthew
#[test]
fn test_append_object() {
diff --git a/parquet-variant/src/variant/metadata.rs
b/parquet-variant/src/variant/metadata.rs
index b6f80508ee..99de7b6447 100644
--- a/parquet-variant/src/variant/metadata.rs
+++ b/parquet-variant/src/variant/metadata.rs
@@ -391,6 +391,78 @@ impl<'m> VariantMetadata<'m> {
self.get(i).expect("Invalid metadata dictionary entry")
}
+ /// Attempts to resolve `field_name` to its field id under the assumption
that `field_name` is
+ /// itself a slice of this dictionary's value region, as is the case for
every field name
+ /// obtained from [`VariantObject::field_name`] or [`Self::get`] on the
same metadata instance.
+ /// Returns `None` when that assumption does not hold, so callers must be
prepared to fall back
+ /// to a name-based search such as [`Self::get_entry`].
+ ///
+ /// This is much cheaper than searching by name, because a borrowed field
name already encodes
+ /// its own field id: it belongs to the entry whose dictionary offset
equals its distance from
+ /// the start of the value region. Finding that entry is a binary search
over the
+ /// (non-decreasing) offset array, comparing integers rather than decoding
and comparing
+ /// dictionary strings at every step, and it needs no string comparison to
confirm the hit.
+ ///
+ /// # Correctness
+ ///
+ /// This is only attempted for a [sorted] dictionary. The spec requires
dictionary entries to
+ /// be unique only when `sorted_strings` is set, so an unsorted dictionary
may legally hold the
+ /// same string at more than one field id. Resolving a borrowed name by
the id it came from
+ /// would then disagree with a resolution by name, and callers rely on a
name mapping to a
+ /// single id: [`ObjectBuilder`] detects duplicate fields by comparing
field ids, so two ids
+ /// naming the same string would build an object whose field names are not
unique. Requiring
+ /// sortedness makes the two resolutions agree, because validation rejects
a sorted dictionary
+ /// with duplicate entries.
+ ///
+ /// A returned field id is also always verified, so this never reports an
id whose entry is not
+ /// `field_name` itself, even for [invalid] metadata whose offsets are
arbitrary. The candidate
+ /// entry is accepted only when it starts at `field_name`'s address and
has exactly
+ /// `field_name`'s length, which makes the entry's bytes and
`field_name`'s bytes the same
+ /// bytes. (Two live allocations cannot overlap, so an address inside our
own byte range
+ /// belongs to our own bytes. In the degenerate zero-length case the two
are both empty and
+ /// therefore still equal.)
+ ///
+ /// [`ObjectBuilder`]: crate::ObjectBuilder
+ /// [`VariantObject::field_name`]: crate::VariantObject::field_name
+ /// [invalid]: Self#Validation
+ /// [sorted]: Self::is_sorted
+ pub(crate) fn borrowed_field_id(&self, field_name: &str) -> Option<u32> {
+ // An unsorted dictionary may hold duplicate entries, which would make
the id a name was
+ // borrowed from differ from the id a search by name returns; see
"Correctness" above.
+ if !self.is_sorted() {
+ return None;
+ }
+
+ // Addresses are compared as integers and never dereferenced, so this
stays safe even when
+ // `field_name` borrows from an unrelated allocation.
+ let value_region_start = (self.bytes.as_ptr() as usize) +
self.first_value_byte as usize;
+ let value_region_end = (self.bytes.as_ptr() as usize) +
self.bytes.len();
+ let field_name_start = field_name.as_ptr() as usize;
+ if field_name_start < value_region_start || field_name_start >=
value_region_end {
+ return None;
+ }
+ let field_name_offset = u32::try_from(field_name_start -
value_region_start).ok()?;
+
+ // Hoist the offset array out of the search, so each step is just an
unaligned load.
+ let offset_byte_range = self.header.first_offset_byte() as
_..self.first_value_byte as _;
+ let offsets = slice_from_slice(self.bytes, offset_byte_range).ok()?;
+ let offset_size = self.header.offset_size;
+ let cmp = |i| {
+ Some(
+ offset_size
+ .unpack_u32(offsets, i)
+ .ok()?
+ .cmp(&field_name_offset),
+ )
+ };
+ let field_id = try_binary_search_range_by(0..self.len(), cmp)?.ok()?;
+
+ // Verify that this entry has exactly `field_name`'s bytes; see
"Correctness" above.
+ let entry_end = offset_size.unpack_u32(offsets, field_id + 1).ok()?;
+ let entry_len = entry_end.checked_sub(field_name_offset)?;
+ (entry_len as usize == field_name.len()).then_some(field_id as u32)
+ }
+
/// Attempts to retrieve a dictionary entry and its field id, returning
None if the requested field
/// name is not present. The search cost is logarithmic if
[`Self::is_sorted`] and linear
/// otherwise.
@@ -401,6 +473,12 @@ impl<'m> VariantMetadata<'m> {
///
/// [invalid]: Self#Validation
pub fn get_entry(&self, field_name: &str) -> Option<(u32, &'m str)> {
+ // A field name borrowed from this dictionary's (sorted) value region
resolves without
+ // any string comparisons.
+ if let Some(field_id) = self.borrowed_field_id(field_name) {
+ return Some((field_id, self.get_impl(field_id as _)));
+ }
+
let field_id = if self.is_sorted() && self.len() > 10 {
// Binary search is faster for a not-tiny sorted metadata
dictionary
let cmp = |i| Some(self.get_impl(i).cmp(field_name));
@@ -650,6 +728,222 @@ mod tests {
assert_eq!(&metadata[1], "");
}
+ /// Builds a metadata dictionary containing `field_names`, in the order
given.
+ fn metadata_bytes_for(field_names: &[&str]) -> Vec<u8> {
+ let mut builder =
VariantBuilder::new().with_field_names(field_names.iter().copied());
+ let mut object = builder.new_object();
+ for name in field_names {
+ object.insert(name, 1i32);
+ }
+ object.finish();
+ builder.finish().0
+ }
+
+ /// Every entry of `metadata` must resolve to its own field id through the
general name search
+ /// and through an owned (non-borrowed) copy of the name. The borrowed
fast path must agree
+ /// whenever it applies, which is only for a sorted dictionary.
+ fn assert_lookups_agree(metadata: &VariantMetadata<'_>) {
+ for i in 0..metadata.len() {
+ let borrowed = metadata.get(i).unwrap();
+ let owned = borrowed.to_string();
+
+ assert_eq!(
+ metadata.borrowed_field_id(borrowed),
+ metadata.is_sorted().then_some(i as u32),
+ "borrowed lookup of {borrowed:?} (field id {i})"
+ );
+ assert_eq!(
+ metadata.get_entry(borrowed),
+ Some((i as u32, borrowed)),
+ "get_entry of borrowed {borrowed:?} (field id {i})"
+ );
+ assert_eq!(
+ metadata.get_entry(owned.as_str()),
+ Some((i as u32, borrowed)),
+ "get_entry of owned {borrowed:?} (field id {i})"
+ );
+ // An owned copy does not borrow from the dictionary, so it cannot
take the fast path.
+ assert_eq!(metadata.borrowed_field_id(owned.as_str()), None);
+ }
+ }
+
+ /// Like [`assert_lookups_agree`], but tolerates the fast path declining
an entry, as it may
+ /// for a dictionary whose offset array does not increase strictly.
+ fn assert_lookups_agree_or_decline(metadata: &VariantMetadata<'_>) {
+ for i in 0..metadata.len() {
+ let borrowed = metadata.get(i).unwrap();
+ if let Some(field_id) = metadata.borrowed_field_id(borrowed) {
+ assert_eq!(field_id, i as u32, "borrowed lookup of
{borrowed:?}");
+ }
+ assert_eq!(
+ metadata.get_entry(borrowed),
+ Some((i as u32, borrowed)),
+ "get_entry of borrowed {borrowed:?} (field id {i})"
+ );
+ }
+ }
+
+ #[test]
+ fn test_borrowed_field_id_sorted_dictionary() {
+ // More than 10 entries, so `get_entry` uses its binary search path.
+ let names: Vec<String> = (0..64).map(|i|
format!("field_{i:03}")).collect();
+ let names: Vec<&str> = names.iter().map(String::as_str).collect();
+ let bytes = metadata_bytes_for(&names);
+ let metadata = VariantMetadata::try_new(&bytes).unwrap();
+
+ assert!(metadata.is_sorted());
+ assert_eq!(metadata.len(), 64);
+ assert_lookups_agree(&metadata);
+
+ assert_eq!(metadata.get_entry("field_999"), None);
+ assert_eq!(metadata.borrowed_field_id("field_999"), None);
+ }
+
+ #[test]
+ fn test_borrowed_field_id_unsorted_dictionary() {
+ let bytes = metadata_bytes_for(&["zebra", "apple", "mango", "kiwi"]);
+ let metadata = VariantMetadata::try_new(&bytes).unwrap();
+
+ assert!(!metadata.is_sorted());
+ assert_eq!(metadata.len(), 4);
+ // An unsorted dictionary may hold duplicate entries, so the fast path
never applies to it
+ // and every name falls back to the general search.
+ assert_lookups_agree(&metadata);
+ }
+
+ /// A dictionary that is not marked sorted may legally repeat a string,
and the two field ids
+ /// naming it must not become distinguishable by where the caller's name
was borrowed from:
+ /// `ObjectBuilder` detects duplicate fields by field id, so a name must
map to a single id.
+ #[test]
+ fn test_borrowed_field_id_unsorted_dictionary_with_duplicate_entries() {
+ let bytes = &[
+ 0b0000_0001, // header: offset_size_minus_one=0, sorted=0,
version=1
+ 2, // dictionary_size
+ 0x00,
+ 0x01,
+ 0x02,
+ b'a',
+ b'a',
+ ];
+ let metadata = VariantMetadata::try_new(bytes).unwrap();
+ assert!(!metadata.is_sorted());
+ assert_eq!(metadata.get(0).unwrap(), "a");
+ assert_eq!(metadata.get(1).unwrap(), "a");
+
+ // Whichever entry a name was borrowed from, it resolves to the first
entry naming it.
+ let owned = String::from("a");
+ for name in [metadata.get(0).unwrap(), metadata.get(1).unwrap(),
&owned] {
+ assert_eq!(metadata.borrowed_field_id(name), None);
+ assert_eq!(metadata.get_entry(name), Some((0, "a")));
+ }
+ }
+
+ #[test]
+ fn test_borrowed_field_id_ignores_names_from_another_dictionary() {
+ let this_bytes = metadata_bytes_for(&["alpha", "beta", "gamma"]);
+ let this = VariantMetadata::try_new(&this_bytes).unwrap();
+
+ // A different dictionary that shares some names, with different field
ids for them.
+ let other_bytes = metadata_bytes_for(&["delta", "gamma", "beta",
"alpha"]);
+ let other = VariantMetadata::try_new(&other_bytes).unwrap();
+
+ for i in 0..other.len() {
+ let name = other.get(i).unwrap();
+ // A copy that borrows from neither dictionary, to compare results
against.
+ let unborrowed_name: String = name.chars().collect();
+ // The name borrows from `other`, so `this` must not take the fast
path for it...
+ assert_eq!(this.borrowed_field_id(name), None);
+ // ...and the general search must still return `this`'s own field
id for that name.
+ assert_eq!(this.get_entry(name), this.get_entry(&unborrowed_name));
+ }
+
+ assert_eq!(this.get_entry(other.get(3).unwrap()), Some((0, "alpha")));
+ assert_eq!(this.get_entry(other.get(0).unwrap()), None);
+ }
+
+ #[test]
+ fn test_borrowed_field_id_rejects_substring_of_an_entry() {
+ // Dictionary ["x", "yy"], stored as the bytes "xyy". A slice of entry
1 that starts one
+ // byte into the value region shares its start offset with entry 1
without being equal to
+ // it, which the fast path must detect rather than reporting a bogus
field id. The
+ // dictionary is sorted, so the fast path applies to it.
+ let bytes = &[
+ 0b0001_0001, // header: offset_size_minus_one=0, ordered=1,
version=1
+ 2, // dictionary_size
+ 0x00,
+ 0x01,
+ 0x03,
+ b'x',
+ b'y',
+ b'y',
+ ];
+ let metadata = VariantMetadata::try_new(bytes).unwrap();
+ assert!(metadata.is_sorted());
+ assert_eq!(metadata.get(0).unwrap(), "x");
+ assert_eq!(metadata.get(1).unwrap(), "yy");
+ assert_eq!(
+ metadata.borrowed_field_id(metadata.get(1).unwrap()),
+ Some(1)
+ );
+
+ let prefix_of_entry_1 = &metadata.get(1).unwrap()[..1];
+ assert_eq!(prefix_of_entry_1, "y");
+ assert_eq!(metadata.borrowed_field_id(prefix_of_entry_1), None);
+ assert_eq!(metadata.get_entry(prefix_of_entry_1), None);
+
+ // A slice that starts inside an entry, at an offset no entry starts
at, is also rejected.
+ let suffix_of_entry_1 = &metadata.get(1).unwrap()[1..];
+ assert_eq!(metadata.borrowed_field_id(suffix_of_entry_1), None);
+ }
+
+ #[test]
+ fn test_borrowed_field_id_with_empty_field_name() {
+ let bytes = &[
+ 0b0000_0001, // header: offset_size_minus_one=0, ordered=0,
version=1
+ 2, // dictionary_size
+ 0x00,
+ 0x02,
+ 0x02, // an unsorted dict may hold an empty string anywhere
+ b'h',
+ b'i',
+ ];
+ let metadata = VariantMetadata::try_new(bytes).unwrap();
+ assert!(!metadata.is_sorted());
+ assert_eq!(metadata.get(0).unwrap(), "hi");
+ assert_eq!(metadata.get(1).unwrap(), "");
+
+ assert_eq!(
+ metadata.get_entry(metadata.get(0).unwrap()),
+ Some((0, "hi"))
+ );
+ assert_eq!(metadata.get_entry(metadata.get(1).unwrap()), Some((1,
"")));
+ assert_eq!(metadata.get_entry(""), Some((1, "")));
+ assert_eq!(metadata.borrowed_field_id(metadata.get(0).unwrap()), None);
+ }
+
+ #[test]
+ fn test_borrowed_field_id_with_leading_empty_field_name() {
+ // A sorted dictionary can only hold an empty string as its first
entry, where it shares a
+ // start offset with the entry after it. The fast path is free to
decline such an
+ // ambiguous offset, but must never report the wrong field id for it.
+ let bytes = &[
+ 0b0001_0001, // header: offset_size_minus_one=0, ordered=1,
version=1
+ 2, // dictionary_size
+ 0x00,
+ 0x00,
+ 0x02,
+ b'h',
+ b'i',
+ ];
+ let metadata = VariantMetadata::try_new(bytes).unwrap();
+ assert!(metadata.is_sorted());
+ assert_eq!(metadata.get(0).unwrap(), "");
+ assert_eq!(metadata.get(1).unwrap(), "hi");
+
+ assert_lookups_agree_or_decline(&metadata);
+ assert_eq!(metadata.get_entry(""), Some((0, "")));
+ }
+
#[test]
fn test_compare_sorted_dictionary_with_unsorted_dictionary() {
// create a sorted object