1 /** 2 Copyright: © 2013 rejectedsoftware e.K. 3 License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. 4 Authors: Sönke Ludwig 5 */ 6 module dubregistry.dbcontroller; 7 8 import dub.semver; 9 import std.array; 10 import std.algorithm; 11 import std.exception; 12 //import std.string; 13 import std.typecons : tuple; 14 import std.uni; 15 import vibe.vibe; 16 17 18 class DbController { 19 private { 20 MongoCollection m_packages; 21 MongoCollection m_downloads; 22 } 23 24 private alias bson = serializeToBson; 25 26 this(string dbname) 27 { 28 auto db = connectMongoDB("127.0.0.1").getDatabase(dbname); 29 m_packages = db["packages"]; 30 m_downloads = db["downloads"]; 31 32 // migrations 33 34 // update package format 35 foreach(p; m_packages.find()){ 36 bool any_change = false; 37 if (p["branches"].type == Bson.Type.object) { 38 Bson[] branches; 39 foreach( b; p["branches"] ) 40 branches ~= b; 41 p["branches"] = branches; 42 any_change = true; 43 } 44 if (p["branches"].type == Bson.Type.array) { 45 auto versions = p["versions"].get!(Bson[]); 46 foreach (b; p["branches"]) versions ~= b; 47 p["branches"] = Bson(null); 48 p["versions"] = Bson(versions); 49 any_change = true; 50 } 51 if (any_change) m_packages.update(["_id": p["_id"]], p); 52 } 53 54 // add updateCounter field for packages that don't have it yet 55 m_packages.update(["updateCounter": ["$exists": false]], ["$set" : ["updateCounter" : 0L]], UpdateFlags.multiUpdate); 56 57 // add default non-@optional stats to packages 58 DbPackageStats stats; 59 m_packages.update(["stats": ["$exists": false]], ["$set": ["stats": stats]], UpdateFlags.multiUpdate); 60 61 // create indices 62 m_packages.ensureIndex([tuple("name", 1)], IndexFlags.Unique); 63 m_downloads.ensureIndex([tuple("package", 1), tuple("version", 1)]); 64 65 Bson[string] doc; 66 doc["v"] = 1; 67 doc["key"] = ["_fts": Bson("text"), "_ftsx": Bson(1)]; 68 doc["ns"] = db.name ~ "." ~ m_packages.name; 69 doc["name"] = "packages_full_text_search_index"; 70 doc["weights"] = [ 71 "name": Bson(4), 72 "versions.info.description" : Bson(3), 73 // TODO: try to index readme 74 "versions.info.homepage" : Bson(1), 75 "versions.info.author" : Bson(1), 76 ]; 77 doc["background"] = true; 78 db["system.indexes"].insert(doc); 79 80 // sort package versions newest to oldest 81 // TODO: likely can be removed as we're now sorting on insert 82 repairVersionOrder(); 83 } 84 85 void addPackage(ref DbPackage pack) 86 { 87 enforce(m_packages.findOne(["name": pack.name], ["_id": true]).isNull(), "A package with the same name is already registered."); 88 if (pack._id == BsonObjectID.init) 89 pack._id = BsonObjectID.generate(); 90 m_packages.insert(pack); 91 } 92 93 void addOrSetPackage(ref DbPackage pack) 94 { 95 enforce(pack._id != BsonObjectID.init, "Cannot update a packag with no ID."); 96 m_packages.update(["_id": pack._id], pack, UpdateFlags.upsert); 97 } 98 99 DbPackage getPackage(string packname) 100 { 101 auto bpack = m_packages.findOne(["name": packname]); 102 enforce!RecordNotFound(!bpack.isNull(), "Unknown package name."); 103 return deserializeBson!DbPackage(bpack); 104 } 105 106 BsonObjectID getPackageID(string packname) 107 { 108 auto bpack = m_packages.findOne(["name": packname], ["_id": 1]); 109 enforce(!bpack.isNull(), "Unknown package name."); 110 return bpack["_id"].get!BsonObjectID; 111 } 112 113 DbPackage getPackage(BsonObjectID id) 114 { 115 auto bpack = m_packages.findOne(["_id": id]); 116 enforce!RecordNotFound(!bpack.isNull(), "Unknown package ID."); 117 return deserializeBson!DbPackage(bpack); 118 } 119 120 auto getAllPackages() 121 { 122 return m_packages.find(Bson.emptyObject, ["name": 1]).map!(p => p["name"].get!string)(); 123 } 124 125 auto getAllPackageIDs() 126 { 127 return m_packages.find(Bson.emptyObject, ["_id": 1]).map!(p => p["_id"].get!BsonObjectID)(); 128 } 129 130 auto getPackageDump() 131 { 132 return m_packages.find(Bson.emptyObject).map!(p => p.deserializeBson!DbPackage); 133 } 134 135 auto getUserPackages(BsonObjectID user_id) 136 { 137 return m_packages.find(["owner": user_id], ["name": 1]).map!(p => p["name"].get!string)(); 138 } 139 140 bool isUserPackage(BsonObjectID user_id, string package_name) 141 { 142 return !m_packages.findOne(["owner": Bson(user_id), "name": Bson(package_name)]).isNull(); 143 } 144 145 void removePackage(string packname, BsonObjectID user) 146 { 147 m_packages.remove(["name": Bson(packname), "owner": Bson(user)]); 148 } 149 150 void setPackageErrors(string packname, string[] error...) 151 { 152 m_packages.update(["name": packname], ["$set": ["errors": error]]); 153 } 154 155 void setPackageCategories(string packname, string[] categories...) 156 { 157 m_packages.update(["name": packname], ["$set": ["categories": categories]]); 158 } 159 160 void setPackageRepository(string packname, DbRepository repo) 161 { 162 m_packages.update(["name": packname], ["$set": ["repository": repo]]); 163 } 164 165 void addVersion(string packname, DbPackageVersion ver) 166 { 167 assert(ver.version_.startsWith("~") || ver.version_.isValidVersion()); 168 169 size_t nretrys = 0; 170 171 while (true) { 172 auto pack = m_packages.findOne(["name": packname], ["versions": true, "updateCounter": true]); 173 auto counter = pack["updateCounter"].get!long; 174 auto versions = deserializeBson!(DbPackageVersion[])(pack["versions"]); 175 auto new_versions = versions ~ ver; 176 new_versions.sort!((a, b) => vcmp(a, b)); 177 178 // remove versions with invalid dependency names to avoid the findAndModify below to fail 179 new_versions = new_versions.filter!( 180 v => !v.info["dependencies"].opt!(Json[string]).byKey.canFind!(k => k.canFind(".")) 181 ).array; 182 183 //assert((cast(Json)bversions).toString() == (cast(Json)serializeToBson(versions)).toString()); 184 185 auto res = m_packages.findAndModify( 186 ["name": Bson(packname), "updateCounter": Bson(counter)], 187 ["$set": ["versions": serializeToBson(new_versions), "updateCounter": Bson(counter+1)]], 188 ["_id": true]); 189 190 if (!res.isNull) return; 191 192 enforce(nretrys++ < 20, format("Failed to store updated version list for %s", packname)); 193 logDebug("Failed to update version list atomically, retrying..."); 194 } 195 } 196 197 void removeVersion(string packname, string ver) 198 { 199 assert(ver.startsWith("~") || ver.isValidVersion()); 200 m_packages.update(["name": packname], ["$pull": ["versions": ["version": ver]]]); 201 } 202 203 void updateVersion(string packname, DbPackageVersion ver) 204 { 205 assert(ver.version_.startsWith("~") || ver.version_.isValidVersion()); 206 m_packages.update(["name": packname, "versions.version": ver.version_], ["$set": ["versions.$": ver]]); 207 } 208 209 bool hasVersion(string packname, string ver) 210 { 211 auto ret = m_packages.findOne(["name": packname, "versions.version" : ver], ["_id": true]); 212 return !ret.isNull(); 213 } 214 215 string getLatestVersion(string packname) 216 { 217 auto slice = serializeToBson(["$slice": -1]); 218 auto pack = m_packages.findOne(["name": packname], ["_id": Bson(true), "versions": slice]); 219 if (pack.isNull() || pack["versions"].isNull() || pack["versions"].length != 1) return null; 220 return deserializeBson!(string)(pack["versions"][0]["version"]); 221 } 222 223 DbPackageVersion getVersionInfo(string packname, string ver) 224 { 225 auto pack = m_packages.findOne(["name": packname, "versions.version": ver], ["versions.$": true]); 226 enforce(!pack.isNull(), "unknown package/version"); 227 assert(pack["versions"].length == 1); 228 return deserializeBson!(DbPackageVersion)(pack["versions"][0]); 229 } 230 231 DbPackage[] searchPackages(string query) 232 { 233 if (!query.strip.length) { 234 return m_packages.find() 235 .sort(["name": 1]) 236 .map!(deserializeBson!DbPackage) 237 .array; 238 } 239 240 return m_packages 241 .find(["$text": ["$search": query]], ["score": ["$meta": "textScore"]]) 242 .sort(["score": ["$meta": "textScore"]]) 243 .map!(deserializeBson!DbPackage) 244 .array; 245 } 246 247 BsonObjectID addDownload(BsonObjectID pack, string ver, string user_agent) 248 { 249 DbPackageDownload download; 250 download._id = BsonObjectID.generate(); 251 download.package_ = pack; 252 download.version_ = ver; 253 download.time = Clock.currTime(UTC()); 254 download.userAgent = user_agent; 255 m_downloads.insert(download); 256 return download._id; 257 } 258 259 DbPackageStats getPackageStats(string packname) 260 { 261 auto pack = m_packages.findOne(["name": Bson(packname)], ["stats": true]); 262 enforce!RecordNotFound(!pack.isNull(), "Unknown package name."); 263 logDebug("getPackageStats(%s) %s", packname, pack["stats"]); 264 return pack["stats"].deserializeBson!DbPackageStats; 265 } 266 267 void updatePackageStats(BsonObjectID packId, ref DbPackageStats stats) 268 { 269 stats.updatedAt = Clock.currTime(UTC()); 270 logDebug("updatePackageStats(%s, %s)", packId, stats); 271 m_packages.update(["_id": packId], ["$set": ["stats": stats]]); 272 } 273 274 DbDownloadStats aggregateDownloadStats(BsonObjectID packId, string ver = null) 275 { 276 static Bson newerThan(SysTime time) 277 { 278 // doc.time >= time ? 1 : 0 279 alias bs = serializeToBson; 280 return bs([ 281 "$cond": [bs(["$gte": [bs("$time"), bs(time)]]), bs(1), bs(0)] 282 ]); 283 } 284 285 auto match = Bson.emptyObject(); 286 match["package"] = Bson(packId); 287 if (ver.length) match["version"] = ver; 288 289 immutable now = Clock.currTime; 290 auto res = m_downloads.aggregate( 291 ["$match": match], 292 ["$project": [ 293 "_id": Bson(false), 294 "total": serializeToBson(["$literal": 1]), 295 "monthly": newerThan(now - 30.days), 296 "weekly": newerThan(now - 7.days), 297 "daily": newerThan(now - 1.days)]], 298 ["$group": [ 299 "_id": Bson(null), // single group 300 "total": Bson(["$sum": Bson("$total")]), 301 "monthly": Bson(["$sum": Bson("$monthly")]), 302 "weekly": Bson(["$sum": Bson("$weekly")]), 303 "daily": Bson(["$sum": Bson("$daily")])]]); 304 assert(res.length <= 1); 305 return res.length ? deserializeBson!DbDownloadStats(res[0]) : DbDownloadStats.init; 306 } 307 308 private void repairVersionOrder() 309 { 310 foreach( bp; m_packages.find() ){ 311 auto p = deserializeBson!DbPackage(bp); 312 auto newversions = p.versions 313 .filter!(v => v.version_.startsWith("~") || v.version_.isValidVersion) 314 .array 315 .sort!((a, b) => vcmp(a, b)) 316 .uniq!((a, b) => a.version_ == b.version_) 317 .array; 318 if (p.versions != newversions) 319 m_packages.update(["_id": p._id], ["$set": ["versions": newversions]]); 320 } 321 } 322 } 323 324 class RecordNotFound : Exception 325 { 326 @nogc @safe pure nothrow this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null) 327 { 328 super(msg, file, line, next); 329 } 330 331 @nogc @safe pure nothrow this(string msg, Throwable next, string file = __FILE__, size_t line = __LINE__) 332 { 333 super(msg, file, line, next); 334 } 335 } 336 337 struct DbPackage { 338 BsonObjectID _id; 339 BsonObjectID owner; 340 string name; 341 DbRepository repository; 342 DbPackageVersion[] versions; 343 DbPackageStats stats; 344 string[] errors; 345 string[] categories; 346 long updateCounter = 0; // used to implement lockless read-modify-write cycles 347 } 348 349 struct DbRepository { 350 string kind; 351 string owner; 352 string project; 353 } 354 355 struct DbPackageVersion { 356 SysTime date; 357 string version_; 358 @optional string commitID; 359 Json info; 360 @optional string readme; 361 } 362 363 struct DbPackageDownload { 364 BsonObjectID _id; 365 BsonObjectID package_; 366 string version_; 367 SysTime time; 368 string userAgent; 369 } 370 371 struct DbPackageStats { 372 SysTime updatedAt; 373 DbDownloadStats downloads; 374 DbRepoStats repo; 375 } 376 377 struct DbDownloadStats { 378 uint total, monthly, weekly, daily; 379 } 380 381 struct DbRepoStats { 382 uint stars, watchers, forks, issues; 383 } 384 385 bool vcmp(DbPackageVersion a, DbPackageVersion b) 386 { 387 return vcmp(a.version_, b.version_); 388 } 389 390 bool vcmp(string va, string vb) 391 { 392 import dub.dependency; 393 return Version(va) < Version(vb); 394 } 395 396 private string[] splitAlphaNumParts(string str) 397 { 398 string[] ret; 399 while (!str.empty) { 400 while (!str.empty && !str.front.isIdentChar()) str.popFront(); 401 if (str.empty) break; 402 size_t i = str.length; 403 foreach (j, dchar ch; str) 404 if (!isIdentChar(ch)) { 405 i = j; 406 break; 407 } 408 if (i > 0) { 409 ret ~= str[0 .. i]; 410 str = str[i .. $]; 411 } 412 if (!str.empty) str.popFront(); // pop non-ident-char 413 } 414 return ret; 415 } 416 417 private bool isIdentChar(dchar ch) 418 { 419 return std.uni.isAlpha(ch) || std.uni.isNumber(ch); 420 }