github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/forum_store.go (about) 1 /* 2 * 3 * Gosora Forum Store 4 * Copyright Azareal 2017 - 2020 5 * 6 */ 7 package common 8 9 import ( 10 "database/sql" 11 "errors" 12 "log" 13 14 //"fmt" 15 "sort" 16 "sync" 17 "sync/atomic" 18 19 qgen "github.com/Azareal/Gosora/query_gen" 20 ) 21 22 var forumCreateMutex sync.Mutex 23 var forumPerms map[int]map[int]*ForumPerms // [gid][fid]*ForumPerms // TODO: Add an abstraction around this and make it more thread-safe 24 var Forums ForumStore 25 var ErrBlankName = errors.New("The name must not be blank") 26 var ErrNoDeleteReports = errors.New("You cannot delete the Reports forum") 27 28 // ForumStore is an interface for accessing the forums and the metadata stored on them 29 type ForumStore interface { 30 LoadForums() error 31 Each(h func(*Forum) error) error 32 DirtyGet(id int) *Forum 33 Get(id int) (*Forum, error) 34 BypassGet(id int) (*Forum, error) 35 BulkGetCopy(ids []int) (forums []Forum, err error) 36 Reload(id int) error // ? - Should we move this to ForumCache? It might require us to do some unnecessary casting though 37 //Update(Forum) error 38 Delete(id int) error 39 AddTopic(tid, uid, fid int) error 40 RemoveTopic(fid int) error 41 RemoveTopics(fid, count int) error 42 UpdateLastTopic(tid, uid, fid int) error 43 Exists(id int) bool 44 GetAll() ([]*Forum, error) 45 GetAllIDs() ([]int, error) 46 GetAllVisible() ([]*Forum, error) 47 GetAllVisibleIDs() ([]int, error) 48 //GetChildren(parentID int, parentType string) ([]*Forum,error) 49 //GetFirstChild(parentID int, parentType string) (*Forum,error) 50 Create(name, desc string, active bool, preset string) (int, error) 51 UpdateOrder(updateMap map[int]int) error 52 53 Count() int 54 } 55 56 type ForumCache interface { 57 CacheGet(id int) (*Forum, error) 58 CacheSet(f *Forum) error 59 CacheDelete(id int) 60 Length() int 61 } 62 63 // MemoryForumStore is a struct which holds an arbitrary number of forums in memory, usually all of them, although we might introduce functionality to hold a smaller subset in memory for sites with an extremely large number of forums 64 type MemoryForumStore struct { 65 forums sync.Map // map[int]*Forum 66 forumView atomic.Value // []*Forum 67 68 get *sql.Stmt 69 getAll *sql.Stmt 70 delete *sql.Stmt 71 create *sql.Stmt 72 count *sql.Stmt 73 updateCache *sql.Stmt 74 addTopics *sql.Stmt 75 removeTopics *sql.Stmt 76 lastTopic *sql.Stmt 77 updateOrder *sql.Stmt 78 } 79 80 // NewMemoryForumStore gives you a new instance of MemoryForumStore 81 func NewMemoryForumStore() (*MemoryForumStore, error) { 82 acc := qgen.NewAcc() 83 f := "forums" 84 set := func(s string) *sql.Stmt { 85 return acc.Update(f).Set(s).Where("fid=?").Prepare() 86 } 87 // TODO: Do a proper delete 88 return &MemoryForumStore{ 89 get: acc.Select(f).Columns("name, desc, tmpl, active, order, preset, parentID, parentType, topicCount, lastTopicID, lastReplyerID").Where("fid=?").Prepare(), 90 getAll: acc.Select(f).Columns("fid, name, desc, tmpl, active, order, preset, parentID, parentType, topicCount, lastTopicID, lastReplyerID").Orderby("order ASC, fid ASC").Prepare(), 91 delete: set("name='',active=0"), 92 create: acc.Insert(f).Columns("name,desc,tmpl,active,preset").Fields("?,?,'',?,?").Prepare(), 93 count: acc.Count(f).Where("name != ''").Prepare(), 94 updateCache: set("lastTopicID=?,lastReplyerID=?"), 95 addTopics: set("topicCount=topicCount+?"), 96 removeTopics: set("topicCount=topicCount-?"), 97 lastTopic: acc.Select("topics").Columns("tid").Where("parentID=?").Orderby("lastReplyAt DESC,createdAt DESC").Limit("1").Prepare(), 98 updateOrder: set("order=?"), 99 }, acc.FirstError() 100 } 101 102 // TODO: Rename to ReloadAll? 103 // TODO: Add support for subforums 104 func (s *MemoryForumStore) LoadForums() error { 105 var forumView []*Forum 106 addForum := func(f *Forum) { 107 s.forums.Store(f.ID, f) 108 if f.Active && f.Name != "" && f.ParentType == "" { 109 forumView = append(forumView, f) 110 } 111 } 112 113 rows, err := s.getAll.Query() 114 if err != nil { 115 return err 116 } 117 defer rows.Close() 118 119 i := 0 120 for ; rows.Next(); i++ { 121 f := &Forum{ID: 0, Active: true, Preset: "all"} 122 err = rows.Scan(&f.ID, &f.Name, &f.Desc, &f.Tmpl, &f.Active, &f.Order, &f.Preset, &f.ParentID, &f.ParentType, &f.TopicCount, &f.LastTopicID, &f.LastReplyerID) 123 if err != nil { 124 return err 125 } 126 127 if f.Name == "" { 128 DebugLog("Adding a placeholder forum") 129 } else { 130 log.Printf("Adding the '%s' forum", f.Name) 131 } 132 133 f.Link = BuildForumURL(NameToSlug(f.Name), f.ID) 134 f.LastTopic = Topics.DirtyGet(f.LastTopicID) 135 f.LastReplyer = Users.DirtyGet(f.LastReplyerID) 136 // TODO: Create a specialised function with a bit less overhead for getting the last page for a post count 137 _, _, lastPage := PageOffset(f.LastTopic.PostCount, 1, Config.ItemsPerPage) 138 f.LastPage = lastPage 139 addForum(f) 140 } 141 s.forumView.Store(forumView) 142 TopicListThaw.Thaw() 143 return rows.Err() 144 } 145 146 // TODO: Hide social groups too 147 // ? - Will this be hit a lot by plugin_guilds? 148 func (s *MemoryForumStore) rebuildView() { 149 var forumView []*Forum 150 s.forums.Range(func(_, val interface{}) bool { 151 f := val.(*Forum) 152 // ? - ParentType blank means that it doesn't have a parent 153 if f.Active && f.Name != "" && f.ParentType == "" { 154 forumView = append(forumView, f) 155 } 156 return true 157 }) 158 sort.Sort(SortForum(forumView)) 159 s.forumView.Store(forumView) 160 TopicListThaw.Thaw() 161 } 162 163 func (s *MemoryForumStore) Each(h func(*Forum) error) (err error) { 164 s.forums.Range(func(_, val interface{}) bool { 165 err = h(val.(*Forum)) 166 if err != nil { 167 return false 168 } 169 return true 170 }) 171 return err 172 } 173 174 func (s *MemoryForumStore) DirtyGet(id int) *Forum { 175 fint, ok := s.forums.Load(id) 176 if !ok || fint.(*Forum).Name == "" { 177 return &Forum{ID: -1, Name: ""} 178 } 179 return fint.(*Forum) 180 } 181 182 func (s *MemoryForumStore) CacheGet(id int) (*Forum, error) { 183 fint, ok := s.forums.Load(id) 184 if !ok || fint.(*Forum).Name == "" { 185 return nil, ErrNoRows 186 } 187 return fint.(*Forum), nil 188 } 189 190 func (s *MemoryForumStore) Get(id int) (*Forum, error) { 191 fint, ok := s.forums.Load(id) 192 if ok { 193 forum := fint.(*Forum) 194 if forum.Name == "" { 195 return nil, ErrNoRows 196 } 197 return forum, nil 198 } 199 200 forum, err := s.BypassGet(id) 201 if err != nil { 202 return nil, err 203 } 204 s.CacheSet(forum) 205 return forum, err 206 } 207 208 func (s *MemoryForumStore) BypassGet(id int) (*Forum, error) { 209 f := &Forum{ID: id} 210 err := s.get.QueryRow(id).Scan(&f.Name, &f.Desc, &f.Tmpl, &f.Active, &f.Order, &f.Preset, &f.ParentID, &f.ParentType, &f.TopicCount, &f.LastTopicID, &f.LastReplyerID) 211 if err != nil { 212 return nil, err 213 } 214 if f.Name == "" { 215 return nil, ErrNoRows 216 } 217 f.Link = BuildForumURL(NameToSlug(f.Name), f.ID) 218 f.LastTopic = Topics.DirtyGet(f.LastTopicID) 219 f.LastReplyer = Users.DirtyGet(f.LastReplyerID) 220 // TODO: Create a specialised function with a bit less overhead for getting the last page for a post count 221 _, _, lastPage := PageOffset(f.LastTopic.PostCount, 1, Config.ItemsPerPage) 222 f.LastPage = lastPage 223 //TopicListThaw.Thaw() 224 225 return f, err 226 } 227 228 // TODO: Optimise this 229 func (s *MemoryForumStore) BulkGetCopy(ids []int) (forums []Forum, err error) { 230 forums = make([]Forum, len(ids)) 231 for i, id := range ids { 232 f, err := s.Get(id) 233 if err != nil { 234 return nil, err 235 } 236 forums[i] = f.Copy() 237 } 238 return forums, nil 239 } 240 241 func (s *MemoryForumStore) Reload(id int) error { 242 forum, err := s.BypassGet(id) 243 if err != nil { 244 return err 245 } 246 s.CacheSet(forum) 247 return nil 248 } 249 250 func (s *MemoryForumStore) CacheSet(f *Forum) error { 251 s.forums.Store(f.ID, f) 252 s.rebuildView() 253 return nil 254 } 255 256 // ! Has a randomised order 257 func (s *MemoryForumStore) GetAll() (forumView []*Forum, err error) { 258 s.forums.Range(func(_, val interface{}) bool { 259 forumView = append(forumView, val.(*Forum)) 260 return true 261 }) 262 sort.Sort(SortForum(forumView)) 263 return forumView, nil 264 } 265 266 // ? - Can we optimise the sorting? 267 func (s *MemoryForumStore) GetAllIDs() (ids []int, err error) { 268 s.forums.Range(func(_, val interface{}) bool { 269 ids = append(ids, val.(*Forum).ID) 270 return true 271 }) 272 sort.Ints(ids) 273 return ids, nil 274 } 275 276 func (s *MemoryForumStore) GetAllVisible() (forumView []*Forum, err error) { 277 forumView = s.forumView.Load().([]*Forum) 278 return forumView, nil 279 } 280 281 func (s *MemoryForumStore) GetAllVisibleIDs() ([]int, error) { 282 forumView := s.forumView.Load().([]*Forum) 283 ids := make([]int, len(forumView)) 284 for i := 0; i < len(forumView); i++ { 285 ids[i] = forumView[i].ID 286 } 287 return ids, nil 288 } 289 290 // TODO: Implement sub-forums. 291 /*func (s *MemoryForumStore) GetChildren(parentID int, parentType string) ([]*Forum,error) { 292 return nil, nil 293 } 294 func (s *MemoryForumStore) GetFirstChild(parentID int, parentType string) (*Forum,error) { 295 return nil, nil 296 }*/ 297 298 // TODO: Add a query for this rather than hitting cache 299 func (s *MemoryForumStore) Exists(id int) bool { 300 forum, ok := s.forums.Load(id) 301 if !ok { 302 return false 303 } 304 return forum.(*Forum).Name != "" 305 } 306 307 // TODO: Batch deletions with name blanking? Is this necessary? 308 func (s *MemoryForumStore) CacheDelete(id int) { 309 s.forums.Delete(id) 310 s.rebuildView() 311 } 312 313 // TODO: Add a hook to allow plugin_guilds to detect when one of it's forums has just been deleted? 314 func (s *MemoryForumStore) Delete(id int) error { 315 if id == ReportForumID { 316 return ErrNoDeleteReports 317 } 318 _, err := s.delete.Exec(id) 319 s.CacheDelete(id) 320 return err 321 } 322 323 func (s *MemoryForumStore) AddTopic(tid, uid, fid int) error { 324 _, err := s.updateCache.Exec(tid, uid, fid) 325 if err != nil { 326 return err 327 } 328 _, err = s.addTopics.Exec(1, fid) 329 if err != nil { 330 return err 331 } 332 // TODO: Bypass the database and update this with a lock or an unsafe atomic swap 333 return s.Reload(fid) 334 } 335 336 func (s *MemoryForumStore) RefreshTopic(fid int) (err error) { 337 var tid int 338 err = s.lastTopic.QueryRow(fid).Scan(&tid) 339 if err == sql.ErrNoRows { 340 f, err := s.CacheGet(fid) 341 if err != nil || f.LastTopicID != 0 { 342 _, err = s.updateCache.Exec(0, 0, fid) 343 if err != nil { 344 return err 345 } 346 s.Reload(fid) 347 } 348 return nil 349 } 350 if err != nil { 351 return err 352 } 353 354 topic, err := Topics.Get(tid) 355 if err != nil { 356 return err 357 } 358 _, err = s.updateCache.Exec(tid, topic.CreatedBy, fid) 359 if err != nil { 360 return err 361 } 362 // TODO: Bypass the database and update this with a lock or an unsafe atomic swap 363 s.Reload(fid) 364 return nil 365 } 366 367 // TODO: Make this update more atomic 368 func (s *MemoryForumStore) RemoveTopic(fid int) error { 369 _, err := s.removeTopics.Exec(1, fid) 370 if err != nil { 371 return err 372 } 373 return s.RefreshTopic(fid) 374 } 375 func (s *MemoryForumStore) RemoveTopics(fid, count int) error { 376 _, err := s.removeTopics.Exec(count, fid) 377 if err != nil { 378 return err 379 } 380 return s.RefreshTopic(fid) 381 } 382 383 // DEPRECATED. forum.Update() will be the way to do this in the future, once it's completed 384 // TODO: Have a pointer to the last topic rather than storing it on the forum itself 385 func (s *MemoryForumStore) UpdateLastTopic(tid, uid, fid int) error { 386 _, err := s.updateCache.Exec(tid, uid, fid) 387 if err != nil { 388 return err 389 } 390 // TODO: Bypass the database and update this with a lock or an unsafe atomic swap 391 return s.Reload(fid) 392 } 393 394 func (s *MemoryForumStore) Create(name, desc string, active bool, preset string) (int, error) { 395 if name == "" { 396 return 0, ErrBlankName 397 } 398 forumCreateMutex.Lock() 399 defer forumCreateMutex.Unlock() 400 401 res, err := s.create.Exec(name, desc, active, preset) 402 if err != nil { 403 return 0, err 404 } 405 406 fid64, err := res.LastInsertId() 407 if err != nil { 408 return 0, err 409 } 410 fid := int(fid64) 411 412 err = s.Reload(fid) 413 if err != nil { 414 return 0, err 415 } 416 417 PermmapToQuery(PresetToPermmap(preset), fid) 418 return fid, nil 419 } 420 421 // TODO: Make this atomic, maybe with a transaction? 422 func (s *MemoryForumStore) UpdateOrder(updateMap map[int]int) error { 423 for fid, order := range updateMap { 424 _, err := s.updateOrder.Exec(order, fid) 425 if err != nil { 426 return err 427 } 428 } 429 return s.LoadForums() 430 } 431 432 // ! Might be slightly inaccurate, if the sync.Map is constantly shifting and churning, but it'll stabilise eventually. Also, slow. Don't use this on every request x.x 433 // Length returns the number of forums in the memory cache 434 func (s *MemoryForumStore) Length() (len int) { 435 s.forums.Range(func(_, _ interface{}) bool { 436 len++ 437 return true 438 }) 439 return len 440 } 441 442 // TODO: Get the total count of forums in the forum store rather than doing a heavy query for this? 443 // Count returns the total number of forums 444 func (s *MemoryForumStore) Count() (count int) { 445 err := s.count.QueryRow().Scan(&count) 446 if err != nil { 447 LogError(err) 448 } 449 return count 450 } 451 452 // TODO: Work on SqlForumStore 453 454 // TODO: Work on the NullForumStore