Автор: Юра

  • Editor API

    Вводная

    Использовать API Connector в DataLens можно для множества вещей, на этой странице разберем основные подходы, как делать базовые запросы через API и разберем интересные примеры использования.

    Основные шаги подключения API

    Простое видео, как сделать запрос к API внутри DataLens

    То есть, основные шаги

    • Создать API или получить информацию по адресу и формату работы с некоторым API
    • Создать подключение к API
    • Создать чарт Editor
    • Написать и настроить код на Sources для интерактивной работы с API
    • Обработать результат на Prepare

    Пример: простой чат внутри DataLens

    Python Код для API

    import psycopg2
    import os
    import csv
    import io
    
    def handler(event, context):
        params = event.get('multiValueQueryStringParameters', {})
        chat_id   = params.get('chat_id',   [None])[0]
        message   = params.get('message',   [None])[0]
        author    = params.get('author',    [None])[0]
        message_key = params.get('message_key', [None])[0]
    
        if not chat_id:
            return {
                'statusCode': 400,
                'body': "Ошибка: параметр 'chat_id' является обязательным.",
                'headers': {'Content-Type': 'text/csv; charset=UTF-8'},
            }
    
        pg_pass = os.environ.get('PG_PASS')
        try:
            conn = psycopg2.connect(
                user="user1",
                password=pg_pass,
                host="rc1a-***.mdb.yandexcloud.net",
                port="6432",
                database="db1"
            )
            cur = conn.cursor()
    
            # 1. Обработка message_key: проверка и вставка
            if message_key is not None:
                cur.execute(
                    "SELECT 1 FROM chat_messages WHERE chat_id = %s AND message_key = %s",
                    (chat_id, message_key)
                )
                if not cur.fetchone():
                    # ключ не найден — вставляем полную строку
                    cur.execute(
                        "INSERT INTO chat_messages (chat_id, author, message, message_key, dttm) "
                        "VALUES (%s, %s, %s, %s, NOW())",
                        (chat_id, author, message, message_key)
                    )
                    conn.commit()
    
            # 2. Обычная логика добавления сообщения (если нет message_key)
            elif message and author:
                stop_words = ['политика', 'религия', 'другие чувствительные темы']
                if not any(w in message.lower() for w in stop_words):
                    cur.execute(
                        "SELECT 1 FROM chat_messages "
                        "WHERE author = %s AND message = %s AND chat_id = %s "
                        "AND dttm > NOW() - INTERVAL '1 minute'",
                        (author, message, chat_id)
                    )
                    if not cur.fetchone():
                        cur.execute(
                            "INSERT INTO chat_messages (chat_id, author, message, dttm) "
                            "VALUES (%s, %s, %s, NOW())",
                            (chat_id, author, message)
                        )
                        conn.commit()
    
            # 3. Получение последних 30 сообщений
            cur.execute("""
                SELECT author, message, dttm
                FROM (
                    SELECT author, message, dttm
                    FROM chat_messages
                    WHERE chat_id = %s
                    ORDER BY dttm DESC
                    LIMIT 30
                ) AS last_30
                ORDER BY dttm ASC;
            """, (chat_id,))
            rows = cur.fetchall()
    
            output = io.StringIO()
            w = csv.writer(output)
            w.writerow(['author', 'message', 'dttm'])
            if not rows:
                w.writerow(['-', '-', '-'])
            else:
                for r in rows:
                    w.writerow([r[0], r[1], r[2].isoformat()])
    
            return {
                'statusCode': 200,
                'body': output.getvalue(),
                'headers': {'Content-Type': 'text/csv; charset=UTF-8'},
            }
    
        except psycopg2.Error as e:
            return {
                'statusCode': 500,
                'body': f"Ошибка базы данных: {e}",
                'headers': {'Content-Type': 'text/csv; charset=UTF-8'},
            }
        finally:
            if 'cur' in locals():
                cur.close()
            if 'conn' in locals():
                conn.close()
    

    API Editor Chart

    var params = Editor.getParams();
    
    var author = params.user[0] != '' ? params.user[0]:Editor.getUserLogin();
    var message = params.message_to_send[0];
    var chat_id = params.chat_id[0];
    
    var message_key = author + params.send_dttm[0];
    
    if (message != '') {
    module.exports = {
        'myApiDataSource': {
            apiConnectionId: 'mu751arjzpn88',
            path: encodeURI(`?chat_id=${chat_id}&author=${author}&message=${message}&message_key=${message_key}`),
            method: 'GET'
            
            
        }
    }
    }
    else {
        module.exports = {
        'myApiDataSource': {
            apiConnectionId: 'mu751arjzpn88',
            path: `?chat_id=${chat_id}`,
            method: 'GET'
            
        }
    }
    }
    Editor.updateParams({"send_it":[""]});
    
    function print(mess) {
      console.log(mess);
    }
    
    
    function parseCsvToObjects(csvString) {
      const lines = csvString.trim().split('\n');
    
      // Функция, которая разбивает строку на значения с учётом кавычек
      function parseCsvLine(line) {
        const result = [];
        let current = '';
        let inQuotes = false;
    
        for (let i = 0; i < line.length; i++) {
          const char = line[i];
    
          if (char === '"') {
            // Если текущий символ кавычка, проверяем следующий чтобы определить если это экранирование
            if (inQuotes && line[i + 1] === '"') {
              // Экранированная кавычка двойным двойным
              current += '"';
              i++; // пропускаем следующий символ
            } else {
              inQuotes = !inQuotes; // переключаем состояние в/из кавычек
            }
          } else if (char === ',' && !inQuotes) {
            // Разделитель поля только если не внутри кавычек
            result.push(current);
            current = '';
          } else {
            current += char;
          }
        }
        result.push(current);
        return result;
      }
    
      const headers = parseCsvLine(lines[0]).map(h => h.trim());
    
      return lines.slice(1).map(line => {
        const values = parseCsvLine(line);
        const obj = {};
    
        headers.forEach((header, index) => {
          obj[header] = values[index] ? values[index].trim() : '';
        });
    
        return obj;
      });
    }
    
    
    var params = Editor.getParams();
    var current_user = params.user[0] != '' ? params.user[0]:Editor.getUserLogin();
    
    
    function csvToMarkdownChat(csvString) {
      const data = parseCsvToObjects(csvString);
      return data.map(({ author, message, dttm }) => {
        const isCurrentUser = author === current_user;
        const time = new Date(dttm).toLocaleTimeString([], { 
          hour: '2-digit', 
          minute: '2-digit',
          hour12: false // 24-часовой формат
        });
        
        // Иконка пользователя
        const userIcon = isCurrentUser ? '👤' : '👥';
        
        // Строка автора и времени
        const authorTimeLine = isCurrentUser 
          ? `> **${userIcon} Вы (${time})**` 
          : `**${userIcon} ${author} (${time})**`;
        
        // Сообщение с правильным выравниванием
        const messageLine = isCurrentUser 
          ? `{blue}(${message.replace(/\n/g, '\n> ')})` // Сохраняем переносы строк в цитате
          : message.replace(/\n/g, '\n'); // Обычное сообщение
        
        return `${authorTimeLine}\n${messageLine}`;
      }).join('\n\n');
    }
    
    
    
    const response = Editor.getLoadedData();
    print(response['myApiDataSource'].data.body.result);
    print(parseCsvToObjects(response['myApiDataSource'].data.body.result));
    const markdown = csvToMarkdownChat(response['myApiDataSource'].data.body.result);
    
    
    module.exports = {
        markdown
    };

    JS SELECTOR for message

    // Controls Tab
    
    var controls = [];
    var params = Editor.getParams();
    var message = params.message[0];
    
    var key = new Date().getTime() / 1000;
    
    if (message != '') {
    
        controls.push(
        {
            type: 'input',
            param: 'message',
            width:'85%',
            postUpdateOnChange: true
        })
            controls.push(
        {
            type: 'input',
            param: 'message_to_send',
            hidden:true
        })
        controls.push(
        {
            type: 'button',
            label: '💬',
            theme: 'action',
            updateOnChange: true,
            onClick: {
            action: 'setParams',
            args: {
                message_to_send:message,
                message: '',
                send_dttm:key
            }
            
        }
        }
        )
    } 
    else {
        controls.push(
        {
            type: 'input',
            param: 'message',
            width:'85%',
            updateOnChange: true
        })
         controls.push(
        {
            type: 'input',
            param: 'message_to_send',
            hidden:true
        })
    }
    
    module.exports = controls;
  • DataLens Editor

    Зачем?

    В данной статье будут по шагам освещены основные моменты, которые нужно знать, чтобы эффективно делать чарты в Editor в продукте DataLens. Основной способ донесения информации — короткие видеоролики

    Что такое Editor?

    Чарт или селектор Editor — это отдельный объект в DataLens, логика которого описывается JS-кодом.

    • Чарты Editor гибче Wizard, так как позволяет подключаться к нескольким источникам данных и отрисовывать любую требуемую визуализацию через HTML и SVG.
    • Селекторы Editor позволят вам реализовать кодозависимые поля выбора с уникальной логикой.

    Общая схема работы объекта приведена ниже, детально о вкладках и примерах их заполнения смотрите видео.

    Разбираем простой чарт Editor

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

    Примеры кода из видео

    // КОД ВКЛАДКИ SOURCES Простого чарта
    const {buildSource} = require('libs/dataset/v2');
    
    const params = Editor.getParams();
    const date_interval = params.date_interval[0];
    const limit = parseInt(params.limit[0]);
    const metrics_list = params.metrics_list;
    
    // создаем пустой массив для заполнения условий
    let where = [];
    // колонки для выбора
    let cols = [];
    // параметры в датасет
    let filled_params = []
    
    
    // Базовое заполнение фильтра по дате как есть
    // поле для фильтрации
    const date_dim = params.date_dimension[0];
    // парсим даты из интервала
    const {from:from_date,to:to_date} = Editor.resolveInterval(date_interval);
    // собираем объект для фильтра
    const dateFilter = {column: date_dim,
                            operation: 'BETWEEN',
                        values: [from_date, to_date]}
    // добавляем в массив where
    where.push(dateFilter)
    
    // добавляем колонку region_delivery
    cols.push('region_delivery');
    
    // добавляем колонки для выбора метрик
    cols.push(...metrics_list);
    
    
    module.exports = {
            dataset: buildSource({
                id: Editor.getId('dataset'),
                columns: cols,
                where: where,
                parameters:filled_params,
                limit:limit,
                order_by:[{'direction':'DESC','column':metrics_list[0]}]
            })
        }
    
    // КОД ВКЛАДКИ PREPARE Простого чарта
    // Импорт необходимых библиотек и получение параметров
    const Dataset = require('libs/dataset/v2'); // Библиотека для обработки данных
    const params = Editor.getParams(); // Получение параметров из редактора
    
    const loadedData = Editor.getLoadedData(); // Получаем загруженные данные
    const preparedData = Dataset.processData(loadedData, 'dataset', Editor); // Обрабатываем данные
    console.log(preparedData)
    
    // Получаем параметры для сводной таблицы
    const metrics_list = params.metrics_list; // Список метрик
    // Измерения для строк (до 4 измерений, пустые отфильтрованы)
    const dimensions = Object.keys(preparedData[0]).filter(key => !metrics_list.includes(key));
    
    // Формируем заголовки
    const head = [
        // Сначала добавляем измерения
        ...dimensions.map(dim => ({
            id: dim,
            name: dim,
            type: 'text'
        })),
        // Затем метрики с индикаторами
        ...metrics_list.map(metric => ({
            id: metric,
            name: metric,
    
        }))
    ];
    
    // Формируем строки
    const rows = preparedData.map(row => ({
        cells: [
            // Ячейки для измерений
            ...dimensions.map(dim => ({
                value: row[dim],
            })),
            // Ячейки для метрик с индикаторами
            ...metrics_list.map(metric => ({
                value: row[metric]
            }))
        ]
    }));
    
    module.exports = {head, rows};
    

    Как в Editor получить значения фильтров и параметров с дашборда

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

    Обработка параметров через встроенные функции объекта Editor

    Когда мы поняли, что всё, что есть на дэше, можно переиспользовать внутри Editor, надо понять, как это сделать оптимально, давайте разберем основные полезные методы объекта Editor

    // парсинг дат
    const {from:from_date,to:to_date} = ce_obj.resolveInterval(date_interval);
    // парсинг полей с операциями
    const {operation:suffx, value:vala} = Editor.resolveOperation(value);
    // парсинг относительных дат
    const val = Editor.resolveRelative(val);

    Посмотрите видео с детальным разъяснением

    Полный пример кода из видео

    // КОД ВКЛАДКИ PREPARE Обработки параметров и фильтров
    const {buildSource} = require('libs/dataset/v2');
    const {dateTimeParse,dateTime,addDays, addUnits,startOf} = require('@gravity-ui/date-utils');
    const FORMAT = 'YYYY-MM-DD'
    
    
    function arrayChecker(some) {
        if (Array.isArray(some)) {
            return some
        }
        else {
            return [some]
            }
    }
    
    function valArrayFromPrefix(arr) {
                let vals = [];
                arr.forEach((element) => {
                    const {operation:suffx, value:vala} = Editor.resolveOperation(element);
                     vals.push(vala);
                });
                return vals;
    }
    
    
    function getDateFilters(ce_obj,params) {
        let date_interval = params.date_interval[0];
        let date_dim = params.filter_date_dimension[0];
        let scale_name = params.date_scale[0];
            let scale_dict = {'day':'D','week':'W','month':'M'};
        
        const {from:from_date,to:to_date} = ce_obj.resolveInterval(date_interval);
        let right_date = to_date;
        let left_date = from_date;
        console.log(right_date,left_date);
        const dateTo = dateTimeParse(right_date).add(1,scale_name).startOf(scale_dict[scale_name]).add(-1,'day').format(FORMAT);
        const dateFrom = dateTimeParse(left_date).startOf(scale_dict[scale_name]).format(FORMAT);
    
        const dateFilter = {column: date_dim,
                                operation: 'BETWEEN',
                            values: [dateFrom, dateTo]}
        return dateFilter;
    }
    
    
        const params = Editor.getParams();
        // для удобства все параметры переносим в переменные без парамс
        const date_interval = params.date_interval[0];
        const limit = parseInt(params.row_limit[0]);
        const metrics_list = params.metrics_list;
        const params_to_send = params.params_to_send[0];
        const filter_fields = params.filter_fields_ids['0'].split('|');
        const filter_mass = params.mass_filter_fields_ids['0'].split('|');
    
    // массив для заполнения условий
    let where = [];
    // колонки для выбора
    let cols = [];
    // передавать параметры в датасет
    let filled_params = []
    
    // 1) Фильтр на даты - отдельная обработка
    let q = getDateFilters(Editor,params);
    where.push(q)
    
    // Фильтры и параметры дэша - все по циклу
    
    for (const [key, value] of Object.entries(params)) {
        // добавляем все параметры из переданных и не пустых
        if (params_to_send.includes(key)) {
          filled_params.push({id:key,value:value.toString()});
        };
    
        if (filter_fields.includes(key) && value != '') {
                let val, suff;
    
    
            if (value[0] && value[0].substr(0, 2) === '__') {
                const {operation:suffx, value:vala} = Editor.resolveOperation(value);
                val = value.length == 1 ? vala:valArrayFromPrefix(value);
                suff = suffx;
    
            } else {
                val = value;
                suff = 'IN';
            }
    
    
            if  (Editor.resolveRelative(val) != null) {
                  val = Editor.resolveRelative(val);
             }
            if  (Editor.resolveInterval(value) != null) {
                  val = [Editor.resolveInterval(value)['from'],Editor.resolveInterval(value)['to']];
                  suff = 'BETWEEN'
             }
    
            if  (filter_mass.includes(key)) {
            suff = 'IN';
            val = value[0].split(' ');
        }
         
        where.push({type:'id',column: key, operation: suff, values: arrayChecker(val)})
      }
      
          if ((key.startsWith('dimension_') || key.startsWith('dim_col'))  && value[0] != '') {
                cols.push(value[0]);
        }
    
    }
    
    // добавляем колонки для выбора данных
    cols.push(...metrics_list);
    
    
    module.exports = {
            dataset: buildSource({
                id: Editor.getId('dataset'),
                columns: cols,
                where: where,
                parameters:filled_params,
                order:params.date_dimension[0],
                limit:limit*2,
                order_by:[{'direction':'DESC','column':metrics_list[0]}]
            })
        }
    

    Обработка событий click и tooltip

    Очень важная часть в дашборде — интерактивность. Рассмотрим, как добавить интерактивность в Advanced-чарты в виде tooltip и обработки кликов на элементы

    // КОД ВКЛАДКИ PREPARE Работа с кликами и тултипами
    // Данные для двух метрик
    const primaryData = [{x: 'Категория A', y: 30, id: 'A'},{x: 'Категория B', y: 80, id: 'B'},{x: 'Категория C', y: 45, id: 'C'},{x: 'Категория D', y: 60, id: 'D'},{x: 'Категория E', y: 20, id: 'E'}];
    const secondaryData = [{x: 'Категория A', y: 50, id: 'A'},{x: 'Категория B', y: 40, id: 'B'},{x: 'Категория C', y: 75, id: 'C'},{x: 'Категория D', y: 30, id: 'D'}];
    const selectedMetric =  'primary';
    
    // Конфигурация
    const config = {
        data: {primaryData:primaryData,secondaryData:secondaryData},
        selectedMetric: selectedMetric
    };
    
    module.exports = {
        render: Editor.wrapFn({
            fn: function(dimensions, config) {
                const {width, height} = dimensions;
    
                const state = Chart.getState() || {};
                const selectedItem = state.selectedItem || config.selectedMetric;
                
                const currentData = selectedItem === 'secondary' ? config.data.secondaryData : config.data.primaryData;
                const sortedData = [...currentData].sort((a, b) => b.y - a.y);
                // Создаем контейнер
                const container = document.createElement('div');container.style.setProperty('display', 'flex');container.style.setProperty('flex-direction', 'column');container.style.setProperty('height', '100%');container.style.setProperty('font-family', 'sans-serif');
                // Блок метрик
                const metricsContainer = document.createElement('div');metricsContainer.style.setProperty('display', 'flex');metricsContainer.style.setProperty('margin', '10px');metricsContainer.style.setProperty('gap', '10px');
    
                // Определяем, какая метрика выбрана
                const isPrimarySelected = selectedItem === 'primary';
    
                // Кнопка "Основная"
                const primaryBtn = document.createElement('div');
                primaryBtn.innerHTML = 'Основная метрика';primaryBtn.style.setProperty('padding', '8px 12px');primaryBtn.style.setProperty('border-radius', '4px');primaryBtn.style.setProperty('cursor', 'pointer');primaryBtn.style.setProperty('user-select', 'none');primaryBtn.style.setProperty('background-color', isPrimarySelected ? '#1e88e5' : '#e0e0e0');
                primaryBtn.style.setProperty('color', isPrimarySelected ? 'white' : 'black');
                primaryBtn.setAttribute('data-id', 'primary');
                metricsContainer.appendChild(primaryBtn);
    
                // Кнопка "Альтернативная"
                const secondaryBtn = document.createElement('div');
                secondaryBtn.innerHTML = 'Альтернативная метрика';
                secondaryBtn.style.setProperty('padding', '8px 12px');secondaryBtn.style.setProperty('border-radius', '4px');secondaryBtn.style.setProperty('cursor', 'pointer');secondaryBtn.style.setProperty('user-select', 'none');
                // СТИЛИ в зависимости от выбора
                secondaryBtn.style.setProperty('background-color', !isPrimarySelected ? '#1e88e5' : '#e0e0e0');
                secondaryBtn.style.setProperty('color', !isPrimarySelected ? 'white' : 'black');
                secondaryBtn.setAttribute('data-id', 'secondary');
                metricsContainer.appendChild(secondaryBtn);
    
                container.appendChild(metricsContainer);
    
                // Блок графика
                const chartContainer = document.createElement('div');
                chartContainer.style.setProperty('flex', '1');
                chartContainer.style.setProperty('position', 'relative');
                chartContainer.style.setProperty('margin', '10px');
    
                // Создаем SVG
                const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
                svg.setAttribute('width', '100%');svg.setAttribute('height', '90%');svg.setAttribute('viewBox', `-20 0 ${width} ${height}`);svg.style.setProperty('overflow', 'visible');
    
                const margin = {top: 20, right: 20, bottom: 40, left: 50};
                const innerWidth = width - margin.left - margin.right;
                const innerHeight = height - margin.top - margin.bottom;
    
                // Группа для отступов
                const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
                g.setAttribute('transform', `translate(${margin.left},${margin.top})`);
                svg.appendChild(g);
    
                // Масштабы: теперь x — это значения, y — категории
                const xScale = d3.scaleLinear()
                    .domain([0, d3.max(sortedData, d => d.y)]).nice()
                    .range([0, innerWidth]);
    
                const yScale = d3.scaleBand()
                    .domain(sortedData.map(d => d.x))
                    .range([0, innerHeight])
                    .padding(0.2);
    
                // Ось X (внизу)
                const xAxis = d3.axisBottom(xScale);
                const xAxisGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
                xAxisGroup.setAttribute('transform', `translate(0,${innerHeight})`);
                g.appendChild(xAxisGroup);
                d3.select(xAxisGroup).call(xAxis);
    
                // Ось Y (слева)
                const yAxis = d3.axisLeft(yScale);
                const yAxisGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
                g.appendChild(yAxisGroup);
                d3.select(yAxisGroup).call(yAxis);
    
                // Столбцы (теперь горизонтальные)
                const bars = document.createElementNS('http://www.w3.org/2000/svg', 'g');
                g.appendChild(bars);
    
                d3.select(bars)
                    .selectAll('rect').data(sortedData).enter().append('rect').attr('y', d => yScale(d.x)).attr('x', 0).attr('height', yScale.bandwidth()).attr('width', d => xScale(d.y)).attr('fill', '#4caf50')
                    .attr('data-id', d => d.id)
                    .attr('cursor', 'pointer');
    
                svg.appendChild(g);
                chartContainer.appendChild(svg);
                container.appendChild(chartContainer);
    
                return Editor.generateHtml(container.outerHTML);
            },
            args: [config],
            libs: ['d3']
        }),
        events: {
            click: Editor.wrapFn({
                fn: function(event, config) {
                    const clickedId = event.target?.getAttribute('data-id');
                    if (!clickedId) return;
    
                    // Проверяем, кликнули ли по кнопке метрики
                    if (clickedId === 'primary' || clickedId === 'secondary') {
                        console.log('got')
                        // Обновляем параметры действия для фильтрации
                        Chart.setState({ selectedItem: clickedId });
                    }
                },
                args: [config]
            })
        },
        tooltip: {
            renderer: Editor.wrapFn({
                fn: function(event, config) {
                    const dataId = event.target?.getAttribute('data-id');
                    if (!dataId) return null;
    
                    // Проверяем, не является ли это кнопкой метрики
                    if (dataId === 'primary' || dataId === 'secondary') {
                        return null;
                    }
    
                    // Определяем, какая метрика выбрана
                    const state = Chart.getState() || {};
                    const selectedItem = state.selectedItem || config.selectedMetric;
                    const currentData = selectedItem === 'secondary' ? config.data.secondaryData : config.data.primaryData;
    
                    const item = currentData.find(d => d.id === dataId);
                    if (!item) return null;
    
                    return Editor.generateHtml(`
                        <div style="padding: 10px; font-family: sans-serif; font-size: 14px;">
                            <div><strong>Категория:</strong> ${item.x}</div>
                            <div><strong>Значение:</strong> ${item.y}</div>
                        </div>
                    `);
                },
                args: [config]
            })
        }
    };
    
    

    JS Selectors

    С помощью селекторов JS можно поддержать совершенно уникальные различные сценарии скрытия / показа селекторов, разрыв связей с селекторами, сортировку и многое другое. Начнем с простого — как с целом сделать JS селектор с Датасетом

    const { buildSource } = require('libs/dataset/v2');
    
    const params = Editor.getParams();
    
    // Для удобства все параметры переносим в переменные без params
    const selectors_date_interval = params.selectors_date_interval[0];
    const filter_fields_ids = params.filter_fields_ids['0'].split('|');
    const filter_fields_names = params.filter_fields_names['0'].split('|');
    
    const filterObject = filter_fields_names.reduce((acc, id, index) => {
        acc[id] = filter_fields_ids[index];
        return acc;
    }, {});
    
    const selector_aliases = params.selector_aliases['0'].split('|');
    
    function ensureArray(value) {
        return Array.isArray(value) ? value : [value];
    }
    
    // определение фильтра по дате - всегда добавляем в селект
    function getDateFilterPoP(params) {
        const date_dim = params.fast_date_dimension[0];
    
        const {from:from_date, to: to_date } = Editor.resolveInterval(selectors_date_interval);
        const right_date = to_date;
    
        let    dateFilter = {
                column: date_dim,
                operation: 'BETWEEN',
                values: [from_date, to_date],
            };
        return dateFilter;
    }
    
    
    // Массив для заполнения условий фильтрации
    const where = [];
    // Колонки для выбора
    const cols = [];
    // Параметры для передачи в датасет
    const filled_params = [];
    
    // 1) Добавляем фильтр дат для PoP (Period over Period) сравнения
    where.push(getDateFilterPoP(params));
    
    // Обрабатываем все параметры
    for (const [key, value] of Object.entries(params)) {
        // Добавляем все параметры из переданных и не пустых
        // Обрабатываем фильтры
        if (filter_fields_ids.includes(key) && value[0] !== '' && value.length != 0) {
            let val, suff;
    
            // Проверяем формат входных данных
            if (value[0] && value[0].substr(0, 2) === '__') {
                const {operation:suffx, value:vala} = Editor.resolveOperation(value);
                val = vala;
                suff = suffx;
    
            } else {
                val = value;
                suff = 'IN';
            }
            
            // Добавляем условие в массив where
            where.push({
                type:'id',
                column: key,
                operation: suff,
                values: ensureArray(val)
            });
        }
    
    }
    
    
    // Экспортируем настройки для buildSource
    let all_filters = {}
    selector_aliases.forEach((item, index) => {
        console.log(filterObject[item])
        all_filters[item] =
        buildSource({
            id: Editor.getId("dataset"),
            columns: [item],
            where: where.filter(object => {return object.column !== filterObject[item];}),
        })
    });
        
    
    console.log(all_filters);
    
    module.exports = all_filters;
    

    const Dataset = require('libs/dataset/v2');
    
    let params = Editor.getParams();
    
    let all_data = Editor.getLoadedData(); // Получаем загруженные данные;
    console.log(all_data)
    
    const commonSettings = {
        width: '100%',
        labelPlacement: 'top',
    };
    
    let selectors_shown = [];
    
    for (const [key, value] of Object.entries(all_data)) {
      let selector_arr = value.result_data[0].rows;
      let dataset_fields = value.fields;
      let selector_id = dataset_fields.find(item => item.title === key).id;
      selectors_shown.push(
        {
            type: 'select',
            param: selector_id,
            label: key,
            content: Object.values(selector_arr).map((value) => ({title: value.data[0], value: value.data[0]})),
            multiselect: true,
            searchable: true,
            ...commonSettings,
            updateOnChange:true
        }
      )
    }
    
    
    
    module.exports = selectors_shown;
    

    Добавить расчетное поле на вкладке sources

    // пустой массив расчетных полей 
    let upd = [];
    // добавляем поле с именем orField и туда в формулу прописываем что и как хотим
    upd.push({
        action:'add_field',
        field: {
            'title':"orField",
            data_type:'string',
            type:'MEASURE',
            formula:"isnull([region_delivery]) or [region_delivery] = 'Саратовская область'"
        }})
    // добавляем в where это поле как тру логическое
    const orFilter = {column: 'orField',
                            operation: 'EQ',
                        values: [true]}
    
    where.push(orFilter)
    module.exports = {
            dataset: buildSource({
                id: Editor.getId('dataset'),
                columns: cols,
                where: where,
                parameters:filled_params,
                limit:limit,
                // раздел updates
                updates:upd,
                order_by:[{'direction':'DESC','column':metrics_list[0]}]
            })
        }
    
  • Полезные скрипты в ClickHouse для анализа производительности SELECT

    Используйте место в ClickHouse с пользой

    Обрезайте ненужное

    Посмотрите, на какие таблицы вы тратите место, попытайтесь оценить ценность таблиц vs сколько место они съедают, сколько ресурсов тратится на то, чтобы выдерживать SLA поставки этих таблиц. ClickHouse любит SSD, поэтому в целом хранение надо стараться оптимизировать. В Маркете мы пришли в итоге к тому, что нашли способ и идеи, как создать в соседней схеме «Песочницу» для аналитиков с TTL поменьше и поняли, что их таблицы наносят больше пользы, чем лежащие данные по ассортименту с 2020 года.

    SELECT table,
    formatReadableSize (sum(bytes))
    size,
    min (min_date) as min_date,
    max (max_date) as max_date
    FROM cluster('{cluster}', system. parts)
    WHERE active
    GROUP BY table order by sum(bytes) DESC

    А все ли колонки нужны?

    Часто бизнес-пользователи приходят и говорят — нам нужны все вот эти вот колонки! Ну если и так и так идет добавление новых справочников / разрезов, чаще всего идет балком добавление и все поля появляются в витрине — посмотрите на них внимательнее через 2-3 недели, а какие вообще никто не юзал с тех пор?

    WITH
        'your_b' AS db_name,
        'your_table' AS tbl_name,
        concat(db_name, '.', tbl_name) AS full_table_name,
        column_usage_stats AS (
            SELECT
                splitByChar('.', full_column_name)[3] AS column_name,
                count() AS usage_count
            FROM cluster('{cluster}',system,query_log)
            ARRAY JOIN columns AS full_column_name
            WHERE
                -- за последние 30 дней
                event_date >= today() - 30
                -- уберем селекторы
                AND query not like 'SELECT DISTINCT%'
                AND startsWith(full_column_name, concat(full_table_name, '.'))
            GROUP BY
                column_name
        )
    SELECT
        c.name AS column_name,
        c.type,
        ifNull(s.usage_count, 0) AS usage_count,
        bar(usage_count, 0, max(usage_count) OVER (), 30) AS popularity_bar
    FROM system.columns AS c
    LEFT JOIN column_usage_stats AS s ON c.name = s.column_name
    WHERE
        c.database = db_name
        AND c.table = tbl_name
    ORDER BY
        usage_count DESC,
        c.position ASC

    Замените в коде выше таблицу и Базу на свои и посмотрите, так ли нужны были эти колонки

    А если колонки очень большие?

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

    WITH 
        -- тут обязательно не дистрибьютед табличка, а настоящая, в дистрибьютед ж нет данных =)
        'some_table'as table_name 
    SELECT
        name AS column_name,
        data_compressed_bytes AS compressed_size_bytes,
        data_uncompressed_bytes AS uncompressed_size_bytes,
        marks_bytes
    FROM system.columns
    WHERE table = table_name
      AND database = currentDatabase()
    ORDER BY data_compressed_bytes DESC;

    И сразу вопрос

    - ну да, вот эта JSON очень большая, но она же мне нужна?
    - а когда нужна?
    - ну мы анализируем конверсию через пару дней после запуска компании так детально
    - а давай TTL на колонку поставим 14 дней?
    - о, круто, давай!

    А как мне сортировать таблицу?

    Если выше мы просто брали из логов columns, то с точки зрения оптимальной сортировки нам нужны колонки, которые были в секции WHERE. Тут скрипт станет другим, будем парсить query, как же я не люблю регулярки =)

    WITH
        'your_table' AS tbl_name
            SELECT 
            tbl_name,
        replaceAll(arrayJoin(arrayDistinct(extractAll(coalesce(arrayElement(splitByString('WHERE',coalesce(replaceAll(query,'"',''),'')),2),''), 't1\\.([\w]+)'))),')','') as field_name,
            SUM(1) as select_count
            FROM  cluster('{cluster}',system,query_log)
            WHERE query ilike 'select%'||tbl_name||'%'
            AND query NOT like 'select distinct%'
            GROUP BY field_name
            ORDER BY select_count DESC

    Этот код нам выдаст самые популярные фильтры, в хорошей картине мира первым полем будет поле партицирования (надеюсь) и дальше внимательно смотрите на резкие падения в значениях, скорее всего, 3-4 поля будут сильно более популярные, чем остальные — это есть ваши претенденты на сортировку

    На что мы тратим ресурсы?

    Эту табличку очень люблю, написал на нее скрипт несколько лет назад и она у нас самая первая на дашборде «Здоровье ClickHouse», сделана через QL-чарт с параметрами, то есть такой вид чарта, где можно что угодно написать в SQL и это визуализировать, оно удобно в моменте посмотреть, кто сейчас нагнул машину

    Со временем, когда мы начали подключать доп штуки в ClickHouse и DataLens(словари, умные справочники, разрыв селекторов между собой) добавлялись новые колонки, но все еще не умещается на 14′ монике =)

    WITH
        'cubes.cubes_clickhouse__' AS prefix_text,
        'cubes' as db_name,
        max(sum(`ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')])) OVER () AS max_user_cpu
    SELECT
        replace(tables[1], prefix_text, '') || ',' || replace(tables[2], prefix_text, '') AS tables,
        CASE WHEN query ILIKE '%dictGet%' THEN 'dict' ELSE '-' END AS dicts,
        bar(
            sum(`ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')]),
            0,
            max_user_cpu,
            12
        ) AS barchik,
        formatReadableQuantity(sum(`ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')])) AS userCPU,
        bar(
            sum(CASE WHEN query LIKE '%DISTINCT%' THEN `ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')] ELSE 0 END),
            0,
            max_user_cpu,
            12
        ) AS "distinct bar",
        formatReadableQuantity(
            sum(CASE WHEN query LIKE '%DISTINCT%' THEN `ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')] ELSE 0 END)
        ) AS "userCPU distincts",
        ROUND(sum(`ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')]) / count(*) / 1000000) AS "per query",
        formatReadableSize(sum(memory_usage)) AS "Memory eaten",
        SUM(query_duration_ms / 1000) AS seconds,
        AVG(CASE WHEN NOT query LIKE '%DISTINCT%' THEN result_rows ELSE 0 END) AS "chart rows",
        uniq(CASE WHEN query LIKE '%DISTINCT%' THEN query ELSE '' END) - 1 AS "distinct count selectors",
        sum(1) AS cnt,
        avg(read_rows) as "read rows"
    FROM cluster('{cluster}', system.query_log)
    WHERE
        event_time > {{left_datetime}}
        AND is_initial_query = 1
        AND type = 'QueryFinish'
        AND query ilike '%'||db_name||'.%'
    GROUP BY tables, dicts
    ORDER BY sum(`ProfileEvents.Values`[indexOf(`ProfileEvents.Names`, 'UserTimeMicroseconds')]) DESC
    LIMIT 30;

    А мы вообще попадаем в индексы?

    Мы все сделали, проекции, индексы, скип индексы — встает вопрос, а мы вообще попадаем в них? простая конструкция, которая позволит вам проверять ваши запросы

    EXPLAIN INDEXES = 1
    -- YOUR SELECT FROM INSPECTOR

    Вот в качестве примера и видно, сколько блоков взяли из общего количества и на каких шагах

    Вообще, детально советую посмотреть видео тут, в целом документация по многим пунктам у ClickHouse исчерпывающая =)

    Удобный чарт для отслеживания в моменте нагрузки

    не совсем скрипт, больше чарт, у нас такой чарт есть на дашборде «Здоровье ClickHouse»

    • Создаем датасет со скриптом
    select * from clusterAllReplicas('{cluster}', system.query_log)
    • Создаем барчарт, с формулой на оси Y
    datetrunc([query_start_time], "minute",15)
    • выкидываем sum([query_duration_ms])/1000 в Y
    • Выкидываем GET_ITEM([tables],1) в цвета
    • Поставьте фильтр на метрику (HAVING), чтобы убрать совсем маленькие запросы

    Можно быстро понять, кто DDOSил систему в режиме online

  • Merge — Легкое объединение разных таблиц в CH

    Пришел тут вопрос, на самом деле достаточно распространенный в физичных компаниях

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

    Процессы

    Задача бизнесово понятная, давайте разберем ее на кусочки процессов — и привяжем и к ним таблички

    • Закупка это свой процесс, там всякие ФЗ могут быть, детализация по товару + поставщику, состояние закупки отдельный пункт, что-то может быть в пути, то есть по сути есть еще будущие даты;
    • Остатки на складах — это другой процесс, считаем, что у нас есть остатки по дням и перемещения между складами и там же агрегированной суммой за день есть продажа
    • Сами продажи, тут может быть много всякой атрибуции на продажу, агрегация идет по чекам, мы знаем, кто купил, куда дальше повезут, с какого склада взяли, тут опять же есть статус заказа — только вновь созданный еще не пройдет в остатках, а нам бы уже понимать, что будет с остатками послезавтра

    Таблицы

    Исходя из этих 3х процессов у нас будет 3 таблицы

    purchase_orders — таблица с заявками на закупку

    CREATE TABLE purchase_orders (
        date_creation Date,
        date_execution Date,
        order_id UInt64,
        event_dt Date DEFAULT date_execution,
        category String,
        contractor_name String,
        price UInt64,
        amount UInt64,
        warehouse_name String
    ) ENGINE = MergeTree()
    ORDER BY event_dt;

    Тут будут и даты создания заявки и дата исполнения, среди важных полей — категория товара, количество и склад

    warehouse_movements — таблица с движениями остатков по складам

    CREATE TABLE warehouse_movements (
        movement_date DATE,
        event_dt Date DEFAULT movement_date,
        warehouse_name String,
        category String,
        beginning_balance UInt64,
        ending_balance UInt64,
        movement_type String,
        movement_quantity UInt64
    ) order by event_dt;

    ну и классическая табличка — sales — продажи наших товаров пользователям

    CREATE TABLE sales (
        order_id UInt64,
        order_date Date,
        shipment_date Date,
        order_status String,
        user_name String,
        event_dt DateTime DEFAULT shipment_date,
        warehouse_name String,
        category String,
        amount UInt64,
        order_price UInt64
    ) ORDER BY event_dt;

    Фишка в том, что часто это 3 разных Data Flow в процессах, разная зона ответственности, а вот в дэшах хочется смотреть всё сразу.

    Создаем Merge-вьюху

    И тут приходит на помощь мега крутая View — Merge таблица в ClickHouse, которая с версии 25.2 научилась хорошо обрабатывать несовпадающие поля между табличками. Сначала создадим эту табличку — Merge-движком

    CREATE TABLE all_goods_movements
    ENGINE = Merge(default, 'warehouse_movements|sales|purchase_orders');

    Проверим, что у нас все в этой вьюхе хорошо:

    SELECT _table, COUNT(1) FROM all_goods_movements GROUP BY 1;

    {
    «_table»: [«sales», «warehouse_movements», «purchase_orders»],
    «count(1)»: [«20», «20», «20»]
    }

    Проверим, что работают фильтры

    SELECT _table, COUNT(1) FROM all_goods_movements WHERE category = 'Категория 3' GROUP BY 1; --5,4,5

    Теперь проверим, как работают поля, которые есть не во всех таблицах

    SELECT _table, user_name FROM all_goods_movements WHERE category = 'Категория 3';

    А что происходит с совпадающими полями — с ними всё хорошо, они в одной колонке сопоставились!

    А ОПТИМАЛЬНО ЛИ?

    И теперь главное поставим фильтр на user_name и посмотрим EXPLAIN

    SELECT event_dt, user_name FROM all_goods_movements WHERE user_name = 'Жора' ;
    Expression ((Project names + Projection))
      ReadFromMerge
        Expression (( + ( + )))
          Filter ((( + ( + )))[split])
            ReadFromMergeTree (default.purchase_orders)
        Expression (( + ( + )))
          Expression
            ReadFromMergeTree (default.sales)
            Indexes:
              PrimaryKey
                Condition: true
                Parts: 1/1
                Granules: 1/1
        Expression (( + ( + )))
          Filter ((( + ( + )))[split])
            ReadFromMergeTree (default.warehouse_movements)

    Как мы видим, фильтр на поле, которое отсутствует в 2х других таблицах — привёл к полному исключению из чтения.

    ИТОГО

    Можно объединять таблицы с разной гранулярностью, атрибутами, чтобы создавать единые бизнесовые метрики, не теряя при этом гибкости и не создавая бутылочных горлышков со стороны Дата-команды, пытаясь сделать одну витрину со всеми полями, даже если они будут null во всех других случаях.

    Итоговый fiddle поиграться ТУТ

  • Привет любителям большиииих данных и их визуализации

    Привет! Меня зовут Юра и мне хочется, чтобы люди делали полезные визуализации над большими данными и оно работало =)

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

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