12.2. Представление данных в табличной форме.

Класс QDataTable -- это ориентированный на работу с базами данных виджет, наследник QTable. Взаимодействие QDataTable с базой данных осуществляется посредством QSqlCursor. В этом разделе мы рассмотрим два диалога, которые используют виджет QDataTable. Диалоги, работающие с QSqlForm будут представлены в следующем разделе.

Приложение, рассматриваемое здесь, работает с тремя таблицами, которые определены следующим образом:

CREATE TABLE artist ( id INTEGER PRIMARY KEY, name VARCHAR(40) NOT NULL, country VARCHAR(40)); CREATE TABLE cd ( id INTEGER PRIMARY KEY, artistid INTEGER NOT NULL, title VARCHAR(40) NOT NULL, year INTEGER NOT NULL, FOREIGN KEY (artistid) REFERENCES artist); CREATE TABLE track ( id INTEGER PRIMARY KEY, cdid INTEGER NOT NULL, number INTEGER NOT NULL, title VARCHAR(40) NOT NULL, duration INTEGER NOT NULL, FOREIGN KEY (cdid) REFERENCES cd); Некоторые базы данных не поддерживают внешние ключи. В этом случае вам следует удалить предложения FOREIGN KEY. Пример останется работоспособным, но база данных не сможет соблюдать ссылочную целостность данных.

Рисунок 12.1. Таблицы приложения CD Collection.


Первым будет класс диалога, который позволит пользователю редактировать список исполнителей. С его помощью пользователь сможет добавлять, изменять или удалять сведения об исполнителях, выбирая соответствующие пункты контекстного меню QDataTable. Внесенные изменения будут записываться в базу данных, по нажатию кнопки Update.

Рисунок 12.2. Диалог ArtistForm.


