Всем привет! Сегодня мы расскажем о полезной возможности СУБД Ред База Данных — создании внешних подпрограмм, то есть процедур, функций и триггеров на языке Java. Например, язык PSQL не позволяет работать с объектами файловой системы или сети, а Java запросто решает такие задачи и существенно расширяет возможности встроенного языка.
Настройка Java
СУБД Ред База Данных для работы с jar-файлами использует движок FBJava, который позволяет загружать и запускать подпрограммы на платформе Java не ниже версии 1.8.
Давайте установим более новую версию — 11.
sudo dnf install java-11-openjdk-deve
Теперь настроим параметры взаимодействия сервера СУБД Ред База Данных с виртуальной машиной Java с помощью конфигурационного файла plugins.conf, следующим образом:
-
Раскомментируем секции:
-
Plugin=JAVA
-
и Config=JAVA_config в /opt/RedDatabase.
-
-
А также установим путь к Java в JavaHome.
-
JavaHome = /usr/lib/jvm/jre-11
-
Далее в файле конфигурации fbjava.yaml зададим путь к каталогу, где будут располагаться jar-файлы с методами, реализующие внешние подпрограммы. Можно указать для всех баз данных одновременно:
classpath: - $(root)/jar/data/*.jar
Или для каждой отдельно:
databases: ".*/employee.fdb": classpath: - /home/rdb/jars/*.jar # путь до jar-файлов
Объявление внешних подпрограмм
Для начала рассмотрим общие правила объявления внешних подпрограмм, реализованных с помощью Java-методов.
Для всех внешних объектов общим является обязательное указание места расположения Java-метода во внешнем модуле с помощью предложения EXTERNAL NAME.
Аргументом этого предложения является строка, в которой через разделитель указано имя внешнего модуля, имя программы внутри модуля и определенная пользователем информация. В предложении ENGINE указывается имя движка для обработки подключения внешних модулей, в нашем случае это JAVA:
EXTERNAL NAME '<полное имя класса>.<имя static метода>!( [<Java тип> [, <Java тип>...]])' [!<определяемая пользователем информация>] ENGINE JAVA
Синтаксис операторов создания, изменения и пересоздания внешних подпрограмм, написанных на Java:
Создание/изменение/пересоздание триггера:
{CREATE [ OR ALTER ] | RECREATE | ALTER} TRIGGER <имя триггера> { <объявление табличного триггера> | <объявление табличного триггера в стандарте SQL-2003> | <объявление триггера базы данных> | <объявление DDL триггера> } EXTERNAL NAME '<полное имя класса>.<имя static метода>!( [<Java тип> [, <Java тип>...]])' [!<определяемая пользователем информация>] ENGINE JAVA
Создание/изменение/пересоздание процедуры:
{CREATE [ OR ALTER ] | RECREATE | ALTER} PROCEDURE <имя хранимой процедуры> [(<входной параметр> [, <входной параметр> ...])] [RETURNS (<выходной параметр> [, <выходной параметр> ...])] EXTERNAL NAME '<полное имя класса>.<имя static метода>!( [<Java тип> [, <Java тип>...]])' [!<определяемая пользователем информация>] ENGINE JAVA
Создание/изменение/пересоздание функции:
{CREATE [ OR ALTER ] | RECREATE | ALTER} FUNCTION <имя функции> [(<входной параметр> [, <входной параметр> ...])] RETURNS (<тип данных>) EXTERNAL NAME '<полное имя класса>.<имя static метода>!( [<Java тип> [, <Java тип>...]])' [!<определяемая пользователем информация>] ENGINE JAVA
Существует два основных способа сопоставить функции и процедуры базы данных с методами Java. Это фиксированные и обобщенные сигнатуры.
Фиксированные сигнатуры означают, что для каждого параметра программы (функции, процедуры) базы данных должен быть параметр в Java-методе.
А вот обобщенные сигнатуры не имеют параметров. Java-код с помощью интерфейса контекстов может получить все параметры или значения полей, переданные программой базы данных.
Триггеры могут отображаться только с помощью обобщенных сигнатур.
Соответствие типов Java типам SQL
|
Тип SQL |
Тип Java |
|
NUMERIC |
java.math.BigDecimal |
|
DECIMAL |
java.math.BigDecimal |
|
SMALLINT |
java.math.BigDecimal |
|
INTEGER |
java.math.BigDecimal |
|
BIGINT |
java.math.BigDecimal |
|
FLOAT |
java.lang.Float |
|
DOUBLE PRECISION |
java.lang.Double |
|
CHAR |
java.lang.String |
|
VARCHAR |
java.lang.String |
|
BLOB |
java.sql.Blob |
|
DATE |
java.sql.Date |
|
TIME |
java.sql.Time |
|
TIMESTAMP |
java.sql.Timestamp |
|
BOOLEAN |
java.lang.Boolean |
Интерфейсы доступа к контексту подпрограмм
Рассмотрим доступ к контекстам подпрограмм, которые позволяют получить информацию о них. Для этого реализованы специальные интерфейсы, представленные в таблице ниже.
|
Интерфейс |
Описание |
|
Context |
Отражает информацию о подпрограммах базы данных. |
|
CallableRoutineContext |
Отражает контекст внешних процедур и функций. Наследуется от Context. |
|
FunctionContext |
Отражает контекст внешних функций. Наследуется от CallableRoutineContext. |
|
ProcedureContext |
Отражает контекст внешних процедур. Наследуется от CallableRoutineContext. |
|
TriggerContext |
Отражает контекст внешних триггеров. Наследуется от Context. |
|
ExternalResultSet |
Представляет ResultSet для внешних хранимых селективных процедур. |
|
Values |
Позволяет получать или устанавливать значения параметров у функций или процедур и полей у триггеров. |
|
ValuesMetadata |
Позволяет получать значения метаданных параметров функций или процедур и полей триггеров. Наследуется от java.sql.ParameterMetaData. |
|
TriggerContext.Action |
Перечисление (enum) для операций, вызвавших триггер. Наследуется от java.lang.Enum. |
|
TriggerContext.Type |
Перечисление (enum) для типа триггера. Наследуется от java.lang.Enum. |
Пример работы с внешними хранимыми процедурами, функциями и триггерами
Напишем несколько внешних подпрограмм на языке Java в среде разработки IntelliJ IDEA.
Создаем новый проект. Имя и расположение проекта выбираем любое, язык — Java, сборочная система — Maven, JDK — 11 версии. В Advanced Setting укажем группу (GroupId) — ru.reddatabase.external и ArtifactId — ExternalJava. Нажимаем создать проект.

