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}