diff --git a/src/db/Database.cpp b/src/db/Database.cpp index c261bb5..536eae0 100644 --- a/src/db/Database.cpp +++ b/src/db/Database.cpp @@ -244,6 +244,20 @@ int Database::tagId(const QString &tag, bool create) return q.lastInsertId().toInt(); } +void Database::clearFileData(int fid) +{ + QSqlQuery q(m_db); + q.prepare(QStringLiteral("DELETE FROM links WHERE src_id = ?")); + q.addBindValue(fid); + q.exec(); + q.prepare(QStringLiteral("DELETE FROM file_tags WHERE file_id = ?")); + q.addBindValue(fid); + q.exec(); + q.prepare(QStringLiteral("DELETE FROM properties WHERE file_id = ?")); + q.addBindValue(fid); + q.exec(); +} + void Database::deleteFileRows(int fid) { QSqlQuery q(m_db); @@ -374,10 +388,8 @@ void Database::syncFile(const QString &path) } const int fid = fileId(clean, false); if (fid >= 0) - deleteFileRows(fid); - else { - // строки нет (например, после ручной чистки) — создастся в indexOneFile - } + clearFileData(fid); + // строки нет (например, после ручной чистки) — создастся в indexOneFile indexOneFile(clean); deleteOrphanTags(); emit changed(); diff --git a/src/db/Database.h b/src/db/Database.h index ebdc0f7..8cc60c6 100644 --- a/src/db/Database.h +++ b/src/db/Database.h @@ -70,6 +70,9 @@ private: int fileId(const QString &path, bool create); int tagId(const QString &tag, bool create); void deleteFileRows(int fileId); + // Снести только собственные данные файла (исходящие ссылки, теги, свойства). + // Строка files и входящие ссылки целы — id сохраняется, граф не рвётся. + void clearFileData(int fileId); void deleteOrphanTags(); void indexOneFile(const QString &path); static bool isReadOnlySql(const QString &sql, QString *error); diff --git a/tests/db_test.cpp b/tests/db_test.cpp index 5325af5..63dfa9c 100644 --- a/tests/db_test.cpp +++ b/tests/db_test.cpp @@ -235,6 +235,145 @@ int main(int argc, char *argv[]) return fail("plain body kept", t.left(200)); } + // --- Связность: вызовы из одного файла в другие через базу --- + // Граф: index -> a, index -> b, a -> b, c — изолят. + QTemporaryDir dir2; + if (!dir2.isValid()) + return fail("temp dir2"); + const QString v2 = dir2.path(); + writeFile(v2 + "/index.md", + "---\nтип: хаб\n---\n\n# Хаб\n\n[[a]] [[b]]\n\n" + "```~sql\n" + "SELECT f2.title AS связанные FROM files f1\n" + " JOIN links l ON l.src_id = f1.id\n" + " JOIN files f2 ON f2.id = l.dst_id\n" + "WHERE f1.path LIKE '%index.md' ORDER BY f2.title;\n" + "```\n"); + writeFile(v2 + "/a.md", "# A #общее\n\n[[b]]\n"); + writeFile(v2 + "/b.md", "# B #общее\n"); + writeFile(v2 + "/c.md", "# C\n\nИзолят\n"); + VaultIndex index2; + index2.rebuild(v2); + if (index2.fileCount() != 4) + return fail("link index count", QString::number(index2.fileCount())); + Database db2; + db2.setIndex(&index2); + if (!db2.open(v2)) + return fail("link db open"); + QVariantMap st2 = db2.stats(); + if (st2.value("files").toInt() != 4) + return fail("link files"); + if (st2.value("links").toInt() != 3) // index->a, index->b, a->b + return fail("link edges", QString::number(st2.value("links").toInt())); + if (st2.value("tags").toInt() != 1) + return fail("link tags"); + + auto titles = [&](const QVariantMap &r) { + QStringList out; + for (const QVariant &row : r.value("rows").toList()) + out.append(row.toList().value(0).toString()); + return out; + }; + // Прямые соседи index.md + { + QVariantMap r = db2.queryTable( + "SELECT f2.title FROM files f1 JOIN links l ON l.src_id = f1.id " + "JOIN files f2 ON f2.id = l.dst_id " + "WHERE f1.path LIKE '%index.md' ORDER BY f2.title"); + if (!r.value("error").toString().isEmpty()) + return fail("fwd err", r.value("error").toString()); + if (titles(r) != QStringList({"a", "b"})) + return fail("fwd index", titles(r).join(",")); + } + // Обратные ссылки на b.md (кто ссылается) + { + QVariantMap r = db2.queryTable( + "SELECT f.title FROM files f JOIN links l ON l.src_id = f.id " + "JOIN files d ON d.id = l.dst_id " + "WHERE d.path LIKE '%b.md' ORDER BY f.title"); + if (titles(r) != QStringList({"a", "index"})) + return fail("backlinks b", titles(r).join(",")); + } + // Достижимость из index.md за любое число шагов (рекурсия) — c.md вне + { + QVariantMap r = db2.queryTable( + "WITH RECURSIVE reach(id) AS (" + " SELECT id FROM files WHERE path LIKE '%index.md'" + " UNION" + " SELECT l.dst_id FROM links l JOIN reach r ON l.src_id = r.id)" + " SELECT f.title FROM files f JOIN reach r ON r.id = f.id" + " WHERE f.path NOT LIKE '%index.md' ORDER BY f.title"); + if (!r.value("error").toString().isEmpty()) + return fail("reach err", r.value("error").toString()); + if (titles(r) != QStringList({"a", "b"})) + return fail("reach index", titles(r).join(",")); + } + // Изолят: у c.md нет рёбер ни в одну сторону + { + QVariantMap r = db2.queryTable( + "SELECT COUNT(*) FROM links l JOIN files f ON f.id IN (l.src_id, l.dst_id)" + " WHERE f.path LIKE '%c.md'"); + if (r.value("rows").toList().value(0).toList().value(0).toString() != "0") + return fail("isolate c"); + } + // ~sql внутри index.md видит другие файлы (и не видит изолят) + { + MdParser p; + p.setDatabaseForVault(v2, &db2); + QFile f(v2 + "/index.md"); + f.open(QIODevice::ReadOnly); + const QString h = p.render(QString::fromUtf8(f.readAll()), v2, v2); + if (!h.contains(">a") || !h.contains(">b")) + return fail("xfile table", h.left(300)); + if (h.contains(">c")) + return fail("xfile leak isolate"); + if (h.contains("тип: хаб")) + return fail("xfile fm leak"); + } + // Межфайловые свойства: статусы соседей index.md + { + QVariantMap pa; + pa.insert("статус", "в работе"); + if (!db2.saveFileProperties(v2 + "/a.md", pa).isEmpty()) + return fail("xfile props save"); + index2.updateFile(v2 + "/a.md"); + db2.syncFile(v2 + "/a.md"); + QVariantMap r = db2.queryTable( + "SELECT f2.title, p.value FROM files f1 JOIN links l ON l.src_id = f1.id " + "JOIN files f2 ON f2.id = l.dst_id " + "JOIN properties p ON p.file_id = f2.id " + "WHERE f1.path LIKE '%index.md' AND p.key = 'статус'"); + const QVariantList rows = r.value("rows").toList(); + if (rows.size() != 1 || rows[0].toList().value(0).toString() != "a" + || rows[0].toList().value(1).toString() != "в работе") + return fail("xfile props query", r.value("error").toString()); + } + // Динамика: a.md ссылается на c.md — изолят подключается + { + writeFile(v2 + "/a.md", "# A #общее\n\n[[b]] [[c]]\n"); + index2.updateFile(v2 + "/a.md"); + db2.syncFile(v2 + "/a.md"); + if (db2.stats().value("links").toInt() != 4) + return fail("dyn link+"); + QVariantMap r = db2.queryTable( + "SELECT f.title FROM files f JOIN links l ON l.src_id = f.id " + "JOIN files d ON d.id = l.dst_id " + "WHERE d.path LIKE '%c.md'"); + if (titles(r) != QStringList({"a"})) + return fail("dyn backlinks c", titles(r).join(",")); + } + // Динамика: удаление c.md чистит рёбра + { + QFile::remove(v2 + "/c.md"); + index2.removeFile(v2 + "/c.md"); + db2.syncRemove(v2 + "/c.md"); + QVariantMap s = db2.stats(); + if (s.value("files").toInt() != 3 || s.value("links").toInt() != 3) + return fail("dyn remove", + QString("files=%1 links=%2").arg(s.value("files").toInt()) + .arg(s.value("links").toInt())); + } + std::printf("db_test OK\n"); return 0; }