ProxyOrmModel — ORM-подход к работе с данными в Qt

Привет, Хабр! В этой статье я хочу рассказать о своём проекте — библиотеке ProxyOrmModel для Qt, которая упрощает работу с данными в моделях. Если вы когда-нибудь сталкивались с необходимостью фильтровать, сортировать, группировать или агрегировать данные в QAbstractItemModel, то, вероятно, знаете, как это может быть утомительно. Я решил создать инструмент, который делает это проще и удобнее, вдохновившись идеями ORM (Object-Relational Mapping) из мира баз данных. Здесь я поделюсь архитектурой, ключевыми классами и уроками, которые я вынес из разработки.

f0f56a2b1940625ce7abea05536d4afb.png

Идея и мотивация

Всё началось с того, что в одном из проектов я решил отделить модели для обработки логикой и модели для представлений (qml). Для представлений мне необходимо объединять данные из нескольких моделей, вычислять суммы и средние значения, а также реализовывать сложные условия вроде SQL CASE WHEN. Стандартные прокси-модели Qt (QSortFilterProxyModel) не покрывали всех моих задач, а добавление join в саму модель делает ее громоздкой, трудно поддерживаемой и перегружает сущности лишними полями. Я решил создать универсальную прокси-модель, которая:

  • Поддерживает фильтрацию (WHERE) — надоело писать filterAcceptRow каждый раз

  • Позволяет сортировать и группировать данные

  • Выполняет агрегацию (например, SUM, AVG)

  • Реализует условную логику (CASE WHEN)

  • Оставляет основные модели чистыми, перенося всю логику представления в QML

Так появился ProxyOrmModel, ProxyOrmValue и набор классов на основе интерфейса ISource.

Обзор проекта

Мой проект состоит из нескольких ключевых компонентов:

  • ProxyOrmModel: Основная модель, наследуемая от QAbstractListModel (в будущем хочу перейти на QAbstractItemModel). Она объединяет данные из разных источников (ISource), поддерживает фильтрацию (Where), сортировку и группировку.

  • ISource: Интерфейс для источников данных с методом data (row, role) и встроенным кэшированием.

  • FromSource: Базовый источник, который напрямую берет данные из исходной модели.

  • Join: Класс для объединения двух моделей по заданным ролям (аналог SQL JOIN).

  • AggregateByRow: Выполняет агрегацию (Count, Sum, Avg, Min, Max, First, Last) для строк с одинаковыми значениями.

  • Case: Реализует условную логику, возвращая значения по заданным условиям.

Каждый класс был протестирован с использованием QTest, чтобы гарантировать надежность.

Технические детали

Ключевая идея проекта — разделение ответственности: основные модели (те, что получают данные из базы) остаются «чистыми» и содержат только данные из исходных таблиц, а вся логика отображения (фильтрация, агрегация, преобразование) переносится в прокси-модели для использования в QML. Вот как это реализовано:

  1. ProxyOrmModel

  • Хранит карту источников (QMap sourceMap), где роли сопоставлены с реализациями ISource. Это позволяет добавлять новые источники данных без изменения основной модели.

  • Поддерживает фильтрацию через Where и OrWhere, сортировку с помощью быстрой сортировки (quickSortRecursive) и группировку по роли.

  • Основная модель остается нетронутой, а вся логика отображения обрабатывается в QML через свойства и роли.

QStandardItemModel source(3, 1);
source.setData(source.index(0, 0), "A", Qt::UserRole);
source.setData(source.index(1, 0), "B", Qt::UserRole);
source.setData(source.index(2, 0), "A", Qt::UserRole);

QMap roles = {{Qt::UserRole, Qt::UserRole}};
ProxyOrmModel model(&source, roles);
model.andWhere(Qt::UserRole, Where(Where::Equals, "A"));
model.sort(Qt::UserRole, Qt::AscendingOrder);
  1. Join — Объединяет две модели по заданным ролям. Например:

Join join(&sourceModel, Qt::UserRole, &joinModel, Qt::UserRole, {{Qt::DisplayRole, Qt::DisplayRole}});
  1. AggregateByRow — Вычисляет агрегированные значения:

AggregateByRow agg(&source, Qt::UserRole, Qt::DisplayRole, Sum, Qt::UserRole + 1);
  1. Case — Реализует условную логику, аналогичную SQL CASE WHEN. Проверяет значение в sourceRole и возвращает результат из списка условий или значение по умолчанию.

QList> conditions = {{"A", "Alpha"}, {"B", "Beta"}};
Case caseObj(&source, Qt::UserRole, conditions, Qt::UserRole + 1, "Unknown");
qDebug() << caseObj.data(0, Qt::UserRole + 1).toString(); // "Alpha" для "A"
  1. ProxyOrmValue — Класс для агрегации данных по всей модели (Count, Sum, Avg, Min, Max) с поддержкой фильтрации через Where. Автоматически пересчитывает значения при изменении модели. Полезен для вывода сводной информации, например, общей суммы или среднего значения.

ProxyOrmValue value(&source, ProxyOrm::ProxyOrmValue::Sum, Qt::DisplayRole);
value.where(Qt::UserRole, Where::Equals, "A");
qDebug() << value.value().toInt(); // Сумма значений для строк с "A"

