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    // Rows are read in place from a moving offset and the consumed prefix is
233    // shifted out once. Draining after every row moved the whole remaining
234    // buffer each time: quadratic in a frame of many small rows.
235    let mut consumed = 0usize;
236    let result = loop {
237        let Some(len) = pending[consumed..].iter().position(|&b| b == b'\n') else {
238            break Ok(());
239        };
240        let row = parse_copy_text_row(&pending[consumed..consumed + len]);
241        consumed += len + 1;
242        if let Err(e) = row.and_then(&mut *on_row) {
243            break Err(e);
244        }
245    };
246    // A failed row is consumed with the rows before it; the rest stay pending.
247    pending.drain(..consumed);
248    result?;
249    // Cap only the residual partial row: CopyData boundaries are arbitrary,
250    // so a large frame full of complete newline-terminated rows is legal —
251    // only a single row growing across frames without a newline is not.
252    if pending.len() > MAX_COPY_TEXT_ROW_BYTES {
253        let buffered = pending.len();
254        pending.clear();
255        return Err(PgError::Protocol(format!(
256            "COPY text row exceeds {} bytes without a newline ({} buffered)",
257            MAX_COPY_TEXT_ROW_BYTES, buffered
258        )));
259    }
260    Ok(())
261}
262
263fn flush_pending_copy_text_row(pending: &[u8]) -> PgResult<()> {
264    if pending.is_empty() {
265        return Ok(());
266    }
267    Err(PgError::Protocol(
268        "COPY text stream ended with a truncated row without final newline".to_string(),
269    ))
270}
271
272impl PgConnection {
273    /// **Fast** bulk insert using COPY protocol with zero-allocation encoding.
274    /// Encodes all rows into a single buffer and writes with one syscall.
275    /// ~2x faster than `copy_in_internal` due to batched I/O.
276    pub(crate) async fn copy_in_fast(
277        &mut self,
278        table: &str,
279        columns: &[String],
280        rows: &[Vec<qail_core::ast::Value>],
281    ) -> PgResult<u64> {
282        use crate::protocol::try_encode_copy_batch;
283
284        let cols: Vec<String> = columns
285            .iter()
286            .map(|c| quote_copy_column_ident(c))
287            .collect::<PgResult<_>>()?;
288        let sql = format!(
289            "COPY {} ({}) FROM STDIN",
290            quote_copy_table_ref(table)?,
291            cols.join(", ")
292        );
293
294        // Encode before opening COPY mode so invalid AST data cannot leave the
295        // connection waiting for CopyFail/CopyDone cleanup.
296        let batch_data = try_encode_copy_batch(rows)?;
297
298        // Send COPY command
299        let bytes = PgEncoder::try_encode_query_string(&sql)?;
300        self.send_bytes(&bytes).await?;
301
302        // Wait for CopyInResponse
303        let mut startup_error: Option<PgError> = None;
304        loop {
305            let msg = self.recv().await?;
306            match msg {
307                BackendMessage::CopyInResponse { .. } => {
308                    if let Some(err) = startup_error {
309                        return return_with_desync(self, err);
310                    }
311                    break;
312                }
313                BackendMessage::ReadyForQuery(_) => {
314                    return return_with_desync(
315                        self,
316                        startup_error.unwrap_or_else(|| {
317                            PgError::Protocol(
318                                "COPY IN failed before CopyInResponse (unexpected ReadyForQuery)"
319                                    .to_string(),
320                            )
321                        }),
322                    );
323                }
324                BackendMessage::ErrorResponse(err) => {
325                    if startup_error.is_none() {
326                        startup_error = Some(PgError::QueryServer(err.into()));
327                    }
328                }
329                msg if is_ignorable_session_message(&msg) => {}
330                other => {
331                    return return_with_desync(
332                        self,
333                        unexpected_backend_message("copy-in startup", &other),
334                    );
335                }
336            }
337        }
338
339        // Single write for entire batch!
340        self.send_copy_data(&batch_data).await?;
341
342        // Send CopyDone
343        self.send_copy_done().await?;
344
345        // Wait for CommandComplete
346        let mut affected = 0u64;
347        let mut final_error: Option<PgError> = None;
348        let mut saw_command_complete = false;
349        loop {
350            let msg = self.recv().await?;
351            match msg {
352                BackendMessage::CommandComplete(tag) => {
353                    if saw_command_complete {
354                        return return_with_desync(
355                            self,
356                            PgError::Protocol(
357                                "COPY IN received duplicate CommandComplete".to_string(),
358                            ),
359                        );
360                    }
361                    saw_command_complete = true;
362                    if final_error.is_none() {
363                        match parse_affected_rows(&tag) {
364                            Ok(parsed) => affected = parsed,
365                            Err(err) => return return_with_desync(self, err),
366                        }
367                    }
368                }
369                BackendMessage::ReadyForQuery(_) => {
370                    if let Some(err) = final_error {
371                        return Err(err);
372                    }
373                    if !saw_command_complete {
374                        return return_with_desync(
375                            self,
376                            PgError::Protocol(
377                                "COPY IN completion missing CommandComplete before ReadyForQuery"
378                                    .to_string(),
379                            ),
380                        );
381                    }
382                    return Ok(affected);
383                }
384                BackendMessage::ErrorResponse(err) => {
385                    if final_error.is_none() {
386                        final_error = Some(PgError::QueryServer(err.into()));
387                    }
388                }
389                msg if is_ignorable_session_message(&msg) => {}
390                other => {
391                    return return_with_desync(
392                        self,
393                        unexpected_backend_message("copy-in completion", &other),
394                    );
395                }
396            }
397        }
398    }
399
400    /// **Fastest** bulk insert using COPY protocol with pre-encoded data.
401    /// Accepts raw COPY text format bytes, no encoding needed.
402    /// Use when caller has already encoded rows to COPY format.
403    /// # Format
404    /// Data should be tab-separated rows with newlines:
405    /// `1\thello\t3.14\n2\tworld\t2.71\n`
406    pub async fn copy_in_raw(
407        &mut self,
408        table: &str,
409        columns: &[String],
410        data: &[u8],
411    ) -> PgResult<u64> {
412        let cols: Vec<String> = columns
413            .iter()
414            .map(|c| quote_copy_column_ident(c))
415            .collect::<PgResult<_>>()?;
416        let sql = format!(
417            "COPY {} ({}) FROM STDIN",
418            quote_copy_table_ref(table)?,
419            cols.join(", ")
420        );
421
422        // Send COPY command
423        let bytes = PgEncoder::try_encode_query_string(&sql)?;
424        self.send_bytes(&bytes).await?;
425
426        // Wait for CopyInResponse
427        let mut startup_error: Option<PgError> = None;
428        loop {
429            let msg = self.recv().await?;
430            match msg {
431                BackendMessage::CopyInResponse { .. } => {
432                    if let Some(err) = startup_error {
433                        return return_with_desync(self, err);
434                    }
435                    break;
436                }
437                BackendMessage::ReadyForQuery(_) => {
438                    return return_with_desync(
439                        self,
440                        startup_error.unwrap_or_else(|| {
441                            PgError::Protocol(
442                                "COPY IN failed before CopyInResponse (unexpected ReadyForQuery)"
443                                    .to_string(),
444                            )
445                        }),
446                    );
447                }
448                BackendMessage::ErrorResponse(err) => {
449                    if startup_error.is_none() {
450                        startup_error = Some(PgError::QueryServer(err.into()));
451                    }
452                }
453                msg if is_ignorable_session_message(&msg) => {}
454                other => {
455                    return return_with_desync(
456                        self,
457                        unexpected_backend_message("copy-in raw startup", &other),
458                    );
459                }
460            }
461        }
462
463        // Single write - data is already encoded!
464        self.send_copy_data(data).await?;
465
466        // Send CopyDone
467        self.send_copy_done().await?;
468
469        // Wait for CommandComplete
470        let mut affected = 0u64;
471        let mut final_error: Option<PgError> = None;
472        let mut saw_command_complete = false;
473        loop {
474            let msg = self.recv().await?;
475            match msg {
476                BackendMessage::CommandComplete(tag) => {
477                    if saw_command_complete {
478                        return return_with_desync(
479                            self,
480                            PgError::Protocol(
481                                "COPY IN raw received duplicate CommandComplete".to_string(),
482                            ),
483                        );
484                    }
485                    saw_command_complete = true;
486                    if final_error.is_none() {
487                        match parse_affected_rows(&tag) {
488                            Ok(parsed) => affected = parsed,
489                            Err(err) => return return_with_desync(self, err),
490                        }
491                    }
492                }
493                BackendMessage::ReadyForQuery(_) => {
494                    if let Some(err) = final_error {
495                        return Err(err);
496                    }
497                    if !saw_command_complete {
498                        return return_with_desync(
499                            self,
500                            PgError::Protocol(
501                                "COPY IN raw completion missing CommandComplete before ReadyForQuery"
502                                    .to_string(),
503                            ),
504                        );
505                    }
506                    return Ok(affected);
507                }
508                BackendMessage::ErrorResponse(err) => {
509                    if final_error.is_none() {
510                        final_error = Some(PgError::QueryServer(err.into()));
511                    }
512                }
513                msg if is_ignorable_session_message(&msg) => {}
514                other => {
515                    return return_with_desync(
516                        self,
517                        unexpected_backend_message("copy-in raw completion", &other),
518                    );
519                }
520            }
521        }
522    }
523
524    /// Send CopyData message (raw bytes).
525    pub(crate) async fn send_copy_data(&mut self, data: &[u8]) -> PgResult<()> {
526        let total_len = data
527            .len()
528            .checked_add(4)
529            .ok_or_else(|| PgError::Protocol("CopyData frame length overflow".to_string()))?;
530        let len = i32::try_from(total_len)
531            .map_err(|_| PgError::Protocol("CopyData frame exceeds i32::MAX".to_string()))?;
532
533        // CopyData: 'd' + length + data
534        let mut buf = BytesMut::with_capacity(1 + 4 + data.len());
535        buf.extend_from_slice(b"d");
536        buf.extend_from_slice(&len.to_be_bytes());
537        buf.extend_from_slice(data);
538        self.send_bytes(&buf).await?;
539        Ok(())
540    }
541
542    async fn send_copy_done(&mut self) -> PgResult<()> {
543        // CopyDone: 'c' + length (4)
544        self.send_bytes(&[b'c', 0, 0, 0, 4]).await?;
545        Ok(())
546    }
547
548    async fn start_copy_out(&mut self, sql: &str, context: &str) -> PgResult<()> {
549        let bytes = PgEncoder::try_encode_query_string(sql)?;
550        self.send_bytes(&bytes).await?;
551
552        let mut startup_error: Option<PgError> = None;
553        loop {
554            let msg = self.recv().await?;
555            match msg {
556                BackendMessage::CopyOutResponse { .. } => {
557                    if let Some(err) = startup_error {
558                        return return_with_desync(self, err);
559                    }
560                    return Ok(());
561                }
562                BackendMessage::ReadyForQuery(_) => {
563                    return return_with_desync(
564                        self,
565                        startup_error.unwrap_or_else(|| {
566                            PgError::Protocol(format!(
567                                "{} failed before CopyOutResponse (unexpected ReadyForQuery)",
568                                context
569                            ))
570                        }),
571                    );
572                }
573                BackendMessage::ErrorResponse(err) => {
574                    if startup_error.is_none() {
575                        startup_error = Some(PgError::QueryServer(err.into()));
576                    }
577                }
578                msg if is_ignorable_session_message(&msg) => {}
579                other => {
580                    return return_with_desync(self, unexpected_backend_message(context, &other));
581                }
582            }
583        }
584    }
585
586    async fn stream_copy_out_chunks<F, Fut>(
587        &mut self,
588        context: &str,
589        mut on_chunk: F,
590    ) -> PgResult<()>
591    where
592        F: FnMut(Vec<u8>) -> Fut,
593        Fut: Future<Output = PgResult<()>>,
594    {
595        let mut stream_error: Option<PgError> = None;
596        let mut callback_error: Option<PgError> = None;
597        let mut saw_copy_done = false;
598        let mut saw_command_complete = false;
599
600        loop {
601            let msg = self.recv().await?;
602            match msg {
603                BackendMessage::CopyData(chunk) => {
604                    if saw_copy_done {
605                        return return_with_desync(
606                            self,
607                            PgError::Protocol(format!(
608                                "{} received CopyData after CopyDone",
609                                context
610                            )),
611                        );
612                    }
613                    if stream_error.is_none()
614                        && callback_error.is_none()
615                        && let Err(e) = on_chunk(chunk).await
616                    {
617                        callback_error = Some(e);
618                    }
619                }
620                BackendMessage::CopyDone => {
621                    if saw_copy_done {
622                        return return_with_desync(
623                            self,
624                            PgError::Protocol(format!("{} received duplicate CopyDone", context)),
625                        );
626                    }
627                    saw_copy_done = true;
628                }
629                BackendMessage::CommandComplete(_) => {
630                    if !saw_copy_done {
631                        return return_with_desync(
632                            self,
633                            PgError::Protocol(format!(
634                                "{} received CommandComplete before CopyDone",
635                                context
636                            )),
637                        );
638                    }
639                    if saw_command_complete {
640                        return return_with_desync(
641                            self,
642                            PgError::Protocol(format!(
643                                "{} received duplicate CommandComplete",
644                                context
645                            )),
646                        );
647                    }
648                    saw_command_complete = true;
649                }
650                BackendMessage::ReadyForQuery(_) => {
651                    if let Some(err) = stream_error {
652                        return Err(err);
653                    }
654                    if let Some(err) = callback_error {
655                        return Err(err);
656                    }
657                    if !saw_copy_done {
658                        return return_with_desync(
659                            self,
660                            PgError::Protocol(format!(
661                                "{} missing CopyDone before ReadyForQuery",
662                                context
663                            )),
664                        );
665                    }
666                    if !saw_command_complete {
667                        return return_with_desync(
668                            self,
669                            PgError::Protocol(format!(
670                                "{} missing CommandComplete before ReadyForQuery",
671                                context
672                            )),
673                        );
674                    }
675                    return Ok(());
676                }
677                BackendMessage::ErrorResponse(err) => {
678                    if stream_error.is_none() {
679                        stream_error = Some(PgError::QueryServer(err.into()));
680                    }
681                }
682                msg if is_ignorable_session_message(&msg) => {}
683                other => {
684                    return return_with_desync(self, unexpected_backend_message(context, &other));
685                }
686            }
687        }
688    }
689
690    /// Export data using COPY TO STDOUT (AST-native).
691    /// Takes a `Qail::Export` and returns rows as `Vec<Vec<String>>`.
692    ///
693    /// A single text row is capped at [`MAX_COPY_TEXT_ROW_BYTES`]; exports
694    /// with wider rows (e.g. large `bytea` columns) should stream raw bytes
695    /// via [`Self::copy_export_stream_raw`] instead. The returned `Vec`
696    /// buffers the whole result — use the `_stream` variants for large
697    /// exports.
698    /// # Example
699    /// ```ignore
700    /// let cmd = Qail::export("users")
701    ///     .columns(["id", "name"]);
702    /// let rows = conn.copy_export(&cmd).await?;
703    /// ```
704    pub async fn copy_export(&mut self, cmd: &Qail) -> PgResult<Vec<Vec<String>>> {
705        let mut rows = Vec::new();
706        self.copy_export_stream_rows(cmd, |row| {
707            rows.push(row);
708            Ok(())
709        })
710        .await?;
711        Ok(rows)
712    }
713
714    /// Stream COPY TO STDOUT chunks using an AST-native `Qail::Export` command.
715    ///
716    /// Chunks are forwarded as they arrive from PostgreSQL, so memory usage
717    /// stays bounded by network frame size and callback processing.
718    pub async fn copy_export_stream_raw<F, Fut>(&mut self, cmd: &Qail, on_chunk: F) -> PgResult<()>
719    where
720        F: FnMut(Vec<u8>) -> Fut,
721        Fut: Future<Output = PgResult<()>>,
722    {
723        let sql = encode_copy_export_sql(cmd)?;
724
725        self.copy_out_raw_stream(&sql, on_chunk).await
726    }
727
728    /// Stream COPY TO STDOUT rows using an AST-native `Qail::Export` command.
729    ///
730    /// Parses PostgreSQL COPY text lines into `Vec<String>` rows and invokes
731    /// `on_row` for each row without buffering the full result.
732    pub async fn copy_export_stream_rows<F>(&mut self, cmd: &Qail, mut on_row: F) -> PgResult<()>
733    where
734        F: FnMut(Vec<String>) -> PgResult<()>,
735    {
736        let mut pending = Vec::new();
737        self.copy_export_stream_raw(cmd, |chunk| {
738            let res = drain_copy_text_rows(&mut pending, &chunk, &mut on_row);
739            std::future::ready(res)
740        })
741        .await?;
742        flush_pending_copy_text_row(&pending)
743    }
744
745    /// Export data using raw COPY TO STDOUT, returning raw bytes.
746    /// Format: tab-separated values, newline-terminated rows.
747    /// Suitable for direct re-import via copy_in_raw.
748    ///
749    /// # Safety
750    /// `pub(crate)` — not exposed externally because callers pass raw SQL.
751    /// External code should use `copy_export()` with the AST encoder instead.
752    pub(crate) async fn copy_out_raw(&mut self, sql: &str) -> PgResult<Vec<u8>> {
753        let mut data = Vec::new();
754        self.copy_out_raw_stream(sql, |chunk| {
755            data.extend_from_slice(&chunk);
756            std::future::ready(Ok(()))
757        })
758        .await?;
759        Ok(data)
760    }
761
762    /// Stream raw COPY TO STDOUT bytes with bounded memory usage.
763    ///
764    /// # Safety
765    /// `pub(crate)` — callers pass raw SQL.
766    pub(crate) async fn copy_out_raw_stream<F, Fut>(
767        &mut self,
768        sql: &str,
769        on_chunk: F,
770    ) -> PgResult<()>
771    where
772        F: FnMut(Vec<u8>) -> Fut,
773        Fut: Future<Output = PgResult<()>>,
774    {
775        self.start_copy_out(sql, "copy-out raw startup").await?;
776        self.stream_copy_out_chunks("copy-out raw stream", on_chunk)
777            .await
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::{
784        drain_copy_text_rows, encode_copy_export_sql, flush_pending_copy_text_row,
785        parse_copy_text_row, quote_copy_column_ident, quote_copy_table_ref, return_with_desync,
786    };
787    use crate::driver::{PgConnection, PgError, PgResult};
788    use qail_core::ast::{Operator, Qail};
789
790    #[cfg(unix)]
791    fn test_conn() -> PgConnection {
792        use crate::driver::connection::StatementCache;
793        use crate::driver::stream::PgStream;
794        use bytes::BytesMut;
795        use std::collections::{HashMap, VecDeque};
796        use std::num::NonZeroUsize;
797        use tokio::net::UnixStream;
798
799        let (unix_stream, _peer) = UnixStream::pair().expect("unix stream pair");
800        PgConnection {
801            stream: PgStream::Unix(unix_stream),
802            buffer: BytesMut::with_capacity(1024),
803            write_buf: BytesMut::with_capacity(1024),
804            sql_buf: BytesMut::with_capacity(256),
805            params_buf: Vec::new(),
806            prepared_statements: HashMap::new(),
807            stmt_cache: StatementCache::new(NonZeroUsize::new(2).expect("non-zero")),
808            column_info_cache: HashMap::new(),
809            process_id: 0,
810            cancel_key_bytes: Vec::new(),
811            requested_protocol_minor: PgConnection::default_protocol_minor(),
812            negotiated_protocol_minor: PgConnection::default_protocol_minor(),
813            notifications: VecDeque::new(),
814            replication_stream_active: false,
815            replication_mode_enabled: false,
816            last_replication_wal_end: None,
817            io_desynced: false,
818            pending_statement_closes: Vec::new(),
819            draining_statement_closes: false,
820        }
821    }
822
823    #[test]
824    fn parse_copy_text_row_splits_tabs() {
825        let row = parse_copy_text_row(b"a\tb\tc").unwrap();
826        assert_eq!(row, vec!["a", "b", "c"]);
827    }
828
829    #[test]
830    fn parse_copy_text_row_trims_cr() {
831        let row = parse_copy_text_row(b"a\tb\r").unwrap();
832        assert_eq!(row, vec!["a", "b"]);
833    }
834
835    #[test]
836    fn parse_copy_text_row_unescapes_copy_text_values() {
837        let row = parse_copy_text_row(b"a\\tb\tline\\nnext\tc\\\\d").unwrap();
838        assert_eq!(row, vec!["a\tb", "line\nnext", "c\\d"]);
839    }
840
841    #[test]
842    fn parse_copy_text_row_rejects_copy_null_marker() {
843        let err = parse_copy_text_row(b"a\t\\N\tb").expect_err("COPY NULL must not be lossy");
844        assert!(
845            err.to_string()
846                .contains("COPY text NULL cannot be represented"),
847            "{err}"
848        );
849    }
850
851    #[test]
852    fn parse_copy_text_row_rejects_invalid_utf8() {
853        let err = parse_copy_text_row(&[0xff]).expect_err("invalid UTF-8 must fail");
854        assert!(
855            err.to_string()
856                .contains("COPY text field is not valid UTF-8")
857        );
858    }
859
860    #[test]
861    fn parse_copy_text_row_rejects_incomplete_escape() {
862        let err = parse_copy_text_row(b"bad\\").expect_err("trailing backslash must fail");
863        assert!(err.to_string().contains("incomplete backslash escape"));
864    }
865
866    #[test]
867    fn parse_copy_text_row_rejects_out_of_range_octal_escape() {
868        let err = parse_copy_text_row(br"\400").expect_err("octal escape > 377 must fail");
869        assert!(err.to_string().contains("out of byte range"));
870    }
871
872    #[test]
873    fn parse_copy_text_row_rejects_hex_escape_without_digits() {
874        let err = parse_copy_text_row(br"\xG").expect_err("hex escape without digits must fail");
875        assert!(err.to_string().contains("hex escape requires"));
876    }
877
878    #[test]
879    fn copy_table_quoting_preserves_schema_qualification() {
880        assert_eq!(
881            quote_copy_table_ref("tenant_a.users").unwrap(),
882            "\"tenant_a\".\"users\""
883        );
884    }
885
886    #[test]
887    fn copy_identifier_quoting_rejects_nul_bytes() {
888        assert!(quote_copy_table_ref("tenant\0.users").is_err());
889        assert!(quote_copy_column_ident("name\0").is_err());
890    }
891
892    #[test]
893    fn copy_export_rejects_parameterized_ast_before_streaming() {
894        let cmd = Qail::export("users").filter("active", Operator::Eq, true);
895        let err = encode_copy_export_sql(&cmd).expect_err("bind params cannot be ignored");
896
897        assert!(matches!(err, PgError::Encode(msg) if msg.contains("parameterized export")));
898    }
899
900    #[cfg(unix)]
901    #[tokio::test]
902    async fn copy_return_with_desync_marks_protocol_error() {
903        let mut conn = test_conn();
904
905        let err = return_with_desync::<()>(
906            &mut conn,
907            PgError::Protocol("copy protocol ordering broke".to_string()),
908        )
909        .expect_err("protocol error must be returned");
910
911        assert!(err.to_string().contains("copy protocol ordering broke"));
912        assert!(conn.is_io_desynced());
913    }
914
915    #[test]
916    fn drain_copy_text_rows_handles_chunk_boundaries() {
917        let mut pending = Vec::new();
918        let mut rows: Vec<Vec<String>> = Vec::new();
919
920        drain_copy_text_rows(&mut pending, b"a\tb\nc", &mut |row: Vec<String>| {
921            rows.push(row);
922            Ok(())
923        })
924        .unwrap();
925        assert_eq!(rows, vec![vec!["a".to_string(), "b".to_string()]]);
926        assert_eq!(pending, b"c");
927
928        drain_copy_text_rows(&mut pending, b"\td\n", &mut |row: Vec<String>| {
929            rows.push(row);
930            Ok(())
931        })
932        .unwrap();
933        assert_eq!(
934            rows,
935            vec![
936                vec!["a".to_string(), "b".to_string()],
937                vec!["c".to_string(), "d".to_string()]
938            ]
939        );
940        assert!(pending.is_empty());
941    }
942
943    #[test]
944    fn flush_pending_copy_text_row_rejects_final_partial_line() {
945        let pending = b"x\ty".to_vec();
946        let err = flush_pending_copy_text_row(&pending)
947            .expect_err("partial final COPY row must fail closed");
948        assert!(matches!(err, PgError::Protocol(msg) if msg.contains("truncated row")));
949        assert_eq!(pending, b"x\ty");
950    }
951
952    #[test]
953    fn callback_error_bubbles_from_row_drainer() {
954        let mut pending = Vec::new();
955        let mut on_row =
956            |_row: Vec<String>| -> PgResult<()> { Err(PgError::Query("fail".to_string())) };
957
958        let err = drain_copy_text_rows(&mut pending, b"a\tb\n", &mut on_row).unwrap_err();
959        assert!(matches!(err, PgError::Query(msg) if msg == "fail"));
960    }
961
962    #[test]
963    fn row_error_consumes_that_row_and_keeps_the_rest_pending() {
964        let mut pending = Vec::new();
965        let mut seen = 0usize;
966        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
967            seen += 1;
968            if seen == 2 {
969                return Err(PgError::Query("second".to_string()));
970            }
971            Ok(())
972        };
973        let err =
974            drain_copy_text_rows(&mut pending, b"a\tb\nc\td\ne\tf\ng", &mut on_row).unwrap_err();
975        assert!(matches!(err, PgError::Query(msg) if msg == "second"));
976        assert_eq!(pending, b"e\tf\ng");
977
978        let mut pending = Vec::new();
979        let mut on_row = |_row: Vec<String>| -> PgResult<()> { Ok(()) };
980        drain_copy_text_rows(&mut pending, b"a\tb\n\\x\nc\td", &mut on_row)
981            .expect_err("an invalid escape must fail the row");
982        assert_eq!(pending, b"c\td");
983    }
984
985    #[test]
986    fn drain_copy_text_rows_rejects_newline_free_row_over_cap() {
987        let mut pending = Vec::new();
988        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
989            panic!("no row should complete without a newline")
990        };
991        let chunk = vec![b'x'; super::MAX_COPY_TEXT_ROW_BYTES + 1];
992
993        let err = drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap_err();
994
995        assert!(matches!(err, PgError::Protocol(msg) if msg.contains("exceeds")));
996        assert!(
997            pending.is_empty(),
998            "overflowed accumulator must be released"
999        );
1000    }
1001
1002    #[test]
1003    fn drain_copy_text_rows_caps_accumulation_across_chunks() {
1004        let mut pending = Vec::new();
1005        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
1006            panic!("no row should complete without a newline")
1007        };
1008        let chunk = vec![b'x'; super::MAX_COPY_TEXT_ROW_BYTES / 2 + 1];
1009
1010        drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap();
1011        let err = drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap_err();
1012
1013        assert!(matches!(err, PgError::Protocol(msg) if msg.contains("exceeds")));
1014        assert!(pending.is_empty());
1015    }
1016
1017    #[test]
1018    fn drain_copy_text_rows_accepts_large_frame_of_complete_rows() {
1019        let mut pending = Vec::new();
1020        let mut rows = 0usize;
1021        let mut on_row = |_row: Vec<String>| -> PgResult<()> {
1022            rows += 1;
1023            Ok(())
1024        };
1025        // One frame larger than the row cap, made entirely of small
1026        // newline-terminated rows: legal per COPY frame-boundary rules.
1027        let row_count = super::MAX_COPY_TEXT_ROW_BYTES / 4 + 1;
1028        let chunk = b"a\tb\n".repeat(row_count);
1029        assert!(chunk.len() > super::MAX_COPY_TEXT_ROW_BYTES);
1030
1031        drain_copy_text_rows(&mut pending, &chunk, &mut on_row).unwrap();
1032
1033        assert_eq!(rows, row_count);
1034        assert!(pending.is_empty());
1035    }
1036}