使用JDBC将CSV复制到具有自定义类型数组的Postgres

时间:2018-05-30 20:02:00

标签: java database postgresql jdbc postgresql-9.5

我在我的数据库中定义了一个自定义类型

CREATE TYPE address AS (ip inet, port int);

在数组中使用此类型的表:

CREATE TABLE my_table (
  addresses  address[] NULL
)

我有一个包含以下内容的示例CSV文件

{(10.10.10.1,80),(10.10.10.2,443)}
{(10.10.10.3,8080),(10.10.10.4,4040)}

我使用以下代码段来执行COPY:

    Class.forName("org.postgresql.Driver");

    String input = loadCsvFromFile();

    Reader reader = new StringReader(input);

    Connection connection = DriverManager.getConnection(
            "jdbc:postgresql://db_host:5432/db_name", "user",
            "password");

    CopyManager copyManager = connection.unwrap(PGConnection.class).getCopyAPI();

    String copyCommand = "COPY my_table (addresses) " + 
                         "FROM STDIN WITH (" + 
                           "DELIMITER '\t', " + 
                           "FORMAT csv, " + 
                           "NULL '\\N', " + 
                           "ESCAPE '\"', " +
                           "QUOTE '\"')";

    copyManager.copyIn(copyCommand, reader);

执行此程序会产生以下异常:

Exception in thread "main" org.postgresql.util.PSQLException: ERROR: malformed record literal: "(10.10.10.1"
  Detail: Unexpected end of input.
  Where: COPY only_address, line 1, column addresses: "{(10.10.10.1,80),(10.10.10.2,443)}"
    at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2422)
    at org.postgresql.core.v3.QueryExecutorImpl.processCopyResults(QueryExecutorImpl.java:1114)
    at org.postgresql.core.v3.QueryExecutorImpl.endCopy(QueryExecutorImpl.java:963)
    at org.postgresql.core.v3.CopyInImpl.endCopy(CopyInImpl.java:43)
    at org.postgresql.copy.CopyManager.copyIn(CopyManager.java:185)
    at org.postgresql.copy.CopyManager.copyIn(CopyManager.java:160)

我尝试了输入中括号的不同组合,但似乎无法使COPY正常工作。我可能会出错的任何想法?

3 个答案:

答案 0 :(得分:4)

有关具有JUnit测试的项目,请参阅https://git.mikael.io/mikaelhg/pg-object-csv-copy-poc/

基本上,您希望能够将逗号用于两件事:分隔数组项和分隔类型字段,但您不希望CSV解析将逗号解释为字段描述符。

