From 8d118faf36c104cc1a3705b8fbf37ba21856e127 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 00:34:35 +1300 Subject: [PATCH 1/5] .qetz: key database rows by uuid, type true/false, version the schema Answers the points arummler raised in discussion #1440 about the project.sqlite that .qetz files carry (format 2, PR #1462): - Folio rows were keyed "f1", "f2"... by position, and every symbol and wire carried a separate key beside its uuid. A row's key is now the item's own uuid, which stays in the folio file; no key attribute is added to the folio files of a project as QElectroTech saves it. An item without a usable uuid (none, or one another item shares, F100) gets a uuid made from its own content, the same on every save, never from where it is. - freezeLabel is stored as 0/1 (INTEGER) and an element information's "show" in its own column; a value QElectroTech would not write stays in the folio file verbatim. The tag is kept in the JSON only when it is not the usual one. - The database's PRAGMA user_version holds the format. Format 3. Format 2 files, written by development builds since #1462, still open: tst_qetcontainer reads one (fixtures/qetz_format2.qetz, grafcet.qet saved by master aa503aec8) and saves it again. Checked: tst_qetcontainer 38/38, and 343/343 readable projects of the legacy corpus round-trip exactly. Every example saved as .qetz then .qet equals its plain resave, and every format 2 file read by this build equals what master writes from it. Each new test goes red with the behaviour it checks broken. Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainer.h | 6 +- sources/container/qetcontainerdb.cpp | 223 +++++++++++++++++------- sources/container/qetcontainerdb.h | 6 +- tests/qttest/CMakeLists.txt | 3 +- tests/qttest/fixtures/qetz_format2.qetz | Bin 0 -> 31363 bytes tests/qttest/tst_qetcontainer.cpp | 149 ++++++++++++++++ 6 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 tests/qttest/fixtures/qetz_format2.qetz diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index d92df084c..32da2223e 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -31,7 +31,7 @@ manifest.xml format and oldest format allowed to read it project.xml the project, each part replaced by - project.sqlite the engineering data (format 2, + project.sqlite the engineering data (format 2 on, QetContainerDb): title-block fields, symbol information, links, wires folios/.xml one per folio @@ -47,7 +47,9 @@ namespace QetContainer extern const char MimeType[]; /// The format written; files of a later format are refused by join() /// unless their min-reader allows this one. - constexpr int Format = 2; + constexpr int Format = 3; + /// 3: the database's rows keyed by the items' uuids, true/false as + /// 1/0, its PRAGMA user_version the format. Format 2 is still read. QList split(const QDomDocument &project, const QString &writer = QString(), diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index 321fddd49..f7eb10b31 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -17,11 +17,15 @@ */ #include "qetcontainerdb.h" +#include "qetcontainer.h" + #include #include #include #include #include +#include +#include #include #include @@ -58,39 +62,63 @@ const QStringList FolioData = { QStringLiteral("title"), QStringLiteral("author"), QStringLiteral("date"), QStringLiteral("folio"), QStringLiteral("indexrev"), QStringLiteral("filename"), QStringLiteral("locmach"), QStringLiteral("plant"), QStringLiteral("auto_page_num")}; + //From format 3 on an item's uuid is its row's key, not a column of its + //own: it stays in the folio file, where it says which row is the item's const QStringList ElementData = { - QStringLiteral("uuid"), QStringLiteral("type"), QStringLiteral("prefix"), - QStringLiteral("freezeLabel")}; + QStringLiteral("type"), QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; const QStringList ConductorData = { - QStringLiteral("uuid"), QStringLiteral("element1"), QStringLiteral("terminal1"), + QStringLiteral("element1"), QStringLiteral("terminal1"), QStringLiteral("element2"), QStringLiteral("terminal2"), QStringLiteral("num"), QStringLiteral("formula"), QStringLiteral("function"), QStringLiteral("tension_protocol"), QStringLiteral("conductor_color"), QStringLiteral("conductor_section"), QStringLiteral("cable"), QStringLiteral("bus"), QStringLiteral("freezeLabel")}; + //Attributes held as 0 or 1 (format 3 on). A value QElectroTech does not + //write, "yes" say, stays in the folio file as it was. +const QStringList BooleanData = {QStringLiteral("freezeLabel")}; + QString columns(const QStringList &names) { QStringList quoted; - for (const QString &n : names) quoted << QStringLiteral("\"%1\" TEXT").arg(n); + for (const QString &n : names) + quoted << QStringLiteral("\"%1\" %2").arg(n, BooleanData.contains(n) ? QStringLiteral("INTEGER") + : QStringLiteral("TEXT")); return quoted.join(QStringLiteral(", ")); } + //@p text as attribute @p name is stored, or invalid if it cannot be +QVariant stored(const QString &name, const QString &text) +{ + if (!BooleanData.contains(name)) return text; + if (text == QLatin1String("true")) return 1; + if (text == QLatin1String("false")) return 0; + return {}; +} + + //A stored value as attribute @p name writes it +QString attributeText(const QString &name, const QVariant &value) +{ + if (BooleanData.contains(name) && value.typeId() == QMetaType::Int) + return value.toInt() ? QStringLiteral("true") : QStringLiteral("false"); + return value.toString(); +} + QString schema() { return QStringLiteral( "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);" "CREATE TABLE project_property (ord INTEGER PRIMARY KEY, name TEXT, value TEXT, attrs TEXT);" - "CREATE TABLE folio (id TEXT PRIMARY KEY, pos INTEGER NOT NULL UNIQUE, %1," + "CREATE TABLE folio (uuid TEXT PRIMARY KEY, pos INTEGER NOT NULL UNIQUE, %1," " props_at INTEGER, props_attrs TEXT);" - "CREATE TABLE folio_property (folio_id TEXT NOT NULL REFERENCES folio, ord INTEGER NOT NULL," - " name TEXT, value TEXT, attrs TEXT, PRIMARY KEY (folio_id, ord));" - "CREATE TABLE element (key TEXT PRIMARY KEY, folio_id TEXT NOT NULL REFERENCES folio, %2," + "CREATE TABLE folio_property (folio_uuid TEXT NOT NULL REFERENCES folio, ord INTEGER NOT NULL," + " name TEXT, value TEXT, attrs TEXT, PRIMARY KEY (folio_uuid, ord));" + "CREATE TABLE element (uuid TEXT PRIMARY KEY, folio_uuid TEXT NOT NULL REFERENCES folio, %2," " info_at INTEGER, info_attrs TEXT, links_at INTEGER, links_attrs TEXT);" - "CREATE TABLE element_info (element_key TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," - " name TEXT, value TEXT, attrs TEXT, PRIMARY KEY (element_key, ord));" - "CREATE TABLE link (element_key TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," - " linked_uuid TEXT, attrs TEXT, PRIMARY KEY (element_key, ord));" - "CREATE TABLE conductor (key TEXT PRIMARY KEY, folio_id TEXT NOT NULL REFERENCES folio, %3);" + "CREATE TABLE element_info (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," + " name TEXT, value TEXT, show INTEGER, attrs TEXT, PRIMARY KEY (element_uuid, ord));" + "CREATE TABLE link (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," + " linked_uuid TEXT, attrs TEXT, PRIMARY KEY (element_uuid, ord));" + "CREATE TABLE conductor (uuid TEXT PRIMARY KEY, folio_uuid TEXT NOT NULL REFERENCES folio, %3);" "CREATE TABLE terminal_strip (uuid TEXT PRIMARY KEY, ord INTEGER NOT NULL UNIQUE," " installation TEXT, location TEXT, name TEXT, comment TEXT, description TEXT);" "CREATE TABLE strip_terminal (strip_uuid TEXT NOT NULL REFERENCES terminal_strip," @@ -165,15 +193,16 @@ QString placeholders(int n) return q.join(QLatin1Char(',')); } - //The attributes of @p e but @p skip, and its tag, as JSON -QVariant attributesJson(const QDomElement &e, const QString &skip = QString(), bool with_tag = false) + //The attributes of @p e but @p skip as JSON, and its tag if it is not + //@p usual_tag +QVariant attributesJson(const QDomElement &e, const QStringList &skip, const QString &usual_tag) { QJsonObject o; - if (with_tag) o.insert(QStringLiteral("tag"), e.tagName()); + if (e.tagName() != usual_tag) o.insert(QStringLiteral("tag"), e.tagName()); const QDomNamedNodeMap map = e.attributes(); for (int i = 0 ; i < map.count() ; ++i) { const QDomAttr a = map.item(i).toAttr(); - if (a.name() != skip) o.insert(a.name(), a.value()); + if (!skip.contains(a.name())) o.insert(a.name(), a.value()); } if (o.isEmpty()) return QVariant(); return QString::fromUtf8(QJsonDocument(o).toJson(QJsonDocument::Compact)); @@ -266,12 +295,15 @@ int childIndex(const QDomNode &child) } + //The attributes @p names of @p e, taken out of it; one that cannot be + //stored stays, its value null QVariantList take(QDomElement e, const QStringList &names) { QVariantList values; for (const QString &n : names) { - values << (e.hasAttribute(n) ? QVariant(e.attribute(n)) : QVariant()); - e.removeAttribute(n); + const QVariant v = e.hasAttribute(n) ? stored(n, e.attribute(n)) : QVariant(); + values << (v.isValid() ? v : QVariant()); + if (v.isValid()) e.removeAttribute(n); } return values; } @@ -279,7 +311,8 @@ QVariantList take(QDomElement e, const QStringList &names) void put(QDomElement e, const QStringList &names, const QVariantList &values, int from) { for (int i = 0 ; i < names.size() ; ++i) - if (!values.at(from + i).isNull()) e.setAttribute(names.at(i), values.at(from + i).toString()); + if (!values.at(from + i).isNull()) + e.setAttribute(names.at(i), attributeText(names.at(i), values.at(from + i))); } //Same tags, attributes, text and child order @@ -417,18 +450,40 @@ QList children(const QDomElement &parent, const QString &tag) return list; } - //uuid when present and unique among @p items, else prefix + count -QStringList keys(const QList &items, const QString &prefix) + //The key of each of @p items: its uuid, when it has one no other of + //@p items has, so the uuid the folio file already holds says which row + //is the item's (@p own true). Else (F100: symbols copied with their + //uuid) a uuid made from the item itself -- the same on every save, + //never from where the item is -- kept in the folio file as qetz-key. +QStringList keys(const QList &items, QList *own) { + static const QUuid key_namespace(QStringLiteral("{3f0c6c1e-5b8e-4b8a-9d55-6b2f5f0f9a41}")); QHash seen; for (const QDomElement &e : items) - if (e.hasAttribute(QStringLiteral("uuid"))) ++seen[e.attribute(QStringLiteral("uuid"))]; + if (!QUuid(e.attribute(QStringLiteral("uuid"))).isNull()) ++seen[e.attribute(QStringLiteral("uuid"))]; + QSet taken(seen.keyBegin(), seen.keyEnd()); + QHash twins; QStringList out; - int made_up = 0; for (const QDomElement &e : items) { const QString uuid = e.attribute(QStringLiteral("uuid")); - out << ((!uuid.isEmpty() && seen.value(uuid) == 1) - ? uuid : prefix + QString::number(++made_up)); + if (seen.value(uuid) == 1) { + out << uuid; + *own << true; + continue; + } + QString text; + QTextStream stream(&text); + e.save(stream, -1); + stream.flush(); + //Items alike to the byte: how many came before tells them apart + const int twin = twins[text]++; + QString key; + for (int salt = 0 ; key.isEmpty() || taken.contains(key) ; ++salt) + key = QUuid::createUuidV5(key_namespace, QStringLiteral("%1\n%2\n%3").arg(text).arg(twin).arg(salt)) + .toString(); + taken.insert(key); + out << key; + *own << false; } return out; } @@ -454,13 +509,14 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) Statement folio(db.m_db, QStringLiteral("INSERT INTO folio VALUES (?,?,%1,?,?)").arg(placeholders(FolioData.size()))); Statement folio_property(db.m_db, QStringLiteral("INSERT INTO folio_property VALUES (?,?,?,?,?)")); Statement element(db.m_db, QStringLiteral("INSERT INTO element VALUES (?,?,%1,?,?,?,?)").arg(placeholders(ElementData.size()))); - Statement element_info(db.m_db, QStringLiteral("INSERT INTO element_info VALUES (?,?,?,?,?)")); + Statement element_info(db.m_db, QStringLiteral("INSERT INTO element_info VALUES (?,?,?,?,?,?)")); Statement link(db.m_db, QStringLiteral("INSERT INTO link VALUES (?,?,?,?)")); Statement conductor(db.m_db, QStringLiteral("INSERT INTO conductor VALUES (?,?,%1)").arg(placeholders(ConductorData.size()))); bool ok = meta.ok() && project_property.ok() && folio.ok() && folio_property.ok() && element.ok() && element_info.ok() && link.ok() && conductor.ok(); - ok = ok && meta.run({QStringLiteral("format"), QStringLiteral("2")}); + ok = ok && meta.run({QStringLiteral("format"), QString::number(QetContainer::Format)}) + && db.exec(QStringLiteral("PRAGMA user_version = %1").arg(QetContainer::Format)); // of a block, as rows of @p rows (with @p prefix values) auto moveProperties = [&](QDomElement block, Statement &rows, const QVariantList &prefix) { @@ -470,7 +526,7 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) simpleValue(p, &value); ok = ok && rows.run(QVariantList(prefix) << ord++ << (p.hasAttribute(QStringLiteral("name")) ? QVariant(p.attribute(QStringLiteral("name"))) : QVariant()) - << value << attributesJson(p, QStringLiteral("name"), true)); + << value << attributesJson(p, {QStringLiteral("name")}, QStringLiteral("property"))); } }; @@ -485,7 +541,7 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) simpleValue(p, &value); ok = ok && project_property.run({ord++, p.hasAttribute(QStringLiteral("name")) ? QVariant(p.attribute(QStringLiteral("name"))) : QVariant(), - value, attributesJson(p, QStringLiteral("name"), true)}); + value, attributesJson(p, {QStringLiteral("name")}, QStringLiteral("property"))}); } leaveMarker(project_props); } @@ -498,14 +554,18 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) for (const QDomElement &list : children(d, QStringLiteral("conductors"))) all_conductors += children(list, QStringLiteral("conductor")); } - const QStringList element_keys = keys(all_elements, QStringLiteral("e")); - const QStringList conductor_keys = keys(all_conductors, QStringLiteral("c")); + QList folio_own, element_own, conductor_own; + const QStringList folio_keys = keys(diagrams, &folio_own); + const QStringList element_keys = keys(all_elements, &element_own); + const QStringList conductor_keys = keys(all_conductors, &conductor_own); int e_index = 0, c_index = 0; int pos = 0; for (QDomElement d : diagrams) { - const QString id = QStringLiteral("f%1").arg(++pos); + const QString id = folio_keys.at(pos); + const bool own_id = folio_own.at(pos); + ++pos; QVariantList row{id, pos}; row += take(d, FolioData); QDomElement props = d.firstChildElement(QStringLiteral("properties")); @@ -517,11 +577,12 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) moveProperties(props, folio_property, {id}); leaveMarker(props); } - d.setAttribute(FolioAttribute, id); + if (!own_id) d.setAttribute(FolioAttribute, id); for (const QDomElement &list : children(d, QStringLiteral("elements"))) { for (QDomElement e : children(list, QStringLiteral("element"))) { - const QString key = element_keys.at(e_index++); + const QString key = element_keys.at(e_index); + const bool own_key = element_own.at(e_index++); QVariantList erow{key, id}; erow += take(e, ElementData); QDomElement info = e.firstChildElement(QStringLiteral("elementInformations")); @@ -540,9 +601,14 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) for (QDomElement i : children(info, QStringLiteral("elementInformation"))) { QVariant value; simpleValue(i, &value); + const QString show = i.attribute(QStringLiteral("show")); + const bool show_stored = show == QLatin1String("0") || show == QLatin1String("1"); + QStringList kept{QStringLiteral("name")}; + if (show_stored) kept << QStringLiteral("show"); ok = ok && element_info.run({key, ord++, i.hasAttribute(QStringLiteral("name")) ? QVariant(i.attribute(QStringLiteral("name"))) : QVariant(), - value, attributesJson(i, QStringLiteral("name"), true)}); + value, show_stored ? QVariant(show.toInt()) : QVariant(), + attributesJson(i, kept, QStringLiteral("elementInformation"))}); } } if (links_at >= 0) { @@ -550,20 +616,21 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) for (QDomElement l : children(links, QStringLiteral("link_uuid"))) ok = ok && link.run({key, ord++, l.hasAttribute(QStringLiteral("uuid")) ? QVariant(l.attribute(QStringLiteral("uuid"))) : QVariant(), - attributesJson(l, QStringLiteral("uuid"), true)}); + attributesJson(l, {QStringLiteral("uuid")}, QStringLiteral("link_uuid"))}); } if (info_at >= 0) leaveMarker(info); if (links_at >= 0) leaveMarker(links); - e.setAttribute(KeyAttribute, key); + if (!own_key) e.setAttribute(KeyAttribute, key); } } for (const QDomElement &list : children(d, QStringLiteral("conductors"))) { for (QDomElement c : children(list, QStringLiteral("conductor"))) { - const QString key = conductor_keys.at(c_index++); + const QString key = conductor_keys.at(c_index); + const bool own_key = conductor_own.at(c_index++); QVariantList crow{key, id}; crow += take(c, ConductorData); ok = ok && conductor.run(crow); - c.setAttribute(KeyAttribute, key); + if (!own_key) c.setAttribute(KeyAttribute, key); } } } @@ -637,10 +704,11 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS } auto entry = [&document](const QVariant &name_attr, const QString &name, const QVariant &value, - const QVariant &attrs, const QString &default_tag) { + const QVariant &attrs, const QString &default_tag, const QVariant &show = QVariant()) { const QJsonObject o = QJsonDocument::fromJson(attrs.toString().toUtf8()).object(); QDomElement e = document.createElement(o.value(QStringLiteral("tag")).toString(default_tag)); if (!name_attr.isNull()) e.setAttribute(name, name_attr.toString()); + if (!show.isNull()) e.setAttribute(QStringLiteral("show"), show.toString()); setAttributesJson(e, attrs); if (!value.isNull()) e.appendChild(document.createTextNode(value.toString())); return e; @@ -652,6 +720,35 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS Statement s(db.m_db, QStringLiteral("SELECT key, value FROM meta")); while (s.next()) meta.insert(s.value(0).toString(), s.value(1)); } + //Format 2 (QElectroTech 0.200.1 development builds) keyed its rows + //apart from the items' uuids: "f1", "e1", "c1" where an item had + //none of its own, and every item's key in the folio file + const int format = meta.value(QStringLiteral("format")).toString().toInt(); + if (format < 2 || format > QetContainer::Format) { + setError(error, QStringLiteral("the project database is of format %1, this reads 2 to %2") + .arg(meta.value(QStringLiteral("format")).toString()).arg(QetContainer::Format)); + return false; + } + const bool v2 = format == 2; + const QString folio_key = v2 ? QStringLiteral("id") : QStringLiteral("uuid"); + const QString folio_ref = v2 ? QStringLiteral("folio_id") : QStringLiteral("folio_uuid"); + const QString item_key = v2 ? QStringLiteral("key") : QStringLiteral("uuid"); + const QString element_ref = v2 ? QStringLiteral("element_key") : QStringLiteral("element_uuid"); + const QString show_column = v2 ? QStringLiteral("NULL") : QStringLiteral("show"); + const QStringList element_data = v2 ? QStringList{QStringLiteral("uuid")} + ElementData : ElementData; + const QStringList conductor_data = v2 ? QStringList{QStringLiteral("uuid")} + ConductorData : ConductorData; + auto quoted = [](const QStringList &names) { + return QStringLiteral("\"") + names.join(QStringLiteral("\", \"")) + QStringLiteral("\""); + }; + //An item's key: the one it was given, else (format 3) its uuid + auto keyOf = [v2](QDomElement e, const QString &attribute) { + if (e.hasAttribute(attribute)) { + const QString key = e.attribute(attribute); + e.removeAttribute(attribute); + return key; + } + return v2 ? QString() : e.attribute(QStringLiteral("uuid")); + }; if (meta.contains(QStringLiteral("props_at"))) { QDomElement props = document.createElement(QStringLiteral("properties")); setAttributesJson(props, meta.value(QStringLiteral("props_attrs"))); @@ -688,11 +785,10 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS for (QDomElement d : children(project, QStringLiteral("diagram"))) { - const QString id = d.attribute(FolioAttribute); + const QString id = keyOf(d, FolioAttribute); if (id.isEmpty()) continue; - d.removeAttribute(FolioAttribute); - Statement f(db.m_db, QStringLiteral("SELECT %1, props_at, props_attrs FROM folio WHERE id = ?") - .arg(QStringLiteral("\"") + FolioData.join(QStringLiteral("\", \"")) + QStringLiteral("\""))); + Statement f(db.m_db, QStringLiteral("SELECT %1, props_at, props_attrs FROM folio WHERE %2 = ?") + .arg(quoted(FolioData), folio_key)); f.bind({id}); if (!f.next()) { setError(error, QStringLiteral("folio %1 is not in the database").arg(id)); return false; } QVariantList values; @@ -701,7 +797,7 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS if (!values.at(FolioData.size()).isNull()) { QDomElement props = document.createElement(QStringLiteral("properties")); setAttributesJson(props, values.at(FolioData.size() + 1)); - Statement p(db.m_db, QStringLiteral("SELECT name, value, attrs FROM folio_property WHERE folio_id = ? ORDER BY ord")); + Statement p(db.m_db, QStringLiteral("SELECT name, value, attrs FROM folio_property WHERE %1 = ? ORDER BY ord").arg(folio_ref)); p.bind({id}); while (p.next()) props.appendChild(entry(p.value(0), QStringLiteral("name"), p.value(1), p.value(2), @@ -715,32 +811,32 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS for (const QDomElement &list : children(d, QStringLiteral("elements"))) { for (QDomElement e : children(list, QStringLiteral("element"))) { - if (!e.hasAttribute(KeyAttribute)) continue; - const QString key = e.attribute(KeyAttribute); - e.removeAttribute(KeyAttribute); - Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs FROM element WHERE key = ?") - .arg(QStringLiteral("\"") + ElementData.join(QStringLiteral("\", \"")) + QStringLiteral("\""))); + const QString key = keyOf(e, KeyAttribute); + if (key.isEmpty()) continue; + Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs FROM element WHERE %2 = ?") + .arg(quoted(element_data), item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("symbol %1 is not in the database").arg(key)); return false; } QVariantList v; - for (int i = 0 ; i < ElementData.size() + 4 ; ++i) v << s.value(i); - put(e, ElementData, v, 0); - const int n = ElementData.size(); + for (int i = 0 ; i < element_data.size() + 4 ; ++i) v << s.value(i); + put(e, element_data, v, 0); + const int n = element_data.size(); QDomElement info, links; if (!v.at(n).isNull()) { info = document.createElement(QStringLiteral("elementInformations")); setAttributesJson(info, v.at(n + 1)); - Statement i(db.m_db, QStringLiteral("SELECT name, value, attrs FROM element_info WHERE element_key = ? ORDER BY ord")); + Statement i(db.m_db, QStringLiteral("SELECT name, value, attrs, %1 FROM element_info WHERE %2 = ? ORDER BY ord") + .arg(show_column, element_ref)); i.bind({key}); while (i.next()) info.appendChild(entry(i.value(0), QStringLiteral("name"), i.value(1), i.value(2), - QStringLiteral("elementInformation"))); + QStringLiteral("elementInformation"), i.value(3))); restoreWhitespace(info, v.at(n + 1)); } if (!v.at(n + 2).isNull()) { links = document.createElement(QStringLiteral("links_uuids")); setAttributesJson(links, v.at(n + 3)); - Statement l(db.m_db, QStringLiteral("SELECT linked_uuid, attrs FROM link WHERE element_key = ? ORDER BY ord")); + Statement l(db.m_db, QStringLiteral("SELECT linked_uuid, attrs FROM link WHERE %1 = ? ORDER BY ord").arg(element_ref)); l.bind({key}); while (l.next()) links.appendChild(entry(l.value(0), QStringLiteral("uuid"), QVariant(), l.value(1), @@ -756,16 +852,15 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS } for (const QDomElement &list : children(d, QStringLiteral("conductors"))) { for (QDomElement c : children(list, QStringLiteral("conductor"))) { - if (!c.hasAttribute(KeyAttribute)) continue; - const QString key = c.attribute(KeyAttribute); - c.removeAttribute(KeyAttribute); - Statement s(db.m_db, QStringLiteral("SELECT %1 FROM conductor WHERE key = ?") - .arg(QStringLiteral("\"") + ConductorData.join(QStringLiteral("\", \"")) + QStringLiteral("\""))); + const QString key = keyOf(c, KeyAttribute); + if (key.isEmpty()) continue; + Statement s(db.m_db, QStringLiteral("SELECT %1 FROM conductor WHERE %2 = ?") + .arg(quoted(conductor_data), item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("wire %1 is not in the database").arg(key)); return false; } QVariantList v; - for (int i = 0 ; i < ConductorData.size() ; ++i) v << s.value(i); - put(c, ConductorData, v, 0); + for (int i = 0 ; i < conductor_data.size() ; ++i) v << s.value(i); + put(c, conductor_data, v, 0); } } } diff --git a/sources/container/qetcontainerdb.h b/sources/container/qetcontainerdb.h index a70dfb1fe..a60c293b3 100644 --- a/sources/container/qetcontainerdb.h +++ b/sources/container/qetcontainerdb.h @@ -30,8 +30,10 @@ drawn. extract() takes them out of the document into a database, restore() puts them back, so that restore(extract(d)) is d. - Each moved item leaves its key in the document (qetz-key, qetz-folio): - its uuid where that is unique, else a made-up one. A block of a shape + A row's key is its item's uuid, which stays in the folio file. An item + without a uuid of its own -- none, or one another item shares -- gets a + uuid made from what it holds, left in the document as qetz-key or + qetz-folio. A block of a shape this does not model -- a value holding elements, say -- stays in the document, so nothing can be lost by moving. */ diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index b5be0cf96..4c3bda623 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1253,4 +1253,5 @@ add_test(NAME tst_qetcontainer COMMAND tst_qetcontainer) target_include_directories(tst_qetcontainer PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_qetcontainer PRIVATE Qt::Test Qt::Xml SQLite3::SQLite3) target_compile_definitions(tst_qetcontainer PRIVATE - "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"" + "QET_FIXTURES_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/fixtures\"") diff --git a/tests/qttest/fixtures/qetz_format2.qetz b/tests/qttest/fixtures/qetz_format2.qetz new file mode 100644 index 0000000000000000000000000000000000000000..fdf8d066b616a2ed5e150a0c26f83c1753e125ae GIT binary patch literal 31363 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 J$E=>d{U67|&vO6( literal 0 HcmV?d00001 diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index ea2a9525c..82323695f 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -69,6 +69,57 @@ class tst_qetcontainer : public QObject return QetContainer::split(*original, QStringLiteral("tst_qetcontainer"), &error); } + static QByteArray entry(const QList &entries, const QString &name) + { + for (const QetZip::Entry &e : entries) + if (e.name == name) return e.data; + return {}; + } + + static QByteArray folioFiles(const QList &entries) + { + QByteArray all; + for (const QetZip::Entry &e : entries) + if (e.name.startsWith(QStringLiteral("folios/"))) all += e.data; + return all; + } + + // The first column of the first row @p sql gives, from @p database + static QString query(const QByteArray &database, const char *sql) + { + sqlite3 *db = nullptr; + sqlite3_open(":memory:", &db); + unsigned char *copy = static_cast(sqlite3_malloc64(sqlite3_uint64(database.size()))); + memcpy(copy, database.constData(), size_t(database.size())); + sqlite3_deserialize(db, "main", copy, database.size(), database.size(), SQLITE_DESERIALIZE_FREEONCLOSE); + sqlite3_stmt *st = nullptr; + QString out; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW) + out = QString::fromUtf8(reinterpret_cast(sqlite3_column_text(st, 0))); + sqlite3_finalize(st); + sqlite3_close(db); + return out; + } + + // grafcet.qet as QElectroTech saves it: every folio, symbol and wire + // with a uuid of its own, which the examples' wires and folios lack + static QDomDocument savedProject() + { + QList entries; + QDomDocument project; + QetZip::fromBytes(read(QStringLiteral(QET_FIXTURES_DIR "/qetz_format2.qetz")), &entries); + QetContainer::join(entries, &project); + return project; + } + + static QString backDifference(const QDomDocument &original, const QList &entries) + { + QDomDocument back; + QString error; + if (!QetContainer::join(entries, &back, &error)) return error; + return difference(original.documentElement(), back.documentElement(), QString()); + } + private slots: void sameDocumentBack_data() { @@ -300,6 +351,104 @@ private slots: QVERIFY2(error.contains(QStringLiteral("element")), qPrintable(error)); } + // A row's key is its item's uuid, which the folio file keeps: no key + // made up from where an item is (format 2 had f1, f2...), no key + // attribute in the folio files of a project as QElectroTech saves it, + // true/false held as 1/0, and the format in the database's + // user_version. + void keysAreTheUuids() + { + const QDomDocument original = savedProject(); + original.elementsByTagName(QStringLiteral("conductor")).at(0).toElement() + .setAttribute(QStringLiteral("freezeLabel"), QStringLiteral("true")); + QString error; + const QList entries = QetContainer::split(original, QString(), &error); + QVERIFY2(!entries.isEmpty(), qPrintable(error)); + const QByteArray database = entry(entries, QStringLiteral("project.sqlite")); + const QByteArray folios = folioFiles(entries); + QVERIFY(!folios.contains("qetz-key")); + QVERIFY(!folios.contains("qetz-folio")); + QCOMPARE(query(database, "PRAGMA user_version"), QString::number(QetContainer::Format)); + const QString first = original.elementsByTagName(QStringLiteral("diagram")).at(0).toElement() + .attribute(QStringLiteral("uuid")); + QCOMPARE(query(database, "SELECT uuid FROM folio WHERE pos = 1"), first); + QCOMPARE(query(database, "SELECT count(*) FROM folio WHERE uuid NOT LIKE '{%}'"), QStringLiteral("0")); + QCOMPARE(query(database, "SELECT count(*) FROM element WHERE uuid NOT LIKE '{%}'"), QStringLiteral("0")); + QCOMPARE(query(database, "SELECT count(*) FROM conductor WHERE uuid NOT LIKE '{%}'"), QStringLiteral("0")); + QCOMPARE(query(database, "SELECT count(*) FROM element WHERE typeof(freezeLabel) <> 'integer'"), + QStringLiteral("0")); + QCOMPARE(query(database, "SELECT count(*) FROM conductor WHERE typeof(freezeLabel) <> 'integer'"), + QStringLiteral("0")); + QCOMPARE(query(database, "SELECT sum(freezeLabel) FROM conductor"), QStringLiteral("1")); + const QString d = backDifference(original, entries); + QVERIFY2(d.isEmpty(), qPrintable(d)); + QCOMPARE(query(database, "SELECT count(*) FROM element_info WHERE typeof(show) <> 'integer'"), + QStringLiteral("0")); + QCOMPARE(query(database, "SELECT count(*) FROM element_info WHERE attrs LIKE '%\"tag\"%'"), + QStringLiteral("0")); + } + + // Two symbols sharing a uuid (F100) each get a key made from what they + // hold, the same on every save, and come back as they were. + void sharedUuidKeyed() + { + QDomDocument original = savedProject(); + const QDomNodeList elements = original.elementsByTagName(QStringLiteral("element")); + QDomElement a, b; + for (int i = 0 ; i < elements.size() && b.isNull() ; ++i) { + const QDomElement e = elements.at(i).toElement(); + if (e.parentNode().nodeName() != QLatin1String("elements")) continue; + if (a.isNull()) a = e; else b = e; + } + QVERIFY(!b.isNull()); + b.setAttribute(QStringLiteral("uuid"), a.attribute(QStringLiteral("uuid"))); + QString error; + const QList once = QetContainer::split(original, QString(), &error); + const QList twice = QetContainer::split(original, QString(), &error); + QVERIFY2(!once.isEmpty(), qPrintable(error)); + QCOMPARE(folioFiles(once).count("qetz-key"), 2); + QCOMPARE(entry(once, QStringLiteral("project.sqlite")), entry(twice, QStringLiteral("project.sqlite"))); + const QString d = backDifference(original, once); + QVERIFY2(d.isEmpty(), qPrintable(d)); + } + + // A true/false attribute QElectroTech would not write stays in the + // folio file as it was. + void oddBooleanKept() + { + QDomDocument original; + QVERIFY(QetContainer::parse(read(QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")), &original)); + const QDomNodeList conductors = original.elementsByTagName(QStringLiteral("conductor")); + QVERIFY(conductors.size() > 0); + conductors.at(0).toElement().setAttribute(QStringLiteral("freezeLabel"), QStringLiteral("yes")); + QString error; + const QList entries = QetContainer::split(original, QString(), &error); + QVERIFY2(!entries.isEmpty(), qPrintable(error)); + QVERIFY(folioFiles(entries).contains("freezeLabel=\"yes\"")); + const QString d = backDifference(original, entries); + QVERIFY2(d.isEmpty(), qPrintable(d)); + } + + // A format 2 file, as QElectroTech 0.200.1 development builds wrote it + // (grafcet.qet saved as .qetz), still opens, and saved again as the + // current format comes back the same. + void format2Read() + { + QList entries; + QString error; + QVERIFY2(QetZip::fromBytes(read(QStringLiteral(QET_FIXTURES_DIR "/qetz_format2.qetz")), &entries, &error), + qPrintable(error)); + QVERIFY(entry(entries, QStringLiteral("manifest.xml")).contains("format=\"2\"")); + QDomDocument project; + QVERIFY2(QetContainer::join(entries, &project, &error), qPrintable(error)); + QVERIFY(project.toString().contains(QStringLiteral(" resaved = QetContainer::split(project, QString(), &error); + QVERIFY2(!resaved.isEmpty(), qPrintable(error)); + const QString d = backDifference(project, resaved); + QVERIFY2(d.isEmpty(), qPrintable(d)); + } + void notAProjectRefused() { QDomDocument back; From 5975b7576612da84d1274348b0f5b9e03c383a00 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 08:57:25 +1300 Subject: [PATCH 2/5] .qetz: keep a symbol's type in its folio; refuse a database that does not match A folio file in a .qetz did not say which symbol each item is: the type (embed://...) was moved to project.sqlite, so a folio read alone could not be drawn or diffed. It stays in the folio file now; format 2 files, which moved it, are still read. Nothing checked that project.sqlite and the folio files agree: a row no item has, an item's row on another folio, or information rows of no symbol were read as far as they went. Reading now refuses such a file and says what does not match. Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainerdb.cpp | 66 ++++++++++++++++++++--- tests/qttest/tst_qetcontainer.cpp | 80 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index f7eb10b31..fd7b7bc4b 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -63,9 +63,14 @@ const QStringList FolioData = { QStringLiteral("folio"), QStringLiteral("indexrev"), QStringLiteral("filename"), QStringLiteral("locmach"), QStringLiteral("plant"), QStringLiteral("auto_page_num")}; //From format 3 on an item's uuid is its row's key, not a column of its - //own: it stays in the folio file, where it says which row is the item's + //own: it stays in the folio file, where it says which row is the item's. + //So does a symbol's type, the symbol it is: a folio file read alone + //still says what is drawn where const QStringList ElementData = { - QStringLiteral("type"), QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; + QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; + //Format 2 also moved the type +const QStringList ElementDataV2 = { + QStringLiteral("uuid"), QStringLiteral("type"), QStringLiteral("prefix"), QStringLiteral("freezeLabel")}; const QStringList ConductorData = { QStringLiteral("element1"), QStringLiteral("terminal1"), QStringLiteral("element2"), QStringLiteral("terminal2"), QStringLiteral("num"), @@ -735,7 +740,7 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS const QString item_key = v2 ? QStringLiteral("key") : QStringLiteral("uuid"); const QString element_ref = v2 ? QStringLiteral("element_key") : QStringLiteral("element_uuid"); const QString show_column = v2 ? QStringLiteral("NULL") : QStringLiteral("show"); - const QStringList element_data = v2 ? QStringList{QStringLiteral("uuid")} + ElementData : ElementData; + const QStringList element_data = v2 ? ElementDataV2 : ElementData; const QStringList conductor_data = v2 ? QStringList{QStringLiteral("uuid")} + ConductorData : ConductorData; auto quoted = [](const QStringList &names) { return QStringLiteral("\"") + names.join(QStringLiteral("\", \"")) + QStringLiteral("\""); @@ -783,10 +788,22 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS } } + //Every row must be an item's, once, on the folio the item is on: + //nothing enforces it in the file, which is untrusted + QSet folios_used, elements_used, conductors_used; + auto use = [error](QSet &used, const QString &key, const QString &what) { + if (used.contains(key)) { + setError(error, QStringLiteral("%1 %2 is in the folios twice").arg(what, key)); + return false; + } + used.insert(key); + return true; + }; for (QDomElement d : children(project, QStringLiteral("diagram"))) { const QString id = keyOf(d, FolioAttribute); if (id.isEmpty()) continue; + if (!use(folios_used, id, QStringLiteral("folio"))) return false; Statement f(db.m_db, QStringLiteral("SELECT %1, props_at, props_attrs FROM folio WHERE %2 = ?") .arg(quoted(FolioData), folio_key)); f.bind({id}); @@ -813,10 +830,15 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS for (QDomElement e : children(list, QStringLiteral("element"))) { const QString key = keyOf(e, KeyAttribute); if (key.isEmpty()) continue; - Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs FROM element WHERE %2 = ?") - .arg(quoted(element_data), item_key)); + if (!use(elements_used, key, QStringLiteral("symbol"))) return false; + Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs, %2 FROM element WHERE %3 = ?") + .arg(quoted(element_data), folio_ref, item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("symbol %1 is not in the database").arg(key)); return false; } + if (s.value(element_data.size() + 4).toString() != id) { + setError(error, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id)); + return false; + } QVariantList v; for (int i = 0 ; i < element_data.size() + 4 ; ++i) v << s.value(i); put(e, element_data, v, 0); @@ -854,15 +876,45 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS for (QDomElement c : children(list, QStringLiteral("conductor"))) { const QString key = keyOf(c, KeyAttribute); if (key.isEmpty()) continue; - Statement s(db.m_db, QStringLiteral("SELECT %1 FROM conductor WHERE %2 = ?") - .arg(quoted(conductor_data), item_key)); + if (!use(conductors_used, key, QStringLiteral("wire"))) return false; + Statement s(db.m_db, QStringLiteral("SELECT %1, %2 FROM conductor WHERE %3 = ?") + .arg(quoted(conductor_data), folio_ref, item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("wire %1 is not in the database").arg(key)); return false; } + if (s.value(conductor_data.size()).toString() != id) { + setError(error, QStringLiteral("wire %1 is on folio %2, the database says another").arg(key, id)); + return false; + } QVariantList v; for (int i = 0 ; i < conductor_data.size() ; ++i) v << s.value(i); put(c, conductor_data, v, 0); } } } + + //No row left over: each table's rows, counted, are the items read, + //and every entry belongs to a row + auto count = [&db](const QString &sql) { + Statement s(db.m_db, sql); + return s.next() ? s.value(0).toInt() : -1; + }; + const struct { QString sql; int expected; QString what; } checks[] = { + {QStringLiteral("SELECT count(*) FROM folio"), int(folios_used.size()), QStringLiteral("folio rows")}, + {QStringLiteral("SELECT count(*) FROM element"), int(elements_used.size()), QStringLiteral("symbol rows")}, + {QStringLiteral("SELECT count(*) FROM conductor"), int(conductors_used.size()), QStringLiteral("wire rows")}, + {QStringLiteral("SELECT count(*) FROM folio_property WHERE %1 NOT IN (SELECT %2 FROM folio)") + .arg(folio_ref, folio_key), 0, QStringLiteral("folio property rows of no folio")}, + {QStringLiteral("SELECT count(*) FROM element_info WHERE %1 NOT IN (SELECT %2 FROM element)") + .arg(element_ref, item_key), 0, QStringLiteral("information rows of no symbol")}, + {QStringLiteral("SELECT count(*) FROM link WHERE %1 NOT IN (SELECT %2 FROM element)") + .arg(element_ref, item_key), 0, QStringLiteral("link rows of no symbol")}}; + for (const auto &check : checks) { + const int found = count(check.sql); + if (found != check.expected) { + setError(error, QStringLiteral("the project database does not match its folios: %1 %2, %3 expected") + .arg(found).arg(check.what).arg(check.expected)); + return false; + } + } return true; } diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 82323695f..84e9db31c 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -323,6 +323,86 @@ private slots: // A crafted project.sqlite whose "element" is a view, not a table, is // refused: nothing in a file's schema is trusted. + // project.sqlite changed by @p sql in @p entries + static void alterDatabase(QList &entries, const char *sql) + { + for (QetZip::Entry &e : entries) { + if (e.name != QLatin1String("project.sqlite")) continue; + sqlite3 *db = nullptr; + sqlite3_open(":memory:", &db); + unsigned char *copy = static_cast(sqlite3_malloc64(sqlite3_uint64(e.data.size()))); + memcpy(copy, e.data.constData(), size_t(e.data.size())); + sqlite3_deserialize(db, "main", copy, e.data.size(), e.data.size(), + SQLITE_DESERIALIZE_FREEONCLOSE | SQLITE_DESERIALIZE_RESIZEABLE); + QCOMPARE(sqlite3_exec(db, sql, nullptr, nullptr, nullptr), SQLITE_OK); + sqlite3_int64 size = 0; + unsigned char *bytes = sqlite3_serialize(db, "main", &size, 0); + e.data = QByteArray(reinterpret_cast(bytes), qsizetype(size)); + sqlite3_free(bytes); + sqlite3_close(db); + } + } + + // A database that does not match its folio files -- a row no item has, + // an item's row on another folio, entries of no symbol -- is refused + // with what is wrong, not read as far as it goes + void mismatchRefused_data() + { + QTest::addColumn("sql"); + QTest::addColumn("said"); + QTest::newRow("symbol row of no symbol") + << QByteArray("INSERT INTO element SELECT 'x', folio_uuid, prefix, freezeLabel, info_at, info_attrs," + " links_at, links_attrs FROM element LIMIT 1") + << QStringLiteral("symbol rows"); + QTest::newRow("wire row of no wire") + << QByteArray("INSERT INTO conductor SELECT 'x', folio_uuid, element1, terminal1, element2, terminal2," + " num, formula, function, tension_protocol, conductor_color, conductor_section, cable," + " bus, freezeLabel FROM conductor LIMIT 1") + << QStringLiteral("wire rows"); + QTest::newRow("symbol on another folio") + << QByteArray("UPDATE element SET folio_uuid = (SELECT uuid FROM folio WHERE pos = 2)" + " WHERE folio_uuid = (SELECT uuid FROM folio WHERE pos = 1)") + << QStringLiteral("the database says another"); + QTest::newRow("information of no symbol") + << QByteArray("UPDATE element_info SET element_uuid = 'x' WHERE rowid = 1") + << QStringLiteral("information rows of no symbol"); + } + void mismatchRefused() + { + QFETCH(QByteArray, sql); + QFETCH(QString, said); + QDomDocument original; + QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + QVERIFY(!entries.isEmpty()); + { + QDomDocument back; + QString error; + QVERIFY2(QetContainer::join(entries, &back, &error), qPrintable(error)); + } + alterDatabase(entries, sql.constData()); + QDomDocument back; + QString error; + QVERIFY(!QetContainer::join(entries, &back, &error)); + QVERIFY2(error.contains(said), qPrintable(error)); + } + + // A folio file says which symbol each item is (its type), read alone + void folioSaysWhichSymbol() + { + QDomDocument original; + const QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + QVERIFY(!entries.isEmpty()); + int typed = 0, symbols = 0; + for (const QetZip::Entry &e : entries) { + if (!e.name.startsWith(QStringLiteral("folios/"))) continue; + const QString text = QString::fromUtf8(e.data); + symbols += int(text.count(QStringLiteral("]*type=\"embed://")))); + } + QVERIFY(symbols > 0); + QCOMPARE(typed, symbols); + } + void viewInsteadOfTableRefused() { QDomDocument original; From 4c3d87e3f716667237bc1905afa0e558b642bdc4 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 09:47:08 +1300 Subject: [PATCH 3/5] .qetz: no columns for where a block sat; open a later format read-only The database held where each moved block sat among its parent's children (props_at, info_at, links_at), though reading only asked whether there was one: the marker the folio file keeps says that, and where it goes back. The columns are gone; format 2 files leave the same markers and still open. A .qetz of a later format whose min-reader allows this version was still refused by the database's own format check. It opens now, read-only, with a message: saving it here could lose what this version does not know. Save As writes a copy in this version's format. Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainer.cpp | 16 ++++-- sources/container/qetcontainer.h | 10 +++- sources/container/qetcontainerdb.cpp | 86 ++++++++++++++-------------- sources/qetdiagrameditor.cpp | 10 ++++ sources/qetproject.cpp | 12 +++- sources/qetproject.h | 3 + tests/qttest/tst_qetcontainer.cpp | 41 ++++++++++++- 7 files changed, 123 insertions(+), 55 deletions(-) diff --git a/sources/container/qetcontainer.cpp b/sources/container/qetcontainer.cpp index 7f27789c4..89c464e22 100644 --- a/sources/container/qetcontainer.cpp +++ b/sources/container/qetcontainer.cpp @@ -153,15 +153,18 @@ bool QetContainer::isZip(const QByteArray &content) /** @brief QetContainer::readDocument The project document held in @p content: unzipped and joined for a - .qetz, parsed for a .qet. + .qetz, parsed for a .qet. @p format, if any: the .qetz's format, 0 + for a .qet. */ -bool QetContainer::readDocument(const QByteArray &content, QDomDocument *document, QString *error) +bool QetContainer::readDocument(const QByteArray &content, QDomDocument *document, QString *error, + int *format) { + if (format) *format = 0; if (!isZip(content)) return parse(content, document, error); QList entries; return QetZip::fromBytes(content, &entries, error) - && join(entries, document, error); + && join(entries, document, error, format); } /** @@ -264,8 +267,11 @@ QList QetContainer::split(const QDomDocument &project, const QStr Refused, with @p error set: not a .qetz, made for a later QElectroTech (the manifest's min-reader), or a part missing or unreadable. Entries nothing names are ignored, so a later format may add some. + @p format, if any: the format the file says it is, above Format for a + later one this may read (its min-reader allows it) */ -bool QetContainer::join(const QList &entries, QDomDocument *project, QString *error) +bool QetContainer::join(const QList &entries, QDomDocument *project, QString *error, + int *format) { QHash files; for (const QetZip::Entry &entry : entries) @@ -286,6 +292,8 @@ bool QetContainer::join(const QList &entries, QDomDocument *proje .arg(Format)); return false; } + if (format) + *format = manifest.documentElement().attribute(QStringLiteral("format")).toInt(); if (!parse(files.value(QStringLiteral("project.xml")), project, error)) return false; diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index 32da2223e..fb0623f7e 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -49,20 +49,24 @@ namespace QetContainer /// unless their min-reader allows this one. constexpr int Format = 3; /// 3: the database's rows keyed by the items' uuids, true/false as - /// 1/0, its PRAGMA user_version the format. Format 2 is still read. + /// 1/0, its PRAGMA user_version the format, the symbols' type left + /// in the folio files, no column for where a block sat. Format 2 is + /// still read; a later format whose min-reader allows this one is + /// read too, and said so through join()'s @p format. QList split(const QDomDocument &project, const QString &writer = QString(), QString *error = nullptr); bool join(const QList &entries, QDomDocument *project, - QString *error = nullptr); + QString *error = nullptr, int *format = nullptr); /// Parse @p xml as a .qet is read (QETProject::openFile) bool parse(const QByteArray &xml, QDomDocument *document, QString *error = nullptr); /// true if @p content is a zip, so a .qetz rather than a .qet bool isZip(const QByteArray &content); /// The project document in @p content, a .qet or a .qetz - bool readDocument(const QByteArray &content, QDomDocument *document, QString *error = nullptr); + bool readDocument(const QByteArray &content, QDomDocument *document, QString *error = nullptr, + int *format = nullptr); } #endif // QETCONTAINER_H diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index fd7b7bc4b..37ed07749 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -58,6 +58,15 @@ bool replaceMarker(QDomElement parent, const QDomElement &block) return false; } + //true if @p parent holds the marker of a block @p tag moved out: where + //the block goes back, and that it was there at all +bool hasMarker(const QDomElement &parent, const QString &tag) +{ + for (QDomElement m = parent.firstChildElement(MarkerTag) ; !m.isNull() ; m = m.nextSiblingElement(MarkerTag)) + if (m.attribute(MarkerBlock) == tag) return true; + return false; +} + const QStringList FolioData = { QStringLiteral("title"), QStringLiteral("author"), QStringLiteral("date"), QStringLiteral("folio"), QStringLiteral("indexrev"), QStringLiteral("filename"), @@ -114,11 +123,11 @@ QString schema() "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);" "CREATE TABLE project_property (ord INTEGER PRIMARY KEY, name TEXT, value TEXT, attrs TEXT);" "CREATE TABLE folio (uuid TEXT PRIMARY KEY, pos INTEGER NOT NULL UNIQUE, %1," - " props_at INTEGER, props_attrs TEXT);" + " props_attrs TEXT);" "CREATE TABLE folio_property (folio_uuid TEXT NOT NULL REFERENCES folio, ord INTEGER NOT NULL," " name TEXT, value TEXT, attrs TEXT, PRIMARY KEY (folio_uuid, ord));" "CREATE TABLE element (uuid TEXT PRIMARY KEY, folio_uuid TEXT NOT NULL REFERENCES folio, %2," - " info_at INTEGER, info_attrs TEXT, links_at INTEGER, links_attrs TEXT);" + " info_attrs TEXT, links_attrs TEXT);" "CREATE TABLE element_info (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," " name TEXT, value TEXT, show INTEGER, attrs TEXT, PRIMARY KEY (element_uuid, ord));" "CREATE TABLE link (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," @@ -291,15 +300,6 @@ bool listBlock(const QDomElement &block, const QString &entry) return true; } -int childIndex(const QDomNode &child) -{ - int i = 0; - for (QDomNode n = child.parentNode().firstChild() ; !n.isNull() ; n = n.nextSibling(), ++i) - if (n == child) return i; - return -1; -} - - //The attributes @p names of @p e, taken out of it; one that cannot be //stored stays, its value null QVariantList take(QDomElement e, const QStringList &names) @@ -511,9 +511,9 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) Statement meta(db.m_db, QStringLiteral("INSERT INTO meta VALUES (?,?)")); Statement project_property(db.m_db, QStringLiteral("INSERT INTO project_property VALUES (?,?,?,?)")); - Statement folio(db.m_db, QStringLiteral("INSERT INTO folio VALUES (?,?,%1,?,?)").arg(placeholders(FolioData.size()))); + Statement folio(db.m_db, QStringLiteral("INSERT INTO folio VALUES (?,?,%1,?)").arg(placeholders(FolioData.size()))); Statement folio_property(db.m_db, QStringLiteral("INSERT INTO folio_property VALUES (?,?,?,?,?)")); - Statement element(db.m_db, QStringLiteral("INSERT INTO element VALUES (?,?,%1,?,?,?,?)").arg(placeholders(ElementData.size()))); + Statement element(db.m_db, QStringLiteral("INSERT INTO element VALUES (?,?,%1,?,?)").arg(placeholders(ElementData.size()))); Statement element_info(db.m_db, QStringLiteral("INSERT INTO element_info VALUES (?,?,?,?,?,?)")); Statement link(db.m_db, QStringLiteral("INSERT INTO link VALUES (?,?,?,?)")); Statement conductor(db.m_db, QStringLiteral("INSERT INTO conductor VALUES (?,?,%1)").arg(placeholders(ConductorData.size()))); @@ -538,7 +538,6 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) //Project properties const QDomElement project_props = project.firstChildElement(QStringLiteral("properties")); if (!project_props.isNull() && listBlock(project_props, QStringLiteral("property"))) { - ok = ok && meta.run({QStringLiteral("props_at"), QString::number(childIndex(project_props))}); ok = ok && meta.run({QStringLiteral("props_attrs"), blockJson(project_props)}); int ord = 0; for (QDomElement p : children(project_props, QStringLiteral("property"))) { @@ -575,8 +574,7 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) row += take(d, FolioData); QDomElement props = d.firstChildElement(QStringLiteral("properties")); const bool move_props = !props.isNull() && listBlock(props, QStringLiteral("property")); - row << (move_props ? QVariant(childIndex(props)) : QVariant()) - << (move_props ? blockJson(props) : QVariant()); + row << (move_props ? blockJson(props) : QVariant()); ok = ok && folio.run(row); if (move_props) { moveProperties(props, folio_property, {id}); @@ -594,14 +592,10 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) QDomElement links = e.firstChildElement(QStringLiteral("links_uuids")); if (!info.isNull() && !listBlock(info, QStringLiteral("elementInformation"))) info = QDomElement(); if (!links.isNull() && !listBlock(links, QStringLiteral("link_uuid"))) links = QDomElement(); - const int info_at = info.isNull() ? -1 : childIndex(info); - const int links_at = links.isNull() ? -1 : childIndex(links); - erow << (info_at < 0 ? QVariant() : QVariant(info_at)) - << (info_at < 0 ? QVariant() : blockJson(info)) - << (links_at < 0 ? QVariant() : QVariant(links_at)) - << (links_at < 0 ? QVariant() : blockJson(links)); + erow << (info.isNull() ? QVariant() : blockJson(info)) + << (links.isNull() ? QVariant() : blockJson(links)); ok = ok && element.run(erow); - if (info_at >= 0) { + if (!info.isNull()) { int ord = 0; for (QDomElement i : children(info, QStringLiteral("elementInformation"))) { QVariant value; @@ -616,15 +610,15 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) attributesJson(i, kept, QStringLiteral("elementInformation"))}); } } - if (links_at >= 0) { + if (!links.isNull()) { int ord = 0; for (QDomElement l : children(links, QStringLiteral("link_uuid"))) ok = ok && link.run({key, ord++, l.hasAttribute(QStringLiteral("uuid")) ? QVariant(l.attribute(QStringLiteral("uuid"))) : QVariant(), attributesJson(l, {QStringLiteral("uuid")}, QStringLiteral("link_uuid"))}); } - if (info_at >= 0) leaveMarker(info); - if (links_at >= 0) leaveMarker(links); + if (!info.isNull()) leaveMarker(info); + if (!links.isNull()) leaveMarker(links); if (!own_key) e.setAttribute(KeyAttribute, key); } } @@ -729,9 +723,11 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS //apart from the items' uuids: "f1", "e1", "c1" where an item had //none of its own, and every item's key in the folio file const int format = meta.value(QStringLiteral("format")).toString().toInt(); - if (format < 2 || format > QetContainer::Format) { - setError(error, QStringLiteral("the project database is of format %1, this reads 2 to %2") - .arg(meta.value(QStringLiteral("format")).toString()).arg(QetContainer::Format)); + //A later format is read when the manifest's min-reader allows it + //(QetContainer::join()): its writer kept what this reads + if (format < 2) { + setError(error, QStringLiteral("the project database is of format %1, this reads format 2 on") + .arg(meta.value(QStringLiteral("format")).toString())); return false; } const bool v2 = format == 2; @@ -754,7 +750,9 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS } return v2 ? QString() : e.attribute(QStringLiteral("uuid")); }; - if (meta.contains(QStringLiteral("props_at"))) { + //A moved block goes back where its marker is: the marker says it + //was there + if (hasMarker(project, QStringLiteral("properties"))) { QDomElement props = document.createElement(QStringLiteral("properties")); setAttributesJson(props, meta.value(QStringLiteral("props_attrs"))); Statement s(db.m_db, QStringLiteral("SELECT name, value, attrs FROM project_property ORDER BY ord")); @@ -804,22 +802,22 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS const QString id = keyOf(d, FolioAttribute); if (id.isEmpty()) continue; if (!use(folios_used, id, QStringLiteral("folio"))) return false; - Statement f(db.m_db, QStringLiteral("SELECT %1, props_at, props_attrs FROM folio WHERE %2 = ?") + Statement f(db.m_db, QStringLiteral("SELECT %1, props_attrs FROM folio WHERE %2 = ?") .arg(quoted(FolioData), folio_key)); f.bind({id}); if (!f.next()) { setError(error, QStringLiteral("folio %1 is not in the database").arg(id)); return false; } QVariantList values; - for (int i = 0 ; i < FolioData.size() + 2 ; ++i) values << f.value(i); + for (int i = 0 ; i < FolioData.size() + 1 ; ++i) values << f.value(i); put(d, FolioData, values, 0); - if (!values.at(FolioData.size()).isNull()) { + if (hasMarker(d, QStringLiteral("properties"))) { QDomElement props = document.createElement(QStringLiteral("properties")); - setAttributesJson(props, values.at(FolioData.size() + 1)); + setAttributesJson(props, values.at(FolioData.size())); Statement p(db.m_db, QStringLiteral("SELECT name, value, attrs FROM folio_property WHERE %1 = ? ORDER BY ord").arg(folio_ref)); p.bind({id}); while (p.next()) props.appendChild(entry(p.value(0), QStringLiteral("name"), p.value(1), p.value(2), QStringLiteral("property"))); - restoreWhitespace(props, values.at(FolioData.size() + 1)); + restoreWhitespace(props, values.at(FolioData.size())); if (!replaceMarker(d, props)) { setError(error, QStringLiteral("folio %1's properties have no place").arg(id)); return false; @@ -831,39 +829,39 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS const QString key = keyOf(e, KeyAttribute); if (key.isEmpty()) continue; if (!use(elements_used, key, QStringLiteral("symbol"))) return false; - Statement s(db.m_db, QStringLiteral("SELECT %1, info_at, info_attrs, links_at, links_attrs, %2 FROM element WHERE %3 = ?") + Statement s(db.m_db, QStringLiteral("SELECT %1, info_attrs, links_attrs, %2 FROM element WHERE %3 = ?") .arg(quoted(element_data), folio_ref, item_key)); s.bind({key}); if (!s.next()) { setError(error, QStringLiteral("symbol %1 is not in the database").arg(key)); return false; } - if (s.value(element_data.size() + 4).toString() != id) { + if (s.value(element_data.size() + 2).toString() != id) { setError(error, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id)); return false; } QVariantList v; - for (int i = 0 ; i < element_data.size() + 4 ; ++i) v << s.value(i); + for (int i = 0 ; i < element_data.size() + 2 ; ++i) v << s.value(i); put(e, element_data, v, 0); const int n = element_data.size(); QDomElement info, links; - if (!v.at(n).isNull()) { + if (hasMarker(e, QStringLiteral("elementInformations"))) { info = document.createElement(QStringLiteral("elementInformations")); - setAttributesJson(info, v.at(n + 1)); + setAttributesJson(info, v.at(n)); Statement i(db.m_db, QStringLiteral("SELECT name, value, attrs, %1 FROM element_info WHERE %2 = ? ORDER BY ord") .arg(show_column, element_ref)); i.bind({key}); while (i.next()) info.appendChild(entry(i.value(0), QStringLiteral("name"), i.value(1), i.value(2), QStringLiteral("elementInformation"), i.value(3))); - restoreWhitespace(info, v.at(n + 1)); + restoreWhitespace(info, v.at(n)); } - if (!v.at(n + 2).isNull()) { + if (hasMarker(e, QStringLiteral("links_uuids"))) { links = document.createElement(QStringLiteral("links_uuids")); - setAttributesJson(links, v.at(n + 3)); + setAttributesJson(links, v.at(n + 1)); Statement l(db.m_db, QStringLiteral("SELECT linked_uuid, attrs FROM link WHERE %1 = ? ORDER BY ord").arg(element_ref)); l.bind({key}); while (l.next()) links.appendChild(entry(l.value(0), QStringLiteral("uuid"), QVariant(), l.value(1), QStringLiteral("link_uuid"))); - restoreWhitespace(links, v.at(n + 3)); + restoreWhitespace(links, v.at(n + 1)); } if ((!info.isNull() && !replaceMarker(e, info)) || (!links.isNull() && !replaceMarker(e, links))) { diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 8c9924606..798f29e23 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1969,6 +1969,16 @@ bool QETDiagramEditor::openAndAddProject( addProject(project); DialogWaiting::dropInstance(); + if (interactive && project->isFromLaterVersion()) { + QET::QetMessageBox::warning( + this, + tr("Opening the file read-only", "message box title"), + tr("This project was saved by a later version of QElectroTech. " + "It is opened read-only, so that saving it cannot lose what " + "this version does not know. Use Save As to keep a copy " + "in this version's format.")); + } + //Report font descriptions which could not be read as-is (written by //an incompatible Qt version or corrupted), so the user learns about //it from somewhere else than the console. See issue #553. diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 913bc9b77..fb96ab0e6 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -456,7 +456,8 @@ QETProject::ProjectState QETProject::openFile(QFile *file) // QetContainer::parse() does exactly that, for a .qet and for each part // of a .qetz alike. QString read_error; - if (!QetContainer::readDocument(content, &xml_project, &read_error)) + int container_format = 0; + if (!QetContainer::readDocument(content, &xml_project, &read_error, &container_format)) { qWarning().noquote() << "Unable to read" << fi.fileName() << ":" << read_error; if(opened_here) { @@ -489,9 +490,16 @@ QETProject::ProjectState QETProject::openFile(QFile *file) << xml_parse_ms / 1000.0 << ", content " << (total_ms - xml_parse_ms) / 1000.0 << ")"; - if (!fi.isWritable()) { + //Written by a later QElectroTech that lets this one read it: saving + //here would drop what this version does not know + m_from_later_version = container_format > QetContainer::Format; + if (!fi.isWritable() || m_from_later_version) { setReadOnly(true); } + if (m_from_later_version) { + qInfo().noquote() << QStringLiteral("Project %1 is of .qetz format %2 (this version writes %3): opened read-only") + .arg(fi.fileName()).arg(container_format).arg(QetContainer::Format); + } if(opened_here) { file->close(); } diff --git a/sources/qetproject.h b/sources/qetproject.h index 30da9d1a4..edf054a80 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -318,6 +318,8 @@ class QETProject : public QObject bool close(); QETResult write(); bool isReadOnly() const; + /// Opened from a .qetz of a later format (opened read-only) + bool isFromLaterVersion() const {return m_from_later_version;} void setReadOnly(bool); bool isEmpty() const; ElementsLocation importElement(ElementsLocation &location); @@ -462,6 +464,7 @@ class QETProject : public QObject bool m_modified = false; /// Whether the project is read only bool m_read_only = false; + bool m_from_later_version = false; /// Filepath for which this project is considered read only QString read_only_file_path_; /// Default dimensions and properties for new diagrams created within the project diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 84e9db31c..a744bd2bc 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -294,6 +294,43 @@ private slots: } // A file whose manifest needs a later reader is refused, not half read + // A later format whose min-reader allows this one opens, and join() says + // it is later: QElectroTech opens it read-only + void laterFormatAllowedRead() + { + QDomDocument original; + QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + QVERIFY(!entries.isEmpty()); + const int later = QetContainer::Format + 1; + for (QetZip::Entry &e : entries) + if (e.name == QLatin1String("manifest.xml")) + e.data = QString::fromUtf8(e.data).replace(QRegularExpression(QStringLiteral(" format=\"\\d+\"")), + QStringLiteral(" format=\"%1\"").arg(later)).toUtf8(); + alterDatabase(entries, QStringLiteral("UPDATE meta SET value = '%1' WHERE key = 'format'") + .arg(later).toUtf8().constData()); + QDomDocument back; + QString error; + int format = 0; + QVERIFY2(QetContainer::join(entries, &back, &error, &format), qPrintable(error)); + QCOMPARE(format, later); + const QString d = difference(original.documentElement(), back.documentElement(), QString()); + QVERIFY2(d.isEmpty(), qPrintable(d)); + } + + // The database holds data, not where a block sat in the XML: the folio + // file's marker says where it goes back + void noPositionColumns() + { + QDomDocument original; + const QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + const QByteArray database = entry(entries, QStringLiteral("project.sqlite")); + QCOMPARE(query(database, "SELECT group_concat(name) FROM pragma_table_info('element')"), + QStringLiteral("uuid,folio_uuid,prefix,freezeLabel,info_attrs,links_attrs")); + QCOMPARE(query(database, "SELECT count(*) FROM pragma_table_info('folio') WHERE name LIKE '%_at'"), + QStringLiteral("0")); + QCOMPARE(query(database, "SELECT count(*) FROM meta WHERE key = 'props_at'"), QStringLiteral("0")); + } + void laterFormatRefused() { QDomDocument original; @@ -351,8 +388,8 @@ private slots: QTest::addColumn("sql"); QTest::addColumn("said"); QTest::newRow("symbol row of no symbol") - << QByteArray("INSERT INTO element SELECT 'x', folio_uuid, prefix, freezeLabel, info_at, info_attrs," - " links_at, links_attrs FROM element LIMIT 1") + << QByteArray("INSERT INTO element SELECT 'x', folio_uuid, prefix, freezeLabel, info_attrs," + " links_attrs FROM element LIMIT 1") << QStringLiteral("symbol rows"); QTest::newRow("wire row of no wire") << QByteArray("INSERT INTO conductor SELECT 'x', folio_uuid, element1, terminal1, element2, terminal2," From 590bd6e532757b7364c096226e46994fc07b69e2 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 10:36:19 +1300 Subject: [PATCH 4/5] .qetz: a property's show flag gets a column The JSON column of a property row held only its show flag, in every project QElectroTech saves: the 25 examples and a 136-folio project used it for nothing else. Folio and project properties now store it as a number, as symbol information does, so a saved project's database holds no JSON; JSON is left for attributes QElectroTech does not write (an older or hand-edited file). Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainer.h | 4 +- sources/container/qetcontainerdb.cpp | 66 +++++++++++++--------------- tests/qttest/tst_qetcontainer.cpp | 20 +++++++++ 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index fb0623f7e..72184045d 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -48,8 +48,8 @@ namespace QetContainer /// The format written; files of a later format are refused by join() /// unless their min-reader allows this one. constexpr int Format = 3; - /// 3: the database's rows keyed by the items' uuids, true/false as - /// 1/0, its PRAGMA user_version the format, the symbols' type left + /// 3: the database's rows keyed by the items' uuids, true/false and + /// show flags as 1/0, its PRAGMA user_version the format, the symbols' type left /// in the folio files, no column for where a block sat. Format 2 is /// still read; a later format whose min-reader allows this one is /// read too, and said so through join()'s @p format. diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index 37ed07749..66cf9cbf8 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -121,11 +121,11 @@ QString schema() { return QStringLiteral( "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);" - "CREATE TABLE project_property (ord INTEGER PRIMARY KEY, name TEXT, value TEXT, attrs TEXT);" + "CREATE TABLE project_property (ord INTEGER PRIMARY KEY, name TEXT, value TEXT, show INTEGER, attrs TEXT);" "CREATE TABLE folio (uuid TEXT PRIMARY KEY, pos INTEGER NOT NULL UNIQUE, %1," " props_attrs TEXT);" "CREATE TABLE folio_property (folio_uuid TEXT NOT NULL REFERENCES folio, ord INTEGER NOT NULL," - " name TEXT, value TEXT, attrs TEXT, PRIMARY KEY (folio_uuid, ord));" + " name TEXT, value TEXT, show INTEGER, attrs TEXT, PRIMARY KEY (folio_uuid, ord));" "CREATE TABLE element (uuid TEXT PRIMARY KEY, folio_uuid TEXT NOT NULL REFERENCES folio, %2," " info_attrs TEXT, links_attrs TEXT);" "CREATE TABLE element_info (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL," @@ -455,6 +455,21 @@ QList children(const QDomElement &parent, const QString &tag) return list; } + //A list entry -- a , an -- as its row: + //its name, value, show flag (0 or 1, else left in the JSON) and any + //other attribute as JSON, its tag too if it is not @p usual_tag +QVariantList entryRow(const QDomElement &e, const QString &usual_tag) +{ + QVariant value; + simpleValue(e, &value); + const QString show = e.attribute(QStringLiteral("show")); + const bool show_stored = show == QLatin1String("0") || show == QLatin1String("1"); + QStringList kept{QStringLiteral("name")}; + if (show_stored) kept << QStringLiteral("show"); + return {e.hasAttribute(QStringLiteral("name")) ? QVariant(e.attribute(QStringLiteral("name"))) : QVariant(), + value, show_stored ? QVariant(show.toInt()) : QVariant(), attributesJson(e, kept, usual_tag)}; +} + //The key of each of @p items: its uuid, when it has one no other of //@p items has, so the uuid the folio file already holds says which row //is the item's (@p own true). Else (F100: symbols copied with their @@ -510,9 +525,9 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) db.exec(QStringLiteral("BEGIN")); Statement meta(db.m_db, QStringLiteral("INSERT INTO meta VALUES (?,?)")); - Statement project_property(db.m_db, QStringLiteral("INSERT INTO project_property VALUES (?,?,?,?)")); + Statement project_property(db.m_db, QStringLiteral("INSERT INTO project_property VALUES (?,?,?,?,?)")); Statement folio(db.m_db, QStringLiteral("INSERT INTO folio VALUES (?,?,%1,?)").arg(placeholders(FolioData.size()))); - Statement folio_property(db.m_db, QStringLiteral("INSERT INTO folio_property VALUES (?,?,?,?,?)")); + Statement folio_property(db.m_db, QStringLiteral("INSERT INTO folio_property VALUES (?,?,?,?,?,?)")); Statement element(db.m_db, QStringLiteral("INSERT INTO element VALUES (?,?,%1,?,?)").arg(placeholders(ElementData.size()))); Statement element_info(db.m_db, QStringLiteral("INSERT INTO element_info VALUES (?,?,?,?,?,?)")); Statement link(db.m_db, QStringLiteral("INSERT INTO link VALUES (?,?,?,?)")); @@ -526,27 +541,15 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) // of a block, as rows of @p rows (with @p prefix values) auto moveProperties = [&](QDomElement block, Statement &rows, const QVariantList &prefix) { int ord = 0; - for (QDomElement p : children(block, QStringLiteral("property"))) { - QVariant value; - simpleValue(p, &value); - ok = ok && rows.run(QVariantList(prefix) << ord++ << (p.hasAttribute(QStringLiteral("name")) - ? QVariant(p.attribute(QStringLiteral("name"))) : QVariant()) - << value << attributesJson(p, {QStringLiteral("name")}, QStringLiteral("property"))); - } + for (QDomElement p : children(block, QStringLiteral("property"))) + ok = ok && rows.run(QVariantList(prefix) << ord++ << entryRow(p, QStringLiteral("property"))); }; //Project properties const QDomElement project_props = project.firstChildElement(QStringLiteral("properties")); if (!project_props.isNull() && listBlock(project_props, QStringLiteral("property"))) { ok = ok && meta.run({QStringLiteral("props_attrs"), blockJson(project_props)}); - int ord = 0; - for (QDomElement p : children(project_props, QStringLiteral("property"))) { - QVariant value; - simpleValue(p, &value); - ok = ok && project_property.run({ord++, p.hasAttribute(QStringLiteral("name")) - ? QVariant(p.attribute(QStringLiteral("name"))) : QVariant(), - value, attributesJson(p, {QStringLiteral("name")}, QStringLiteral("property"))}); - } + moveProperties(project_props, project_property, {}); leaveMarker(project_props); } @@ -597,18 +600,9 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error) ok = ok && element.run(erow); if (!info.isNull()) { int ord = 0; - for (QDomElement i : children(info, QStringLiteral("elementInformation"))) { - QVariant value; - simpleValue(i, &value); - const QString show = i.attribute(QStringLiteral("show")); - const bool show_stored = show == QLatin1String("0") || show == QLatin1String("1"); - QStringList kept{QStringLiteral("name")}; - if (show_stored) kept << QStringLiteral("show"); - ok = ok && element_info.run({key, ord++, i.hasAttribute(QStringLiteral("name")) - ? QVariant(i.attribute(QStringLiteral("name"))) : QVariant(), - value, show_stored ? QVariant(show.toInt()) : QVariant(), - attributesJson(i, kept, QStringLiteral("elementInformation"))}); - } + for (QDomElement i : children(info, QStringLiteral("elementInformation"))) + ok = ok && element_info.run(QVariantList{key, ord++} + << entryRow(i, QStringLiteral("elementInformation"))); } if (!links.isNull()) { int ord = 0; @@ -755,10 +749,11 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS if (hasMarker(project, QStringLiteral("properties"))) { QDomElement props = document.createElement(QStringLiteral("properties")); setAttributesJson(props, meta.value(QStringLiteral("props_attrs"))); - Statement s(db.m_db, QStringLiteral("SELECT name, value, attrs FROM project_property ORDER BY ord")); + Statement s(db.m_db, QStringLiteral("SELECT name, value, attrs, %1 FROM project_property ORDER BY ord") + .arg(show_column)); while (s.next()) props.appendChild(entry(s.value(0), QStringLiteral("name"), s.value(1), s.value(2), - QStringLiteral("property"))); + QStringLiteral("property"), s.value(3))); restoreWhitespace(props, meta.value(QStringLiteral("props_attrs"))); if (!replaceMarker(project, props)) { setError(error, QStringLiteral("the project's properties have no place")); @@ -812,11 +807,12 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS if (hasMarker(d, QStringLiteral("properties"))) { QDomElement props = document.createElement(QStringLiteral("properties")); setAttributesJson(props, values.at(FolioData.size())); - Statement p(db.m_db, QStringLiteral("SELECT name, value, attrs FROM folio_property WHERE %1 = ? ORDER BY ord").arg(folio_ref)); + Statement p(db.m_db, QStringLiteral("SELECT name, value, attrs, %1 FROM folio_property WHERE %2 = ? ORDER BY ord") + .arg(show_column, folio_ref)); p.bind({id}); while (p.next()) props.appendChild(entry(p.value(0), QStringLiteral("name"), p.value(1), p.value(2), - QStringLiteral("property"))); + QStringLiteral("property"), p.value(3))); restoreWhitespace(props, values.at(FolioData.size())); if (!replaceMarker(d, props)) { setError(error, QStringLiteral("folio %1's properties have no place").arg(id)); diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index a744bd2bc..82f0e8020 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -331,6 +331,26 @@ private slots: QCOMPARE(query(database, "SELECT count(*) FROM meta WHERE key = 'props_at'"), QStringLiteral("0")); } + // A project as QElectroTech saves it needs no JSON: every attribute it + // writes has a column, the show flag of a property too + void savedProjectNeedsNoJson() + { + QString error; + const QList entries = QetContainer::split(savedProject(), QString(), &error); + QVERIFY2(!entries.isEmpty(), qPrintable(error)); + const QByteArray database = entry(entries, QStringLiteral("project.sqlite")); + QVERIFY(query(database, "SELECT count(*) FROM project_property").toInt() > 0); + QCOMPARE(query(database, "SELECT count(*) FROM project_property WHERE show IS NOT NULL"), + query(database, "SELECT count(*) FROM project_property")); + for (const char *sql : {"SELECT count(*) FROM project_property WHERE attrs IS NOT NULL", + "SELECT count(*) FROM folio_property WHERE attrs IS NOT NULL", + "SELECT count(*) FROM element_info WHERE attrs IS NOT NULL", + "SELECT count(*) FROM link WHERE attrs IS NOT NULL", + "SELECT count(*) FROM folio WHERE props_attrs IS NOT NULL", + "SELECT count(*) FROM element WHERE info_attrs IS NOT NULL OR links_attrs IS NOT NULL"}) + QVERIFY2(query(database, sql) == QLatin1String("0"), sql); + } + void laterFormatRefused() { QDomDocument original; From f65b24250c543a2152bed6245beca50cefd79766 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 14 Oct 2026 11:14:12 +1300 Subject: [PATCH 5/5] Document the .qetz format QETZ-FORMAT.md describes format 3: the archive and its files, the manifest and the version rules, every table and column of project.sqlite and what it comes from, how rows are keyed, the markers a moved block leaves, and what reading checks. Asked for in #1440: the format had no written specification. tst_qetcontainer checks that the schema the document gives is the one written: the same tables and columns, in the same order, with the same types and keys. Co-Authored-By: Claude Opus 5.5 --- sources/container/QETZ-FORMAT.md | 182 ++++++++++++++++++++++++++++++ sources/container/qetcontainer.h | 2 + tests/qttest/CMakeLists.txt | 3 +- tests/qttest/tst_qetcontainer.cpp | 44 ++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 sources/container/QETZ-FORMAT.md diff --git a/sources/container/QETZ-FORMAT.md b/sources/container/QETZ-FORMAT.md new file mode 100644 index 000000000..d7b2f0234 --- /dev/null +++ b/sources/container/QETZ-FORMAT.md @@ -0,0 +1,182 @@ +# The `.qetz` project file, format 3 + +A `.qetz` is a QElectroTech project saved as a zip archive: the same project +a `.qet` holds, split into files, with the engineering data in an SQLite +database. This document describes format 3. `tst_qetcontainer` checks that +the schema below is the one QElectroTech writes. + +The code is `qetcontainer.cpp` (the zip and its files), `qetcontainerdb.cpp` +(the database) and `qetzip.cpp` (reading and writing zip archives). + +## 1. Principles + +- **One writer.** A `.qet` and a `.qetz` are written from the same project + document. Saving a `.qetz` splits that document into files and a database; + opening one puts it back together, and the program reads it exactly as a + `.qet`. Opening a `.qetz` and saving it as `.qet` gives the file a plain + `.qet` save gives. +- **Each fact once.** A value moved into the database is taken out of the + folio file. Nothing is stored in both. +- **What a thing is, against where it is drawn.** The database holds the + engineering data; the folio files hold the drawing: + + | | database | folio file | + |---|---|---| + | Symbol | its information (label, comment, …), prefix, links | which symbol it is (`type`), position, rotation | + | Label shown on a folio | the value | the text item: position, font, rotation | + | Wire | its ends, number, function, voltage, colour, section | its path, the number's text position and font | + | Folio | title-block fields and custom variables | everything drawn on it | + | Terminal strip | its terminals, their order and bridges | the strip drawn on a folio | + + Cables are kept in `project.xml` in this format. +- **Nothing is lost.** Anything the database does not model stays in the + XML as it was. A block is moved only if it can be rebuilt exactly. + +## 2. The archive + +| Entry | Content | +|---|---| +| `mimetype` | `application/x-qelectrotech-project+zip`, the first entry, stored uncompressed | +| `manifest.xml` | `` | +| `project.xml` | the project document, each part replaced by `` | +| `project.sqlite` | the engineering data (section 4) | +| `folios/.xml` | one `` per folio, named by the folio's uuid (or its position when it has none) | +| `elements//` | the project's symbol definitions, as in the symbol collection | +| `titleblocks/.titleblock` | the project's title-block templates | +| `images/.png` | each folio picture once, as a file instead of base64 text (`.bin` if it is not a PNG) | + +Names that would collide get `~2`, `~3`… before the extension. A picture +in a folio file keeps the name of its file in the attribute `qetz-file`. + +A reader refuses the whole archive if an entry is encrypted, uses a +compression method it cannot read, has a name that is absolute, holds `..`, +`\` or `:`, or repeats another entry's name, or if the archive holds more +than 100 000 entries or more than 2 GiB once unpacked. Entries the format +does not name are ignored. + +## 3. Versions + +`manifest.xml` gives two numbers: `format`, the format written, and +`min-reader`, the oldest format allowed to read the file. + +- A reader opens a file whose `min-reader` is not above its own format. +- If `format` is above its own, the project opens **read-only**, with a + message: saving it could drop what that reader does not know. *Save As* + writes a copy in the reader's own format. +- If `min-reader` is above its own format, the file is refused with a + message. + +The database repeats the format in `meta` (key `format`) and in +`PRAGMA user_version`. + +**Rules for a new format:** + +1. New data goes in a new column or table. An older reader skips both. +2. A change an older reader would misread raises `min-reader`. +3. Every format keeps a saved file in `tests/qttest/fixtures` that must + keep opening. + +| Format | Written by | Reading | +|---|---|---| +| 2 | QElectroTech 0.200.1 development builds (#1462) | read: rows keyed by position (`f1`, `e1`, `c1`) where an item had no uuid of its own, every value as text | +| 3 | this document | read and written | + +## 4. The database + +### 4.1 Schema + + +```sql +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); +CREATE TABLE project_property (ord INTEGER PRIMARY KEY, name TEXT, value TEXT, show INTEGER, attrs TEXT); +CREATE TABLE folio (uuid TEXT PRIMARY KEY, pos INTEGER NOT NULL UNIQUE, + "title" TEXT, "author" TEXT, "date" TEXT, "folio" TEXT, "indexrev" TEXT, "filename" TEXT, + "locmach" TEXT, "plant" TEXT, "auto_page_num" TEXT, props_attrs TEXT); +CREATE TABLE folio_property (folio_uuid TEXT NOT NULL REFERENCES folio, ord INTEGER NOT NULL, + name TEXT, value TEXT, show INTEGER, attrs TEXT, PRIMARY KEY (folio_uuid, ord)); +CREATE TABLE element (uuid TEXT PRIMARY KEY, folio_uuid TEXT NOT NULL REFERENCES folio, + "prefix" TEXT, "freezeLabel" INTEGER, info_attrs TEXT, links_attrs TEXT); +CREATE TABLE element_info (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL, + name TEXT, value TEXT, show INTEGER, attrs TEXT, PRIMARY KEY (element_uuid, ord)); +CREATE TABLE link (element_uuid TEXT NOT NULL REFERENCES element, ord INTEGER NOT NULL, + linked_uuid TEXT, attrs TEXT, PRIMARY KEY (element_uuid, ord)); +CREATE TABLE conductor (uuid TEXT PRIMARY KEY, folio_uuid TEXT NOT NULL REFERENCES folio, + "element1" TEXT, "terminal1" TEXT, "element2" TEXT, "terminal2" TEXT, "num" TEXT, + "formula" TEXT, "function" TEXT, "tension_protocol" TEXT, "conductor_color" TEXT, + "conductor_section" TEXT, "cable" TEXT, "bus" TEXT, "freezeLabel" INTEGER); +CREATE TABLE terminal_strip (uuid TEXT PRIMARY KEY, ord INTEGER NOT NULL UNIQUE, + installation TEXT, location TEXT, name TEXT, comment TEXT, description TEXT); +CREATE TABLE strip_terminal (strip_uuid TEXT NOT NULL REFERENCES terminal_strip, + pos INTEGER NOT NULL, level INTEGER NOT NULL, element_uuid TEXT, + PRIMARY KEY (strip_uuid, pos, level)); +CREATE TABLE strip_bridge (uuid TEXT PRIMARY KEY, strip_uuid TEXT NOT NULL REFERENCES terminal_strip, + ord INTEGER NOT NULL, color TEXT); +CREATE TABLE strip_bridge_terminal (bridge_uuid TEXT NOT NULL REFERENCES strip_bridge, + ord INTEGER NOT NULL, element_uuid TEXT, PRIMARY KEY (bridge_uuid, ord)); +``` + +### 4.2 What each table holds + +| Table | One row per | Comes from | +|---|---|---| +| `meta` | setting of the file | `format`; `props_attrs` (attributes of the project's ``); `terminal_strips` = `1` when the strips are in the database | +| `project_property` | project property (project-wide variable) | `` | +| `folio` | folio, in folio order (`pos` from 1) | the title-block attributes of `` | +| `folio_property` | custom title-block variable of a folio | `` | +| `element` | symbol placed on a folio | ``: its label prefix and frozen-label flag | +| `element_info` | piece of symbol information, in order | `` | +| `link` | cross-reference link of a symbol, in order | `` | +| `conductor` | wire | ``: its two ends (symbol uuid and terminal), number, formula, function, voltage/protocol, colour, section, cable, bus, frozen-label flag | +| `terminal_strip`, `strip_terminal`, `strip_bridge`, `strip_bridge_terminal` | terminal strip, terminal, bridge, bridged terminal | `` | + +A value is NULL when the XML has no such attribute. + +- `freezeLabel` is 1 for `true`, 0 for `false`. Any other text stays as an + attribute in the folio file. +- `show` is 0 or 1. Any other text goes in `attrs`. +- `attrs`, `props_attrs`, `info_attrs` and `links_attrs` are JSON objects of + the attributes that have no column, and the entry's tag when it is not the + usual one. In a project QElectroTech saves they are all NULL. They hold + something only for older or hand-edited files. Whitespace between + entries, which only a hand-edited file has, is kept in them as + `qetz-ws`. + +### 4.3 Keys + +Every row is keyed by its item's **uuid**, which stays in the folio file: +that is how a folio file's item finds its row. Folios, symbols and wires +are keyed this way. + +An item with no uuid of its own (none, or one another item of the same kind +shares) gets a key made from the item itself: a version 5 uuid in the +namespace `{3f0c6c1e-5b8e-4b8a-9d55-6b2f5f0f9a41}` from its XML text, how +many identical items came before it, and a counter that avoids taken keys. +The key is the same on every save and never depends on where the item is. +It is written on the item as `qetz-key` (a symbol or wire) or `qetz-folio` +(a folio). + +### 4.4 Markers + +A block moved into the database leaves `` where it was: +`properties`, `elementInformations`, `links_uuids` or `terminal_strips`. +Reading puts the block back where its marker is; a block without a marker +was never moved. + +Only a block that can be rebuilt exactly is moved: entries of the expected +tag with plain text values. Terminal strips are moved only if their rows +rebuild the same XML. Anything else stays in the folio file or +`project.xml` unchanged. + +## 5. Reading a file safely + +A `.qetz` is untrusted. A reader: + +- runs nothing from the database's schema (`PRAGMA trusted_schema = OFF`, + read-only) and refuses a database where any table of section 4.1 is not a + plain table (a view, say); +- refuses the file, with a message saying what is wrong, if the database + and the folio files disagree: an item whose row is missing or on another + folio, an item twice, a row no item has, or information, links or + properties of a symbol or folio that does not exist. + +A damaged archive is caught by the zip format's own checksums. diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index 72184045d..1fc642f3c 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -41,6 +41,8 @@ join(split(d)) is d. Nothing here knows what the parts mean: the project is read and written by the same code as a .qet file. + + The format is written down in QETZ-FORMAT.md, beside this file. */ namespace QetContainer { diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 4c3bda623..7f42b2b45 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1254,4 +1254,5 @@ target_include_directories(tst_qetcontainer PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_qetcontainer PRIVATE Qt::Test Qt::Xml SQLite3::SQLite3) target_compile_definitions(tst_qetcontainer PRIVATE "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"" - "QET_FIXTURES_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/fixtures\"") + "QET_FIXTURES_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/fixtures\"" + "QET_FORMAT_SPEC=\"${QET_DIR}/sources/container/QETZ-FORMAT.md\"") diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 82f0e8020..7fdf4af9b 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -351,6 +351,50 @@ private slots: QVERIFY2(query(database, sql) == QLatin1String("0"), sql); } + // The schema in QETZ-FORMAT.md is the one written: same tables, same + // columns in the same order, same types and keys + void specMatchesSchema() + { + const QString spec = QString::fromUtf8(read(QStringLiteral(QET_FORMAT_SPEC))); + const int from = spec.indexOf(QStringLiteral("```sql\n")); + const int to = spec.indexOf(QStringLiteral("```"), from + 7); + QVERIFY2(from >= 0 && to > from, "no schema in QETZ-FORMAT.md"); + const QByteArray sql = spec.mid(from + 7, to - from - 7).toUtf8(); + + QDomDocument original; + const QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + const QByteArray written = entry(entries, QStringLiteral("project.sqlite")); + + auto describe = [](sqlite3 *db) { + QStringList out; + sqlite3_stmt *st = nullptr; + sqlite3_prepare_v2(db, "SELECT m.name, p.name, p.type, p.pk, p.\"notnull\" FROM sqlite_master m, " + "pragma_table_info(m.name) p WHERE m.type = 'table' ORDER BY m.name, p.cid", + -1, &st, nullptr); + while (st && sqlite3_step(st) == SQLITE_ROW) { + QStringList row; + for (int i = 0 ; i < 5 ; ++i) + row << QString::fromUtf8(reinterpret_cast(sqlite3_column_text(st, i))); + out << row.join(QLatin1Char(' ')); + } + sqlite3_finalize(st); + return out; + }; + sqlite3 *from_spec = nullptr; + sqlite3_open(":memory:", &from_spec); + QCOMPARE(sqlite3_exec(from_spec, sql.constData(), nullptr, nullptr, nullptr), SQLITE_OK); + sqlite3 *from_file = nullptr; + sqlite3_open(":memory:", &from_file); + unsigned char *copy = static_cast(sqlite3_malloc64(sqlite3_uint64(written.size()))); + memcpy(copy, written.constData(), size_t(written.size())); + sqlite3_deserialize(from_file, "main", copy, written.size(), written.size(), SQLITE_DESERIALIZE_FREEONCLOSE); + const QStringList spec_tables = describe(from_spec), file_tables = describe(from_file); + sqlite3_close(from_spec); + sqlite3_close(from_file); + QVERIFY(!spec_tables.isEmpty()); + QCOMPARE(spec_tables, file_tables); + } + void laterFormatRefused() { QDomDocument original;