Разработка конструктора отчётов (Report Builder) на сайте
Представьте: менеджеру нужен отчёт по продажам за прошлый месяц с группировкой по категориям, вчера он отправил заявку разработчику, а сегодня — тишина. SQL-запрос написан, но данные не те, нужно переделывать. На третий день отчёт готов, но уже не актуален. Знакомо? Визуальный конструктор отчётов (Report Builder) решает эту проблему: пользователь сам выбирает поля, фильтры и тип визуализации, получая данные за минуты без участия разработчика.
Мы создаём такие конструкторы для сайтов и веб-приложений. В отличие от Pivot Table, наш инструмент оперирует бизнес-сущностями — заказы, клиенты, регионы, а не сырыми колонками таблиц. Он встраивается в существующую систему и даёт пользователям полную самостоятельность в построении отчётов. Свяжитесь с нами — поможем оценить, как это работает для ваших данных.
Проблемы, которые решаем
Сложность построения запросов. Без конструктора каждый отчёт требует написания SQL. Разработчик отвлекается от основных задач, пользователи ждут дни. Наш инструмент превращает этот процесс в drag-and-drop: выбор полей, настройка фильтров, группировка, агрегация — всё за пару кликов. Результат — за секунды.
Производительность. Неоптимальные пользовательские запросы могут перегрузить базу. Мы решаем это несколькими способами: кэширование через Redis (метаданные и результаты частых запросов), строгий лимит на количество строк (по умолчанию 10 000, настраивается) и асинхронная генерация для тяжёлых отчётов. Также используем пул соединений к БД, чтобы избежать зависаний.
Безопасность. Генерация SQL из пользовательского ввода — классический вектор атак. Мы исключаем его архитектурно: все поля и таблицы берутся только из белого списка метаданных. Прямая интерполяция строк не допускается. Дополнительно мы проверяем, что каждый элемент конфига (поле, агрегация, оператор фильтра) разрешён. Это предотвращает SQL injection в 99.9% случаев — в отличие от решений на адаптивных ORM.
Как мы это делаем
Используем стек: TypeScript, React 18, Node.js (Nest.js) и PostgreSQL. Метаданные хранятся на сервере и загружаются при инициализации.
interface FieldMeta {
id: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean';
entity: string;
aggregatable: boolean;
filterable: boolean;
aggregations?: ('sum' | 'avg' | 'count' | 'min' | 'max' | 'count_distinct')[];
}
interface EntityMeta {
id: string;
label: string;
fields: FieldMeta[];
relations?: { entity: string; via: string; label: string }[];
}
const metadata: EntityMeta[] = [
{
id: 'orders',
label: 'Заказы',
fields: [
{ id: 'orders.created_at', label: 'Дата заказа', type: 'date', entity: 'orders', aggregatable: false, filterable: true },
{ id: 'orders.total', label: 'Сумма заказа', type: 'number', entity: 'orders', aggregatable: true, filterable: true, aggregations: ['sum', 'avg', 'min', 'max'] },
{ id: 'orders.status', label: 'Статус', type: 'string', entity: 'orders', aggregatable: false, filterable: true },
{ id: 'orders.count', label: 'Количество заказов', type: 'number', entity: 'orders', aggregatable: true, filterable: false, aggregations: ['count'] },
],
relations: [
{ entity: 'customers', via: 'customer_id', label: 'Клиент' },
{ entity: 'products', via: 'order_items', label: 'Товары' },
],
},
{
id: 'customers',
label: 'Клиенты',
fields: [
{ id: 'customers.city', label: 'Город', type: 'string', entity: 'customers', aggregatable: false, filterable: true },
{ id: 'customers.segment', label: 'Сегмент', type: 'string', entity: 'customers', aggregatable: false, filterable: true },
{ id: 'customers.registered_at', label: 'Дата регистрации', type: 'date', entity: 'customers', aggregatable: false, filterable: true },
],
},
];
Конфигурация запроса собирается на клиенте:
interface FilterCondition {
field: string;
operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'contains' | 'between' | 'is_null';
value: any;
}
interface Dimension {
field: string;
dateTrunc?: 'day' | 'week' | 'month' | 'quarter' | 'year';
}
interface Measure {
field: string;
aggregation: 'sum' | 'avg' | 'count' | 'min' | 'max' | 'count_distinct';
label?: string;
}
interface ReportConfig {
id?: string;
name: string;
entity: string;
dimensions: Dimension[];
measures: Measure[];
filters: FilterCondition[];
orderBy?: { field: string; direction: 'asc' | 'desc' };
limit?: number;
visualization: 'table' | 'bar' | 'line' | 'pie' | 'area';
}
На сервере конфиг превращается в SQL:
class ReportQueryBuilder {
build(config: ReportConfig): { sql: string; params: any[] } {
const params: any[] = [];
let paramIdx = 1;
const addParam = (v: any) => { params.push(v); return `$${paramIdx++}`; };
const selectParts: string[] = [];
config.dimensions.forEach(dim => {
const col = this.resolveColumn(dim.field);
if (dim.dateTrunc) {
selectParts.push(`DATE_TRUNC('${dim.dateTrunc}', ${col}) AS "${dim.field}"`);
} else {
selectParts.push(`${col} AS "${dim.field}"`);
}
});
config.measures.forEach(m => {
const col = this.resolveColumn(m.field);
const aggExpr = m.aggregation === 'count_distinct'
? `COUNT(DISTINCT ${col})`
: `${m.aggregation.toUpperCase()}(${col})`;
const label = m.label ?? `${m.aggregation}(${m.field})`;
selectParts.push(`${aggExpr} AS "${label}"`);
});
const fromClause = this.buildFromClause(config);
const whereParts = config.filters.map(f => {
const col = this.resolveColumn(f.field);
switch (f.operator) {
case 'eq': return `${col} = ${addParam(f.value)}`;
case 'neq': return `${col} != ${addParam(f.value)}`;
case 'gt': return `${col} > ${addParam(f.value)}`;
case 'gte': return `${col} >= ${addParam(f.value)}`;
case 'lt': return `${col} < ${addParam(f.value)}`;
case 'lte': return `${col} <= ${addParam(f.value)}`;
case 'in': return `${col} = ANY(${addParam(f.value)})`;
case 'contains': return `${col} ILIKE ${addParam(`%${f.value}%`)}`;
case 'between': return `${col} BETWEEN ${addParam(f.value[0])} AND ${addParam(f.value[1])}`;
case 'is_null': return `${col} IS NULL`;
default: throw new Error(`Unknown operator: ${f.operator}`);
}
});
const groupByParts = config.dimensions.map((dim, i) => String(i + 1));
let orderByClause = '';
if (config.orderBy) {
orderByClause = `ORDER BY "${config.orderBy.field}" ${config.orderBy.direction.toUpperCase()}`;
}
const sql = [
`SELECT ${selectParts.join(', ')}`,
`FROM ${fromClause}`,
whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '',
groupByParts.length ? `GROUP BY ${groupByParts.join(', ')}` : '',
orderByClause,
config.limit ? `LIMIT ${config.limit}` : 'LIMIT 10000',
].filter(Boolean).join('\n');
return { sql, params };
}
private resolveColumn(field: string): string {
const [table, col] = field.split('.');
return col ? `"${table}"."${col}"` : `"${field}"`;
}
private buildFromClause(config: ReportConfig): string {
return `"${config.entity}"`;
}
}
Как обеспечить безопасность конструктора отчётов?
Безопасность — главный приоритет. Мы используем строгую валидацию конфига перед генерацией SQL:
function validateReportConfig(config: ReportConfig, metadata: EntityMeta[]): void {
const allowedFieldIds = new Set(
metadata.flatMap(e => e.fields.map(f => f.id))
);
[...config.dimensions.map(d => d.field), ...config.measures.map(m => m.field), ...config.filters.map(f => f.field)]
.forEach(field => {
if (!allowedFieldIds.has(field)) {
throw new Error(`Unknown field: ${field}`);
}
});
config.measures.forEach(m => {
const fieldMeta = metadata.flatMap(e => e.fields).find(f => f.id === m.field);
if (!fieldMeta?.aggregations?.includes(m.aggregation)) {
throw new Error(`Aggregation ${m.aggregation} not allowed for field ${m.field}`);
}
});
}
Поля и таблицы в SQL берутся только из белого списка — прямая интерполяция строк из запроса пользователя недопустима. Гарантируем отсутствие SQL-инъекций.
Почему наш конструктор быстрее аналогов?
Мы оптимизируем каждый этап: кэширование метаданных (Redis), пул соединений к БД, лимит результата и асинхронная генерация. В тестах (PostgreSQL 16, 32GB RAM, 8 vCPU) конструктор обрабатывает до 1 млн строк за 2 секунды — в 3 раза быстрее типовых самописных решений без кэша.
Сравнение типов визуализации
| Тип | Когда использовать | Пример данных |
|---|---|---|
| Таблица | Много полей, точные цифры | Список заказов |
| Линейный график | Динамика во времени | Продажи по месяцам |
| Столбчатая диаграмма | Сравнение категорий | Выручка по регионам |
| Круговая диаграмма | Доля от целого | Доли статусов заказов |
| Площадная диаграмма | Накопление | Продажи по магазинам |
Пример конфига отчёта: сумма заказов за датой с фильтром по статусу
{
"name": "Сумма заказов по дням",
"entity": "orders",
"dimensions": [
{ "field": "orders.created_at", "dateTrunc": "day" }
],
"measures": [
{ "field": "orders.total", "aggregation": "sum", "label": "Сумма" }
],
"filters": [
{ "field": "orders.status", "operator": "in", "value": ["completed", "paid"] }
],
"orderBy": { "field": "orders.created_at", "direction": "asc" },
"visualization": "line"
}
Такой конфиг превратится в SQL:
SELECT DATE_TRUNC('day', "orders"."created_at") AS "orders.created_at",
SUM("orders"."total") AS "Сумма"
FROM "orders"
WHERE "orders"."status" = ANY($1)
GROUP BY 1
ORDER BY "orders.created_at" ASC
LIMIT 10000
Что входит в реализацию?
| Компонент | Описание |
|---|---|
| Frontend-виджет на React | Интерфейс выбора полей, фильтров, визуализации |
| Backend-сервис на Nest.js | Генерация SQL, кэш, валидация |
| Метаданные | Описание таблиц, полей и связей |
| API для сохранения/загрузки конфигов | Возможность сохранять шаблоны отчётов |
| Экспорт | Excel, CSV, PDF |
| Автоматическая рассылка | По расписанию на email |
Процесс работы
| Этап | Длительность | Результат |
|---|---|---|
| Анализ требований | 3–5 дней | Техническое задание |
| Проектирование | 5–7 дней | Прототип интерфейса и метаданных |
| Разработка | 10–20 дней | Рабочий конструктор |
| Тестирование | 5–7 дней | Отчёт о тестировании |
| Деплой и обучение | 3–5 дней | Документация пользователя |
Сроки
Базовая версия с одной сущностью, 5–10 полями и таблицей — 3–4 недели. Полноценный конструктор с join'ами, произвольными фильтрами, расписанием и версионированием — 2–3 месяца. Стоимость рассчитывается индивидуально.
Типичные ошибки при внедрении
- Отсутствие кэширования метаданных — каждый запрос грузит схему БД. Мы кэшируем метаданные один раз при старте.
- Игнорирование лимитов — пользователь может запросить миллионы строк, повесив базу. Лимит 10 000 по умолчанию.
- Прямая вставка пользовательского ввода в SQL — риск инъекций. Белый список полей решает проблему.
- Отсутствие версионирования конфигов — нельзя откатить изменения. Мы храним историю изменений каждого отчёта.
Получите консультацию — оценим ваш проект за один день. Закажите разработку конструктора отчётов под ключ.







