PostgreSQL позволяет создавать хранимые функции и процедуры. И если при их вызове с простыми данными проблем не возникает, то сложные иерархические структуры могут вызвать трудности.

В статье мы разберёмся, как передавать и получать вложенные объекты в процедурах и функциях PostgreSQL с помощью JDBC.

Вступление

Привет! Меня зовут Андрей Ковальков. Я старший инженер‑программист группы Platform Core в компании Bercut.

Мы поговорим про работу с пользовательскими типами данных (UDT, User Defined Types) PostgreSQL из Java‑кода.

После прочтения вы узнаете, что из себя представляют UDT и как с ними работать. Разберём, как реализовать поддержку чтения и записи составных UDT в своём коде.

Что такое UDT

Пользовательские типы данных (User Defined Types, UDT) — это типы, созданные разработчиком схемы данных (пользователем). Они расширяют или объединяют стандартные типы данных.

С помощью UDT можно:

  • формировать сложные структуры данных.

  • расширять стандартные типы.

  • повторно использовать созданные типы.

  • повысить уровень абстракции и делать код более понятным

В основном под UDT подразумевают типы двух видов:

  • Составные типы (Composite types) – набор полей разных типов.

  • Доменные типы (Domain types) – это существующие типы с ограничениями, например CHECK/NOT NULL и т. д.

В статье мы будем работать только с составными типами. 

Примеры композитных и доменных пользовательских типов
-- составной тип
create type public.employee as (
  id   bigint,  --id
  name varchar, --имя
  age  numeric  --возраст
);
 
