Skip to content

Commit b1853bf

Browse files
committed
feat: support PostgreSQL COPY WITH options
Signed-off-by: minleejae <mmj9808@gmail.com>
1 parent 2fc6e3e commit b1853bf

4 files changed

Lines changed: 140 additions & 4 deletions

File tree

‎src/main/java/net/sf/jsqlparser/statement/CopyStatement.java‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
import net.sf.jsqlparser.statement.select.Select;
1717

1818
import java.util.List;
19+
import java.util.function.Consumer;
1920

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

3437
public Table getTable() {
3538
return table;
@@ -110,17 +113,41 @@ public CopyStatement withOptions(List<String> options) {
110113
return this;
111114
}
112115

116+
/** Whether the option list is introduced by the optional WITH keyword. */
117+
public boolean isWithKeyword() {
118+
return withKeyword;
119+
}
120+
121+
public void setWithKeyword(boolean withKeyword) {
122+
this.withKeyword = withKeyword;
123+
}
124+
125+
public CopyStatement withWithKeyword(boolean withKeyword) {
126+
setWithKeyword(withKeyword);
127+
return this;
128+
}
129+
113130
public StringBuilder appendTo(StringBuilder builder) {
131+
return appendTo(builder, builder::append);
132+
}
133+
134+
/** Renders the query through the caller's select writer. */
135+
public StringBuilder appendTo(StringBuilder builder, Consumer<Select> selectPrinter) {
114136
builder.append("COPY ");
115137
if (select != null) {
116-
builder.append("(").append(select).append(")");
138+
builder.append('(');
139+
selectPrinter.accept(select);
140+
builder.append(')');
117141
} else {
118142
builder.append(table);
119143
if (columns != null && !columns.isEmpty()) {
120144
builder.append(" (").append(columns).append(")");
121145
}
122146
}
123147
builder.append(from ? " FROM " : " TO ").append(path);
148+
if (withKeyword) {
149+
builder.append(" WITH");
150+
}
124151
if (options != null && !options.isEmpty()) {
125152
builder.append(" ").append(PlainSelect.getStringList(options, true, true));
126153
}

‎src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,8 @@ public <S> StringBuilder visit(DeallocateStatement deallocateStatement, S contex
624624

625625
@Override
626626
public <S> StringBuilder visit(CopyStatement copyStatement, S context) {
627-
copyStatement.appendTo(builder);
627+
copyStatement.appendTo(builder,
628+
select -> select.accept((SelectVisitor<?>) selectDeParser, context));
628629
return builder;
629630
}
630631

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4685,10 +4685,18 @@ CopyStatement CopyStatement() #CopyStatement:
46854685
|
46864686
token=<S_IDENTIFIER> { copyStatement.setPath(token.image); }
46874687
)
4688+
[ LOOKAHEAD({ getToken(1).kind == K_WITH })
4689+
<K_WITH> { copyStatement.setWithKeyword(true); } ]
46884690
[
46894691
LOOKAHEAD(2) "(" option=DatabaseOption() { options.add(option); }
46904692
( "," option=DatabaseOption() { options.add(option); } )*
4691-
")" { copyStatement.setOptions(options); }
4693+
")" {
4694+
if (copyStatement.isWithKeyword()
4695+
|| Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) {
4696+
requireDdlSyntax(!options.contains(""), "Expected a COPY option");
4697+
}
4698+
copyStatement.setOptions(options);
4699+
}
46924700
]
46934701
{ return copyStatement; }
46944702
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertFalse;
14+
import static org.junit.jupiter.api.Assertions.assertThrows;
15+
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
17+
import net.sf.jsqlparser.JSQLParserException;
18+
import net.sf.jsqlparser.expression.LongValue;
19+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
20+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
21+
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
22+
import net.sf.jsqlparser.util.deparser.SelectDeParser;
23+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.params.ParameterizedTest;
26+
import org.junit.jupiter.params.provider.ValueSource;
27+
28+
class PostgreSqlCopyTest {
29+
@ParameterizedTest
30+
@ValueSource(strings = {
31+
"COPY users (email, name) FROM STDIN WITH (FORMAT csv, HEADER true)",
32+
"COPY users TO STDOUT WITH (FORMAT csv, DELIMITER ';', NULL '')",
33+
"COPY users TO STDOUT WITH (FORMAT csv, FORCE_QUOTE (email, name))",
34+
"COPY users TO STDOUT WITH (FORMAT csv, FORCE_QUOTE *)",
35+
"COPY (SELECT email FROM users) TO STDOUT WITH (FORMAT csv, HEADER true)",
36+
"COPY users FROM STDIN WITH (FORMAT csv, HEADER MATCH, FORCE_NULL (name))",
37+
"COPY users FROM STDIN WITH (FORMAT csv, ON_ERROR ignore, ENCODING 'UTF8')",
38+
"COPY users TO '/tmp/export.csv' WITH (FORMAT csv)",
39+
"COPY users TO STDOUT (FORMAT csv, HEADER true)",
40+
"COPY users FROM STDIN",
41+
"COPY users TO STDOUT WITH",
42+
"COPY (WITH c AS (SELECT email FROM users) SELECT email FROM c) TO STDOUT WITH (FORMAT csv)",
43+
"COPY (SELECT 1 UNION ALL SELECT 2) TO STDOUT WITH (FORMAT csv)"
44+
})
45+
void preservesCopyOptionsAndWithKeyword(String sql) throws JSQLParserException {
46+
CopyStatement copy = parse(sql);
47+
assertEquals(sql.contains(" WITH ") || sql.endsWith(" WITH"), copy.isWithKeyword());
48+
assertRoundTrip(copy);
49+
assertEquals(copy.toString(), CCJSqlParserUtil.parse(copy.toString()).toString());
50+
}
51+
52+
@Test
53+
void optionalWithKeywordCanBeEditedWithoutChangingTheOptions() throws JSQLParserException {
54+
CopyStatement copy = parse("COPY users TO STDOUT WITH (FORMAT csv, HEADER true)");
55+
assertTrue(copy.isWithKeyword());
56+
copy.setWithKeyword(false);
57+
assertEquals("COPY users TO STDOUT (FORMAT csv, HEADER true)", copy.toString());
58+
assertFalse(parse(copy.toString()).isWithKeyword());
59+
copy.withWithKeyword(true);
60+
assertRoundTrip(copy);
61+
copy.setOptions(null);
62+
assertEquals("COPY users TO STDOUT WITH", copy.toString());
63+
assertRoundTrip(copy);
64+
}
65+
66+
@Test
67+
void passesQueryExpressionsToTheConfiguredDeparser() throws JSQLParserException {
68+
CopyStatement copy = parse("COPY (SELECT 7 FROM users WHERE id > 8) "
69+
+ "TO STDOUT WITH (FORMAT csv)");
70+
StringBuilder buffer = new StringBuilder();
71+
ExpressionDeParser expressions = new ExpressionDeParser() {
72+
@Override
73+
public <S> StringBuilder visit(LongValue value, S context) {
74+
return getBuilder().append(value.getValue() + 100);
75+
}
76+
};
77+
copy.accept(new StatementDeParser(expressions, new SelectDeParser(), buffer), null);
78+
assertEquals("COPY (SELECT 107 FROM users WHERE id > 108) TO STDOUT WITH (FORMAT csv)",
79+
buffer.toString());
80+
assertRoundTrip(parse(buffer.toString()));
81+
}
82+
83+
@ParameterizedTest
84+
@ValueSource(strings = {"WITH ()", "WITH (FORMAT csv,)", "WITH (,FORMAT csv)",
85+
"WITH FORMAT csv", "WITH (FORMAT csv,, HEADER true)", "WITH (FORCE_QUOTE (email)"})
86+
void rejectsMalformedWithOptions(String options) {
87+
assertThrows(JSQLParserException.class, () -> parse("COPY users TO STDOUT " + options));
88+
}
89+
90+
private static CopyStatement parse(String sql) throws JSQLParserException {
91+
return (CopyStatement) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
92+
}
93+
94+
private static void assertRoundTrip(CopyStatement copy) throws JSQLParserException {
95+
StringBuilder buffer = new StringBuilder();
96+
copy.accept(new StatementDeParser(buffer), null);
97+
assertEquals(copy.toString(), buffer.toString());
98+
assertEquals(copy.toString(), parse(buffer.toString()).toString());
99+
}
100+
}

0 commit comments

Comments
 (0)