Skip to main content

rudb_vector/
string.rs

1//! The string representation.
2//!
3//! `spec/07-execution.md` section 7.1: a string is a 16 byte structure, 4 bytes of length, 4 bytes
4//! of prefix, and 8 bytes that are either the rest of a short string or a way to find a long one.
5//! Strings of 12 bytes or fewer live entirely inside the structure. The prefix means most
6//! comparisons and most equality tests answer without dereferencing anything, which on the string
7//! heavy queries in ClickBench is the difference between a cache hit and a cache miss per row.
8//!
9//! **Where this differs from the specification, and why.** The document says the last 8 bytes are
10//! a pointer, which is what DuckDB and Umbra do. Here they are a block index and an offset, which
11//! is what Arrow's `StringView` does. The sizes are identical, the prefix trick is identical, and
12//! the prefix trick is the part that makes it fast. The difference is one predictable load against
13//! one pointer chase on the slow path only, and in exchange the whole representation is safe code
14//! with no pinning machinery, which does not exist until the buffer manager arrives at M2. This is
15//! the kind of decision that gets remeasured rather than argued about, and it is tracked as an
16//! issue so that M3 measures it instead of inheriting it.
17
18use rudb_common::{Error, Result};
19
20use crate::buffer::Buffer;
21
22/// The longest string that fits entirely inside a view.
23pub const INLINE_LIMIT: usize = 12;
24
25/// A 16 byte handle on a string.
26///
27/// The layout is a `u32` length and 12 bytes of payload. For a string of 12 bytes or fewer the
28/// payload is the string, zero padded. For a longer one the first 4 bytes are the prefix and the
29/// last 8 are the offset into the column's arena.
30///
31/// Arrow spends 4 of those 8 bytes on a buffer index and 4 on an offset within the buffer, because
32/// an Arrow array is a list of buffers. This column is one arena, so there is no buffer to name and
33/// the whole 8 bytes are the offset, which reads as one load rather than two and takes the reachable
34/// size of a column from 4 GiB to more than anything will ever put in one.
35///
36/// A view on its own cannot produce a long string, only a short one. That is deliberate: the arena
37/// lives in the [`StringColumn`] and the borrow checker is what stops a view from outliving it,
38/// rather than a rule somebody has to remember.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct StringView {
41    length: u32,
42    payload: [u8; 12],
43}
44
45impl StringView {
46    /// A view on a string that fits inline.
47    ///
48    /// # Panics
49    ///
50    /// If the string is longer than [`INLINE_LIMIT`]. Callers that do not know the length go
51    /// through [`StringColumn::push`], which decides.
52    #[must_use]
53    pub fn inline(text: &str) -> Self {
54        assert!(text.len() <= INLINE_LIMIT, "a string of {} bytes is not inline", text.len());
55        let mut payload = [0u8; 12];
56        payload[..text.len()].copy_from_slice(text.as_bytes());
57        Self { length: text.len() as u32, payload }
58    }
59
60    /// A view on a string that lives in the arena.
61    fn indirect(text: &str, offset: u64) -> Self {
62        let mut payload = [0u8; 12];
63        payload[..4].copy_from_slice(&text.as_bytes()[..4]);
64        payload[4..].copy_from_slice(&offset.to_le_bytes());
65        Self { length: text.len() as u32, payload }
66    }
67
68    /// The length in bytes.
69    #[must_use]
70    pub fn len(&self) -> usize {
71        self.length as usize
72    }
73
74    /// Whether the string is empty.
75    #[must_use]
76    pub fn is_empty(&self) -> bool {
77        self.length == 0
78    }
79
80    /// Whether the whole string is in the view.
81    #[must_use]
82    pub fn is_inline(&self) -> bool {
83        self.len() <= INLINE_LIMIT
84    }
85
86    /// The first four bytes, zero padded.
87    ///
88    /// This is the whole point of the representation. Two strings with different prefixes are
89    /// different, and two strings with the same prefix are usually equal, so a filter on a string
90    /// column resolves without touching the payload on almost every row.
91    #[must_use]
92    pub fn prefix(&self) -> [u8; 4] {
93        [self.payload[0], self.payload[1], self.payload[2], self.payload[3]]
94    }
95
96    /// The bytes, when the whole string is in the view.
97    ///
98    /// A comparison wants bytes rather than a `&str`, because SQL's string order is byte order and
99    /// because [`Self::as_inline_str`] pays for a UTF-8 validation that a comparison has no use
100    /// for. On a filter against a varchar column that validation is the whole cost of the row.
101    #[must_use]
102    pub fn inline_bytes(&self) -> Option<&[u8]> {
103        if self.is_inline() { Some(&self.payload[..self.len()]) } else { None }
104    }
105
106    /// The string, when it is short enough to be in the view.
107    #[must_use]
108    pub fn as_inline_str(&self) -> Option<&str> {
109        if !self.is_inline() {
110            return None;
111        }
112        // Every constructor takes a `&str`, so the bytes came from valid UTF-8 and a prefix of the
113        // inline payload up to the recorded length is exactly what was written.
114        std::str::from_utf8(&self.payload[..self.len()]).ok()
115    }
116
117    fn offset(&self) -> usize {
118        u64::from_le_bytes([
119            self.payload[4],
120            self.payload[5],
121            self.payload[6],
122            self.payload[7],
123            self.payload[8],
124            self.payload[9],
125            self.payload[10],
126            self.payload[11],
127        ]) as usize
128    }
129
130    /// Whether these two views are definitely different, answered from the view alone.
131    ///
132    /// A `false` here means the payloads have to be compared. A `true` means they do not, which on
133    /// a filter against a selective literal is almost every row.
134    #[must_use]
135    pub fn definitely_differs(&self, other: &Self) -> bool {
136        self.length != other.length || self.prefix() != other.prefix()
137    }
138}
139
140/// A column of strings: the views, and the one arena the long ones live in.
141///
142/// The arena is append only, so an offset recorded in a view stays correct for the life of the
143/// column even though the arena's address does not. That is the property a `Vec<u8>` has and a raw
144/// pointer into it does not, and it is the reason a view holds an offset.
145///
146/// This was a `Vec<Vec<u8>>` of fixed size blocks, which meant reading one long string was two
147/// dependent loads, the outer vector's element to find the block's data pointer and then the bytes.
148/// One arena makes it one, from a base the compiler can keep in a register across a row loop, and it
149/// deletes the case where a string longer than a block needed a block of its own. On server3, over a
150/// chunk of 1024 strings, comparing a column against a literal went from 14.9 nanoseconds a row to
151/// 13.2 at 40 bytes a string and from 14.2 to 12.9 at 120, gathering half the rows from 29.5 to 25.3
152/// and from 36.9 to 29.1, and building the column from 12.0 to 8.9 at 40 bytes.
153///
154/// # The one number that got worse, and what it actually is
155///
156/// Building a column whose payload passes 128 KiB, which at 1024 rows means strings averaging more
157/// than 128 bytes, went the other way: 14.6 nanoseconds a row to 41.0. That is not the copy and it
158/// is not the doubling, it is glibc. An allocation that size comes from `mmap` rather than the heap,
159/// so it is handed back to the kernel when the column is dropped and the next chunk faults every
160/// page of it in again, while sixteen KiB blocks come back off a free list already faulted. Run the
161/// same benchmark with `MALLOC_MMAP_THRESHOLD_` raised and the arena builds that column in 9.6
162/// nanoseconds a row against the blocks' 16.2, so the design is not what is slow there.
163///
164/// The fix is that a chunk's payload should come from a pool the engine owns rather than from
165/// `malloc` per chunk, which is the buffer manager at layer three and is where this belongs.
166/// [`Self::reserve_bytes`] is the part that is available now, and it recovers a quarter of it.
167///
168/// # Equality is about the strings and not about the arena
169///
170/// [`Self::over`] means two columns holding exactly the same strings can hold completely different
171/// arenas, because one of them was built by copying the strings in and the other was built over a
172/// page that already had them somewhere in it with other strings in between. Derived equality would
173/// call those two columns different, and every test in the workspace that compares two vectors would
174/// then be asserting on how a column was built rather than on what is in it. So equality is the
175/// strings, position by position, which is the only definition that survives the seam.
176#[derive(Debug, Clone, Default, Eq)]
177pub struct StringColumn {
178    views: Vec<StringView>,
179    arena: Buffer<u8>,
180}
181
182impl StringColumn {
183    /// An empty column.
184    #[must_use]
185    pub fn new() -> Self {
186        Self::default()
187    }
188
189    /// An empty column with room for `capacity` strings.
190    #[must_use]
191    pub fn with_capacity(capacity: usize) -> Self {
192        Self { views: Vec::with_capacity(capacity), arena: Buffer::new() }
193    }
194
195    /// A column with no strings in it yet, over an arena that already holds bytes.
196    ///
197    /// The seam `spec/engine/03-data-plane.md` section 3.5 asks for. Without it the only way in is
198    /// [`Self::push`], which copies, so a scan reading a Parquet page of strings copies every byte of
199    /// the page into an arena and the query then reads the copy. With it the page is the arena: the
200    /// scan hands the bytes over once, records where each string starts with
201    /// [`Self::push_in_place`], and nothing is copied but the views.
202    ///
203    /// It is useful today, because a reader that already has the page in a `Vec<u8>` can move it in
204    /// rather than copy out of it. It matters at layer three, when the [`Buffer`] is the pinned page
205    /// itself and the move is not even that.
206    ///
207    /// Appending with [`Self::push`] afterwards still works and still appends to the arena. That is
208    /// the case to keep away from once a real page is in here, because writing through a borrowed
209    /// buffer copies it, which is [`Buffer::to_mut`] and is the whole page.
210    #[must_use]
211    pub fn over(arena: Buffer<u8>) -> Self {
212        Self { views: Vec::new(), arena }
213    }
214
215    /// How many strings are in the column.
216    #[must_use]
217    pub fn len(&self) -> usize {
218        self.views.len()
219    }
220
221    /// Whether the column has no strings in it.
222    #[must_use]
223    pub fn is_empty(&self) -> bool {
224        self.views.is_empty()
225    }
226
227    /// The views, for a kernel that wants to compare prefixes without reading any payload.
228    #[must_use]
229    pub fn views(&self) -> &[StringView] {
230        &self.views
231    }
232
233    /// Appends a string and returns its index.
234    pub fn push(&mut self, text: &str) -> usize {
235        let view = if text.len() <= INLINE_LIMIT {
236            StringView::inline(text)
237        } else {
238            let offset = self.arena.len() as u64;
239            self.arena.extend_from_slice(text.as_bytes());
240            StringView::indirect(text, offset)
241        };
242        self.views.push(view);
243        self.views.len() - 1
244    }
245
246    /// Records a string that is already in the arena, and returns its index.
247    ///
248    /// The half of the seam that does the work. [`Self::over`] puts the page in, this says where in
249    /// it a string is, and between them a column of long strings is built without the payload being
250    /// touched at all.
251    ///
252    /// A string short enough to sit inside a view is copied into the view, which is at most twelve
253    /// bytes and is what makes it readable without going near the arena at all. Everything longer
254    /// keeps its bytes where they are and the view records the offset.
255    ///
256    /// # Errors
257    ///
258    /// If the range is not inside the arena, or if the bytes are not valid UTF-8. The validation is
259    /// the one cost this seam does not remove, and it is here rather than skipped because
260    /// [`Self::get`] hands back a `&str` and a column that cannot produce one for a string it claims
261    /// to hold is a wrong answer rather than a slow one. A scan over a page where the format
262    /// guarantees UTF-8 wants to validate the page once instead of once per string, which is a pass
263    /// the layer three reader makes and is not something this type can do on its behalf.
264    pub fn push_in_place(&mut self, offset: usize, len: usize) -> Result<usize> {
265        let end = offset.checked_add(len).ok_or_else(|| {
266            Error::internal(format!(
267                "a string at {offset} of {len} bytes runs off the end of memory"
268            ))
269        })?;
270        let bytes = self.arena.get(offset..end).ok_or_else(|| {
271            Error::internal(format!(
272                "a string at {offset} of {len} bytes is not inside a {} byte arena",
273                self.arena.len()
274            ))
275        })?;
276        let text = std::str::from_utf8(bytes)
277            .map_err(|_| Error::internal(format!("the bytes at {offset} are not valid UTF-8")))?;
278        let view = if len <= INLINE_LIMIT {
279            StringView::inline(text)
280        } else {
281            StringView::indirect(text, offset as u64)
282        };
283        self.views.push(view);
284        Ok(self.views.len() - 1)
285    }
286
287    /// The bytes the long strings live in.
288    ///
289    /// For a column over a page this is the page, including whatever of it no view points at. The
290    /// offsets in the views are offsets into exactly this, which is what makes them meaningful to a
291    /// reader that put the page here in the first place.
292    #[must_use]
293    pub fn arena(&self) -> &[u8] {
294        &self.arena
295    }
296
297    /// The bytes at `index`, or `None` past the end.
298    ///
299    /// This is what a comparison, a hash and an equality check all actually want, and it is worth
300    /// having separately from [`Self::get`] because that one validates UTF-8 and they do not need
301    /// it. Everything in a column arrived through [`Self::push`], which takes a `&str`, so the
302    /// bytes are valid either way and the validation is a scan of the payload that changes no
303    /// answer. On a varchar filter it was measured at most of the per row cost.
304    #[must_use]
305    pub fn bytes(&self, index: usize) -> Option<&[u8]> {
306        let view = self.views.get(index)?;
307        if let Some(inline) = view.inline_bytes() {
308            return Some(inline);
309        }
310        self.arena.get(view.offset()..view.offset() + view.len())
311    }
312
313    /// The string at `index`, or `None` past the end.
314    #[must_use]
315    pub fn get(&self, index: usize) -> Option<&str> {
316        // Written from a `&str` into a block that is append only, so the bytes are the same bytes.
317        std::str::from_utf8(self.bytes(index)?).ok()
318    }
319
320    /// Every string in order.
321    pub fn iter(&self) -> impl Iterator<Item = &str> {
322        (0..self.len()).filter_map(|index| self.get(index))
323    }
324
325    /// Total bytes of payload held in the arena, which is what the memory accounting wants.
326    ///
327    /// For a column over a page it is the page and not the part of it any view points at, which is
328    /// the right answer for accounting, because the page is what is resident.
329    #[must_use]
330    pub fn heap_bytes(&self) -> usize {
331        self.arena.len()
332    }
333
334    /// Room for `bytes` of payload, taken in one allocation rather than as the strings arrive.
335    ///
336    /// A builder that knows the total byte count, which a scan reading a page and a gather copying a
337    /// column both do, saves the doubling entirely. Nothing is wrong without it, which is why it is
338    /// a hint and not a constructor argument.
339    pub fn reserve_bytes(&mut self, bytes: usize) {
340        self.arena.reserve(bytes);
341    }
342}
343
344/// Two columns are equal when they hold the same strings in the same order, whatever their arenas
345/// look like.
346///
347/// See the note on [`StringColumn`]. Comparing the views is not enough on its own either, because
348/// two views of the same long string at different offsets in different arenas are different views,
349/// so the comparison is length, then view by view with the payload read for the ones that are not
350/// inline. The prefix inside the view is what makes that cheap: a pair that differs in the first
351/// four bytes or in the length is settled without either arena being touched.
352impl PartialEq for StringColumn {
353    fn eq(&self, other: &Self) -> bool {
354        self.views.len() == other.views.len()
355            && (0..self.views.len()).all(|index| {
356                let mine = self.views[index];
357                let theirs = other.views[index];
358                if mine.definitely_differs(&theirs) {
359                    return false;
360                }
361                if mine.is_inline() {
362                    return mine == theirs;
363                }
364                self.bytes(index) == other.bytes(index)
365            })
366    }
367}
368
369impl<'a> Extend<&'a str> for StringColumn {
370    fn extend<T: IntoIterator<Item = &'a str>>(&mut self, iter: T) {
371        for text in iter {
372            self.push(text);
373        }
374    }
375}
376
377impl<'a> FromIterator<&'a str> for StringColumn {
378    fn from_iter<T: IntoIterator<Item = &'a str>>(iter: T) -> Self {
379        let mut column = Self::new();
380        column.extend(iter);
381        column
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::{INLINE_LIMIT, StringColumn, StringView};
388    use crate::buffer::Buffer;
389
390    /// The seam, used the way layer three will use it. The page arrives whole, each string is
391    /// recorded where it already is, and the arena at the end is the page byte for byte, including
392    /// the header this page has in front of the strings and the bytes between them that belong to
393    /// nothing. A column that had copied would have an arena the size of the strings instead.
394    #[test]
395    fn a_column_over_a_page_records_the_strings_without_moving_them() {
396        let page =
397            b"HEADER..a string well past the inline limit!!a second one past the limit".to_vec();
398        let mut column = StringColumn::over(Buffer::from_vec(page.clone()));
399        assert_eq!(column.push_in_place(8, 37).expect("inside the page"), 0);
400        assert_eq!(column.push_in_place(45, 27).expect("inside the page"), 1);
401        assert_eq!(column.get(0), Some("a string well past the inline limit!!"));
402        assert_eq!(column.get(1), Some("a second one past the limit"));
403        assert_eq!(column.arena(), page.as_slice());
404        assert_eq!(column.heap_bytes(), page.len());
405        assert_eq!(column.len(), 2);
406    }
407
408    /// A string short enough to live inside its view is copied into the view, which is twelve bytes
409    /// and is what lets it be read without the arena. The page is still the arena and is still
410    /// untouched, so a page of short strings costs the views and nothing else.
411    #[test]
412    fn a_short_string_in_a_page_is_copied_into_its_view() {
413        let mut column = StringColumn::over(Buffer::from_vec(b"one.two".to_vec()));
414        column.push_in_place(0, 3).expect("inside the page");
415        column.push_in_place(4, 3).expect("inside the page");
416        assert!(column.views()[0].is_inline());
417        assert_eq!(column.get(0), Some("one"));
418        assert_eq!(column.get(1), Some("two"));
419        assert_eq!(column.arena(), b"one.two");
420    }
421
422    /// The two ways a caller can be wrong about a page, both of them answered before anything is
423    /// recorded rather than at the point somebody reads the string back and finds nothing there.
424    #[test]
425    fn a_range_outside_the_page_or_bytes_that_are_not_text_are_refused() {
426        let mut column = StringColumn::over(Buffer::from_vec(vec![0xff, 0xfe, 0xfd]));
427        assert!(column.push_in_place(2, 4).is_err());
428        assert!(column.push_in_place(usize::MAX, 1).is_err());
429        assert!(column.push_in_place(0, 3).is_err());
430        assert_eq!(column.len(), 0);
431    }
432
433    /// What the seam does to equality. The same two strings, one column built by copying them in
434    /// and one built over a page that has them in the other order with a gap in the middle, and the
435    /// two arenas have nothing in common. Equality is the strings, so the columns are equal.
436    #[test]
437    fn the_same_strings_over_different_arenas_are_the_same_column() {
438        let copied: StringColumn =
439            ["the first string past the limit", "the second string past the limit"]
440                .into_iter()
441                .collect();
442        let page =
443            b"gap!the second string past the limit....the first string past the limit".to_vec();
444        let mut over = StringColumn::over(Buffer::from_vec(page));
445        over.push_in_place(40, 31).expect("inside the page");
446        over.push_in_place(4, 32).expect("inside the page");
447        assert_ne!(copied.arena(), over.arena());
448        assert_eq!(copied, over);
449
450        let mut different: StringColumn = copied.clone();
451        different.push("a third one past the inline limit");
452        assert_ne!(copied, different);
453    }
454
455    #[test]
456    fn a_view_is_sixteen_bytes_and_stays_sixteen_bytes() {
457        // The number the whole design is built around. A vector of 1024 strings is 16 KiB of
458        // views, which is the budget spec/07-execution.md section 7.1 spends on purpose.
459        assert_eq!(size_of::<StringView>(), 16);
460        assert_eq!(align_of::<StringView>(), 4);
461    }
462
463    #[test]
464    fn twelve_bytes_is_inline_and_thirteen_is_not() {
465        let mut column = StringColumn::new();
466        column.push("123456789012");
467        column.push("1234567890123");
468        assert!(column.views()[0].is_inline());
469        assert!(!column.views()[1].is_inline());
470        assert_eq!(column.get(0), Some("123456789012"));
471        assert_eq!(column.get(1), Some("1234567890123"));
472        assert_eq!(INLINE_LIMIT, 12);
473    }
474
475    #[test]
476    fn a_prefix_answers_the_comparison_without_reading_the_payload() {
477        let mut column = StringColumn::new();
478        column.push("https://example.com/a");
479        column.push("https://example.com/b");
480        column.push("mailto:someone@example.com");
481        let views = column.views();
482        // Same prefix, same length: the payloads have to be read. This is the case the prefix
483        // cannot help with, and on a URL column it is the common case, which is why the
484        // dictionary work at M3 matters more than this does.
485        assert!(!views[0].definitely_differs(&views[1]));
486        // Different prefix: answered from the view.
487        assert!(views[0].definitely_differs(&views[2]));
488    }
489
490    /// A string of any size goes in whole, with the short ones on either side of it still reading
491    /// back. The old layout had a size at which a string stopped fitting a block and got one of its
492    /// own, and one arena has no such size, so the case worth keeping is the one that used to be
493    /// special rather than the branch that used to handle it.
494    #[test]
495    fn a_string_far_larger_than_any_block_would_have_been_goes_in_whole() {
496        let long = "x".repeat(40 * 1024);
497        let mut column = StringColumn::new();
498        column.push("short");
499        column.push(&long);
500        column.push("also short");
501        assert_eq!(column.get(1), Some(long.as_str()));
502        assert_eq!(column.get(2), Some("also short"));
503        assert_eq!(column.heap_bytes(), long.len());
504    }
505
506    /// The property the whole arena rests on. Two thousand strings is tens of reallocations, and
507    /// every one of them moves the bytes to a new address while the offsets recorded in the views
508    /// before it stay exactly as they were. A view holding a pointer would be reading freed memory
509    /// by the end of this test.
510    #[test]
511    fn the_arena_moving_underneath_does_not_move_what_the_views_point_at() {
512        let mut column = StringColumn::new();
513        let strings: Vec<String> =
514            (0..2000).map(|i| format!("value number {i} padded out")).collect();
515        for text in &strings {
516            column.push(text);
517        }
518        for (index, text) in strings.iter().enumerate() {
519            assert_eq!(column.get(index), Some(text.as_str()), "at {index}");
520        }
521        assert_eq!(column.len(), 2000);
522        assert_eq!(column.iter().count(), 2000);
523    }
524
525    #[test]
526    fn reserving_bytes_changes_nothing_but_where_the_allocation_happens() {
527        let mut column = StringColumn::with_capacity(3);
528        column.reserve_bytes(128);
529        for text in ["a string past the limit", "another one past it", "short"] {
530            column.push(text);
531        }
532        assert_eq!(column.get(0), Some("a string past the limit"));
533        assert_eq!(column.get(1), Some("another one past it"));
534        assert_eq!(column.get(2), Some("short"));
535        assert_eq!(column.heap_bytes(), 42);
536    }
537
538    #[test]
539    fn the_empty_string_is_inline_and_reads_back_empty() {
540        let mut column = StringColumn::new();
541        column.push("");
542        assert_eq!(column.get(0), Some(""));
543        assert!(column.views()[0].is_empty());
544        assert_eq!(column.heap_bytes(), 0);
545    }
546
547    #[test]
548    fn multibyte_text_survives_the_inline_boundary() {
549        // The boundary is bytes and not characters, so a four byte emoji is what decides whether
550        // a three character string is inline.
551        let mut column = StringColumn::new();
552        column.push("héllo wörld");
553        column.push("🦀🦀🦀🦀");
554        assert_eq!(column.get(0), Some("héllo wörld"));
555        assert_eq!(column.get(1), Some("🦀🦀🦀🦀"));
556        assert!(!column.views()[1].is_inline());
557    }
558
559    #[test]
560    fn reading_past_the_end_is_none_rather_than_a_panic() {
561        let column: StringColumn = ["a", "b"].into_iter().collect();
562        assert_eq!(column.get(2), None);
563        assert_eq!(column.len(), 2);
564    }
565
566    /// The bytes and the string have to be the same string on both sides of the inline boundary
567    /// and on multibyte text, because the comparison kernels read the bytes and everything else
568    /// reads the string, and a disagreement between them would be a filter that matched a row the
569    /// projection then printed differently.
570    #[test]
571    fn the_bytes_and_the_string_are_the_same_string() {
572        let long = "x".repeat(9000);
573        let words = ["", "a", "twelve bytes", "thirteen bytes", "π is two bytes", &long];
574        let column: StringColumn = words.into_iter().collect();
575        for (index, text) in words.iter().enumerate() {
576            assert_eq!(column.bytes(index), Some(text.as_bytes()), "at {index}");
577            assert_eq!(column.get(index), Some(*text), "at {index}");
578        }
579        assert_eq!(column.bytes(words.len()), None);
580    }
581}