Данная статья описывает решение для выполнения рутинных процедур заполнения и сохранения данных форм в\из SQL базы данных. Код сложный. Для его понимания надо хорошо знать фреймворк Qt по части QtGui, QtSql. И хотя бы средне C++.




История


Как то раз пришлось работать над проектом, написанным на Qt. Бэкендом была самая обычная SQL база данных. Приложение напоминало адресную книгу. Но там было около сотни всяких Qt форм и диалогов. Все эти формы обслуживались простым кодом на С++. Который просто берёт данные из базы, просто расталкивает их по простым полям. И затем, при закрытии формы просто сохраняет в базу.


Например:

	Form::Form(QObject* parent)	: QWidget(parent)
	{
		QSqlQuery query;
		query.prepare( "SELECT firstname  " // + ещё много всего
								  " FROM persons WHERE id = ?" );

		const bool right = query.exec() && query.first(); 
		Q_ASSERT( right ); // Накосячил в запросе.

		if( right )
		{
			ui->firstnameEdit->setText( query.valie( 0 ).toString() );
			/* И ещё полсотни полей */
		}
	}

	~Form::Form()
	{
		QSqlQuery query;
		query.prepare( "UPDATE persons SET firstname = :firstname " // + ещё много всего
								 " WHERE id = :id" );

		query.bindValie( ":firstname", ui->firstnameEdit->text() );
		/* Опять полсотни полей */
		const bool right = query.exec(); 
		Q_ASSERT( right ); // Снова накосячил в запросе.
	}


Всё было хорошо какое то время. Но начали появляться подозрения что то тут не так. Хотя бы потому что 'firstname' в запросах упоминалось два раза. На первый взгляд ничего страшного. На второй 'firstname' можно было вынести в статическую переменную. Но всё это было не то. Вроде код работает. Но что то тут надо радикально поменять.


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

Мечты:
  • Колонки для полей ввода ('firstname') и имена таблиц должны задаваться 1 раз. В идеале это должны быть property у виджетов указываемые через редактор (QtDesigner).
  • Рутинные SQL запросы должны делаться на основе этих property с минимумом кода.
  • Форма должна содержать только код специфичный для некоторых полей и самой формы. Простое заполнение из БД и сохранение не должно занимать места в реализации. Нужен был универсальный код для всех форм.
  • Код должен уметь заполнять форму данными из нескольких таблиц.
  • Должно оставаться место для «грустного кода» из примера выше. Он мог пригодится во всяких нестандартных ситуациях.

В общем всё надо было отшлифовать в лучших традициях ООП. При этом я плохо представлял себе как это будет реализовано. Такой код был получен. Конечно сейчас этот код навевает на меня не меньшую грусть чем пример выше. Но он работал. Он так хорошо работал что успел забыть как он работал. А я с этим кодом работал так. Делал форму со всеми полями ввода на ней. И добавлял к каждому полю property, в котором хранилось имя столбца в БД, где хранились данные из этого поля. Потом, в конструкторе класса формы, писал волшебные строки типа setupForm( «persons» ); и fillForm( dbID ); Т.е. образно говорил форме «настрой на использование таблицы 'persons' » и «заполни её данным из строки хранящейся под ключом из переменной dbID». Форма делала что ей говорят. Остальное были уже проблемы базового класса, где эти методы и были реализованы.


Как вы уже догадываетесь реализация состояла из этих двух строк и общего кода, генерируемого QtCreator-ом. Форма с сотней полей, содержала кода в реализации даже меньше, чем в примере выше. Но иногда приходилось вызывать fillForm() ещё раз. Для того чтобы заполнить поля из связанных SQL таблиц. Типа fillForm( anotherRecordId, «address» ).


Поскольку мой код из продакшена доступен только посвящённым. Пришлось написать данное решение заново. А поскольку со временем все мы набираемся опыта. Он получился гораздо лучше оригинального. Весь код был оформлен в виде приложения — адресной книги. И залит на github. Далее здесь будут многословные объяснения как оно устроено и работает.



Тест решения


Для теста надо иметь QtCreator c Qt не ниже 5.0. Лично я собирал проект с Qt 5.5.0 компилятором gcc 5.3.1. Хотя проект будет собираться и даже работать на Qt 4.8.1. Однако некоторые «спецэффекты» делают программу там не очень дружелюбной.