Добавим в файл конфигурации pom.xml библиотеку, в которой указаны интерфейсы для доступа к контекстам подпрограмм.
<dependencies> <dependency> <groupId>ru.reddatabase</groupId> <artifactId>fbjava</artifactId> <version>1.1.18</version> <scope>system</scope> <systemPath>/opt/RedDatabase/jar/fbjava-1.1.18.jar</systemPath> </dependency> </dependencies>
После этого обновим проект.

Работа с триггерами
Поработаем с триггерами. Создадим новый класс для их реализации и назовем его Triggers.


B нем создадим публичный статический метод, который будет использоваться, как тело внешнего триггера. В нём будет реализовано получение контекста триггера, старых и новых значений записи и изменение полей записи: числовые умножим на определенный коэффициент, к строковым добавим информацию с длиной вставляемой строки, а в последнее поле добавим информацию с метаданными триггера.
public static void saveContextTrigger() throws SQLException { TriggerContext context = TriggerContext.get(); String action = context.getAction().toString(); ValuesMetadata fieldsM = context.getFieldsMetadata(); Values newValues = context.getNewValues(); Values oldValues = context.getOldValues(); String tableName = context.getTableName(); String type = context.getType().toString(); String nameInfo = context.getNameInfo(); String objectName = context.getObjectName(); String info = ""; info = info + "Действие: " + action + "\n"; info = info + "Таблица: " + tableName + "\n"; info = info + "Тип: " + type + "\n"; info = info + "Сохраненные метаданные: " + nameInfo + "\n"; info = info + "Имя объекта: " + objectName + "\n"; if (fieldsM != null) { int count = fieldsM.getParameterCount(); for (int i = 1; i <= count - 1; ++i) { info = info + "Имя поля: " + fieldsM.getName(i) + "\n"; info = info + "Класс поля: " + fieldsM.getJavaClass(i).toString() + "\n"; info = info + "Старое значение: " + (oldValues == null || oldValues.getObject(i) == null ? null : oldValues.getObject(i).toString()) + "\n"; info = info + "Новое значение: " + (newValues == null || newValues.getObject(i) == null ? null : newValues.getObject(i).toString()) + "\n"; if (newValues != null) { Object obj = newValues.getObject(i); if (obj == null) newValues.setObject(i, null); else if (obj instanceof java.math.BigDecimal) { BigDecimal val = (BigDecimal) obj; newValues.setObject(i, new BigDecimal(val.intValue() * -1)); } else if (obj instanceof java.lang.Double) newValues.setObject(i, (Double) obj * -2.0); else if (obj instanceof java.lang.Float) newValues.setObject(i, (Float) obj * -3.0); else if (obj instanceof java.lang.Boolean) newValues.setObject(i, !((Boolean) obj)); else if (obj instanceof java.sql.Date) newValues.setObject(i, java.sql.Date.valueOf(LocalDateTime.now().toLocalDate())); else if (obj instanceof java.sql.Time) newValues.setObject(i, java.sql.Time.valueOf(LocalDateTime.now().toLocalTime())); else if (obj instanceof java.sql.Timestamp) newValues.setObject(i, java.sql.Timestamp.valueOf(LocalDateTime.now())); else newValues.setObject(i, obj.toString() + ". Длина строки: " + obj.toString().length()); info = info + "Новое значение установленное внешним триггером: " + (newValues.getObject(i) == null ? null : newValues.getObject(i).toString()) + "\n"; } if (newValues != null) newValues.setObject(count, info); } } }
В этом примере мы получаем контекст триггера через интерфейс TriggerContext:
TriggerContext context = TriggerContext.get();
Информацию о событии, которое вызвало триггер, метаданные записи и объектах записи получаем вызовом следующих методов:
String action = context.getAction().toString(); ValuesMetadata fieldsM = context.getFieldsMetadata(); String tableName = context.getTableName(); String type = context.getType().toString(); Values newValues = context.getNewValues(); Values oldValues = context.getOldValues(); String nameInfo = context.getNameInfo(); String objectName = context.getObjectName();
Через объекты oldValues и newValues получаем старые значения записи и устанавливаем новые:
if (newValues != null) {}
Object obj = newValues.getObject — Получаем значение столбца, которое было добавлено в результате INSERT или UPDATE запроса;
newValues.setObject — Устанавливаем новое значение столбца.
После этого соберем проект.

