diff --git a/pglab/crud/CrudModel.cpp b/pglab/crud/CrudModel.cpp index c30a1bc..9b9b965 100644 --- a/pglab/crud/CrudModel.cpp +++ b/pglab/crud/CrudModel.cpp @@ -6,7 +6,6 @@ #include "catalog/PgConstraintContainer.h" #include "catalog/PgClassContainer.h" #include "SqlFormattingUtils.h" -#include "Pgsql_oids.h" #include "CustomDataRole.h" #include #include @@ -98,45 +97,46 @@ Oid CrudModel::getType(int column) const CrudModel::Value CrudModel::getLatestData(int columnIndex, int rowIndex) const { - if (m_roData) + if (!m_roData) + return {}; + + const RowMapping &row_mapping = m_rowMapping[rowIndex]; + if (row_mapping.pending) { - auto row_mapping = m_rowMapping[rowIndex]; - - std::optional val; - if (row_mapping.pending) - val = m_pendingRowList.getValue(columnIndex, row_mapping.rowKey); - - if (!val.has_value()) - val = getSavedData(row_mapping, columnIndex); - - return val.value_or(Value()); + // an edited cell can be NULL, that is different from not having been edited + if (auto edited = m_pendingRowList.getValue(columnIndex, row_mapping.rowKey)) + return *edited; } - return {}; -} - -CrudModel::Value CrudModel::getSavedData(int columnIndex, int rowIndex) const -{ - if (m_roData) - { - auto row_mapping = m_rowMapping[rowIndex]; - return getSavedData(row_mapping, columnIndex); - } - return {}; + return getSavedData(row_mapping, columnIndex); } CrudModel::Value CrudModel::getSavedData(const RowMapping &row_mapping, int columnIndex) const { - std::optional val; - if (!val.has_value() && row_mapping.isModified()) - val = row_mapping.modifiedValue(columnIndex); + // rows that have been saved (or are new) have their own copy of the data + if (row_mapping.isModified()) + return row_mapping.modifiedValue(columnIndex); - if (!val.has_value() && row_mapping.rowKey < m_roData->rows()) - if (!m_roData->null(columnIndex, row_mapping.rowKey)) - val = std::string(m_roData->val(columnIndex, row_mapping.rowKey)); + if (row_mapping.rowKey < m_roData->rows() && !m_roData->null(columnIndex, row_mapping.rowKey)) + return std::string(m_roData->val(columnIndex, row_mapping.rowKey)); - return val.value_or(Value()); + return {}; } +CrudModel::RowMapping* CrudModel::findRowMapping(int rowKey) +{ + auto iter = std::find_if(m_rowMapping.begin(), m_rowMapping.end(), + [rowKey](const RowMapping &rhs) { return rhs.rowKey == rowKey; }); + return iter != m_rowMapping.end() ? &*iter : nullptr; +} + +void CrudModel::updatePendingState(bool was_dirty) +{ + if (was_dirty != hasPendingChanges()) + emit pendingChangesChanged(hasPendingChanges()); +} + +namespace { + QVariant StringToVariant(const std::string &str, Oid oid) { Pgsql::Value v(str.c_str(), oid); @@ -174,9 +174,10 @@ QVariant StringToVariant(const std::string &str, Oid oid) case Pgsql::float8_oid: return v.operator double(); } - } +} // namespace + QVariant CrudModel::data(const QModelIndex &index, int role) const { if (index.column() < PreColumnCount) @@ -254,8 +255,7 @@ void CrudModel::loadIntoModel(std::shared_ptr data) if (m_wholeTable) appendNewRowInternal(); endResetModel(); - if (was_dirty) - emit pendingChangesChanged(false); + updatePendingState(was_dirty); } void CrudModel::initRowMapping() @@ -269,26 +269,11 @@ void CrudModel::initRowMapping() void CrudModel::connectionStateChanged() { - switch (m_dbConn.state()) { - case ASyncDBConnection::State::NotConnected: - break; - case ASyncDBConnection::State::Connecting: - break; - case ASyncDBConnection::State::Connected: - if (callLoadData) - { - callLoadData = false; - loadData(); - } - break; - case ASyncDBConnection::State::QuerySend: - - break; - case ASyncDBConnection::State::CancelSend: - break; - case ASyncDBConnection::State::Terminating: - break; - } + if (m_dbConn.state() == ASyncDBConnection::State::Connected && callLoadData) + { + callLoadData = false; + loadData(); + } } Qt::ItemFlags CrudModel::flags(const QModelIndex &index) const @@ -326,8 +311,7 @@ bool CrudModel::setData(const QModelIndex &index, const QVariant &value, int rol } bool was_dirty = hasPendingChanges(); m_pendingRowList.setValue(col, row_mapping.rowKey, val); - if (!was_dirty) - emit pendingChangesChanged(true); + updatePendingState(was_dirty); emit dataChanged(index, index, QVector() << role); return true; @@ -337,8 +321,7 @@ bool CrudModel::setData(const QModelIndex &index, const QVariant &value, int rol std::tuple> CrudModel::saveRow(const PendingRow &pending_row, const RowMapping &row_mapping) { - auto data = pending_row.data(); - if (data.empty()) + if (pending_row.data().empty()) return { true, {} }; if (row_mapping.isNew()) @@ -348,31 +331,16 @@ std::tuple> CrudModel::saveRow(const Pending std::tie(buffer, params) = createInsertQuery(pending_row); Pgsql::Connection db_update_conn; - auto dbconfig = m_database->config(); - db_update_conn.connect(dbconfig.connectionString()); + connectForUpdate(db_update_conn); try { auto result = db_update_conn.queryParam(buffer, params); if (result && result.rows() == 1) - { - std::vector values; - auto row = *result.begin(); - for (auto v : row) - { - if (v.null()) - values.push_back(Value()); - else - values.push_back(std::string(v.c_str())); - } - - return { true, values }; - } + return { true, rowValues(*result.begin()) }; } catch (const Pgsql::PgResultError &ex) { - QMessageBox msgBox; - msgBox.setText(ex.what()); - msgBox.exec(); + reportError(ex.what()); } return { false, {} }; } @@ -389,8 +357,7 @@ std::tuple> CrudModel::saveUpdate(const Pend try { Pgsql::Connection db_update_conn; - auto dbconfig = m_database->config(); - db_update_conn.connect(dbconfig.connectionString()); + connectForUpdate(db_update_conn); auto tx = Pgsql::Transaction::startTransaction(db_update_conn); for (Oid table_oid : tables) { @@ -418,32 +385,47 @@ std::tuple> CrudModel::saveUpdate(const Pend } catch (const Pgsql::PgResultError &ex) { - QMessageBox msgBox; - msgBox.setText(ex.what()); - msgBox.exec(); + reportError(ex.what()); } return { false, {} }; } +void CrudModel::connectForUpdate(Pgsql::Connection &connection) const +{ + auto dbconfig = m_database->config(); + connection.connect(dbconfig.connectionString()); +} + +void CrudModel::reportError(const QString &message) const +{ + QMessageBox msgBox; + msgBox.setText(message); + msgBox.exec(); +} + +std::vector CrudModel::rowValues(const Pgsql::Row &row) +{ + std::vector values; + for (auto v : row) + { + if (v.null()) + values.push_back(Value()); + else + values.push_back(std::string(v.c_str())); + } + return values; +} + QString CrudModel::tableName(Oid table_oid) const { auto table = m_database->catalog()->classes()->getByKey(table_oid); return table ? table->fullyQualifiedQuotedObjectName() : QString(); } -std::vector CrudModel::resultColumnsOfTable(Oid table_oid) const -{ - std::vector cols; - for (int col = 0; col < m_roData->cols(); ++col) - if (m_roData->ftable(col) == table_oid && m_roData->ftableCol(col) > 0) - cols.push_back(col); - return cols; -} - QString CrudModel::attributeName(int col) const { - if (col < static_cast(columnList.size()) && columnList[col]) - return columnList[col]->name; + if (col < static_cast(m_columnList.size()) && m_columnList[col]) + return m_columnList[col]->name; return QString::fromUtf8(m_roData->getColName(col)); } @@ -493,7 +475,7 @@ std::tuple CrudModel::createUpdateQuery(Oid table_oid, c std::tuple CrudModel::createInsertQuery(const PendingRow &pending_row) { Pgsql::Params params; - auto data = pending_row.data(); + const auto &data = pending_row.data(); QString table_name = m_table->fullyQualifiedQuotedObjectName(); QString buffer; QTextStream q(&buffer); @@ -523,7 +505,6 @@ std::tuple CrudModel::createInsertQuery(const PendingRow QString CrudModel::createDeleteStatement() const { - Pgsql::Params params; QString table_name = m_table->fullyQualifiedQuotedObjectName(); QString buffer; QTextStream q(&buffer); @@ -547,40 +528,25 @@ bool CrudModel::savePendingChanges() while (!m_pendingRowList.m_rows.empty()) { auto iter = m_pendingRowList.m_rows.begin(); - int pendingKey = iter->first; - auto mapping_iter = std::find_if(m_rowMapping.begin(), m_rowMapping.end(), - [pendingKey](const RowMapping &rhs) -> bool { return rhs.rowKey == pendingKey; }); - if (mapping_iter == m_rowMapping.end()) + RowMapping *mapping = findRowMapping(iter->first); + if (!mapping) return false; - auto [ok, modified_row] = saveRow(iter->second, *mapping_iter); - if (ok) - { - int rowKey = iter->first; - m_pendingRowList.m_rows.erase(iter); + auto [ok, modified_row] = saveRow(iter->second, *mapping); + if (!ok) + return false; - auto iter = std::find_if(m_rowMapping.begin(), m_rowMapping.end(), - [rowKey](const RowMapping &rhs) -> bool { return rhs.rowKey == rowKey; }); - if (iter != m_rowMapping.end()) - { - iter->setModifiedRowData(modified_row); - iter->pending = false; - if (IsLastRow(iter)) - appendNewRow(); - } - } - else - return false; + m_pendingRowList.m_rows.erase(iter); + mapping->setModifiedRowData(modified_row); + mapping->pending = false; + // appending invalidates mapping, so keep this last + if (mapping == &m_rowMapping.back()) + appendNewRow(); } return true; } -bool CrudModel::IsLastRow(RowMappingVector::iterator mapping_iter) const -{ - return mapping_iter == --m_rowMapping.end(); -} - bool CrudModel::columnIsReadOnly(int column_index) const { if (m_roData == nullptr) @@ -590,30 +556,30 @@ bool CrudModel::columnIsReadOnly(int column_index) const if (!m_mapping || !m_mapping->isEditable(col)) return true; - const auto &column = columnList[col]; + const auto &column = m_columnList[col]; return !column || column->getIdentity() == PgAttribute::Identity::Always || column->getGenerated() != PgAttribute::Generated::None; } void CrudModel::initializeColumnList() { - columnList.clear(); - columnList.reserve(m_roData->cols()); + m_columnList.clear(); + m_columnList.reserve(m_roData->cols()); auto atts = m_database->catalog()->attributes(); for (int col = 0; col < m_roData->cols(); ++col) { int attnum = m_roData->ftableCol(col); if (attnum <= 0) { - columnList.emplace_back(); + m_columnList.emplace_back(); continue; } auto columns = atts->getColumnsForRelation(m_roData->ftable(col)); auto find_result = std::find_if(columns.begin(), columns.end(), [attnum](const PgAttribute &att) { return att.num == attnum; }); if (find_result != columns.end()) - columnList.emplace_back(*find_result); + m_columnList.emplace_back(*find_result); else - columnList.emplace_back(); + m_columnList.emplace_back(); } } @@ -621,8 +587,7 @@ bool CrudModel::saveChanges() { bool was_dirty = hasPendingChanges(); bool ok = savePendingChanges(); - if (was_dirty != hasPendingChanges()) - emit pendingChangesChanged(hasPendingChanges()); + updatePendingState(was_dirty); return ok; } @@ -664,49 +629,42 @@ std::tuple CrudModel::removeRows(const std::set return { false, "Range error" }; if (!m_wholeTable || !m_table || m_mapping->primaryKeyColumns(m_table->oid()).empty()) return { false, "Rows can only be removed from a table with a primary key" }; - // When removing rows there is no direct mapping anymore between the rows in the grid - // and the rows in m_roData - - // Therefor we need an indirection to keep track of which rows are visible and which - // grid row maps to what data row. Maybe we can also keep track where the current data - // of the row is located original data, pending data or modified data - - // 1. Get PKEY and remove that row from table + // When removing rows there is no direct mapping anymore between the rows in the grid + // and the rows in m_roData, that is why the grid rows are mapped through m_rowMapping. try { - Pgsql::Connection db_update_conn; - auto dbconfig = m_database->config(); - db_update_conn.connect(dbconfig.connectionString()); - - // First delete rows in table - QString delete_statement = createDeleteStatement(); - { - auto tx = Pgsql::Transaction::startTransaction(db_update_conn); - for (auto range : row_ranges) - { - for (int current_row = range.start(); current_row < range.end(); ++current_row) - { - auto&& mapping = m_rowMapping[static_cast(current_row)]; - auto params = getPKeyParamsForRow(mapping, m_table->oid()); - if (!params.empty()) - db_update_conn.queryParam(delete_statement, params); - } - } - tx.commit(); - // If something goes wrong after this commit we should reload contents of model - } - // Then from model + deleteRowsFromDatabase(row_ranges); + // If something goes wrong after the commit we should reload contents of model bool was_dirty = hasPendingChanges(); - RemoveRangesOfRowsFromModel(row_ranges); - if (was_dirty != hasPendingChanges()) - emit pendingChangesChanged(hasPendingChanges()); - return { true, "" }; + removeRangesFromModel(row_ranges); + updatePendingState(was_dirty); + return { true, "" }; } catch (const Pgsql::PgResultError &error) { - return { false, QString::fromUtf8(error.details().messageDetail.c_str()) }; - } + return { false, QString::fromUtf8(error.details().messageDetail.c_str()) }; + } +} + +void CrudModel::deleteRowsFromDatabase(const std::set> &row_ranges) +{ + Pgsql::Connection db_update_conn; + connectForUpdate(db_update_conn); + + QString delete_statement = createDeleteStatement(); + auto tx = Pgsql::Transaction::startTransaction(db_update_conn); + for (const auto &range : row_ranges) + { + for (int current_row = range.start(); current_row < range.end(); ++current_row) + { + const auto &mapping = m_rowMapping[static_cast(current_row)]; + auto params = getPKeyParamsForRow(mapping, m_table->oid()); + if (!params.empty()) + db_update_conn.queryParam(delete_statement, params); + } + } + tx.commit(); } bool CrudModel::removeRows(int row, int count, const QModelIndex &) @@ -719,18 +677,18 @@ bool CrudModel::removeRows(int row, int count, const QModelIndex &) return res; } -void CrudModel::RemoveRangesOfRowsFromModel(const std::set> &row_ranges) +void CrudModel::removeRangesFromModel(const std::set> &row_ranges) { int rows_deleted = 0; for (auto range : row_ranges) { range.setStart(range.start() - rows_deleted); - RemoveRangeOfRowsFromModel(range); + removeRangeFromModel(range); rows_deleted += range.length(); } } -void CrudModel::RemoveRangeOfRowsFromModel(IntegerRange range) +void CrudModel::removeRangeFromModel(IntegerRange range) { beginRemoveRows(QModelIndex(), range.start(), range.end() - 1); SCOPE_EXIT { endRemoveRows(); }; diff --git a/pglab/crud/CrudModel.h b/pglab/crud/CrudModel.h index 485b6c2..f2c8f78 100644 --- a/pglab/crud/CrudModel.h +++ b/pglab/crud/CrudModel.h @@ -60,11 +60,10 @@ signals: /// Emitted when the model goes from clean to having pending changes or back void pendingChangesChanged(bool hasChanges); -public slots: - private: - const int PreColumnCount = 1; + /// Number of grid columns in front of the result columns (the row number column) + static constexpr int PreColumnCount = 1; class ColumnSort { @@ -125,17 +124,6 @@ private: public: using Map = std::map; - PendingRow& getRow(int row) - { - auto iter = m_rows.lower_bound(row); - if (iter != m_rows.end() && iter->first == row) { - return iter->second; - } - else { - return m_rows.insert(iter, {row, PendingRow(row)})->second; - } - } - void setValue(int col, int row, const Value &value) { auto iter = m_rows.find(row); @@ -226,13 +214,7 @@ private: std::shared_ptr m_roData; - struct ColumnData - { - std::string name; - // identity allways or generated - }; - - std::vector> columnList; // attribute per column in m_roData, empty for expression columns + std::vector> m_columnList; // attribute per column in m_roData, empty for expression columns PendingRowList m_pendingRowList; @@ -242,47 +224,64 @@ private: /// call on initial load to fill in the mappings void initRowMapping(); + /// Value as shown in the grid, pending edits take precedence over saved data Value getLatestData(int column, int row) const; Value getLatestData(const QModelIndex &index) const { return getLatestData(index.column() - PreColumnCount, index.row()); } - Value getSavedData(int columnIndex, int rowIndex) const; + /// Value as last loaded from or saved to the database Value getSavedData(const RowMapping &row_mapping, int columnIndex) const; + /// Mapping of the row with the given key, nullptr when the row is not in the grid (anymore) + RowMapping* findRowMapping(int rowKey); + /// Call after changing the pending rows to emit pendingChangesChanged when the state toggled + void updatePendingState(bool was_dirty); + Oid getType(int column) const; - - /// Get the PKey values from when the row was last restored or retrieved. - Pgsql::Params getPKeyParamsForRow(const RowMapping &row_mapping, Oid table_oid) const; + // ---- Saving, all of these only use the data of the pending row and the row mapping bool savePendingChanges(); + /// Inserts the row when it is new otherwise updates it. Returns the values of the row as stored in the database. + std::tuple> saveRow(const PendingRow &pending_row, const RowMapping &row_mapping); + std::tuple> saveUpdate(const PendingRow &pending_row, const RowMapping &row_mapping); - std::tuple createUpdateQuery(Oid table_oid, const PendingRow &pending_row, const RowMapping &row_mapping); + /// Get the PKey values from when the row was last restored or retrieved. + Pgsql::Params getPKeyParamsForRow(const RowMapping &row_mapping, Oid table_oid) const; + std::tuple createUpdateQuery(Oid table_oid, const PendingRow &pending_row, const RowMapping &row_mapping); std::tuple createInsertQuery(const PendingRow &pending_row); - QString createDeleteStatement() const; - std::tuple> saveRow(const PendingRow &pending_row, const RowMapping &row_mapping); - std::tuple> saveUpdate(const PendingRow &pending_row, const RowMapping &row_mapping); + + /// Opens a separate connection for writing so edits don't interfere with the loading connection + void connectForUpdate(Pgsql::Connection &connection) const; + /// Tells the user about a failed write, the one place where the model shows a dialog + void reportError(const QString &message) const; + static std::vector rowValues(const Pgsql::Row &row); + + // ---- Rows void appendNewRow(); - void appendNewRowInternal(); + void appendNewRowInternal(); - int lastRowKey = -1; + int lastRowKey = -1; int allocNewRowKey() { return ++lastRowKey; } + + /// Deletes the rows from the database in one transaction, throws on failure + void deleteRowsFromDatabase(const std::set> &row_ranges); + /// Removes the rows from the grid and drops their pending edits + void removeRangesFromModel(const std::set> &row_ranges); + void removeRangeFromModel(IntegerRange row_range); + + // ---- Table and column information + /// Name of the table with the given oid, quoted and schema qualified QString tableName(Oid table_oid) const; - /// Result columns that are user columns (attnum > 0) of the given table, in result order - std::vector resultColumnsOfTable(Oid table_oid) const; /// Catalog name of the attribute behind a result column, falls back to the result column name QString attributeName(int col) const; + bool columnIsReadOnly(int column_index) const; + void initializeColumnList(); - void RemoveRangesOfRowsFromModel(const std::set> &row_ranges); - void RemoveRangeOfRowsFromModel(IntegerRange row_range); - - bool IsLastRow(RowMappingVector::iterator mapping_iter) const; - bool columnIsReadOnly(int column_index) const; - void initializeColumnList(); private slots: void loadIntoModel(std::shared_ptr data); diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index fbff992..eb33c27 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(IntegrationTests tst_DatabaseSizes.cpp tst_DataGridMapping.cpp tst_DataGridUpdate.cpp + tst_CrudModelBehavior.cpp ${CMAKE_SOURCE_DIR}/pglab/crud/CrudModel.cpp ${CMAKE_SOURCE_DIR}/pglab/crud/DataGridWidget.cpp ${CMAKE_SOURCE_DIR}/pglab/OpenDatabase.cpp diff --git a/tests/integration/DataGridTest.h b/tests/integration/DataGridTest.h index 1d4a0a6..b1b6562 100644 --- a/tests/integration/DataGridTest.h +++ b/tests/integration/DataGridTest.h @@ -9,7 +9,11 @@ #include "crud/DataGridWidget.h" #include "Pgsql_Connection.h" +#include +#include #include +#include "catalog/PgClassContainer.h" +#include "catalog/PgDatabaseCatalog.h" #include #include #include @@ -41,6 +45,26 @@ protected: model->setResult(db, result); } + /// Shows a whole table (CRUD mode) and waits for the asynchronous load to finish + bool showTable(const QString &name) + { + db = openDb(); + auto table = db->catalog()->classes()->findIf([&name](const PgClass &c) + { return c.fullyQualifiedQuotedObjectName().endsWith("." + name); }); + if (!table) + return false; + model = std::make_unique(); + widget = std::make_unique(); + widget->setSourceModel(model.get()); + bool reset = false; + QObject::connect(model.get(), &QAbstractItemModel::modelReset, model.get(), [&reset] { reset = true; }); + model->setConfig(db, *table); + QDeadlineTimer deadline(10000); + while (!reset && !deadline.hasExpired()) + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + return reset; + } + /// Editable flag of every result column, as seen through the widget's proxy std::vector editableColumns() { diff --git a/tests/integration/fixtures/schema.sql b/tests/integration/fixtures/schema.sql index 28d7d1a..28c37a2 100644 --- a/tests/integration/fixtures/schema.sql +++ b/tests/integration/fixtures/schema.sql @@ -31,3 +31,30 @@ CREATE TABLE grid_nopk ( y text ); INSERT INTO grid_nopk (x, y) VALUES (1, 'a'), (2, 'b'); + +CREATE TABLE crud_item ( + id serial PRIMARY KEY, + name text NOT NULL, + qty integer, + flag boolean, + born date, + tag text DEFAULT 'dflt' +); +INSERT INTO crud_item (name, qty, flag, born) VALUES + ('a', 1, true, '2020-01-02'), + ('b', 2, false, NULL), + ('c', NULL, NULL, '2021-03-04'), + ('d', 4, true, NULL), + ('e', 5, false, NULL); + +CREATE TABLE crud_empty ( + id serial PRIMARY KEY, + v text +); + +CREATE TABLE crud_gen ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + a integer, + b integer GENERATED ALWAYS AS (a * 2) STORED +); +INSERT INTO crud_gen (a) VALUES (1), (2); diff --git a/tests/integration/tst_CrudModelBehavior.cpp b/tests/integration/tst_CrudModelBehavior.cpp new file mode 100644 index 0000000..76c7ed2 --- /dev/null +++ b/tests/integration/tst_CrudModelBehavior.cpp @@ -0,0 +1,656 @@ +// Characterization tests for CrudModel, they lock in the behavior of the model +// so it can be refactored safely. +#include "DataGridTest.h" + +#include "CustomDataRole.h" +#include "Pgsql_oids.h" + +#include +#include +#include +#include + +namespace { + +/// Failed saves show a modal message box, this closes it so the test can continue +void autoCloseModalDialogs(int triesLeft = 100) +{ + if (auto w = QApplication::activeModalWidget()) + w->close(); + else if (triesLeft > 0) + QTimer::singleShot(20, [triesLeft] { autoCloseModalDialogs(triesLeft - 1); }); +} + +/// Records the values of CrudModel::pendingChangesChanged +struct PendingSpy { + explicit PendingSpy(CrudModel *m) + { + QObject::connect(m, &CrudModel::pendingChangesChanged, m, [this](bool b) { values.push_back(b); }); + } + std::vector values; +}; + +/// Counts the emissions of a signal +struct CountSpy { + template + CountSpy(Sender *sender, Func signal) + { + QObject::connect(sender, signal, sender, [this] { ++n; }); + } + int count() const { return n; } + int n = 0; +}; + +// crud_item columns +constexpr int ColId = 0; +constexpr int ColName = 1; +constexpr int ColQty = 2; +constexpr int ColFlag = 3; +constexpr int ColBorn = 4; +constexpr int ColTag = 5; + +class CrudModelBehaviorTest : public DataGridTest { +protected: + QModelIndex idx(int row, int resultCol) const { return model->index(row, resultCol + 1); } + + QVariant cell(int row, int resultCol, int role = Qt::DisplayRole) const + { + return model->data(idx(row, resultCol), role); + } + + bool edit(int row, int resultCol, const QString &value) + { + return model->setData(idx(row, resultCol), value, Qt::EditRole); + } + + /// Saves while auto closing any error dialog + bool save() + { + QTimer::singleShot(0, [] { autoCloseModalDialogs(); }); + return model->saveChanges(); + } + + /// Row of crud_item with the given name, -1 if not shown + int rowOfName(const QString &name) const + { + for (int r = 0; r < model->rowCount(); ++r) + if (cell(r, ColName, Qt::EditRole).toString() == name) + return r; + return -1; + } + + QStringList names() const + { + QStringList l; + for (int r = 0; r < model->rowCount(); ++r) + l << cell(r, ColName, Qt::EditRole).toString(); + return l; + } + + void reload() + { + bool reset = false; + QObject::connect(model.get(), &QAbstractItemModel::modelReset, model.get(), [&reset] { reset = true; }); + model->loadData(); + QDeadlineTimer deadline(10000); + while (!reset && !deadline.hasExpired()) + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + ASSERT_TRUE(reset); + } +}; + +} // namespace + +// ---- loading, headers, row number column + +TEST_F(CrudModelBehaviorTest, WholeTableLoadsRowsPlusEmptyNewRow) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_EQ(model->rowCount(), 6); + EXPECT_EQ(model->columnCount(), 7); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", ""})); + for (int c = 0; c <= ColTag; ++c) + EXPECT_FALSE(cell(5, c).isValid()) << c; +} + +TEST_F(CrudModelBehaviorTest, EmptyTableHasOnlyTheNewRow) +{ + ASSERT_TRUE(showTable("crud_empty")); + EXPECT_EQ(model->rowCount(), 1); +} + +TEST_F(CrudModelBehaviorTest, QueryResultHasNoNewRow) +{ + showQuery("SELECT id, name FROM grid_parent"); + EXPECT_EQ(model->rowCount(), 3); +} + +TEST_F(CrudModelBehaviorTest, HeaderShowsNameAndType) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_EQ(model->headerData(0, Qt::Horizontal).toString(), "#"); + for (int c = 0; c <= ColTag; ++c) + { + auto text = model->headerData(c + 1, Qt::Horizontal, Qt::DisplayRole).toString(); + EXPECT_EQ(text.count('\n'), 1) << c; + EXPECT_FALSE(text.section('\n', 1).isEmpty()) << c; + } + EXPECT_EQ(columnName(ColId), "id"); + EXPECT_EQ(columnName(ColName), "name"); + EXPECT_EQ(columnName(ColTag), "tag"); + EXPECT_FALSE(model->headerData(1, Qt::Horizontal, Qt::EditRole).isValid()); +} + +TEST_F(CrudModelBehaviorTest, RowNumberColumnData) +{ + ASSERT_TRUE(showTable("crud_item")); + for (int r = 0; r < model->rowCount(); ++r) + { + auto i = model->index(r, 0); + EXPECT_EQ(model->data(i, Qt::DisplayRole).toInt(), r + 1); + EXPECT_EQ(model->data(i, CustomSortRole).toInt(), r + 1); + EXPECT_EQ(model->data(i, CustomDataTypeRole).toUInt(), static_cast(Pgsql::int4_oid)); + EXPECT_FALSE(model->data(i, Qt::EditRole).isValid()); + EXPECT_FALSE(model->data(i, Qt::ForegroundRole).isValid()); + } + EXPECT_FALSE(model->setData(model->index(0, 0), "5", Qt::EditRole)); + EXPECT_FALSE(model->hasPendingChanges()); +} + +// ---- data roles + +TEST_F(CrudModelBehaviorTest, DisplayAndEditRoles) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_EQ(cell(0, ColName).toString(), "a"); + EXPECT_EQ(cell(0, ColQty).toString(), "1"); + EXPECT_EQ(cell(0, ColQty, Qt::EditRole).toString(), "1"); + EXPECT_EQ(cell(0, ColBorn).toString(), "2020-01-02"); + EXPECT_EQ(cell(0, ColTag).toString(), "dflt"); + // NULL + EXPECT_FALSE(cell(1, ColBorn).isValid()); + EXPECT_FALSE(cell(1, ColBorn, Qt::EditRole).isValid()); + // Roles that are not handled + EXPECT_FALSE(cell(0, ColName, Qt::ToolTipRole).isValid()); + EXPECT_FALSE(cell(0, ColName, Qt::ForegroundRole).isValid()); +} + +TEST_F(CrudModelBehaviorTest, BoolDisplaysAsBool) +{ + ASSERT_TRUE(showTable("crud_item")); + auto t = cell(0, ColFlag); + EXPECT_EQ(t.typeId(), QMetaType::Bool); + EXPECT_TRUE(t.toBool()); + auto f = cell(1, ColFlag); + EXPECT_EQ(f.typeId(), QMetaType::Bool); + EXPECT_FALSE(f.toBool()); + EXPECT_FALSE(cell(2, ColFlag).isValid()); + EXPECT_EQ(cell(0, ColFlag, Qt::EditRole).toString(), "t"); +} + +TEST_F(CrudModelBehaviorTest, DisplayTruncatesButEditDoesNot) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(0, ColName, QString(300, 'x'))); + EXPECT_EQ(cell(0, ColName, Qt::DisplayRole).toString().size(), 256); + EXPECT_EQ(cell(0, ColName, Qt::EditRole).toString().size(), 300); +} + +TEST_F(CrudModelBehaviorTest, SortRoleGivesTypedValues) +{ + ASSERT_TRUE(showTable("crud_item")); + auto i = cell(0, ColQty, CustomSortRole); + EXPECT_EQ(i.typeId(), QMetaType::Int); + EXPECT_EQ(i.toInt(), 1); + EXPECT_EQ(cell(0, ColBorn, CustomSortRole).toDate(), QDate(2020, 1, 2)); + EXPECT_EQ(cell(0, ColBorn, CustomSortRole).typeId(), QMetaType::QDate); + auto b = cell(0, ColFlag, CustomSortRole); + EXPECT_EQ(b.typeId(), QMetaType::Bool); + EXPECT_TRUE(b.toBool()); + EXPECT_EQ(cell(0, ColName, CustomSortRole).toString(), "a"); + EXPECT_FALSE(cell(1, ColBorn, CustomSortRole).isValid()); + EXPECT_FALSE(cell(2, ColQty, CustomSortRole).isValid()); +} + +TEST_F(CrudModelBehaviorTest, DataTypeRole) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_EQ(cell(0, ColId, CustomDataTypeRole).toUInt(), static_cast(Pgsql::int4_oid)); + EXPECT_EQ(cell(0, ColName, CustomDataTypeRole).toUInt(), static_cast(Pgsql::text_oid)); + EXPECT_EQ(cell(0, ColFlag, CustomDataTypeRole).toUInt(), static_cast(Pgsql::bool_oid)); + EXPECT_EQ(cell(0, ColBorn, CustomDataTypeRole).toUInt(), static_cast(Pgsql::date_oid)); + // also for the new row + EXPECT_EQ(cell(5, ColQty, CustomDataTypeRole).toUInt(), static_cast(Pgsql::int4_oid)); +} + +TEST_F(CrudModelBehaviorTest, ReadOnlyColumnsAreGrayInQueryMode) +{ + showQuery("SELECT id, name || 'x' AS e FROM grid_parent"); + EXPECT_FALSE(cell(0, 0, Qt::ForegroundRole).isValid()); + auto brush = cell(0, 1, Qt::ForegroundRole); + ASSERT_TRUE(brush.isValid()); + EXPECT_EQ(brush.value().color(), QColor(Qt::gray)); +} + +// ---- flags + +TEST_F(CrudModelBehaviorTest, FlagsOfGeneratedAndIdentityColumns) +{ + ASSERT_TRUE(showTable("crud_gen")); + EXPECT_EQ(editableColumns(), (std::vector{false, true, false})); + auto f = model->flags(model->index(0, 0)); + EXPECT_TRUE(f & Qt::ItemIsSelectable); + EXPECT_TRUE(f & Qt::ItemIsEnabled); + EXPECT_FALSE(f & Qt::ItemIsEditable); +} + +TEST_F(CrudModelBehaviorTest, FlagsOfWholeTable) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_EQ(editableColumns(), (std::vector(6, true))); +} + +TEST_F(CrudModelBehaviorTest, ExpressionColumnIsNotEditable) +{ + showQuery("SELECT id, name || 'x' AS e FROM grid_parent"); + EXPECT_EQ(editableColumns(), (std::vector{true, false})); +} + +TEST_F(CrudModelBehaviorTest, TableWithoutPkIsNotEditable) +{ + ASSERT_TRUE(showTable("grid_nopk")); + EXPECT_EQ(editableColumns(), (std::vector{false, false})); +} + +// ---- setData + +TEST_F(CrudModelBehaviorTest, SetDataRejectsOtherRoles) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_FALSE(model->setData(idx(0, ColName), "x", Qt::DisplayRole)); + EXPECT_FALSE(model->hasPendingChanges()); +} + +TEST_F(CrudModelBehaviorTest, SetDataShowsPendingValueAndEmitsDataChanged) +{ + ASSERT_TRUE(showTable("crud_item")); + CountSpy spy(model.get(), &QAbstractItemModel::dataChanged); + ASSERT_TRUE(edit(0, ColName, "changed")); + EXPECT_EQ(spy.count(), 1); + EXPECT_TRUE(model->hasPendingChanges()); + EXPECT_EQ(cell(0, ColName).toString(), "changed"); + EXPECT_EQ(cell(1, ColName).toString(), "b"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "a"); +} + +TEST_F(CrudModelBehaviorTest, EmptyStringBecomesNullAndQuotesBecomeEmptyString) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(0, ColTag, "")); + ASSERT_TRUE(edit(1, ColTag, "''")); + EXPECT_FALSE(cell(0, ColTag).isValid()); + EXPECT_TRUE(cell(1, ColTag).isValid()); + EXPECT_EQ(cell(1, ColTag).toString(), ""); + ASSERT_TRUE(save()); + + EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'a'"), std::nullopt); + EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'b'"), ""); + EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'c'"), "dflt"); +} + +TEST_F(CrudModelBehaviorTest, RepeatedEditsOfSameCellKeepLastValue) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + ASSERT_TRUE(edit(0, ColName, "one")); + ASSERT_TRUE(edit(0, ColName, "two")); + ASSERT_TRUE(edit(1, ColName, "three")); + EXPECT_EQ(spy.values, (std::vector{true})); + ASSERT_TRUE(save()); + EXPECT_EQ(spy.values, (std::vector{true, false})); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "two"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 2"), "three"); +} + +// ---- saving updates + +TEST_F(CrudModelBehaviorTest, SavedUpdateIsShownAfterwards) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(2, ColQty, "42")); + ASSERT_TRUE(save()); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(cell(2, ColQty).toString(), "42"); + EXPECT_EQ(cell(2, ColName).toString(), "c"); + EXPECT_EQ(cell(2, ColBorn).toString(), "2021-03-04"); + EXPECT_EQ(model->rowCount(), 6); + EXPECT_EQ(dbValue("SELECT qty FROM crud_item WHERE name = 'c'"), "42"); +} + +TEST_F(CrudModelBehaviorTest, SaveWithoutChangesIsOk) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + EXPECT_TRUE(model->saveChanges()); + EXPECT_TRUE(spy.values.empty()); +} + +TEST_F(CrudModelBehaviorTest, RevertThenEditAgain) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + ASSERT_TRUE(edit(0, ColName, "discarded")); + model->revertChanges(); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(cell(0, ColName).toString(), "a"); + model->revertChanges(); // nothing to revert, no signal + ASSERT_TRUE(edit(0, ColName, "kept")); + EXPECT_EQ(cell(0, ColName).toString(), "kept"); + ASSERT_TRUE(save()); + EXPECT_EQ(spy.values, (std::vector{true, false, true, false})); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "kept"); +} + +TEST_F(CrudModelBehaviorTest, RevertDoesNotRemoveSavedValues) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(0, ColName, "saved")); + ASSERT_TRUE(save()); + ASSERT_TRUE(edit(0, ColName, "unsaved")); + model->revertChanges(); + EXPECT_EQ(cell(0, ColName).toString(), "saved"); +} + +TEST_F(CrudModelBehaviorTest, PartialFailureKeepsOnlyFailingRowPending) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + ASSERT_TRUE(edit(0, ColName, "ok")); + ASSERT_TRUE(edit(1, ColName, "")); // NULL violates NOT NULL + ASSERT_TRUE(edit(2, ColName, "never saved")); + EXPECT_FALSE(save()); + + EXPECT_TRUE(model->hasPendingChanges()); + EXPECT_EQ(spy.values, (std::vector{true})); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 1"), "ok"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 2"), "b"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 3"), "c"); + EXPECT_EQ(cell(0, ColName).toString(), "ok"); + EXPECT_FALSE(cell(1, ColName).isValid()); // still shows the pending value + + // Fixing the failing row lets the rest be saved + ASSERT_TRUE(edit(1, ColName, "fixed")); + ASSERT_TRUE(save()); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(spy.values, (std::vector{true, false})); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 2"), "fixed"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 3"), "never saved"); +} + +TEST_F(CrudModelBehaviorTest, UpdateOfRowChangedElsewhereFails) +{ + ASSERT_TRUE(showTable("crud_item")); + conn().query("DELETE FROM crud_item WHERE id = 1"); + ASSERT_TRUE(edit(0, ColName, "gone")); + EXPECT_FALSE(save()); + EXPECT_TRUE(model->hasPendingChanges()); +} + +// ---- reload + +TEST_F(CrudModelBehaviorTest, ReloadClearsPendingChangesAndShowsDbState) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(0, ColName, "pending")); + conn().query("INSERT INTO crud_item (name) VALUES ('f')"); + PendingSpy spy(model.get()); + reload(); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(spy.values, (std::vector{false})); + EXPECT_EQ(model->rowCount(), 7); + EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", "f", ""})); +} + +TEST_F(CrudModelBehaviorTest, ReloadWithoutPendingChangesEmitsNoSignal) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + reload(); + EXPECT_TRUE(spy.values.empty()); + EXPECT_EQ(model->rowCount(), 6); +} + +// ---- insert + +TEST_F(CrudModelBehaviorTest, InsertNewRow) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + ASSERT_TRUE(edit(5, ColName, "new")); + ASSERT_TRUE(edit(5, ColQty, "7")); + EXPECT_EQ(spy.values, (std::vector{true})); + ASSERT_TRUE(save()); + + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(spy.values, (std::vector{true, false})); + EXPECT_EQ(model->rowCount(), 7); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "6"); + EXPECT_EQ(dbValue("SELECT qty FROM crud_item WHERE name = 'new'"), "7"); + + // RETURNING * brings the defaults into the model + EXPECT_EQ(cell(5, ColName).toString(), "new"); + EXPECT_EQ(cell(5, ColQty).toString(), "7"); + EXPECT_EQ(cell(5, ColTag).toString(), "dflt"); + EXPECT_EQ(cell(5, ColId).toString(), *dbValue("SELECT id FROM crud_item WHERE name = 'new'")); + EXPECT_FALSE(cell(5, ColBorn).isValid()); + + // and a fresh empty row is appended + EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", "new", ""})); + EXPECT_FALSE(cell(6, ColId).isValid()); +} + +TEST_F(CrudModelBehaviorTest, InsertTwoRowsInARow) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(5, ColName, "n1")); + ASSERT_TRUE(save()); + ASSERT_TRUE(edit(6, ColName, "n2")); + ASSERT_TRUE(save()); + EXPECT_EQ(model->rowCount(), 8); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "7"); + EXPECT_EQ(names(), (QStringList{"a", "b", "c", "d", "e", "n1", "n2", ""})); +} + +TEST_F(CrudModelBehaviorTest, InsertIntoEmptyTable) +{ + ASSERT_TRUE(showTable("crud_empty")); + ASSERT_TRUE(edit(0, 1, "first")); + ASSERT_TRUE(save()); + EXPECT_EQ(model->rowCount(), 2); + EXPECT_EQ(dbValue("SELECT v FROM crud_empty"), "first"); + EXPECT_EQ(cell(0, 0).toString(), "1"); +} + +TEST_F(CrudModelBehaviorTest, InsertWithExplicitNull) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(5, ColName, "nulltag")); + ASSERT_TRUE(edit(5, ColTag, "")); + ASSERT_TRUE(save()); + EXPECT_EQ(dbValue("SELECT tag FROM crud_item WHERE name = 'nulltag'"), std::nullopt); + EXPECT_FALSE(cell(5, ColTag).isValid()); +} + +TEST_F(CrudModelBehaviorTest, FailedInsertKeepsRowPending) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(5, ColQty, "9")); // name is NOT NULL and missing + EXPECT_FALSE(save()); + EXPECT_TRUE(model->hasPendingChanges()); + EXPECT_EQ(model->rowCount(), 6); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5"); + EXPECT_EQ(cell(5, ColQty).toString(), "9"); + + ASSERT_TRUE(edit(5, ColName, "now valid")); + ASSERT_TRUE(save()); + EXPECT_EQ(model->rowCount(), 7); + EXPECT_EQ(dbValue("SELECT qty FROM crud_item WHERE name = 'now valid'"), "9"); +} + +TEST_F(CrudModelBehaviorTest, EditingOnlyThenRevertingNewRowDoesNotInsert) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(edit(5, ColName, "temp")); + model->revertChanges(); + EXPECT_FALSE(cell(5, ColName).isValid()); + EXPECT_EQ(model->rowCount(), 6); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5"); +} + +// ---- delete + +TEST_F(CrudModelBehaviorTest, RemoveSingleRow) +{ + ASSERT_TRUE(showTable("crud_item")); + CountSpy spy(model.get(), &QAbstractItemModel::rowsRemoved); + auto [ok, msg] = model->removeRows({IntegerRange(1, 1)}); + EXPECT_TRUE(ok) << msg.toStdString(); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(names(), (QStringList{"a", "c", "d", "e", ""})); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "4"); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item WHERE name = 'b'"), "0"); + EXPECT_EQ(model->data(model->index(4, 0), Qt::DisplayRole).toInt(), 5); +} + +TEST_F(CrudModelBehaviorTest, RemoveContiguousRange) +{ + ASSERT_TRUE(showTable("crud_item")); + auto [ok, msg] = model->removeRows({IntegerRange(1, 3)}); + EXPECT_TRUE(ok) << msg.toStdString(); + EXPECT_EQ(names(), (QStringList{"a", "e", ""})); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "2"); +} + +TEST_F(CrudModelBehaviorTest, RemoveNonContiguousRanges) +{ + ASSERT_TRUE(showTable("crud_item")); + auto [ok, msg] = model->removeRows({IntegerRange(0, 1), IntegerRange(2, 2)}); + EXPECT_TRUE(ok) << msg.toStdString(); + EXPECT_EQ(names(), (QStringList{"b", "e", ""})); + EXPECT_EQ(dbValue("SELECT string_agg(name, ',' ORDER BY name) FROM crud_item"), "b,e"); +} + +TEST_F(CrudModelBehaviorTest, RemoveRowsOverloadForViews) +{ + ASSERT_TRUE(showTable("crud_item")); + EXPECT_TRUE(model->removeRows(0, 2)); + EXPECT_EQ(names(), (QStringList{"c", "d", "e", ""})); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "3"); +} + +TEST_F(CrudModelBehaviorTest, RemoveNothingIsOk) +{ + ASSERT_TRUE(showTable("crud_item")); + auto [ok, msg] = model->removeRows({}); + EXPECT_TRUE(ok); + EXPECT_EQ(model->rowCount(), 6); +} + +TEST_F(CrudModelBehaviorTest, RemoveDropsPendingEditsOfRemovedRows) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + ASSERT_TRUE(edit(1, ColName, "edited then removed")); + auto [ok, msg] = model->removeRows({IntegerRange(1, 1)}); + EXPECT_TRUE(ok); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(spy.values, (std::vector{true, false})); +} + +TEST_F(CrudModelBehaviorTest, PendingEditsOfOtherRowsSurviveRemoval) +{ + ASSERT_TRUE(showTable("crud_item")); + PendingSpy spy(model.get()); + ASSERT_TRUE(edit(3, ColName, "d edited")); + auto [ok, msg] = model->removeRows({IntegerRange(0, 3)}); + EXPECT_TRUE(ok); + EXPECT_TRUE(model->hasPendingChanges()); + EXPECT_EQ(spy.values, (std::vector{true})); + EXPECT_EQ(names(), (QStringList{"d edited", "e", ""})); + + ASSERT_TRUE(save()); + EXPECT_FALSE(model->hasPendingChanges()); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 4"), "d edited"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 5"), "e"); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "2"); +} + +TEST_F(CrudModelBehaviorTest, EditAfterRemovalUpdatesTheRightRow) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(std::get<0>(model->removeRows({IntegerRange(0, 3)}))); + ASSERT_TRUE(edit(1, ColName, "e edited")); + ASSERT_TRUE(save()); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 5"), "e edited"); + EXPECT_EQ(dbValue("SELECT name FROM crud_item WHERE id = 4"), "d"); +} + +TEST_F(CrudModelBehaviorTest, InsertAfterRemoval) +{ + ASSERT_TRUE(showTable("crud_item")); + ASSERT_TRUE(std::get<0>(model->removeRows({IntegerRange(0, 1)}))); + ASSERT_TRUE(edit(model->rowCount() - 1, ColName, "after delete")); + ASSERT_TRUE(save()); + EXPECT_EQ(names(), (QStringList{"b", "c", "d", "e", "after delete", ""})); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5"); +} + +TEST_F(CrudModelBehaviorTest, RemoveOutOfRangeFails) +{ + ASSERT_TRUE(showTable("crud_item")); + auto [ok, msg] = model->removeRows({IntegerRange(5, 5)}); + EXPECT_FALSE(ok); + EXPECT_FALSE(msg.isEmpty()); + EXPECT_EQ(model->rowCount(), 6); + EXPECT_EQ(dbValue("SELECT count(*) FROM crud_item"), "5"); +} + +TEST_F(CrudModelBehaviorTest, RemoveFromQueryResultFails) +{ + showQuery("SELECT id, name FROM grid_parent"); + auto [ok, msg] = model->removeRows({IntegerRange(0, 1)}); + EXPECT_FALSE(ok); + EXPECT_FALSE(msg.isEmpty()); + EXPECT_EQ(model->rowCount(), 3); + EXPECT_EQ(dbValue("SELECT count(*) FROM grid_parent"), "3"); +} + +TEST_F(CrudModelBehaviorTest, RemoveFromTableWithoutPkFails) +{ + ASSERT_TRUE(showTable("grid_nopk")); + auto [ok, msg] = model->removeRows({IntegerRange(0, 1)}); + EXPECT_FALSE(ok); + EXPECT_FALSE(msg.isEmpty()); + EXPECT_EQ(model->rowCount(), 3); + EXPECT_EQ(dbValue("SELECT count(*) FROM grid_nopk"), "2"); +} + +TEST_F(CrudModelBehaviorTest, RemoveRowReferencedByForeignKeyFails) +{ + conn().query("ALTER TABLE grid_child ADD FOREIGN KEY (parent_id) REFERENCES grid_parent (id)"); + ASSERT_TRUE(showTable("grid_parent")); + auto [ok, msg] = model->removeRows({IntegerRange(0, 1)}); + EXPECT_FALSE(ok); + EXPECT_EQ(model->rowCount(), 4); + EXPECT_EQ(dbValue("SELECT count(*) FROM grid_parent"), "3"); +} + +TEST_F(CrudModelBehaviorTest, RemoveFromCompositePkTable) +{ + ASSERT_TRUE(showTable("grid_composite")); + auto [ok, msg] = model->removeRows({IntegerRange(1, 1)}); + EXPECT_TRUE(ok) << msg.toStdString(); + EXPECT_EQ(dbValue("SELECT count(*) FROM grid_composite"), "2"); + EXPECT_EQ(dbValue("SELECT count(*) FROM grid_composite WHERE a = 1 AND b = 2"), "0"); +}