Add a link table to the project database

One row per element and element it is linked to -- a coil and its
contacts, a pair of folio reports -- from each side, with the contact
group the element saved for it. Nothing reads it yet: cross-references
are the main thing one folio needs from another, and this is what lets
them be answered without building every folio.

Filled like the other tables: from the document when it can be read
exactly (a link only one side lists, between kinds that cannot link, or
giving a contact a second coil is left to the folios), from the folios
otherwise. A link made or undone afterwards rewrites that element's rows
when the database is next read, as drawing items are, since the contact
group is set after the link is made.

Test: tst_databasefromdocument compares the link table both ways on
every example; linksAreListed checks a coil and contact (with a group)
in m_000.qet, oneSidedLinkFallsBack the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Wga9B92BNurDEtxxtCDxf
This commit is contained in:
ispyisail
2026-10-10 17:22:56 +13:00
parent 613f05a05e
commit 3089408b19
3 changed files with 281 additions and 14 deletions
+204 -10
View File
@@ -121,6 +121,7 @@ void projectDataBase::updateDB()
if (!m_content_changed)
{
flushDrawingItems();
flushLinks();
emit dataBaseUpdated();
return;
}
@@ -130,6 +131,7 @@ void projectDataBase::updateDB()
populateElementTable();
populateElementInfoTable();
populateConductorTable();
populateLinkTable();
populateDrawingItemTables();
m_content_changed = false;
@@ -140,8 +142,8 @@ void projectDataBase::updateDB()
@brief projectDataBase::updateDB
updateDB() for a project just read from @p document.
The diagram, diagram_info, element, element_info, terminal and conductor
tables are filled from the document itself when it carries everything
The diagram, diagram_info, element, element_info, terminal, conductor
and link tables are filled from the document itself when it carries everything
they need -- see populateFromDocument() -- and from the built folios
otherwise, as updateDB() does. Shapes, texts and pictures always come from
the built folios: their boxes need the fonts and pens a folio renders with.
@@ -229,6 +231,7 @@ struct DocumentElement
DiagramContext informations;
QString label;
QHash<QUuid, DocumentTerminal> terminals;
QList<QPair<QString, int>> links; //linked uuid, group index
};
//The sequential values an element or a conductor was saved with, as
@@ -256,8 +259,8 @@ struct DocumentConductor
/**
@brief projectDataBase::populateFromDocument
Fill the diagram, diagram_info, element, element_info, terminal and
conductor tables from @p document, the project as read from its file,
Fill the diagram, diagram_info, element, element_info, terminal,
conductor and link tables from @p document, the project as read from its file,
with the same values the built folios give -- using the same code:
a BorderTitleBlock read from each folio's XML gives the title-block
values and each element's grid cell, the project's embedded collection
@@ -270,8 +273,10 @@ struct DocumentConductor
a folio number uses %autonum, a conductor ends on a terminal that shows
its master's contact label, an element's definition is missing or
not one the folios could build, two elements on a folio number their
terminals alike, or sequential numbers are saved as the attributes
older files carry. A file saved by a current QElectroTech carries
terminals alike, sequential numbers are saved as the attributes
older files carry, or a link is listed by one side only, joins kinds
that cannot link, or gives a contact or a folio report a second
partner. A file saved by a current QElectroTech carries
everything else.
A label or a conductor text made from a formula is worked out again,
@@ -491,6 +496,13 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString
element.label = autonum::AssignVariables::formulaToLabel(formula, sequence, context);
}
for (const QDomElement &link : QET::findInDomElement(
element_xml, QStringLiteral("links_uuids"), QStringLiteral("link_uuid"))) {
element.links << qMakePair(QUuid(link.attribute(QStringLiteral("uuid"))).toString(),
link.attribute(QStringLiteral("group_index"),
QStringLiteral("-1")).toInt());
}
on_this_folio.insert(uuid, int(elements.size()));
elements << element;
}
@@ -585,13 +597,58 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString
diagram_dates << border->date();
}
//The links the folios make (Element::initLink()): a link to an
//element the project does not have is dropped there too. Anything
//else they would resolve one way or another -- a link only one side
//lists, between kinds that cannot link, or a second coil for a
//contact -- is left to them.
QHash<QString, int> element_index;
for (int i = 0 ; i < elements.size() ; ++i) {
element_index.insert(elements.at(i).uuid, i);
}
auto canLink = [](const QString &a, const QString &b) {
return (a == QLatin1String("master") && b == QLatin1String("slave"))
|| (a == QLatin1String("slave") && b == QLatin1String("master"))
|| (a == QLatin1String("next_report") && b == QLatin1String("previous_report"))
|| (a == QLatin1String("previous_report") && b == QLatin1String("next_report"));
};
for (DocumentElement &element : elements)
{
QList<QPair<QString, int>> kept;
QSet<QString> seen;
for (const auto &link : std::as_const(element.links))
{
const int other = element_index.value(link.first, -1);
if (other < 0) {
continue;
}
const DocumentElement &partner = elements.at(other);
bool listed_back = false;
for (const auto &back : partner.links) {
listed_back |= back.first == element.uuid;
}
if (seen.contains(link.first) || !listed_back
|| !canLink(element.type, partner.type)) {
return refuse(QStringLiteral("a link is not one the folios would make as saved"));
}
seen.insert(link.first);
kept << link;
}
if (kept.size() > 1 && element.type != QLatin1String("master")) {
return refuse(QStringLiteral("a link is not one the folios would make as saved"));
}
element.links = kept;
}
//Everything could be read: write it.
QSqlQuery query(m_data_base);
for (const QString &table : {QStringLiteral("diagram"), QStringLiteral("diagram_info"),
QStringLiteral("element"), QStringLiteral("element_info"),
QStringLiteral("conductor"), QStringLiteral("terminal")}) {
QStringLiteral("conductor"), QStringLiteral("terminal"),
QStringLiteral("link")}) {
query.exec(QStringLiteral("DELETE FROM ") + table);
}
m_dirty_link_elements.clear();
for (int i = 0 ; i < diagram_uuids.size() ; ++i)
{
@@ -625,10 +682,19 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString
}
}
QHash<QString, int> element_index;
for (int i = 0 ; i < elements.size() ; ++i) {
element_index.insert(elements.at(i).uuid, i);
query.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index) "
"VALUES (:element_uuid, :linked_uuid, :group_index)"));
for (const DocumentElement &element : std::as_const(elements)) {
for (const auto &link : element.links) {
query.bindValue(QStringLiteral(":element_uuid"), element.uuid);
query.bindValue(QStringLiteral(":linked_uuid"), link.first);
query.bindValue(QStringLiteral(":group_index"), link.second >= 0 ? QVariant(link.second) : QVariant());
if (!query.exec()) {
qDebug() << "projectDataBase::populateFromDocument link insert error : " << query.lastError();
}
}
}
for (const DocumentConductor &conductor : std::as_const(conductors))
{
for (const auto &end : {std::make_pair(conductor.element1, conductor.terminal1),
@@ -758,7 +824,9 @@ QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) {
//Drawing-item rows are rewritten lazily, see drawingItemChanged().
//Every read from outside comes through here, so this is the one
//place the queue has to be emptied for a reader to see current rows.
//The same goes for link rows, see linksChanged().
flushDrawingItems();
flushLinks();
// First gate: which kind of statement is acceptable here at all. A
// textual check is the right tool for that and the wrong tool for
@@ -850,6 +918,8 @@ void projectDataBase::addElement(Element *element)
if (!m_insert_elements_query.exec()) {
qDebug() << "projectDataBase::addElement insert element error : " << m_insert_elements_query.lastError();
}
connect(element, &Element::linkedElementChanged,
this, &projectDataBase::linksChanged, Qt::UniqueConnection);
bindElementInfoValues(m_insert_element_info_query, element);
if (!m_insert_element_info_query.exec()) {
@@ -882,6 +952,13 @@ void projectDataBase::removeElement(Element *element)
qDebug() << "projectDataBase::removeElement remove element_info error : " << m_remove_element_info_query.lastError();
}
QSqlQuery remove_links(m_data_base);
remove_links.prepare(QStringLiteral("DELETE FROM link WHERE element_uuid = :uuid OR linked_uuid = :uuid"));
remove_links.bindValue(QStringLiteral(":uuid"), element->uuid().toString());
if (!remove_links.exec()) {
qDebug() << "projectDataBase::removeElement remove link error : " << remove_links.lastError();
}
if (changed) {
emit dataBaseUpdated();
}
@@ -972,6 +1049,18 @@ void projectDataBase::removeDiagram(Diagram *diagram)
//element/terminal/element_info/conductor rows survive.
m_data_base.transaction();
QSqlQuery cascade_links(m_data_base);
cascade_links.prepare(QStringLiteral(
"DELETE FROM link WHERE element_uuid IN (SELECT uuid FROM element WHERE diagram_uuid = :uuid) "
"OR linked_uuid IN (SELECT uuid FROM element WHERE diagram_uuid = :uuid)"));
cascade_links.bindValue(QStringLiteral(":uuid"), uuid_str);
if (!cascade_links.exec()) {
qDebug() << "projectDataBase::removeDiagram link cascade error : "
<< cascade_links.lastError();
m_data_base.rollback();
return;
}
m_cascade_remove_element_info_query.bindValue(":uuid", uuid_str);
if (!m_cascade_remove_element_info_query.exec()) {
qDebug() << "projectDataBase::removeDiagram element_info cascade error : "
@@ -1605,6 +1694,20 @@ bool projectDataBase::createDataBase()
qDebug() << " element_info_table query : " << query_.lastError();
}
//Create the link table: one row per element and element it is
//linked to -- a coil and its contacts, a pair of folio reports --
//from each side, with the contact group the element saved for it.
const QString link_table(QStringLiteral(
"CREATE TABLE link ("
"element_uuid VARCHAR(50) NOT NULL, "
"linked_uuid VARCHAR(50) NOT NULL, "
"group_index INTEGER, "
"PRIMARY KEY (element_uuid, linked_uuid), "
"FOREIGN KEY (element_uuid) REFERENCES element (uuid))"));
if (!query_.exec(link_table)) {
qDebug() << " link_table query : " << query_.lastError();
}
//Create the terminal table.
//Terminal::uuid() is the terminal-position id baked into the catalog
//.elmt definition (e.g. "the top terminal") -- identical across every
@@ -2001,6 +2104,96 @@ void projectDataBase::populateElementInfoTable()
}
}
/**
@brief projectDataBase::populateLinkTable
Populate the link table from the built folios
*/
void projectDataBase::populateLinkTable()
{
m_dirty_link_elements.clear();
QSqlQuery query(m_data_base);
query.exec(QStringLiteral("DELETE FROM link"));
query.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index) "
"VALUES (:element_uuid, :linked_uuid, :group_index)"));
for (const auto &diagram : m_project->diagrams())
{
const ElementProvider ep(diagram);
for (const auto &elmt : ep.find(allElementTypes()))
{
for (Element *linked : elmt->linkedElements())
{
const int group = elmt->groupIndexForElement(linked);
query.bindValue(QStringLiteral(":element_uuid"), elmt->uuid().toString());
query.bindValue(QStringLiteral(":linked_uuid"), linked->uuid().toString());
query.bindValue(QStringLiteral(":group_index"), group >= 0 ? QVariant(group) : QVariant());
if (!query.exec()) {
qDebug() << "projectDataBase::populateLinkTable insert error : " << query.lastError();
}
}
}
}
}
/**
@brief projectDataBase::linksChanged
A link of the sender() was made or undone: its rows are written again
by the next flushLinks().
*/
void projectDataBase::linksChanged()
{
m_content_changed = true;
auto *element = qobject_cast<Element *>(sender());
if (element && !m_dirty_link_elements.contains(element)) {
m_dirty_link_elements << element;
}
}
/**
@brief projectDataBase::flushLinks
Write the link rows of every element queued by linksChanged()
*/
void projectDataBase::flushLinks()
{
if (m_dirty_link_elements.isEmpty()) {
return;
}
const auto dirty = m_dirty_link_elements;
m_dirty_link_elements.clear();
const bool own_transaction = m_data_base.transaction();
QSqlQuery remove(m_data_base);
remove.prepare(QStringLiteral("DELETE FROM link WHERE element_uuid = :uuid"));
QSqlQuery insert(m_data_base);
insert.prepare(QStringLiteral("INSERT INTO link (element_uuid, linked_uuid, group_index) "
"VALUES (:element_uuid, :linked_uuid, :group_index)"));
for (const QPointer<Element> &element : dirty)
{
if (!element) {
continue;
}
remove.bindValue(QStringLiteral(":uuid"), element->uuid().toString());
if (!remove.exec()) {
qDebug() << "projectDataBase::flushLinks remove error : " << remove.lastError();
}
//An element taken off its folio is unlinked first (Diagram::
//removeItem()), so it writes no rows here.
for (Element *linked : element->linkedElements())
{
const int group = element->groupIndexForElement(linked);
insert.bindValue(QStringLiteral(":element_uuid"), element->uuid().toString());
insert.bindValue(QStringLiteral(":linked_uuid"), linked->uuid().toString());
insert.bindValue(QStringLiteral(":group_index"), group >= 0 ? QVariant(group) : QVariant());
if (!insert.exec()) {
qDebug() << "projectDataBase::flushLinks insert error : " << insert.lastError();
}
}
}
if (own_transaction) {
m_data_base.commit();
}
}
void projectDataBase::populateDiagramInfoTable()
{
QSqlQuery query(m_data_base);
@@ -2403,6 +2596,7 @@ void projectDataBase::exportDb(projectDataBase *db,
// requiring access to the SQLite driver's native connection handle.
const auto escaped_path = path_.replace("'", "''");
db->flushDrawingItems();
db->flushLinks();
QSqlQuery query(db->m_data_base);
if (!query.exec("VACUUM INTO '" % escaped_path % "'")) {
qWarning() << "Unable to export project database:" << query.lastError().text();
+8
View File
@@ -124,6 +124,8 @@ class projectDataBase : public QObject
//Queue the sender()'s drawing-item row for rewriting.
void drawingItemChanged();
void drawingItemDestroyed(QObject *object);
//Queue the sender()'s link rows for rewriting.
void linksChanged();
public:
@@ -141,6 +143,8 @@ class projectDataBase : public QObject
void populateElementInfoTable();
void populateDiagramInfoTable();
void populateConductorTable();
void populateLinkTable();
void flushLinks();
void populateDrawingItemTables();
bool populateFromDocument(const QDomDocument &document, QString *why = nullptr);
bool writeDrawingItem(QObject *object);
@@ -206,6 +210,10 @@ class projectDataBase : public QObject
QHash<QObject *, QUuid> m_drawing_item_row;
QHash<QUuid, QObject *> m_drawing_row_owner;
QSet<QObject *> m_dirty_drawing_items;
//Elements whose links changed since their link rows were last
//written: a link is made first and its contact group set after,
//so the rows are written when next read, as drawing items are.
QList<QPointer<Element>> m_dirty_link_elements;
#ifdef QET_EXPORT_PROJECT_DB
public:
+69 -4
View File
@@ -15,7 +15,7 @@
// what the same database filled from the built folios holds. Every example
// is saved once (so that it carries the uuids a current QElectroTech
// writes), then opened twice through the real binary's --run: once as is,
// once with QET_DATABASE_FROM_FOLIOS=1, and the six tables compared.
// once with QET_DATABASE_FROM_FOLIOS=1, and the seven tables compared.
class tst_databasefromdocument : public QObject
{
Q_OBJECT
@@ -50,7 +50,7 @@ class tst_databasefromdocument : public QObject
return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError());
}
// The six tables, each as a sorted list of its rows, and which way the
// The seven tables, each as a sorted list of its rows, and which way the
// database was filled.
QJsonObject dump(const QString &project, bool from_folios, QString *how)
{
@@ -102,7 +102,7 @@ class tst_databasefromdocument : public QObject
QVERIFY2(how_document == QLatin1String("Project database filled from the document"),
qPrintable(how_document));
QVERIFY2(how_folios.contains(QStringLiteral("QET_DATABASE_FROM_FOLIOS")), qPrintable(how_folios));
QCOMPARE(document.keys().size(), 6);
QCOMPARE(document.keys().size(), 7);
for (const QString &table : folios.keys()) {
const QJsonArray a = document.value(table).toArray(), b = folios.value(table).toArray();
QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows from the document, %3 from the folios")
@@ -121,7 +121,7 @@ private slots:
QFile js(m_dir.filePath(QStringLiteral("dump.js")));
QVERIFY(js.open(QIODevice::WriteOnly));
js.write("var out = {};\n"
"['diagram', 'diagram_info', 'element', 'element_info', 'terminal', 'conductor']"
"['diagram', 'diagram_info', 'element', 'element_info', 'terminal', 'conductor', 'link']"
".forEach(function (t) { out[t] = qet.query('SELECT * FROM ' + t); });\n"
"qet.log('DUMP ' + JSON.stringify(out));\n");
}
@@ -280,6 +280,71 @@ private slots:
"two elements on a folio number their terminals alike"));
}
// m_000.qet saved once, as a document to change
QDomDocument resavedWithLinks(const QString &saved)
{
run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/m_000.qet"), saved});
QFile file(saved);
QDomDocument document;
if (file.open(QIODevice::ReadOnly))
document.setContent(&file);
return document;
}
// A coil and its contacts are in the link table, from each side, with
// both fills: m_000.qet's coil b02216df and its contact 998190fa.
void linksAreListed()
{
const QString saved = m_dir.filePath(QStringLiteral("links.qet"));
QDomDocument document = resavedWithLinks(saved);
const QString coil = QStringLiteral("{b02216df-0851-4732-893b-901fea80703e}"),
contact = QStringLiteral("{998190fa-5b4c-4be3-bc67-8a828eaab2b4}");
//The coil puts this contact in its contact group 1.
bool grouped = false;
const QDomNodeList elements = document.elementsByTagName(QStringLiteral("element"));
for (int i = 0 ; i < elements.size() ; ++i) {
const QDomElement e = elements.at(i).toElement();
if (e.attribute(QStringLiteral("uuid")) != coil) continue;
for (QDomElement l = e.firstChildElement(QStringLiteral("links_uuids")).firstChildElement(QStringLiteral("link_uuid")) ;
!l.isNull() ; l = l.nextSiblingElement(QStringLiteral("link_uuid"))) {
if (l.attribute(QStringLiteral("uuid")) == contact) {
l.setAttribute(QStringLiteral("group_index"), 1);
grouped = true;
}
}
}
QVERIFY(grouped);
QVERIFY(write(saved, document));
compareBothWays(saved);
bool coil_side = false, contact_side = false;
for (const QJsonValue &row : m_last.value(QStringLiteral("link")).toArray()) {
const QJsonObject link = QJsonDocument::fromJson(row.toString().toUtf8()).object();
const QString from = link.value(QStringLiteral("element_uuid")).toString(),
to = link.value(QStringLiteral("linked_uuid")).toString();
coil_side |= from == coil && to == contact
&& link.value(QStringLiteral("group_index")).toVariant().toInt() == 1;
contact_side |= from == contact && to == coil;
}
QVERIFY2(coil_side, "the coil's link to its contact, in group 1, is missing");
QVERIFY2(contact_side, "the contact's link to its coil is missing");
}
// A link only one side lists: the folios decide what it becomes.
void oneSidedLinkFallsBack()
{
const QString saved = m_dir.filePath(QStringLiteral("onesided.qet"));
QDomDocument document = resavedWithLinks(saved);
const QDomNodeList links = document.elementsByTagName(QStringLiteral("link_uuid"));
QVERIFY(links.size() > 0);
QDomNode link = links.at(0);
link.parentNode().removeChild(link);
QVERIFY(write(saved, document));
QString how;
dump(saved, false, &how);
QCOMPARE(how, QStringLiteral("Project database filled from the folios: "
"a link is not one the folios would make as saved"));
}
// A file whose items carry no saved uuid is filled from the folios,
// which work those uuids out as they are built, and says why.
void olderFileFallsBack()