hyperdb_api/inserter.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! High-performance bulk data inserter using COPY protocol.
5//!
6//! This module provides the `Inserter` struct for efficient bulk data insertion
7//! into Hyper tables, along with the `IntoValue` trait for type-safe insertion.
8//!
9//! # Example
10//!
11//! ```no_run
12//! # use hyperdb_api::{Inserter, Connection, CreateMode, Result};
13//! # fn example(conn: &Connection, table_def: &hyperdb_api::TableDefinition) -> Result<()> {
14//! let mut inserter = Inserter::new(&conn, &table_def)?;
15//! for i in 0..10000i32 {
16//! inserter.add_row(&[&i, &format!("item {}", i), &(i as f64 * 1.5)])?;
17//! }
18//! inserter.execute()?;
19//! # Ok(())
20//! # }
21//! ```
22
23use std::time::Instant;
24
25use hyperdb_api_core::client::client::CopyInWriter;
26use hyperdb_api_core::protocol::copy;
27use hyperdb_api_core::types::bytes::BytesMut;
28use hyperdb_api_core::types::{
29 Date, Geography, Interval, Numeric, OffsetTimestamp, Time, Timestamp,
30};
31use tracing::{debug, info};
32
33use crate::catalog::Catalog;
34use crate::connection::Connection;
35use crate::error::{Error, Result};
36use crate::table_definition::TableDefinition;
37
38/// Initial buffer size (4 MB) to reduce early reallocations.
39///
40/// The COPY protocol sends data in chunks, and each chunk requires a
41/// contiguous buffer. Starting at 4 MB avoids repeated reallocations
42/// during the first chunk for typical workloads while keeping initial
43/// memory allocation reasonable.
44const INITIAL_BUFFER_SIZE: usize = 4 * 1024 * 1024;
45
46/// Maximum buffer size per chunk before flushing to the server (16 MB).
47///
48/// This balances two competing concerns:
49/// - **Throughput**: Larger chunks amortize per-chunk overhead (COPY header,
50/// network round-trip). Below ~1 MB, per-chunk overhead becomes significant.
51/// - **Memory**: The buffer must be fully materialized before sending. 16 MB
52/// keeps resident memory bounded even when rows are wide.
53///
54/// The 16 MB value was chosen empirically — it lands on the flat part of
55/// the throughput curve where further increases yield diminishing returns.
56const CHUNK_SIZE_LIMIT: usize = 16 * 1024 * 1024;
57
58/// Maximum rows per chunk before flushing to the server.
59///
60/// This is a secondary flush trigger alongside [`CHUNK_SIZE_LIMIT`]. For
61/// narrow rows (few bytes each), the byte limit alone would accumulate
62/// millions of rows before flushing, which delays server-side processing.
63/// 64K rows ensures timely flushes regardless of row width and aligns with
64/// the 64K chunk size used for query result streaming
65/// ([`DEFAULT_BINARY_CHUNK_SIZE`](crate::result::DEFAULT_BINARY_CHUNK_SIZE)).
66const CHUNK_ROW_LIMIT: usize = 64_000;
67
68/// A high-performance bulk data inserter.
69///
70/// The `Inserter` efficiently inserts large amounts of data into a Hyper table
71/// using the COPY protocol with `HyperBinary` format for optimal performance.
72///
73/// # Example
74///
75/// ```no_run
76/// use hyperdb_api::{Connection, CreateMode, Catalog, TableDefinition, Inserter, SqlType, Result};
77///
78/// fn main() -> Result<()> {
79/// let conn = Connection::connect("localhost:7483", "test.hyper", CreateMode::CreateIfNotExists)?;
80///
81/// let table_def = TableDefinition::new("users")
82/// .add_required_column("id", SqlType::int())
83/// .add_nullable_column("name", SqlType::text());
84///
85/// Catalog::new(&conn).create_table(&table_def)?;
86///
87/// let mut inserter = Inserter::new(&conn, &table_def)?;
88///
89/// for i in 0..1000i32 {
90/// inserter.add_row(&[&i, &format!("User {}", i)])?;
91/// }
92///
93/// let rows = inserter.execute()?;
94/// println!("Inserted {} rows", rows);
95/// Ok(())
96/// }
97/// ```
98#[derive(Debug)]
99pub struct Inserter<'conn> {
100 connection: &'conn Connection,
101 table_def: TableDefinition,
102 /// The current chunk being populated (delegates encoding).
103 chunk: InsertChunk,
104 /// Total rows inserted across all chunks.
105 row_count: u64,
106 /// Number of chunks sent.
107 chunk_count: usize,
108 /// Active COPY writer (lazily initialized on first write).
109 writer: Option<CopyInWriter<'conn>>,
110 /// Start time for timing the insert operation.
111 start_time: Instant,
112}
113
114impl<'conn> Inserter<'conn> {
115 /// Creates a new inserter for the given table.
116 ///
117 /// The underlying COPY session is started lazily on the first flush or
118 /// execute, so construction is lightweight. However, the connection's
119 /// transport is validated eagerly — using a gRPC connection will return
120 /// an error immediately.
121 ///
122 /// # Errors
123 ///
124 /// - Returns [`Error::InvalidTableDefinition`] if `table_def` has zero
125 /// columns.
126 /// - Returns [`Error::FeatureNotSupported`] if `connection` is using gRPC transport
127 /// (COPY is TCP-only).
128 pub fn new(connection: &'conn Connection, table_def: &TableDefinition) -> Result<Self> {
129 if table_def.column_count() == 0 {
130 return Err(Error::invalid_table_definition(
131 "Table definition must have at least one column",
132 ));
133 }
134
135 // Fail fast: verify the connection supports COPY (TCP only)
136 if connection.tcp_client().is_none() {
137 return Err(Error::feature_not_supported(
138 "Inserter requires a TCP connection. \
139 gRPC connections do not support COPY operations.",
140 ));
141 }
142
143 Ok(Inserter {
144 connection,
145 table_def: table_def.clone(),
146 chunk: InsertChunk::from_table_definition(table_def),
147 row_count: 0,
148 chunk_count: 0,
149 writer: None,
150 start_time: Instant::now(),
151 })
152 }
153
154 /// Creates an inserter by querying the table schema from the database.
155 ///
156 /// This method queries the database to get the table definition automatically,
157 /// which is useful when you want to insert into an existing table without
158 /// manually specifying the schema.
159 ///
160 /// # Arguments
161 ///
162 /// * `connection` - The database connection.
163 /// * `table_name` - The table name (can be a simple name, or "schema.table", etc.)
164 ///
165 /// # Errors
166 ///
167 /// Returns an error if the table doesn't exist or if the schema cannot be retrieved.
168 ///
169 /// # Example
170 ///
171 /// ```no_run
172 /// use hyperdb_api::{Connection, CreateMode, Inserter, Result};
173 ///
174 /// fn main() -> Result<()> {
175 /// let conn = Connection::connect("localhost:7483", "test.hyper", CreateMode::CreateIfNotExists)?;
176 ///
177 /// // Create a table first
178 /// conn.execute_command("CREATE TABLE IF NOT EXISTS products (id INT NOT NULL, name TEXT, price DOUBLE PRECISION)")?;
179 ///
180 /// // Create inserter by querying the schema directly from a string
181 /// let mut inserter = Inserter::from_table(&conn, "public.products")?;
182 ///
183 /// // Now we can insert data without knowing the exact schema
184 /// inserter.add_row(&[&1i32, &"Widget", &19.99f64])?;
185 /// inserter.add_row(&[&2i32, &"Gadget", &29.99f64])?;
186 ///
187 /// let rows = inserter.execute()?;
188 /// println!("Inserted {} rows", rows);
189 /// Ok(())
190 /// }
191 /// ```
192 pub fn from_table<T>(connection: &'conn Connection, table_name: T) -> Result<Self>
193 where
194 T: TryInto<crate::TableName>,
195 crate::Error: From<T::Error>,
196 {
197 let catalog = Catalog::new(connection);
198 let table_def = catalog.get_table_definition(table_name)?;
199 Self::new(connection, &table_def)
200 }
201
202 /// Creates an inserter with column mappings that allow SQL expressions.
203 ///
204 /// This method uses a temporary table and INSERT...SELECT to support
205 /// column mappings with SQL expressions. Data is first inserted into
206 /// a temporary staging table, then transformed using the mappings.
207 ///
208 /// # Arguments
209 ///
210 /// * `connection` - The database connection.
211 /// * `inserter_def` - Defines the columns to be provided to the inserter (staging table).
212 /// * `target_table` - The qualified name of the target table to insert into.
213 /// Use `TableDefinition::qualified_name()` for properly escaped names like `"schema"."table"`.
214 /// * `mappings` - Column mappings defining how values are transformed.
215 ///
216 /// # Example
217 ///
218 /// ```no_run
219 /// use hyperdb_api::{Connection, CreateMode, TableDefinition, ColumnMapping, Inserter, SqlType, Result};
220 ///
221 /// fn main() -> Result<()> {
222 /// let conn = Connection::connect("localhost:7483", "test.hyper", CreateMode::CreateIfNotExists)?;
223 ///
224 /// // Target table with computed columns
225 /// conn.execute_command(r#"
226 /// CREATE TABLE orders (
227 /// id INT NOT NULL,
228 /// product TEXT,
229 /// quantity INT,
230 /// price DOUBLE PRECISION,
231 /// total DOUBLE PRECISION,
232 /// created_at TIMESTAMP
233 /// )
234 /// "#)?;
235 ///
236 /// // Inserter definition - what we provide
237 /// let inserter_def = TableDefinition::new("_stage")
238 /// .add_required_column("id", SqlType::int())
239 /// .add_nullable_column("product", SqlType::text())
240 /// .add_nullable_column("quantity", SqlType::int())
241 /// .add_nullable_column("price", SqlType::double());
242 ///
243 /// // Column mappings - how values are transformed
244 /// let mappings = vec![
245 /// ColumnMapping::new("id"),
246 /// ColumnMapping::new("product"),
247 /// ColumnMapping::new("quantity"),
248 /// ColumnMapping::new("price"),
249 /// ColumnMapping::with_expression("total", "quantity * price"),
250 /// ColumnMapping::with_expression("created_at", "NOW()"),
251 /// ];
252 ///
253 /// // For simple table names in the public schema, use quoted name
254 /// // For qualified names, use target_table_def.qualified_name()
255 /// let mut inserter = Inserter::with_column_mappings(&conn, &inserter_def, "orders", &mappings)?;
256 ///
257 /// inserter.add_row(&[&1i32, &"Widget", &5i32, &10.0f64])?;
258 /// inserter.add_row(&[&2i32, &"Gadget", &3i32, &25.0f64])?;
259 ///
260 /// let rows = inserter.execute()?;
261 /// Ok(())
262 /// }
263 /// ```
264 ///
265 /// # Errors
266 ///
267 /// - Returns an error if `target_table` fails to convert into a
268 /// [`TableName`](crate::TableName).
269 /// - Returns [`Error::Server`] if creating the temporary staging table
270 /// fails on the server.
271 /// - Returns the errors from [`Inserter::new`] for the staging table
272 /// (zero-column table definition, gRPC transport).
273 pub fn with_column_mappings<T>(
274 connection: &'conn Connection,
275 inserter_def: &TableDefinition,
276 target_table: T,
277 mappings: &[ColumnMapping],
278 ) -> Result<MappedInserter<'conn>>
279 where
280 T: TryInto<crate::TableName>,
281 crate::Error: From<T::Error>,
282 {
283 MappedInserter::new(connection, inserter_def, target_table, mappings)
284 }
285
286 /// Returns the table definition.
287 #[must_use]
288 pub fn table_definition(&self) -> &TableDefinition {
289 &self.table_def
290 }
291
292 /// Returns the number of columns.
293 #[must_use]
294 pub fn column_count(&self) -> usize {
295 self.table_def.column_count()
296 }
297
298 /// Returns the number of complete rows buffered.
299 #[must_use]
300 pub fn row_count(&self) -> u64 {
301 self.row_count
302 }
303
304 /// Adds a NULL value for the current column.
305 ///
306 /// # Errors
307 ///
308 /// Returns [`Error::InvalidTableDefinition`] if the current row already has all columns
309 /// supplied, or if the current column is marked `NOT NULL` in the table
310 /// definition.
311 #[inline]
312 pub fn add_null(&mut self) -> Result<()> {
313 self.chunk.add_null()
314 }
315
316 /// Adds a boolean value.
317 ///
318 /// # Errors
319 ///
320 /// Returns [`Error::InvalidTableDefinition`] with message `"Too many columns in row"` if
321 /// the current row already has all columns supplied.
322 #[inline]
323 pub fn add_bool(&mut self, value: bool) -> Result<()> {
324 self.chunk.add_bool(value)
325 }
326
327 /// Adds an i16 value (SMALLINT).
328 ///
329 /// # Errors
330 ///
331 /// See [`add_bool`](Self::add_bool).
332 #[inline]
333 pub fn add_i16(&mut self, value: i16) -> Result<()> {
334 self.chunk.add_i16(value)
335 }
336
337 /// Adds an i32 value (INT).
338 ///
339 /// # Errors
340 ///
341 /// See [`add_bool`](Self::add_bool).
342 #[inline]
343 pub fn add_i32(&mut self, value: i32) -> Result<()> {
344 self.chunk.add_i32(value)
345 }
346
347 /// Adds an i64 value (BIGINT).
348 ///
349 /// # Errors
350 ///
351 /// See [`add_bool`](Self::add_bool).
352 #[inline]
353 pub fn add_i64(&mut self, value: i64) -> Result<()> {
354 self.chunk.add_i64(value)
355 }
356
357 /// Adds an f32 value (REAL/FLOAT4).
358 ///
359 /// # Errors
360 ///
361 /// See [`add_bool`](Self::add_bool).
362 #[inline]
363 pub fn add_f32(&mut self, value: f32) -> Result<()> {
364 self.chunk.add_f32(value)
365 }
366
367 /// Adds an f64 value (DOUBLE PRECISION/FLOAT8).
368 ///
369 /// # Errors
370 ///
371 /// See [`add_bool`](Self::add_bool).
372 #[inline]
373 pub fn add_f64(&mut self, value: f64) -> Result<()> {
374 self.chunk.add_f64(value)
375 }
376
377 /// Adds a string value (TEXT/VARCHAR).
378 ///
379 /// # Errors
380 ///
381 /// See [`add_bool`](Self::add_bool).
382 #[inline]
383 pub fn add_str(&mut self, value: &str) -> Result<()> {
384 self.chunk.add_str(value)
385 }
386
387 /// Adds a bytes value (BYTEA).
388 ///
389 /// # Errors
390 ///
391 /// See [`add_bool`](Self::add_bool).
392 #[inline]
393 pub fn add_bytes(&mut self, value: &[u8]) -> Result<()> {
394 self.chunk.add_bytes(value)
395 }
396
397 /// Adds a 128-bit value (NUMERIC/INTERVAL).
398 ///
399 /// # Errors
400 ///
401 /// See [`add_bool`](Self::add_bool).
402 #[inline]
403 pub fn add_data128(&mut self, value: &[u8; 16]) -> Result<()> {
404 self.chunk.add_data128(value)
405 }
406
407 /// Adds an optional value. If None, adds NULL.
408 ///
409 /// # Errors
410 ///
411 /// Propagates whatever `add_fn` or [`add_null`](Self::add_null) would
412 /// return for the current row position.
413 pub fn add_optional<T, F>(&mut self, value: Option<T>, add_fn: F) -> Result<()>
414 where
415 F: FnOnce(&mut Self, T) -> Result<()>,
416 {
417 match value {
418 Some(v) => add_fn(self, v),
419 None => self.add_null(),
420 }
421 }
422
423 /// Ends the current row.
424 ///
425 /// Returns an error if the wrong number of columns were added.
426 /// Automatically flushes the buffer if chunk limits are reached.
427 ///
428 /// # Errors
429 ///
430 /// - Returns [`Error::InvalidTableDefinition`] if fewer (or more) columns were supplied
431 /// than the table definition requires.
432 /// - Returns any error from [`flush`](Self::flush) when an automatic
433 /// flush is triggered by reaching the chunk byte/row limit.
434 pub fn end_row(&mut self) -> Result<()> {
435 self.chunk.end_row()?;
436 self.row_count += 1;
437
438 // Auto-flush if we've reached chunk limits
439 if self.chunk.should_flush() {
440 self.flush()?;
441 }
442
443 Ok(())
444 }
445
446 /// Flushes the current buffer to the server.
447 ///
448 /// This sends all buffered rows as a chunk and resets the buffer.
449 /// Called automatically when chunk limits are reached.
450 ///
451 /// # Errors
452 ///
453 /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC transport
454 /// (COPY is TCP-only) and no COPY session exists yet.
455 /// - Returns [`Error::Server`] if the server rejects the `COPY IN` start
456 /// or the subsequent data send.
457 /// - Returns [`Error::Io`] on transport-level I/O failures while writing
458 /// the chunk.
459 pub fn flush(&mut self) -> Result<()> {
460 if self.chunk.is_empty() {
461 return Ok(());
462 }
463
464 let chunk_rows = self.chunk.row_count();
465 let Some(buffer) = self.chunk.take() else {
466 return Ok(());
467 };
468
469 // Ensure the COPY connection is started
470 if self.writer.is_none() {
471 let client = self.connection.tcp_client().ok_or_else(|| {
472 crate::Error::feature_not_supported(
473 "Inserter requires a TCP connection. gRPC connections do not support COPY operations.",
474 )
475 })?;
476 let columns: Vec<&str> = self
477 .table_def
478 .columns
479 .iter()
480 .map(|c| c.name.as_str())
481 .collect();
482 let table_name = self.table_def.qualified_name();
483 self.writer = Some(client.copy_in(&table_name, &columns)?);
484 }
485
486 // Write the chunk directly to the socket, avoiding a full-chunk memcpy
487 // into the connection's write buffer. flush_stream ensures the data
488 // reaches the server before we return.
489 if let Some(ref mut writer) = self.writer {
490 writer.send_direct(&buffer)?;
491 writer.flush_stream()?;
492 }
493
494 debug!(
495 target: "hyperdb_api",
496 chunk = self.chunk_count,
497 rows = chunk_rows,
498 bytes = buffer.len(),
499 "inserter-chunk"
500 );
501
502 self.chunk_count += 1;
503 Ok(())
504 }
505
506 /// Adds a complete row of values.
507 ///
508 /// This is a convenience method that adds all column values at once
509 /// using the `IntoValue` trait for type-safe insertion.
510 ///
511 /// # Arguments
512 ///
513 /// * `values` - A slice of values implementing `IntoValue`.
514 ///
515 /// # Errors
516 ///
517 /// Returns an error if the number of values doesn't match the column count,
518 /// or if any value cannot be added.
519 ///
520 /// # Example
521 ///
522 /// ```no_run
523 /// use hyperdb_api::{Connection, CreateMode, Catalog, TableDefinition, Inserter, SqlType, Result};
524 ///
525 /// fn main() -> Result<()> {
526 /// let conn = Connection::connect("localhost:7483", "test.hyper", CreateMode::CreateIfNotExists)?;
527 ///
528 /// let table_def = TableDefinition::new("users")
529 /// .add_required_column("id", SqlType::int())
530 /// .add_nullable_column("name", SqlType::text());
531 ///
532 /// Catalog::new(&conn).create_table(&table_def)?;
533 ///
534 /// let mut inserter = Inserter::new(&conn, &table_def)?;
535 ///
536 /// // Add rows using IntoValue trait
537 /// inserter.add_row(&[&1i32, &"Alice"])?;
538 /// inserter.add_row(&[&2i32, &"Bob"])?;
539 ///
540 /// // Option<T> can be used for nullable columns
541 /// inserter.add_row(&[&3i32, &None::<&str>])?;
542 ///
543 /// let rows = inserter.execute()?;
544 /// Ok(())
545 /// }
546 /// ```
547 pub fn add_row(&mut self, values: &[&dyn IntoValue]) -> Result<()> {
548 let column_count = self.table_def.column_count();
549 if values.len() != column_count {
550 return Err(Error::invalid_table_definition(format!(
551 "Column count mismatch: expected {} columns but got {}",
552 column_count,
553 values.len()
554 )));
555 }
556
557 for value in values {
558 value.add_to_inserter(self)?;
559 }
560
561 self.end_row()?;
562 Ok(())
563 }
564
565 /// Adds a Date value.
566 ///
567 /// # Errors
568 ///
569 /// See [`add_bool`](Self::add_bool).
570 #[inline]
571 pub fn add_date(&mut self, value: Date) -> Result<()> {
572 self.chunk.add_date(value)
573 }
574
575 /// Adds a Time value.
576 ///
577 /// # Errors
578 ///
579 /// See [`add_bool`](Self::add_bool).
580 #[inline]
581 pub fn add_time(&mut self, value: Time) -> Result<()> {
582 self.chunk.add_time(value)
583 }
584
585 /// Adds a Timestamp value.
586 ///
587 /// # Errors
588 ///
589 /// See [`add_bool`](Self::add_bool).
590 #[inline]
591 pub fn add_timestamp(&mut self, value: Timestamp) -> Result<()> {
592 self.chunk.add_timestamp(value)
593 }
594
595 /// Adds an `OffsetTimestamp` (TIMESTAMP WITH TIME ZONE) value.
596 ///
597 /// # Errors
598 ///
599 /// See [`add_bool`](Self::add_bool).
600 #[inline]
601 pub fn add_offset_timestamp(&mut self, value: OffsetTimestamp) -> Result<()> {
602 self.chunk.add_offset_timestamp(value)
603 }
604
605 /// Adds an Interval value.
606 ///
607 /// # Errors
608 ///
609 /// See [`add_bool`](Self::add_bool).
610 #[inline]
611 pub fn add_interval(&mut self, value: Interval) -> Result<()> {
612 self.chunk.add_interval(value)
613 }
614
615 /// Adds a Geography value.
616 ///
617 /// # Errors
618 ///
619 /// See [`add_bool`](Self::add_bool).
620 #[inline]
621 pub fn add_geography(&mut self, value: &Geography) -> Result<()> {
622 self.chunk.add_geography(value)
623 }
624
625 /// Adds a Numeric value.
626 ///
627 /// For NUMERIC(precision, scale) where precision ≤ [`Numeric::SMALL_NUMERIC_MAX_PRECISION`]
628 /// (18), the value is stored as i64. For higher precision, 128-bit storage is used.
629 ///
630 /// # Errors
631 ///
632 /// Returns an error if the column's precision cannot be determined from the
633 /// table definition. Ensure that NUMERIC columns are defined with explicit
634 /// `SqlType` information including precision.
635 pub fn add_numeric(&mut self, value: Numeric) -> Result<()> {
636 let column_index = self.chunk.column_index();
637
638 // Check the column's precision to determine storage format
639 let precision = self
640 .table_def
641 .columns
642 .get(column_index)
643 .and_then(super::table_definition::ColumnDefinition::sql_type)
644 .and_then(|t| t.precision())
645 .ok_or_else(|| {
646 let col_name = self
647 .table_def
648 .columns
649 .get(column_index)
650 .map_or("<unknown>", |c| c.name.as_str());
651 Error::conversion(format!(
652 "Cannot determine numeric precision for column '{col_name}' at index {column_index}. \
653 Ensure the column is defined with explicit SqlType including precision.\n\n\
654 Example fix:\n \
655 table_def.add_column_with_type(\"{col_name}\", SqlType::Numeric {{ precision: 10, scale: 2 }}, true);"
656 ))
657 })?;
658
659 if precision <= Numeric::SMALL_NUMERIC_MAX_PRECISION {
660 // Small numeric: stored as i64
661 let unscaled = value.unscaled_value();
662 let narrowed = i64::try_from(unscaled).map_err(|_| {
663 Error::conversion(format!(
664 "Numeric value {unscaled} is out of range for i64 storage (precision {precision})"
665 ))
666 })?;
667 self.chunk.add_i64(narrowed)
668 } else {
669 // Big numeric: stored as 128-bit
670 self.chunk.add_data128(&value.to_packed())
671 }
672 }
673
674 /// Executes the insert and commits all buffered rows.
675 ///
676 /// This sends any remaining buffered data and finishes the COPY operation.
677 /// Returns the number of rows inserted.
678 ///
679 /// The inserter is single-use: calling `execute` a second time returns
680 /// `Ok(0)` because the internal row counter has been reset and no further
681 /// data has been added. To insert additional batches, create a new
682 /// [`Inserter`].
683 ///
684 /// # Errors
685 ///
686 /// Returns an error if:
687 /// - There's an incomplete row (`column_index` != 0)
688 /// - The COPY connection fails to start
689 /// - Sending data fails
690 pub fn execute(&mut self) -> Result<u64> {
691 if self.chunk.column_index() != 0 {
692 return Err(Error::invalid_table_definition(
693 "Incomplete row at execute time",
694 ));
695 }
696
697 if self.row_count == 0 {
698 return Ok(0);
699 }
700
701 // Ensure COPY connection exists before proceeding when we have rows
702 if self.writer.is_none() {
703 let client = self.connection.tcp_client().ok_or_else(|| {
704 Error::feature_not_supported(
705 "Inserter requires a TCP connection. gRPC connections do not support COPY operations.",
706 )
707 })?;
708 let columns: Vec<&str> = self
709 .table_def
710 .columns
711 .iter()
712 .map(|c| c.name.as_str())
713 .collect();
714 let table_name = self.table_def.qualified_name();
715 self.writer = Some(client.copy_in(&table_name, &columns)?);
716 }
717
718 // At this point, writer must exist since we have rows
719 let writer = self
720 .writer
721 .as_mut()
722 .ok_or_else(|| Error::internal("Failed to initialize COPY connection for inserter"))?;
723
724 // If we have buffered data that hasn't been sent yet
725 if !self.chunk.is_empty() {
726 writer.send(self.chunk.buffer())?;
727 }
728
729 // Write and send the COPY trailer
730 let mut trailer_buf = BytesMut::with_capacity(2);
731 copy::write_trailer(&mut trailer_buf);
732 writer.send(&trailer_buf)?;
733
734 // Finish the COPY operation
735 let rows = self
736 .writer
737 .take()
738 .map(hyperdb_api_core::client::CopyInWriter::finish)
739 .transpose()?
740 .unwrap_or(0);
741
742 let duration_ms = u64::try_from(self.start_time.elapsed().as_millis()).unwrap_or(u64::MAX);
743 info!(
744 target: "hyperdb_api",
745 rows,
746 chunks = self.chunk_count,
747 duration_ms,
748 table = %self.table_def.qualified_name(),
749 "inserter-end"
750 );
751
752 // Reset row counter so a stray second execute() call returns Ok(0)
753 // instead of attempting another COPY trailer on a finished writer.
754 self.row_count = 0;
755
756 Ok(rows)
757 }
758
759 /// Cancels the insert and discards all buffered rows.
760 pub fn cancel(&mut self) {
761 // Drop the in-progress writer (if any). The Drop impl on CopyInWriter
762 // sends a CopyFail to the server.
763 self.writer = None;
764 self.row_count = 0;
765 }
766}
767
768// =============================================================================
769// ColumnMapping
770// =============================================================================
771
772/// Defines how a column receives its value during insertion.
773///
774/// Column mappings allow you to:
775/// - Insert values directly from the inserter stream
776/// - Compute values using SQL expressions
777/// - Use server-side functions like `NOW()` or `DEFAULT`
778///
779/// # Example
780///
781/// ```
782/// use hyperdb_api::ColumnMapping;
783///
784/// // Simple column - insert value directly
785/// let id_col = ColumnMapping::new("id");
786///
787/// // Column with expression - computed value
788/// let created_at = ColumnMapping::with_expression("created_at", "NOW()");
789/// let full_name = ColumnMapping::with_expression("full_name", "first_name || ' ' || last_name");
790/// ```
791#[derive(Debug, Clone)]
792#[must_use = "ColumnMapping represents a column configuration that should not be discarded. Use it when defining inserter column mappings"]
793pub struct ColumnMapping {
794 /// The name of the target column.
795 pub column_name: String,
796 /// Optional SQL expression. If None, the value is inserted directly.
797 pub expression: Option<String>,
798}
799
800impl ColumnMapping {
801 /// Creates a column mapping for direct value insertion.
802 ///
803 /// The column will receive values directly from the inserter.
804 pub fn new(column_name: impl Into<String>) -> Self {
805 ColumnMapping {
806 column_name: column_name.into(),
807 expression: None,
808 }
809 }
810
811 /// Creates a column mapping with a SQL expression.
812 ///
813 /// The column value will be computed using the given SQL expression.
814 /// The expression can reference other columns or use SQL functions.
815 ///
816 /// # Arguments
817 ///
818 /// * `column_name` - The name of the target column.
819 /// * `expression` - A SQL expression to compute the column value.
820 ///
821 /// # Example
822 ///
823 /// ```
824 /// use hyperdb_api::ColumnMapping;
825 ///
826 /// // Use current timestamp
827 /// let created = ColumnMapping::with_expression("created_at", "NOW()");
828 ///
829 /// // Compute from other columns
830 /// let total = ColumnMapping::with_expression("total", "quantity * price");
831 /// ```
832 pub fn with_expression(column_name: impl Into<String>, expression: impl Into<String>) -> Self {
833 ColumnMapping {
834 column_name: column_name.into(),
835 expression: Some(expression.into()),
836 }
837 }
838
839 /// Returns the column name.
840 #[must_use]
841 pub fn column_name(&self) -> &str {
842 &self.column_name
843 }
844
845 /// Returns the SQL expression, if any.
846 #[must_use]
847 pub fn expression(&self) -> Option<&str> {
848 self.expression.as_deref()
849 }
850
851 /// Returns true if this is a direct value mapping (no expression).
852 #[must_use]
853 pub fn is_direct(&self) -> bool {
854 self.expression.is_none()
855 }
856
857 /// Returns the select list item for this mapping.
858 fn to_select_item(&self) -> String {
859 match &self.expression {
860 Some(expr) => format!("{} AS \"{}\"", expr, self.column_name.replace('"', "\"\"")),
861 None => format!("\"{}\"", self.column_name.replace('"', "\"\"")),
862 }
863 }
864}
865
866// =============================================================================
867// IntoValue Trait
868// =============================================================================
869
870/// Trait for types that can be inserted into a Hyper table.
871///
872/// This trait is implemented for common Rust types, allowing them to be
873/// used with [`Inserter::add_row()`] for type-safe insertion.
874///
875/// # Supported Types
876///
877/// - Integers: `i16`, `i32`, `i64`
878/// - Floats: `f32`, `f64`
879/// - `bool`
880/// - `&str`, `String`
881/// - `Option<T>` where `T: IntoValue` (for nullable columns)
882/// - Date/time types: `Date`, `Time`, `Timestamp`, `Interval`
883/// - `Numeric`, `Geography`, `Vec<u8>` (bytes)
884///
885/// # Example
886///
887/// ```no_run
888/// use hyperdb_api::{Connection, CreateMode, Catalog, TableDefinition, Inserter, IntoValue, SqlType, Result};
889///
890/// fn main() -> Result<()> {
891/// let conn = Connection::connect("localhost:7483", "test.hyper", CreateMode::CreateIfNotExists)?;
892///
893/// let table_def = TableDefinition::new("example")
894/// .add_required_column("a", SqlType::int())
895/// .add_nullable_column("b", SqlType::text())
896/// .add_nullable_column("c", SqlType::double());
897/// Catalog::new(&conn).create_table(&table_def)?;
898///
899/// let mut inserter = Inserter::new(&conn, &table_def)?;
900///
901/// // IntoValue allows adding rows with mixed types
902/// inserter.add_row(&[&1i32, &"Alice", &Some(3.14f64)])?;
903/// inserter.add_row(&[&2i32, &"Bob", &None::<f64>])?; // NULL value
904///
905/// inserter.execute()?;
906/// Ok(())
907/// }
908/// ```
909pub trait IntoValue {
910 /// Adds this value to the inserter.
911 ///
912 /// # Errors
913 ///
914 /// Implementations call the matching `Inserter::add_*` method and
915 /// forward its error — see [`Inserter::add_bool`] for the shared
916 /// failure modes (too many columns, NULL into non-nullable, etc).
917 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()>;
918}
919
920// Implementations for basic types
921
922impl IntoValue for bool {
923 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
924 inserter.add_bool(*self)
925 }
926}
927
928impl IntoValue for i16 {
929 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
930 inserter.add_i16(*self)
931 }
932}
933
934impl IntoValue for i32 {
935 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
936 inserter.add_i32(*self)
937 }
938}
939
940impl IntoValue for i64 {
941 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
942 inserter.add_i64(*self)
943 }
944}
945
946impl IntoValue for f32 {
947 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
948 inserter.add_f32(*self)
949 }
950}
951
952impl IntoValue for f64 {
953 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
954 inserter.add_f64(*self)
955 }
956}
957
958impl IntoValue for str {
959 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
960 inserter.add_str(self)
961 }
962}
963
964impl IntoValue for String {
965 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
966 inserter.add_str(self)
967 }
968}
969
970impl IntoValue for [u8] {
971 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
972 inserter.add_bytes(self)
973 }
974}
975
976impl IntoValue for Vec<u8> {
977 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
978 inserter.add_bytes(self)
979 }
980}
981
982// Hyper-specific types
983
984impl IntoValue for Date {
985 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
986 inserter.add_date(*self)
987 }
988}
989
990impl IntoValue for Time {
991 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
992 inserter.add_time(*self)
993 }
994}
995
996impl IntoValue for Timestamp {
997 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
998 inserter.add_timestamp(*self)
999 }
1000}
1001
1002impl IntoValue for OffsetTimestamp {
1003 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1004 inserter.add_offset_timestamp(*self)
1005 }
1006}
1007
1008impl IntoValue for Interval {
1009 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1010 inserter.add_interval(*self)
1011 }
1012}
1013
1014impl IntoValue for Numeric {
1015 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1016 inserter.add_numeric(*self)
1017 }
1018}
1019
1020impl IntoValue for Geography {
1021 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1022 inserter.add_geography(self)
1023 }
1024}
1025
1026// Option<T> for nullable values
1027impl<T: IntoValue> IntoValue for Option<T> {
1028 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1029 match self {
1030 Some(value) => value.add_to_inserter(inserter),
1031 None => inserter.add_null(),
1032 }
1033 }
1034}
1035
1036// Reference implementations for primitives
1037
1038impl IntoValue for &bool {
1039 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1040 inserter.add_bool(**self)
1041 }
1042}
1043
1044impl IntoValue for &i16 {
1045 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1046 inserter.add_i16(**self)
1047 }
1048}
1049
1050impl IntoValue for &i32 {
1051 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1052 inserter.add_i32(**self)
1053 }
1054}
1055
1056impl IntoValue for &i64 {
1057 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1058 inserter.add_i64(**self)
1059 }
1060}
1061
1062impl IntoValue for &f32 {
1063 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1064 inserter.add_f32(**self)
1065 }
1066}
1067
1068impl IntoValue for &f64 {
1069 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1070 inserter.add_f64(**self)
1071 }
1072}
1073
1074impl IntoValue for &String {
1075 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1076 inserter.add_str(self)
1077 }
1078}
1079
1080impl IntoValue for &str {
1081 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1082 inserter.add_str(self)
1083 }
1084}
1085
1086impl IntoValue for &&str {
1087 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1088 inserter.add_str(self)
1089 }
1090}
1091
1092impl IntoValue for &[u8] {
1093 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1094 inserter.add_bytes(self)
1095 }
1096}
1097
1098impl IntoValue for &Date {
1099 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1100 inserter.add_date(**self)
1101 }
1102}
1103
1104impl IntoValue for &Time {
1105 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1106 inserter.add_time(**self)
1107 }
1108}
1109
1110impl IntoValue for &Timestamp {
1111 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1112 inserter.add_timestamp(**self)
1113 }
1114}
1115
1116impl IntoValue for &OffsetTimestamp {
1117 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1118 inserter.add_offset_timestamp(**self)
1119 }
1120}
1121
1122impl IntoValue for &Interval {
1123 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1124 inserter.add_interval(**self)
1125 }
1126}
1127
1128impl IntoValue for &Numeric {
1129 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1130 inserter.add_numeric(**self)
1131 }
1132}
1133
1134impl IntoValue for &Geography {
1135 fn add_to_inserter(&self, inserter: &mut Inserter<'_>) -> Result<()> {
1136 inserter.add_geography(self)
1137 }
1138}
1139
1140// =============================================================================
1141// MappedInserter
1142// =============================================================================
1143
1144/// An inserter that supports SQL expression mappings.
1145///
1146/// This inserter uses a staging table to support computed columns via
1147/// INSERT...SELECT with SQL expressions. It's created by
1148/// [`Inserter::with_column_mappings`].
1149#[derive(Debug)]
1150pub struct MappedInserter<'conn> {
1151 /// The underlying inserter for the staging table.
1152 inner: Inserter<'conn>,
1153 /// The target table name.
1154 target_table: crate::TableName,
1155 /// The column mappings.
1156 mappings: Vec<ColumnMapping>,
1157 /// The staging table name.
1158 staging_table: String,
1159}
1160
1161impl<'conn> MappedInserter<'conn> {
1162 /// Creates a new mapped inserter.
1163 fn new<T>(
1164 connection: &'conn Connection,
1165 inserter_def: &TableDefinition,
1166 target_table: T,
1167 mappings: &[ColumnMapping],
1168 ) -> Result<Self>
1169 where
1170 T: TryInto<crate::TableName>,
1171 crate::Error: From<T::Error>,
1172 {
1173 let target_table = target_table.try_into()?;
1174
1175 // Create a unique staging table name
1176 let staging_table = format!("_hyper_staging_{}", std::process::id());
1177
1178 // Create the staging table definition (temporary)
1179 let mut staging_def = inserter_def.clone();
1180 staging_def.name.clone_from(&staging_table);
1181
1182 // Create the staging table
1183 let create_sql = staging_def.to_create_sql(true)?;
1184 let create_temp = create_sql.replace("CREATE TABLE", "CREATE TEMPORARY TABLE");
1185 connection.execute_command(&create_temp)?;
1186
1187 // Create the inner inserter for the staging table
1188 let inner = Inserter::new(connection, &staging_def)?;
1189
1190 Ok(MappedInserter {
1191 inner,
1192 target_table,
1193 mappings: mappings.to_vec(),
1194 staging_table,
1195 })
1196 }
1197
1198 /// Adds a row of values to the inserter.
1199 ///
1200 /// The values should correspond to the columns in the inserter definition,
1201 /// not the target table.
1202 ///
1203 /// # Errors
1204 ///
1205 /// Forwards the error from [`Inserter::add_row`].
1206 pub fn add_row(&mut self, values: &[&dyn IntoValue]) -> Result<()> {
1207 self.inner.add_row(values)
1208 }
1209
1210 /// Adds a NULL value.
1211 ///
1212 /// # Errors
1213 ///
1214 /// Forwards the error from [`Inserter::add_null`].
1215 pub fn add_null(&mut self) -> Result<()> {
1216 self.inner.add_null()
1217 }
1218
1219 /// Adds a boolean value.
1220 ///
1221 /// # Errors
1222 ///
1223 /// Forwards the error from [`Inserter::add_bool`].
1224 pub fn add_bool(&mut self, value: bool) -> Result<()> {
1225 self.inner.add_bool(value)
1226 }
1227
1228 /// Adds an i16 value.
1229 ///
1230 /// # Errors
1231 ///
1232 /// Forwards the error from [`Inserter::add_i16`].
1233 pub fn add_i16(&mut self, value: i16) -> Result<()> {
1234 self.inner.add_i16(value)
1235 }
1236
1237 /// Adds an i32 value.
1238 ///
1239 /// # Errors
1240 ///
1241 /// Forwards the error from [`Inserter::add_i32`].
1242 pub fn add_i32(&mut self, value: i32) -> Result<()> {
1243 self.inner.add_i32(value)
1244 }
1245
1246 /// Adds an i64 value.
1247 ///
1248 /// # Errors
1249 ///
1250 /// Forwards the error from [`Inserter::add_i64`].
1251 pub fn add_i64(&mut self, value: i64) -> Result<()> {
1252 self.inner.add_i64(value)
1253 }
1254
1255 /// Adds an f32 value.
1256 ///
1257 /// # Errors
1258 ///
1259 /// Forwards the error from [`Inserter::add_f32`].
1260 pub fn add_f32(&mut self, value: f32) -> Result<()> {
1261 self.inner.add_f32(value)
1262 }
1263
1264 /// Adds an f64 value.
1265 ///
1266 /// # Errors
1267 ///
1268 /// Forwards the error from [`Inserter::add_f64`].
1269 pub fn add_f64(&mut self, value: f64) -> Result<()> {
1270 self.inner.add_f64(value)
1271 }
1272
1273 /// Adds a string value.
1274 ///
1275 /// # Errors
1276 ///
1277 /// Forwards the error from [`Inserter::add_str`].
1278 pub fn add_str(&mut self, value: &str) -> Result<()> {
1279 self.inner.add_str(value)
1280 }
1281
1282 /// Adds a bytes value.
1283 ///
1284 /// # Errors
1285 ///
1286 /// Forwards the error from [`Inserter::add_bytes`].
1287 pub fn add_bytes(&mut self, value: &[u8]) -> Result<()> {
1288 self.inner.add_bytes(value)
1289 }
1290
1291 /// Ends the current row.
1292 ///
1293 /// # Errors
1294 ///
1295 /// Forwards the error from [`Inserter::end_row`].
1296 pub fn end_row(&mut self) -> Result<()> {
1297 self.inner.end_row()
1298 }
1299
1300 /// Executes the insert with column mappings.
1301 ///
1302 /// This method:
1303 /// 1. Inserts all buffered rows into the staging table
1304 /// 2. Executes INSERT...SELECT from staging to target with mappings
1305 /// 3. Drops the staging table
1306 ///
1307 /// Returns the number of rows inserted into the target table.
1308 ///
1309 /// # Errors
1310 ///
1311 /// - Returns the error from the inner [`Inserter::execute`] if writing
1312 /// the staging rows fails.
1313 /// - Returns [`Error::Server`] if the `INSERT ... SELECT` from staging
1314 /// to the target table is rejected (e.g. a mapping expression fails
1315 /// to evaluate).
1316 /// - Returns [`Error::Server`] if dropping the staging table fails.
1317 pub fn execute(&mut self) -> Result<u64> {
1318 let connection = self.inner.connection;
1319 let staging_table = self.staging_table.clone();
1320
1321 // Insert data into staging table
1322 let _staging_rows = self.inner.execute()?;
1323
1324 // Build the INSERT...SELECT statement
1325 use hyperdb_api_core::protocol::escape::SqlIdentifier;
1326
1327 let target_columns: Vec<String> = self
1328 .mappings
1329 .iter()
1330 .map(|m| format!("{}", SqlIdentifier(&m.column_name)))
1331 .collect();
1332
1333 let select_items: Vec<String> = self
1334 .mappings
1335 .iter()
1336 .map(ColumnMapping::to_select_item)
1337 .collect();
1338
1339 let sql = format!(
1340 "INSERT INTO {} ({}) SELECT {} FROM {}",
1341 self.target_table,
1342 target_columns.join(", "),
1343 select_items.join(", "),
1344 SqlIdentifier(&staging_table),
1345 );
1346
1347 // Execute the INSERT...SELECT (returns row count directly)
1348 let row_count = connection.execute_command(&sql)?;
1349
1350 // Drop the staging table
1351 connection.execute_command(&format!(
1352 "DROP TABLE IF EXISTS {}",
1353 SqlIdentifier(&staging_table)
1354 ))?;
1355
1356 // Return the number of rows inserted
1357 Ok(row_count)
1358 }
1359
1360 /// Cancels the insert and drops the staging table.
1361 ///
1362 /// This method handles cleanup failures gracefully by logging warnings
1363 /// instead of returning errors. This prevents masking the original error
1364 /// that caused the cancellation.
1365 ///
1366 /// # Logging
1367 ///
1368 /// Cleanup failures are logged using the `tracing` crate at WARN level.
1369 /// If `tracing` is not initialized, errors are written to stderr.
1370 pub fn cancel(&mut self) {
1371 let connection = self.inner.connection;
1372 let staging_table = &self.staging_table;
1373
1374 // Drop the staging table, but don't fail if cleanup fails
1375 // This avoids masking the original error that caused cancellation
1376 if let Err(e) = connection.execute_command(&format!(
1377 "DROP TABLE IF EXISTS \"{}\"",
1378 staging_table.replace('"', "\"\"")
1379 )) {
1380 // Log the cleanup failure for debugging
1381 // In production, consider using a logging framework like `tracing`
1382 eprintln!("Warning: Failed to drop staging table '{staging_table}' during cancel: {e}");
1383 }
1384 }
1385}
1386
1387// =============================================================================
1388// InsertChunk - Thread-safe chunk for parallel encoding
1389// =============================================================================
1390
1391/// A thread-safe chunk for encoding rows in parallel.
1392///
1393/// `InsertChunk` can be created and populated in any thread, then sent to a
1394/// [`ChunkSender`] for transmission. This enables parallel data encoding across
1395/// multiple worker threads while serializing the actual network sends.
1396///
1397/// # Example
1398///
1399/// ```no_run
1400/// use hyperdb_api::{InsertChunk, TableDefinition, SqlType, Result};
1401///
1402/// fn encode_chunk(table_def: &TableDefinition, start_id: i32) -> Result<InsertChunk> {
1403/// let mut chunk = InsertChunk::from_table_definition(table_def);
1404///
1405/// for i in 0..1000 {
1406/// chunk.add_i32(start_id + i)?;
1407/// chunk.add_str(&format!("Item {}", start_id + i))?;
1408/// chunk.end_row()?;
1409/// }
1410///
1411/// Ok(chunk)
1412/// }
1413/// ```
1414#[derive(Debug)]
1415pub struct InsertChunk {
1416 buffer: BytesMut,
1417 header_written: bool,
1418 column_index: usize,
1419 column_count: usize,
1420 row_count: usize,
1421 column_nullable: Vec<bool>,
1422}
1423
1424// SAFETY: Every field of `InsertChunk` (`BytesMut`, `bool`, `usize`,
1425// `Vec<bool>`) is itself `Send`, and none of them hold raw pointers or
1426// thread-local state. The manual `unsafe impl` exists only because the
1427// auto-trait derivation is conservative for this struct's compilation context;
1428// the compound type has no `!Send` components.
1429unsafe impl Send for InsertChunk {}
1430// SAFETY: Same reasoning as the `Send` impl above — all fields are `Sync`
1431// and there is no interior mutability crossing a `&InsertChunk` boundary,
1432// so sharing `&InsertChunk` across threads is sound.
1433unsafe impl Sync for InsertChunk {}
1434
1435impl InsertChunk {
1436 /// Creates a new empty chunk with the given schema.
1437 ///
1438 /// # Arguments
1439 ///
1440 /// * `column_count` - Number of columns per row
1441 /// * `column_nullable` - Whether each column is nullable
1442 #[must_use]
1443 pub fn new(column_count: usize, column_nullable: Vec<bool>) -> Self {
1444 debug_assert_eq!(column_count, column_nullable.len());
1445 InsertChunk {
1446 buffer: BytesMut::with_capacity(INITIAL_BUFFER_SIZE),
1447 header_written: false,
1448 column_index: 0,
1449 column_count,
1450 row_count: 0,
1451 column_nullable,
1452 }
1453 }
1454
1455 /// Creates a chunk from a table definition.
1456 #[must_use]
1457 pub fn from_table_definition(table_def: &TableDefinition) -> Self {
1458 let column_nullable: Vec<bool> = table_def.columns.iter().map(|c| c.nullable).collect();
1459 Self::new(table_def.column_count(), column_nullable)
1460 }
1461
1462 /// Returns the number of complete rows in this chunk.
1463 #[must_use]
1464 pub fn row_count(&self) -> usize {
1465 self.row_count
1466 }
1467
1468 /// Returns the current buffer size in bytes.
1469 #[must_use]
1470 pub fn buffer_size(&self) -> usize {
1471 self.buffer.len()
1472 }
1473
1474 /// Returns true if the chunk has reached size or row limits and should be sent.
1475 #[must_use]
1476 pub fn should_flush(&self) -> bool {
1477 self.row_count >= CHUNK_ROW_LIMIT || self.buffer.len() >= CHUNK_SIZE_LIMIT
1478 }
1479
1480 /// Returns true if the chunk is empty (no rows).
1481 #[must_use]
1482 pub fn is_empty(&self) -> bool {
1483 self.row_count == 0
1484 }
1485
1486 /// Takes the buffer, consuming the chunk data.
1487 ///
1488 /// Returns `None` if the chunk is empty. After calling this, the chunk
1489 /// can be reused by calling the add_* methods again.
1490 ///
1491 /// Note: The header flag is NOT reset - subsequent chunks from the same
1492 /// `InsertChunk` will NOT include the header (`HyperBinary` only needs one
1493 /// header per COPY stream).
1494 pub fn take(&mut self) -> Option<BytesMut> {
1495 if self.row_count == 0 {
1496 return None;
1497 }
1498 // Don't reset header_written - only first chunk should have header
1499 self.row_count = 0;
1500 Some(std::mem::take(&mut self.buffer))
1501 }
1502
1503 /// Resets the chunk for reuse without reallocating.
1504 pub fn clear(&mut self) {
1505 self.buffer.clear();
1506 self.header_written = false;
1507 self.column_index = 0;
1508 self.row_count = 0;
1509 }
1510
1511 #[allow(
1512 clippy::inline_always,
1513 reason = "hot-path numeric kernel; forced inlining measured to matter on this specific function"
1514 )]
1515 fn ensure_header(&mut self) {
1516 if !self.header_written {
1517 copy::write_header(&mut self.buffer);
1518 self.header_written = true;
1519 }
1520 }
1521
1522 #[expect(
1523 clippy::inline_always,
1524 reason = "hot inner loop of the inserter; measured to matter for per-row throughput"
1525 )]
1526 #[inline(always)]
1527 fn current_column_nullable(&self) -> bool {
1528 *self.column_nullable.get(self.column_index).unwrap_or(&true)
1529 }
1530
1531 /// Adds a NULL value for the current column.
1532 ///
1533 /// # Errors
1534 ///
1535 /// - Returns [`Error::InvalidTableDefinition`] with message `"Too many columns in row"`
1536 /// if the current row already has all columns supplied.
1537 /// - Returns [`Error::InvalidTableDefinition`] with message
1538 /// `"Cannot add NULL to non-nullable column"` if the current column
1539 /// is `NOT NULL` in the schema.
1540 pub fn add_null(&mut self) -> Result<()> {
1541 if self.column_index >= self.column_count {
1542 return Err(Error::invalid_table_definition("Too many columns in row"));
1543 }
1544 if !self.current_column_nullable() {
1545 return Err(Error::invalid_table_definition(
1546 "Cannot add NULL to non-nullable column",
1547 ));
1548 }
1549 self.ensure_header();
1550 copy::write_null(&mut self.buffer);
1551 self.column_index += 1;
1552 Ok(())
1553 }
1554
1555 /// Adds a boolean value.
1556 ///
1557 /// # Errors
1558 ///
1559 /// Returns [`Error::InvalidTableDefinition`] with message `"Too many columns in row"` if
1560 /// the current row already has all columns supplied.
1561 pub fn add_bool(&mut self, value: bool) -> Result<()> {
1562 if self.column_index >= self.column_count {
1563 return Err(Error::invalid_table_definition("Too many columns in row"));
1564 }
1565 self.ensure_header();
1566 let int_value = i8::from(value);
1567 if self.current_column_nullable() {
1568 copy::write_i8(&mut self.buffer, int_value);
1569 } else {
1570 copy::write_i8_not_null(&mut self.buffer, int_value);
1571 }
1572 self.column_index += 1;
1573 Ok(())
1574 }
1575
1576 /// Adds an i16 value (SMALLINT).
1577 ///
1578 /// # Errors
1579 ///
1580 /// See [`add_bool`](Self::add_bool).
1581 pub fn add_i16(&mut self, value: i16) -> Result<()> {
1582 if self.column_index >= self.column_count {
1583 return Err(Error::invalid_table_definition("Too many columns in row"));
1584 }
1585 self.ensure_header();
1586 if self.current_column_nullable() {
1587 copy::write_i16(&mut self.buffer, value);
1588 } else {
1589 copy::write_i16_not_null(&mut self.buffer, value);
1590 }
1591 self.column_index += 1;
1592 Ok(())
1593 }
1594
1595 /// Adds an i32 value (INT).
1596 ///
1597 /// # Errors
1598 ///
1599 /// See [`add_bool`](Self::add_bool).
1600 pub fn add_i32(&mut self, value: i32) -> Result<()> {
1601 if self.column_index >= self.column_count {
1602 return Err(Error::invalid_table_definition("Too many columns in row"));
1603 }
1604 self.ensure_header();
1605 if self.current_column_nullable() {
1606 copy::write_i32(&mut self.buffer, value);
1607 } else {
1608 copy::write_i32_not_null(&mut self.buffer, value);
1609 }
1610 self.column_index += 1;
1611 Ok(())
1612 }
1613
1614 /// Adds an i64 value (BIGINT).
1615 ///
1616 /// # Errors
1617 ///
1618 /// See [`add_bool`](Self::add_bool).
1619 pub fn add_i64(&mut self, value: i64) -> Result<()> {
1620 if self.column_index >= self.column_count {
1621 return Err(Error::invalid_table_definition("Too many columns in row"));
1622 }
1623 self.ensure_header();
1624 if self.current_column_nullable() {
1625 copy::write_i64(&mut self.buffer, value);
1626 } else {
1627 copy::write_i64_not_null(&mut self.buffer, value);
1628 }
1629 self.column_index += 1;
1630 Ok(())
1631 }
1632
1633 /// Adds an f32 value (REAL/FLOAT4).
1634 ///
1635 /// # Errors
1636 ///
1637 /// See [`add_bool`](Self::add_bool).
1638 pub fn add_f32(&mut self, value: f32) -> Result<()> {
1639 if self.column_index >= self.column_count {
1640 return Err(Error::invalid_table_definition("Too many columns in row"));
1641 }
1642 self.ensure_header();
1643 if self.current_column_nullable() {
1644 copy::write_f32(&mut self.buffer, value);
1645 } else {
1646 copy::write_f32_not_null(&mut self.buffer, value);
1647 }
1648 self.column_index += 1;
1649 Ok(())
1650 }
1651
1652 /// Adds an f64 value (DOUBLE PRECISION/FLOAT8).
1653 ///
1654 /// # Errors
1655 ///
1656 /// See [`add_bool`](Self::add_bool).
1657 pub fn add_f64(&mut self, value: f64) -> Result<()> {
1658 if self.column_index >= self.column_count {
1659 return Err(Error::invalid_table_definition("Too many columns in row"));
1660 }
1661 self.ensure_header();
1662 if self.current_column_nullable() {
1663 copy::write_f64(&mut self.buffer, value);
1664 } else {
1665 copy::write_f64_not_null(&mut self.buffer, value);
1666 }
1667 self.column_index += 1;
1668 Ok(())
1669 }
1670
1671 /// Adds a string value (TEXT/VARCHAR).
1672 ///
1673 /// # Errors
1674 ///
1675 /// See [`add_bool`](Self::add_bool).
1676 pub fn add_str(&mut self, value: &str) -> Result<()> {
1677 self.add_bytes(value.as_bytes())
1678 }
1679
1680 /// Adds a bytes value (BYTEA).
1681 ///
1682 /// # Errors
1683 ///
1684 /// See [`add_bool`](Self::add_bool).
1685 pub fn add_bytes(&mut self, value: &[u8]) -> Result<()> {
1686 if self.column_index >= self.column_count {
1687 return Err(Error::invalid_table_definition("Too many columns in row"));
1688 }
1689 if value.len() > u32::MAX as usize {
1690 return Err(Error::conversion(format!(
1691 "Value length {} exceeds HyperBinary 4-byte length limit ({})",
1692 value.len(),
1693 u32::MAX
1694 )));
1695 }
1696 self.ensure_header();
1697 if self.current_column_nullable() {
1698 copy::write_varbinary(&mut self.buffer, value);
1699 } else {
1700 copy::write_varbinary_not_null(&mut self.buffer, value);
1701 }
1702 self.column_index += 1;
1703 Ok(())
1704 }
1705
1706 /// Adds a 128-bit value (NUMERIC/INTERVAL).
1707 ///
1708 /// # Errors
1709 ///
1710 /// See [`add_bool`](Self::add_bool).
1711 pub fn add_data128(&mut self, value: &[u8; 16]) -> Result<()> {
1712 if self.column_index >= self.column_count {
1713 return Err(Error::invalid_table_definition("Too many columns in row"));
1714 }
1715 self.ensure_header();
1716 if self.current_column_nullable() {
1717 copy::write_data128(&mut self.buffer, value);
1718 } else {
1719 copy::write_data128_not_null(&mut self.buffer, value);
1720 }
1721 self.column_index += 1;
1722 Ok(())
1723 }
1724
1725 /// Adds a Date value.
1726 ///
1727 /// # Errors
1728 ///
1729 /// See [`add_bool`](Self::add_bool).
1730 pub fn add_date(&mut self, value: Date) -> Result<()> {
1731 if self.column_index >= self.column_count {
1732 return Err(Error::invalid_table_definition("Too many columns in row"));
1733 }
1734 self.ensure_header();
1735 let julian_day = value.to_julian_day();
1736 if self.current_column_nullable() {
1737 copy::write_i32(&mut self.buffer, julian_day);
1738 } else {
1739 copy::write_i32_not_null(&mut self.buffer, julian_day);
1740 }
1741 self.column_index += 1;
1742 Ok(())
1743 }
1744
1745 /// Adds a Time value.
1746 ///
1747 /// # Errors
1748 ///
1749 /// See [`add_bool`](Self::add_bool).
1750 pub fn add_time(&mut self, value: Time) -> Result<()> {
1751 if self.column_index >= self.column_count {
1752 return Err(Error::invalid_table_definition("Too many columns in row"));
1753 }
1754 self.ensure_header();
1755 let micros = value.to_microseconds();
1756 if self.current_column_nullable() {
1757 copy::write_i64(&mut self.buffer, micros);
1758 } else {
1759 copy::write_i64_not_null(&mut self.buffer, micros);
1760 }
1761 self.column_index += 1;
1762 Ok(())
1763 }
1764
1765 /// Adds a Timestamp value.
1766 ///
1767 /// # Errors
1768 ///
1769 /// See [`add_bool`](Self::add_bool).
1770 pub fn add_timestamp(&mut self, value: Timestamp) -> Result<()> {
1771 if self.column_index >= self.column_count {
1772 return Err(Error::invalid_table_definition("Too many columns in row"));
1773 }
1774 self.ensure_header();
1775 let micros = value.to_microseconds();
1776 if self.current_column_nullable() {
1777 copy::write_i64(&mut self.buffer, micros);
1778 } else {
1779 copy::write_i64_not_null(&mut self.buffer, micros);
1780 }
1781 self.column_index += 1;
1782 Ok(())
1783 }
1784
1785 /// Adds an `OffsetTimestamp` (TIMESTAMP WITH TIME ZONE) value.
1786 ///
1787 /// # Errors
1788 ///
1789 /// See [`add_bool`](Self::add_bool).
1790 pub fn add_offset_timestamp(&mut self, value: OffsetTimestamp) -> Result<()> {
1791 if self.column_index >= self.column_count {
1792 return Err(Error::invalid_table_definition("Too many columns in row"));
1793 }
1794 self.ensure_header();
1795 let micros = value.to_microseconds_utc();
1796 if self.current_column_nullable() {
1797 copy::write_i64(&mut self.buffer, micros);
1798 } else {
1799 copy::write_i64_not_null(&mut self.buffer, micros);
1800 }
1801 self.column_index += 1;
1802 Ok(())
1803 }
1804
1805 /// Adds an Interval value.
1806 ///
1807 /// # Errors
1808 ///
1809 /// See [`add_bool`](Self::add_bool).
1810 pub fn add_interval(&mut self, value: Interval) -> Result<()> {
1811 if self.column_index >= self.column_count {
1812 return Err(Error::invalid_table_definition("Too many columns in row"));
1813 }
1814 self.ensure_header();
1815 let packed = value.to_packed();
1816 if self.current_column_nullable() {
1817 copy::write_data128(&mut self.buffer, &packed);
1818 } else {
1819 copy::write_data128_not_null(&mut self.buffer, &packed);
1820 }
1821 self.column_index += 1;
1822 Ok(())
1823 }
1824
1825 /// Adds a Geography value.
1826 ///
1827 /// # Errors
1828 ///
1829 /// See [`add_bool`](Self::add_bool).
1830 pub fn add_geography(&mut self, value: &Geography) -> Result<()> {
1831 // Geography uses the same varbinary path as add_bytes
1832 self.add_bytes(value.as_bytes())
1833 }
1834
1835 /// Ends the current row.
1836 ///
1837 /// Returns an error if the wrong number of columns were added.
1838 ///
1839 /// # Errors
1840 ///
1841 /// Returns [`Error::InvalidTableDefinition`] if fewer (or more) columns were supplied
1842 /// for this row than the chunk's column count.
1843 pub fn end_row(&mut self) -> Result<()> {
1844 if self.column_index != self.column_count {
1845 return Err(Error::invalid_table_definition(format!(
1846 "Expected {} columns, got {}",
1847 self.column_count, self.column_index
1848 )));
1849 }
1850 self.column_index = 0;
1851 self.row_count += 1;
1852 Ok(())
1853 }
1854
1855 /// Returns the current column index (for checking incomplete rows).
1856 #[must_use]
1857 pub fn column_index(&self) -> usize {
1858 self.column_index
1859 }
1860
1861 /// Returns a reference to the internal buffer.
1862 pub(crate) fn buffer(&self) -> &BytesMut {
1863 &self.buffer
1864 }
1865}
1866
1867// =============================================================================
1868// ChunkSender - Mutex-protected sender for InsertChunks
1869// =============================================================================
1870
1871use std::sync::Mutex;
1872use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1873
1874/// A thread-safe sender for [`InsertChunk`]s.
1875///
1876/// `ChunkSender` manages the COPY protocol connection and ensures that only one
1877/// chunk is sent at a time. Multiple threads can call `send_chunk()` concurrently;
1878/// the mutex ensures serialized access.
1879///
1880/// # Example
1881///
1882/// ```no_run
1883/// use hyperdb_api::{Catalog, Connection, CreateMode, ChunkSender, InsertChunk, TableDefinition, SqlType, Result};
1884/// use std::sync::mpsc;
1885/// use std::thread;
1886///
1887/// fn main() -> Result<()> {
1888/// let conn = Connection::connect("localhost:7483", "test.hyper", CreateMode::CreateIfNotExists)?;
1889///
1890/// let table_def = TableDefinition::new("products")
1891/// .add_required_column("id", SqlType::int())
1892/// .add_nullable_column("name", SqlType::text());
1893///
1894/// Catalog::new(&conn).create_table(&table_def)?;
1895///
1896/// let sender = ChunkSender::new(&conn, &table_def)?;
1897/// let (tx, rx) = mpsc::channel::<InsertChunk>();
1898///
1899/// // Worker thread
1900/// let table_def_clone = table_def.clone();
1901/// let handle = thread::spawn(move || {
1902/// let mut chunk = InsertChunk::from_table_definition(&table_def_clone);
1903/// for i in 0..1000i32 {
1904/// chunk.add_i32(i).unwrap();
1905/// chunk.add_str(&format!("Product {}", i)).unwrap();
1906/// chunk.end_row().unwrap();
1907/// }
1908/// tx.send(chunk).unwrap();
1909/// });
1910///
1911/// // Receive and send chunks
1912/// while let Ok(chunk) = rx.recv() {
1913/// sender.send_chunk(chunk)?;
1914/// }
1915///
1916/// handle.join().unwrap();
1917/// let rows = sender.finish()?;
1918/// println!("Inserted {} rows", rows);
1919/// Ok(())
1920/// }
1921/// ```
1922#[derive(Debug)]
1923pub struct ChunkSender<'conn> {
1924 connection: &'conn Connection,
1925 table_name: String,
1926 columns: Vec<String>,
1927 writer: Mutex<Option<CopyInWriter<'conn>>>,
1928 header_sent: std::sync::atomic::AtomicBool,
1929 total_rows: AtomicU64,
1930 chunks_sent: AtomicUsize,
1931}
1932
1933impl<'conn> ChunkSender<'conn> {
1934 /// Creates a new chunk sender for the given table.
1935 ///
1936 /// # Errors
1937 ///
1938 /// Returns [`Error::InvalidTableDefinition`] if `table_def` has zero
1939 /// columns. The COPY session itself is opened lazily on the first
1940 /// [`send_chunk`](Self::send_chunk), so transport errors surface there.
1941 pub fn new(connection: &'conn Connection, table_def: &TableDefinition) -> Result<Self> {
1942 if table_def.column_count() == 0 {
1943 return Err(Error::invalid_table_definition(
1944 "Table definition must have at least one column",
1945 ));
1946 }
1947
1948 let columns: Vec<String> = table_def.columns.iter().map(|c| c.name.clone()).collect();
1949 let table_name = table_def.qualified_name();
1950
1951 Ok(ChunkSender {
1952 connection,
1953 table_name,
1954 columns,
1955 writer: Mutex::new(None),
1956 header_sent: std::sync::atomic::AtomicBool::new(false),
1957 total_rows: AtomicU64::new(0),
1958 chunks_sent: AtomicUsize::new(0),
1959 })
1960 }
1961
1962 /// Sends a chunk to Hyper.
1963 ///
1964 /// This method is thread-safe - multiple threads can call it concurrently,
1965 /// but only one chunk will be sent at a time.
1966 ///
1967 /// Each `InsertChunk` includes a `HyperBinary` header (19 bytes). This method
1968 /// automatically handles headers: the first chunk's header is sent, and
1969 /// headers in subsequent chunks are stripped (`HyperBinary` expects only one
1970 /// header per COPY stream).
1971 ///
1972 /// # Errors
1973 ///
1974 /// Returns an error if the chunk is empty or if sending fails.
1975 pub fn send_chunk(&self, mut chunk: InsertChunk) -> Result<()> {
1976 // Capture row count before take() resets it
1977 let row_count = chunk.row_count();
1978
1979 let Some(buffer) = chunk.take() else {
1980 return Ok(());
1981 };
1982
1983 // Acquire the lock for exclusive send access
1984 let mut writer_guard = self
1985 .writer
1986 .lock()
1987 .map_err(|_| Error::internal("ChunkSender mutex poisoned"))?;
1988
1989 // Lazily initialize the COPY connection
1990 if writer_guard.is_none() {
1991 let client = self.connection.tcp_client().ok_or_else(|| {
1992 Error::feature_not_supported(
1993 "ChunkSender requires a TCP connection. gRPC connections do not support COPY operations."
1994 )
1995 })?;
1996 let columns: Vec<&str> = self
1997 .columns
1998 .iter()
1999 .map(std::string::String::as_str)
2000 .collect();
2001 *writer_guard = Some(client.copy_in(&self.table_name, &columns)?);
2002 }
2003
2004 // Handle headers: only first chunk should have header in the COPY stream
2005 // Each InsertChunk includes a 19-byte HyperBinary header, so we need to
2006 // strip headers from all chunks except the first one sent.
2007 let is_first = !self.header_sent.swap(true, Ordering::SeqCst);
2008
2009 let data_to_send = if is_first {
2010 // First chunk: send with header
2011 &buffer[..]
2012 } else {
2013 // Subsequent chunks: strip the 19-byte header if present
2014 if buffer.len() > hyperdb_api_core::protocol::copy::HYPER_BINARY_HEADER_SIZE
2015 && buffer.starts_with(hyperdb_api_core::protocol::copy::HYPER_BINARY_HEADER)
2016 {
2017 &buffer[hyperdb_api_core::protocol::copy::HYPER_BINARY_HEADER_SIZE..]
2018 } else {
2019 &buffer[..]
2020 }
2021 };
2022
2023 // Write the chunk directly to the socket, avoiding a full-chunk memcpy
2024 // into the connection's write buffer. flush_stream ensures the data
2025 // reaches the server before we return.
2026 if let Some(ref mut writer) = *writer_guard {
2027 writer.send_direct(data_to_send)?;
2028 writer.flush_stream()?;
2029 }
2030
2031 // Update counters (lock already released for these atomic ops)
2032 drop(writer_guard);
2033 self.total_rows
2034 .fetch_add(row_count as u64, Ordering::Relaxed);
2035 self.chunks_sent.fetch_add(1, Ordering::Relaxed);
2036
2037 debug!(
2038 target: "hyperdb_api",
2039 chunk = self.chunks_sent.load(Ordering::Relaxed),
2040 rows = row_count,
2041 bytes = data_to_send.len(),
2042 "chunk-sender"
2043 );
2044
2045 Ok(())
2046 }
2047
2048 /// Returns the total number of rows sent so far.
2049 pub fn total_rows(&self) -> u64 {
2050 self.total_rows.load(Ordering::Relaxed)
2051 }
2052
2053 /// Returns the number of chunks sent so far.
2054 pub fn chunks_sent(&self) -> usize {
2055 self.chunks_sent.load(Ordering::Relaxed)
2056 }
2057
2058 /// Finishes the COPY operation and returns the total row count.
2059 ///
2060 /// This method consumes the sender. After calling this, the COPY operation
2061 /// is complete and all data has been committed.
2062 ///
2063 /// # Errors
2064 ///
2065 /// - Returns [`Error::Internal`] with message `"ChunkSender mutex poisoned"`
2066 /// if a sender thread panicked while holding the writer lock.
2067 /// - Returns [`Error::Server`] or [`Error::Io`] if sending the COPY
2068 /// trailer or finishing the COPY operation fails.
2069 pub fn finish(self) -> Result<u64> {
2070 let mut writer_guard = self
2071 .writer
2072 .lock()
2073 .map_err(|_| Error::internal("ChunkSender mutex poisoned"))?;
2074
2075 // If no chunks were sent, return 0
2076 let Some(writer) = writer_guard.take() else {
2077 return Ok(0);
2078 };
2079
2080 // Write and send the COPY trailer
2081 let mut trailer_buf = BytesMut::with_capacity(2);
2082 copy::write_trailer(&mut trailer_buf);
2083
2084 // Need to get mutable access to send trailer
2085 let mut writer = writer;
2086 writer.send(&trailer_buf)?;
2087
2088 // Finish the COPY operation
2089 let rows = writer.finish()?;
2090
2091 info!(
2092 target: "hyperdb_api",
2093 rows,
2094 chunks = self.chunks_sent.load(Ordering::Relaxed),
2095 table = %self.table_name,
2096 "chunk-sender-finish"
2097 );
2098
2099 Ok(rows)
2100 }
2101}
2102
2103#[cfg(test)]
2104mod tests {
2105 use crate::table_definition::TableDefinition;
2106 use hyperdb_api_core::types::SqlType;
2107
2108 use super::InsertChunk;
2109
2110 fn create_test_table_def() -> TableDefinition {
2111 TableDefinition::new("test")
2112 .add_required_column("id", SqlType::int())
2113 .add_nullable_column("name", SqlType::text())
2114 }
2115
2116 #[test]
2117 fn test_inserter_column_validation() {
2118 // We can't fully test without a connection, but we can test validation logic
2119 let table_def = create_test_table_def();
2120 assert_eq!(table_def.column_count(), 2);
2121 }
2122
2123 #[test]
2124 fn test_insert_chunk_encoding() {
2125 let table_def = create_test_table_def();
2126 let mut chunk = InsertChunk::from_table_definition(&table_def);
2127
2128 // Add a row
2129 chunk.add_i32(42).unwrap();
2130 chunk.add_str("hello").unwrap();
2131 chunk.end_row().unwrap();
2132
2133 assert_eq!(chunk.row_count(), 1);
2134 assert!(!chunk.is_empty());
2135 assert!(!chunk.should_flush()); // Not at limit yet
2136
2137 // Add more rows
2138 for i in 0..100 {
2139 chunk.add_i32(i).unwrap();
2140 chunk.add_str(&format!("item {i}")).unwrap();
2141 chunk.end_row().unwrap();
2142 }
2143
2144 assert_eq!(chunk.row_count(), 101);
2145
2146 // Take the buffer
2147 let buffer = chunk.take().unwrap();
2148 assert!(!buffer.is_empty());
2149
2150 // Chunk should now be empty after take
2151 assert!(chunk.take().is_none());
2152 }
2153
2154 #[test]
2155 fn test_insert_chunk_null_handling() {
2156 let table_def = create_test_table_def();
2157 let mut chunk = InsertChunk::from_table_definition(&table_def);
2158
2159 // First column is NOT NULL, should fail
2160 assert!(chunk.add_null().is_err());
2161
2162 // Add the required column first
2163 chunk.add_i32(1).unwrap();
2164
2165 // Second column is nullable, should succeed
2166 chunk.add_null().unwrap();
2167 chunk.end_row().unwrap();
2168
2169 assert_eq!(chunk.row_count(), 1);
2170 }
2171
2172 #[test]
2173 fn test_insert_chunk_column_count_validation() {
2174 let table_def = create_test_table_def();
2175 let mut chunk = InsertChunk::from_table_definition(&table_def);
2176
2177 // Add only one column
2178 chunk.add_i32(1).unwrap();
2179
2180 // end_row should fail
2181 assert!(chunk.end_row().is_err());
2182
2183 // Add second column
2184 chunk.add_str("test").unwrap();
2185
2186 // Now end_row should succeed
2187 chunk.end_row().unwrap();
2188 }
2189
2190 #[test]
2191 fn test_insert_chunk_too_many_columns() {
2192 let table_def = create_test_table_def();
2193 let mut chunk = InsertChunk::from_table_definition(&table_def);
2194
2195 chunk.add_i32(1).unwrap();
2196 chunk.add_str("test").unwrap();
2197
2198 // Third column should fail
2199 assert!(chunk.add_i32(2).is_err());
2200 }
2201
2202 #[test]
2203 fn test_insert_chunk_clear() {
2204 let table_def = create_test_table_def();
2205 let mut chunk = InsertChunk::from_table_definition(&table_def);
2206
2207 chunk.add_i32(1).unwrap();
2208 chunk.add_str("test").unwrap();
2209 chunk.end_row().unwrap();
2210
2211 assert_eq!(chunk.row_count(), 1);
2212
2213 chunk.clear();
2214
2215 assert_eq!(chunk.row_count(), 0);
2216 assert!(chunk.is_empty());
2217 }
2218}