Определение класса диалога: class ArtistForm : public QDialog { Q_OBJECT public: ArtistForm(QWidget *parent = 0, const char *name = 0); protected slots: void accept(); void reject(); private slots: void primeInsertArtist(QSqlRecord *buffer); void beforeInsertArtist(QSqlRecord *buffer); void beforeDeleteArtist(QSqlRecord *buffer); private: QSqlDatabase *db; QDataTable *artistTable; QPushButton *updateButton; QPushButton *cancelButton; }; Слоты accept() и reject() унаследованы от QDialog. ArtistForm::ArtistForm(QWidget *parent, const char *name) : QDialog(parent, name) { setCaption(tr("Update Artists")); db = QSqlDatabase::database("ARTIST"); db->transaction(); QSqlCursor *artistCursor = new QSqlCursor("artist", true, db); artistTable = new QDataTable(artistCursor, false, this); artistTable->addColumn("name", tr("Name")); artistTable->addColumn("country", tr("Country")); artistTable->setAutoDelete(true); artistTable->setConfirmDelete(true); artistTable->setSorting(true); artistTable->refresh(); updateButton = new QPushButton(tr("Update"), this); updateButton->setDefault(true); cancelButton = new QPushButton(tr("Cancel"), this); В конструкторе ArtistForm запускается транзакция для соединения под именем "ARTIST". Затем создается QSqlCursor, для таблицы artist в базе данных, и QDataTable, которая будет отображать содержимое таблицы.

Второй аргумент конструктора QSqlCursor -- это флаг "автозаполнение". Если в этом аргументе передать true, QSqlCursor будет загружать информацию о каждом из полей в таблице.

Второй аргумент конструктора QDataTable -- так же флаг "автозаполнение". В случае true, QDataTable будет автоматически создавать колонки для каждого из полей в QSqlCursor. В нашем примере этот флаг передается со значением false и с помощью addColumn() в виджет добавляются две колонки, соответствующие полям name и country.

Владение объектом QSqlCursor передается виджету QDataTable. Вызовом setAutoDelete() устанавливается режим автоматического удаления записей, средствами QDataTable, таким образом нам не нужно будет писать дополнительный код, удаляющий записи из таблицы. Вызовом setConfirmDelete() устанавливается режим подтверждения удаления, теперь QDataTable будет выкидывать перед пользователем окно с запросом на подтверждение выполнения операции удаления. Функция setSorting(true) позволит пользователю выполнять сортировку данных в виджете, щелчком мыши по заголовкам колонок. В заключение вызывается функция refresh(), которая заполняет QDataTable данными.

Затем создаются кнопки Update и Cancel.

connect(artistTable, SIGNAL(beforeDelete(QSqlRecord *)), this, SLOT(beforeDeleteArtist(QSqlRecord *))); connect(artistTable, SIGNAL(primeInsert(QSqlRecord *)), this, SLOT(primeInsertArtist(QSqlRecord *))); connect(artistTable, SIGNAL(beforeInsert(QSqlRecord *)), this, SLOT(beforeInsertArtist(QSqlRecord *))); connect(updateButton, SIGNAL(clicked()), this, SLOT(accept())); connect(cancelButton, SIGNAL(clicked()), this, SLOT(reject())); Здесь подключаются три сигнала от QDataTable к трем приватным слотам. Кнопка Update соединяется со слотом accept(), кнопка Cancel -- со слотом reject(). QHBoxLayout *buttonLayout = new QHBoxLayout; buttonLayout->addStretch(1); buttonLayout->addWidget(updateButton); buttonLayout->addWidget(cancelButton); QVBoxLayout *mainLayout = new QVBoxLayout(this); mainLayout->setMargin(11); mainLayout->setSpacing(6); mainLayout->addWidget(artistTable); mainLayout->addLayout(buttonLayout); } В заключение кнопки передаются менеджеру размещения по горизонтали, а QDataTable и менеджер размещения по горизонтали -- менеджеру размещения по вертикали. void ArtistForm::accept() { db->commit(); QDialog::accept(); } Когда пользователь нажимает кнопку Update, выполняется подтверждение транзакции и вызывается унаследованный метод accept() предка. void ArtistForm::reject() { db->rollback(); QDialog::reject(); } Когда пользователь нажимает кнопку Cancel, выполняется откат транзакции и вызывается унаследованный метод reject() предка. void ArtistForm::beforeDeleteArtist(QSqlRecord *buffer) { QSqlQuery query(db); query.exec("DELETE FROM track WHERE track.id IN " "(SELECT track.id FROM track, cd " "WHERE track.cdid = cd.id AND cd.artistid = " + buffer->value("id").toString() + ")"); query.exec("DELETE FROM cd WHERE artistid = " + buffer->value("id").toString()); } Слот beforeDeleteArtist() связан с сигналом beforeDelete(), виджета QDataTable, который выдается непосредственно перед удалением записи. Здесь выполняется каскадное удаление записей, запуском двух запросов: первый удаляет все записи о дорожках на CD по исполнителю, второй -- все CD по исполнителю. Эти операции не нарушают целостность базы данных, потому что выполняются в контексте одной транзакции, которая была запущена в конструкторе формы. void ArtistForm::primeInsertArtist(QSqlRecord *buffer) { buffer->setValue("country", "USA"); } Слот primeInsertArtist() связан с сигналом primeInsert(), виджета QDataTable, который выдается непосредственно перед созданием новой записи. Здесь устанавливается значение по-умолчанию для поля country.

Это один из способов установки значений по-умолчанию. Другой способ состоит в создании производного класса от QSqlCursor и перекрытии метода primeInsert(), но такая метода имеет смысл только в том случае, если один и тот же класс QSqlCursor используется в нескольких местах в приложении и обеспечивает непротиворечивость интерфейса. Третий вариант -- сделать это на уровне базы данных, с помощью предложения DEFAULT в блоке CREATE TABLE.

void ArtistForm::beforeInsertArtist(QSqlRecord *buffer) { buffer->setValue("id", generateId("artist", db)); } Слот beforeInsertArtist() связан с сигналом beforeInsert(), виджета QDataTable, который выдается в тот момент, когда пользователь завершает редактирование записи и нажимает клавишу Enter, чтобы подтвердить изменения. Здесь устанавливается значение поля id. Функция generateId() генерирует уникальное значение для первичного ключа.

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

