Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/main/java/net/sf/jsqlparser/statement/CopyStatement.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
import net.sf.jsqlparser.statement.select.Select;

import java.util.List;
import java.util.function.Consumer;

/**
* {@code COPY table [(columns)] FROM path [(options)]} and
* {@code COPY {table | (query)} TO path [(options)]}, DuckDB's bulk import and export statement.
* {@code COPY {table | (query)} TO path [(options)]}, the bulk import and export statement in
* DuckDB and PostgreSQL.
*
* @see <a href="https://duckdb.org/docs/stable/sql/statements/copy">COPY</a>
*/
Expand All @@ -30,6 +32,7 @@ public class CopyStatement implements Statement {
private boolean from;
private String path;
private List<String> options;
private boolean withKeyword;

public Table getTable() {
return table;
Expand Down Expand Up @@ -110,17 +113,41 @@ public CopyStatement withOptions(List<String> options) {
return this;
}

/** Whether the option list is introduced by the optional WITH keyword. */
public boolean isWithKeyword() {
return withKeyword;
}

public void setWithKeyword(boolean withKeyword) {
this.withKeyword = withKeyword;
}

public CopyStatement withWithKeyword(boolean withKeyword) {
setWithKeyword(withKeyword);
return this;
}

public StringBuilder appendTo(StringBuilder builder) {
return appendTo(builder, builder::append);
}

/** Renders the query through the caller's select writer. */
public StringBuilder appendTo(StringBuilder builder, Consumer<Select> selectPrinter) {
builder.append("COPY ");
if (select != null) {
builder.append("(").append(select).append(")");
builder.append('(');
selectPrinter.accept(select);
builder.append(')');
} else {
builder.append(table);
if (columns != null && !columns.isEmpty()) {
builder.append(" (").append(columns).append(")");
}
}
builder.append(from ? " FROM " : " TO ").append(path);
if (withKeyword) {
builder.append(" WITH");
}
if (options != null && !options.isEmpty()) {
builder.append(" ").append(PlainSelect.getStringList(options, true, true));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,8 @@ public <S> StringBuilder visit(DeallocateStatement deallocateStatement, S contex

@Override
public <S> StringBuilder visit(CopyStatement copyStatement, S context) {
copyStatement.appendTo(builder);
copyStatement.appendTo(builder,
select -> select.accept((SelectVisitor<?>) selectDeParser, context));
return builder;
}

Expand Down
10 changes: 9 additions & 1 deletion src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt
Original file line number Diff line number Diff line change
Expand Up @@ -4685,10 +4685,18 @@ CopyStatement CopyStatement() #CopyStatement:
|
token=<S_IDENTIFIER> { copyStatement.setPath(token.image); }
)
[ LOOKAHEAD({ getToken(1).kind == K_WITH })
<K_WITH> { copyStatement.setWithKeyword(true); } ]
[
LOOKAHEAD(2) "(" option=DatabaseOption() { options.add(option); }
( "," option=DatabaseOption() { options.add(option); } )*
")" { copyStatement.setOptions(options); }
")" {
if (copyStatement.isWithKeyword()
|| Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) {
requireDdlSyntax(!options.contains(""), "Expected a COPY option");
}
copyStatement.setOptions(options);
}
]
{ return copyStatement; }
}
Expand Down
100 changes: 100 additions & 0 deletions src/test/java/net/sf/jsqlparser/statement/PostgreSqlCopyTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*-
* #%L
* JSQLParser library
* %%
* Copyright (C) 2004 - 2026 JSQLParser
* %%
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
* #L%
*/
package net.sf.jsqlparser.statement;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
import net.sf.jsqlparser.util.deparser.SelectDeParser;
import net.sf.jsqlparser.util.deparser.StatementDeParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class PostgreSqlCopyTest {
@ParameterizedTest
@ValueSource(strings = {
"COPY users (email, name) FROM STDIN WITH (FORMAT csv, HEADER true)",
"COPY users TO STDOUT WITH (FORMAT csv, DELIMITER ';', NULL '')",
"COPY users TO STDOUT WITH (FORMAT csv, FORCE_QUOTE (email, name))",
"COPY users TO STDOUT WITH (FORMAT csv, FORCE_QUOTE *)",
"COPY (SELECT email FROM users) TO STDOUT WITH (FORMAT csv, HEADER true)",
"COPY users FROM STDIN WITH (FORMAT csv, HEADER MATCH, FORCE_NULL (name))",
"COPY users FROM STDIN WITH (FORMAT csv, ON_ERROR ignore, ENCODING 'UTF8')",
"COPY users TO '/tmp/export.csv' WITH (FORMAT csv)",
"COPY users TO STDOUT (FORMAT csv, HEADER true)",
"COPY users FROM STDIN",
"COPY users TO STDOUT WITH",
"COPY (WITH c AS (SELECT email FROM users) SELECT email FROM c) TO STDOUT WITH (FORMAT csv)",
"COPY (SELECT 1 UNION ALL SELECT 2) TO STDOUT WITH (FORMAT csv)"
})
void preservesCopyOptionsAndWithKeyword(String sql) throws JSQLParserException {
CopyStatement copy = parse(sql);
assertEquals(sql.contains(" WITH ") || sql.endsWith(" WITH"), copy.isWithKeyword());
assertRoundTrip(copy);
assertEquals(copy.toString(), CCJSqlParserUtil.parse(copy.toString()).toString());
}

@Test
void optionalWithKeywordCanBeEditedWithoutChangingTheOptions() throws JSQLParserException {
CopyStatement copy = parse("COPY users TO STDOUT WITH (FORMAT csv, HEADER true)");
assertTrue(copy.isWithKeyword());
copy.setWithKeyword(false);
assertEquals("COPY users TO STDOUT (FORMAT csv, HEADER true)", copy.toString());
assertFalse(parse(copy.toString()).isWithKeyword());
copy.withWithKeyword(true);
assertRoundTrip(copy);
copy.setOptions(null);
assertEquals("COPY users TO STDOUT WITH", copy.toString());
assertRoundTrip(copy);
}

@Test
void passesQueryExpressionsToTheConfiguredDeparser() throws JSQLParserException {
CopyStatement copy = parse("COPY (SELECT 7 FROM users WHERE id > 8) "
+ "TO STDOUT WITH (FORMAT csv)");
StringBuilder buffer = new StringBuilder();
ExpressionDeParser expressions = new ExpressionDeParser() {
@Override
public <S> StringBuilder visit(LongValue value, S context) {
return getBuilder().append(value.getValue() + 100);
}
};
copy.accept(new StatementDeParser(expressions, new SelectDeParser(), buffer), null);
assertEquals("COPY (SELECT 107 FROM users WHERE id > 108) TO STDOUT WITH (FORMAT csv)",
buffer.toString());
assertRoundTrip(parse(buffer.toString()));
}

@ParameterizedTest
@ValueSource(strings = {"WITH ()", "WITH (FORMAT csv,)", "WITH (,FORMAT csv)",
"WITH FORMAT csv", "WITH (FORMAT csv,, HEADER true)", "WITH (FORCE_QUOTE (email)"})
void rejectsMalformedWithOptions(String options) {
assertThrows(JSQLParserException.class, () -> parse("COPY users TO STDOUT " + options));
}

private static CopyStatement parse(String sql) throws JSQLParserException {
return (CopyStatement) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
}

private static void assertRoundTrip(CopyStatement copy) throws JSQLParserException {
StringBuilder buffer = new StringBuilder();
copy.accept(new StatementDeParser(buffer), null);
assertEquals(copy.toString(), buffer.toString());
assertEquals(copy.toString(), parse(buffer.toString()).toString());
}
}
Loading