Skip to main content

qail_pg/driver/
copy.rs

1//! COPY protocol methods for PostgreSQL bulk operations.
2//!
3
4use super::{
5    PgConnection, PgError, PgResult, is_ignorable_session_message, parse_affected_rows,
6    unexpected_backend_message,
7};
8use crate::protocol::{AstEncoder, BackendMessage, PgEncoder};
9use bytes::BytesMut;
10use qail_core::ast::{Action, Qail};
11use std::future::Future;
12
13/// Quote a single SQL identifier atom for COPY statements.
14pub(crate) fn quote_copy_column_ident(ident: &str) -> PgResult<String> {
15    if ident.is_empty() {
16        return Err(PgError::Query(
17            "COPY column identifier is empty".to_string(),
18        ));
19    }
20    if ident.contains('\0') {
21        return Err(PgError::Query(
22            "COPY column identifier contains NUL byte".to_string(),
23        ));
24    }
25    Ok(format!("\"{}\"", ident.replace('"', "\"\"")))
26}
27
28/// Quote a COPY table reference, preserving schema-qualified names.
29pub(crate) fn quote_copy_table_ref(table: &str) -> PgResult<String> {
30    if table.is_empty() {
31        return Err(PgError::Query("COPY table identifier is empty".to_string()));
32    }
33    if table.contains('\0') {
34        return Err(PgError::Query(
35            "COPY table identifier contains NUL byte".to_string(),
36        ));
37    }
38
39    table
40        .split('.')
41        .map(|part| {
42            let part = part.trim();
43            if part.is_empty() {
44                return Err(PgError::Query(
45                    "COPY table identifier contains an empty path segment".to_string(),
46                ));
47            }
48            quote_copy_column_ident(part)
49        })
50        .collect::<PgResult<Vec<_>>>()
51        .map(|parts| parts.join("."))
52}
53
54fn parse_copy_text_row(line: &[u8]) -> PgResult<Vec<String>> {
55    let line = if line.ends_with(b"\r") {
56        &line[..line.len().saturating_sub(1)]
57    } else {
58        line
59    };
60
61    let mut fields = Vec::new();
62    let mut start = 0;
63    for (idx, byte) in line.iter().enumerate() {
64        if *byte == b'\t' {
65            fields.push(decode_copy_text_field(&line[start..idx])?);
66            start = idx + 1;
67        }
68    }
69    fields.push(decode_copy_text_field(&line[start..])?);
70    Ok(fields)
71}
72
73fn decode_copy_text_field(field: &[u8]) -> PgResult<String> {
74    if field == b"\\N" {
75        return Err(PgError::Protocol(
76            "COPY text NULL cannot be represented by Vec<String>; use copy_export_stream_raw for nullable exports"
77                .to_string(),
78        ));
79    }
80
81    let mut out = Vec::with_capacity(field.len());
82    let mut idx = 0;
83    while idx < field.len() {
84        if field[idx] != b'\\' {
85            out.push(field[idx]);
86            idx += 1;
87            continue;
88        }
89
90        let Some(&escaped) = field.get(idx + 1) else {
91            return Err(PgError::Protocol(
92                "COPY text field ends with incomplete backslash escape".to_string(),
93            ));
94        };
95
96        match escaped {
97            b'b' => {
98                out.push(0x08);
99                idx += 2;
100            }
101            b'f' => {
102                out.push(0x0c);
103                idx += 2;
104            }
105            b'n' => {
106                out.push(b'\n');
107                idx += 2;
108            }
109            b'r' => {
110                out.push(b'\r');
111                idx += 2;
112            }
113            b't' => {
114                out.push(b'\t');
115                idx += 2;
116            }
117            b'v' => {
118                out.push(0x0b);
119                idx += 2;
120            }
121            b'\\' => {
122                out.push(b'\\');
123                idx += 2;
124            }
125            b'0'..=b'7' => {
126                let mut value = 0u16;
127                let mut next = idx + 1;
128                for _ in 0..3 {
129                    let Some(&digit) = field.get(next) else {
130                        break;
131                    };
132                    if !(b'0'..=b'7').contains(&digit) {
133                        break;
134                    }
135                    value = (value * 8) + u16::from(digit - b'0');
136                    next += 1;
137                }
138                if value > u16::from(u8::MAX) {
139                    return Err(PgError::Protocol(format!(
140                        "COPY text octal escape is out of byte range: \\{:o}",
141                        value
142                    )));
143                }
144                out.push(value as u8);
145                idx = next;
146            }
147            b'x' => {
148                let mut value = 0u8;
149                let mut next = idx + 2;
150                let mut digits = 0;
151                while digits < 2 {
152                    let Some(&digit) = field.get(next) else {
153                        break;
154                    };
155                    let Some(nibble) = hex_nibble(digit) else {
156                        break;
157                    };
158                    value = (value << 4) | nibble;
159                    next += 1;
160                    digits += 1;
161                }
162                if digits == 0 {
163                    return Err(PgError::Protocol(
164                        "COPY text hex escape requires at least one hex digit".to_string(),
165                    ));
166                } else {
167                    out.push(value);
168                    idx = next;
169                }
170            }
171            other => {
172                out.push(other);
173                idx += 2;
174            }
175        }
176    }
177
178    String::from_utf8(out)
179        .map_err(|e| PgError::Protocol(format!("COPY text field is not valid UTF-8: {}", e)))
180}
181
182fn hex_nibble(byte: u8) -> Option<u8> {
183    match byte {
184        b'0'..=b'9' => Some(byte - b'0'),
185        b'a'..=b'f' => Some(byte - b'a' + 10),
186        b'A'..=b'F' => Some(byte - b'A' + 10),
187        _ => None,
188    }
189}
190
191#[inline]
192fn return_with_desync<T>(conn: &mut PgConnection, err: PgError) -> PgResult<T> {
193    if matches!(
194        err,
195        PgError::Protocol(_) | PgError::Connection(_) | PgError::Timeout(_)
196    ) {
197        conn.mark_io_desynced();
198    }
199    Err(err)
200}
201
202fn encode_copy_export_sql(cmd: &Qail) -> PgResult<String> {
203    if cmd.action != Action::Export {
204        return Err(PgError::Query(
205            "copy_export requires Qail::Export action".to_string(),
206        ));
207    }
208
209    let (sql, params) =
210        AstEncoder::encode_cmd_sql(cmd).map_err(|e| PgError::Encode(e.to_string()))?;
211    if !params.is_empty() {
212        return Err(PgError::Encode(format!(
213            "copy_export cannot encode parameterized export with {} bind parameter(s); use an unfiltered export, a prefiltered database view, or a raw COPY statement with trusted SQL",
214            params.len()
215        )));
216    }
217
218    Ok(sql)
219}
220
221/// Maximum bytes a single COPY text row may accumulate across CopyData
222/// frames before a newline. `MAX_MESSAGE_SIZE` caps one frame; this caps the
223/// cross-frame accumulator so a newline-free stream cannot grow memory
224/// without bound.
225const MAX_COPY_TEXT_ROW_BYTES: usize = 16 * 1024 * 1024;
226
227fn drain_copy_text_rows<F>(pending: &mut Vec<u8>, chunk: &[u8], on_row: &mut F) -> PgResult<()>
228where
229    F: FnMut(Vec<String>) -> PgResult<()>,
230{
231    pending.extend_from_slice(chunk);
232    while let Some(pos) = pending.iter().position(|&b| b == b'\n') {
233        let line = pending[..pos].to_vec();
234        pending.drain(..=pos);
235        let row = parse_copy_text_row(&line)?;
236        on_row(row)?;
237    }
238    // Cap only the residual partial row: CopyData boundaries are arbitrary,
239    // so a large frame full of complete newline-terminated rows is legal —
240    // only a single row growing across frames without a newline is not.
241    if pending.len() > MAX_COPY_TEXT_ROW_BYTES {
242        let buffered = pending.len();
243        pending.clear();
244        return Err(PgError::Protocol(format!(
245            "COPY text row exceeds {} bytes without a newline ({} buffered)",
246            MAX_COPY_TEXT_ROW_BYTES, buffered
247        )));
248    }
249    Ok(())
250}
251
252fn flush_pending_copy_text_row(pending: &[u8]) -> PgResult<()> {
253    if pending.is_empty() {
254        return Ok(());
255    }
256    Err(PgError::Protocol(
257        "COPY text stream ended with a truncated row without final newline".to_string(),
258    ))
259}
260
261impl PgConnection {
262    /// **Fast** bulk insert using COPY protocol with zero-allocation encoding.
263    /// Encodes all rows into a single buffer and writes with one syscall.
264    /// ~2x faster than `copy_in_internal` due to batched I/O.
265    pub(crate) async fn copy_in_fast(
266        &mut self,
267        table: &str,
268        columns: &[String],
269        rows: &[Vec<qail_core::ast::Value>],
270    ) -> PgResult<u64> {
271        use crate::protocol::try_encode_copy_batch;
272
273        let cols: Vec<String> = columns
274            .iter()
275            .map(|c| quote_copy_column_ident(c))
276            .collect::<PgResult<_>>()?;
277        let sql = format!(
278            "COPY {} ({}) FROM STDIN",
279            quote_copy_table_ref(table)?,
280            cols.join(", ")
281        );
282
283        // Encode before opening COPY mode so invalid AST data cannot leave the
284        // connection waiting for CopyFail/CopyDone cleanup.
285        let batch_data = try_encode_copy_batch(rows)?;
286
287        // Send COPY command
288        let bytes = PgEncoder::try_encode_query_string(&sql)?;
289        self.send_bytes(&bytes).await?;
290
291        // Wait for CopyInResponse
292        let mut startup_error: Option<PgError> = None;
293        loop {
294            let msg = self.recv().await?;
295            match msg {
296                BackendMessage::CopyInResponse { .. } => {
297                    if let Some(err) = startup_error {
298                        return return_with_desync(self, err);
299                    }
300                    break;
301                }
302                BackendMessage::ReadyForQuery(_) => {
303                    return return_with_desync(
304                        self,
305                        startup_error.unwrap_or_else(|| {
306                            PgError::Protocol(
307                                "COPY IN failed before CopyInResponse (unexpected ReadyForQuery)"
308                                    .to_string(),
309                            )
310                        }),
311                    );
312                }
313                BackendMessage::ErrorResponse(err) => {
314                    if startup_error.is_none() {
315                        startup_error = Some(PgError::QueryServer(err.into()));
316                    }
317                }
318                msg if is_ignorable_session_message(&msg) => {}
319                other => {
320                    return return_with_desync(
321                        self,
322                        unexpected_backend_message("copy-in startup", &other),
323                    );
324                }
325            }
326        }
327
328        // Single write for entire batch!
329        self.send_copy_data(&batch_data).await?;
330
331        // Send CopyDone
332        self.send_copy_done().await?;
333
334        // Wait for CommandComplete
335        let mut affected = 0u64;
336        let mut final_error: Option<PgError> = None;
337        let mut saw_command_complete = false;
338        loop {
339            let msg = self.recv().await?;
340            match msg {
341                BackendMessage::CommandComplete(tag) => {
342                    if saw_command_complete {
343                        return return_with_desync(
344                            self,
345                            PgError::Protocol(
346                                "COPY IN received duplicate CommandComplete".to_string(),
347                            ),
348                        );
349                    }
350                    saw_command_complete = true;
351                    if final_error.is_none() {
352                        match parse_affected_rows(&tag) {
353                            Ok(parsed) => affected = parsed,
354                            Err(err) => return return_with_desync(self, err),
355                        }
356                    }
357                }
358                BackendMessage::ReadyForQuery(_) => {
359                    if let Some(err) = final_error {
360                        return Err(err);
361                    }
362                    if !saw_command_complete {
363                        return return_with_desync(
364                            self,
365                            PgError::Protocol(
366                                "COPY IN completion missing CommandComplete before ReadyForQuery"
367                                    .to_string(),
368                            ),
369                        );
370                    }
371                    return Ok(affected);
372                }
373                BackendMessage::ErrorResponse(err) => {
374                    if final_error.is_none() {
375                        final_error = Some(PgError::QueryServer(err.into()));
376                    }
377                }
378                msg if is_ignorable_session_message(&msg) => {}
379                other => {
380                    return return_with_desync(
381                        self,
382                        unexpected_backend_message("copy-in completion", &other),
383                    );
384                }
385            }
386        }
387    }
388
389    /// **Fastest** bulk insert using COPY protocol with pre-encoded data.
390    /// Accepts raw COPY text format bytes, no encoding needed.
391    /// Use when caller has already encoded rows to COPY format.
392    /// # Format
393    /// Data should be tab-separated rows with newlines:
394    /// `1\thello\t3.14\n2\tworld\t2.71\n`
395    pub async fn copy_in_raw(
396        &mut self,
397        table: &str,
398        columns: &[String],
399        data: &[u8],
400    ) -> PgResult<u64> {
401        let cols: Vec<String> = columns
402            .iter()
403            .map(|c| quote_copy_column_ident(c))
404            .collect::<PgResult<_>>()?;
405        let sql = format!(
406            "COPY {} ({}) FROM STDIN",
407            quote_copy_table_ref(table)?,
408            cols.join(", ")
409        );
410
411        // Send COPY command
412        let bytes = PgEncoder::try_encode_query_string(&sql)?;
413        self.send_bytes(&bytes).await?;
414
415        // Wait for CopyInResponse
416        let mut startup_error: Option<PgError> = None;
417        loop {
418            let msg = self.recv().await?;
419            match msg {
420                BackendMessage::CopyInResponse { .. } => {
421                    if let Some(err) = startup_error {
422                        return return_with_desync(self, err);
423                    }
424                    break;
425                }
426                BackendMessage::ReadyForQuery(_) => {
427                    return return_with_desync(
428                        self,
429                        startup_error.unwrap_or_else(|| {
430                            PgError::Protocol(
431                                "COPY IN failed before CopyInResponse (unexpected ReadyForQuery)"
432                                    .to_string(),
433                            )
434                        }),
435                    );
436                }
437                BackendMessage::ErrorResponse(err) => {
438                    if startup_error.is_none() {
439                        startup_error = Some(PgError::QueryServer(err.into()));
440                    }
441                }
442                msg if is_ignorable_session_message(&msg) => {}
443                other => {
444                    return return_with_desync(
445                        self,
446                        unexpected_backend_message("copy-in raw startup", &other),
447                    );
448                }
449            }
450        }
451
452        // Single write - data is already encoded!
453        self.send_copy_data(data).await?;
454
455        // Send CopyDone
456        self.send_copy_done().await?;
457
458        // Wait for CommandComplete
459        let mut affected = 0u64;
460        let mut final_error: Option<PgError> = None;
461        let mut saw_command_complete = false;
462        loop {
463            let msg = self.recv().await?;
464            match msg {
465                BackendMessage::CommandComplete(tag) => {
466                    if saw_command_complete {
467                        return return_with_desync(
468                            self,
469                            PgError::Protocol(
470                                "COPY IN raw received duplicate CommandComplete".to_string(),
471                            ),
472                        );
473                    }
474                    saw_command_complete = true;
475                    if final_error.is_none() {
476                        match parse_affected_rows(&tag) {
477                            Ok(parsed) => affected = parsed,
478                            Err(err) => return return_with_desync(self, err),
479                        }
480                    }
481                }
482                BackendMessage::ReadyForQuery(_) => {
483                    if let Some(err) = final_error {
484                        return Err(err);
485                    }
486                    if !saw_command_complete {
487                        return return_with_desync(
488                            self,
489                            PgError::Protocol(
490                                "COPY IN raw completion missing CommandComplete before ReadyForQuery"
491                                    .to_string(),
492                            ),
493                        );
494                    }
495                    return Ok(affected);
496                }
497                BackendMessage::ErrorResponse(err) => {
498                    if final_error.is_none() {
499                        final_error = Some(PgError::QueryServer(err.into()));
500                    }
501                }
502                msg if is_ignorable_session_message(&msg) => {}
503                other => {
504                    return return_with_desync(
505                        self,
506                        unexpected_backend_message("copy-in raw completion", &other),
507                    );
508                }
509            }
510        }
511    }
512
513    /// Send CopyData message (raw bytes).
514    pub(crate) async fn send_copy_data(&mut self, data: &[u8]) -> PgResult<()> {
515        let total_len = data
516            .len()
517            .checked_add(4)
518            .ok_or_else(|| PgError::Protocol("CopyData frame length overflow".to_string()))?;
519        let len = i32::try_from(total_len)
520            .map_err(|_| PgError::Protocol("CopyData frame exceeds i32::MAX".to_string()))?;
521
522        // CopyData: 'd' + length + data
523        let mut buf = BytesMut::with_capacity(1 + 4 + data.len());
524        buf.extend_from_slice(b"d");
525        buf.extend_from_slice(&len.to_be_bytes());
526        buf.extend_from_slice(data);
527        self.send_bytes(&buf).await?;
528        Ok(())
529    }
530
531    async fn send_copy_done(&mut self) -> PgResult<()> {
532        // CopyDone: 'c' + length (4)
533        self.send_bytes(&[b'c', 0, 0, 0, 4]).await?;
534        Ok(())
535    }
536
537    async fn start_copy_out(&mut self, sql: &str, context: &str) -> PgResult<()> {
538        let bytes = PgEncoder::try_encode_query_string(sql)?;
539        self.send_bytes(&bytes).await?;
540
541        let mut startup_error: Option<PgError> = None;
542        loop {
543            let msg = self.recv().await?;
544            match msg {
545                BackendMessage::CopyOutResponse { .. } => {
546                    if let Some(err) = startup_error {
547                        return return_with_desync(self, err);
548                    }
549                    return Ok(());
550                }
551                BackendMessage::ReadyForQuery(_) => {
552                    return return_with_desync(
553                        self,
554                        startup_error.unwrap_or_else(|| {
555                            PgError::Protocol(format!(
556                                "{} failed before CopyOutResponse (unexpected ReadyForQuery)",
557                                context
558                            ))
559                        }),
560                    );
561                }
562                BackendMessage::ErrorResponse(err) => {
563                    if startup_error.is_none() {
564                        startup_error = Some(PgError::QueryServer(err.into()));
565                    }
566                }
567                msg if is_ignorable_session_message(&msg) => {}
568                other => {
569                    return return_with_desync(self, unexpected_backend_message(context, &other));
570                }
571            }
572        }
573    }
574
575    async fn stream_copy_out_chunks<F, Fut>(
576        &mut self,
577        context: &str,
578        mut on_chunk: F,
579    ) -> PgResult<()>
580    where
581        F: FnMut(Vec<u8>) -> Fut,
582        Fut: Future<Output = PgResult<()>>,
583    {
584        let mut stream_error: Option<PgError> = None;
585        let mut callback_error: Option<PgError> = None;
586        let mut saw_copy_done = false;
587        let mut saw_command_complete = false;
588
589        loop {
590            let msg = self.recv().await?;
591            match msg {
592                BackendMessage::CopyData(chunk) => {
593                    if saw_copy_done {
594                        return return_with_desync(
595                            self,
596                            PgError::Protocol(format!(
597                                "{} received CopyData after CopyDone",
598                                context
599                            )),
600                        );
601                    }
602                    if stream_error.is_none()
603                        && callback_error.is_none()
604                        && let Err(e) = on_chunk(chunk).await
605                    {
606                        callback_error = Some(e);
607                    }
608                }
609                BackendMessage::CopyDone => {
610                    if saw_copy_done {
611                        return return_with_desync(
612                            self,
613                            PgError::Protocol(format!("{} received duplicate CopyDone", context)),
614                        );
615                    }
616                    saw_copy_done = true;
617                }
618                BackendMessage::CommandComplete(_) => {
619                    if !saw_copy_done {
620                        return return_with_desync(
621                            self,
622                            PgError::Protocol(format!(
623                                "{} received CommandComplete before CopyDone",
624                                context
625                            )),
626                        );
627                    }
628                    if saw_command_complete {
629                        return return_with_desync(
630                            self,
631                            PgError::Protocol(format!(
632                                "{} received duplicate CommandComplete",
633                                context
634                            )),
635                        );
636                    }
637                    saw_command_complete = true;
638                }
639                BackendMessage::ReadyForQuery(_) => {
640                    if let Some(err) = stream_error {
641                        return Err(err);
642                    }
643                    if let Some(err) = callback_error {
644                        return Err(err);
645                    }
646                    if !saw_copy_done {
647                        return return_with_desync(
648                            self,
649                            PgError::Protocol(format!(
650                                "{} missing CopyDone before ReadyForQuery",
651                                context
652                            )),
653                        );
654                    }
655                    if !saw_command_complete {
656                        return return_with_desync(
657                            self,
658                            PgError::Protocol(format!(
659                                "{} missing CommandComplete before ReadyForQuery",
660                                context
661                            )),
662                        );
663                    }
664                    return Ok(());
665                }
666                BackendMessage::ErrorResponse(err) => {
667                    if stream_error.is_none() {
668                        stream_error = Some(PgError::QueryServer(err.into()));
669                    }
670                }
671                msg if is_ignorable_session_message(&msg) => {}
672                other => {
673                    return return_with_desync(self, unexpected_backend_message(context, &other));
674                }
675            }
676        }
677    }
678
679    /// Export data using COPY TO STDOUT (AST-native).
680    /// Takes a `Qail::Export` and returns rows as `Vec<Vec<String>>`.
681    ///
682    /// A single text row is capped at [`MAX_COPY_TEXT_ROW_BYTES`]; exports
683    /// with wider rows (e.g. large `bytea` columns) should stream raw bytes
684    /// via [`Self::copy_export_stream_raw`] instead. The returned `Vec`
685    /// buffers the whole result — use the `_stream` variants for large
686    /// exports.
687    /// # Example
688    /// ```ignore
689    /// let cmd = Qail::export("users")
690    ///     .columns(["id", "name"]);
691    /// let rows = conn.copy_export(&cmd).await?;
692    /// ```
693    pub async fn copy_export(&mut self, cmd: &Qail) -> PgResult<Vec<Vec<String>>> {
694        let mut rows = Vec::new();
695        self.copy_export_stream_rows(cmd, |row| {
696            rows.push(row);
697            Ok(())
698        })
699        .await?;
700        Ok(rows)
701    }
702
703    /// Stream COPY TO STDOUT chunks using an AST-native `Qail::Export` command.
704    ///
705    /// Chunks are forwarded as they arrive from PostgreSQL, so memory usage
706    /// stays bounded by network frame size and callback processing.
707    pub async fn copy_export_stream_raw<F, Fut>(&mut self, cmd: &Qail, on_chunk: F) -> PgResult<()>
708    where
709        F: FnMut(Vec<u8>) -> Fut,
710        Fut: Future<Output = PgResult<()>>,
711    {
712        let sql = encode_copy_export_sql(cmd)?;
713
714        self.copy_out_raw_stream(&sql, on_chunk).await
715    }
716
717    /// Stream COPY TO STDOUT rows using an AST-native `Qail::Export` command.
718    ///
719    /// Parses PostgreSQL COPY text lines into `Vec<String>` rows and invokes
720    /// `on_row` for each row without buffering the full result.
721    pub async fn copy_export_stream_rows<F>(&mut self, cmd: &Qail, mut on_row: F) -> PgResult<()>
722    where
723        F: FnMut(Vec<String>) -> PgResult<()>,
724    {
725        let mut pending = Vec::new();
726        self.copy_export_stream_raw(cmd, |chunk| {
727            let res = drain_copy_text_rows(&mut pending, &chunk, &mut on_row);
728            std::future::ready(res)
729        })
730        .await?;
731        flush_pending_copy_text_row(&pending)
732    }
733
734    /// Export data using raw COPY TO STDOUT, returning raw bytes.
735    /// Format: tab-separated values, newline-terminated rows.
736    /// Suitable for direct re-import via copy_in_raw.
737    ///
738    /// # Safety
739    /// `pub(crate)` — not exposed externally because callers pass raw SQL.
740    /// External code should use `copy_export()` with the AST encoder instead.
741    pub(crate) async fn copy_out_raw(&mut self, sql: &str) -> PgResult<Vec<u8>> {
742        let mut data = Vec::new();
743        self.copy_out_raw_stream(sql, |chunk| {
744            data.extend_from_slice(&chunk);
745            std::future::ready(Ok(()))
746        })
747        .await?;
748        Ok(data)
749    }
750
751    /// Stream raw COPY TO STDOUT bytes with bounded memory usage.
752    ///
753    /// # Safety
754    /// `pub(crate)` — callers pass raw SQL.
755    pub(crate) async fn copy_out_raw_stream<F, Fut>(
756        &mut self,
757        sql: &str,
758        on_chunk: F,
759    ) -> PgResult<()>
760    where
761        F: FnMut(Vec<u8>) -> Fut,
762        Fut: Future<Output = PgResult<()>>,
763    {
764        self.start_copy_out(sql, "copy-out raw startup").await?;
765        self.stream_copy_out_chunks("copy-out raw stream", on_chunk)
766            .await
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::{
773        drain_copy_text_rows, encode_copy_export_sql, flush_pending_copy_text_row,
774        parse_copy_text_row, quote_copy_column_ident, quote_copy_table_ref, return_with_desync,
775    };
776    use crate::driver::{PgConnection, PgError, PgResult};
777    use qail_core::ast::{Operator, Qail};
778
779    #[cfg(unix)]
780    fn test_conn() -> PgConnection {
781        use crate::driver::connection::StatementCache;
782        use crate::driver::stream::PgStream;
783        use bytes::BytesMut;
784        use std::collections::{HashMap, VecDeque};
785        use std::num::NonZeroUsize;
786        use tokio::net::UnixStream;
787
788        let (unix_stream, _peer) = UnixStream::pair().expect("unix stream pair");
789        PgConnection {
790            stream: PgStream::Unix(unix_stream),
791            buffer: BytesMut::with_capacity(1024),
792            write_buf: BytesMut::with_capacity(1024),
793            sql_buf: BytesMut::with_capacity(256),
794            params_buf: Vec::new(),
795            prepared_statements: HashMap::new(),
796            stmt_cache: StatementCache::new(NonZeroUsize::new(2).expect("non-zero")),
797            column_info_cache: HashMap::new(),
798            process_id: 0,
799            cancel_key_bytes: Vec::new(),
800            requested_protocol_minor: PgConnection::default_protocol_minor(),
801            negotiated_protocol_minor: PgConnection::default_protocol_minor(),
802            notifications: VecDeque::new(),
803            replication_stream_active: false,
804            replication_mode_enabled: false,
805            last_replication_wal_end: None,
806            io_desynced: false,
807            pending_statement_closes: Vec::new(),
808            draining_statement_closes: false,
809        }
810    }
811
812    #[test]
813    fn parse_copy_text_row_splits_tabs() {
814        let row = parse_copy_text_row(b"a\tb\tc").unwrap();
815        assert_eq!(row, vec!["a", "b", "c"]);
816    }
817
818    #[test]
819    fn parse_copy_text_row_trims_cr() {
820        let row = parse_copy_text_row(b"a\tb\r").unwrap();
821        assert_eq!(row, vec!["a", "b"]);
822    }
823
824    #[test]
825    fn parse_copy_text_row_unescapes_copy_text_values() {
826        let row = parse_copy_text_row(b"a\\tb\tline\\nnext\tc\\\\d").unwrap();
827        assert_eq!(row, vec!["a\tb", "line\nnext", "c\\d"]);
828    }
829
830    #[test]
831    fn parse_copy_text_row_rejects_copy_null_marker() {
832        let err = parse_copy_text_row(b"a\t\\N\tb").expect_err("COPY NULL must not be lossy");
833        assert!(
834            err.to_string()
835                .contains("COPY text NULL cannot be represented"),
836            "{err}"
837        );
838    }
839
840    #[test]
841    fn parse_copy_text_row_rejects_invalid_utf8() {
842        let err = parse_copy_text_row(&[0xff]).expect_err("invalid UTF-8 must fail");
843        assert!(
844            err.to_string()
845                .contains("COPY text field is not valid UTF-8")
846        );
847    }
848
849    #[test]
850    fn parse_copy_text_row_rejects_incomplete_escape() {
851        let err = parse_copy_text_row(b"bad\\").expect_err("trailing backslash must fail");
852        assert!(err.to_string().contains("incomplete backslash escape"));
853    }
854
855    #[test]
856    fn parse_copy_text_row_rejects_out_of_range_octal_escape() {
857        let err = parse_copy_text_row(br"\400").expect_err("octal escape > 377 must fail");
858        assert!(err.to_string().contains("out of byte range"));
859    }
860
861    #[test]
862    fn parse_copy_text_row_rejects_hex_escape_without_digits() {
863        let err = parse_copy_text_row(br"\xG").expect_err("hex escape without digits must fail");
864        assert!(err.to_string().contains("hex escape requires"));
865    }
866
867    #[test]
868    fn copy_table_quoting_preserves_schema_qualification() {
869        assert_eq!(
870            quote_copy_table_ref("tenant_a.users").unwrap(),
871            "\"tenant_a\".\"users\""
872        );
873    }
874
875    #[test]
876    fn copy_identifier_quoting_rejects_nul_bytes() {
877        assert!(quote_copy_table_ref("tenant\0.users").is_err());
878        assert!(quote_copy_column_ident("name\0").is_err());
879    }
880
881    #[test]
882    fn copy_export_rejects_parameterized_ast_before_streaming() {
883        let cmd = Qail::export("users").filter("active", Operator::Eq, true);
884        let err = encode_copy_export_sql(&cmd).expect_err("bind params cannot be ignored");
885
886        assert!(matches!(err, PgError::Encode(msg) if msg.contains("parameterized export")));
887    }
888
889    #[cfg(unix)]
890    #[tokio::test]
891    async fn copy_return_with_desync_marks_protocol_error() {
892        let mut conn = test_conn();
893
894        let err = return_with_desync::<()>(
895            &mut conn,
896            PgError::Protocol("copy protocol ordering broke".to_string()),
897        )
898        .expect_err("protocol error must be returned");
899
900        assert!(err.to_string().contains("copy protocol ordering broke"));
901        assert!(conn.is_io_desynced());
902    }
903
904    #[test]
905    fn drain_copy_text_rows_handles_chunk_boundaries() {
906        let mut pending = Vec::new();
907        let mut rows: Vec<Vec<String>> = Vec::new();
908
909        drain_copy_text_rows(&mut pending, b"a\tb\nc", &mut |row: Vec<String>| {
910            rows.push(row);
911            Ok(())
912        })
913        .unwrap();
914        assert_eq!(rows, vec![vec!["a".to_string(), "b".to_string()]]);
915        assert_eq!(pending, b"c");
916
917        drain_copy_text_rows(&mut pending, b"\td\n", &mut |row: Vec<String>| {
918            rows.push(row);
919            Ok(())
920        })
921        .unwrap();
922        assert_eq!(
923            rows,
924            vec![
925                vec!["a".to_string(), "b".to_string()],
926                vec!["c".to_string(), "d".to_string()]
927            ]
928        );
929        assert!(pending.is_empty());
930    }
931
932    #[test]
933    fn flush_pending_copy_text_row_rejects_final_partial_line() {
934        let pending = b"x\ty".to_vec();
935        let err = flush_pending_copy_text_row(&pending)
936            .expect_err("partial final COPY row must fail closed");
937        assert!(matches!(err, PgError::Protocol(msg) if msg.contains("truncated row")));
938        assert_eq!(pending, b"x\ty");
939    }
940
941    #[test]
942    fn callback_error_bubbles_from_row_drainer() {
943        let mut pending = Vec::new();
944        let mut on_row =
945            |_row: Vec<String>| -> PgResult<()> { Err(PgError::Query("fail".to_string())) };
946
947        let err = drain_copy_text_rows(&mut pending, b"a\tb\n", &mut on_row).unwrap_err();
948        assert!(matches!(err, PgError::Query(msg) if msg == "fail"));
949    }
950
951    #[test]
952    fn drain_copy_text_rows_rejects_newline_free_row_over_cap() {
953        let mut pending = Vec::new();
954        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
955            panic!("no row should complete without a newline")
956        };
957        let chunk = vec![b'x'; super::MAX_COPY_TEXT_ROW_BYTES + 1];
958
959        let err = drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap_err();
960
961        assert!(matches!(err, PgError::Protocol(msg) if msg.contains("exceeds")));
962        assert!(
963            pending.is_empty(),
964            "overflowed accumulator must be released"
965        );
966    }
967
968    #[test]
969    fn drain_copy_text_rows_caps_accumulation_across_chunks() {
970        let mut pending = Vec::new();
971        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
972            panic!("no row should complete without a newline")
973        };
974        let chunk = vec![b'x'; super::MAX_COPY_TEXT_ROW_BYTES / 2 + 1];
975
976        drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap();
977        let err = drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap_err();
978
979        assert!(matches!(err, PgError::Protocol(msg) if msg.contains("exceeds")));
980        assert!(pending.is_empty());
981    }
982
983    #[test]
984    fn drain_copy_text_rows_accepts_large_frame_of_complete_rows() {
985        let mut pending = Vec::new();
986        let mut rows = 0usize;
987        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
988            rows += 1;
989            Ok(())
990        };
991        // One frame larger than the row cap, made entirely of small
992        // newline-terminated rows: legal per COPY frame-boundary rules.
993        let row_count = super::MAX_COPY_TEXT_ROW_BYTES / 4 + 1;
994        let chunk = b"a\tb\n".repeat(row_count);
995        assert!(chunk.len() > super::MAX_COPY_TEXT_ROW_BYTES);
996
997        drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap();
998
999        assert_eq!(rows, row_count);
1000        assert!(pending.is_empty());
1001    }
1002}