Начальная настройка проекта:
  1. Скачайте проект «git clone github.com/stanislav888/AddressBook.git»
  2. Меняете текущий каталог «cd AddressBook»
  3. Инициализируйте подмодуль «git submodule init»
  4. Подгружаете код подмодуля в проект «git submodule update»
  5. Открываете и собираете проект
  6. Запускаете программу
  7. Если всё хорошо, появиться окно выбора\создания файла базы данных. Можете посмотреть что за программа. Для заполнения тестовыми данными есть кнопочка «Fill test data»
  8. Удаляете созданную БД кнопкой «Delete DB file»

Далее вы сами добавите поля на интерфейс и в базу данных:

  1. В функции AddressBookMainWindow::createDb() добавьте любой, новый столбец к созданию таблицы «persons»(PERSONS_TABLE_NAME). Что-то типа ' << «middlename TEXT» ' только со своим названием столбца.
  2. Откройте файл addressbookmainwindow.ui в редакторе форм
  3. Добавьте туда виджет для редактирования нового поля. Для начала пусть будет QLineEdit.
  4. У нового виджета создайте property с именем «c»(column), значением-именем нового столбца. Созданного в п.1
  5. Запускаете программу. Вас опять должны спросить где хранить файл базы. Если нет. Удаляете базу и запускате программу снова
  6. Видите данное поле. Сохранение и заполнение данных там будет настроено автоматом. Для теста можете поменять поле и изменить выделение в таблице слева. На другую строку и обратно
  7. Следующий пример. Добавление поля из связанной таблицы.
    Добавьте что-то типа ' << «postcode TEXT» ' в создание таблицы «address»(ADDRESS_TABLE_NAME) там же.
  8. Потом добавьте опять QLineEdit на форму addressdialog.ui property с именем «c» опять со значением — именем колонки которое вы добавили в предыдущем шаге
  9. Так же удалите файл с базой и запустите программу. Укажите имя файла новой базы
  10. На главной форме кликаете «Choose address» и видите диалог с вашим новым QLineEdit. Он должен так же сохраняться в базу как и другие поля
  11. Заполните любой адрес с вашим новым полем и выбелите его(«Select address»)
  12. Закройте программу
  13. Откройте addressbookmainwindow.ui для редактирования и абсолютно так же добавьте такое же поле с тем же property в этот файл
  14. Дополнительно добавьте property «t»(table) со значением «address»
  15. Далее надо было добавить заполнение данных из таблицы «address» в данной форме. В функции void AddressBookMainWindow::updateForm(). Уже есть такой код «m_widgetHelpers.fillForm( addressId, ADDRESS_TABLE_NAME );»
  16. Запускаем, проверяем. Новое поле должно заполняться и сохраняться в обоих формах

После успешных опытов с QLineEdit можете пробовать любые другие виджеты типа QSpinBox, QDateEdit и т.п. Всё в той же последовательности. Неподдерживаемые виджеты будут выдавать горы ассертов.


Обзор кода


Файлы проекта


Ссылка на файлы

  • Папка common. Все общие файлы, которые имеет смысл тащить в другие проекты для добавления «волшебного» функционала. Выделены в отдельный проект
  • common/widgethelpers.* «Помошники виджета». Или реализация «велосипеда» для заполнения форм.
  • setupdialog.* диалог спрашивающий где база данных
  • addressbookmainwindow.* основное окно программы. Там же код инициализации БД.
  • addressdialog.* форма выбора адреса.

Скажу сразу. Не хотел делать эту программу для использования простыми пользователями. Я делал код который может стать основой других проектов. Поэтому иногда там могут использоваться неидеальные решения. Просто потому что бы сохранить универсальность WidgetHelpers и подобных классов.



Классы проекта


WidgetHelpers