Далее откроем терминал и скопируем собранную Jar-библиотеку в каталог data (/opt/RedDatabase/jar/data) следующей командой:
sudo cp ~/reddatabase/ExternalJava/target/ExternalJava-1.0-SNAPSHOT.jar /opt/RedDatabase/jar/data
Перезапустим сервер базы данных:
sudo service firebird restart
И теперь задействуем написанный код. Подключимся к тестовой базе данных, например к Employee, и создадим новую таблицу:
CREATE TABLE TRIGGER_CONTEXT ( ID BIGINT NOT NULL, F_VCHAR VARCHAR(100) NOT NULL, F_DOUBLE DOUBLE PRECISION NOT NULL, F_TIMESTAMP TIMESTAMP NOT NULL, INFO VARCHAR(16384), CONSTRAINT PK_TRIGGER_CONTEXT PRIMARY KEY (ID) );
Добавим внешний триггер. Мы хотим, чтобы триггер изменял данные до вставки в таблицу, поэтому создадим его с ключевым словом BEFORE и выполним скрипт.
CREATE OR ALTER TRIGGER EXTERNAL_TRIGGER_SAVE_CONTEXT BEFORE DELETE OR INSERT OR UPDATE ON TRIGGER_CONTEXT EXTERNAL NAME 'ru.reddatabase.external.Triggers.saveContextTrigger()' ENGINE JAVA;
Теперь проверим его работу. Откроем таблицу и вставим строку:
INSERT INTO TRIGGER_CONTEXT(ID, F_VCHAR, F_DOUBLE, F_TIMESTAMP) VALUES (1, 'Уж небо осенью дышало...', 73.522, '01-01-2012 12:00');
Добавленные значения изменились.

А в поле INFO добавилась запись, сформированная внутри внешнего триггера:

