Копирование 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 интерпретировал запятые как разграничители полей.

так

  1. вы хотите, чтобы парсер CSV рассматривал всю строку как одну строку, одно поле, которое вы можете сделать это, заключив его в одинарные кавычки и сообщив об этом парсеру CSV, и
  2. вы хотите, чтобы анализатор полей 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. В любом случае, вы должны преобразовать строки, прежде чем вставлять их в базу данных. У тебя их два. опции:

  1. Вы читаете каждую строку входного файла и делаете INSERT (это может занять некоторое время);
  2. вы конвертируете входной файл в текстовый файл с ожидаемым форматом и используете 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;

это ускорит загрузку.