1use std::fmt::Display;
16use std::marker::PhantomData;
17use std::mem::size_of;
18use std::ops::{Deref, DerefMut};
19use std::sync::Arc;
20use std::{fmt, mem};
21
22use either::Either;
23use enum_as_inner::EnumAsInner;
24use itertools::Itertools;
25use rand::prelude::SmallRng;
26use rand::{Rng, SeedableRng};
27use risingwave_common_estimate_size::EstimateSize;
28use risingwave_pb::data::{PbOp, PbStreamChunk};
29
30use super::stream_chunk_builder::StreamChunkBuilder;
31use super::{ArrayImpl, ArrayRef, ArrayResult, DataChunkTestExt, RowRef};
32use crate::array::DataChunk;
33use crate::bitmap::{Bitmap, BitmapBuilder};
34use crate::catalog::Schema;
35use crate::field_generator::VarcharProperty;
36use crate::row::Row;
37use crate::types::{DataType, DefaultOrdered, ToText};
38
39#[derive(Clone, Copy, Debug, PartialOrd, Ord, PartialEq, Eq, Hash, EnumAsInner)]
46pub enum Op {
47 Insert,
48 Delete,
49 UpdateDelete,
50 UpdateInsert,
51}
52
53impl Op {
54 pub fn to_protobuf(self) -> PbOp {
55 match self {
56 Op::Insert => PbOp::Insert,
57 Op::Delete => PbOp::Delete,
58 Op::UpdateInsert => PbOp::UpdateInsert,
59 Op::UpdateDelete => PbOp::UpdateDelete,
60 }
61 }
62
63 pub fn from_protobuf(prost: &i32) -> ArrayResult<Op> {
64 let op = match PbOp::try_from(*prost) {
65 Ok(PbOp::Insert) => Op::Insert,
66 Ok(PbOp::Delete) => Op::Delete,
67 Ok(PbOp::UpdateInsert) => Op::UpdateInsert,
68 Ok(PbOp::UpdateDelete) => Op::UpdateDelete,
69 Ok(PbOp::Unspecified) => unreachable!(),
70 Err(_) => bail!("No such op type"),
71 };
72 Ok(op)
73 }
74
75 pub fn normalize_update(self) -> Op {
77 match self {
78 Op::Insert => Op::Insert,
79 Op::Delete => Op::Delete,
80 Op::UpdateDelete => Op::Delete,
81 Op::UpdateInsert => Op::Insert,
82 }
83 }
84
85 pub fn to_i16(self) -> i16 {
86 match self {
87 Op::Insert => 1,
88 Op::Delete => 2,
89 Op::UpdateInsert => 3,
90 Op::UpdateDelete => 4,
91 }
92 }
93
94 pub fn to_varchar(self) -> String {
95 match self {
96 Op::Insert => "Insert",
97 Op::Delete => "Delete",
98 Op::UpdateInsert => "UpdateInsert",
99 Op::UpdateDelete => "UpdateDelete",
100 }
101 .to_owned()
102 }
103}
104
105#[derive(Clone, PartialEq)]
107pub struct StreamChunk {
108 ops: Arc<[Op]>,
110 data: DataChunk,
111}
112
113impl Default for StreamChunk {
114 fn default() -> Self {
118 Self {
119 ops: Arc::new([]),
120 data: DataChunk::new(vec![], 0),
121 }
122 }
123}
124
125impl StreamChunk {
126 pub fn new(ops: impl Into<Arc<[Op]>>, columns: Vec<ArrayRef>) -> Self {
128 let ops = ops.into();
129 let visibility = Bitmap::ones(ops.len());
130 Self::with_visibility(ops, columns, visibility)
131 }
132
133 pub fn with_visibility(
135 ops: impl Into<Arc<[Op]>>,
136 columns: Vec<ArrayRef>,
137 visibility: Bitmap,
138 ) -> Self {
139 let ops = ops.into();
140 for col in &columns {
141 assert_eq!(col.len(), ops.len());
142 }
143 let data = DataChunk::new(columns, visibility);
144 StreamChunk { ops, data }
145 }
146
147 pub fn from_rows(rows: &[(Op, impl Row)], data_types: &[DataType]) -> Self {
154 let mut builder = StreamChunkBuilder::unlimited(data_types.to_vec(), Some(rows.len()));
155
156 for (op, row) in rows {
157 let none = builder.append_row(*op, row);
158 debug_assert!(none.is_none());
159 }
160
161 builder.take().expect("chunk should not be empty")
162 }
163
164 pub fn empty(data_types: &[DataType]) -> Self {
165 StreamChunkBuilder::build_empty(data_types.to_vec())
166 }
167
168 pub fn data_chunk(&self) -> &DataChunk {
170 &self.data
171 }
172
173 pub fn compact_vis(self) -> Self {
181 if self.is_vis_compacted() {
182 return self;
183 }
184
185 let (ops, columns, visibility) = self.into_inner();
186
187 let cardinality = visibility
188 .iter()
189 .fold(0, |vis_cnt, vis| vis_cnt + vis as usize);
190 let columns: Vec<_> = columns
191 .into_iter()
192 .map(|col| col.compact_vis(&visibility, cardinality).into())
193 .collect();
194 let mut new_ops = Vec::with_capacity(cardinality);
195 for idx in visibility.iter_ones() {
196 new_ops.push(ops[idx]);
197 }
198 StreamChunk::new(new_ops, columns)
199 }
200
201 pub fn split(&self, size: usize) -> Vec<Self> {
209 let mut builder = StreamChunkBuilder::new(size, self.data_types());
210 let mut outputs = Vec::new();
211
212 for (op, row) in self.rows() {
214 if let Some(chunk) = builder.append_row(op, row) {
215 outputs.push(chunk);
216 }
217 }
218 if let Some(output) = builder.take() {
219 outputs.push(output);
220 }
221
222 outputs
223 }
224
225 pub fn into_parts(self) -> (DataChunk, Arc<[Op]>) {
226 (self.data, self.ops)
227 }
228
229 pub fn from_parts(ops: impl Into<Arc<[Op]>>, data_chunk: DataChunk) -> Self {
230 let (columns, vis) = data_chunk.into_parts();
231 Self::with_visibility(ops, columns, vis)
232 }
233
234 pub fn into_inner(self) -> (Arc<[Op]>, Vec<ArrayRef>, Bitmap) {
235 let (columns, vis) = self.data.into_parts();
236 (self.ops, columns, vis)
237 }
238
239 pub fn to_protobuf(&self) -> PbStreamChunk {
240 if !self.is_vis_compacted() {
241 return self.clone().compact_vis().to_protobuf();
242 }
243 PbStreamChunk {
244 cardinality: self.cardinality() as u32,
245 ops: self.ops.iter().map(|op| op.to_protobuf() as i32).collect(),
246 columns: self.columns().iter().map(|col| col.to_protobuf()).collect(),
247 }
248 }
249
250 pub fn from_protobuf(prost: &PbStreamChunk) -> ArrayResult<Self> {
251 let cardinality = prost.get_cardinality() as usize;
252 let mut ops = Vec::with_capacity(cardinality);
253 for op in prost.get_ops() {
254 ops.push(Op::from_protobuf(op)?);
255 }
256 let mut columns = vec![];
257 for column in prost.get_columns() {
258 columns.push(ArrayImpl::from_protobuf(column, cardinality)?.into());
259 }
260 Ok(StreamChunk::new(ops, columns))
261 }
262
263 pub fn ops(&self) -> &[Op] {
264 &self.ops
265 }
266
267 pub fn to_pretty(&self) -> impl Display + use<> {
269 self.to_pretty_inner(None)
270 }
271
272 pub fn to_pretty_with_schema(&self, schema: &Schema) -> impl Display + use<> {
275 self.to_pretty_inner(Some(schema))
276 }
277
278 fn to_pretty_inner(&self, schema: Option<&Schema>) -> impl Display + use<> {
279 use comfy_table::{Cell, CellAlignment, Table};
280
281 if self.cardinality() == 0 {
282 return Either::Left("(empty)");
283 }
284
285 let mut table = Table::new();
286 table.load_preset(DataChunk::PRETTY_TABLE_PRESET);
287
288 if let Some(schema) = schema {
289 assert_eq!(self.dimension(), schema.len());
290 let cells = std::iter::once(String::new())
291 .chain(schema.fields().iter().map(|f| f.name.clone()));
292 table.set_header(cells);
293 }
294
295 for (op, row_ref) in self.rows() {
296 let mut cells = Vec::with_capacity(row_ref.len() + 1);
297 cells.push(
298 Cell::new(match op {
299 Op::Insert => "+",
300 Op::Delete => "-",
301 Op::UpdateDelete => "U-",
302 Op::UpdateInsert => "U+",
303 })
304 .set_alignment(CellAlignment::Right),
305 );
306 for datum in row_ref.iter() {
307 let str = match datum {
308 None => "".to_owned(), Some(scalar) => scalar.to_text(),
310 };
311 cells.push(Cell::new(str));
312 }
313 table.add_row(cells);
314 }
315
316 Either::Right(table)
317 }
318
319 pub fn project(&self, indices: &[usize]) -> Self {
325 Self {
326 ops: self.ops.clone(),
327 data: self.data.project(indices),
328 }
329 }
330
331 pub fn eliminate_adjacent_noop_update(self) -> Self {
333 let len = self.data_chunk().capacity();
334 let mut c: StreamChunkMut = self.into();
335 let mut prev_r = None;
336 for curr in 0..len {
337 if !c.vis(curr) {
338 continue;
339 }
340 if let Some(prev) = prev_r
341 && (
342 (matches!(c.op(prev), Op::UpdateDelete | Op::Delete)
344 && matches!(c.op(curr), Op::UpdateInsert | Op::Insert))
345 ||
346 (matches!(c.op(prev), Op::UpdateInsert | Op::Insert)
359 && matches!(c.op(curr), Op::UpdateDelete | Op::Delete))
360 )
361 && c.row_ref(prev) == c.row_ref(curr)
362 {
363 c.set_vis(prev, false);
364 c.set_vis(curr, false);
365 prev_r = None;
366 } else {
367 prev_r = Some(curr);
368 }
369 }
370
371 for idx in 0..len.saturating_sub(1) {
374 if c.op(idx) == Op::UpdateDelete && c.op(idx + 1) == Op::UpdateInsert {
375 let delete_vis = c.vis(idx);
376 let insert_vis = c.vis(idx + 1);
377 if delete_vis && !insert_vis {
378 c.set_op(idx, Op::Delete);
379 } else if !delete_vis && insert_vis {
380 c.set_op(idx + 1, Op::Insert);
381 }
382 }
383 }
384 c.into()
385 }
386
387 pub fn project_with_vis(&self, indices: &[usize], vis: Bitmap) -> Self {
389 Self {
390 ops: self.ops.clone(),
391 data: self.data.project_with_vis(indices, vis),
392 }
393 }
394
395 pub fn clone_with_vis(&self, vis: Bitmap) -> Self {
397 Self {
398 ops: self.ops.clone(),
399 data: self.data.with_visibility(vis),
400 }
401 }
402}
403
404impl Deref for StreamChunk {
405 type Target = DataChunk;
406
407 fn deref(&self) -> &Self::Target {
408 &self.data
409 }
410}
411
412impl DerefMut for StreamChunk {
413 fn deref_mut(&mut self) -> &mut Self::Target {
414 &mut self.data
415 }
416}
417
418impl From<DataChunk> for StreamChunk {
420 fn from(data: DataChunk) -> Self {
421 Self::from_parts(vec![Op::Insert; data.capacity()], data)
422 }
423}
424
425impl fmt::Debug for StreamChunk {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 if f.alternate() {
428 write!(
429 f,
430 "StreamChunk {{ cardinality: {}, capacity: {}, data:\n{}\n }}",
431 self.cardinality(),
432 self.capacity(),
433 self.to_pretty()
434 )
435 } else {
436 f.debug_struct("StreamChunk")
437 .field("cardinality", &self.cardinality())
438 .field("capacity", &self.capacity())
439 .finish_non_exhaustive()
440 }
441 }
442}
443
444impl EstimateSize for StreamChunk {
445 fn estimated_heap_size(&self) -> usize {
446 self.data.estimated_heap_size() + self.ops.len() * size_of::<Op>()
447 }
448}
449
450enum OpsMutState {
451 ArcRef(Arc<[Op]>),
452 Mut(Vec<Op>),
453}
454
455impl OpsMutState {
456 const UNDEFINED: Self = Self::Mut(Vec::new());
457}
458
459pub struct OpsMut {
460 state: OpsMutState,
461}
462
463impl OpsMut {
464 pub fn new(ops: Arc<[Op]>) -> Self {
465 Self {
466 state: OpsMutState::ArcRef(ops),
467 }
468 }
469
470 pub fn len(&self) -> usize {
471 match &self.state {
472 OpsMutState::ArcRef(v) => v.len(),
473 OpsMutState::Mut(v) => v.len(),
474 }
475 }
476
477 pub fn is_empty(&self) -> bool {
478 self.len() == 0
479 }
480
481 pub fn set(&mut self, n: usize, val: Op) {
482 debug_assert!(n < self.len());
483 if let OpsMutState::Mut(v) = &mut self.state {
484 v[n] = val;
485 } else {
486 let state = mem::replace(&mut self.state, OpsMutState::UNDEFINED); let mut v = match state {
488 OpsMutState::ArcRef(v) => v.to_vec(),
489 OpsMutState::Mut(_) => unreachable!(),
490 };
491 v[n] = val;
492 self.state = OpsMutState::Mut(v);
493 }
494 }
495
496 pub fn get(&self, n: usize) -> Op {
497 debug_assert!(n < self.len());
498 match &self.state {
499 OpsMutState::ArcRef(v) => v[n],
500 OpsMutState::Mut(v) => v[n],
501 }
502 }
503}
504impl From<OpsMut> for Arc<[Op]> {
505 fn from(v: OpsMut) -> Self {
506 match v.state {
507 OpsMutState::ArcRef(a) => a,
508 OpsMutState::Mut(v) => v.into(),
509 }
510 }
511}
512
513pub struct StreamChunkMut {
516 columns: Arc<[ArrayRef]>,
517 ops: OpsMut,
518 vis: BitmapBuilder,
519}
520
521impl From<StreamChunk> for StreamChunkMut {
522 fn from(c: StreamChunk) -> Self {
523 let (c, ops) = c.into_parts();
524 let (columns, vis) = c.into_parts_v2();
525 Self {
526 columns,
527 ops: OpsMut::new(ops),
528 vis: vis.into(),
529 }
530 }
531}
532
533impl From<StreamChunkMut> for StreamChunk {
534 fn from(c: StreamChunkMut) -> Self {
535 StreamChunk::from_parts(c.ops, DataChunk::from_parts(c.columns, c.vis.finish()))
536 }
537}
538
539pub struct OpRowMutRef<'a> {
543 c: *mut StreamChunkMut,
544 i: usize,
545 _phantom: PhantomData<&'a mut StreamChunkMut>,
546}
547
548impl PartialEq for OpRowMutRef<'_> {
549 fn eq(&self, other: &Self) -> bool {
550 self.row_ref() == other.row_ref()
551 }
552}
553impl Eq for OpRowMutRef<'_> {}
554
555impl<'a> OpRowMutRef<'a> {
556 pub fn index(&self) -> usize {
557 self.i
558 }
559
560 pub fn vis(&self) -> bool {
564 let vis = unsafe { &(*self.c).vis };
565 vis.is_set(self.i)
566 }
567
568 pub fn op(&self) -> Op {
569 let ops = unsafe { &(*self.c).ops };
570 ops.get(self.i)
571 }
572
573 pub fn set_vis(&mut self, val: bool) {
574 let vis = unsafe { &mut (*self.c).vis };
575 vis.set(self.i, val);
576 }
577
578 pub fn set_op(&mut self, val: Op) {
579 let ops = unsafe { &mut (*self.c).ops };
580 ops.set(self.i, val);
581 }
582
583 pub fn row_ref(&self) -> RowRef<'_> {
584 RowRef::with_columns(unsafe { &(*self.c).columns }, self.i)
585 }
586
587 pub fn same_chunk(&self, other: &Self) -> bool {
589 std::ptr::eq(self.c, other.c)
590 }
591}
592
593impl StreamChunkMut {
594 pub fn capacity(&self) -> usize {
595 self.vis.len()
596 }
597
598 pub fn vis(&self, i: usize) -> bool {
599 self.vis.is_set(i)
600 }
601
602 pub fn op(&self, i: usize) -> Op {
603 self.ops.get(i)
604 }
605
606 pub fn row_ref(&self, i: usize) -> RowRef<'_> {
607 RowRef::with_columns(self.columns(), i)
608 }
609
610 pub fn set_vis(&mut self, n: usize, val: bool) {
611 self.vis.set(n, val);
612 }
613
614 pub fn set_op(&mut self, n: usize, val: Op) {
615 self.ops.set(n, val);
616 }
617
618 pub fn columns(&self) -> &[ArrayRef] {
619 &self.columns
620 }
621
622 pub fn to_rows_mut(&mut self) -> impl Iterator<Item = (RowRef<'_>, OpRowMutRef<'_>)> {
624 unsafe { Self::rows_mut_ptr(self) }
627 }
628
629 unsafe fn rows_mut_ptr<'a>(
634 p: *mut Self,
635 ) -> impl Iterator<Item = (RowRef<'a>, OpRowMutRef<'a>)> {
636 let len = {
637 let vis = unsafe { &(*p).vis };
638 vis.len()
639 };
640 (0..len)
641 .filter(move |i| {
642 let vis = unsafe { &(*p).vis };
643 vis.is_set(*i)
644 })
645 .map(move |i| {
646 (
647 RowRef::with_columns(unsafe { &(*p).columns }, i),
648 OpRowMutRef {
649 c: p,
650 i,
651 _phantom: PhantomData,
652 },
653 )
654 })
655 }
656}
657
658#[easy_ext::ext(StreamChunkTestExt)]
660impl StreamChunk {
661 pub fn from_pretty(s: &str) -> Self {
702 let mut chunk_str = String::new();
703 let mut ops = vec![];
704
705 let (header, body) = match s.split_once('\n') {
706 Some(pair) => pair,
707 None => {
708 return StreamChunk {
710 ops: Arc::new([]),
711 data: DataChunk::from_pretty(s),
712 };
713 }
714 };
715 chunk_str.push_str(header);
716 chunk_str.push('\n');
717
718 for line in body.split_inclusive('\n') {
719 if line.trim_start().is_empty() {
720 continue;
721 }
722 let (op, row) = line
723 .trim_start()
724 .split_once(|c: char| c.is_ascii_whitespace())
725 .ok_or_else(|| panic!("missing operation: {line:?}"))
726 .unwrap();
727 ops.push(match op {
728 "+" => Op::Insert,
729 "-" => Op::Delete,
730 "U+" => Op::UpdateInsert,
731 "U-" => Op::UpdateDelete,
732 t => panic!("invalid op: {t:?}"),
733 });
734 chunk_str.push_str(row);
735 }
736 StreamChunk {
737 ops: ops.into(),
738 data: DataChunk::from_pretty(&chunk_str),
739 }
740 }
741
742 pub fn valid(&self) -> bool {
744 let len = self.ops.len();
745 let data = &self.data;
746 data.visibility().len() == len && data.columns().iter().all(|col| col.len() == len)
747 }
748
749 pub fn concat(chunks: Vec<StreamChunk>) -> StreamChunk {
753 let data_types = chunks[0].data_types();
754 let size = chunks.iter().map(|c| c.cardinality()).sum::<usize>();
755
756 let mut builder = StreamChunkBuilder::unlimited(data_types, Some(size));
757
758 for chunk in chunks {
759 for (op, row) in chunk.rows() {
761 let none = builder.append_row(op, row);
762 debug_assert!(none.is_none());
763 }
764 }
765
766 builder.take().expect("chunk should not be empty")
767 }
768
769 pub fn sort_rows(self) -> Self {
771 if self.capacity() == 0 {
772 return self;
773 }
774 let rows = self.rows().collect_vec();
775 let mut idx = (0..self.capacity()).collect_vec();
776 idx.sort_by_key(|&i| {
777 let (op, row_ref) = rows[i];
778 (op, DefaultOrdered(row_ref))
779 });
780 StreamChunk {
781 ops: idx.iter().map(|&i| self.ops[i]).collect(),
782 data: self.data.reorder_rows(&idx),
783 }
784 }
785
786 pub fn gen_stream_chunks(
790 num_of_chunks: usize,
791 chunk_size: usize,
792 data_types: &[DataType],
793 varchar_properties: &VarcharProperty,
794 ) -> Vec<StreamChunk> {
795 Self::gen_stream_chunks_inner(
796 num_of_chunks,
797 chunk_size,
798 data_types,
799 varchar_properties,
800 1.0,
801 1.0,
802 )
803 }
804
805 pub fn gen_stream_chunks_inner(
806 num_of_chunks: usize,
807 chunk_size: usize,
808 data_types: &[DataType],
809 varchar_properties: &VarcharProperty,
810 visibility_percent: f64, inserts_percent: f64, ) -> Vec<StreamChunk> {
813 let ops = if inserts_percent == 0.0 {
814 vec![Op::Delete; chunk_size]
815 } else if inserts_percent == 1.0 {
816 vec![Op::Insert; chunk_size]
817 } else {
818 let mut rng = SmallRng::from_seed([0; 32]);
819 let mut ops = vec![];
820 for _ in 0..chunk_size {
821 ops.push(if rng.random_bool(inserts_percent) {
822 Op::Insert
823 } else {
824 Op::Delete
825 });
826 }
827 ops
828 };
829 DataChunk::gen_data_chunks(
830 num_of_chunks,
831 chunk_size,
832 data_types,
833 varchar_properties,
834 visibility_percent,
835 )
836 .into_iter()
837 .map(|chunk| StreamChunk::from_parts(ops.clone(), chunk))
838 .collect()
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn test_to_pretty_string() {
848 let chunk = StreamChunk::from_pretty(
849 " I I
850 + 1 6
851 - 2 .
852 U- 3 7
853 U+ 4 .",
854 );
855 assert_eq!(
856 chunk.to_pretty().to_string(),
857 "\
858+----+---+---+
859| + | 1 | 6 |
860| - | 2 | |
861| U- | 3 | 7 |
862| U+ | 4 | |
863+----+---+---+"
864 );
865 }
866
867 #[test]
868 fn test_split_1() {
869 let chunk = StreamChunk::from_pretty(
870 " I I
871 + 1 6
872 - 2 .
873 U- 3 7
874 U+ 4 .",
875 );
876 let results = chunk.split(2);
877 assert_eq!(2, results.len());
878 assert_eq!(
879 results[0].to_pretty().to_string(),
880 "\
881+---+---+---+
882| + | 1 | 6 |
883| - | 2 | |
884+---+---+---+"
885 );
886 assert_eq!(
887 results[1].to_pretty().to_string(),
888 "\
889+----+---+---+
890| U- | 3 | 7 |
891| U+ | 4 | |
892+----+---+---+"
893 );
894 }
895
896 #[test]
897 fn test_split_2() {
898 let chunk = StreamChunk::from_pretty(
899 " I I
900 + 1 6
901 U- 3 7
902 U+ 4 .
903 - 2 .",
904 );
905 let results = chunk.split(2);
906 assert_eq!(2, results.len());
907 assert_eq!(
908 results[0].to_pretty().to_string(),
909 "\
910+----+---+---+
911| + | 1 | 6 |
912| U- | 3 | 7 |
913| U+ | 4 | |
914+----+---+---+"
915 );
916 assert_eq!(
917 results[1].to_pretty().to_string(),
918 "\
919+---+---+---+
920| - | 2 | |
921+---+---+---+"
922 );
923 }
924
925 #[test]
926 fn test_eliminate_adjacent_noop_update() {
927 let c = StreamChunk::from_pretty(
928 " I I
929 - 1 6 D
930 - 2 2
931 + 2 3
932 - 2 3
933 + 1 6
934 - 1 7
935 + 1 10 D
936 + 1 7
937 U- 3 7
938 U+ 3 7
939 + 2 3",
940 );
941 let c = c.eliminate_adjacent_noop_update();
942 assert_eq!(
943 c.to_pretty().to_string(),
944 "\
945+---+---+---+
946| - | 2 | 2 |
947| + | 1 | 6 |
948| + | 2 | 3 |
949+---+---+---+"
950 );
951 }
952
953 #[test]
954 fn test_eliminate_adjacent_noop_update_normalize_update_pair() {
955 let c = StreamChunk::from_pretty(
956 " I I
957 + 1 10
958 U- 1 10
959 U+ 1 20",
960 );
961 let c = c.eliminate_adjacent_noop_update();
962 assert_eq!(
963 c.to_pretty().to_string(),
964 "\
965+---+---+----+
966| + | 1 | 20 |
967+---+---+----+"
968 );
969 }
970}