При необходимости можно вывести информацию в файл. Создадим метод, который будет печатать информацию в файл:
public static void saveContextToFileTrigger() throws SQLException { TriggerContext context = TriggerContext.get(); String action = context.getAction().toString(); ValuesMetadata fieldsM = context.getFieldsMetadata(); Values newValues = context.getNewValues(); Values oldValues = context.getOldValues(); String tableName = context.getTableName(); String type = context.getType().toString(); String nameInfo = context.getNameInfo(); String objectName = context.getObjectName(); String info = ""; info = info + "Действие: " + action + "\n"; info = info + "Таблица: " + tableName + "\n"; info = info + "Тип: " + type + "\n"; info = info + "Сохраненные метаданные: " + nameInfo + "\n"; info = info + "Имя объекта: " + objectName + "\n"; if (fieldsM != null) { int count = fieldsM.getParameterCount(); for (int i = 1; i <= count - 1; ++i) { info = info + "Имя поля: " + fieldsM.getName(i) + "\n"; info = info + "Класс поля: " + fieldsM.getJavaClass(i).toString() + "\n"; info = info + "Старое значение: " + (oldValues == null || oldValues.getObject(i) == null ? null : oldValues.getObject(i).toString()) + "\n"; info = info + "Новое значение: " + (newValues == null || newValues.getObject(i) == null ? null : newValues.getObject(i).toString()) + "\n"; if (newValues != null) { PrintWriter writer = null; try { writer = new PrintWriter("/tmp/external_trigger.txt"); } catch (FileNotFoundException e) { throw new RuntimeException(e); } writer.println(info); writer.close(); } } } }
В этом примере мы получаем значения триггера через интерфейсы и выводим их в файл через объект PrintWriter:
if (newValues != null) {}
Проверим работу метода. Создадим внешний триггер:
CREATE OR ALTER TRIGGER EXTERNAL_TRIGGER_TO_FILE BEFORE DELETE OR INSERT OR UPDATE ON TRIGGER_CONTEXT EXTERNAL NAME 'ru.reddatabase.external.Triggers.saveContextToFileTrigger()' ENGINE JAVA;
И выполним следующий запрос:
INSERT INTO TRIGGER_CONTEXT(ID, F_VCHAR, F_DOUBLE, F_TIMESTAMP) VALUES (2, 'Я помню чудное...', 29.12, '07-13-1999 13:51');
По пути, указанному в Java-коде, создался текстовый файл.

Работа с внешними процедурами
Процедура более сложная конструкция, у нее могут быть входные и выходные параметры. В примере мы будем во входном параметре передавать имя Java-свойства, значение которого хотим получить в выходном параметре.
Создадим новый класс Procedures:
public static ExternalResultSet getPropertyProcedure(final String property, final String[] result) { return new ExternalResultSet() { boolean first = true; public boolean fetch() throws Exception { if (this.first) { result[0] = System.getProperty(property.trim()); this.first = false; return true; } else { return false; } } }; }
Реализация тела внешней процедуры имеет фиксированный вид из-за указанных параметров в скобках. Входные параметры перечислены вначале, выходные — в конце и обязательно объявляются как массивы.
final String property, final String[] result.
Метод возвращает объект ExternalResultSet, реализующий доступ к получаемому набору данных. У него необходимо реализовать единственный метод fetch(), перемещающий набор к следующей записи. Метод fetch() будет вызываться до тех пор, пока возвращает значение true. Это позволяет процедуре возвращать больше одной записи.
Проверим работу. Соберем проект, скопируем собранную Jar-библиотеку в каталог data (/opt/RedDatabase/jar/data):
sudo cp ~/reddatabase/ExternalJava/target/ExternalJava-1.0-SNAPSHOT.jar /opt/RedDatabase/jar/data
И перезапустим сервер:
sudo service firebird restart
Подключимся к тестовой базе данных и создадим внешнюю процедуру:
CREATE OR ALTER PROCEDURE EXTERNAL_PROCEDURE_PROPERTY(PROPERTY CHAR(50)) RETURNS (RESULT CHAR(150)) EXTERNAL NAME 'ru.reddatabase.external.Procedures.getPropertyProcedure(String, String[])' ENGINE JAVA;
Узнаем значение переменной среды JAVA_HOME, выполнив процедуру:
SELECT * FROM EXTERNAL_PROCEDURE_PROPERTY("java.home");