Ссодержит весь «волшебный» функционал.

  • void setupForm( QWidget* fornPointer, const QString& defaultTableName );

    Настраивает поля формы, у которых определён property COLUMN_NAME_PROP на сохранение данных в БД после изменения фокуса ввода.

    fornPointer — указатель на форму которую надо настроить. Вы можете связывать данный класс с классом формы, как за счёт наследования, так и за счёт агрегации. Здесь для универсальности, указатель на форму которую настраивать, надо передавать явно.

    defaultTableName — SQL таблица «по умолчанию». Если у поля не присутствует property «t»(TABLE_NAME_PROP), то defaultTableName записывается в это property.

    Таким образом у формы будет некая таблица «по-умолчанию». Которую не надо явно указывать в свойствах виджета.

  • void fillForm(const QVariant& tableRecordId, const QString &tableName /*= QString::null */)

    Заполняет поля ввода данными из базы. Надо вызывать 1 раз для полей каждой SQL таблицы на форме.

    tableName -имя SQL таблицы, поля которой заполняются на форме. Если оно не указано явно. То заполняются поля для таблицы «по-умолчанию» см. defaultTableName выше.

    tableRecordId — ID строки в таблице, данные которой надо заполнить для редактирования на форме. Если туда передать просто QVariant(). Т.е. «null». Который может вернуть нам запрос к базе. То соответствующие поля очистятся и залочатся.

  • QVariant getFieldValie(const QString &tableName, const QString &colimnName) const;

    Извлекает любое значение из кэша. Используется для извлечения foreign key при добавлении новых записей в кэш. Так же можно извлекать пользовательские данные для заполнения каких то отдельных виджетов напрямую. Без участия fillForm().

  • QSqlRecord addRecord(const QString &tableName, const QVariant &recordId);

    Добавление данных для заполнения полей ввода в кэш m_tablesRecords. Обычно вызывается из fillForm(). Можно вызвать явно, когда установили параметр recordId, на основе getFieldValie() И не требуется fillForm().

  • void setAdditionalDisableWidgets( const QWidgetList &widgets );

    Задаёт отдельные виджеты, которые должны лочиться вместе с виджетами основной таблицы. Например это кнопки которые сами по себе не содержат данных, но не имеют смысла при отсутствии выделения в таблице.

  • Переменные COLUMN_NAME_PROP, TABLE_NAME_PROP.

    Содержат имена property виджетов, где хранятся имя столбца и таблицы в базе данных. Если виджет не имеет COLUMN_NAME_PROP значит он никак не участвует в автоматическом заполнении данными. И его можно спокойно заполнять по-своему. Короткие имена выбраны для удобства заполнения форм. Меньше букв в названии property меньше писанины и ошибок.

Хороший пример заполнения формы данными из нескольких таблиц AddressBookMainWindow::updateForm(). Такой код будет работать правильно после вызова WidgetHelpers::setupForm()



AddressBookMainWindow


Основное окно программы. Запускается, ищет базу. Если не находит, спрашивает у пользователя где файл с базой. Если указать несуществующий файл, создаёт базу и записывает путь в настройки. Содержит таблицу с контактами адресной книги слева и детали по каждой записи справа. Таблица использует QSqlTableModel как источник данных. Форма же берёт данные непосредственно из базы через функционал WidgetHelpers, сохраняет так же. QSqlTableModel может сохранять данные в базу. Странно что на форме раздельное сохранение данных для таблицы и простых полей. QSqlTableModel иногда работает с ошибками. Потом, у него немного ограниченный функционал. Поэтому вы можете плюнуть и воспользоваться QSqlQueryModel. Который уже ничего не сохраняет, но и ограничений у него нет. В этом случае таблица будет только для чтения. И функционал сохранения простых полей в правой части будет нужен. Для тех кто реально хочет сохранять через QSqlTableModel(m_personsModelPtr). WidgetHelpers(m_widgetHelpers) выбрасывает сигнал dataChanged( QString tableName, QString columnName, QVariant id, QVariant value ); Хотя тут ещё нужна логика предотвращающая сохранение в WidgetHelpers::saveDataSlot(). Здесь не реализована за ненадобностью. Так же на форме есть другая особенность. Комбобокс ui->countryCombo. Нужен только для того чтобы показать как сохранять и заполнять данные из связанных SQL таблиц. Здесь это таблица «country»(COUNTRY_TABLE_NAME) связанная через «address»(ADDRESS_TABLE_NAME). Обычно он скрыт от посторонних глаз т.к. менять страну вне остального адреса будет очень странно. Кто хочет открывайте и экспериментируйте (ui->countryCombo->hide(); ).


