| 
 | 1 | +package database  | 
 | 2 | + | 
 | 3 | +import (  | 
 | 4 | +	"context"  | 
 | 5 | +	"gin-rest-api-example/internal/article/model"  | 
 | 6 | +	"gin-rest-api-example/internal/database"  | 
 | 7 | +	"gin-rest-api-example/pkg/logging"  | 
 | 8 | +	"gorm.io/gorm"  | 
 | 9 | +	"time"  | 
 | 10 | +)  | 
 | 11 | + | 
 | 12 | +type IterateArticleCriteria struct {  | 
 | 13 | +	Tags   []string  | 
 | 14 | +	Author string  | 
 | 15 | +	Offset uint  | 
 | 16 | +	Limit  uint  | 
 | 17 | +}  | 
 | 18 | + | 
 | 19 | +//go:generate mockery --name ArticleDB --filename article_mock.go  | 
 | 20 | +type ArticleDB interface {  | 
 | 21 | +	// SaveArticle saves a given article with tags.  | 
 | 22 | +	// if not exist tags, then save a new tag  | 
 | 23 | +	SaveArticle(ctx context.Context, article *model.Article) error  | 
 | 24 | + | 
 | 25 | +	// FindArticleBySlug returns a article with given slug  | 
 | 26 | +	// database.ErrNotFound error is returned if not exist  | 
 | 27 | +	FindArticleBySlug(ctx context.Context, slug string) (*model.Article, error)  | 
 | 28 | + | 
 | 29 | +	// FindArticles returns article list with given criteria and total count  | 
 | 30 | +	FindArticles(ctx context.Context, criteria IterateArticleCriteria) ([]*model.Article, int64, error)  | 
 | 31 | + | 
 | 32 | +	// DeleteArticleBySlug deletes a article with given slug  | 
 | 33 | +	// and returns nil if success to delete, otherwise returns an error  | 
 | 34 | +	DeleteArticleBySlug(ctx context.Context, authorId uint, slug string) error  | 
 | 35 | + | 
 | 36 | +	// SaveComment saves a comment with given article slug and comment  | 
 | 37 | +	SaveComment(ctx context.Context, slug string, comment *model.Comment) error  | 
 | 38 | + | 
 | 39 | +	// FindComments returns all comments with given article slug  | 
 | 40 | +	FindComments(ctx context.Context, slug string) ([]*model.Comment, error)  | 
 | 41 | + | 
 | 42 | +	// DeleteCommentById deletes a comment with given article slug and comment id  | 
 | 43 | +	// database.ErrNotFound error is returned if not exist  | 
 | 44 | +	DeleteCommentById(ctx context.Context, authorId uint, slug string, id uint) error  | 
 | 45 | + | 
 | 46 | +	// DeleteComments deletes all comment with given author id and slug  | 
 | 47 | +	// and returns deleted records count  | 
 | 48 | +	DeleteComments(ctx context.Context, authorId uint, slug string) (int64, error)  | 
 | 49 | +}  | 
 | 50 | + | 
 | 51 | +type articleDB struct {  | 
 | 52 | +	db *gorm.DB  | 
 | 53 | +}  | 
 | 54 | + | 
 | 55 | +func (a *articleDB) SaveArticle(ctx context.Context, article *model.Article) error {  | 
 | 56 | +	logger := logging.FromContext(ctx)  | 
 | 57 | +	db := database.FromContext(ctx, a.db)  | 
 | 58 | +	logger.Debugw("article.db.SaveArticle", "article", article)  | 
 | 59 | + | 
 | 60 | +	for _, tag := range article.Tags {  | 
 | 61 | +		if err := db.WithContext(ctx).FirstOrCreate(&tag, "name = ?", tag.Name).Error; err != nil {  | 
 | 62 | +			logger.Errorw("article.db.SaveArticle failed to first or save tag", "err", err)  | 
 | 63 | +			return err  | 
 | 64 | +		}  | 
 | 65 | +	}  | 
 | 66 | + | 
 | 67 | +	if err := db.WithContext(ctx).Create(article).Error; err != nil {  | 
 | 68 | +		logger.Errorw("article.db.SaveArticle failed to save article", "err", err)  | 
 | 69 | +		if database.IsKeyConflictErr(err) {  | 
 | 70 | +			return database.ErrKeyConflict  | 
 | 71 | +		}  | 
 | 72 | +		return err  | 
 | 73 | +	}  | 
 | 74 | +	return nil  | 
 | 75 | +}  | 
 | 76 | + | 
 | 77 | +func (a *articleDB) FindArticleBySlug(ctx context.Context, slug string) (*model.Article, error) {  | 
 | 78 | +	logger := logging.FromContext(ctx)  | 
 | 79 | +	db := database.FromContext(ctx, a.db)  | 
 | 80 | +	logger.Debugw("article.db.FindArticleBySlug", "slug", slug)  | 
 | 81 | + | 
 | 82 | +	var ret model.Article  | 
 | 83 | +	// 1) load article with author  | 
 | 84 | +	// SELECT articles.*, accounts.*  | 
 | 85 | +	// FROM `articles` LEFT JOIN `accounts` `Author` ON `articles`.`author_id` = `Author`.`id`  | 
 | 86 | +	// WHERE slug = "title1" AND deleted_at_unix = 0 ORDER BY `articles`.`id` LIMIT 1  | 
 | 87 | +	err := db.Joins("Author").  | 
 | 88 | +		First(&ret, "slug = ? AND deleted_at_unix = 0", slug).Error  | 
 | 89 | +	// 2) load tags  | 
 | 90 | +	if err == nil {  | 
 | 91 | +		// SELECT * from tags JOIN article_tags ON article_tags.tag_id = tags.id AND article_tags.article_id = ?  | 
 | 92 | +		err = db.Model(&ret).Association("Tags").Find(&ret.Tags)  | 
 | 93 | +	}  | 
 | 94 | + | 
 | 95 | +	if err != nil {  | 
 | 96 | +		logger.Errorw("failed to find article", "err", err)  | 
 | 97 | +		if database.IsRecordNotFoundErr(err) {  | 
 | 98 | +			return nil, database.ErrNotFound  | 
 | 99 | +		}  | 
 | 100 | +		return nil, err  | 
 | 101 | +	}  | 
 | 102 | +	return &ret, nil  | 
 | 103 | +}  | 
 | 104 | + | 
 | 105 | +func (a *articleDB) FindArticles(ctx context.Context, criteria IterateArticleCriteria) ([]*model.Article, int64, error) {  | 
 | 106 | +	logger := logging.FromContext(ctx)  | 
 | 107 | +	db := database.FromContext(ctx, a.db)  | 
 | 108 | +	logger.Debugw("article.db.FindArticles", "criteria", criteria)  | 
 | 109 | + | 
 | 110 | +	chain := db.Table("articles a").Where("deleted_at_unix = 0")  | 
 | 111 | +	if len(criteria.Tags) != 0 {  | 
 | 112 | +		chain = chain.Where("t.name IN ?", criteria.Tags)  | 
 | 113 | +	}  | 
 | 114 | +	if criteria.Author != "" {  | 
 | 115 | +		chain = chain.Where("au.username = ?", criteria.Author)  | 
 | 116 | +	}  | 
 | 117 | +	if len(criteria.Tags) != 0 {  | 
 | 118 | +		chain = chain.Joins("LEFT JOIN article_tags ats on ats.article_id = a.id").  | 
 | 119 | +			Joins("LEFT JOIN tags t on t.id = ats.tag_id")  | 
 | 120 | +	}  | 
 | 121 | +	if criteria.Author != "" {  | 
 | 122 | +		chain = chain.Joins("LEFT JOIN accounts au on au.id = a.author_id")  | 
 | 123 | +	}  | 
 | 124 | + | 
 | 125 | +	// get total count  | 
 | 126 | +	var totalCount int64  | 
 | 127 | +	err := chain.Distinct("a.id").Count(&totalCount).Error  | 
 | 128 | +	if err != nil {  | 
 | 129 | +		logger.Error("failed to get total count", "err", err)  | 
 | 130 | +	}  | 
 | 131 | + | 
 | 132 | +	// get article ids  | 
 | 133 | +	rows, err := chain.Select("DISTINCT(a.id) id").  | 
 | 134 | +		Offset(int(criteria.Offset)).  | 
 | 135 | +		Limit(int(criteria.Limit)).  | 
 | 136 | +		Order("a.id DESC").  | 
 | 137 | +		Rows()  | 
 | 138 | +	if err != nil {  | 
 | 139 | +		logger.Error("failed to read article ids", "err", err)  | 
 | 140 | +		return nil, 0, err  | 
 | 141 | +	}  | 
 | 142 | +	var ids []uint  | 
 | 143 | +	for rows.Next() {  | 
 | 144 | +		var id uint  | 
 | 145 | +		err := rows.Scan(&id)  | 
 | 146 | +		if err != nil {  | 
 | 147 | +			logger.Error("failed to scan id from id rows", "err", err)  | 
 | 148 | +			return nil, 0, err  | 
 | 149 | +		}  | 
 | 150 | +		ids = append(ids, id)  | 
 | 151 | +	}  | 
 | 152 | + | 
 | 153 | +	// get articles with author by ids  | 
 | 154 | +	var ret []*model.Article  | 
 | 155 | +	if len(ids) == 0 {  | 
 | 156 | +		return []*model.Article{}, totalCount, nil  | 
 | 157 | +	}  | 
 | 158 | +	err = db.Joins("Author").  | 
 | 159 | +		Where("articles.id IN (?)", ids).  | 
 | 160 | +		Order("articles.id DESC").  | 
 | 161 | +		Find(&ret).Error  | 
 | 162 | +	if err != nil {  | 
 | 163 | +		logger.Error("failed to find article by ids", "err", err)  | 
 | 164 | +		return nil, 0, err  | 
 | 165 | +	}  | 
 | 166 | + | 
 | 167 | +	// get tags by article ids  | 
 | 168 | +	ma := make(map[uint]*model.Article)  | 
 | 169 | +	for _, r := range ret {  | 
 | 170 | +		ma[r.ID] = r  | 
 | 171 | +	}  | 
 | 172 | +	type ArticleTag struct {  | 
 | 173 | +		model.Tag  | 
 | 174 | +		ArticleId uint  | 
 | 175 | +	}  | 
 | 176 | +	batchSize := 100 // TODO : config  | 
 | 177 | +	for i := 0; i < len(ret); i += batchSize {  | 
 | 178 | +		var at []*ArticleTag  | 
 | 179 | +		last := i + batchSize  | 
 | 180 | +		if last > len(ret) {  | 
 | 181 | +			last = len(ret)  | 
 | 182 | +		}  | 
 | 183 | + | 
 | 184 | +		err = db.Table("tags").  | 
 | 185 | +			Where("article_tags.article_id IN (?)", ids[i:last]).  | 
 | 186 | +			Joins("LEFT JOIN article_tags ON article_tags.tag_id = tags.id").  | 
 | 187 | +			Select("tags.*, article_tags.article_id article_id").  | 
 | 188 | +			Find(&at).Error  | 
 | 189 | + | 
 | 190 | +		if err != nil {  | 
 | 191 | +			logger.Error("failed to load tags by article ids", "articleIds", ids[i:last], "err", err)  | 
 | 192 | +			return nil, 0, err  | 
 | 193 | +		}  | 
 | 194 | +		for _, tag := range at {  | 
 | 195 | +			a := ma[tag.ArticleId]  | 
 | 196 | +			a.Tags = append(a.Tags, &tag.Tag)  | 
 | 197 | +		}  | 
 | 198 | +	}  | 
 | 199 | +	return ret, totalCount, nil  | 
 | 200 | +}  | 
 | 201 | + | 
 | 202 | +func (a *articleDB) DeleteArticleBySlug(ctx context.Context, authorId uint, slug string) error {  | 
 | 203 | +	logger := logging.FromContext(ctx)  | 
 | 204 | +	db := database.FromContext(ctx, a.db)  | 
 | 205 | +	logger.Debugw("article.db.DeleteArticleBySlug", "slug", slug)  | 
 | 206 | + | 
 | 207 | +	chain := db.Model(&model.Article{}).  | 
 | 208 | +		Where("slug = ?", slug).  | 
 | 209 | +		Where("author_id = ?", authorId).  | 
 | 210 | +		Update("deleted_at_unix", time.Now().Unix())  | 
 | 211 | +	if chain.Error != nil {  | 
 | 212 | +		logger.Errorw("failed to delete an article", "err", chain.Error)  | 
 | 213 | +		return chain.Error  | 
 | 214 | +	}  | 
 | 215 | +	if chain.RowsAffected == 0 {  | 
 | 216 | +		logger.Error("failed to delete an article because not found")  | 
 | 217 | +		return database.ErrNotFound  | 
 | 218 | +	}  | 
 | 219 | +	return nil  | 
 | 220 | +}  | 
0 commit comments