А теперь напишем процедуру, которая выведет список идентификаторов подключений к базе.
public static ExternalResultSet getAttachmentsProcedure(final int[] attId, final String[] attName) { try { return new ExternalResultSet() { Connection con = DriverManager.getConnection("jdbc:default:connection:"); PreparedStatement statement = con.prepareStatement("select mon$attachment_id, mon$attachment_name from mon$attachments"); ResultSet resultSet = statement.executeQuery(); public boolean fetch() throws Exception { if (resultSet.next()) { attId[0] = resultSet.getInt(1); attName[0] = resultSet.getString(2); return true; } else { resultSet.close(); statement.close(); con.close(); return false; } } }; } catch (SQLException e) { throw new RuntimeException(e); } }
Этот пример похож на предыдущий, также имеет фиксированный вид, но содержит выходные параметры (final int[] attId, final String[] attName).
В этом примере мы создали подключение в той же транзакции с помощью метода DriverManager.getConnection() с URL вида jdbc:default:connection:. Суть в том, что в этой же транзакции мы выполнили отдельный запрос, который вернул набор данных. Для получения доступа из java-метода к базе данных с отдельной транзакцией необходимо использовать URL вида jdbc:new:connection:.
Проверим работу Java-метода. Пересоберем библиотеку:
sudo cp ~/reddatabase/ExternalJava/target/ExternalJava-1.0-SNAPSHOT.jar /opt/RedDatabase/jar/data
И перезапустим сервер:
sudo service firebird restart
Теперь подключимся к тестовой базе данных и создадим внешнюю процедуру:
CREATE OR ALTER PROCEDURE EXTERNAL_PROCEDURE_ATTACHMENTS() RETURNS (ID INTEGER, NAME CHAR(150)) EXTERNAL NAME 'ru.reddatabase.external.Procedures.getAttachmentsProcedure(int[], String[])' ENGINE JAVA;
Проверим ее работу:
SELECT * FROM EXTERNAL_PROCEDURE_ATTACHMENTS;

Рассмотрим пример сопоставления через обобщенные сигнатуры. Создадим метод, в котором будем вызывать процедуру получения случайного числа, максимальное значение которого будет передано во входном параметре:
public static ExternalResultSet randomAdditionProcedure() { try { ProcedureContext context = ProcedureContext.get(); final ValuesMetadata inputMetadata = context.getInputMetadata(); final ValuesMetadata outputMetadata = context.getOutputMetadata(); final Values input = context.getInputValues(); final Values output = context.getOutputValues(); return new ExternalResultSet() { Connection conDefault = DriverManager.getConnection("jdbc:default:connection:"); PreparedStatement attachments = conDefault.prepareStatement("select mon$attachment_id, mon$attachment_name from mon$attachments"); Connection conNew = DriverManager.getConnection("jdbc:new:connection:"); PreparedStatement rand = conNew.prepareStatement( String.format("select cast(trunc(rand() * %d) as integer) from rdb$database", ((BigDecimal) input.getObject(1)).intValue())); ResultSet rsAttachments = attachments.executeQuery(); public boolean fetch() throws Exception { if (rsAttachments.next()) { ResultSet rsRand = rand.executeQuery(); rsRand.next(); output.setObject(1, rsAttachments.getBigDecimal(1)); output.setObject(2, rsAttachments.getString(2)); output.setObject(3, String.format( "Count of input parameters: '%d', " + "Count of output parameters: '%d', " + "Rand result: '%s', " + "Attachment ID: '%s', " + "Attachment name: '%s'", inputMetadata.getParameterCount(), outputMetadata.getParameterCount(), rsRand.getBigDecimal(1).toString(), rsAttachments.getBigDecimal(1).toString(), rsAttachments.getString(2))); rsRand.close(); return true; } else { rsAttachments.close(); attachments.close(); rand.close(); conDefault.close(); conNew.close(); return false; } } }; } catch (SQLException e) { throw new RuntimeException(e); } }
В этом методе доступ к входным и выходным параметрам осуществляется через интерфейс ProcedureContext.
ProcedureContext context = ProcedureContext.get();
Информацию о параметрах получаем через метаданные.
final ValuesMetadata inputMetadata = context.getInputMetadata(); final ValuesMetadata outputMetadata = context.getOutputMetadata();
А значения получаем с помощью getInputValues() и getOutputValues(). В примере вызывается процедура в автономной транзакции, которая создается открытием подключения с URL jdbc:new:connection:.
Connection conNew = DriverManager.getConnection("jdbc:new:connection:"); PreparedStatement rand = conNew.prepareStatement( String.format("select cast(trunc(rand() * %d) as integer) from rdb$database", ((BigDecimal) input.getObject(1)).intValue()));
Метод fetch() возвращает true до тех пор, пока из набора данных rsAttachments не будут получены все строки. Значение, возвращенное функцией rand() добавляем в выходные значения.
Проверим работу процедуры:
CREATE OR ALTER PROCEDURE EXTERNAL_PROCEDURE_ADD_RAND("RANGE" INTEGER) RETURNS (ID INTEGER, NAME CHAR(150), RESULT VARCHAR(500)) EXTERNAL NAME 'ru.reddatabase.external.Procedures.randomAdditionProcedure()' ENGINE JAVA;
Внешние функции
Рассмотрим внешние функции. Они похожи на процедуры, но возвращают только одно значение.
Напишем внешнюю функцию, которая вычисляет факториал числа, переданного в параметре.
Создадим класс Functions, в котором опишем метод, вычисляющий факториал:
public static long factorialFunction(int x) { if ((x == 0) || (x == 1)) { return 1; } else { long fact = 1; for (int i = 1; i <= x; i++) { fact = fact * i; } return fact; } }
В этом примере только 1 входной параметр X. Возвращаемый параметр не требует объявления массива и возвращается вызовом return fact.
Соберем проект, скопируем собранную Jar-библиотеку в каталог /opt/RedDatabase/jar/data:
sudo cp ~/reddatabase/ExternalJava/target/ExternalJava-1.0-SNAPSHOT.jar /opt/RedDatabase/jar/data
И перезапустим сервер:
sudo service firebird restart
Подключимся к тестовой БД и создадим внешнюю функцию.
CREATE OR ALTER FUNCTION EXTERNAL_FUNCTION_FACTORIAL(X INTEGER) RETURNS BIGINT EXTERNAL NAME 'ru.reddatabase.external.Functions.factorialFunction(int)' ENGINE JAVA;
Вычислим факториал 7:
SELECT EXTERNAL_FUNCTION_FACTORIAL(7) FROM RDB$DATABASE;

