Skip to main content

radixdb_executor/mutation/
copy.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! COPY FROM Statement Execution
16//!
17//! Bulk imports data from CSV or JSON files, bypassing per-row SQL parsing
18//! for significantly faster loading compared to individual INSERT statements.
19
20use radixdb_core::{time_compat::Instant, CompactArc, SmartString};
21use radixdb_core::{DataType, Error, Result, Row, Schema, Value};
22use radixdb_sql::ast::{CopyFormat, CopyStatement};
23use radixdb_storage::traits::{Engine, QueryResult, Table};
24use rustc_hash::{FxHashMap, FxHashSet};
25
26use super::dml_support::evaluate_default_expr;
27use crate::context::{
28    invalidate_in_subquery_cache_for_table, invalidate_scalar_subquery_cache_for_table,
29    invalidate_semi_join_cache_for_table, ExecutionContext,
30};
31use crate::mutation::host::MutationHost;
32use crate::mutation::validation::{
33    compile_table_check_constraints, prepare_insert_row_constraints,
34};
35use crate::result::ExecResult;
36
37// One uncommitted COPY row is represented simultaneously by row values,
38// transaction-local MVCC/version maps, constraint/index state and commit/WAL
39// staging. The budget is deliberately conservative; it is a safety envelope,
40// not a malloc profiler.
41const COPY_TRANSACTION_MEMORY_AMPLIFICATION: usize = 8;
42// Keep COPY parsing streaming while amortizing the cold-segment snapshot and
43// seal-fence work owned by SegmentedTable::insert_batch. The rows are moved
44// into transaction-local MVCC storage at every flush, so this is only a small
45// bounded staging window, not a second COPY-sized owner.
46const COPY_INSERT_BATCH_ROWS: usize = 4096;
47
48#[inline]
49fn flush_copy_insert_batch(table: &mut Box<dyn Table>, rows: &mut Vec<Row>) -> Result<()> {
50    if rows.is_empty() {
51        return Ok(());
52    }
53    let next = Vec::with_capacity(COPY_INSERT_BATCH_ROWS);
54    table.insert_batch(std::mem::replace(rows, next))
55}
56
57fn account_copy_transaction_row(
58    used_bytes: &mut usize,
59    limit_bytes: usize,
60    row: &Row,
61    row_number: i64,
62) -> Result<()> {
63    let row_bytes = radixdb_storage::mvcc::version_store::estimate_row_hot_bytes(row)
64        .saturating_mul(COPY_TRANSACTION_MEMORY_AMPLIFICATION);
65    let attempted_bytes = used_bytes.saturating_add(row_bytes);
66    if attempted_bytes > limit_bytes {
67        return Err(Error::CopyTransactionMemoryLimit {
68            row: row_number,
69            limit_bytes,
70            attempted_bytes,
71        });
72    }
73    *used_bytes = attempted_bytes;
74    Ok(())
75}
76
77/// Parse a CSV field directly into a Value for the target type.
78/// Avoids the intermediate Value::text() + coerce_to_type() allocation path.
79#[inline]
80fn parse_field(field: &str, target_type: DataType, col_name: &str) -> Result<Value> {
81    match target_type {
82        DataType::Integer => field.parse::<i64>().map(Value::Integer).map_err(|_| {
83            Error::Type(format!(
84                "cannot convert value '{}' to INTEGER for column '{}'",
85                field, col_name
86            ))
87        }),
88        DataType::Float => field.parse::<f64>().map(Value::Float).map_err(|_| {
89            Error::Type(format!(
90                "cannot convert value '{}' to FLOAT for column '{}'",
91                field, col_name
92            ))
93        }),
94        DataType::Boolean => {
95            if field.eq_ignore_ascii_case("true")
96                || field.eq_ignore_ascii_case("t")
97                || field.eq_ignore_ascii_case("yes")
98                || field.eq_ignore_ascii_case("y")
99                || field == "1"
100            {
101                Ok(Value::Boolean(true))
102            } else if field.eq_ignore_ascii_case("false")
103                || field.eq_ignore_ascii_case("f")
104                || field.eq_ignore_ascii_case("no")
105                || field.eq_ignore_ascii_case("n")
106                || field == "0"
107            {
108                Ok(Value::Boolean(false))
109            } else {
110                Err(Error::Type(format!(
111                    "cannot convert value '{}' to BOOLEAN for column '{}'",
112                    field, col_name
113                )))
114            }
115        }
116        DataType::Timestamp => radixdb_core::parse_timestamp(field)
117            .map(Value::Timestamp)
118            .map_err(|_| {
119                Error::Type(format!(
120                    "cannot convert value '{}' to TIMESTAMP for column '{}'",
121                    field, col_name
122                ))
123            }),
124        DataType::Decimal => radixdb_core::value::parse_decimal_str(field)
125            .and_then(|(unscaled, precision, scale)| {
126                Value::try_decimal(unscaled, precision, scale).ok()
127            })
128            .ok_or_else(|| {
129                Error::Type(format!(
130                    "cannot convert value '{}' to DECIMAL for column '{}'",
131                    field, col_name
132                ))
133            }),
134        DataType::Date => radixdb_core::value::parse_date_days_since_unix_epoch(field)
135            .map(Value::date)
136            .ok_or_else(|| {
137                Error::Type(format!(
138                    "cannot convert value '{}' to DATE for column '{}'",
139                    field, col_name
140                ))
141            }),
142        DataType::Bytes => Ok(Value::bytes(field.as_bytes().to_vec())),
143        DataType::Text => {
144            // SmartString::new takes &str: inlines <=15 bytes (0 allocs),
145            // heap-allocates only for longer strings (1 alloc for Arc)
146            Ok(Value::Text(SmartString::new(field)))
147        }
148        DataType::Json => Value::try_json(field).map_err(|_| {
149            Error::Type(format!(
150                "cannot convert value '{}' to JSON for column '{}'",
151                field, col_name
152            ))
153        }),
154        DataType::Uuid => radixdb_core::value::parse_uuid_str(field)
155            .map(Value::uuid)
156            .ok_or_else(|| {
157                Error::Type(format!(
158                    "cannot convert value '{}' to UUID for column '{}'",
159                    field, col_name
160                ))
161            }),
162        _ => {
163            // Fallback: go through Value::text + coerce for uncommon types (Vector, etc.)
164            let text_val = Value::text(field);
165            let coerced = text_val.coerce_to_type(target_type);
166            if !text_val.is_null() && coerced.is_null() {
167                return Err(Error::Type(format!(
168                    "cannot convert value '{}' to {:?} for column '{}'",
169                    field, target_type, col_name
170                )));
171            }
172            Ok(coerced)
173        }
174    }
175}
176
177#[doc(hidden)]
178pub trait CopyExecutorExt: MutationHost {
179    /// Execute a COPY FROM statement
180    fn execute_copy(
181        &self,
182        stmt: &CopyStatement,
183        _ctx: &ExecutionContext,
184    ) -> Result<Box<dyn QueryResult>> {
185        let copy_started = Instant::now();
186        let table_name = &stmt.table_name.value_lower;
187
188        // COPY is not allowed inside explicit transactions (like PRAGMA CHECKPOINT)
189        {
190            let active_tx = self.mutation_active_transaction().lock().unwrap();
191            if active_tx.is_some() {
192                return Err(Error::InvalidArgument(
193                    "COPY FROM cannot be used inside an explicit transaction".to_string(),
194                ));
195            }
196        }
197
198        // One storage transaction owns the target schema snapshot, every row,
199        // and the publication point. A terminal parse/constraint error leaves
200        // no durable prefix for the caller to discover or deduplicate.
201        let mut transaction = self.mutation_engine().begin_transaction()?;
202        let mut table = transaction.get_table(table_name)?;
203        let schema = table.schema().clone();
204        let schema_column_count = schema.columns.len();
205        let copy_max_transaction_bytes = self
206            .mutation_engine()
207            .config()
208            .persistence
209            .copy_max_transaction_bytes;
210        let mut copy_transaction_bytes = 0usize;
211
212        let all_column_types: Vec<DataType> = schema.columns.iter().map(|c| c.data_type).collect();
213        let all_vector_dims: Vec<u16> =
214            schema.columns.iter().map(|c| c.vector_dimensions).collect();
215        let default_exprs: Vec<Option<String>> = schema
216            .columns
217            .iter()
218            .map(|c| c.default_expr.clone())
219            .collect();
220        let column_indices: Vec<usize> = if stmt.columns.is_empty() {
221            (0..schema_column_count).collect()
222        } else {
223            let mut seen = FxHashSet::default();
224            for identifier in &stmt.columns {
225                if !seen.insert(identifier.value_lower.clone()) {
226                    return Err(Error::InvalidArgument(format!(
227                        "duplicate COPY target column '{}'",
228                        identifier.value
229                    )));
230                }
231            }
232            let col_map = schema.column_index_map();
233            stmt.columns
234                .iter()
235                .map(|id| {
236                    col_map
237                        .get(id.value_lower.as_str())
238                        .copied()
239                        .ok_or_else(|| Error::ColumnNotFound(id.value.to_string()))
240                })
241                .collect::<Result<Vec<_>>>()?
242        };
243
244        // Pre-compute FK info
245        let fk_schema: Option<CompactArc<Schema>> = if !schema.foreign_keys.is_empty() {
246            Some(CompactArc::new(schema.clone()))
247        } else {
248            None
249        };
250
251        let parse_started = Instant::now();
252        let outcome = match stmt.format {
253            CopyFormat::Csv => self.copy_from_csv(
254                stmt,
255                &mut table,
256                &schema,
257                &column_indices,
258                &all_column_types,
259                &all_vector_dims,
260                &default_exprs,
261                &fk_schema,
262                schema_column_count,
263                &mut copy_transaction_bytes,
264                copy_max_transaction_bytes,
265            ),
266            CopyFormat::Json => self.copy_from_json(
267                stmt,
268                &mut table,
269                &schema,
270                &column_indices,
271                &all_column_types,
272                &all_vector_dims,
273                &default_exprs,
274                &fk_schema,
275                schema_column_count,
276                &mut copy_transaction_bytes,
277                copy_max_transaction_bytes,
278            ),
279        };
280        let parse_elapsed = parse_started.elapsed();
281
282        drop(table);
283        let rows_affected = match outcome {
284            Ok(rows_affected) => {
285                let commit_started = Instant::now();
286                let commit_result = transaction.commit();
287                let commit_elapsed = commit_started.elapsed();
288                if let Err(error) = commit_result {
289                    radixdb_storage::instrumentation::record_copy(
290                        rows_affected.max(0) as u64,
291                        parse_elapsed,
292                        commit_elapsed,
293                        copy_started.elapsed(),
294                    );
295                    return Err(error);
296                }
297                if rows_affected > 0 {
298                    self.invalidate_copy_caches(table_name);
299                }
300                radixdb_storage::instrumentation::record_copy(
301                    rows_affected.max(0) as u64,
302                    parse_elapsed,
303                    commit_elapsed,
304                    copy_started.elapsed(),
305                );
306                rows_affected
307            }
308            Err(error) => {
309                transaction.rollback()?;
310                radixdb_storage::instrumentation::record_copy(
311                    0,
312                    parse_elapsed,
313                    std::time::Duration::ZERO,
314                    copy_started.elapsed(),
315                );
316                return Err(error);
317            }
318        };
319
320        Ok(Box::new(ExecResult::with_rows_affected(rows_affected)))
321    }
322
323    /// Clear table-scoped caches at the one successful COPY publication point.
324    fn invalidate_copy_caches(&self, table_name: &str) {
325        self.mutation_invalidate_semantic_cache(table_name);
326        invalidate_semi_join_cache_for_table(table_name);
327        invalidate_scalar_subquery_cache_for_table(table_name);
328        invalidate_in_subquery_cache_for_table(table_name);
329    }
330
331    /// Import rows from a CSV file
332    #[allow(clippy::too_many_arguments)]
333    fn copy_from_csv(
334        &self,
335        stmt: &CopyStatement,
336        table: &mut Box<dyn Table>,
337        schema: &Schema,
338        column_indices: &[usize],
339        all_column_types: &[DataType],
340        all_vector_dims: &[u16],
341        default_exprs: &[Option<String>],
342        fk_schema: &Option<CompactArc<Schema>>,
343        schema_column_count: usize,
344        copy_transaction_bytes: &mut usize,
345        copy_max_transaction_bytes: usize,
346    ) -> Result<i64> {
347        let file = std::fs::File::open(&stmt.file_path).map_err(|e| {
348            Error::InvalidArgument(format!("cannot open file '{}': {}", stmt.file_path, e))
349        })?;
350
351        let mut reader = csv::ReaderBuilder::new()
352            .has_headers(stmt.header)
353            .delimiter(stmt.delimiter)
354            .from_reader(std::io::BufReader::new(file));
355
356        // If header is present and columns are not specified, try to map header names to columns
357        let field_to_col: Option<Vec<usize>> = if stmt.header && stmt.columns.is_empty() {
358            let headers = reader
359                .headers()
360                .map_err(|e| Error::InvalidArgument(format!("cannot read CSV headers: {}", e)))?;
361            let col_map = {
362                let m = schema.column_index_map();
363                m.clone()
364            };
365            let mut mapping = Vec::with_capacity(headers.len());
366            let mut seen_headers = FxHashSet::default();
367            for h in headers.iter() {
368                let lower = h.to_lowercase();
369                if !seen_headers.insert(lower.clone()) {
370                    return Err(Error::InvalidArgument(format!(
371                        "duplicate CSV header '{}'",
372                        h
373                    )));
374                }
375                if let Some(&idx) = col_map.get(lower.as_str()) {
376                    mapping.push(idx);
377                } else {
378                    return Err(Error::ColumnNotFound(h.to_string()));
379                }
380            }
381            Some(mapping)
382        } else {
383            None
384        };
385
386        let null_str = stmt.null_string.as_deref().unwrap_or("");
387        let mut rows_affected = 0i64;
388
389        let compiled_table_checks = compile_table_check_constraints(schema)?;
390        let mut table_check_vm = crate::expression::ExprVM::new();
391        let mut insert_batch = Vec::with_capacity(COPY_INSERT_BATCH_ROWS);
392
393        for result in reader.records() {
394            let record = result.map_err(|e| {
395                Error::InvalidArgument(format!(
396                    "CSV parse error at row {}: {}",
397                    rows_affected + 1,
398                    e
399                ))
400            })?;
401
402            let effective_indices = field_to_col.as_deref().unwrap_or(column_indices);
403
404            if record.len() != effective_indices.len() {
405                return Err(Error::InvalidArgument(format!(
406                    "CSV row {} has {} fields but expected {}",
407                    rows_affected + 1,
408                    record.len(),
409                    effective_indices.len()
410                )));
411            }
412
413            // Defaults are expressions and may be volatile; evaluate them for
414            // every resulting row, never once per COPY statement.
415            let mut row_values =
416                build_default_row(default_exprs, all_column_types, schema_column_count)?;
417
418            // Parse CSV fields directly into target types (no intermediate Value::text allocation)
419            for (i, field) in record.iter().enumerate() {
420                let col_idx = effective_indices[i];
421
422                if field == null_str {
423                    row_values[col_idx] = Value::null_unknown();
424                    continue;
425                }
426
427                let target_type = all_column_types[col_idx];
428                let col_name = schema.columns[col_idx].name.as_str();
429                let value = parse_field(field, target_type, col_name)?;
430                validate_vector_dims(&value, target_type, all_vector_dims[col_idx])?;
431                row_values[col_idx] = value;
432            }
433
434            let mut row = Row::from_values(row_values);
435            prepare_insert_row_constraints(
436                table.as_mut(),
437                schema,
438                &compiled_table_checks,
439                &mut row,
440                &mut table_check_vm,
441            )?;
442
443            // FK parent validation
444            if let Some(ref fks) = fk_schema {
445                crate::mutation::foreign_key::check_parent_exists(
446                    self.mutation_engine(),
447                    table.txn_id(),
448                    fks,
449                    &row,
450                )?;
451            }
452
453            account_copy_transaction_row(
454                copy_transaction_bytes,
455                copy_max_transaction_bytes,
456                &row,
457                rows_affected + 1,
458            )?;
459            insert_batch.push(row);
460            // A later row in a self-referential COPY may depend on a parent
461            // inserted earlier by the same statement. Publish FK-bearing rows
462            // into the transaction-local table immediately; the whole COPY is
463            // still committed or rolled back as one storage transaction.
464            if fk_schema.is_some() || insert_batch.len() == COPY_INSERT_BATCH_ROWS {
465                flush_copy_insert_batch(table, &mut insert_batch)?;
466            }
467            rows_affected += 1;
468        }
469
470        flush_copy_insert_batch(table, &mut insert_batch)?;
471
472        Ok(rows_affected)
473    }
474
475    /// Import rows from a JSON file (JSON Lines or JSON array)
476    #[allow(clippy::too_many_arguments)]
477    fn copy_from_json(
478        &self,
479        stmt: &CopyStatement,
480        table: &mut Box<dyn Table>,
481        schema: &Schema,
482        column_indices: &[usize],
483        all_column_types: &[DataType],
484        all_vector_dims: &[u16],
485        default_exprs: &[Option<String>],
486        fk_schema: &Option<CompactArc<Schema>>,
487        schema_column_count: usize,
488        copy_transaction_bytes: &mut usize,
489        copy_max_transaction_bytes: usize,
490    ) -> Result<i64> {
491        let null_str = stmt.null_string.as_deref();
492        let use_columns = !stmt.columns.is_empty();
493
494        let compiled_table_checks = compile_table_check_constraints(schema)?;
495        let mut table_check_vm = crate::expression::ExprVM::new();
496        let mut insert_batch = Vec::with_capacity(COPY_INSERT_BATCH_ROWS);
497
498        // Pre-build lowercase column name map for case-insensitive JSON key matching
499        let col_name_lower_map: FxHashMap<String, usize> = if use_columns {
500            stmt.columns
501                .iter()
502                .enumerate()
503                .map(|(i, column)| (column.value_lower.to_string(), column_indices[i]))
504                .collect()
505        } else {
506            schema
507                .columns
508                .iter()
509                .enumerate()
510                .map(|(idx, c)| (c.name.to_lowercase(), idx))
511                .collect()
512        };
513
514        // Stream JSON objects one at a time with O(object) memory.
515        // For JSON arrays, we strip `[`, `]`, and `,` between objects so
516        // StreamDeserializer sees a sequence of top-level values.
517        // For JSON Lines, objects are already top-level.
518        let file = std::fs::File::open(&stmt.file_path).map_err(|e| {
519            Error::InvalidArgument(format!("cannot open file '{}': {}", stmt.file_path, e))
520        })?;
521        let reader = JsonArrayStripper::new(std::io::BufReader::new(file));
522        let stream = serde_json::Deserializer::from_reader(reader).into_iter::<serde_json::Value>();
523
524        let mut rows_affected = 0i64;
525        for (idx, result) in stream.enumerate() {
526            let item = result.map_err(|e| {
527                Error::InvalidArgument(format!("JSON parse error at object {}: {}", idx + 1, e))
528            })?;
529
530            let obj = item.as_object().ok_or_else(|| {
531                Error::InvalidArgument(format!("JSON item {} is not an object", idx + 1))
532            })?;
533            let row = self.prepare_json_row(
534                obj,
535                table,
536                schema,
537                default_exprs,
538                schema_column_count,
539                &col_name_lower_map,
540                use_columns,
541                all_column_types,
542                all_vector_dims,
543                null_str,
544                &compiled_table_checks,
545                &mut table_check_vm,
546                fk_schema,
547                copy_transaction_bytes,
548                copy_max_transaction_bytes,
549                rows_affected + 1,
550            )?;
551            insert_batch.push(row);
552            if fk_schema.is_some() || insert_batch.len() == COPY_INSERT_BATCH_ROWS {
553                flush_copy_insert_batch(table, &mut insert_batch)?;
554            }
555            rows_affected += 1;
556        }
557
558        flush_copy_insert_batch(table, &mut insert_batch)?;
559
560        Ok(rows_affected)
561    }
562
563    /// Prepare one JSON object for the next bounded insert batch.
564    #[allow(clippy::too_many_arguments)]
565    fn prepare_json_row(
566        &self,
567        obj: &serde_json::Map<String, serde_json::Value>,
568        table: &mut Box<dyn Table>,
569        schema: &Schema,
570        default_exprs: &[Option<String>],
571        schema_column_count: usize,
572        col_name_lower_map: &FxHashMap<String, usize>,
573        use_columns: bool,
574        all_column_types: &[DataType],
575        all_vector_dims: &[u16],
576        null_str: Option<&str>,
577        compiled_table_checks: &[(String, crate::expression::SharedProgram)],
578        table_check_vm: &mut crate::expression::ExprVM,
579        fk_schema: &Option<CompactArc<Schema>>,
580        copy_transaction_bytes: &mut usize,
581        copy_max_transaction_bytes: usize,
582        row_number: i64,
583    ) -> Result<Row> {
584        let mut normalized_obj = FxHashMap::default();
585        for (key, value) in obj {
586            let normalized = key.to_lowercase();
587            if normalized_obj.insert(normalized, value).is_some() {
588                return Err(Error::InvalidArgument(format!(
589                    "duplicate JSON key '{}' after case normalization",
590                    key
591                )));
592            }
593        }
594
595        // Validate the complete supplied key domain before defaults,
596        // constraints, auto-increment, or row mutation can run.
597        for lower_key in normalized_obj.keys() {
598            if !col_name_lower_map.contains_key(lower_key) {
599                return Err(Error::ColumnNotFound(lower_key.clone()));
600            }
601        }
602
603        let mut row_values =
604            build_default_row(default_exprs, all_column_types, schema_column_count)?;
605
606        if use_columns {
607            // Only import specified columns
608            for (lower_name, col_idx) in col_name_lower_map {
609                let target_type = all_column_types[*col_idx];
610                if let Some(v) = normalized_obj.get(lower_name) {
611                    let value = json_value_to_radixdb(v, target_type, lower_name, null_str)?;
612                    validate_vector_dims(&value, target_type, all_vector_dims[*col_idx])?;
613                    row_values[*col_idx] = value;
614                }
615                // Missing key: keep default/null
616            }
617        } else {
618            // Import all columns by matching JSON keys case-insensitively
619            for (lower_key, json_val) in normalized_obj {
620                let col_idx = col_name_lower_map[&lower_key];
621                let target_type = all_column_types[col_idx];
622                let value = json_value_to_radixdb(json_val, target_type, &lower_key, null_str)?;
623                validate_vector_dims(&value, target_type, all_vector_dims[col_idx])?;
624                row_values[col_idx] = value;
625            }
626        }
627
628        let mut row = Row::from_values(row_values);
629        prepare_insert_row_constraints(
630            &mut **table,
631            schema,
632            compiled_table_checks,
633            &mut row,
634            table_check_vm,
635        )?;
636
637        if let Some(ref fks) = fk_schema {
638            crate::mutation::foreign_key::check_parent_exists(
639                self.mutation_engine(),
640                table.txn_id(),
641                fks,
642                &row,
643            )?;
644        }
645
646        account_copy_transaction_row(
647            copy_transaction_bytes,
648            copy_max_transaction_bytes,
649            &row,
650            row_number,
651        )?;
652        Ok(row)
653    }
654}
655
656impl<T: MutationHost + ?Sized> CopyExecutorExt for T {}
657
658/// Build the default row template from schema default expressions.
659fn build_default_row(
660    default_exprs: &[Option<String>],
661    all_column_types: &[DataType],
662    schema_column_count: usize,
663) -> Result<Vec<Value>> {
664    let mut row = Vec::with_capacity(schema_column_count);
665    for i in 0..schema_column_count {
666        if let Some(ref default_expr) = default_exprs[i] {
667            let default_type = all_column_types[i];
668            row.push(evaluate_default_expr(default_expr, default_type)?);
669        } else {
670            row.push(Value::null_unknown());
671        }
672    }
673    Ok(row)
674}
675
676/// Validate vector dimensions if the column is a VECTOR type.
677#[inline]
678fn validate_vector_dims(value: &Value, target_type: DataType, expected_dims: u16) -> Result<()> {
679    if target_type == DataType::Vector && expected_dims > 0 {
680        if let Value::Extension(data) = value {
681            if data.first() == Some(&(DataType::Vector as u8)) {
682                let got_dim = u16::try_from((data.len() - 1) / 4).unwrap_or(u16::MAX);
683                if got_dim != expected_dims {
684                    return Err(Error::VectorDimensionMismatch {
685                        expected: expected_dims,
686                        got: got_dim,
687                    });
688                }
689            }
690        }
691    }
692    Ok(())
693}
694
695/// Convert a serde_json::Value to a radixdb Value with type coercion.
696/// Returns an error if a non-null value silently becomes null during coercion.
697fn json_value_to_radixdb(
698    v: &serde_json::Value,
699    target_type: DataType,
700    col_name: &str,
701    null_str: Option<&str>,
702) -> Result<Value> {
703    let val = match v {
704        serde_json::Value::Null => return Ok(Value::null_unknown()),
705        serde_json::Value::Bool(b) => Value::Boolean(*b),
706        serde_json::Value::Number(n) => {
707            // Keep the canonical decimal spelling until the destination type
708            // performs its checked conversion. Routing large integers through
709            // f64 silently rounds values above i64::MAX and 2^53.
710            Value::text(n.to_string())
711        }
712        serde_json::Value::String(s) => {
713            if let Some(ns) = null_str {
714                if s == ns {
715                    return Ok(Value::null_unknown());
716                }
717            }
718            Value::text(s)
719        }
720        serde_json::Value::Object(_) | serde_json::Value::Array(_) => Value::text(v.to_string()),
721    };
722
723    val.try_coerce_to_type(target_type).map_err(|error| {
724        Error::Type(format!(
725            "cannot convert value '{}' to {:?} for column '{}': {}",
726            val, target_type, col_name, error
727        ))
728    })
729}
730
731/// A Read adapter that transforms a JSON array `[{...},{...}]` into a stream
732/// of top-level objects `{...} {...}` by replacing `[`, `]`, and inter-element
733/// commas with whitespace. For JSON Lines input (no leading `[`), bytes pass
734/// through unchanged. This lets `serde_json::StreamDeserializer` yield one
735/// object at a time with O(object) memory for both formats.
736struct JsonArrayStripper<R> {
737    inner: R,
738    is_array: bool,
739    /// Nesting depth inside JSON values. 0 = between top-level values.
740    depth: u32,
741    /// True while inside a JSON string literal (skip structural chars).
742    in_string: bool,
743    /// Previous byte was `\` inside a string (skip escaped quotes).
744    escape: bool,
745    /// Saved first non-whitespace byte for non-array input (needs replay).
746    pending: Option<u8>,
747    /// An outer array must alternate object, comma, object and end with `]`.
748    expect_value: bool,
749    seen_value: bool,
750    array_closed: bool,
751    terminal_checked: bool,
752}
753
754impl<R: std::io::Read> JsonArrayStripper<R> {
755    fn new(mut inner: R) -> Self {
756        // Peek at first non-whitespace byte to detect array format
757        let mut first = [0u8; 1];
758        let (is_array, pending) = loop {
759            match inner.read(&mut first) {
760                Ok(1) if first[0].is_ascii_whitespace() => continue,
761                Ok(1) if first[0] == b'[' => break (true, None), // `[` consumed, don't replay
762                Ok(1) => break (false, Some(first[0])),          // save for replay
763                _ => break (false, None),                        // empty file
764            }
765        };
766
767        JsonArrayStripper {
768            inner,
769            is_array,
770            depth: 0,
771            in_string: false,
772            escape: false,
773            pending,
774            expect_value: true,
775            seen_value: false,
776            array_closed: false,
777            terminal_checked: false,
778        }
779    }
780}
781
782impl<R: std::io::Read> std::io::Read for JsonArrayStripper<R> {
783    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
784        // Replay the saved first byte if present
785        if let Some(b) = self.pending.take() {
786            buf[0] = b;
787            if buf.len() == 1 {
788                return Ok(1);
789            }
790            let n = self.inner.read(&mut buf[1..])?;
791            return Ok(1 + n);
792        }
793
794        let n = self.inner.read(buf)?;
795        if self.is_array {
796            if n == 0 {
797                if !self.terminal_checked {
798                    self.terminal_checked = true;
799                    if !self.array_closed || self.in_string || self.depth != 0 {
800                        return Err(std::io::Error::new(
801                            std::io::ErrorKind::InvalidData,
802                            "unterminated JSON array",
803                        ));
804                    }
805                }
806            } else {
807                self.strip_array_syntax(buf, n)?;
808            }
809        }
810        Ok(n)
811    }
812}
813
814impl<R> JsonArrayStripper<R> {
815    /// Replace outer-array `[`, `]`, and inter-element `,` with spaces.
816    /// Tracks nesting depth and string literals to avoid touching structural
817    /// characters inside JSON values.
818    fn strip_array_syntax(&mut self, buf: &mut [u8], len: usize) -> std::io::Result<()> {
819        for b in &mut buf[..len] {
820            if self.array_closed {
821                if !b.is_ascii_whitespace() {
822                    return Err(std::io::Error::new(
823                        std::io::ErrorKind::InvalidData,
824                        "trailing data after JSON array",
825                    ));
826                }
827                continue;
828            }
829
830            if self.in_string {
831                if self.escape {
832                    self.escape = false;
833                } else if *b == b'\\' {
834                    self.escape = true;
835                } else if *b == b'"' {
836                    self.in_string = false;
837                }
838                continue;
839            }
840
841            match *b {
842                b'"' => {
843                    if self.depth == 0 {
844                        return Err(std::io::Error::new(
845                            std::io::ErrorKind::InvalidData,
846                            "JSON array COPY items must be objects",
847                        ));
848                    }
849                    self.in_string = true;
850                }
851                b'{' if self.depth == 0 => {
852                    if !self.expect_value {
853                        return Err(std::io::Error::new(
854                            std::io::ErrorKind::InvalidData,
855                            "missing comma between JSON array items",
856                        ));
857                    }
858                    self.expect_value = false;
859                    self.seen_value = true;
860                    self.depth = 1;
861                }
862                b'{' | b'[' => {
863                    self.depth += 1;
864                }
865                b'}' | b']' => {
866                    if self.depth > 0 {
867                        self.depth -= 1;
868                    } else if *b == b']' && (!self.expect_value || !self.seen_value) {
869                        // Closing `]` of the outer array
870                        *b = b' ';
871                        self.array_closed = true;
872                    } else {
873                        return Err(std::io::Error::new(
874                            std::io::ErrorKind::InvalidData,
875                            "invalid JSON array closing delimiter",
876                        ));
877                    }
878                }
879                b',' if self.depth == 0 => {
880                    if self.expect_value || !self.seen_value {
881                        return Err(std::io::Error::new(
882                            std::io::ErrorKind::InvalidData,
883                            "unexpected comma in JSON array",
884                        ));
885                    }
886                    // Comma between top-level array elements
887                    *b = b' ';
888                    self.expect_value = true;
889                }
890                _ if self.depth == 0 && !b.is_ascii_whitespace() => {
891                    return Err(std::io::Error::new(
892                        std::io::ErrorKind::InvalidData,
893                        "JSON array COPY items must be objects",
894                    ));
895                }
896                _ => {}
897            }
898        }
899        Ok(())
900    }
901}