inline int generateId(const QString &table, QSqlDatabase *db) { QSqlQuery query(db); query.exec("SELECT max(id) FROM " + table); query.next(); return query.value(0).toInt() + 1; } Функция generateId() гарантирует корректную работу только в контексте той же самой транзакции, где исполняется соответствующее выражение INSERT.

Некоторые типы баз данных поддерживают автоматическую генерацию значений полей. В этом случае нужно просто настроить базу данных на автоматическую генерацию значений поля id и вызвать setGenerated("id", false) класса QSqlCursor, чтобы сообщить ему, что не нужно генерировать значения для поля id.

Теперь рассмотрим другой диалог, который использует QDataTable. Этот диалог реализует просмотр таблиц, связанных отношением "мастер-деталь". Мастер-таблица -- это список компакт дисков (CD). Деталь-таблица -- список дорожек на текущем диске. Этот диалог является главным окном приложения CD Collection.

На этот раз, вместо контекстного меню, на форму диалога положены кнопки Add, Edit и Delete, которые позволяют пользователю вносить изменения в список компакт дисков. Когда пользователь нажимает на кнопку Add или Edit, перед ним появляется диалог CDForm. (Описание формы будет приведено в следующем разделе.)

Рисунок 12.3. Диалог MainForm.


Еще одно отличие этого примера от предыдущего заключается в том, что теперь придется работать с внешними ключами, чтобы вместо числового идентификатора исполнителя вывести его имя и название страны. Чтобы добиться этого, необходимо использовать класс QSqlSelectCursor, производный от класса QSqlCursor.

Определение класса главного окна:

class MainForm : public QDialog { Q_OBJECT public: MainForm(QWidget *parent = 0, const char *name = 0); private slots: void addCd(); void editCd(); void deleteCd(); void currentCdChanged(QSqlRecord *record); private: QSplitter *splitter; QDataTable *cdTable; QDataTable *trackTable; QPushButton *addButton; ... QPushButton *quitButton; }; Класс MainForm -- производный от класса QDialog. MainForm::MainForm(QWidget *parent, const char *name) : QDialog(parent, name) { setCaption(tr("CD Collection")); splitter = new QSplitter(Vertical, this); QSqlSelectCursor *cdCursor = new QSqlSelectCursor( "SELECT cd.id, title, name, country, year " "FROM cd, artist WHERE cd.artistid = artist.id"); if (!cdCursor->isActive()) { QMessageBox::critical(this, tr("CD Collection"), tr("The database has not been created.\n" "Run the cdtables example to create a sample " "database, then copy cdcollection.dat into " "this directory and restart this application.")); qApp->quit(); } cdTable = new QDataTable(cdCursor, false, splitter); cdTable->addColumn("title", tr("CD")); cdTable->addColumn("name", tr("Artist")); cdTable->addColumn("country", tr("Country")); cdTable->addColumn("year", tr("Year")); cdTable->setAutoDelete(true); cdTable->refresh(); В конструкторе создается QDataTable для таблицы cd и связанный с ней курсор. Курсор основан на запросе, который соединяет таблицы cd и artist. QDataTable работает в режиме "только для чтения", потому что взаимодействует с объектом класса QSqlSelectCursor. Виджет таблицы, работающий "только на чтение" не имеет контекстного меню.

Если попытка выполнения запроса терпит неудачу, перед пользователем выводится окно, с сообщением об ошибке, и на этом работа приложения завершается.

QSqlCursor *trackCursor = new QSqlCursor("track"); trackCursor->setMode(QSqlCursor::ReadOnly); trackTable = new QDataTable(trackCursor, false, splitter); trackTable->setSort(trackCursor->index("number")); trackTable->addColumn("title", tr("Track")); trackTable->addColumn("duration", tr("Duration")); Здесь создается второй виджет QDataTable и его курсор. Вызовом setMode(QSqlCursor::ReadOnly) таблица переводится в режим "только для чтения", а вызовом setSort() выполняется сортировка по полю с номером дорожки. addButton = new QPushButton(tr("&Add"), this); editButton = new QPushButton(tr("&Edit"), this); deleteButton = new QPushButton(tr("&Delete"), this); refreshButton = new QPushButton(tr("&Refresh"), this); quitButton = new QPushButton(tr("&Quit"), this); connect(addButton, SIGNAL(clicked()), this, SLOT(addCd())); ... connect(quitButton, SIGNAL(clicked()), this, SLOT(close())); connect(cdTable, SIGNAL(currentChanged(QSqlRecord *)), this, SLOT(currentCdChanged(QSqlRecord *))); connect(cdTable, SIGNAL(doubleClicked(int, int, int, const QPoint &)), this, SLOT(editCd())); ... } Здесь настраивается остальная часть пользовательского интерфейса и создаются необходимые соединения "сигнал-слот". void MainForm::addCd() { CdForm form(this); if (form.exec()) { cdTable->refresh(); trackTable->refresh(); } } Когда пользователь нажимает на кнопку Add, вызывается модальный диалог CdForm и, если пользователь в этом диалоге нажмет на кнопку Update, выполняется обновление таблиц QDataTable. void MainForm::editCd() { QSqlRecord *record = cdTable->currentRecord(); if (record) { CdForm form(record->value("id").toInt(), this); if (form.exec()) { cdTable->refresh(); trackTable->refresh(); } } } Когда пользователь нажимает на кнопку Edit, вызывается модальный диалог CdForm, конструктору которого, передается идентификатор текущего компакт диска. В этом случае диалог запускается с заполненными полями, соответствующими заданному CD.

