Автоматизация обмена данными Qt форм с SQL базой данных

в 8:35, , рубрики: c++, qt, qtgui, qtsql, sql, sqlite, метки: ,

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

Автоматизация обмена данными Qt форм с SQL базой данных - 1

История

Как то раз пришлось работать над проектом, написанным на 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 и т.п. Всё в той же последовательности. Неподдерживаемые виджеты будут выдавать горы ассертов.

Обзор кода

Автоматизация обмена данными Qt форм с SQL базой данных - 3

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

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

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

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

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

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

Автоматизация обмена данными Qt форм с SQL базой данных - 5

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

Автоматизация обмена данными Qt форм с SQL базой данных - 6

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

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

Автоматизация обмена данными Qt форм с SQL базой данных - 7

Как уже заметили пытливые умы. В 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.

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

Автоматизация обмена данными Qt форм с SQL базой данных - 8

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

На форме 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().

Профит

Автоматизация обмена данными Qt форм с SQL базой данных - 9

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

QDataMapper

Автоматизация обмена данными Qt форм с SQL базой данных - 10

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

Заключение

Автоматизация обмена данными Qt форм с SQL базой данных - 11

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

Автор: stanislav888

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js