collection_service.cpp 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. #include "smartbotic/webserver/collection_service.hpp"
  2. #include <chrono>
  3. #include <functional>
  4. #include <iomanip>
  5. #include <regex>
  6. #include <sstream>
  7. #include <nlohmann/json.hpp>
  8. #include <spdlog/spdlog.h>
  9. namespace smartbotic::webserver {
  10. namespace {
  11. /// System collection for storing collection settings/metadata
  12. constexpr const char* kSettingsCollection = "_collection_settings";
  13. /// Convert timestamp to ISO 8601 string
  14. auto TimestampToString(const ::smartbotic::database::Timestamp& ts) -> std::string {
  15. if (ts.seconds() == 0 && ts.nanos() == 0) {
  16. return "";
  17. }
  18. std::time_t time = ts.seconds();
  19. std::tm tm{};
  20. gmtime_r(&time, &tm);
  21. std::ostringstream oss;
  22. oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
  23. if (ts.nanos() > 0) {
  24. oss << "." << std::setfill('0') << std::setw(3) << (ts.nanos() / 1000000);
  25. }
  26. oss << "Z";
  27. return oss.str();
  28. }
  29. } // namespace
  30. CollectionService::CollectionService(DatabaseClient& db_client) : db_client_(db_client) {
  31. // Ensure settings collection exists
  32. grpc::ClientContext ctx;
  33. ::smartbotic::database::CreateCollectionRequest req;
  34. ::smartbotic::database::CollectionMetadata resp;
  35. req.set_name(kSettingsCollection);
  36. auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp);
  37. if (status.ok()) {
  38. spdlog::info("Created system collection {}", kSettingsCollection);
  39. } else if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
  40. spdlog::debug("System collection {} already exists", kSettingsCollection);
  41. } else {
  42. spdlog::warn("Failed to ensure {} collection: {}", kSettingsCollection, status.error_message());
  43. }
  44. }
  45. CollectionService::~CollectionService() = default;
  46. CollectionService::CollectionService(CollectionService&&) noexcept = default;
  47. auto CollectionService::operator=(CollectionService&& /*other*/) noexcept -> CollectionService& {
  48. // Cannot reassign reference member, so this is essentially a no-op
  49. return *this;
  50. }
  51. auto CollectionService::CreateCollection(const CreateCollectionRequest& request) -> CollectionResult {
  52. // Validate collection name
  53. if (!IsValidCollectionName(request.name)) {
  54. return {.success = false, .error = "Invalid collection name. Must be alphanumeric with underscores, 1-64 chars.", .collection = std::nullopt};
  55. }
  56. // System collection names are reserved
  57. if (IsSystemCollection(request.name)) {
  58. return {.success = false, .error = "Collection name is reserved for system use", .collection = std::nullopt};
  59. }
  60. std::string internal_name = GetInternalName(request.workspace_id, request.name);
  61. // Create the collection via gRPC
  62. grpc::ClientContext ctx;
  63. ::smartbotic::database::CreateCollectionRequest req;
  64. ::smartbotic::database::CollectionMetadata resp;
  65. req.set_name(internal_name);
  66. auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp);
  67. if (!status.ok()) {
  68. if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
  69. return {.success = false, .error = "Collection already exists", .collection = std::nullopt};
  70. }
  71. spdlog::error("Failed to create collection {}: {}", internal_name, status.error_message());
  72. return {.success = false, .error = "Failed to create collection: " + status.error_message(), .collection = std::nullopt};
  73. }
  74. // Store settings in metadata collection
  75. if (!StoreSettings(request.workspace_id, request.name, request.settings)) {
  76. spdlog::warn("Collection {} created but failed to store settings", internal_name);
  77. }
  78. auto info = MetadataToInfo(request.workspace_id, resp);
  79. info.settings = request.settings;
  80. spdlog::info("Created collection {} in workspace {}", request.name, request.workspace_id);
  81. return {.success = true, .error = "", .collection = info};
  82. }
  83. auto CollectionService::GetCollection(const std::string& workspace_id, const std::string& name) -> CollectionResult {
  84. std::string internal_name = GetInternalName(workspace_id, name);
  85. grpc::ClientContext ctx;
  86. ::smartbotic::database::GetCollectionMetadataRequest req;
  87. ::smartbotic::database::CollectionMetadata resp;
  88. req.set_name(internal_name);
  89. auto status = db_client_.GetCollectionService()->GetCollectionMetadata(&ctx, req, &resp);
  90. if (!status.ok()) {
  91. if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
  92. return {.success = false, .error = "Collection not found", .collection = std::nullopt};
  93. }
  94. spdlog::error("Failed to get collection {}: {}", internal_name, status.error_message());
  95. return {.success = false, .error = "Failed to get collection: " + status.error_message(), .collection = std::nullopt};
  96. }
  97. auto info = MetadataToInfo(workspace_id, resp);
  98. info.settings = LoadSettings(workspace_id, name);
  99. return {.success = true, .error = "", .collection = info};
  100. }
  101. auto CollectionService::ListCollections(const std::string& workspace_id) -> CollectionListResult {
  102. grpc::ClientContext ctx;
  103. ::smartbotic::database::ListCollectionsRequest req;
  104. ::smartbotic::database::ListCollectionsResponse resp;
  105. req.set_page_size(1000); // Get all collections
  106. auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp);
  107. if (!status.ok()) {
  108. spdlog::error("Failed to list collections: {}", status.error_message());
  109. return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0};
  110. }
  111. std::string prefix = GetInternalName(workspace_id, "");
  112. std::vector<CollectionInfo> collections;
  113. for (const auto& meta : resp.collections()) {
  114. // Only include collections that belong to this workspace
  115. if (meta.name().rfind(prefix, 0) == 0) {
  116. auto info = MetadataToInfo(workspace_id, meta);
  117. info.settings = LoadSettings(workspace_id, info.name);
  118. collections.push_back(std::move(info));
  119. }
  120. }
  121. int64_t count = static_cast<int64_t>(collections.size());
  122. return {
  123. .success = true,
  124. .error = "",
  125. .collections = std::move(collections),
  126. .total_count = count
  127. };
  128. }
  129. auto CollectionService::ListSystemCollections() -> CollectionListResult {
  130. grpc::ClientContext ctx;
  131. ::smartbotic::database::ListCollectionsRequest req;
  132. ::smartbotic::database::ListCollectionsResponse resp;
  133. req.set_page_size(1000);
  134. auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp);
  135. if (!status.ok()) {
  136. spdlog::error("Failed to list system collections: {}", status.error_message());
  137. return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0};
  138. }
  139. std::vector<CollectionInfo> collections;
  140. for (const auto& meta : resp.collections()) {
  141. // System collections start with underscore and are not workspace-prefixed
  142. if (!meta.name().empty() && meta.name()[0] == '_') {
  143. CollectionInfo info;
  144. info.name = meta.name();
  145. info.workspace_id = ""; // Global collection
  146. info.document_count = meta.document_count();
  147. info.size_bytes = meta.size_bytes();
  148. info.created_at = TimestampToString(meta.created_at());
  149. info.updated_at = TimestampToString(meta.updated_at());
  150. collections.push_back(std::move(info));
  151. }
  152. }
  153. int64_t count = static_cast<int64_t>(collections.size());
  154. return {
  155. .success = true,
  156. .error = "",
  157. .collections = std::move(collections),
  158. .total_count = count
  159. };
  160. }
  161. auto CollectionService::UpdateCollection(const std::string& workspace_id, const std::string& name,
  162. const CollectionSettings& settings) -> CollectionResult {
  163. // First verify the collection exists
  164. auto get_result = GetCollection(workspace_id, name);
  165. if (!get_result.success) {
  166. return get_result;
  167. }
  168. // Update settings in metadata collection
  169. if (!StoreSettings(workspace_id, name, settings)) {
  170. return {.success = false, .error = "Failed to update collection settings", .collection = std::nullopt};
  171. }
  172. // Return updated info
  173. auto info = *get_result.collection;
  174. info.settings = settings;
  175. spdlog::info("Updated collection {} in workspace {}", name, workspace_id);
  176. return {.success = true, .error = "", .collection = info};
  177. }
  178. auto CollectionService::DropCollection(const std::string& workspace_id, const std::string& name,
  179. bool force) -> CollectionResult {
  180. std::string internal_name = GetInternalName(workspace_id, name);
  181. grpc::ClientContext ctx;
  182. ::smartbotic::database::DropCollectionRequest req;
  183. ::smartbotic::database::DropCollectionResponse resp;
  184. req.set_name(internal_name);
  185. req.set_force(force);
  186. auto status = db_client_.GetCollectionService()->DropCollection(&ctx, req, &resp);
  187. if (!status.ok()) {
  188. if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
  189. return {.success = false, .error = "Collection not found", .collection = std::nullopt};
  190. }
  191. if (status.error_code() == grpc::StatusCode::FAILED_PRECONDITION) {
  192. return {.success = false, .error = "Collection is not empty. Use force=true to drop anyway.", .collection = std::nullopt};
  193. }
  194. spdlog::error("Failed to drop collection {}: {}", internal_name, status.error_message());
  195. return {.success = false, .error = "Failed to drop collection: " + status.error_message(), .collection = std::nullopt};
  196. }
  197. // Delete settings
  198. DeleteSettings(workspace_id, name);
  199. spdlog::info("Dropped collection {} from workspace {}", name, workspace_id);
  200. return {.success = true, .error = "", .collection = std::nullopt};
  201. }
  202. auto CollectionService::IsValidCollectionName(const std::string& name) -> bool {
  203. if (name.empty() || name.length() > 64) {
  204. return false;
  205. }
  206. // Must start with letter or underscore, contain only alphanumeric and underscores
  207. static const std::regex pattern("^[a-zA-Z_][a-zA-Z0-9_]*$");
  208. return std::regex_match(name, pattern);
  209. }
  210. auto CollectionService::IsSystemCollection(const std::string& name) -> bool {
  211. return !name.empty() && name[0] == '_';
  212. }
  213. auto CollectionService::GetInternalName(const std::string& workspace_id, const std::string& name) -> std::string {
  214. return std::string(kCollectionPrefix) + workspace_id + "_" + name;
  215. }
  216. auto CollectionService::GetDisplayName(const std::string& workspace_id, const std::string& internal_name) -> std::string {
  217. std::string prefix = GetInternalName(workspace_id, "");
  218. if (internal_name.rfind(prefix, 0) == 0) {
  219. return internal_name.substr(prefix.length());
  220. }
  221. return internal_name;
  222. }
  223. auto CollectionService::MetadataToInfo(const std::string& workspace_id,
  224. const ::smartbotic::database::CollectionMetadata& meta) -> CollectionInfo {
  225. CollectionInfo info;
  226. info.name = GetDisplayName(workspace_id, meta.name());
  227. info.workspace_id = workspace_id;
  228. info.document_count = meta.document_count();
  229. info.size_bytes = meta.size_bytes();
  230. info.created_at = TimestampToString(meta.created_at());
  231. info.updated_at = TimestampToString(meta.updated_at());
  232. return info;
  233. }
  234. auto CollectionService::GetCurrentTimestamp() -> std::string {
  235. auto now = std::chrono::system_clock::now();
  236. auto time_t = std::chrono::system_clock::to_time_t(now);
  237. auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
  238. std::tm tm{};
  239. gmtime_r(&time_t, &tm);
  240. std::ostringstream oss;
  241. oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
  242. oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
  243. return oss.str();
  244. }
  245. auto CollectionService::StoreSettings(const std::string& workspace_id, const std::string& name,
  246. const CollectionSettings& settings) -> bool {
  247. std::string doc_id = workspace_id + "_" + name;
  248. nlohmann::json json_settings;
  249. json_settings["workspace_id"] = workspace_id;
  250. json_settings["collection_name"] = name;
  251. json_settings["schema"] = settings.schema;
  252. json_settings["is_system"] = settings.is_system;
  253. json_settings["encrypted_fields"] = settings.encrypted_fields;
  254. json_settings["ttl_seconds"] = settings.ttl_seconds;
  255. json_settings["updated_at"] = GetCurrentTimestamp();
  256. // Serialize field_permissions
  257. nlohmann::json field_perms_json = nlohmann::json::array();
  258. for (const auto& fp : settings.field_permissions) {
  259. nlohmann::json fp_json;
  260. fp_json["field_name"] = fp.field_name;
  261. fp_json["read_groups"] = fp.read_groups;
  262. fp_json["write_groups"] = fp.write_groups;
  263. field_perms_json.push_back(fp_json);
  264. }
  265. json_settings["field_permissions"] = field_perms_json;
  266. // Build the proto document using a recursive lambda to handle nested structures
  267. std::function<void(const nlohmann::json&, ::smartbotic::database::Value*)> json_to_proto;
  268. json_to_proto = [&json_to_proto](const nlohmann::json& j, ::smartbotic::database::Value* val) {
  269. if (j.is_string()) {
  270. val->set_string_value(j.get<std::string>());
  271. } else if (j.is_boolean()) {
  272. val->set_bool_value(j.get<bool>());
  273. } else if (j.is_number_integer()) {
  274. val->set_int_value(j.get<int64_t>());
  275. } else if (j.is_number_float()) {
  276. val->set_double_value(j.get<double>());
  277. } else if (j.is_array()) {
  278. auto* arr = val->mutable_array_value();
  279. for (const auto& item : j) {
  280. json_to_proto(item, arr->add_values());
  281. }
  282. } else if (j.is_object()) {
  283. auto* map = val->mutable_map_value();
  284. for (const auto& [k, v] : j.items()) {
  285. json_to_proto(v, &(*map->mutable_fields())[k]);
  286. }
  287. } else if (j.is_null()) {
  288. val->set_null_value(true);
  289. }
  290. };
  291. ::smartbotic::database::MapValue data;
  292. for (const auto& [key, value] : json_settings.items()) {
  293. json_to_proto(value, &(*data.mutable_fields())[key]);
  294. }
  295. // Try update first, then create
  296. {
  297. grpc::ClientContext ctx;
  298. ::smartbotic::database::UpdateDocumentRequest req;
  299. ::smartbotic::database::Document resp;
  300. req.set_collection(kSettingsCollection);
  301. req.set_id(doc_id);
  302. req.set_merge(true);
  303. *req.mutable_data() = data;
  304. auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
  305. if (status.ok()) {
  306. return true;
  307. }
  308. }
  309. // Create if update failed
  310. {
  311. grpc::ClientContext ctx;
  312. ::smartbotic::database::CreateDocumentRequest req;
  313. ::smartbotic::database::Document resp;
  314. req.set_collection(kSettingsCollection);
  315. req.set_id(doc_id);
  316. *req.mutable_data() = data;
  317. auto status = db_client_.GetDocumentService()->CreateDocument(&ctx, req, &resp);
  318. if (!status.ok()) {
  319. spdlog::error("Failed to store collection settings: {}", status.error_message());
  320. return false;
  321. }
  322. }
  323. return true;
  324. }
  325. auto CollectionService::LoadSettings(const std::string& workspace_id, const std::string& name) -> CollectionSettings {
  326. std::string doc_id = workspace_id + "_" + name;
  327. grpc::ClientContext ctx;
  328. ::smartbotic::database::GetDocumentRequest req;
  329. ::smartbotic::database::Document resp;
  330. req.set_collection(kSettingsCollection);
  331. req.set_id(doc_id);
  332. auto status = db_client_.GetDocumentService()->GetDocument(&ctx, req, &resp);
  333. if (!status.ok()) {
  334. return {}; // Return defaults if not found
  335. }
  336. CollectionSettings settings;
  337. const auto& fields = resp.data().fields();
  338. if (auto it = fields.find("schema"); it != fields.end() && it->second.has_string_value()) {
  339. settings.schema = it->second.string_value();
  340. }
  341. if (auto it = fields.find("is_system"); it != fields.end() && it->second.has_bool_value()) {
  342. settings.is_system = it->second.bool_value();
  343. }
  344. if (auto it = fields.find("ttl_seconds"); it != fields.end() && it->second.has_int_value()) {
  345. settings.ttl_seconds = it->second.int_value();
  346. }
  347. if (auto it = fields.find("encrypted_fields"); it != fields.end() && it->second.has_array_value()) {
  348. for (const auto& val : it->second.array_value().values()) {
  349. if (val.has_string_value()) {
  350. settings.encrypted_fields.push_back(val.string_value());
  351. }
  352. }
  353. }
  354. // Load field_permissions
  355. if (auto it = fields.find("field_permissions"); it != fields.end() && it->second.has_array_value()) {
  356. for (const auto& fp_val : it->second.array_value().values()) {
  357. if (fp_val.has_map_value()) {
  358. FieldPermission fp;
  359. const auto& fp_fields = fp_val.map_value().fields();
  360. if (auto fn_it = fp_fields.find("field_name"); fn_it != fp_fields.end() && fn_it->second.has_string_value()) {
  361. fp.field_name = fn_it->second.string_value();
  362. }
  363. if (auto rg_it = fp_fields.find("read_groups"); rg_it != fp_fields.end() && rg_it->second.has_array_value()) {
  364. for (const auto& rg : rg_it->second.array_value().values()) {
  365. if (rg.has_string_value()) {
  366. fp.read_groups.push_back(rg.string_value());
  367. }
  368. }
  369. }
  370. if (auto wg_it = fp_fields.find("write_groups"); wg_it != fp_fields.end() && wg_it->second.has_array_value()) {
  371. for (const auto& wg : wg_it->second.array_value().values()) {
  372. if (wg.has_string_value()) {
  373. fp.write_groups.push_back(wg.string_value());
  374. }
  375. }
  376. }
  377. settings.field_permissions.push_back(std::move(fp));
  378. }
  379. }
  380. }
  381. return settings;
  382. }
  383. auto CollectionService::DeleteSettings(const std::string& workspace_id, const std::string& name) -> bool {
  384. std::string doc_id = workspace_id + "_" + name;
  385. grpc::ClientContext ctx;
  386. ::smartbotic::database::DeleteDocumentRequest req;
  387. ::smartbotic::database::DeleteDocumentResponse resp;
  388. req.set_collection(kSettingsCollection);
  389. req.set_id(doc_id);
  390. auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
  391. return status.ok();
  392. }
  393. } // namespace smartbotic::webserver