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.uni; 14 import vibe.vibe; 15 16 17 class DbController { 18 private { 19 MongoCollection m_packages; 20 MongoCollection m_downloads; 21 } 22 23 this(string dbname) 24 { 25 auto db = connectMongoDB("127.0.0.1").getDatabase(dbname); 26 m_packages = db["packages"]; 27 m_downloads = db["downloads"]; 28 29 // update package format 30 foreach(p; m_packages.find()){ 31 if (p.branches.type == Bson.Type.object) { 32 Bson[] branches; 33 foreach( b; p.branches ) 34 branches ~= b; 35 p.branches = branches; 36 } 37 if (p.branches.type == Bson.Type.array) { 38 auto versions = p.versions.get!(Bson[]); 39 foreach (b; p.branches) versions ~= b; 40 p.branches = Bson(null); 41 p.versions = Bson(versions); 42 } 43 m_packages.update(["_id": p._id], p); 44 } 45 46 repairVersionOrder(); 47 48 // create indices 49 m_packages.ensureIndex(["name": 1], IndexFlags.Unique); 50 m_packages.ensureIndex(["searchTerms": 1]); 51 m_downloads.ensureIndex([tuple("package", 1), tuple("version", 1)]); 52 } 53 54 void addPackage(ref DbPackage pack) 55 { 56 enforce(m_packages.findOne(["name": pack.name], ["_id": true]).isNull(), "A package with the same name is already registered."); 57 pack._id = BsonObjectID.generate(); 58 m_packages.insert(pack); 59 updateKeywords(pack.name); 60 } 61 62 DbPackage getPackage(string packname) 63 { 64 auto bpack = m_packages.findOne(["name": packname]); 65 enforce(!bpack.isNull(), "Unknown package name."); 66 return deserializeBson!DbPackage(bpack); 67 } 68 69 auto getAllPackages() 70 { 71 return m_packages.find(Bson.emptyObject, ["name": 1]).map!(p => p.name.get!string)(); 72 } 73 74 auto getUserPackages(BsonObjectID user_id) 75 { 76 return m_packages.find(["owner": user_id], ["name": 1]).map!(p => p.name.get!string)(); 77 } 78 79 bool isUserPackage(BsonObjectID user_id, string package_name) 80 { 81 return !m_packages.findOne(["owner": Bson(user_id), "name": Bson(package_name)]).isNull(); 82 } 83 84 void removePackage(string packname, BsonObjectID user) 85 { 86 m_packages.remove(["name": Bson(packname), "owner": Bson(user)]); 87 } 88 89 void setPackageErrors(string packname, string[] error...) 90 { 91 m_packages.update(["name": packname], ["$set": ["errors": error]]); 92 } 93 94 void setPackageCategories(string packname, string[] categories...) 95 { 96 m_packages.update(["name": packname], ["$set": ["categories": categories]]); 97 } 98 99 void setPackageRepository(string packname, Json repo) 100 { 101 m_packages.update(["name": packname], ["$set": ["repository": repo]]); 102 } 103 104 void addVersion(string packname, DbPackageVersion ver) 105 { 106 assert(ver.version_.startsWith("~") || ver.version_.isValidVersion()); 107 108 size_t nretrys = 0; 109 110 while (true) { 111 auto bversions = m_packages.findOne(["name": packname], ["versions": true]).versions; 112 auto versions = deserializeBson!(DbPackageVersion[])(bversions); 113 auto new_versions = versions ~ ver; 114 new_versions.sort!((a, b) => vcmp(a, b)); 115 116 //assert((cast(Json)bversions).toString() == (cast(Json)serializeToBson(versions)).toString()); 117 118 auto res = m_packages.findAndModify(["name": Bson(packname), "versions": bversions], ["$set": ["versions": new_versions]], ["_id": true]); 119 if (!res.isNull) { 120 updateKeywords(packname); 121 return; 122 } 123 124 enforce(nretrys++ < 20, format("Failed to store updated version list for %s", packname)); 125 logDebug("Failed to update version list atomically, retrying..."); 126 } 127 } 128 129 void removeVersion(string packname, string ver) 130 { 131 assert(ver.startsWith("~") || ver.isValidVersion()); 132 m_packages.update(["name": packname], ["$pull": ["versions": ["version": ver]]]); 133 } 134 135 void updateVersion(string packname, DbPackageVersion ver) 136 { 137 assert(ver.version_.startsWith("~") || ver.version_.isValidVersion()); 138 m_packages.update(["name": packname, "versions.version": ver.version_], ["$set": ["versions.$": ver]]); 139 updateKeywords(packname); 140 } 141 142 bool hasVersion(string packname, string ver) 143 { 144 auto ret = m_packages.findOne(["name": packname, "versions.version" : ver], ["_id": true]); 145 return !ret.isNull(); 146 } 147 148 string getLatestVersion(string packname) 149 { 150 auto slice = serializeToBson(["$slice": -1]); 151 auto pack = m_packages.findOne(["name": packname], ["_id": Bson(true), "versions": slice]); 152 if (pack.isNull() || pack.versions.isNull() || pack.versions.length != 1) return null; 153 return deserializeBson!(string)(pack.versions[0]["version"]); 154 } 155 156 DbPackageVersion getVersionInfo(string packname, string ver) 157 { 158 auto pack = m_packages.findOne(["name": packname, "versions.version": ver], ["versions.$": true]); 159 enforce(!pack.isNull(), "unknown package/version"); 160 assert(pack.versions.length == 1); 161 return deserializeBson!(DbPackageVersion)(pack.versions[0]); 162 } 163 164 auto searchPackages(string[] keywords) 165 { 166 Appender!(string[]) barekeywords; 167 foreach( kw; keywords ) { 168 kw = kw.strip(); 169 //kw = kw.normalize(); // separate character from diacritics 170 string[] parts = splitAlphaNumParts(kw.toLower()); 171 barekeywords ~= parts.filter!(p => p.count >= 2).map!(p => p.toLower).array; 172 } 173 logInfo("search for %s %s", keywords, barekeywords.data); 174 175 static if (0) { 176 // performs only exact matches - we should implement something more 177 // flexible, for example based on elastic search 178 return m_packages.find(["searchTerms": ["$all": barekeywords.data]]).map!(b => deserializeBson!DbPackage(b))(); 179 } else { 180 // in the meantime, we'll perform a brute force search instead 181 Appender!(Tuple!(DbPackage, size_t)[]) results; 182 foreach (p; m_packages.find().map!(b => deserializeBson!DbPackage(b))) { 183 size_t score = 0; 184 foreach (t; p.searchTerms) 185 foreach (kw; barekeywords.data) { 186 import std.algorithm; 187 auto dist = levenshteinDistance(t, kw); 188 if (dist <= 3 && dist+1 < kw.length) score += 3 - dist; 189 } 190 if (score > 0) results ~= tuple(p, score); 191 } 192 sort!((a, b) => a[1] > b[1])(results.data); 193 return results.data.map!(r => r[0]); 194 } 195 } 196 197 BsonObjectID addDownload(BsonObjectID pack, string ver, string user_agent) 198 { 199 DbPackageDownload download; 200 download._id = BsonObjectID.generate(); 201 download.package_ = pack; 202 download.version_ = ver; 203 download.time = Clock.currTime(UTC()); 204 download.userAgent = user_agent; 205 m_downloads.insert(download); 206 return download._id; 207 } 208 209 auto getDownloadStats(BsonObjectID pack, string ver = null) 210 { 211 static Bson newerThan(SysTime time) 212 { 213 // doc.time >= time ? 1 : 0 214 alias bs = serializeToBson; 215 return bs([ 216 "$cond": [bs(["$gte": [bs("$time"), bs(time)]]), bs(1), bs(0)] 217 ]); 218 } 219 220 auto match = Bson.emptyObject(); 221 match["package"] = Bson(pack); 222 if (ver.length) match["version"] = ver; 223 224 immutable now = Clock.currTime; 225 auto res = m_downloads.aggregate( 226 ["$match": match], 227 ["$project": [ 228 "_id": Bson(false), 229 "total": serializeToBson(["$literal": 1]), 230 "monthly": newerThan(now - 30.days), 231 "weekly": newerThan(now - 7.days), 232 "daily": newerThan(now - 1.days)]], 233 ["$group": [ 234 "_id": Bson(null), // single group 235 "total": Bson(["$sum": Bson("$total")]), 236 "monthly": Bson(["$sum": Bson("$monthly")]), 237 "weekly": Bson(["$sum": Bson("$weekly")]), 238 "daily": Bson(["$sum": Bson("$daily")])]]); 239 assert(res.length <= 1); 240 return res.length ? deserializeBson!DbDownloadStats(res[0]) : DbDownloadStats.init; 241 } 242 243 private void updateKeywords(string package_name) 244 { 245 auto p = getPackage(package_name); 246 bool[string] keywords; 247 void processString(string str) { 248 if (str.length == 0) return; 249 foreach (w; splitAlphaNumParts(str)) 250 if (w.count >= 2) 251 keywords[w.toLower()] = true; 252 } 253 void processVer(Json info) { 254 if (auto pv = "description" in info) processString(pv.opt!string); 255 if (auto pv = "authors" in info) processString(pv.opt!string); 256 if (auto pv = "homepage" in info) processString(pv.opt!string); 257 } 258 259 processString(p.name); 260 foreach (ver; p.versions) processVer(ver.info); 261 262 Appender!(string[]) kwarray; 263 foreach (kw; keywords.byKey) kwarray ~= kw; 264 m_packages.update(["name": package_name], ["$set": ["searchTerms": kwarray.data]]); 265 } 266 267 private void repairVersionOrder() 268 { 269 foreach( bp; m_packages.find() ){ 270 auto p = deserializeBson!DbPackage(bp); 271 p.versions = p.versions 272 .filter!(v => v.version_.startsWith("~") || v.version_.isValidVersion) 273 .array 274 .sort!((a, b) => vcmp(a, b)) 275 .array; 276 m_packages.update(["_id": p._id], ["$set": ["versions": p.versions]]); 277 } 278 } 279 } 280 281 struct DbPackage { 282 BsonObjectID _id; 283 BsonObjectID owner; 284 string name; 285 Json repository; 286 DbPackageVersion[] versions; 287 string[] errors; 288 string[] categories; 289 string[] searchTerms; 290 } 291 292 struct DbPackageVersion { 293 BsonDate date; 294 string version_; 295 @optional string commitID; 296 Json info; 297 @optional string readme; 298 } 299 300 struct DbPackageDownload { 301 BsonObjectID _id; 302 BsonObjectID package_; 303 string version_; 304 SysTime time; 305 string userAgent; 306 } 307 308 struct DbDownloadStats { 309 uint total, monthly, weekly, daily; 310 } 311 312 bool vcmp(DbPackageVersion a, DbPackageVersion b) 313 { 314 return vcmp(a.version_, b.version_); 315 } 316 317 bool vcmp(string va, string vb) 318 { 319 import dub.dependency; 320 return Version(va) < Version(vb); 321 } 322 323 private string[] splitAlphaNumParts(string str) 324 { 325 string[] ret; 326 while (!str.empty) { 327 while (!str.empty && !str.front.isIdentChar()) str.popFront(); 328 if (str.empty) break; 329 size_t i = str.length; 330 foreach (j, dchar ch; str) 331 if (!isIdentChar(ch)) { 332 i = j; 333 break; 334 } 335 if (i > 0) { 336 ret ~= str[0 .. i]; 337 str = str[i .. $]; 338 } 339 if (!str.empty) str.popFront(); // pop non-ident-char 340 } 341 return ret; 342 } 343 344 private bool isIdentChar(dchar ch) 345 { 346 return std.uni.isAlpha(ch) || std.uni.isNumber(ch); 347 }