При необходимости можно скачать файл из интернета.
Создадим метод в классе Functions, который позволит скачать файл и вернуть его в формате блоб.
public static Blob downloadFileFunction(String fileURL) { SerialBlob blob = null; try (BufferedInputStream in = new BufferedInputStream(new URL(fileURL).openStream())) { byte[] dataBuffer = new byte[1024]; int bytesRead; ByteArrayOutputStream baos = new ByteArrayOutputStream(); while ((bytesRead = in.read(dataBuffer, 0, 1024)) != -1) { baos.write(dataBuffer, 0, bytesRead); } blob = new SerialBlob(baos.toByteArray()); } catch (IOException | SQLException e) { throw new RuntimeException(e); } return blob; }
Создаем блоб SerialBlob.
blob = new SerialBlob(baos.toByteArray());
И возвращаем его как результат.
Собираем проект и копируем собранную Jar-библиотеку в каталог /opt/RedDatabase/jar/data:
sudo cp ~/reddatabase/ExternalJava/target/ExternalJava-1.0-SNAPSHOT.jar /opt/RedDatabase/jar/data
Перезапускаем сервер:
sudo service firebird restart
Подключимся к текстовой БД и создадим внешнюю функцию:
CREATE OR ALTER FUNCTION EXTERNAL_FUNCTION_DOWNLOAD_FILE(URL VARCHAR(255)) RETURNS BLOB EXTERNAL NAME 'ru.reddatabase.external.Functions.downloadFileFunction(String)' ENGINE JAVA;
Проверим работу функции и загрузим картинку в блоб:
SELECT EXTERNAL_FUNCTION_DOWNLOAD_FILE('https://reddatabase.ru/media/articles/%D1%80%D0%B0%D0%B7%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B0-10.jpg') FROM RDB$DATABASE;

Заключение
Сегодня мы познакомились с разработкой внешних хранимых процедур, функций и триггеров на Java для СУБД Ред Базы Данных!
ссылка на оригинал статьи https://habr.com/ru/company/redsoft/blog/699732/
Добавить комментарий