Skip to main content

linkrs_storage/
cursor.rs

1//! Cursor / batch-reader traits for vertex and edge scanning.
2//!
3//! These traits provide a cursor-based alternative to the Vec-returning
4//! scan methods on [`StorageReader`](super::StorageReader).  The caller
5//! pulls batches of rows on demand instead of having the entire result
6//! materialized upfront.
7//!
8//! # Performance contract
9//!
10//! Storage engines are expected to provide native lazy cursors. The
11//! `Vec*Cursor` types remain available for adapters and test doubles, but
12//! they are explicit materialized implementations rather than an implicit
13//! fallback for production scans.
14
15use std::sync::Arc;
16
17use parking_lot::RwLock;
18
19use linkrs_core::types::{DataType, Timestamp};
20use linkrs_core::StorageError;
21
22pub mod column_batch;
23pub mod predicates;
24pub mod vec_cursors;
25
26pub use column_batch::{ColumnValues, EdgeColumnBatch, PropertyColumn, VertexColumnBatch};
27pub use predicates::{compare_stat_values, IndexPredicate, PredicateRange, ScanPredicate};
28pub use vec_cursors::{VecEdgeCursor, VecVertexCursor};
29
30// ---------------------------------------------------------------------------
31// Scan target (type-safe scan intent)
32// ---------------------------------------------------------------------------
33
34/// Identifies what kind of scan is being performed.
35///
36/// Used alongside [`ScanOptions`] to make the scan intent explicit and
37/// catch misconfiguration (e.g. passing `edge_type` with a vertex scan).
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ScanTarget {
40    Vertex,
41    Edge { edge_type: Option<String> },
42}
43
44// ---------------------------------------------------------------------------
45// Required property (typed projection)
46// ---------------------------------------------------------------------------
47
48/// Carries resolved metadata so that scan operators and storage cursors
49/// no longer rely on alias/name heuristics.  The `schema_version` binds
50/// the identity to a specific catalog generation, preventing stale reuse
51/// after schema changes.
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub struct RequiredProperty {
54    /// Property name (column name in storage).
55    pub name: Arc<str>,
56    /// Resolved column index in the target `ColumnStore`, if known.
57    pub column_id: Option<i32>,
58    /// Data type from schema binding.
59    pub data_type: Option<DataType>,
60    /// Schema version at binding time.
61    pub schema_version: u64,
62}
63
64impl RequiredProperty {
65    pub fn new(name: impl Into<Arc<str>>) -> Self {
66        let name = name.into();
67        Self {
68            name,
69            column_id: None,
70            data_type: None,
71            schema_version: 0,
72        }
73    }
74
75    pub fn with_metadata(
76        name: impl Into<Arc<str>>,
77        column_id: Option<i32>,
78        data_type: Option<DataType>,
79        schema_version: u64,
80    ) -> Self {
81        Self {
82            name: name.into(),
83            column_id,
84            data_type,
85            schema_version,
86        }
87    }
88
89    pub fn name(&self) -> &str {
90        &self.name
91    }
92
93    pub fn column_id(&self) -> Option<i32> {
94        self.column_id
95    }
96
97    pub fn data_type(&self) -> Option<&DataType> {
98        self.data_type.as_ref()
99    }
100
101    pub fn schema_version(&self) -> u64 {
102        self.schema_version
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Scan options
108// ---------------------------------------------------------------------------
109
110/// Unified scan options that configure cursor behavior.
111///
112/// This is the contract between the query planner/executor and the storage
113/// layer.  Future phases will add predicate pushdown, projection, partition,
114/// range, and snapshot support.
115#[derive(Debug, Clone, Default)]
116pub struct ScanOptions {
117    /// Maximum number of rows to return (None = unlimited).
118    pub limit: Option<usize>,
119    /// Number of matching rows to skip before emitting the first row.
120    pub offset: usize,
121    /// Batch size for cursor reads.
122    pub batch_size: usize,
123    /// Optional vertex ID range filter over the **external** vertex ID
124    /// (the same i64 domain as `PartitionSpec` ranges). Only vertices whose
125    /// external ID falls in this range (inclusive of start, exclusive of
126    /// end) are returned. When set, this filter is applied at scan time,
127    /// not as a post-filter.
128    pub vertex_id_range: Option<std::ops::Range<i64>>,
129    /// Optional edge source ID range filter. Only edges whose source ID
130    /// (parsed as `i64`) falls in this range are returned.
131    pub edge_src_id_range: Option<std::ops::Range<i64>>,
132    /// Edge type filter (for edge scans only).
133    pub edge_type: Option<String>,
134    /// Optional tag filter for vertex scans: only rows whose tag matches
135    /// this name are scanned.  The tag tables of all other tags are skipped
136    /// at scan time (the query layer may therefore elide the matching
137    /// `contains(labels(v), ...)` residual conjunct).
138    pub tag: Option<String>,
139    /// Optional property projection pushed into the physical scan.
140    pub projection: Option<Vec<RequiredProperty>>,
141    /// Topology-only vertex scan: return vertex identities and tags with
142    /// zero property decode. Only valid with an empty projection and no
143    /// pushed predicates; the cursor rejects any other combination at open
144    /// so misuse surfaces as an error instead of silently wrong rows.
145    pub topology_only: bool,
146    /// Read timestamp captured by the caller.
147    pub read_timestamp: Option<Timestamp>,
148    /// Optional conjunctive scan predicates pushed from the query layer.
149    ///
150    /// All predicates must match for a row to be emitted.  The query layer
151    /// keeps the original filter on top, so the pushdown is a pure
152    /// pre-filter (see [`ScanPredicate`]).
153    pub predicate: Option<Vec<ScanPredicate>>,
154    /// Column-block scan mode: pull column-major batches from the storage
155    /// cursor via `VertexCursor::next_column_batch` instead of row-major
156    /// batches. Default off — the row-based path stays the fallback.
157    pub column_block_mode: bool,
158    /// Optional internal-ID allowlist (semi-mask) for vertex scans: when
159    /// set, the cursor decodes exactly these global internal IDs instead of
160    /// enumerating the table's live IDs. IDs that are deleted, invisible at
161    /// the read timestamp, or out of range yield no row, mirroring the
162    /// batch point-lookup semantics. Internal ID spaces are per tag table,
163    /// so an allowlist requires [`ScanOptions::tag`]; opening without a tag
164    /// fails. The join/expand layer uses this to push a known vertex set
165    /// into the scan instead of scanning whole tables.
166    pub internal_id_allowlist: Option<Vec<u32>>,
167    /// Optional edge-table allowlist for table-sharded parallel scans: when
168    /// set, the edge cursor only drains tables whose key is listed. Shards
169    /// partition the key set, so the union of shards covers every table of
170    /// the edge type exactly once. `None` (default) scans all tables.
171    pub edge_table_allowlist: Option<Vec<crate::engine::data_store::EdgeTableKey>>,
172    /// Optional shard slice for parallel vertex scans: when set, the cursor
173    /// enumerates only this slice of every tag table's shard-local id
174    /// space. Slices partition the enumeration space (not the external-id
175    /// value space, which is hash-distributed over shards and cannot prune
176    /// enumeration), so the union of all slices covers the table exactly
177    /// once. `None` (default) enumerates the whole table.
178    pub shard_slice: Option<ShardSlice>,
179    /// Optional shard slice for parallel edge scans: when set, the cursor
180    /// walks only the edges whose source internal id falls in this slice of
181    /// the source tables' shard-local id space. Same division contract as
182    /// `shard_slice`, applied to the edge source space so the union of all
183    /// slices covers every edge exactly once. Composes with
184    /// `edge_src_id_range`, which stays a row-level external-id filter.
185    /// `None` (default) walks the whole source space.
186    pub edge_src_slice: Option<ShardSlice>,
187}
188
189/// One slice of every shard's local-id space, selected by partition index.
190///
191/// Slice `partition_id` of `partition_count` owns a contiguous chunk of each
192/// shard's `[0, high-water)` local-id universe. Every local id belongs to
193/// exactly one slice, so partitioned scans divide enumeration work instead
194/// of repeating a full-table walk per partition.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct ShardSlice {
197    /// Zero-based index of the owned slice.
198    pub partition_id: usize,
199    /// Total slice count the table is divided into.
200    pub partition_count: usize,
201}
202
203impl ShardSlice {
204    /// Describe one slice of a `partition_count`-way division.
205    pub fn new(partition_id: usize, partition_count: usize) -> Self {
206        Self {
207            partition_id,
208            partition_count,
209        }
210    }
211}
212
213impl ScanOptions {
214    pub const DEFAULT_BATCH_SIZE: usize = 1024;
215
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Builder: set edge type filter.
221    pub fn with_edge_type(mut self, edge_type: String) -> Self {
222        self.edge_type = Some(edge_type);
223        self
224    }
225
226    /// Builder: set row limit.
227    pub fn with_limit(mut self, limit: usize) -> Self {
228        self.limit = Some(limit);
229        self
230    }
231
232    /// Builder: set the number of matching rows to skip.
233    pub fn with_offset(mut self, offset: usize) -> Self {
234        self.offset = offset;
235        self
236    }
237
238    pub fn with_projection_named(mut self, projection: Vec<String>) -> Self {
239        self.projection = Some(projection.into_iter().map(RequiredProperty::new).collect());
240        self
241    }
242
243    pub fn with_projection(mut self, projection: Vec<RequiredProperty>) -> Self {
244        self.projection = Some(projection);
245        self
246    }
247
248    /// Builder: restrict a vertex scan to a single tag by name.
249    pub fn with_tag(mut self, tag: String) -> Self {
250        self.tag = Some(tag);
251        self
252    }
253
254    /// Builder: decode exactly these global internal IDs instead of
255    /// enumerating live IDs (semi-mask pushdown). Requires `with_tag`.
256    pub fn with_internal_id_allowlist(mut self, ids: Vec<u32>) -> Self {
257        self.internal_id_allowlist = Some(ids);
258        self
259    }
260
261    /// Builder: enumerate only one shard slice of the table instead of the
262    /// whole table (parallel-scan division).
263    pub fn with_shard_slice(mut self, slice: ShardSlice) -> Self {
264        self.shard_slice = Some(slice);
265        self
266    }
267
268    /// Builder: walk only one shard slice of the edge source space instead
269    /// of the whole space (parallel edge-scan division).
270    pub fn with_edge_src_slice(mut self, slice: ShardSlice) -> Self {
271        self.edge_src_slice = Some(slice);
272        self
273    }
274
275    pub fn batch_size(&self) -> usize {
276        if self.batch_size == 0 {
277            Self::DEFAULT_BATCH_SIZE
278        } else {
279            self.batch_size
280        }
281    }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Default)]
285pub enum PartitionSelector {
286    #[default]
287    All,
288    Shards(Vec<u32>),
289    KeyRange {
290        lower: Option<Vec<u8>>,
291        upper: Option<Vec<u8>>,
292    },
293}
294
295#[derive(Debug, Clone, PartialEq)]
296pub enum IndexRow {
297    RowId(linkrs_core::wal::EntityRef),
298    Covering {
299        entity_ref: linkrs_core::wal::EntityRef,
300        columns: Vec<(std::sync::Arc<str>, linkrs_core::Value)>,
301    },
302}
303
304/// Immutable physical index scan contract.
305#[derive(Debug, Clone, PartialEq)]
306pub struct IndexScanPlan {
307    pub space: String,
308    pub index_id: u64,
309    pub predicate: IndexPredicate,
310    pub partition: PartitionSelector,
311    /// Optional vertex/edge ID range for partition-based shard selection.
312    /// Forwarded from `PartitionView`; the storage layer converts this to
313    /// precise key bounds using index metadata.
314    pub partition_id_range: Option<std::ops::Range<i64>>,
315    pub projection: Option<Vec<Arc<str>>>,
316    pub limit: Option<usize>,
317    pub offset: usize,
318    pub read_timestamp: Timestamp,
319}
320
321// ---------------------------------------------------------------------------
322// Flat vertex record (scan boundary bypassing Vertex/HashMap boxing)
323// ---------------------------------------------------------------------------
324
325/// A vertex row read from the storage columns without `Vertex`/`HashMap`
326/// boxing.
327///
328/// The properties are a plain `Vec<(Arc<str>, Value)>` in storage projection
329/// order. The query layer rebuilds the `Value::Vertex` slot 0 only when a
330/// consumer actually needs the entity (graph operators, `RETURN p`, label
331/// checks), skipping the per-row `HashMap` construction at the storage
332/// boundary.
333#[derive(Debug, Clone, PartialEq)]
334pub struct FlatVertexRecord {
335    /// External vertex ID.
336    pub vid: linkrs_core::types::VertexId,
337    /// Internal (storage) vertex ID.
338    pub internal_id: i64,
339    /// Tag (label) name of the scanned table.
340    pub tag_name: String,
341    /// Projected properties in storage order.
342    pub props: Vec<(std::sync::Arc<str>, linkrs_core::Value)>,
343}
344
345/// Default data type for a fallback `General` column: scan the decoded values
346/// and pick the type of the first non-null / non-empty row; when every value
347/// is missing keep [`DataType::Empty`] (the upstream typed-column path then
348/// degrades to `TypedColumn::Fallback`, matching the pre-existing behavior).
349///
350/// This is the analog of what the storage-side `GraphVertexCursor` does in
351/// `assemble_column_batch` (`cursor_impl.rs`); keeping the trait default
352/// aligned with the real engine path means non-GraphStorage engines
353/// (`VecVertexCursor`, `VecEdgeCursor`) get the same typed-column coverage
354/// when they opt into the column-block scan path.
355pub(crate) fn column_data_type(values: &[Option<linkrs_core::Value>]) -> DataType {
356    for v in values.iter().flatten() {
357        let ty = v.get_type();
358        if !matches!(ty, DataType::Empty | DataType::Null) {
359            return ty;
360        }
361    }
362    DataType::Empty
363}
364
365// ---------------------------------------------------------------------------
366// Cursor traits
367// ---------------------------------------------------------------------------
368
369/// A cursor that yields vertices in batches.
370pub trait VertexCursor: Send + std::fmt::Debug {
371    /// Read the next batch of vertices (at most `batch_size` rows).
372    ///
373    /// Returns an empty `Vec` when the scan is exhausted.
374    fn next_batch(&mut self, batch_size: usize) -> Result<Vec<linkrs_core::Vertex>, StorageError>;
375
376    /// Read the next batch as flat vertex records (at most `batch_size`
377    /// rows), skipping `Vertex` construction and `HashMap` boxing.
378    ///
379    /// The default implementation materialises vertices via [`Self::next_batch`]
380    /// and converts them. Storage engines should override this when they can
381    /// produce records directly from the column store.
382    ///
383    /// Returns an empty `Vec` when the scan is exhausted.
384    fn next_flat_batch(
385        &mut self,
386        batch_size: usize,
387    ) -> Result<Vec<FlatVertexRecord>, StorageError> {
388        Ok(self
389            .next_batch(batch_size)?
390            .into_iter()
391            .map(|v| FlatVertexRecord {
392                vid: v.vid,
393                // No table row address exists behind this fallback path:
394                // integer vids project to their row key, anything else
395                // reports -1. Storage engines override this with real ids.
396                internal_id: v.vid.as_internal_u32().map(|id| id as i64).unwrap_or(-1),
397                tag_name: v.tag.name.clone(),
398                props: v.tag.properties.clone().into_iter().collect(),
399            })
400            .collect())
401    }
402
403    /// Read the next batch as column-major data (at most `batch_size` rows),
404    /// skipping per-row `VertexRecord` / `HashMap` / `Value` materialization.
405    ///
406    /// `prop_names` lists the properties to decode; an empty list means decode
407    /// every column of the scanned table(s).  The returned batch carries one
408    /// [`PropertyColumn`] per requested property (or per table column when
409    /// `prop_names` is empty).
410    ///
411    /// The default implementation falls back to [`Self::next_flat_batch`] and
412    /// transposes the rows into columns.  Storage engines override this when
413    /// they can decode straight from the column store.
414    ///
415    /// Returns an empty batch when the scan is exhausted.
416    fn next_column_batch(
417        &mut self,
418        prop_names: &[std::sync::Arc<str>],
419        batch_size: usize,
420    ) -> Result<VertexColumnBatch, StorageError> {
421        let records = self.next_flat_batch(batch_size)?;
422        let row_count = records.len();
423        let mut batch = VertexColumnBatch {
424            vids: Vec::with_capacity(row_count),
425            internal_ids: Vec::with_capacity(row_count),
426            tag_names: Vec::with_capacity(row_count),
427            columns: Vec::with_capacity(prop_names.len()),
428        };
429        let mut per_name: Vec<Vec<Option<linkrs_core::Value>>> = prop_names
430            .iter()
431            .map(|_| Vec::with_capacity(row_count))
432            .collect();
433        for record in records {
434            batch.tag_names.push(record.tag_name.clone());
435            batch.vids.push(record.vid);
436            batch.internal_ids.push(record.internal_id);
437            for (i, name) in prop_names.iter().enumerate() {
438                let value = record
439                    .props
440                    .iter()
441                    .find(|(n, _)| n.as_ref() == name.as_ref())
442                    .map(|(_, v)| v.clone());
443                per_name[i].push(value);
444            }
445        }
446        for (i, name) in prop_names.iter().enumerate() {
447            batch.columns.push(PropertyColumn {
448                name: name.clone(),
449                data_type: column_data_type(&per_name[i]),
450                values: ColumnValues::General(std::mem::take(&mut per_name[i])),
451            });
452        }
453        Ok(batch)
454    }
455}
456
457/// A cursor that yields edges in batches.
458pub trait EdgeCursor: Send + std::fmt::Debug {
459    /// Read the next batch of edges (at most `batch_size` rows).
460    ///
461    /// Returns an empty `Vec` when the scan is exhausted.
462    fn next_batch(&mut self, batch_size: usize) -> Result<Vec<linkrs_core::Edge>, StorageError>;
463
464    /// Read the next batch as column-major data (at most `batch_size` rows).
465    ///
466    /// `prop_names` lists the properties to decode; an empty list means decode
467    /// every property of the edge.  The returned batch carries one
468    /// [`PropertyColumn`] per requested property.
469    ///
470    /// The default implementation falls back to [`Self::next_batch`] and
471    /// transposes the rows into columns.  Storage engines override this when
472    /// they can decode straight from the column store.
473    ///
474    /// Returns an empty batch when the scan is exhausted.
475    fn next_column_batch(
476        &mut self,
477        prop_names: &[std::sync::Arc<str>],
478        batch_size: usize,
479    ) -> Result<EdgeColumnBatch, StorageError> {
480        let edges = self.next_batch(batch_size)?;
481        let row_count = edges.len();
482        let mut batch = EdgeColumnBatch {
483            srcs: Vec::with_capacity(row_count),
484            dsts: Vec::with_capacity(row_count),
485            edge_types: Vec::with_capacity(row_count),
486            rankings: Vec::with_capacity(row_count),
487            columns: Vec::with_capacity(prop_names.len()),
488        };
489        let mut per_name: Vec<Vec<Option<linkrs_core::Value>>> = prop_names
490            .iter()
491            .map(|_| Vec::with_capacity(row_count))
492            .collect();
493        for edge in edges {
494            batch.srcs.push(edge.src);
495            batch.dsts.push(edge.dst);
496            batch.edge_types.push(edge.edge_type);
497            batch.rankings.push(edge.ranking);
498            for (i, name) in prop_names.iter().enumerate() {
499                per_name[i].push(edge.props.get(name.as_ref()).cloned());
500            }
501        }
502        for (i, name) in prop_names.iter().enumerate() {
503            batch.columns.push(PropertyColumn {
504                name: name.clone(),
505                data_type: column_data_type(&per_name[i]),
506                values: ColumnValues::General(std::mem::take(&mut per_name[i])),
507            });
508        }
509        Ok(batch)
510    }
511
512    /// Number of malformed/unparseable entries skipped so far.
513    fn malformed_skipped(&self) -> u64 {
514        0
515    }
516}
517
518/// A cursor that yields index entries (row IDs or covering rows) in batches.
519///
520/// Bound to a transaction snapshot at creation time.  Supports equality,
521/// range, and prefix predicates as available in the storage engine.
522/// Unsupported predicate types return an error at open time, not at runtime.
523pub trait IndexCursor: Send + std::fmt::Debug {
524    /// The type of row identifier this cursor yields.
525    type Row: Send;
526
527    /// Read the next batch of index entries (at most `batch_size`).
528    ///
529    /// Returns an empty `Vec` when exhausted.
530    /// Stale or deleted row IDs are counted but skipped — they do not
531    /// cause premature exhaustion.
532    fn next_batch(&mut self, batch_size: usize) -> Result<Vec<Self::Row>, StorageError>;
533
534    /// Number of stale rows skipped so far (for diagnostics).
535    fn stale_skipped(&self) -> u64 {
536        0
537    }
538
539    /// Number of invisible (MVCC-hidden) entries skipped so far.
540    fn invisible_skipped(&self) -> u64 {
541        0
542    }
543
544    /// Number of malformed/unparseable entries skipped so far.
545    fn malformed_skipped(&self) -> u64 {
546        0
547    }
548
549    /// Whether the cursor has reached the end of its physical scan.
550    ///
551    /// A batch may be empty even before exhaustion when all entries in that
552    /// batch are invisible or stale, so callers that need to continue over
553    /// such entries must inspect this flag.
554    fn is_exhausted(&self) -> bool {
555        true
556    }
557}
558
559// ---------------------------------------------------------------------------
560// Cursor-opening helpers on StorageClient
561// ---------------------------------------------------------------------------
562
563/// Open a vertex scan cursor through a storage client.
564///
565/// Requires the storage engine's native lazy cursor (via
566/// [`StorageReader::create_vertex_cursor`]); storage engines without one
567/// report a capability error.  The [`VecVertexCursor`] type is only used by
568/// adapters and test doubles, not as an implicit fallback.
569///
570/// When `options.limit` is `Some(n)`, at most `n` vertices are returned.
571pub fn open_vertex_scan<S: crate::StorageReader + ?Sized>(
572    storage: &Arc<RwLock<S>>,
573    space: &str,
574    options: &ScanOptions,
575) -> Result<Box<dyn VertexCursor>, StorageError> {
576    let reader = storage.read();
577    reader.create_vertex_cursor(space, options)
578}
579
580/// Open an edge scan cursor through a storage client.
581///
582/// Requires the storage engine's native lazy cursor (via
583/// [`StorageReader::create_edge_cursor`]); storage engines without one
584/// report a capability error.  The [`VecEdgeCursor`] type is only used by
585/// adapters and test doubles, not as an implicit fallback.
586///
587/// When `options.edge_type` is set, only edges of that type are scanned.
588/// When `options.limit` is `Some(n)`, at most `n` edges are returned.
589pub fn open_edge_scan<S: crate::StorageReader + ?Sized>(
590    storage: &Arc<RwLock<S>>,
591    space: &str,
592    options: &ScanOptions,
593) -> Result<Box<dyn EdgeCursor>, StorageError> {
594    let reader = storage.read();
595    reader.create_edge_cursor(space, options)
596}
597
598/// Open an index scan cursor through a storage client.
599///
600/// Returns a cursor that yields row IDs for the given index and predicate.
601/// When the index is covering, the cursor yields full rows directly.
602///
603/// # Note
604/// This is a placeholder.  Storage engines should override
605/// `StorageReader::create_index_cursor` when they support native index
606/// cursors.  The default implementation returns a capability error.
607pub fn open_index_cursor<S: crate::StorageReader + ?Sized>(
608    storage: &Arc<RwLock<S>>,
609    plan: &IndexScanPlan,
610) -> Result<Box<dyn IndexCursor<Row = IndexRow>>, StorageError> {
611    let reader = storage.read();
612    reader.create_index_cursor(plan)
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use linkrs_core::types::storage_ids::VertexId;
619    use linkrs_core::{Tag, Value, Vertex};
620    use std::collections::HashMap;
621
622    #[test]
623    fn next_flat_batch_default_converts_vertices() {
624        let mut props = HashMap::new();
625        props.insert("age".into(), Value::BigInt(30));
626        let vertex = Vertex::new(
627            VertexId::try_from_int64(1).expect("test vertex id"),
628            Tag::new("person".into(), props),
629        );
630
631        let mut cursor = VecVertexCursor::new(vec![vertex]);
632        let batch = cursor
633            .next_flat_batch(10)
634            .expect("flat batch should succeed");
635        assert_eq!(batch.len(), 1);
636        let rec = &batch[0];
637        assert_eq!(
638            rec.vid,
639            VertexId::try_from_int64(1).expect("test vertex id")
640        );
641        assert_eq!(rec.internal_id, 1);
642        assert_eq!(rec.tag_name, "person");
643        assert_eq!(rec.props, vec![("age".into(), Value::BigInt(30))]);
644    }
645
646    #[test]
647    fn next_flat_batch_empty_when_exhausted() {
648        let mut cursor = VecVertexCursor::new(Vec::new());
649        let batch = cursor
650            .next_flat_batch(10)
651            .expect("flat batch should succeed");
652        assert!(batch.is_empty());
653    }
654}