Чтобы ускорить работу, я добавил кэширование в ISource и оптимизировал агрегацию через QHash в некоторых классах.

Пример использования

Вот как можно использовать ProxyOrmModel для фильтрации и агрегации:

OrderViewModel::OrderViewModel(QAbstractListModel *sourceModel,
                               QAbstractListModel *userModel,
                               QAbstractListModel *staffModel,
                               QObject *parent)
    : ProxyOrm::ProxyOrmModel{sourceModel,
                              {{OrderViewModel::IdRole, OrderModel::IdRole},
                               {OrderViewModel::NumberRole, OrderModel::NumberRole},
                               {OrderViewModel::UuidRole, OrderModel::UuidRole},
                               {OrderViewModel::CreatedByRole, OrderModel::CreatedByRole},
                               {OrderViewModel::TotalRole, OrderModel::TotalRole},
                               {OrderViewModel::CreatedAtRole, OrderModel::CreatedAtRole}},
                              parent}
{
    ProxyOrm::Join *userJoin
        = new ProxyOrm::Join(this,
                             OrderModel::CreatedByRole,
                             userModel,
                             UserModel::IdRole,
                             {{OrderViewModel::FirstnameRole, UserModel::FirstnameRole},
                              {OrderViewModel::LastnameRole, UserModel::LastnameRole},
                              {OrderViewModel::StaffIdRole, UserModel::StaffIdRole}});

    addSource(userJoin);

    ProxyOrm::Join *staffJoin
        = new ProxyOrm::Join(this,
                             OrderViewModel::StaffIdRole,
                             staffModel,
                             StaffModel::IdRole,
                             {{OrderViewModel::StaffNameRole, StaffModel::NameRole},
                              {OrderViewModel::StaffRateRole, StaffModel::RateRole}});

    addSource(staffJoin);

    auto userOrderTotal = new ProxyOrm::AggregateByRow(this,
                                                       OrderViewModel::CreatedByRole,
                                                       OrderViewModel::TotalRole,
                                                       ProxyOrm::TypeAggregate::Avg,
                                                       OrderViewModel::UserOrderTotalRole);

    addSource(userOrderTotal);

    auto userCountOrder = new ProxyOrm::AggregateByRow(this,
                                                       OrderViewModel::CreatedByRole,
                                                       OrderViewModel::IdRole,
                                                       ProxyOrm::TypeAggregate::Count,
                                                       OrderViewModel::CountOrderRole);
    addSource(userCountOrder);

    auto caseOrder = new ProxyOrm::Case(this,
                                        OrderViewModel::CountOrderRole,
                                        {{1, "один заказ"}, {2, "два заказа"}},
                                        OrderViewModel::CaseRole,
                                        "какое-то количество заказов");

    addSource(caseOrder);

    auto orWhere = ProxyOrm::OrWhere{ProxyOrm::Where{ProxyOrm::Where::Equals, 11},
                                     ProxyOrm::Where{ProxyOrm::Where::Equals, 1}};

    andWhere(OrderViewModel::CreatedByRole, orWhere);
   
	sort(OrderViewModel::StaffRateRole, Qt::SortOrder::AscendingOrder);
    
    groupBy(OrderViewModel::CreatedByRole);
}

QHash OrderViewModel::roleNames() const
{
    QHash roles;
    roles[IdRole] = "idRole";
    roles[NumberRole] = "numberRole";
    roles[UuidRole] = "uuidRole";
    roles[CreatedByRole] = "createdByRole";
    roles[TotalRole] = "totalRole";
    roles[CreatedAtRole] = "createdAtRole";
    roles[FirstnameRole] = "firstnameRole";
    roles[LastnameRole] = "lastnameRole";
    roles[StaffIdRole] = "staffIdRole";
    roles[StaffNameRole] = "staffNameRole";
    roles[StaffRateRole] = "staffRateRole";
    roles[UserOrderTotalRole] = "userOrderTotalRole";
    roles[CountOrderRole] = "countOrderRole";
    roles[CaseRole] = "caseRole";
    return roles;
}
ListView {
    model: orderViewModel
    delegate: Label {
        text: qsTr("fullname: %1 %2 ").arg(firstnameRole).arg(lastnameRole)
    }
}
Label {
    text: "Total: " + totalValue.value
}

Проблемы и решения

В процессе разработки я столкнулся с несколькими трудностями:

  • Производительность: Линейный поиск в Join и AggregateByRow был медленным. Решил это с помощью кэширования и хэш-таблиц.

  • Сигналы: Было сложно отследить изменение join моделей.

  • Тестирование: Сначала писал код без тестов, но потом добавил модульные тесты с QTest, что сильно упростило отладку.

Результаты и выводы

В итоге я получил гибкую модель, которая упрощает работу с данными в Qt. Она позволяет заменить сложные цепочки прокси-моделей на одну универсальную сущность с поддержкой сложных операций. Среди преимуществ:

  • Удобство настройки фильтров и агрегации.

  • Быстрая работа благодаря кэшированию.

  • Хорошая тестируемость.

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

Заключение

Надеюсь, мой проект будет полезен сообществу Qt-разработчиков. Если вам интересно попробовать ProxyOrmModel, то вот ссылка на GitHub. Буду рад вашим отзывам и предложениям! Пишите в комментариях, что вы думаете, и делитесь своими идеями.

© Habrahabr.ru