finish setup
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 16:46:31 +02:00
parent 2ce8a23ce9
commit 773341fc84
16 changed files with 1780 additions and 4 deletions

View File

@@ -0,0 +1,391 @@
#include "storage/path_guide_store.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <sqlite3.h>
#include <algorithm>
namespace lm {
namespace {
nlohmann::json parsePositionsJson(const std::string& raw)
{
if (raw.empty())
return nlohmann::json::array();
try
{
const auto parsed = nlohmann::json::parse(raw);
return parsed.is_array() ? parsed : nlohmann::json::array();
}
catch (...)
{
return nlohmann::json::array();
}
}
nlohmann::json rowToJson(sqlite3_stmt* stmt)
{
auto text = [&](int col) -> std::string {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
return "";
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
return v ? std::string(v) : "";
};
const nlohmann::json positions = parsePositionsJson(text(4));
int starts_count = 0;
int vias_count = 0;
int goals_count = 0;
for (const auto& pos : positions)
{
if (!pos.is_object())
continue;
const std::string role = StringUtil::toLower(pos.value("role", ""));
if (role == "start")
++starts_count;
else if (role == "via")
++vias_count;
else if (role == "goal")
++goals_count;
}
return {{"id", text(0)},
{"site_id", text(1)},
{"map_id", text(2)},
{"name", text(3)},
{"positions", positions},
{"starts_count", starts_count},
{"vias_count", vias_count},
{"goals_count", goals_count},
{"created_by", text(5)},
{"created_by_group", text(6)},
{"created_at", text(7)},
{"updated_at", text(8)}};
}
constexpr const char* kSelect =
"SELECT id, site_id, map_id, name, positions_json, created_by, created_by_group, created_at, updated_at "
"FROM path_guides";
bool validRole(const std::string& role)
{
return role == "start" || role == "via" || role == "goal";
}
std::optional<nlohmann::json> normalizePositions(const nlohmann::json& raw, std::string& err)
{
if (!raw.is_array())
{
err = "positions must be an array";
return std::nullopt;
}
nlohmann::json out = nlohmann::json::array();
int starts = 0;
int goals = 0;
int via_priority = 0;
for (const auto& item : raw)
{
if (!item.is_object())
continue;
const std::string position_id = StringUtil::trimCopy(item.value("position_id", ""));
const std::string role = StringUtil::toLower(StringUtil::trimCopy(item.value("role", "")));
if (position_id.empty() || !validRole(role))
{
err = "invalid position entry";
return std::nullopt;
}
nlohmann::json entry = {{"position_id", position_id}, {"role", role}};
if (role == "via")
{
int priority = via_priority + 1;
if (item.contains("priority") && item["priority"].is_number_integer())
priority = item["priority"].get<int>();
if (priority <= 0)
priority = via_priority + 1;
via_priority = priority;
entry["priority"] = priority;
}
else if (role == "start")
{
++starts;
}
else if (role == "goal")
{
++goals;
}
out.push_back(std::move(entry));
}
if (starts < 1 || goals < 1)
{
err = "path guide requires at least one start and one goal";
return std::nullopt;
}
std::sort(out.begin(), out.end(), [](const nlohmann::json& a, const nlohmann::json& b) {
const std::string ra = a.value("role", "");
const std::string rb = b.value("role", "");
if (ra != rb)
{
if (ra == "start")
return true;
if (rb == "start")
return false;
if (ra == "via")
return true;
return false;
}
if (ra == "via")
return a.value("priority", 0) < b.value("priority", 0);
return a.value("position_id", "") < b.value("position_id", "");
});
return out;
}
} // namespace
PathGuideStore::PathGuideStore(Database& db) : db_(db) {}
bool PathGuideStore::findNameConflictUnlocked(const std::string& site_id,
const std::string& name,
const std::string& except_id) const
{
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name));
if (needle.empty())
return false;
sqlite3_stmt* stmt = nullptr;
const char* sql = except_id.empty()
? "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) LIMIT 1"
: "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) AND id != ?3 LIMIT 1";
if (sqlite3_prepare_v2(db_.handle(), sql, -1, &stmt, nullptr) != SQLITE_OK)
return false;
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
if (!except_id.empty())
sqlite3_bind_text(stmt, 3, except_id.c_str(), -1, SQLITE_TRANSIENT);
const bool conflict = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
return conflict;
}
nlohmann::json PathGuideStore::list(const std::string& site_id) const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array();
std::string sql = kSelect;
if (!site_id.empty())
sql += " WHERE site_id = ?1";
sql += " ORDER BY site_id, map_id, name";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return out;
if (!site_id.empty())
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(stmt) == SQLITE_ROW)
out.push_back(rowToJson(stmt));
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> PathGuideStore::find(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
const std::string sql = std::string(kSelect) + " WHERE id = ?1";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> PathGuideStore::create(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
const std::string site_id = StringUtil::trimCopy(payload.value("site_id", ""));
const std::string map_id = StringUtil::trimCopy(payload.value("map_id", ""));
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
const std::string created_by = StringUtil::trimCopy(payload.value("created_by", ""));
const std::string created_by_group = StringUtil::trimCopy(payload.value("created_by_group", ""));
const auto positions = normalizePositions(payload.value("positions", nlohmann::json::array()), err);
if (!positions)
return std::nullopt;
if (site_id.empty() || map_id.empty() || name.empty())
{
err = "missing required fields";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
if (findNameConflictUnlocked(site_id, name, ""))
{
err = "path guide name already exists for this site";
return std::nullopt;
}
const std::string id = payload.value("id", IdUtil::newId());
const std::string now = IdUtil::nowIso8601();
const std::string positions_json = positions->dump();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO path_guides(id, site_id, map_id, name, positions_json, created_by, "
"created_by_group, created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return std::nullopt;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, created_by.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE)
{
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return std::nullopt;
}
sqlite3_finalize(stmt);
nlohmann::json created = {{"id", id},
{"site_id", site_id},
{"map_id", map_id},
{"name", name},
{"positions", *positions},
{"created_by", created_by},
{"created_by_group", created_by_group},
{"created_at", now},
{"updated_at", now}};
int starts_count = 0;
int vias_count = 0;
int goals_count = 0;
for (const auto& pos : *positions)
{
const std::string role = pos.value("role", "");
if (role == "start")
++starts_count;
else if (role == "via")
++vias_count;
else if (role == "goal")
++goals_count;
}
created["starts_count"] = starts_count;
created["vias_count"] = vias_count;
created["goals_count"] = goals_count;
return created;
}
bool PathGuideStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
{
auto existing = find(id);
if (!existing)
{
err = "path guide not found";
return false;
}
nlohmann::json merged = *existing;
for (const char* key : {"site_id", "map_id", "name"})
{
if (payload.contains(key))
merged[key] = payload[key];
}
if (payload.contains("positions"))
merged["positions"] = payload["positions"];
const std::string site_id = StringUtil::trimCopy(merged.value("site_id", ""));
const std::string map_id = StringUtil::trimCopy(merged.value("map_id", ""));
const std::string name = StringUtil::trimCopy(merged.value("name", ""));
const auto positions = normalizePositions(merged.value("positions", nlohmann::json::array()), err);
if (!positions)
return false;
if (site_id.empty() || map_id.empty() || name.empty())
{
err = "missing required fields";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
if (findNameConflictUnlocked(site_id, name, id))
{
err = "path guide name already exists for this site";
return false;
}
const std::string now = IdUtil::nowIso8601();
const std::string positions_json = positions->dump();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"UPDATE path_guides SET site_id=?2, map_id=?3, name=?4, positions_json=?5, updated_at=?6 WHERE id=?1",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
bool PathGuideStore::remove(const std::string& id, std::string& err)
{
if (!find(id))
{
err = "path guide not found";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM path_guides WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok)
err = sqlite3_errmsg(db_.handle());
sqlite3_finalize(stmt);
return ok;
}
} // namespace lm