Initial support for multiple primary key columns in WITHOUT ROWID tables

This add initial and mostly untested support for WITHOUT ROWID tables
with multiple primary key columns. It should now be possible to update
and to delete records in these tables.

This commit also improves the overall handling of multiple primary key
columns in preparation for better support of them in general.

Note that this makes us depend on an SQLite version with a built-in JSON
extension.

See issues #516, #1075, and #1834.
This commit is contained in:
Martin Kleusberg
2019-04-01 21:28:34 +02:00
parent b788e2ddc8
commit a615c7b5a0
11 changed files with 146 additions and 90 deletions

View File

@@ -372,8 +372,8 @@ void EditTableDialog::itemChanged(QTreeWidgetItem *item, int column)
// we need to check for this case and cancel here. Maybe we can think of some way to modify the INSERT INTO ... SELECT statement
// to at least replace all troublesome NULL values by the default value
SqliteTableModel m(pdb, this);
m.setQuery(QString("SELECT COUNT(%1) FROM %2 WHERE %3 IS NULL;")
.arg(sqlb::escapeIdentifier(pdb.getObjectByName<sqlb::Table>(curTable)->rowidColumn()))
m.setQuery(QString("SELECT COUNT(%1) FROM %2 WHERE coalesce(NULL,%3) IS NULL;")
.arg(sqlb::escapeIdentifier(pdb.getObjectByName<sqlb::Table>(curTable)->rowidColumns()).join(","))
.arg(curTable.toString())
.arg(sqlb::escapeIdentifier(field.name())));
if(!m.completeCache())
@@ -666,27 +666,31 @@ void EditTableDialog::setWithoutRowid(bool without_rowid)
if(without_rowid)
{
// Before setting the without rowid flag, first perform a check to see if the table meets all the required criteria for without rowid tables
auto pk = m_table.findPk();
if(pk == m_table.fields.end() || pk->autoIncrement())
auto pks = m_table.primaryKey();
for(const auto& pk_name : pks)
{
QMessageBox::information(this, QApplication::applicationName(),
tr("Please add a field which meets the following criteria before setting the without rowid flag:\n"
" - Primary key flag set\n"
" - Auto increment disabled"));
auto pk = sqlb::findField(m_table, pk_name);
if(pk == m_table.fields.end() || pk->autoIncrement())
{
QMessageBox::information(this, QApplication::applicationName(),
tr("Please add a field which meets the following criteria before setting the without rowid flag:\n"
" - Primary key flag set\n"
" - Auto increment disabled"));
// Reset checkbox state to unchecked. Block any signals while doing this in order to avoid an extra call to
// this function being triggered.
ui->checkWithoutRowid->blockSignals(true);
ui->checkWithoutRowid->setChecked(false);
ui->checkWithoutRowid->blockSignals(false);
return;
// Reset checkbox state to unchecked. Block any signals while doing this in order to avoid an extra call to
// this function being triggered.
ui->checkWithoutRowid->blockSignals(true);
ui->checkWithoutRowid->setChecked(false);
ui->checkWithoutRowid->blockSignals(false);
return;
}
}
// If it does, override the the rowid column name of the table object with the name of the primary key.
m_table.setRowidColumn(pk->name());
// If it does, override the the rowid column names of the table object with the names of the primary keys.
m_table.setRowidColumns(pks);
} else {
// If the without rowid flag is unset no further checks are required. Just set the rowid column name back to "_rowid_"
m_table.setRowidColumn("_rowid_");
m_table.setRowidColumns({"_rowid_"});
}
// Update the SQL preview

View File

@@ -140,7 +140,7 @@ QWidget* ExtendedTableWidgetEditorDelegate::createEditor(QWidget* parent, const
// If no column name is set, assume the primary key is meant
if(fk.columns().isEmpty()) {
sqlb::TablePtr obj = m->db().getObjectByName<sqlb::Table>(foreignTable);
column = obj->findPk()->name();
column = obj->primaryKey().first();
} else
column = fk.columns().at(0);

View File

@@ -967,7 +967,7 @@ void MainWindow::addRecord()
void MainWindow::insertValues()
{
QString pseudo_pk = m_browseTableModel->hasPseudoPk() ? m_browseTableModel->pseudoPk() : QString();
QString pseudo_pk = m_browseTableModel->hasPseudoPk() ? QString::fromStdString(m_browseTableModel->pseudoPk().front()) : QString();
AddRecordDialog dialog(db, currentlyBrowsedTableName(), this, pseudo_pk);
if (dialog.exec())
populateTable();
@@ -3314,7 +3314,7 @@ void MainWindow::jumpToRow(const sqlb::ObjectIdentifier& table, QString column,
// If no column name is set, assume the primary key is meant
if(!column.size())
column = obj->findPk()->name();
column = obj->primaryKey().first();
// If column doesn't exist don't do anything
auto column_index = sqlb::findField(obj, column);
@@ -3564,7 +3564,7 @@ void MainWindow::unlockViewEditing(bool unlock, QString pk)
// (De)activate editing
enableEditing(unlock);
m_browseTableModel->setPseudoPk(pk);
m_browseTableModel->setPseudoPk({pk.toStdString()});
// Update checked status of the popup menu action
ui->actionUnlockViewEditing->blockSignals(true);

View File

@@ -12,7 +12,7 @@ Query::Query()
void Query::clear()
{
m_table.clear();
m_rowid_column = "_rowid_";
m_rowid_columns = {"_rowid_"};
m_selected_columns.clear();
m_where.clear();
m_sort.clear();
@@ -45,7 +45,20 @@ std::string Query::buildQuery(bool withRowid) const
// Selector and display formats
std::string selector;
if (withRowid)
selector = sqlb::escapeIdentifier(m_rowid_column) + ",";
{
// We select the rowid data into a JSON array in case there are multiple rowid columns in order to have all values at hand.
// If there is only one rowid column, we leave it as is.
if(m_rowid_columns.size() == 1)
{
selector = sqlb::escapeIdentifier(m_rowid_columns.at(0)) + ",";
} else {
selector += "json_array(";
for(size_t i=0;i<m_rowid_columns.size();i++)
selector += sqlb::escapeIdentifier(m_rowid_columns.at(i)) + ",";
selector.pop_back(); // Remove the last comma
selector += "),";
}
}
if(m_selected_columns.empty())
{

View File

@@ -61,9 +61,10 @@ public:
void setTable(const sqlb::ObjectIdentifier& table) { m_table = table; }
sqlb::ObjectIdentifier table() const { return m_table; }
void setRowIdColumn(const std::string& rowid) { m_rowid_column = rowid; }
std::string rowIdColumn() const { return m_rowid_column; }
bool hasCustomRowIdColumn() const { return m_rowid_column != "rowid" && m_rowid_column != "_rowid_"; }
void setRowIdColumns(const std::vector<std::string>& rowids) { m_rowid_columns = rowids; }
std::vector<std::string> rowIdColumns() const { return m_rowid_columns; }
void setRowIdColumn(const std::string& rowid) { m_rowid_columns = {rowid}; }
bool hasCustomRowIdColumn() const { return m_rowid_columns.size() != 1 || (m_rowid_columns.at(0) != "rowid" && m_rowid_columns.at(0) != "_rowid_"); }
const std::vector<SelectedColumn>& selectedColumns() const { return m_selected_columns; }
std::vector<SelectedColumn>& selectedColumns() { return m_selected_columns; }
@@ -78,7 +79,7 @@ public:
private:
std::vector<std::string> m_column_names;
sqlb::ObjectIdentifier m_table;
std::string m_rowid_column;
std::vector<std::string> m_rowid_columns;
std::vector<SelectedColumn> m_selected_columns;
std::unordered_map<int, std::string> m_where;
std::vector<SortedColumn> m_sort;

View File

@@ -333,7 +333,7 @@ Table& Table::operator=(const Table& rhs)
Object::operator=(rhs);
// Just assign the strings
m_rowidColumn = rhs.m_rowidColumn;
m_rowidColumns = rhs.m_rowidColumns;
m_virtual = rhs.m_virtual;
// Clear the fields and the constraints first in order to avoid duplicates and/or old data in the next step
@@ -354,7 +354,7 @@ bool Table::operator==(const Table& rhs) const
if(!Object::operator==(rhs))
return false;
if(m_rowidColumn != rhs.m_rowidColumn)
if(m_rowidColumns != rhs.m_rowidColumns)
return false;
if(m_virtual != rhs.m_virtual)
return false;
@@ -389,17 +389,6 @@ bool Table::operator==(const Table& rhs) const
return true;
}
Table::field_iterator Table::findPk()
{
// TODO This is a stupid function (and always was) which should be fixed/improved
QStringList pk = primaryKey();
if(pk.empty())
return fields.end();
else
return findField(this, pk.at(0));
}
QStringList Table::fieldList() const
{
QStringList sl;
@@ -1009,7 +998,7 @@ TablePtr CreateTableWalker::table()
s = s->getNextSibling(); // WITHOUT
s = s->getNextSibling(); // ROWID
tab->setRowidColumn(tab->findPk()->name());
tab->setRowidColumns(tab->primaryKey());
}
}
}

View File

@@ -401,7 +401,7 @@ private:
class Table : public Object
{
public:
explicit Table(const QString& name): Object(name), m_rowidColumn("_rowid_") {}
explicit Table(const QString& name): Object(name), m_rowidColumns({"_rowid_"}) {}
Table& operator=(const Table& rhs);
bool operator==(const Table& rhs) const;
@@ -420,9 +420,9 @@ public:
QStringList fieldNames() const;
void setRowidColumn(const QString& rowid) { m_rowidColumn = rowid; }
const QString& rowidColumn() const { return m_rowidColumn; }
bool isWithoutRowidTable() const { return m_rowidColumn != "_rowid_"; }
void setRowidColumns(const QStringList& rowid) { m_rowidColumns = rowid; }
const QStringList& rowidColumns() const { return m_rowidColumns; }
bool isWithoutRowidTable() const { return m_rowidColumns != (QStringList() << "_rowid_"); }
void setVirtualUsing(const QString& virt_using) { m_virtual = virt_using; }
QString virtualUsing() const { return m_virtual; }
@@ -442,8 +442,6 @@ public:
void removeKeyFromAllConstraints(const QString& key);
void renameKeyInAllConstraints(const QString& key, const QString& to);
field_iterator findPk();
/**
* @brief parseSQL Parses the create Table statement in sSQL.
* @param sSQL The create table statement.
@@ -455,7 +453,7 @@ private:
bool hasAutoIncrement() const;
private:
QString m_rowidColumn;
QStringList m_rowidColumns;
ConstraintMap m_constraints;
QString m_virtual;
};

View File

@@ -1141,10 +1141,19 @@ bool DBBrowserDB::getRow(const sqlb::ObjectIdentifier& table, const QString& row
if(!_db)
return false;
QString sQuery = QString("SELECT * FROM %1 WHERE %2='%3';")
.arg(table.toString())
.arg(sqlb::escapeIdentifier(getObjectByName<sqlb::Table>(table)->rowidColumn()))
.arg(rowid);
QString sQuery = QString("SELECT * FROM %1 WHERE ")
.arg(table.toString());
// For a single rowid column we can use a simple WHERE condition, for multiple rowid columns we have to use json_array to decode the composed rowid values.
QStringList pks = getObjectByName<sqlb::Table>(table)->rowidColumns();
if(pks.size() == 1)
{
sQuery += QString("%1='%2;").arg(sqlb::escapeIdentifier(pks.front())).arg(rowid);
} else {
sQuery += QString("json_array(%1)='%2';")
.arg(sqlb::escapeIdentifier(pks).join(","))
.arg(QString(rowid).replace("'", "''"));
}
QByteArray utf8Query = sQuery.toUtf8();
sqlite3_stmt *stmt;
@@ -1270,7 +1279,9 @@ QString DBBrowserDB::addRecord(const sqlb::ObjectIdentifier& tablename)
QString pk_value;
if(table->isWithoutRowidTable())
{
pk_value = QString::number(max(tablename, *sqlb::findField(table, table->rowidColumn())).toLongLong() + 1);
// For multiple rowid columns we just use the value of the last one and increase that one by one. If this doesn't yield a valid combination
// the insert record dialog should pop up automatically.
pk_value = QString::number(max(tablename, *sqlb::findField(table, table->rowidColumns().last())).toLongLong() + 1);
sInsertstmt = emptyInsertStmt(tablename.schema(), *table, pk_value);
} else {
sInsertstmt = emptyInsertStmt(tablename.schema(), *table);
@@ -1288,26 +1299,42 @@ QString DBBrowserDB::addRecord(const sqlb::ObjectIdentifier& tablename)
}
}
bool DBBrowserDB::deleteRecords(const sqlb::ObjectIdentifier& table, const QStringList& rowids, const QString& pseudo_pk)
bool DBBrowserDB::deleteRecords(const sqlb::ObjectIdentifier& table, const QStringList& rowids, const std::vector<std::string>& pseudo_pk)
{
if (!isOpen()) return false;
// Get primary key of the object to edit.
QString pk = primaryKeyForEditing(table, pseudo_pk);
if(pk.isNull())
QStringList pks = primaryKeyForEditing(table, pseudo_pk);
if(pks.isEmpty())
{
lastErrorMessage = tr("Cannot delete this object");
return false;
}
// Quote all values in advance
QStringList quoted_rowids;
for(QString rowid : rowids)
quoted_rowids.append("'" + rowid.replace("'", "''") + "'");
QString statement = QString("DELETE FROM %1 WHERE %2 IN (%3);")
.arg(table.toString())
.arg(pk)
.arg(quoted_rowids.join(", "));
// For a single rowid column we can use a SELECT ... IN(...) statement which is faster.
// For multiple rowid columns we have to use json_array to decode the composed rowid values.
QString statement;
if(pks.size() == 1)
{
statement = QString("DELETE FROM %1 WHERE %2 IN (%3);")
.arg(table.toString())
.arg(pks.at(0))
.arg(quoted_rowids.join(", "));
} else {
statement = QString("DELETE FROM %1 WHERE ").arg(table.toString());
statement += "json_array(";
for(const auto& pk : pks)
statement += sqlb::escapeIdentifier(pk) + ",";
statement.chop(1);
statement += QString(") IN (%1)").arg(quoted_rowids.join(", "));
}
if(executeSQL(statement))
{
return true;
@@ -1318,24 +1345,34 @@ bool DBBrowserDB::deleteRecords(const sqlb::ObjectIdentifier& table, const QStri
}
bool DBBrowserDB::updateRecord(const sqlb::ObjectIdentifier& table, const QString& column,
const QString& rowid, const QByteArray& value, bool itsBlob, const QString& pseudo_pk)
const QString& rowid, const QByteArray& value, bool itsBlob, const std::vector<std::string>& pseudo_pk)
{
waitForDbRelease();
if (!isOpen()) return false;
// Get primary key of the object to edit.
QString pk = primaryKeyForEditing(table, pseudo_pk);
if(pk.isNull())
QStringList pks = primaryKeyForEditing(table, pseudo_pk);
if(pks.isEmpty())
{
lastErrorMessage = tr("Cannot set data on this object");
return false;
}
QString sql = QString("UPDATE %1 SET %2=? WHERE %3='%4';")
QString sql = QString("UPDATE %1 SET %2=? WHERE ")
.arg(table.toString())
.arg(sqlb::escapeIdentifier(column))
.arg(sqlb::escapeIdentifier(pk))
.arg(QString(rowid).replace("'", "''"));
.arg(sqlb::escapeIdentifier(column));
// For a single rowid column we can use a simple WHERE condition, for multiple rowid columns we have to use json_array to decode the composed rowid values.
if(pks.size() == 1)
{
sql += QString("%1='%2';")
.arg(sqlb::escapeIdentifier(pks.first()))
.arg(QString(rowid).replace("'", "''"));
} else {
sql += QString("json_array(%1)='%2';")
.arg(sqlb::escapeIdentifier(pks).join(","))
.arg(QString(rowid).replace("'", "''"));
}
logSQL(sql, kLogMsg_App);
setSavepoint();
@@ -1375,22 +1412,25 @@ bool DBBrowserDB::updateRecord(const sqlb::ObjectIdentifier& table, const QStrin
}
}
QString DBBrowserDB::primaryKeyForEditing(const sqlb::ObjectIdentifier& table, const QString& pseudo_pk) const
QStringList DBBrowserDB::primaryKeyForEditing(const sqlb::ObjectIdentifier& table, const std::vector<std::string>& pseudo_pk) const
{
// This function returns the primary key of the object to edit. For views we support 'pseudo' primary keys which must be specified manually.
// If no pseudo pk is specified we'll take the rowid column of the table instead. If this neither a table nor was a pseudo-PK specified,
// it is most likely a view that hasn't been configured for editing yet. In this case we return a null string to abort.
if(pseudo_pk.isEmpty())
if(pseudo_pk.empty())
{
sqlb::TablePtr tbl = getObjectByName<sqlb::Table>(table);
if(tbl)
return tbl->rowidColumn();
return tbl->rowidColumns();
} else {
return pseudo_pk;
QStringList ret;
for(const auto& col : pseudo_pk)
ret << QString::fromStdString(col);
return ret;
}
return QString();
return QStringList();
}
bool DBBrowserDB::createTable(const sqlb::ObjectIdentifier& name, const sqlb::FieldVector& structure)

View File

@@ -164,8 +164,8 @@ private:
public:
QString addRecord(const sqlb::ObjectIdentifier& tablename);
bool deleteRecords(const sqlb::ObjectIdentifier& table, const QStringList& rowids, const QString& pseudo_pk = QString());
bool updateRecord(const sqlb::ObjectIdentifier& table, const QString& column, const QString& rowid, const QByteArray& value, bool itsBlob, const QString& pseudo_pk = QString());
bool deleteRecords(const sqlb::ObjectIdentifier& table, const QStringList& rowids, const std::vector<std::string>& pseudo_pk = {});
bool updateRecord(const sqlb::ObjectIdentifier& table, const QString& column, const QString& rowid, const QByteArray& value, bool itsBlob, const std::vector<std::string>& pseudo_pk = {});
bool createTable(const sqlb::ObjectIdentifier& name, const sqlb::FieldVector& structure);
bool renameTable(const QString& schema, const QString& from_table, const QString& to_table);
@@ -261,7 +261,7 @@ private:
bool isEncrypted;
bool isReadOnly;
QString primaryKeyForEditing(const sqlb::ObjectIdentifier& table, const QString& pseudo_pk) const;
QStringList primaryKeyForEditing(const sqlb::ObjectIdentifier& table, const std::vector<std::string>& pseudo_pk) const;
// SQLite Callbacks
void collationNeeded(void* pData, sqlite3* db, int eTextRep, const char* sCollationName);

View File

@@ -132,9 +132,12 @@ void SqliteTableModel::setQuery(const sqlb::Query& query)
sqlb::TablePtr t = m_db.getObjectByName<sqlb::Table>(query.table());
if(t && t->fields.size()) // parsing was OK
{
QString rowid = t->rowidColumn();
m_query.setRowIdColumn(rowid.toStdString());
m_headers.push_back(rowid);
QStringList rowids = t->rowidColumns();
std::vector<std::string> rowids_std;
for(const auto& rowid : rowids)
rowids_std.push_back(rowid.toStdString());
m_query.setRowIdColumns(rowids_std);
m_headers.push_back(rowids.join(","));
m_headers.append(t->fieldNames());
// parse columns types
@@ -160,7 +163,7 @@ void SqliteTableModel::setQuery(const sqlb::Query& query)
if(!allOk)
{
QString sColumnQuery = QString::fromUtf8("SELECT * FROM %1;").arg(query.table().toString());
if(m_query.rowIdColumn().empty())
if(m_query.rowIdColumns().empty())
m_query.setRowIdColumn("_rowid_");
m_headers.push_back("_rowid_");
m_headers.append(getColumns(nullptr, sColumnQuery, m_vDataTypes));
@@ -453,10 +456,13 @@ bool SqliteTableModel::setTypedData(const QModelIndex& index, bool isBlob, const
if(oldValue == newValue && oldValue.isNull() == newValue.isNull())
return true;
if(m_db.updateRecord(m_query.table(), m_headers.at(index.column()), cached_row.at(0), newValue, isBlob, QString::fromStdString(m_query.rowIdColumn())))
if(m_db.updateRecord(m_query.table(), m_headers.at(index.column()), cached_row.at(0), newValue, isBlob, m_query.rowIdColumns()))
{
cached_row.replace(index.column(), newValue);
if(m_headers.at(index.column()).toStdString() == m_query.rowIdColumn()) {
QStringList header;
for(const auto& col : m_query.rowIdColumns())
header += QString::fromStdString(col);
if(m_headers.at(index.column()) == header.join(",")) {
cached_row.replace(0, newValue);
const QModelIndex& rowidIndex = index.sibling(index.row(), 0);
lock.unlock();
@@ -596,7 +602,7 @@ bool SqliteTableModel::removeRows(int row, int count, const QModelIndex& parent)
}
}
bool ok = m_db.deleteRecords(m_query.table(), rowids, QString::fromStdString(m_query.rowIdColumn()));
bool ok = m_db.deleteRecords(m_query.table(), rowids, m_query.rowIdColumns());
if (ok) {
beginRemoveRows(parent, row, row + count - 1);
@@ -830,18 +836,23 @@ bool SqliteTableModel::dropMimeData(const QMimeData* data, Qt::DropAction, int r
return false;
}
void SqliteTableModel::setPseudoPk(QString pseudoPk)
void SqliteTableModel::setPseudoPk(std::vector<std::string> pseudoPk)
{
if(pseudoPk.isNull())
pseudoPk = QString("_rowid_");
if(pseudoPk.empty())
pseudoPk.emplace_back("_rowid_");
// Do nothing if the value didn't change
if(m_query.rowIdColumn() == pseudoPk.toStdString())
if(m_query.rowIdColumns() == pseudoPk)
return;
m_query.setRowIdColumn(pseudoPk.toStdString());
m_query.setRowIdColumns(pseudoPk);
if(m_headers.size())
m_headers[0] = pseudoPk;
{
QStringList headers;
for(const auto& col : pseudoPk)
headers << QString::fromStdString(col);
m_headers[0] = headers.join(",");
}
buildQuery();
}

View File

@@ -99,9 +99,9 @@ public:
QString encoding() const { return m_encoding; }
// The pseudo-primary key is exclusively for editing views
void setPseudoPk(QString pseudoPk);
void setPseudoPk(std::vector<std::string> pseudoPk);
bool hasPseudoPk() const;
QString pseudoPk() const { return QString::fromStdString(m_query.rowIdColumn()); }
std::vector<std::string> pseudoPk() const { return m_query.rowIdColumns(); }
sqlb::ForeignKeyClause getForeignKeyClause(int column) const;