Концептуально, данное окно(AddressBookMainWindow) должно наследоваться от WidgetHelpers. Но по факту разработчики Qt ограничили стандарт С++. И оставили много граблей для желающих воспользоваться всеми возможностями языка. Поэтому здесь и далее применена именно агрегация, что бы не запутать и без того сложный код. Если вы видите недостатки такого подхода. Значит вы же должны видеть и выход в каждом конкретном случае. Если не видите ничего дурного. Значит пока об этом думать не стоит. Код работает и этого достаточно.



AddressDialog


Диалог выбора адреса из списка. Работает как и AddressBookMainWindow. Но не стал выносить абсолютно весь подобный код для левой таблицы в WidgetHelpers. Таблицы пока не являются нашей целью. А вот работа с виджетами ввода делается также.



Как устанавливаются значения в виджетах


Как уже заметили пытливые умы. В WidgetHelpers есть две интересные функции static void setWidgetValie(const QVariant &valie, QObject * const inputBox); и static QVariant getWidgetValie(const QObject* const sourceBox); они берут значения из любого виджета и записывают значение в любой виджет, где это имеет смысл. В случае с простыми виджетами типа QLineEdit, QSpinBox используется решение от Qt. Это функции «сеттеры» и «геттеры» обозначенные как «USER». Для примера, в файле «qlineedit.h» есть строчка «Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged USER true)» Есть даже документация чего это заклинание обозначает.


Кроме простых случаев есть, например, случай QComboBox. «Q_PROPERTY(QString currentText READ currentText WRITE setCurrentText NOTIFY currentTextChanged USER true)». На первый взгляд тут ничего страшного нет. Но поскольку комбобоксы, как правило перечисления из БД. Нам было бы удобнее устанавливать значения по вторичному ключу из БД, а не по строке видимой пользователю. Поэтому тут логика изменена. Значение устанавливается по ключу из БД. хранящемуся в UserRole модели комбобокса.


Потом есть случай с группой QRadioButton -ов. Такое на формах тоже может быть перечислением из БД. И лень устанавливать эти переключатели руками. Т.е. надо выбрать тот переключатель, значение которого сохранено в БД. Для этого есть property «v»(VALUE_PROP). Содержащее значение хранимое в базе. Настраивается оно очень интересно. У логически связанных QRadioButton -ов должна быть назначена QButtonGroup. Но COLUMN_NAME_PROP, TABLE_NAME_PROP задаются у любого одного QRadioButton. Просто для удобства разработчика. Код сам разберётся что делать с группой. VALUE_PROP задаются у всех переключателей разные.


В AddressBookMainWindow реализован, наверное, самый сложный случай группы переключателей. Для выбора пола контакта. Там добавлен фейковый QRadioButton, на случай когда в БД храниться NULL.



Как показать страну двумя разными способами


Теперь, для лучшего усвоения, разберём самый ужасный и запутанный пример. На форме addressbookmainwindow.ui есть QComboBox ui->countryCombo. Что бы его видеть нужно закомментировать ui->countryCombo->hide(); Для его заполнения данными из базы. Надо заполнить поля связанные с таблицей «address». Потом есть ui->countryLabel. Для него надо заполнить поля связанные с таблицей «country».


