391
src/storage/path_guide_store.cpp
Normal file
391
src/storage/path_guide_store.cpp
Normal 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
|
||||
Reference in New Issue
Block a user