При таком варианте параметризации формы диалога, возможна ситуация, когда к моменту появления окна диалога, идентификатор диска уже будет отсутствовать в базе данных. Например, пользователь мог нажать кнопку Edit за доли секунды до того, как другой пользователь удалил запрашиваемый компакт диск из базы данных. Для решения этой проблемы мы могли бы в CdForm выполнить запрос SELECT по заданному идентификатору диска и продолжать работу только в том случае, если диск еще присутствует в базе. Однако здесь мы полностью полагаемся на сообщение об ошибке от базы данных.

void MainForm::deleteCd() { QSqlRecord *record = cdTable->currentRecord(); if (record) { QSqlQuery query; query.exec("DELETE FROM track WHERE cdid = " + record->value("id").toString()); query.exec("DELETE FROM cd WHERE id = " + record->value("id").toString()); cdTable->refresh(); trackTable->refresh(); } } Когда пользователь нажимает на кнопку Delete, выполняется удаление всех дорожек диска из таблицы track, после чего удаляется запись из таблицы cd. В завершение обновляются обе таблицы-виджеты. void MainForm::currentCdChanged(QSqlRecord *record) { trackTable->setFilter("cdid = " + record->value("id").toString()); trackTable->refresh(); } Слот currentCdChanged() связан с сигналом currentChanged() объекта cdTable, который выдается, когда пользователь вносит изменения в текущую запись о CD или перемещается к другой записи. Всякий раз, когда это происходит, вызывается функция setFilter() и обновляется таблица trackTable. Таким образом она всегда будет отображать только те дорожки, которые относятся к текущему CD.

По сути -- это весь код, реализующий функциональность MainForm. Однако тут следует упомянуть об одном небольшом улучшении, которое можно добавить. Суть его заключается в том, чтобы показывать длительность звучания дорожки не в секундах (например, "155"), как это делается сейчас, а в минутах и секундах (например, "02:35"). С этой целью необходимо создать производный класс от QSqlCursor и перекрыть в нем метод calculateField(), для преобразования значения поля duration в QString с заданным форматом представления:

QVariant TrackSqlCursor::calculateField(const QString &name) { if (name == "duration") { int duration = value("duration").toInt(); return QString("%1:%2").arg(duration / 60, 2) .arg(duration % 60, 2); } return QVariant(); } Кроме того, в этом случае необходимо вызвать метод курсора setCalculated("duration", true), чтобы QDataTable использовала значение поля duration, возвращаемое функцией calculateField().