Делается это так:
void AddressBookMainWindow::updateForm()
{
	// Немного кода пропущено

	m_widgetHelpers.fillForm( id );
	const QVariant addressId = m_widgetHelpers.getFieldValie( PERSONS_TABLE_NAME, ADDRESS_FK_COL_NAME );
	setAddress( addressId );
	m_widgetHelpers.fillForm( addressId, ADDRESS_TABLE_NAME );
	const QVariant countryId( m_widgetHelpers.getFieldValie( ADDRESS_TABLE_NAME, COUNTRY_FK_COL_NAME ) );
	m_widgetHelpers.fillForm( countryId, COUNTRY_TABLE_NAME );
}

  1. Сначала идёт вызов m_widgetHelpers.setupForm( this, PERSONS_TABLE_NAME ); в конструкторе.
  2. Первая строка m_widgetHelpers.fillForm( id ); заполняет форму данными таблицы «по-умолчанию» (defaultTableName в setupForm()). В нашем случае это «persons» (PERSONS_TABLE_NAME). Эта же функция добавляет(или замещает) нужную SQL строку(QSqlRecord) в кэш(m_tablesRecords).
  3. Далее m_widgetHelpers.getFieldValie() достаёт из кэша нужный «внешний ключ» на запись в таблице «address»(ADDRESS_TABLE_NAME).
  4. Полученный «addressId» используется в setAddress(). Что пока не имеет значения.
  5. Потом в m_widgetHelpers.fillForm( addressId, ADDRESS_TABLE_NAME );. Данный вызов заполняет все виджеты где явно указанно значение property «t» равным переменной ADDRESS_TABLE_NAME В том числе и ui->countryCombo. Этот же вызов подгружает в кэш строку из таблицы «address».
  6. Далее так же извлекается «внешний ключ»(countryId) на запись в таблице «country».
  7. И уже m_widgetHelpers.fillForm( countryId, COUNTRY_TABLE_NAME ) заполняет все виджеты где property «t» равно COUNTRY_TABLE_NAME(«country»). Т.е. ui->countryLabel

Разница тут в том что комбобокс сам работает с «foreign key». В качестве имени столбца ему задано «countryid»(COUNTRY_FK_COL_NAME) из таблицы «address»(ADDRESS_TABLE_NAME). Тогда как для ui->countryLabel надо добавлять строку из таблицы «country» в кэш(что сделано неявно). И уже потом заполнять виджет данными из кэша. Получается что fillForm( countryId, COUNTRY_TABLE_NAME ); нужен только для ui->countryLabel. Конечно два поля выводящих страну, по соседству не нужны. Поэтому одно из них было скрыто.


ui->countryLabel заполняется благодаря property «c» и «t». Этих property нет у виджета в addressbookmainwindow.ui. Они задаются программно.

	ui->countryLabel->setProperty( WidgetHelpers::COLUMN_NAME_PROP, COUNTRY_NAME_COLUMN );
	ui->countryLabel->setProperty( WidgetHelpers::TABLE_NAME_PROP, COUNTRY_TABLE_NAME );

Смысла в этом особого нет. Просто пример что так тоже можно заполнять property. Можете закомментировать эти строки и определить их через редактор для форм. Такая настройка property имеет смысл только до setupForm().



Профит


Сразу после разработки всего этого. Внезапно стали появляться новые возможности. Одна из них вывод дополнительной отладочной информации о поле во всплывающих подсказках к виджету. При наведении курсора на виджет появляется что то типа «t = persons, c = skype, id = 3». Что значит таблицу, столбец и первичный ключ строки которыми было заполнено данное поле. При этом оригинальные подсказки, если они статичные. Никуда не деваются. Динамичные тоже прикрутить не проблема. Думаю это будет «нерукотворный памятник» разработчику для тестеров.



QDataWidgetMapper


Конечно же такое моё решение, кажется немного велосипедным по сравнению с использованием решения на основе QDataWidgetMapper. Признаюсь пытался использовать этот класс. Но он банально не захотел работать. Можно конечно зарыться в исходники Qt и выяснить что же там такое. При этом решением мог быть патч к Qt либо своя реализация маппера. Что меня совсем не устраивало. Я хотел просто задавать имена столбцов для виджета в QtDesigner. Это очень наглядно и не захламляется файл реализации формы. QDataWidgetMapper же не совсем то. Надо ручками линковать столбец в модели и виджет. При этом я обязан использовать ненадёжную модель QSqlTableModel. У меня с SQLite она работает. Но в Qt не одна SQLite. Непонятно какие фокусы эти модели будут выкидывать с другими базами. А мне хотелось сделать универсальное решение. Потом, QDataWidgetMapper не захочет поддерживать QComboBox и QRadioButton, как это делаю я.





Заключение


На мой взгляд велосипед получился очень удачным. Пишите комментарии, пинайте меня сильно. Ведь это моя первая статья здесь. Если вам понравиться данная тема. Есть идея написать о моделях (которые QAbstractItemModel).

Поделиться с друзьями
-->