-- доменный тип
CREATE DOMAIN Email AS text CHECK (VALUE ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$');
 
 
-- пример таблицы с доменным и составным типом
CREATE TABLE public.employees (
    id SERIAL PRIMARY KEY,
    employee_info public.employee, 
    email Email
);
 
 
-- вставка
INSERT INTO public.employees (employee_info, email) VALUES( row(1, 'Ivan', 20)::public.employee, 'IvanovIvan@company.com');
 
-- чтение
select * from employees e ;
 
--id|employee_info  |email                 |
----+---------------+----------------------+
-- 1|[1, Ivan, 20.0]|IvanovIvan@company.com|
 
-- передадим не корректный email
INSERT INTO public.employees (employee_info, email) VALUES(row(1, 'Ivan', 20)::public.employee, 'IvanovIvan');
 
-- SQL Error [23514]: ERROR: value for domain email violates check constraint "email_check"

Зачем нам понадобилась поддержка сложных UDT

Наша команда разрабатывает и поддерживает генератор, который упрощает работу с базами данных. Он считывает сигнатуры процедур и функций для PostgreSQL и Oracle и создаёт WAR‑артефакт. Этот артефакт работает как прокси‑сервер, который запускается на базе Tomcat.

Сгенерированный сервис позволяет внешним системам вызывать функции и процедуры через SOAP и REST. При необходимости сервис можно расширить, добавляя собственную логику с помощью плагинов.

Не всегда хранимые процедуры/функции (далее ХФ/ХП) принимают и возвращают простые типы данных. Иногда встречаются очень сложные составные объекты, с большой вложенностью. Раньше проблема не была актуальна, так как пользователи генерировали сервисы в основном для Oracle. А драйвер Oracle из коробки умел работать с композитными UDT с помощью SQLData. Теперь, когда в генерации доминировать стал PostgreSQL, понадобилось поддержать UDT и для него.

После введения в наши цели и проект предлагаю обозначить несколько моментов:

  • Мы не рассматривали для себя смену технологий/добавление фреймворков/замену или модификацию JDBC драйвера и т. д. Всё, что мы будем делать в статье, работает вместе с официальным драйвером postgresql версии 42.7.3.

  • Генератор уже умел реализовывать интерфейс SQLData (для Oracle), поэтому поддержка SQLData была предпочтительным решением.

  • Мы реализуем генератор, который используется там, где используют ХФ/ХП. Поэтому в статье мы не будем обсуждать необходимость их использования или отказа от них.

Основная часть

Обзор спецификация JDBC для работы с UDT

Рассмотрим спецификацию JDBC, которая позволяет работать с UDT. Далее будут приводиться интерфейсы из пакета java.sql.

Для объектов, которые мы хотим сопоставлять с объектами БД, нам доступен интерфейс SQLData.

SQLData.java
public interface SQLData {
 
  String getSQLTypeName() throws SQLException;
 
  void readSQL (SQLInput stream, String typeName) throws SQLException;
 
  void writeSQL (SQLOutput stream) throws SQLException;
}

Интерфейс включает 3 метода:

  • getSQLTypeName для получения имени типа данных БД.

  • readSQL для чтения объекта из БД.

  • writeSQL для записи объекта в БД.

В сигнатурах read/write методов используются два интерфейса SQLInput/SQLOutput. Подразумевается, что реализации этих интерфейсов занимаются чтением и записью данных.

Давайте взглянем на интерфейсы, доступные для чтения и записи.

SQLInput.java
public interface SQLInput {
 
    String readString() throws SQLException;
 
    boolean readBoolean() throws SQLException;
 
    byte readByte() throws SQLException;
 
    short readShort() throws SQLException;
 
    int readInt() throws SQLException;
 
    long readLong() throws SQLException;
 
    float readFloat() throws SQLException;
 
    double readDouble() throws SQLException;
 
    java.math.BigDecimal readBigDecimal() throws SQLException;
 
    byte[] readBytes() throws SQLException;
 
    java.sql.Date readDate() throws SQLException;
 
    java.sql.Time readTime() throws SQLException;
 
    java.sql.Timestamp readTimestamp() throws SQLException;
 
    java.io.Reader readCharacterStream() throws SQLException;
 
    java.io.InputStream readAsciiStream() throws SQLException;
 
    java.io.InputStream readBinaryStream() throws SQLException;
 
    Object readObject() throws SQLException;
 
    Ref readRef() throws SQLException;
 
    Blob readBlob() throws SQLException;
 
    Clob readClob() throws SQLException;
 
    Array readArray() throws SQLException;
 
    boolean wasNull() throws SQLException;
 
    java.net.URL readURL() throws SQLException;
 
    NClob readNClob() throws SQLException;
 
    String readNString() throws SQLException;
 
    SQLXML readSQLXML() throws SQLException;
 
    RowId readRowId() throws SQLException;
 
    default <T> T readObject(Class<T> type) throws SQLException {
       throw new SQLFeatureNotSupportedException();
    }
}
SQLOutput.java
 public interface SQLOutput {
 
  void writeString(String x) throws SQLException;
 
  void writeBoolean(boolean x) throws SQLException;
 
  void writeByte(byte x) throws SQLException;
 
  void writeShort(short x) throws SQLException;
 
  void writeInt(int x) throws SQLException;
 
  void writeLong(long x) throws SQLException;
 
  void writeFloat(float x) throws SQLException;
 
  void writeDouble(double x) throws SQLException;
 
  void writeBigDecimal(java.math.BigDecimal x) throws SQLException;
 
  void writeBytes(byte[] x) throws SQLException;
 
  void writeDate(java.sql.Date x) throws SQLException;
 
  void writeTime(java.sql.Time x) throws SQLException;
 
  void writeTimestamp(java.sql.Timestamp x) throws SQLException;
 
  void writeCharacterStream(java.io.Reader x) throws SQLException;
 
  void writeAsciiStream(java.io.InputStream x) throws SQLException;
 
  void writeBinaryStream(java.io.InputStream x) throws SQLException;
 
  void writeObject(SQLData x) throws SQLException;
 
  void writeRef(Ref x) throws SQLException;
 
  void writeBlob(Blob x) throws SQLException;
 
  void writeClob(Clob x) throws SQLException;
 
  void writeStruct(Struct x) throws SQLException;
 
  void writeArray(Array x) throws SQLException;
 
  void writeURL(java.net.URL x) throws SQLException;
 
  void writeNString(String x) throws SQLException;
 
  void writeNClob(NClob x) throws SQLException;
 
  void writeRowId(RowId x) throws SQLException;
 
  void writeSQLXML(SQLXML x) throws SQLException;
 
  default void writeObject(Object x, SQLType targetSqlType) throws SQLException {
        throw new SQLFeatureNotSupportedException();
  }
 
}

В интерфейсах есть методы для чтения и записи различных типов данных. А типы данных, которые не входят в состав интерфейсов, можно прочитать/записать с помощью методов readObject/writeObject.

Обычно разработчики приложений реализуют интерфейс SQLData в своих объектах, ожидая, что реализация SQLInput/SQLOutput присутствует в драйвере.

Однако каждый драйвер создаётся под свои нужды, запросы, цели, со своим виденьем архитектуры и функционалом. Поэтому не всегда JDBC‑драйверы полностью соответствуют спецификации, и это нужно учитывать.

В Connection мы можем регистрировать наши SQLData‑типы, делается это с помощью методов getTypeMap/setTypeMap.

getTypeMap/setTypeMap
package java.sql;
 
import java.util.Properties;
import java.util.concurrent.Executor;
 
public interface Connection  extends Wrapper, AutoCloseable {
 
   ...
 
    java.util.Map<String,Class<?>> getTypeMap() throws SQLException;
 
    void setTypeMap(java.util.Map<String,Class<?>> map) throws SQLException;
}

С помощью этих методов можно задать соответствие между SQLData‑классами и именами типов. Имея такой реестр объектов, драйвер, если он это поддерживает, сможет сопоставлять объекты, производить их чтение и запись.

Если вы используете сервера/платформы с общим пулом соединений для запуска своих артефактов, будьте осторожны:

  • Замена коллекции в соединении может убрать маппинг других приложений.

  • Перед использованием setTypeMap проверьте, какую мапу создаёт драйвер по умолчанию. Старайтесь создать аналогичную или похожую.

  • Иногда лучше держать маппинг в приложении. Заменить перед запросом в БД и вернуть обратно после.

    • Можно столкнуться с ошибками, если драйвер не рассчитывал видеть у себя TypeMapping, а он есть.

    • Если 2 и более приложения работают с одними и теми же объектами БД, то можно поймать исключения типа «SomeClass1 cannot be cast to class SomeClass2 is in unnamed module of loader...»

В случае с PostgreSQL JDBC драйвером я не встречал, чтобы кто‑то использовал getTypeMap/setTypeMap, но с практической точки зрения нам понадобится TypeMap во второй части, когда мы приступим к реализации массивов.

Работаем с объектами в PostgreSQL

Как вы, вероятно, уже поняли, в драйвере для PostgreSQL (пока) нет реализации SQLInput/SQLOutput.

Вместо этого для работы с объектами используется PGobject — обёртка для неизвестных типов.

PGobject.java
/*
 * Copyright (c) 2003, PostgreSQL Global Development Group
 * See the LICENSE file in the project root for more information.
 */
 
package org.postgresql.util;
 
import static org.postgresql.util.internal.Nullness.castNonNull;
 
import org.checkerframework.checker.nullness.qual.Nullable;
 
import java.io.Serializable;
import java.sql.SQLException;
 
/**
 * PGobject is a class used to describe unknown types An unknown type is any type that is unknown by
 * JDBC Standards.
 */
public class PGobject implements Serializable, Cloneable {
  protected @Nullable String type;
  protected @Nullable String value;
 
  /**
   * This is called by org.postgresql.Connection.getObject() to create the object.
   */
  public PGobject() {
  }
 
  /**
   * <p>This method sets the type of this object.</p>
   *
   * <p>It should not be extended by subclasses, hence it is final</p>
   *
   * @param type a string describing the type of the object
   */
  public final void setType(String type) {
    this.type = type;
  }
 
  /**
   * This method sets the value of this object. It must be overridden.
   *
   * @param value a string representation of the value of the object
   * @throws SQLException thrown if value is invalid for this type
   */
  public void setValue(@Nullable String value) throws SQLException {
    this.value = value;
  }
 
  /**
   * As this cannot change during the life of the object, it's final.
   *
   * @return the type name of this object
   */
  public final String getType() {
    return castNonNull(type, "PGobject#type is uninitialized. Please call setType(String)");
  }
 
  /**
   * This must be overridden, to return the value of the object, in the form required by
   * org.postgresql.
   *
   * @return the value of this object
   */
  public @Nullable String getValue() {
    return value;
  }
 
  /**
   * Returns true if the current object wraps `null` value.
   * This might be helpful
   *
   * @return true if the current object wraps `null` value.
   */
  public boolean isNull() {
    return getValue() == null;
  }
 
  /**
   * This must be overridden to allow comparisons of objects.
   *
   * @param obj Object to compare with
   * @return true if the two boxes are identical
   */
  @Override
  public boolean equals(@Nullable Object obj) {
    if (obj instanceof PGobject) {
      final Object otherValue = ((PGobject) obj).getValue();
 
      if (otherValue == null) {
        return getValue() == null;
      }
      return otherValue.equals(getValue());
    }
    return false;
  }
 
  /**
   * This must be overridden to allow the object to be cloned.
   */
  public Object clone() throws CloneNotSupportedException {
    return super.clone();
  }
 
  /**
   * This is defined here, so user code need not override it.
   *
   * @return the value of this object, in the syntax expected by org.postgresql
   */
  @Override
  @SuppressWarnings("nullness")
  public String toString() {
    return getValue();
  }
 
  /**
   * Compute hash. As equals() use only value. Return the same hash for the same value.
   *
   * @return Value hashcode, 0 if value is null {@link java.util.Objects#hashCode(Object)}
   */
  @Override
  public int hashCode() {
    String value = getValue();
    return value != null ? value.hashCode() : 0;
  }
 
  protected static boolean equals(@Nullable Object a, @Nullable Object b) {
    return a == b || a != null && a.equals(b);
  }
}

Структура объекта довольно простая. Мы можем вставить/прочитать имя типа и данные. Для записи используется setType и setValue, для чтения, соответственно, get‑методы.

Само значение внутри PgObject хранится в строковом, чем‑то похожем на JSON виде.

Формат данных PGobject

Давайте рассмотрим используемые конструкции (возможно, есть и другие, но для наших задач хватило этих):

  • () — круглыми скобками обозначается объект.

  • {} — фигурными скобками обозначается массив объектов.

  • Запятая (,) — разделяет элементы в списке или поля в объекте.

  • » — двойными кавычками выделяют строки, вложенные скобки. Если внутри строки нет специальных символов, кавычки можно не использовать.

  • null не требует указания — просто ставим запятую.

Скобки вложенных объектов/массивов мы экранируем всегда. Строковое значение мы экранируем, если оно содержит: пробел, запятую, обратную косую черту, двойные кавычки, круглые/фигурные/угловые/квадратные скобки. Нам также может понадобится экранировать символы обратной косой черты, если она используется как значение.

Уровень вложенности объектов в value имеет значение.

Реализация

Сделаем объект и пустую функцию, которая принимает объект и ничего не делает.

Предположим, у нас в БД хранится информация о сотрудниках компании. И есть процедура, которая принимает на вход сущность сотрудника компании.

create type public.employee as (
  id   bigint,  --id
  name varchar, --имя
  age  numeric  --возраст
);
 
CREATE OR REPLACE FUNCTION public.test_employee_in( in in_employee public.employee )
returns VOID
LANGUAGE plpgsql
AS $$
BEGIN
      RAISE NOTICE 'OK';
END;
$$;

Реализация "в лоб"

Теперь давайте напишем реализацию, что называется, "в лоб".

    public static final String URL = "jdbc:postgresql://localhost:5432/test";
    public static final String USR = "";
    public static final String PWD = "";
 
    public static final String QUERY = "{call public.test_employee_in(?)}";
 
    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection(URL, USR, PWD)) {
            try (CallableStatement cSt = connection.prepareCall(QUERY)) {
                cSt.setObject(1, makeData(), Types.OTHER);
                cSt.execute();
                System.out.println("Executed");
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
 
    private static PGobject makeData() throws SQLException {
        PGobject pgObject = new PGobject();
        pgObject.setType("public.employee");
        pgObject.setValue("(3,\"Victor\",25)");
        return pgObject;
    }   

Это простой и рабочий подход, особенно если это что‑то разовое и объектная модель вам для этого не нужна.

Реализация с объектом в Java

Нам объектная модель нужна, хардкодить значение мы не хотим. Сделаем объект Employee, а внутри него составим нашу строку value прямо в методе toString.

package org.example.model;
 
import java.math.BigDecimal;
 
public class Employee {
    public static final String TYPE_NAME = "public.employee";
 
    private long id;
    private String name;
    private BigDecimal age;
 
    public Employee() {
    }
 
    public Employee(long id, String name, BigDecimal age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }
 
    public String getSQLTypeName() {
        return TYPE_NAME;
    }
 
    public long getId() {
        return id;
    }
 
    public void setId(long id) {
        this.id = id;
    }
 
    public String getName() {
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
    public BigDecimal getAge() {
        return age;
    }
 
    public void setAge(BigDecimal age) {
        this.age = age;
    }
 
    @Override
    public String toString() {
        return "(" + getId() +"," + "\"" + getName() + "\"" +"," + getAge() + ")";
    }
 
    @Override
    public boolean equals(Object object) {
        if (this == object) return true;
        if (object == null || getClass() != object.getClass()) return false;
        Employee employee = (Employee) object;
        return id == employee.id && Objects.equals(name, employee.name) && Objects.equals(age, employee.age);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(id, name, age);
    }
}

Мы создали объект Employee. Добавили ему get/set-методы, пустой конструктор и конструктор со всеми аргументами, реализовали контракты toString/equals/hashCode и сделали метод getSQLTypeName, который будет возвращать тип нашего объекта в БД.

Поправим наш makeData.

   private static PGobject makeData() throws SQLException {
        PGobject pgObject = new PGobject();
        pgObject.setType("public.employee");
        pgObject.setValue(new Employee(3L, "Victor", BigDecimal.valueOf(25)).toString());
        return pgObject;
    }   

 Мы можем избавиться от постоянного создания PGobject, сделав наш Employee наследником PGobject.

public class Employee extends PGobject {
    public static final String TYPE_NAME = "public.employee";
 
...
    public Employee() {
        super.type = TYPE_NAME;
    }
 
    public Employee(long id, String name, BigDecimal age) {
        this();
        this.id = id;
        this.name = name;
        this.age = age;
    }
 
    @Override
    public String getValue() {
        return this.toString();
    }
    ...
}
 
...
 
 
   private static PGobject makeData() throws SQLException {
       return new Employee(3L, "Victor", BigDecimal.valueOf(25));
    }   

Выносим печать объекта в утилитарный класс

Наш объект уже выглядит неплохо. Но по мере развития нашего проекта у нас будут появляться всё новые и новые сущности. Постоянно подгонять toString под структуру объекта будет неудобно. Лучше его вынести отдельно.

Напомню, что это тестовый пример, и, возможно, мы и не хотим использовать контракт toString для PGobject, сам метод может быть и другим, но на данном этапе нас устраивает, что возвращает toString.

package org.example;
 
@SuppressWarnings("rawtypes")
public final class PgStringUtils {
    private static final String QUOTE = "\"";
 
    public static String toString(Object... objects) {
        if (objects == null || objects.length == 0) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        sb.append("(");
        for (int i = 0; i < objects.length; i++) {
            Object object = objects[i];
            sb.append(PgStringUtils.toStringInternal(object));
            if (i + 1 < objects.length) {
                sb.append(",");
            }
        }
        return sb.append(")").toString();
    }
 
    private static String toStringInternal(Object object) {
        if (object == null) {
            return "";
        } else if (object instanceof Iterable) {
            boolean firstGone = false;
            StringBuilder sb = new StringBuilder().append(QUOTE).append("{");
            for (Object elem : (Iterable) object) {
                if (firstGone) {
                    sb.append(",");
                }
                sb.append(QUOTE).append(toString(elem)).append(QUOTE);
                firstGone = true;
            }
            return sb.append("}").append(QUOTE).toString();
        } else if (object instanceof Number) {
            return String.valueOf(object);
        } else if (object instanceof String) {
            String string = (String) object;
            object = string.contains(QUOTE) ? string.replace(QUOTE, "\\\"") : string;
        }
        return QUOTE + object + QUOTE;
    }
 
 
    private PgStringUtils() {
    }
}
 
------------------------------------------------------------------
package org.example.model;
 
import org.example.PgStringUtils;
import org.postgresql.util.PGobject;
 
import java.math.BigDecimal;
 
public class Employee extends PGobject {
 
    ...
    
 
    @Override
    public String toString() {
        return PgStringUtils.toString(getId(), getName(), getAge());
    }
}

Теперь нам будет проще реализовывать новые объекты.

Данный вариант работоспособный, если все объекты имеют простую структуру без вложенностей. В таком случае этого должно быть достаточно.

Вложенные объекты

Как насчёт чего‑то посложней?

Предположим, теперь у нас процедура возвращает компанию, внутри которой идёт департамент, внутри департамента сотрудник.

В будущем мы их изменим, у нас будет список департаментов и список сотрудников в каждом.

А пока тренировочный пример с вложенными объектами.

create type public.employee as (
  id   bigint,  --id
  name varchar, --имя
  age  numeric  --возраст
);
 
create type public.department as (
  id   bigint,  --id
  name varchar, -- название департамента
  staff public.employee -- сотрудник департамента
);
 
create type public.company as (
  id   bigint,  --id
  name varchar, -- название департамента
  departments public.department -- департамент компании
);
 
 
CREATE OR REPLACE FUNCTION public.test_company_in( in company_in public.company)
returns VOID
LANGUAGE plpgsql
AS $$
BEGIN
      RAISE NOTICE 'OK';
END;
$$;

Аналогичным образом создаём объекты модели в java‑коде.

Создаём наш объект и передаём в процедуру:

    private static PGobject makeCompanyData() throws SQLException {
        return new Company(1L, "Evil Corp", new Department(1L, "IT", new Employee(1L, "Ivan", BigDecimal.valueOf(20))));
    }

Вызываем нашу функцию и получаем ошибку:

(1,"Evil Corp","(1,"IT","(1,"Ivan",20)")")
Exception in thread "main" java.lang.RuntimeException: org.postgresql.util.PSQLException: ERROR: malformed record literal: "(1,IT,(1"
  Подробности: Unexpected end of input.
  Где: unnamed portal parameter $1 = '...'
    at org.example.MainLittleCompany.main(MainLittleCompany.java:29)
Caused by: org.postgresql.util.PSQLException: ERROR: malformed record literal: "(1,IT,(1"
  Подробности: Unexpected end of input.
  Где: unnamed portal parameter $1 = '...'
    at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2725)
    at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2412)
    at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:371)
    at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:502)
    at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:419)
    at org.postgresql.jdbc.PgPreparedStatement.executeWithFlags(PgPreparedStatement.java:194)
    at org.postgresql.jdbc.PgCallableStatement.executeWithFlags(PgCallableStatement.java:90)
    at org.postgresql.jdbc.PgPreparedStatement.execute(PgPreparedStatement.java:180)
    at org.example.MainLittleCompany.main(MainLittleCompany.java:25)

