
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;
$$;