Комментарии (18)


  1. Delphinum
    15.05.2017 11:51
    +2

    image


  1. Siemargl
    15.05.2017 12:14
    +7

    Котики плохую статью не спасут.

    Плохая или хорошая же, за ними не видно — нужно сначала утопить 90%.


    1. stanislav888
      15.05.2017 13:24
      -5

      Котики плохую статью не спасут.
      Котик(он тут один) никого не спасает. Насчёт «плохой статьи», обоснуйте.


      1. Siemargl
        15.05.2017 15:05
        +1

        Я же написал — за котиками не видно.

        Оформите статью по-человечески, чтобы можно было с ней работать. А пока я ее читать не буду, хотя тема мне лично интересна.


        1. stanislav888
          15.05.2017 15:12

          Уменьшил размеры иллюстраций. Читайте.


          1. Siemargl
            15.05.2017 19:36
            -1

            1. А в чем смысл конструкции?

            AddressDialog::AddressDialog( QVariant id, QWidget *parent) :
            	QDialog(parent),
            	ui(new Ui::AddressDialog)
            

            Диалог создает еще одну копию своего же класса и хранит у себя же в мембере ui, зачем?

            2. Очень странная обработка ошибок — местами есть, местами нет. И никаких действий при этом.
            Тем более работа с БД ведется в конструкторе — а если произойдет сбой при создании формы?


            1. stanislav888
              23.05.2017 22:27

              Например что там, в конструкторе, может произойти?


              1. Siemargl
                23.05.2017 22:46

                Отличная идея, задавать уточняющие вопросы через неделю =)

                Много чего может произойти в реальном мире. По коду:
                1) Данные из БД не загрузились. Программа была открыта 3 дня и коннект к серверу умер.
                Или мастер-детайл не соответствует старым данным — удалили на соседнем рабочем месте.

                2) Данные не проходят валидацию. Я понимаю, что пока в этом примере ее нет, но будет же когда нибудь!

                Итого — объект формы создан (QT же не выбрасывает таких исключений), а содержимое формы «левое». Нажимаем ОК.


          1. Siemargl
            15.05.2017 20:37
            -1

            Вопрос 1 снимается. Это специфика QT — Ui::AddressDialog это Qt сгенерированный класс данных формы типа AddressDialog (другой класс).


  1. xRay
    15.05.2017 12:40
    +1

    Тема интересная.
    Количество котиков зашкаливает. И даже есть котики которые в по высоте в FulHD не влезают целиком.


    1. stanislav888
      15.05.2017 13:30
      -6

      Котика можно пропустить. Извиняйте что не влезает. Просто первая статья, и тренируюсь на котиках.


    1. stanislav888
      15.05.2017 14:54
      -1

      Спасибо что заметили про размеры. Иллюстрации переделал.


  1. Burich
    15.05.2017 13:31

    Каждому полю на форме нужно вручную добавлять свойство с заданным именем?
    Мы в подобной ситуации создали плагин с наследниками компонент, чтобы они содержали в себе нужные свойства, плюс доработали его так, чтобы можно было массово задавать имя таблицы дочерним компонентам.


    1. stanislav888
      15.05.2017 13:39

      Каждому полю на форме нужно вручную добавлять свойство с заданным именем?

      Да. Это логично, что поля ввода на форме, как то линкуются со столбцами в базе. У меня это property QObject -a.


  1. reishi
    15.05.2017 15:00
    +2

    Меня хватило только на разделы «История» и «Обзор кода».
    Дальше просто пробежал глазами, потому что фотографий кота больше, чем полезной информации. Зачем злоупотреблять? Хабр не Пикабу.


    1. stanislav888
      15.05.2017 15:06
      -4

      Иногда можно потратить часы на исследование чужого кода. Там всякое можно увидеть. А вы не захотели даже понять описанное решение среди плохих иллюстраций. Наверное не очень то и хотелось.

      У вас просто не хватит терпения на код… Можете смело закрыть статью и забыть.


  1. heleo
    16.05.2017 10:58

    Модель\представление\делегат для Qt не пробовали использовать?


    1. stanislav888
      16.05.2017 17:19

      Очень много работал со всеми этими моделями и делегатами для таблиц и немного для форм. И хотел бы даже написать об этом.
      В целом, работает 50\50. Надо «плясать с бубном». Этим пляскам и была посвящена часть про «QDataMapper».