所以

  1. 您想告诉CSV解析器将整行视为一个字符串,一个字段,您可以将其封装在单引号中并告诉CSV解析器这个,
  2. 您希望PG字段解析器将每个数组项类型实例视为包含在双引号中。
  3. 代码:

    copyManager.copyIn("COPY my_table (addresses) FROM STDIN WITH CSV QUOTE ''''", reader);
    

    DML示例1:

    COPY my_table (addresses) FROM STDIN WITH CSV QUOTE ''''
    

    CSV示例1:

    '{"(10.0.0.1,1)","(10.0.0.2,2)"}'
    '{"(10.10.10.1,80)","(10.10.10.2,443)"}'
    '{"(10.10.10.3,8080)","(10.10.10.4,4040)"}'
    

    DML示例2,转义双引号:

    COPY my_table (addresses) FROM STDIN WITH CSV
    

    CSV示例2,转义双引号:

    "{""(10.0.0.1,1)"",""(10.0.0.2,2)""}"
    "{""(10.10.10.1,80)"",""(10.10.10.2,443)""}"
    "{""(10.10.10.3,8080)"",""(10.10.10.4,4040)""}"
    

    完整的JUnit测试类:

    package io.mikael.poc;
    
    import com.google.common.io.CharStreams;
    import org.junit.*;
    import org.postgresql.PGConnection;
    import org.postgresql.copy.CopyManager;
    import org.testcontainers.containers.PostgreSQLContainer;
    
    import java.io.*;
    import java.sql.Connection;
    import java.sql.DriverManager;
    import java.sql.ResultSet;
    
    import static java.nio.charset.StandardCharsets.UTF_8;
    
    public class CopyTest {
    
        private Reader reader;
    
        private Connection connection;
    
        private CopyManager copyManager;
    
        private static final String CREATE_TYPE = "CREATE TYPE address AS (ip inet, port int)";
    
        private static final String CREATE_TABLE = "CREATE TABLE my_table (addresses  address[] NULL)";
    
        private String loadCsvFromFile(final String fileName) throws IOException {
            try (InputStream is = getClass().getResourceAsStream(fileName)) {
                return CharStreams.toString(new InputStreamReader(is, UTF_8));
            }
        }
    
        @ClassRule
        public static PostgreSQLContainer db = new PostgreSQLContainer("postgres:10-alpine");
    
        @BeforeClass
        public static void beforeClass() throws Exception {
            Class.forName("org.postgresql.Driver");
        }
    
        @Before
        public void before() throws Exception {
            String input = loadCsvFromFile("/data_01.csv");
            reader = new StringReader(input);
    
            connection = DriverManager.getConnection(db.getJdbcUrl(), db.getUsername(), db.getPassword());
            copyManager = connection.unwrap(PGConnection.class).getCopyAPI();
    
            connection.setAutoCommit(false);
            connection.beginRequest();
    
            connection.prepareCall(CREATE_TYPE).execute();
            connection.prepareCall(CREATE_TABLE).execute();
        }
    
        @After
        public void after() throws Exception {
            connection.rollback();
        }
    
        @Test
        public void copyTest01() throws Exception {
            copyManager.copyIn("COPY my_table (addresses) FROM STDIN WITH CSV QUOTE ''''", reader);
    
            final StringWriter writer = new StringWriter();
            copyManager.copyOut("COPY my_table TO STDOUT WITH CSV", writer);
            System.out.printf("roundtrip:%n%s%n", writer.toString());
    
            final ResultSet rs = connection.prepareStatement(
                    "SELECT array_to_json(array_agg(t)) FROM (SELECT addresses FROM my_table) t")
                    .executeQuery();
            rs.next();
            System.out.printf("json:%n%s%n", rs.getString(1));
        }
    
    }
    

    测试输出:

    roundtrip:
    "{""(10.0.0.1,1)"",""(10.0.0.2,2)""}"
    "{""(10.10.10.1,80)"",""(10.10.10.2,443)""}"
    "{""(10.10.10.3,8080)"",""(10.10.10.4,4040)""}"
    
    json:
    [{"addresses":[{"ip":"10.0.0.1","port":1},{"ip":"10.0.0.2","port":2}]},{"addresses":[{"ip":"10.10.10.1","port":80},{"ip":"10.10.10.2","port":443}]},{"addresses":[{"ip":"10.10.10.3","port":8080},{"ip":"10.10.10.4","port":4040}]}]
    

答案 1 :(得分:1)

CSV 格式,当您指定分隔符时,您不能将其用作数据中的字符,除非您将其转义!

使用逗号作为分隔符的csv文件示例

正确记录:data1, data2 解析结果:[0] => data1 [1] => data2

错误的一个:data,1, data2解析结果:[0] => data [1] => 1 [2] => data2

最后您不需要将文件作为csv加载,而是作为简单文件加载,因此请将方法loadCsvFromFile();替换为

public String loadRecordsFromFile(File file) {
 LineIterator it = FileUtils.lineIterator(file, "UTF-8");
 StringBuilder sb = new StringBuilder();
 try {
   while (it.hasNext()) {
     sb.append(it.nextLine()).append(System.nextLine);
   }
 } 
 finally {
   LineIterator.closeQuietly(iterator);
 }

 return sb.toString();
}

不要忘记在pom文件中添加此依赖项

<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->

    <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.6</version>
    </dependency>

或者从commons.apache.org

下载JAR

答案 2 :(得分:0)

1NF

首先,我认为您的表格设计错误,因为它不符合1NF。每个字段应该只包含原子属性,但事实并非如此。为什么不是这样的表:

CREATE TABLE my_table (
    id,
    ip inet,
    port int
)

其中id是源文件中的行号,而ip / port是此行中的其中一个地址? 样本数据:

id | ip         | port
-----------------------
1  | 10.10.10.1 | 80
1  | 10.10.10.2 | 443
2  | 10.10.10.3 | 8080
2  | 10.10.10.4 | 4040
...

因此,您将能够在单个地址上查询您的数据库(查找所有关联的地址,如果两个地址位于同一行,则返回true,无论您想要什么......)。

