Skip to content

Commit ad024ec

Browse files
committed
Added article, comment database layer
1 parent fbe3c34 commit ad024ec

File tree

18 files changed

+1089
-19
lines changed

18 files changed

+1089
-19
lines changed

Makefile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
MODULE = $(shell go list -m)
22

3-
.PHONY: generate build build-docker compose compose-down test
3+
.PHONY: generate build test lint build-docker compose compose-down
44
generate:
55
go generate ./...
66

77
build: # build a server
88
go build -a -o article-server $(MODULE)/cmd/server
99

10+
test:
11+
go clean -testcache
12+
go test ./... -v
13+
14+
lint:
15+
gofmt -l .
16+
1017
build-docker: # build docker image
1118
docker build -f cmd/server/Dockerfile -t gin-example/article-server .
1219

@@ -16,5 +23,3 @@ compose: # run with docker-compose
1623
compose-down: # down docker-compose
1724
docker-compose down -v
1825

19-
test:
20-
go test ./... -v

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ API Server technology stack is
2828
- [x] configure project layer
2929
- [x] impl account db
3030
- [x] impl account handler (binding, serialize, common error middleware, etc...)
31-
- [ ] impl article db
31+
- [x] impl article db
3232
- [ ] impl article handler
3333
- [ ] configure docker compose
3434
- [ ] configure tests (newman or http)

config/local.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jwt:
77
secret: secret-key
88
sessionTime: 86400
99
db:
10-
dataSourceName: root:password@tcp(127.0.0.1:3306)/local_db?charset=utf8&parseTime=True
10+
dataSourceName: root:password@tcp(127.0.0.1:3306)/local_db?charset=utf8&parseTime=True&multiStatements=true
1111
migrate: true
1212
pool:
1313
maxOpen: 50

internal/account/database/account_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package database
33
import (
44
"gin-rest-api-example/internal/account/model"
55
"gin-rest-api-example/internal/database"
6+
"gin-rest-api-example/pkg/logging"
67
"github.com/stretchr/testify/suite"
8+
"go.uber.org/zap/zapcore"
79
"gorm.io/gorm"
810
"testing"
911
"time"
@@ -16,6 +18,7 @@ type DBSuite struct {
1618
}
1719

1820
func (s *DBSuite) SetupSuite() {
21+
logging.SetLevel(zapcore.FatalLevel)
1922
s.originDB = database.NewTestDatabase(s.T(), true)
2023
s.db = &accountDB{
2124
db: s.originDB,

internal/account/handler_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import (
77
"gin-rest-api-example/internal/account/model"
88
"gin-rest-api-example/internal/config"
99
"gin-rest-api-example/internal/database"
10+
"gin-rest-api-example/pkg/logging"
1011
"github.com/gin-gonic/gin"
1112
"github.com/stretchr/testify/mock"
1213
"github.com/stretchr/testify/suite"
1314
"github.com/tidwall/gjson"
15+
"go.uber.org/zap/zapcore"
1416
"net/http"
1517
"net/http/httptest"
1618
"testing"
@@ -24,6 +26,10 @@ type HandlerSuite struct {
2426
db *mocks.AccountDB
2527
}
2628

29+
func (s *HandlerSuite) SetupSuite() {
30+
logging.SetLevel(zapcore.FatalLevel)
31+
}
32+
2733
func (s *HandlerSuite) SetupTest() {
2834
cfg, err := config.Load("")
2935
s.NoError(err)

internal/account/model/account_model.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@ import (
77
)
88

99
type Account struct {
10-
ID uint `gorm:"column:id"`
11-
Username string `gorm:"column:username"`
12-
Email string `gorm:"column:email"`
13-
Password string `gorm:"column:password"`
14-
Bio string `gorm:"column:bio"`
15-
Image string `gorm:"column:image"`
16-
10+
ID uint `gorm:"column:id"`
11+
Username string `gorm:"column:username"`
12+
Email string `gorm:"column:email"`
13+
Password string `gorm:"column:password"`
14+
Bio string `gorm:"column:bio"`
15+
Image string `gorm:"column:image"`
1716
CreatedAt time.Time `gorm:"column:created_at"`
1817
UpdatedAt time.Time `gorm:"column:updated_at"`
1918
Disabled bool `gorm:"column:disabled"`
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)