Нашему драйверу явно не нравится, то что мы ему передали, и нужно понять, что именно.

Наша строка с данными, если её распечатать выглядит следующим образом:

(1,"Evil Corp","(1,"IT","(1,"Ivan",20)")")

Из описания ошибки можно предположить, что с текущим экранированием мы как будто не закрыли первый объект Departement и открыли второй. т. е. наше экранирование драйверу не нравится.

Определим функцию, которая вернёт нам объект. Посмотрим, как экранируется возвращаемое значение и что исправить. Заодно мы попробуем вернуть данные, а не записать их.

Делаем процедуру, которая возвращает объект, я специально добавил тут массивы, чтобы уровень вложенности был как можно больше:

CREATE OR REPLACE FUNCTION public.test_company_out()
returns company
 LANGUAGE plpgsql
AS $function$begin
  return row(1, 'Evil Corp', 
  array[
    row(1, 'IT', array[
      row(1, 'Ivan', 20)::public.employee,
      row(2, 'Petr', 30)::public.employee
    ])::public.department,
    row(2, 'Sales', array[
          row(3, 'Victor', 25)::public.employee,
          row(4, 'Dmitriy', 35)::public.employee
        ])::public.department
  ]
  )::public.company;
end$function$

Сделаем новый тестовый класс, который зачитает наш объект как PGobject:

Но теперь вызов {call public.test_company_in(?)} нам не подходит.

Отладка вызова
Отладка вызова

Как видно из отладки, мы получаем три колонки в ответ, т. е. наш объект драйвер как будто бы развернул объект, а нам это не нужно.

Завернём объект обратно сами. Для этого нам нужно заменить call на select.

Вызовем функцию:

public class GetCompanyOut {
 
    public static final String URL = "jdbc:postgresql://localhost:5432/test";
    public static final String USR = "";
    public static final String PWD = "";
 
    public static final String QUERY_NOT_WORK = "{ ? = call public.test_company_out()}";
    public static final String QUERY = "select ROW(result.*) from public.test_company_out() as result";
 
    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection(URL, USR, PWD)) {
            try (CallableStatement cSt = connection.prepareCall(QUERY)) {
                cSt.execute();
                if (cSt.getResultSet().next()) {
                    PGobject object = (PGobject) cSt.getResultSet().getObject(1);
                    System.out.println(object.getValue());
                }
                System.out.println("Executed");
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}

Вызвав процедуру, мы видим, что нам пришло из БД.

(1,"Evil Corp","{""(1,IT,\\""{\\""\\""(1,Ivan,20)\\""\\"",\\""\\""(2,Petr,30)\\""\\""}\\"")"",""(2,Sales,\\""{\\""\\""(3,Victor,25)\\""\\"",\\""\\""(4,Dmitriy,35)\\""\\""}\\"")""}")

Как видно, экранирование зависит от уровня вложенности, что и привело в нашем случае к malformed‑проблеме.

Сначала массив экранируется в кавычки, потом объект в двойные кавычки, затем двойные кавычки с двумя обратными слешами, затем прошлая конструкция повторяется дважды и т. д.

Чтобы не разбирать каждый случай отдельно, приведу закономерность, которую выявил.

Уровень вложенности

Экранирование

0

Не экранируем

1

"

2

""

3

\\""

4

\\""\\""

5

\\\\\\\\\\""\\"" (\\\\\\\\+ 4 уровень)

6

\\\\\\\\\\""\\""\\\\\\\\\\""\\"" (5 ур. дважды)

7

\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\""\\""\\\\\\\\\\""\\"" (8 обратных слешей 4 раза + 6 уровень)

Если с первыми 5 уровнями всё понятно.

То для 6+ уровней проще показать саму закономерность, вот и она:

private static String getLargeEscapeDeep(int deep) {
        StringBuilder escape = new StringBuilder("\\\\\\\\\\\\\\\\" + "\\\\\"\"\\\\\"\"");
        if (deep == 5) {
            return escape.toString();
        }
        for (int i = 6; i <= deep; i++) {
            if (i % 2 == 0) {
                escape = new StringBuilder(escape.toString().repeat(2));
            } else {
                escape.insert(0, "\\\\\\\\\\\\\\\\".repeat((int) (Math.pow(2, i - 2)) / 8));
            }
        }
        return escape.toString();
    }

Теперь мы можем экранировать объекты любого уровня вложенности. Если кто‑то знает, как можно экранировать другим способом, напишите в комментарии, будет очень интересно.

Возвращаясь к нашему прошлому решению:

— нам нужно отслеживать уровень вложенности, а значит, простой метод toString нам уже не подходит.

— мы не хотим видеть в своём toString (например, в логах) что‑то вроде:

{"(\"Test double \"\" single ' slash \\\\ end\",\"{\"\"(\\\\\"\"Test double \\\\\"\"\\\\\"\" single ' slash \\\\\\\\\\\\\\\\ end\\\\\"\",\\\\\"\"{\\\\\"\"\\\\\"\"(\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"Test double \\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\" single ' slash \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ end\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\",\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"{\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"(\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"Test double \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\" single ' slash \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ end\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\")\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\"}\\\\\\\\\\\\\\\\\\\\\"\"\\\\\"\")\\\\\"\"\\\\\"\"}\\\\\"\")\"\"}\")"}

Помимо добавления уровня вложенности нам понадобится протаскивать в наши объекты сущности драйвера, такие как Connection, TimestampUtils, поэтому будет куда лучше абстрагировать нашу модель чтения/записи от драйверов. Наиболее подходящим решением, как мне кажется, является уход к SQLInput и SQLOutput.

Реализация SQLInput

Функционал чтения объектов из БД уже есть. Ознакомиться с ним можно в пул реквесте драйвера pgjdbc.

Это рабочее решение, за исключением массивов объектов, но их мы будем реализовывать во второй части.

Для наших задач нам понадобятся следующие сущности: SQLDataReader/PgSQLInput.

Предлагаю кратко рассмотреть эти 2 класса. Код брался на момент написания статьи и мог измениться в оригинальном pull‑реквест:

/*
 * Copyright (c) 2024, PostgreSQL Global Development Group
 * See the LICENSE file in the project root for more information.
 */
 
package org.postgresql.jdbc;
 
import static java.lang.Character.isWhitespace;
 
import org.postgresql.core.BaseConnection;
import org.postgresql.util.GT;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
 
import org.checkerframework.checker.nullness.qual.Nullable;
 
import java.sql.SQLData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
 
public class SQLDataReader {
    public @Nullable  <T> T read(@Nullable String value, Class<T> type, BaseConnection connection, TimestampUtils timestampUtils) throws SQLException {
        if (value == null) {
            return null;
        }
        SQLData data;
        try {
            data = (SQLData) type.getConstructor().newInstance();
        } catch (Exception ex) {
            throw new PSQLException(GT.tr("An accessible no-arg constructor is required for type {0}", type), PSQLState.SYNTAX_ERROR, ex);
        }
 
        data.readSQL(new PgSQLInput(parseObj(value), connection, timestampUtils), data.getSQLTypeName());
 
        return type.cast(data);
    }
 
    /**
     * This will parse strings such as would be returned from "select table_name from table_name"
     *
     * <p>e.g. (42,43,44,Thing,t,1,42.3,65.97777777777777,78.94444445444,"some bytes",2024-10-10,14:12:35,"2024-10-10 14:12:35")
     *
     * @return list of parsed strings
     */
    public List<@Nullable String> parseObj(String value) {
        return parse(value, '(', ')');
    }
 
    private static List<@Nullable String> parse(String value, char begin, char end) {
        List<@Nullable String> values = new ArrayList<>();
 
        int len = value.length();
        StringBuilder builder = null;
 
        int lastDelimIdx = -1;
        int charIdx = 0;
        while (charIdx < len) {
            char ch = value.charAt(charIdx);
            if (ch == begin) {
                //
                // Found our begin character.
                //
                lastDelimIdx = charIdx;
            } else if (ch == end) {
                //
                // Found our end character. Add the last item and break out of loop.
                //
                addParsedItem(builder, lastDelimIdx, charIdx, values);
                break;
            } else if (ch == '"') {
                //
                // Found the start of a quoted string item. So read till next closing quote.
                //
                builder = new StringBuilder();
                int index;
                for (index = charIdx + 1; index < len; ++index) {
                    char ch2 = value.charAt(index);
                    //
                    // found potential end quote.
                    //
                    if (ch2 == '"') {
                        //
                        // Look to make sure this is not an escaped double quote. If so, add double quote character,
                        // otherwise we are done, break out of loop.
                        //
                        if (index < len - 1 && value.charAt(index + 1) == '"') {
                            ++index;
                            builder.append('"');
                        } else {
                            break;
                        }
                    } else if (ch2 == '\\') {
                        //
                        // Found escape character. Append the next value instead of the escape character.
                        // Unless it is the last character then just append the slash.
                        //
                        ++index;
                        if (index < len) {
                            builder.append(value.charAt(index));
                        } else {
                            builder.append(ch2);
                        }
                    } else {
                        builder.append(ch2);
                    }
                }
                //
                // Next char should be a comma or our end char. The builder contents will then
                // be added on the next pass through the loop.
                //
                charIdx = index;
            } else if (ch == ',') {
                //
                // Found a comma, so add last item, and get ready to look for next.
                //
                addParsedItem(builder, lastDelimIdx, charIdx, values);
                builder = null;
                lastDelimIdx = charIdx;
            } else {
                //
                // Ignore any whitespace we encounter
                //
                if (isWhitespace(ch)) {
                    ++charIdx;
                    while (charIdx < len && isWhitespace(value.charAt(charIdx))) {
                        ++charIdx;
                    }
                    continue;
                }
 
                if (builder == null) {
                    builder = new StringBuilder();
                }
                builder.append(ch);
            }
            ++charIdx;
        }
        return values;
    }
 
    private static void addParsedItem(@Nullable StringBuilder builder, int lastDelimIdx, int charIdx, List<@Nullable String> values) {
        if (lastDelimIdx == charIdx - 1) {
            values.add(null);
        } else if (builder != null) {
            values.add(builder.toString());
        }
    }
}

Класс SQLDataReader является точкой входа к чтению объектов и массивов. В методе read он принимает value пришедшего PGobject, класс, в который мы хотим преобразовать value ( он должен реализовывать SQLData), объект BaseConnection и утилитарный класс для timestamp. В методе parse входящее value разбивается на массив значений, разделённых запятыми.

SQLDataReader создаёт экземпляр PgSQLInput который реализует интерфейс SQLInput.

/*
 * Copyright (c) 2024, PostgreSQL Global Development Group
 * See the LICENSE file in the project root for more information.
 */
 
package org.postgresql.jdbc;
 
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
 
import org.postgresql.Driver;
import org.postgresql.core.BaseConnection;
 
import org.checkerframework.checker.nullness.qual.Nullable;
 
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.sql.Array;
import java.sql.Blob;
import java.sql.Clob;
import java.sql.Date;
import java.sql.NClob;
import java.sql.Ref;
import java.sql.RowId;
import java.sql.SQLData;
import java.sql.SQLException;
import java.sql.SQLInput;
import java.sql.SQLXML;
import java.sql.Time;
import java.sql.Timestamp;
import java.util.List;
 
public class PgSQLInput implements SQLInput {
    private static final SQLFunction<String, String> stringConv = (value) -> value;
    private static final SQLFunction<String, Byte> byteConv = (value) -> Byte.valueOf(value);
    private static final SQLFunction<String, Short> shortConv = (value) -> Short.valueOf(value);
    private static final SQLFunction<String, Integer> intConv = (value) -> Integer.valueOf(value);
    private static final SQLFunction<String, Long> longConv = (value) -> Long.valueOf(value);
    private static final SQLFunction<String, Float> floatConv = (value) -> Float.valueOf(value);
    private static final SQLFunction<String, Double> doubleConv = (value) -> Double.valueOf(value);
    private static final SQLFunction<String, BigDecimal> bigDecimalConv = (value) -> new BigDecimal(value);
    private static final SQLFunction<String, BigInteger> bigIntConv = (value) -> new BigInteger(value);
    private static final SQLFunction<String, byte[]> bytesConv = (value) -> value.getBytes(UTF_8);
    private static final SQLFunction<String, Boolean> boolConv = (value) -> {
        if ("t".equals(value)) {
            return Boolean.TRUE;
        }
        return Boolean.FALSE;
    };
    private static final SQLFunction<String, URL> urlConv = (value) -> {
        try {
            return new URL(value);
        } catch (MalformedURLException ex) {
            throw new SQLException(ex);
        }
    };
 
    private final SQLFunction<String, Timestamp> timestampConv;
    private final SQLFunction<String, Time> timeConv;
    private final SQLFunction<String, Date> dateConv;
    private final SQLFunction<String, Array> arrayConv;
 
    private int index = -1;
    private @Nullable Boolean wasNull = null;
    private final List<@Nullable String> values;
    private BaseConnection connection;
    private TimestampUtils timestampUtils;
 
    public PgSQLInput(List<@Nullable String> values, BaseConnection connection, TimestampUtils timestampUtils) {
        this.values = values;
        this.connection = connection;
        this.timestampUtils = timestampUtils;
 
        timestampConv = getTimestampConvFn(timestampUtils);
        timeConv = getTimeConvFn(timestampUtils);
        dateConv = getDateConvFn(timestampUtils);
        // arrayConv = getArrayConvFn(connection);
        arrayConv = getArrayConvFn();
    }
 
    ...
 
    private @Nullable <T> T getNextValue(SQLFunction<String, T> convert) throws SQLException {
        index++;
 
        String value = values.get(index);
        if (value == null) {
            wasNull = true;
            return null;
        }
 
        T result = convert.apply(value);
        wasNull = result == null;
        return result;
    }
 
    @SuppressWarnings("override.return")
    @Override
    public @Nullable String readString() throws SQLException {
        return getNextValue(stringConv);
    }
 
    ...
 
    @Override
    public long readLong() throws SQLException {
        Long result = getNextValue(longConv);
        return result == null ? 0 : result;
    }
 
    ...
 
    @SuppressWarnings("override.return")
    @Override
    public @Nullable BigDecimal readBigDecimal() throws SQLException {
        return getNextValue(bigDecimalConv);
    }
 
 
    @SuppressWarnings("override.return")
    @Override
    public @Nullable Object readObject() throws SQLException {
        return getNextValue(stringConv);
    }
 
    @SuppressWarnings("override.return")
    @Override
    public @Nullable <T> T readObject(Class<T> type) throws SQLException {
        return getNextValue(getConverter(type, connection, timestampUtils));
    }
 
    ...
    
    @Override
    public boolean wasNull() throws SQLException {
        return wasNull == null ? false : wasNull;
    }
 
    ...
    
    private static <T> SQLFunction<String, T> getConverter(Class<T> type, BaseConnection connection, TimestampUtils timestampUtils) throws SQLException {
        if (type.isArray()) {
            return (value) -> readGenericArray(value, type, connection, timestampUtils);
        }
 
        if (SQLData.class.isAssignableFrom(type)) {
            return (value) -> {
                //
                // NOTE: This method can return null but I think the converters are all called after a null check
                // and thus are not configured to return null because the null is handled prior to this call.
                // But since this is used elsewhere where null is a valid result I'm just going to throw an error
                // if we get a null result here to make the CheckerFramework happy.
                //
                T result = new SQLDataReader().read(value, type, connection, timestampUtils);
                if (result == null) {
                    throw new SQLException("Null value found.");
                }
                return result;
            };
        }
 
        if (type == String.class) {
            return (SQLFunction<String, T>) stringConv;
        }
 
        if (type == Boolean.class || type == boolean.class) {
            return (SQLFunction<String, T>) boolConv;
        }
 
        if (type == Short.class || type == short.class) {
            return (SQLFunction<String, T>) shortConv;
        }
 
        if (type == Integer.class || type == int.class) {
            return (SQLFunction<String, T>) intConv;
        }
 
        if (type == Long.class || type == long.class) {
            return (SQLFunction<String, T>) longConv;
        }
 
        if (type == BigInteger.class) {
            return (SQLFunction<String, T>) bigIntConv;
        }
 
        if (type == Float.class || type == float.class) {
            return (SQLFunction<String, T>) floatConv;
        }
 
        if (type == Double.class || type == double.class) {
            return (SQLFunction<String, T>) doubleConv;
        }
 
        if (type == BigDecimal.class) {
            return (SQLFunction<String, T>) bigDecimalConv;
        }
 
        if (type == Byte.class || type == byte.class) {
            return (SQLFunction<String, T>) byteConv;
        }
 
        if (type == Timestamp.class) {
            return (SQLFunction<String, T>) getTimestampConvFn(timestampUtils);
        }
 
        if (type == Time.class) {
            return (SQLFunction<String, T>) getTimeConvFn(timestampUtils);
        }
 
        if (type == Date.class) {
            return (SQLFunction<String, T>) getDateConvFn(timestampUtils);
        }
 
        if (type == URL.class) {
            return (SQLFunction<String, T>) urlConv;
        }
 
        if (type == Array.class) {
            // return (SQLFunction<String, T>) getArrayConvFn(connection);
            return (SQLFunction<String, T>) getArrayConvFn();
        }
 
        if (type == SQLXML.class) {
            return (SQLFunction<String, T>) (value) -> (T) new PgSQLXML(connection, value);
        }
 
        throw new SQLException(String.format("Unsupported type conversion to [%s].", type));
    }
}

В PgSQLInput каждый раз при вызове методов write мы обращаемся к getNextValue. В нём мы переходим на следующее значение из массива и применяем к нему лямбда‑функцию для преобразования в нужный тип. Сам массив значений мы получили в результате парсинга value в SQLDataReader.

В случае если мы вызываем readObject, то мы снова вызываем конструкцию SQLDataReader.

Пример вызова чтения:

private static Company readFromDB(CallableStatement cSt, Connection connection) throws SQLException {
  BaseConnection baseConnection = connection.unwrap(BaseConnection.class);
 
  QueryExecutor queryExecutor = baseConnection.getQueryExecutor();
  
  Provider<TimeZone> timeZoneProvider = () -> Optional.ofNullable(queryExecutor.getTimeZone()).orElseThrow(() -> new IllegalStateException("Unknown timezone"));
  TimestampUtils timestampUtils = new TimestampUtils(!queryExecutor.getIntegerDateTimes(), timeZoneProvider);
  
  PGobject object = (PGobject) cSt.getResultSet().getObject(1);
  return new SQLDataReader().read(object.getValue(), Company.class, baseConnection, timestampUtils);
}

Мы получили BaseConnection из Connection с помощью unwrap.

Далее из BaseConnection получили QueryExecutor. Который использовали для создания провайдера TimeZone и инициализации TimestampUtils. Конкретно в нашем эксперименте TimestampUtils не используется, но будет необходим при работе с timestamp‑типами.

После этого получаем value из пришедшего PGobject и передаём его на чтение в SQLDataReader.

Реализация SQLOutput

В данном пул реквесте нет SQLOutput, поэтому его реализуем сами.

Сперва имплементируем SQLData в наших модельных объектах

Имплементация SQLData
public class Employee implements SQLData {
    public static final String TYPE_NAME = "public.employee";
 
    private long id;
    private String name;
    private BigDecimal age;
 
    public Employee() {
    }
 
    public Employee(long id, String name, BigDecimal age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }
 
    @Override
    public String getSQLTypeName() {
        return TYPE_NAME;
    }
 
    @Override
    public void readSQL(SQLInput stream, String typeName) throws SQLException {
        setId(stream.readLong());
        setName(stream.readString());
        setAge(stream.readBigDecimal());
    }
 
    @Override
    public void writeSQL(SQLOutput stream) throws SQLException {
        stream.writeLong(getId());
        stream.writeString(getName());
        stream.writeBigDecimal(getAge());
    }
 
...
}
 
 
 
public class Department implements SQLData {
    public static final String TYPE_NAME = "public.department";
 
    private long id;
    private String name;
    private Employee staff;
 
    public Department() {
    }
 
    public Department(long id, String name, Employee staff) {
        this.id = id;
        this.name = name;
        this.staff = staff;
    }
 
    @Override
    public String getSQLTypeName() {
        return TYPE_NAME;
    }
 
    @Override
    public void readSQL(SQLInput stream, String typeName) throws SQLException {
        setId(stream.readLong());
        setName(stream.readString());
        setStaff(stream.readObject(Employee.class));
    }
 
    @Override
    public void writeSQL(SQLOutput stream) throws SQLException {
        stream.writeLong(getId());
        stream.writeString(getName());
        stream.writeObject(staff);
    }
 
...
}
 
 
public class Company implements SQLData {
    public static final String TYPE_NAME = "public.company";
 
    private long id;
    private String name;
    private Department departments;
 
    public Company() {
    }
 
    public Company(long id, String name, Department departments) {
        this.id = id;
        this.name = name;
        this.departments = departments;
    }
 
    @Override
    public String getSQLTypeName() {
        return TYPE_NAME;
    }
 
    @Override
    public void readSQL(SQLInput stream, String typeName) throws SQLException {
        setId(stream.readLong());
        setName(stream.readString());
        setDepartments(stream.readObject(Department.class));
    }
 
    @Override
    public void writeSQL(SQLOutput stream) throws SQLException {
        stream.writeLong(getId());
        stream.writeString(getName());
        stream.writeObject(departments);
    }
 
}

Итак, мы реализовали SQLData. Метод getSQLTypeName у нас был, а readSQL и writeSQL заполнили в соответствии с нашими полями.

Теперь реализуем свой SQLOutput:

public class PgSQLOutput implements SQLOutput {
 
    private final BaseConnection connection;
    private final int deep;
    private final String escape;
    private final StringBuilder stringBuilder;
 
    private boolean isResultCalled = false;
    private boolean isFirstArgument = true;
 
 
    public PgSQLOutput(final BaseConnection connection, int deep) {
        this.connection = connection;
        this.deep = deep;
        this.escape = PgStringUtils.getEscapeForDeep(deep);
        this.stringBuilder = new StringBuilder().append(escape).append(PgStringUtils.BRACKET_S);
    }
 
    public PgSQLOutput(final BaseConnection connection) {
        this(connection, 0);
    }
 
    @Override
    public void writeString(String str) throws SQLException {
        writeAsString(PgStringUtils.escapeString(str, deep));
    }
 
    @Override
    public void writeLong(long x) {
        writeAsString(String.valueOf(x));
    }
 
    @Override
    public void writeBigDecimal(BigDecimal x) throws SQLException {
        writeAsString(x == null ? null : x.toString());
    }
 
    @Override
    public void writeObject(SQLData x) throws SQLException {
        String result = null;
        if (x != null) {
            PgSQLOutput output = new PgSQLOutput(connection, deep + 1);
            x.writeSQL(output);
            result =  output.getResult();
        }
        writeAsString(result);
    }
 
    protected void writeAsString(String str) {
        ensureCanChange();
        addDelimiter();
        append(str);
    }
 
    private void ensureCanChange() {
        if (isResultCalled) {
            throw new IllegalStateException();
        }
    }
 
    private void addDelimiter() {
        if (isFirstArgument) {
            isFirstArgument = false;
        } else {
            stringBuilder.append(PgStringUtils.COMMA);
        }
    }
 
    private void append(String str) {
        if (str != null) {
            stringBuilder.append(str);
        }
    }
 
    public String getResult() {
        if (!isResultCalled) {
            isResultCalled = true;
            stringBuilder.append(PgStringUtils.BRACKET_E).append(escape);
        }
        return stringBuilder.toString();
    }
    
    ...
}

В нашей реализации мы берём StringBuilder для сборки будущего результата. Вписываем в него различные типы данных и, опираясь на уровень вложенности, добавляем экранирование. Каждое значение разделяем запятой.

Строку для отправки в БД мы получим с помощью метода getResult.

Для экономии места мы реализовали только нужные для наших данных методы.

Методы для типов byte/short/int/float/double/BigDecimal/Array реализуются по подобию writeLong.

Если необходимо реализовать какой‑то более сложный метод, который вам нужен, можно обратиться к пул реквесту, указанному выше, и написать обратную функцию по аналогии.

Теперь посмотрим на добавленные методы в PgStringUtils.

Помимо объектов и массивов, мы экранируем строки, но только те, которые имеют специальные символы, требующие этого.

Здесь также может понадобится экранирование самого содержимого строк, в случае если у нас есть обратные слеши, а также двойные кавычки. Сам подход к экранированию не будет отличаться, но мы экранируем на 1 уровень глубже

Экранирующий класс
public final class PgStringUtils {
    private static final char BACKSLASH_LITERAL = '\\';
    private static final char DOUBLE_QUOTE_LITERAL = '\"';
 
    public static final char BRACKET_S = '(';
    public static final char BRACKET_E = ')';
    public static final char CURL_BRACKET_S = '{';
    public static final char CURL_BRACKET_E = '}';
    public static final char COMMA = ',';
    private static final char ANGLE_BRACKET_S = '<';
    private static final char ANGLE_BRACKET_E = '>';
    private static final char SQUARE_BRACKET_S = '[';
    private static final char SQUARE_BRACKET_E = ']';
 
    private static final String QUOTE = "\"";
    private static final String NO_ESCAPE = "";
    private static final String FIRST_LEVEL_ESCAPE = "\"";
    private static final String SECOND_LEVEL_ESCAPE = "\"\"";
    private static final String THIRD_LEVEL_ESCAPE = "\\\\\"\"";
    private static final String FOURTH_LEVEL_ESCAPE = "\\\\\"\"\\\\\"\"";
    private static final String EIGHT_BACKSLASHES_PADDING = "\\\\\\\\\\\\\\\\";
    
    public static String getEscapeForDeep(int deep) {
        if (deep == 0) {
            return NO_ESCAPE;
        }
        if (deep == 1) {
            return FIRST_LEVEL_ESCAPE;
        }
        if (deep == 2) {
            return SECOND_LEVEL_ESCAPE;
        }
        if (deep == 3) {
            return THIRD_LEVEL_ESCAPE;
        }
        if (deep == 4) {
            return FOURTH_LEVEL_ESCAPE;
        }
        return getLargeEscapeDeep(deep);
    }
 
    private static String getLargeEscapeDeep(int deep) {
        StringBuilder escape = new StringBuilder(EIGHT_BACKSLASHES_PADDING + FOURTH_LEVEL_ESCAPE);
        if (deep == 5) {
            return escape.toString();
        }
        for (int i = 6; i <= deep; i++) {
            if (i % 2 == 0) {
                escape = new StringBuilder(escape.toString().repeat(2));
            } else {
                escape.insert(0, EIGHT_BACKSLASHES_PADDING.repeat((int) (Math.pow(2, i - 2)) / 8));
            }
        }
        return escape.toString();
    }
 
    public static String escapeString(String string, int currentDeep) throws PSQLException {
        if (isNoNeedToEscape(string)) {
            return string;
        }
        String escapeString = getEscapeForDeep(currentDeep + 1);
        return escapeString + string + escapeString; // экранировать строку если нужно
    }
    
 
    private static boolean isNoNeedToEscape(String string) {
        if (string == null) {
            return true;
        }
        char tmpChar;
        for (int i = 0; i < string.length(); i++) {
            tmpChar = string.charAt(i);
            if (isAmbiguousCharacter(tmpChar)) {
                return false;
            }
        }
        return true;
    }
 
    private static boolean isAmbiguousCharacter(char tmpChar) {
        return Character.isWhitespace(tmpChar)
                || tmpChar == COMMA
                || tmpChar == BACKSLASH_LITERAL
                || tmpChar == DOUBLE_QUOTE_LITERAL
                || tmpChar == BRACKET_S
                || tmpChar == BRACKET_E
                || tmpChar == CURL_BRACKET_S
                || tmpChar == CURL_BRACKET_E
                || tmpChar == ANGLE_BRACKET_S
                || tmpChar == ANGLE_BRACKET_E
                || tmpChar == SQUARE_BRACKET_E
                || tmpChar == SQUARE_BRACKET_S;
    }
    ...
}

Давайте сделаем функцию, которая примет объект и отдаст его обратно.

CREATE OR REPLACE FUNCTION public.test_little_company_in_out( in in_company public.company )
returns public.company
LANGUAGE plpgsql
AS $$
BEGIN
      return in_company;
END;
$$;

Мы сделаем объект, отправим, получим обратно и сравним результат:

public class CompanyInOut {
 
    public static final String URL = "jdbc:postgresql://localhost:5432/test";
    public static final String USR = "";
    public static final String PWD = "";
 
    public static final String QUERY = "select ROW(result.*) from public.test_little_company_in_out(?) as result";
 
    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection(URL, USR, PWD)) {
            try (CallableStatement cSt = connection.prepareCall(QUERY)) {
                Company company = makeCompany();
                PGobject pGobject = makePgObject(connection, company);
                cSt.setObject(1, pGobject, Types.OTHER);
                cSt.execute();
                if (cSt.getResultSet().next()) {
                    Company read = readFromDB(cSt, connection);
                    System.out.println("Is equals: " + company.equals(read));
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
 
    private static Company readFromDB(CallableStatement cSt, Connection connection) throws SQLException {
        BaseConnection baseConnection = connection.unwrap(BaseConnection.class);
        QueryExecutor queryExecutor = baseConnection.getQueryExecutor();
        Provider<TimeZone> timeZoneProvider = () -> Optional.ofNullable(queryExecutor.getTimeZone()).orElseThrow(() -> new IllegalStateException("Unknown timezone"));
        TimestampUtils timestampUtils = new TimestampUtils(!queryExecutor.getIntegerDateTimes(), timeZoneProvider);
        PGobject object = (PGobject) cSt.getResultSet().getObject(1);
        return new SQLDataReader().read(object.getValue(), Company.class, baseConnection, timestampUtils);
    }
 
    private static PGobject makePgObject(Connection connection, SQLData sqlData) throws SQLException {
        PgSQLOutput pgSQLOutput = new PgSQLOutput(connection.unwrap(BaseConnection.class));
        sqlData.writeSQL(pgSQLOutput);
        PGobject pGobject = new PGobject();
        pGobject.setType(sqlData.getSQLTypeName());
        pGobject.setValue(pgSQLOutput.getResult());
        return pGobject;
    }
 
    private static Company makeCompany() {
        return new Company(1L, "Evil Corp", new Department(1L, "IT", new Employee(1L, "Ivan", BigDecimal.valueOf(20))));
    }
}

В результате то, что мы отправили, совпадает с тем, что мы получили. При этом наш объект имеет несколько уровней вложенности.

Альтернативы

Альтернатив, на которые я натыкался при изучении вопроса (сам с ними не работал):

  • Драйвер pgjdbc‑ng умеет работать с UDT. Однако его развитие остановилось, последний релиз был в 2021 году.

  • Фреймворк sproc, который тоже работает с UDT.

При выборе альтернатив стоит помнить, что рано или поздно в официальном драйвере появится полная поддержка SQLData, и альтернатив, и своих решений писать не придётся.

Заключение

Кажется, в статье удалось исполнить всё задуманное:

  • разобрать что из себя представляет пользовательские типы

  • познакомить читателя с частью спецификации JDBC, которая позволяет с ними работать

  • посмотреть, как с ними работать с драйвером PostgreSQL

  • опробовать чтение UDT из pull‑реквеста

  • сделать свой собственный SQLOutput для записи UDT

Благодарю за время, уделенное чтению статьи.

Если тема вызовет интерес, в следующей части расскажу о поддержке на базе процедур SQLInput/SQLOutput с массивами объектов.

Пример массивов объектов
create type public.employee as (
  id   bigint,  --id
  name varchar, --имя
  age  numeric,  --возраст
);
 
create type public.department as (
  id   bigint,  --id
  name varchar, -- название департамента
  staff public.employee[] -- сотрудники департамента
);
 
create type public.company as (
  id   bigint,  --id
  name varchar, -- название департамента
  departments public.department[] -- департаменты компании
);
 
 
CREATE OR REPLACE FUNCTION public.test_company_in_out( in in_company public.company[] )
returns public.company[]
LANGUAGE plpgsql
AS $$
BEGIN
      return in_company;
END;
$$;

Комментарии (0)