加载数据

但我们假设你知道自己在做什么。这里的主要问题是您的输入数据文件是特殊格式。它可能是一个单列CSV文件,但它将是一个非常简并的CSV文件。无论如何,在将行插入数据库之前,必须先对这些行进行转换。您有两种选择:

  1. 您阅读了输入文件的每一行并创建了INSERT(这可能需要一段时间);
  2. 您将输入文件转换为具有预期格式的文本文件,并使用COPY
  3. 逐个插入

    第一个选项似乎很简单:对于csv文件的第一行{(10.10.10.1,80),(10.10.10.2,443)},您必须运行查询:

    INSERT INTO my_table VALUES (ARRAY[('10.10.10.1',80),('10.10.10.2',443)]::address[], 4)
    

    为此,您只需创建一个新字符串:

    String value = row.replaceAll("\\{", "ARRAY[")
                        .replaceAll("\\}", "]::address[]")
                        .replaceAll("\\(([0-9.]+),", "'$1'");
    String sql = String.format("INSERT INTO my_table VALUES (%s)", value);
    

    并对输入文件的每一行执行查询(或为了更好的安全性,请使用prepared statement)。

    使用COPY

    插入

    我将详细说明第二种选择。您必须在Java代码中使用:

    copyManager.copyIn(sql, from);
    

    复制查询是COPY FROM STDIN语句,from是读者。声明将是:

    COPY my_table (addresses) FROM STDIN WITH (FORMAT text);
    

    要提供副本管理器,您需要数据(请注意引号):

    {"(10.10.10.1,80)","(10.10.10.2,443)"}
    {"(10.10.10.3,8080)","(10.10.10.4,4040)"}
    

    使用临时文件

    以正确格式获取数据的简单方法是创建临时文件。您阅读输入文件的每一行,并将(替换为"(,将)替换为)"。将此处理的行写入临时文件。然后将此文件的读者传递给副本管理器。

    动态

    使用两个帖子 您可以使用两个线程:

    • 线程1读取输入文件,逐个处理这些行并将它们写入PipedWriter

    • 主题2将连接到上一个PipedReader的{​​{1}}传递给副本管理器。

    主要的困难是以线程2开始将数据写入PipedWriter之前线程2开始读取PipedReader的方式同步线程。有关示例,请参阅this project of mine

    使用自定义阅读器 PipedWriter读者可以是类似(天真版本)的实例:

    from

    (这是一个天真的版本,因为正确的方法是只覆盖class DataReader extends Reader { PushbackReader csvFileReader; private boolean wasParenthese; public DataReader(Reader csvFileReader) { this.csvFileReader = new PushbackReader(csvFileReader, 1); wasParenthese = false; } @Override public void close() throws IOException { this.csvFileReader.close(); } @Override public int read(char[] cbuf, int off, int len) throws IOException { // rely on read() for (int i = off; i < off + len; i++) { int c = this.read(); if (c == -1) { return i-off > 0 ? i-off : -1; } cbuf[i] = (char) c; } return len; } @Override public int read() throws IOException { final int c = this.csvFileReader.read(); if (c == '(' && !this.wasParenthese) { this.wasParenthese = true; this.csvFileReader.unread('('); return '"'; // add " before ( } else { this.wasParenthese = false; if (c == ')') { this.csvFileReader.unread('"'); return ')'; // add " after ) } else { return c; } } } } 。但是你应该处理public int read(char[] cbuf, int off, int len)添加引号并存储推送到的额外字符。对:这有点单调乏味)。 现在,如果cbuf是该文件的读者:

    r

    只需使用:

    {(10.10.10.1,80),(10.10.10.2,443)}
    {(10.10.10.3,8080),(10.10.10.4,4040)}
    

    批量加载

    如果要加载大量数据,请不要忘记the basic tips:禁用自动提交,删除索引和约束,并使用Class.forName("org.postgresql.Driver"); Connection connection = DriverManager .getConnection("jdbc:postgresql://db_host:5432/db_base", "user", "passwd"); CopyManager copyManager = connection.unwrap(PGConnection.class).getCopyAPI(); copyManager.copyIn("COPY my_table FROM STDIN WITH (FORMAT text)", new DataReader(r)); TRUNCATE,如下所示:

    ANALYZE

    这会加快装载速度。