Копирование CSV в Postgres с массивом пользовательского типа с помощью JDBC
у меня есть пользовательский тип, определенный в моей базе данных как
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)}
и я использую следующий фрагмент кода для выполнения моей копии:
    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)
Я пробовал с различными комбинациями круглых скобок во входных данных, но не могу сделать копию рабочей. Есть идеи, где я могу ошибиться?
3 ответов
см.https://git.mikael.io/mikaelhg/pg-object-csv-copy-poc/ для проекта с тестом JUnit, который делает то, что вы хотите.
в принципе, вы хотите иметь возможность использовать запятые для двух вещей: для разделения элементов массива и для разделения полей типа, но вы не хотите, чтобы синтаксический анализ CSV интерпретировал запятые как разграничители полей.
так
- вы хотите, чтобы парсер CSV рассматривал всю строку как одну строку, одно поле, которое вы можете сделать это, заключив его в одинарные кавычки и сообщив об этом парсеру CSV, и
- вы хотите, чтобы анализатор полей PG рассматривал каждый экземпляр типа элемента массива как заключенный в двойную кавычку.
код:
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}]}]
на CSV-файла формат, когда вы указываете разделитель, вы не можете использовать его в качестве символа в своих данных, если вы не избегаете его! 
пример csv-файла, используя запятую в качестве разделителя
правильная запись: data1, data2
  результаты анализа: [0] => data1 [1] => data2
неправильный: data,1, data2 парсить: [0] => data  [1] => 1 [2] => data2
наконец, вам не нужно загружать файл как csv, но как простой файл, поэтому замените свой метод loadCsvFromFile(); by
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
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. В любом случае, вы должны преобразовать строки, прежде чем вставлять их в базу данных. У тебя их два. опции:
- Вы читаете каждую строку входного файла и делаете INSERT(это может занять некоторое время);
- вы конвертируете входной файл в текстовый файл с ожидаемым форматом и используете COPY.
вставить один в один
первые параметры кажутся легкими: для первой строки 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.]+),", "''");
String sql = String.format("INSERT INTO my_table VALUES (%s)", value);
и выполнить запрос для каждой строки входного файла (или для лучшей безопасности используйте подготовленное заявление).
с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)"}
с временным файлом
более простой способ получить данные в правильном формате-создать временный файл. Вы читаете каждую строку входного файла и заменить ( by "( и ) by )". Запишите эту обработанную строку во временный файл. Затем передайте считыватель этого файла менеджеру копирования.
на ходу
с двумя нитями Вы можете использовать два темы:
- поток 1 считывает входной файл, обрабатывает строки одну за другой и записывает их в - PipedWriter.
- поток 2 проходит - PipedReaderподключен к диспетчер скопировать.
основная трудность заключается в синхронизации потоков таким образом, что поток 2 начинает читать PipedReader прежде чем поток 1 начнет записывать данные в PipedWriter. См.этот проект шахта для примера.
с пользовательским читатель
The from reader может быть экземпляром чего-то вроде (наивная версия):
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)}
просто использовать:
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 следующим образом:
TRUNCATE my_table;
COPY ...;
ANALYZE my_table;
это ускорит загрузку.
