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