1use std::collections::BTreeSet;
15
16use pg_query::NodeEnum;
17
18use crate::SQLError;
19
20mod codec;
21
22pub use codec::{decode_copy_input, encode_copy_result, encode_copy_result_with_engine};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CopyDirection {
27 From,
28 To,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CopyFormat {
34 Text,
35 Csv,
36 Binary,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CopyHeader {
42 False,
43 True,
44 Match,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct CopyOptions {
50 pub format: CopyFormat,
51 pub delimiter: u8,
52 pub null: String,
53 pub header: CopyHeader,
54 pub quote: u8,
55 pub escape: u8,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CopyTarget {
61 Relation {
62 name: String,
63 qualifier: String,
64 columns: Vec<String>,
65 },
66 Query(String),
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum CopyEndpoint {
72 Stdio,
73 File(String),
74 Program(String),
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct CopyStatement {
80 pub direction: CopyDirection,
81 pub target: CopyTarget,
82 pub endpoint: CopyEndpoint,
83 pub options: CopyOptions,
84}
85
86pub type CopyInputField = Option<String>;
90
91pub fn compile_copy(sql: &str) -> Result<CopyStatement, SQLError> {
93 let parsed = pg_query::parse(sql)?;
94 if parsed.protobuf.stmts.len() != 1 {
95 return Err(SQLError::Routine {
96 sqlstate: "42601".into(),
97 message: "COPY stream API accepts exactly one COPY statement".into(),
98 });
99 }
100 let node = parsed.protobuf.stmts[0]
101 .stmt
102 .as_deref()
103 .and_then(|node| node.node.as_ref())
104 .ok_or_else(|| SQLError::Internal("parser returned an empty COPY statement".into()))?;
105 let NodeEnum::CopyStmt(statement) = node else {
106 return Err(SQLError::Routine {
107 sqlstate: "42601".into(),
108 message: "COPY stream API requires a COPY statement".into(),
109 });
110 };
111 let direction = if statement.is_from {
112 CopyDirection::From
113 } else {
114 CopyDirection::To
115 };
116 if statement.where_clause.is_some() {
117 return Err(SQLError::Unsupported(
118 "COPY FROM WHERE is not implemented".into(),
119 ));
120 }
121 let target = match (&statement.relation, &statement.query) {
122 (Some(relation), None) => {
123 if !relation.catalogname.is_empty() {
124 return Err(SQLError::Unsupported(
125 "COPY across databases is not supported".into(),
126 ));
127 }
128 let name = if relation.schemaname.is_empty() {
129 quote_relation_component(&relation.relname)
130 } else {
131 format!(
132 "{}.{}",
133 quote_relation_component(&relation.schemaname),
134 quote_relation_component(&relation.relname)
135 )
136 };
137 let columns = statement
138 .attlist
139 .iter()
140 .map(copy_column_name)
141 .collect::<Result<Vec<_>, _>>()?;
142 CopyTarget::Relation {
143 name,
144 qualifier: relation.relname.clone(),
145 columns,
146 }
147 }
148 (None, Some(query)) if direction == CopyDirection::To => CopyTarget::Query(
149 query
150 .deparse()
151 .map_err(|error| SQLError::Parse(error.to_string()))?,
152 ),
153 (None, Some(_)) => {
154 return Err(SQLError::Routine {
155 sqlstate: "42601".into(),
156 message: "COPY FROM does not accept a query".into(),
157 });
158 }
159 _ => {
160 return Err(SQLError::Internal(
161 "COPY statement has neither one relation nor one query".into(),
162 ));
163 }
164 };
165 let endpoint = if statement.is_program {
166 CopyEndpoint::Program(statement.filename.clone())
167 } else if statement.filename.is_empty() {
168 CopyEndpoint::Stdio
169 } else {
170 CopyEndpoint::File(statement.filename.clone())
171 };
172 let options = compile_copy_options(&statement.options)?;
173 if direction == CopyDirection::To && options.header == CopyHeader::Match {
174 return Err(copy_option_error(
175 "COPY HEADER MATCH is only valid for COPY FROM",
176 ));
177 }
178 Ok(CopyStatement {
179 direction,
180 target,
181 endpoint,
182 options,
183 })
184}
185
186fn quote_relation_component(component: &str) -> String {
187 crate::expr::quote_ident(component)
188}
189
190fn copy_column_name(node: &pg_query::Node) -> Result<String, SQLError> {
191 match node.node.as_ref() {
192 Some(NodeEnum::String(value)) => Ok(value.sval.clone()),
193 other => Err(SQLError::Internal(format!(
194 "COPY column list contains malformed node {other:?}"
195 ))),
196 }
197}
198
199fn compile_copy_options(nodes: &[pg_query::Node]) -> Result<CopyOptions, SQLError> {
200 let raw = nodes
201 .iter()
202 .map(|node| {
203 let Some(NodeEnum::DefElem(option)) = node.node.as_ref() else {
204 return Err(SQLError::Internal(
205 "COPY option list contains a malformed node".into(),
206 ));
207 };
208 Ok((
209 option.defname.to_ascii_lowercase(),
210 copy_option_value(option)?,
211 ))
212 })
213 .collect::<Result<Vec<_>, SQLError>>()?;
214 let mut names = BTreeSet::new();
215 for (name, _) in &raw {
216 if !names.insert(name.clone()) {
217 return Err(SQLError::Routine {
218 sqlstate: "42601".into(),
219 message: format!("COPY option \"{name}\" specified more than once"),
220 });
221 }
222 }
223 let format = raw.iter().find(|(name, _)| name == "format").map_or(
224 Ok(CopyFormat::Text),
225 |(_, value)| match value.to_ascii_lowercase().as_str() {
226 "text" => Ok(CopyFormat::Text),
227 "csv" => Ok(CopyFormat::Csv),
228 "binary" => Ok(CopyFormat::Binary),
229 other => Err(copy_option_error(format!(
230 "COPY format \"{other}\" not recognized"
231 ))),
232 },
233 )?;
234 let mut options = CopyOptions {
235 format,
236 delimiter: if format == CopyFormat::Csv {
237 b','
238 } else {
239 b'\t'
240 },
241 null: if format == CopyFormat::Csv {
242 String::new()
243 } else {
244 "\\N".into()
245 },
246 header: CopyHeader::False,
247 quote: b'"',
248 escape: b'"',
249 };
250 let quote_explicit = names.contains("quote");
251 let escape_explicit = names.contains("escape");
252 for (name, value) in raw {
253 match name.as_str() {
254 "format" => {}
255 "delimiter" => options.delimiter = copy_single_byte_option("DELIMITER", &value)?,
256 "null" => options.null = value,
257 "header" => {
258 options.header = if value.eq_ignore_ascii_case("match") {
259 CopyHeader::Match
260 } else if copy_boolean_option("HEADER", &value)? {
261 CopyHeader::True
262 } else {
263 CopyHeader::False
264 };
265 }
266 "quote" => options.quote = copy_single_byte_option("QUOTE", &value)?,
267 "escape" => options.escape = copy_single_byte_option("ESCAPE", &value)?,
268 "encoding" if matches!(value.to_ascii_lowercase().as_str(), "utf8" | "utf-8") => {}
269 "encoding" => {
270 return Err(SQLError::Unsupported(format!(
271 "COPY ENCODING {value} is not implemented"
272 )));
273 }
274 other => {
275 return Err(SQLError::Unsupported(format!(
276 "COPY option `{other}` is not implemented"
277 )));
278 }
279 }
280 }
281 validate_copy_options(&options, quote_explicit, escape_explicit)?;
282 Ok(options)
283}
284
285fn copy_option_value(option: &pg_query::protobuf::DefElem) -> Result<String, SQLError> {
286 match option
287 .arg
288 .as_deref()
289 .and_then(|argument| argument.node.as_ref())
290 {
291 None => Ok("true".into()),
292 Some(NodeEnum::String(value)) => Ok(value.sval.clone()),
293 Some(NodeEnum::Integer(value)) => Ok(value.ival.to_string()),
294 Some(NodeEnum::Float(value)) => Ok(value.fval.clone()),
295 Some(NodeEnum::Boolean(value)) => Ok(value.boolval.to_string()),
296 other => Err(copy_option_error(format!(
297 "COPY option \"{}\" requires a scalar value, got {other:?}",
298 option.defname
299 ))),
300 }
301}
302
303fn copy_boolean_option(name: &str, value: &str) -> Result<bool, SQLError> {
304 match value.to_ascii_lowercase().as_str() {
305 "true" | "on" | "yes" | "1" => Ok(true),
306 "false" | "off" | "no" | "0" => Ok(false),
307 _ => Err(copy_option_error(format!(
308 "COPY {name} requires a Boolean value"
309 ))),
310 }
311}
312
313fn copy_single_byte_option(name: &str, value: &str) -> Result<u8, SQLError> {
314 if value.len() != 1 {
315 return Err(copy_option_error(format!(
316 "COPY {name} must be a single one-byte character"
317 )));
318 }
319 let byte = value.as_bytes()[0];
320 if matches!(byte, 0 | b'\n' | b'\r') {
321 return Err(copy_option_error(format!(
322 "COPY {name} cannot be newline or carriage return"
323 )));
324 }
325 Ok(byte)
326}
327
328fn validate_copy_options(
329 options: &CopyOptions,
330 quote_explicit: bool,
331 escape_explicit: bool,
332) -> Result<(), SQLError> {
333 if options.null.contains(['\n', '\r']) {
334 return Err(copy_option_error(
335 "COPY null representation cannot use newline or carriage return",
336 ));
337 }
338 if options.format == CopyFormat::Text && options.delimiter == b'\\' {
339 return Err(copy_option_error("COPY delimiter cannot be backslash"));
340 }
341 if options.format != CopyFormat::Csv && (quote_explicit || escape_explicit) {
342 return Err(copy_option_error(
343 "COPY quote or escape available only in CSV mode",
344 ));
345 }
346 if options.format == CopyFormat::Binary && options.header != CopyHeader::False {
347 return Err(copy_option_error(
348 "COPY HEADER is not available in binary mode",
349 ));
350 }
351 if options.format == CopyFormat::Csv && options.delimiter == options.quote {
352 return Err(copy_option_error(
353 "COPY delimiter and quote must be different",
354 ));
355 }
356 if options.null.as_bytes().contains(&options.delimiter) {
357 return Err(copy_option_error(
358 "COPY delimiter character must not appear in the NULL specification",
359 ));
360 }
361 if options.format == CopyFormat::Csv && options.null.as_bytes().contains(&options.quote) {
362 return Err(copy_option_error(
363 "CSV quote character must not appear in the NULL specification",
364 ));
365 }
366 Ok(())
367}
368
369fn copy_option_error(message: impl Into<String>) -> SQLError {
370 SQLError::Routine {
371 sqlstate: "22023".into(),
372 message: message.into(),
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn copy_parser_preserves_relation_query_and_format_contracts() {
382 let from = compile_copy(
383 "COPY app.items (bucket, payload) FROM STDIN WITH (FORMAT csv, HEADER MATCH)",
384 )
385 .unwrap();
386 assert_eq!(from.direction, CopyDirection::From);
387 assert_eq!(from.endpoint, CopyEndpoint::Stdio);
388 assert_eq!(from.options.format, CopyFormat::Csv);
389 assert_eq!(from.options.header, CopyHeader::Match);
390 assert_eq!(
391 from.target,
392 CopyTarget::Relation {
393 name: "app.items".into(),
394 qualifier: "items".into(),
395 columns: vec!["bucket".into(), "payload".into()]
396 }
397 );
398
399 let to = compile_copy("COPY (SELECT * FROM ONLY items ORDER BY id) TO STDOUT").unwrap();
400 assert_eq!(to.direction, CopyDirection::To);
401 let CopyTarget::Query(query) = to.target else {
402 panic!("expected COPY query target");
403 };
404 assert!(query.contains("ONLY items"));
405
406 let quoted =
407 compile_copy("COPY \"copy.schema\".\"Odd.Table\" (\"Odd Column\") FROM STDIN").unwrap();
408 assert_eq!(
409 quoted.target,
410 CopyTarget::Relation {
411 name: "\"copy.schema\".\"Odd.Table\"".into(),
412 qualifier: "Odd.Table".into(),
413 columns: vec!["Odd Column".into()],
414 }
415 );
416
417 for invalid in [
418 r"COPY items FROM STDIN WITH (FORMAT text, DELIMITER E'\\')",
419 "COPY items FROM STDIN WITH (FORMAT text, QUOTE '\"')",
420 "COPY items FROM STDIN WITH (FORMAT binary, ESCAPE '\"')",
421 "COPY items FROM STDIN WITH (FORMAT binary, HEADER)",
422 "COPY items FROM STDIN WITH (DELIMITER '|', NULL 'a|b')",
423 "COPY items FROM STDIN WITH (FORMAT csv, NULL 'a\"b')",
424 ] {
425 assert_eq!(compile_copy(invalid).unwrap_err().sqlstate(), Some("22023"));
426 }
427 }
428
429 #[test]
430 fn text_codec_distinguishes_null_escapes_and_end_marker() {
431 let options = compile_copy("COPY t FROM STDIN").unwrap().options;
432 let columns = vec!["a".into(), "b".into()];
433 let rows = decode_copy_input(
434 b"\\N\tempty\\tvalue\n\\\\N\tline\\nvalue\n\\.\nignored\tx\n",
435 &options,
436 &columns,
437 )
438 .unwrap();
439 assert_eq!(rows.len(), 2);
440 assert_eq!(rows[0][0], None);
441 assert_eq!(rows[0][1].as_deref(), Some("empty\tvalue"));
442 assert_eq!(rows[1][0].as_deref(), Some("\\N"));
443 assert_eq!(rows[1][1].as_deref(), Some("line\nvalue"));
444
445 let custom = compile_copy("COPY t FROM STDIN WITH (DELIMITER '|')")
446 .unwrap()
447 .options;
448 let escaped_delimiter =
449 decode_copy_input(b"left\\|right|tail\n", &custom, &columns).unwrap();
450 assert_eq!(
451 escaped_delimiter[0],
452 vec![Some("left|right".into()), Some("tail".into())]
453 );
454 assert_eq!(
455 decode_copy_input(b"\\000\tvalue\n", &options, &columns)
456 .unwrap_err()
457 .sqlstate(),
458 Some("22021")
459 );
460
461 let with_header = compile_copy("COPY t FROM STDIN WITH (HEADER MATCH)")
462 .unwrap()
463 .options;
464 assert_eq!(
465 decode_copy_input(b"a\tb\n1\t2\n", &with_header, &columns).unwrap(),
466 vec![vec![Some("1".into()), Some("2".into())]]
467 );
468 }
469
470 #[test]
471 fn csv_codec_preserves_quoted_empty_and_embedded_newline() {
472 let options = compile_copy("COPY t FROM STDIN WITH (FORMAT csv)")
473 .unwrap()
474 .options;
475 let columns = vec!["a".into(), "b".into()];
476 let rows =
477 decode_copy_input(b",\"\"\n\"a,b\",\"line\nvalue\"\n", &options, &columns).unwrap();
478 assert_eq!(rows[0], vec![None, Some(String::new())]);
479 assert_eq!(rows[1][0].as_deref(), Some("a,b"));
480 assert_eq!(rows[1][1].as_deref(), Some("line\nvalue"));
481
482 let permissive = decode_copy_input(b"\"a\"b,c\n\"a\" ,b\n", &options, &columns).unwrap();
483 assert_eq!(permissive[0], vec![Some("ab".into()), Some("c".into())]);
484 assert_eq!(permissive[1], vec![Some("a ".into()), Some("b".into())]);
485 }
486}