Экспериментируем с пользовательскими функциями JavaScript на Trino

Что такое Trino?

Trino  —  это быстрый дистрибутивный механизм SQL-запросов, предназначенный для аналитики больших данных. Он позволяет подключаться ко всем данным и запрашивать их в полном объеме без необходимости копировать их в унифицированную систему хранения.

Что такое пользовательская функция?

Пользовательская функция  —  это функция, определенная пользователем программы или среды в условиях, когда обычно предполагается, что функции встроены в программу или среду. Пользовательские функции обычно пишутся для нужд их создателя (Wikipedia).

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

Почему пользовательские функции JavaScript?

Согласно Statista, JavaScript является самым используемым языком программирования среди разработчиков всего мира.

Разработчики любят JavaScript, потому что он:

  • легко изучается благодаря огромному количеству учебных материалов;
  • обладает развитой и эффективной экосистемой, доступной через npm.

Краткое содержание данного руководства

Мы разработаем простую пользовательскую функцию, которая позволит выполнить функцию Javascript с помощью встроенного в JVM 11 скриптового движка Nashorn Javascript.

Что может пойти не так?

Безопасность

Обратите внимание, что движок JavaScript не является “песочницей” (то есть не накладывает ограничения в целях безопасности информации), позволяя выполнять произвольный Java-код и загружать любой класс на активном Java ClassLoader.

Например, этот код раскрывает все переменные окружения рабочего процесса Trino (возможно, раскрывая все учетные данные озера или базы данных).

javascript_eval('function udf() { 
return java.lang.System.getenv();
}')

Производительность

Очевидно, что простые функции JavaScript потенциально намного быстрее, если выражены в стандартных SQL-функциях Trino, которые могут быть переписаны оптимизатором Trino или даже перенесены в базовую систему хранения.

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

Приступим!

Примечание. Не используйте данный код в реальных условиях. В противном случае полагайтесь на свой страх и риск.

Код коннектора доступен на Github.

Как выполнить Javascript на JVM?

Используя Nashorn ScriptEngine, можно оценить код Javascript и вызвать любые объявленные функции.

ScriptEngine engine = new ScriptEngineManager().getEngineByName("javascript");
engine.eval("function udf(a,b){ return a + b;}");
System.out.println(((Invocable) engine).invokeFunction("udf", new Object[]{1, 1}));

Этот код применит два аргумента: 1 и 1. Сложит их вместе в Javascript и вернет значение 2.0, выведенное с помощью собственной функции Java System.out.println.

Быстрая настройка нового коннектора Trino

Чтобы быстро настроить коннектор Trino, мы будем использовать этот архетип Maven, разработанный пользователем Jan Waś.

Обратите внимание на Nashorn. В качестве следующего шага можно заменить существующий код на движок GraalVM Javascript. Потенциально это также решит проблемы безопасности, упомянутые выше.

mvn archetype:generate \                                         
-DarchetypeGroupId=pl.net.was \
-DarchetypeArtifactId=trino-plugin-archetype \
-DarchetypeVersion=1.5-SNAPSHOT \
-DgroupId=io.innover.trino.plugin.javascript \
-DartifactId=trino-javascript \
-DclassPrefix=Javascript \
-DconnectorNAme=trino-javascript \
-DgithubRepo=mdesmet/trino-javascript

Создание пользовательской функции

Некоторые базы данных позволяют создавать UDF-функции с помощью подобных утверждений:

CREATE FUNCTION dbo.CtoF(Celsius FLOAT)
RETURNS FLOAT
RETURN (Celsius * 1.8) + 32

Можно было бы попробовать реализовать это в Trino, но это выходит за рамки того, что в настоящее время поддерживается в коннекторе Trino.

Vargargs или нет?

Нам нужно получить нечто подобное:

SELECT javascript_eval('function udf(a, b) { return a + b; }', 1, 1)

Этот API очень похож на существующую функцию format, т. е. позволяет передавать произвольное количество переменных аргументов различных типов.

Обратимся к коду на Github:

public final class FormatFunction
extends SqlScalarFunction
{
private FormatFunction()
{
super(FunctionMetadata.scalarBuilder()
.signature(Signature.builder()
.name(NAME)
.variadicTypeParameter("T", "row")
.argumentType(VARCHAR.getTypeSignature())
.argumentType(new TypeSignature("T"))
.returnType(VARCHAR.getTypeSignature())
.build())
.hidden()
.description("formats the input arguments using a format string")
.build());
}

Обратите внимание на variadicTypeParameter, предоставляющий varargs для функций.

В настоящее время эта опция не поддерживается коннекторами.

2022-05-15T12:01:16.547+0000 INFO Регистрация функций из JavascriptPlugin
java.lang.IllegalArgumentException: Класс [io.innover.trino.plugin.javascript.JavascriptEvalFunction] не имеет ни одного метода, аннотированного с помощью @ScalarFunction и @ScalarOperator

Создание другого API

Как насчет использования типа ROW? Тип row, как следует из его названия, похож на строку базы данных. Каждый столбец этой строки может быть разного типа. Это нам и нужно!

SELECT javascript_eval('function udf(a, b) { return a + b; }', ROW(1, 1))

Будем также придерживаться аннотаций, как в примерах пользовательских UDF в документации Trino.

@ScalarFunction("javascript_eval")
@TypeParameter("V")
@SqlType(StandardTypes.VARCHAR)
public static Slice evalWithArgs(
@TypeParameter("V") RowType rowType,
@SqlType(StandardTypes.VARCHAR) Slice slice,
@SqlNullable @SqlType("V") Block row)
{
Object[] args = mapArgs(rowType, row);
return executeJavascript(slice, args);
}

Выше было рассмотрено выполнение JavaScript, но как получить аргументы с элементами ROW?

private static final Set<Type> SUPPORTED_TYPES = ImmutableSet.of(UNKNOWN, TINYINT, SMALLINT, INTEGER, BIGINT);

private static Object[] mapArgs(RowType rowType, Block block)
{
    int positionCount = block.getPositionCount();
    List<Type> types = rowType.getTypeParameters();
    Object[] output = new Object[positionCount];
    for (int position = 0; position < positionCount; position++) {
        Type type = types.get(0);
        if (!SUPPORTED_TYPES.contains(type)) {
            throw new TrinoException(NOT_SUPPORTED, "Аргумент типа " + type.getDisplayName() + " не поддерживается в javascript_eval invocation.");
        }

        if (type.equals(UNKNOWN)) {
            output[position] = null;
        }
        if (type.equals(TINYINT) || type.equals(SMALLINT) || type.equals(INTEGER) || type.equals(BIGINT)) {
            output[position] = type.getLong(block, position);
        }
        if (type.equals(REAL)) {
            output[position] = intBitsToFloat(toIntExact(type.getLong(block, position)));
        }
        if (type.equals(DOUBLE)) {
            output[position] = type.getDouble(block, position);
        }
    }
    return output;
}

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

Работает ли это?

Добавим модульный тест, чтобы проверить, работает ли это.

@Test
public void javascriptEval()
{
assertFunction("javascript_eval('function udf(a,b){ return a + b;}', ROW(1, 1))", VARCHAR, "2.0");
}

Тест завершен успешно!

Что еще можно сделать?

  • Переход на GraalVM Javascript для повышения безопасности и производительности.
  • Поддержка большего количества типов ввода (например, вложенные объекты).
  • Поддержка большего количества типов вывода.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Michiel De Smet: Experimenting with Javascript User Defined Functions on Trino

Предыдущая статьяОсновы CI/CD
Следующая статьяBigQuery теперь поддерживает Query Queues