Separated graphql schema into multiple files and moved them to graph/schema dir, update the gqlgen.yml accordingly

Removed unnecesary bindings for ID and Int in gqlgen.yml
Minor changes to Makefile docker-clean target
Reduced docker-compose postgres healthcheck interval for faster local db connections
Added pagination to graphql schema, the paginated queries are WIP
Removed unneeded fields from the model structs
Added allowComment check when adding comments
Switched gorm logger to Info mode
App now panics if the specified APP_STORAGE in env doesn't exist
Reworked database methods, still barely working and are WIP
This commit is contained in:
erius 2024-06-26 04:45:04 +03:00
parent ab4c41c7f3
commit e0aa12b126
16 changed files with 1813 additions and 369 deletions

View file

@ -13,4 +13,6 @@ go-generate:
clean: docker-clean clean: docker-clean
docker-clean: docker-clean:
docker container rm postgres-ozon-task-1 postgres-postgres-1 && docker volume rm postgres_postgres-data -docker container rm postgres-ozon-task-1 postgres-postgres-1 standalone-ozon-task-1;\
docker volume rm postgres_postgres-data;\
docker network rm postgres_default standalone_defaul\

View file

@ -13,7 +13,7 @@ services:
env_file: postgres.env env_file: postgres.env
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s interval: 1s
timeout: 5s timeout: 5s
retries: 5 retries: 5
volumes: volumes:

View file

@ -1,6 +1,6 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls # Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema: schema:
- graph/*.graphqls - graph/schema/*.graphqls
# Where should the generated server code go? # Where should the generated server code go?
exec: exec:
@ -75,14 +75,6 @@ autobind:
# your liking # your liking
models: models:
ID: ID:
model: model: github.com/99designs/gqlgen/graphql.Uint
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
- github.com/99designs/gqlgen/graphql.Uint
Int: Int:
model: model: github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32

File diff suppressed because it is too large Load diff

View file

@ -15,18 +15,10 @@ type Comment struct {
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
// db foreign key to reference parent post PostID uint `json:"post_id"`
PostID uint Author string `json:"author"`
Contents string `json:"contents"`
Post *Post `json:"post"` Replies []*Comment `json:"replies" gorm:"many2many:comment_replies"`
Author string `json:"author"`
Contents string `json:"contents"`
// db key to reference parent comment
ReplyToID *uint
ReplyTo *Comment `json:"reply_to,omitempty"`
Replies []*Comment `json:"replies" gorm:"many2many:comment_replies"`
} }
type Post struct { type Post struct {
@ -49,6 +41,7 @@ func PostFromInput(input *PostInput) *Post {
Title: input.Title, Title: input.Title,
Contents: input.Contents, Contents: input.Contents,
AllowComments: input.AllowComments, AllowComments: input.AllowComments,
Comments: make([]*Comment, 0),
} }
} }

View file

@ -2,8 +2,34 @@
package model package model
type CommentsConnection struct {
Edges []*CommentsEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type CommentsEdge struct {
Cursor uint `json:"cursor"`
Node *Comment `json:"node"`
}
type Mutation struct { type Mutation struct {
} }
type PageInfo struct {
StartCursor uint `json:"startCursor"`
EndCursor uint `json:"endCursor"`
HasNextPage bool `json:"hasNextPage"`
}
type PostsConnection struct {
Edges []*PostsEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type PostsEdge struct {
Cursor uint `json:"cursor"`
Node *Post `json:"node"`
}
type Query struct { type Query struct {
} }

View file

@ -12,31 +12,26 @@ import (
) )
// AddPost is the resolver for the add_post field. // AddPost is the resolver for the add_post field.
func (r *mutationResolver) AddPost(ctx context.Context, input *model.PostInput) (*model.AddResult, error) { func (r *mutationResolver) AddPost(ctx context.Context, input model.PostInput) (*model.AddResult, error) {
return r.Storage.AddPost(input) return r.Storage.AddPost(&input)
} }
// AddComment is the resolver for the add_comment field. // AddComment is the resolver for the add_comment field.
func (r *mutationResolver) AddComment(ctx context.Context, input *model.CommentInput) (*model.AddResult, error) { func (r *mutationResolver) AddComment(ctx context.Context, input model.CommentInput) (*model.AddResult, error) {
if len(input.Contents) > model.CommentLengthLimit { if len(input.Contents) > model.CommentLengthLimit {
return nil, fmt.Errorf("exceeded max comment length of %d chars", model.CommentLengthLimit) return nil, fmt.Errorf("exceeded max comment length of %d chars", model.CommentLengthLimit)
} }
if input.ParentCommentID == nil && input.ParentPostID == nil { switch {
case input.ParentPostID != nil:
return r.Storage.AddCommentToPost(&input)
case input.ParentCommentID != nil:
return r.Storage.AddReplyToComment(&input)
default:
return nil, fmt.Errorf("parent post or parent comment ids weren't specified") return nil, fmt.Errorf("parent post or parent comment ids weren't specified")
} }
return r.Storage.AddComment(input)
}
// AllPosts is the resolver for the all_posts field.
func (r *queryResolver) AllPosts(ctx context.Context) ([]*model.Post, error) {
return r.Storage.GetPosts()
} }
// Mutation returns MutationResolver implementation. // Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver } type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

49
graph/query.resolvers.go Normal file
View file

@ -0,0 +1,49 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.49
import (
"context"
"git.obamna.ru/erius/ozon-task/graph/model"
)
// Replies is the resolver for the replies field.
func (r *commentResolver) Replies(ctx context.Context, obj *model.Comment, first uint, after uint) (*model.CommentsConnection, error) {
return r.Storage.GetReplies(obj, first, after, ctx)
}
// Comments is the resolver for the comments field.
func (r *postResolver) Comments(ctx context.Context, obj *model.Post, first uint, after uint) (*model.CommentsConnection, error) {
return r.Storage.GetComments(obj, first, after, ctx)
}
// Post is the resolver for the post field.
func (r *queryResolver) Post(ctx context.Context, id uint) (*model.Post, error) {
return r.Storage.GetPost(id, ctx)
}
// Posts is the resolver for the posts field.
func (r *queryResolver) Posts(ctx context.Context, first uint, after uint) (*model.PostsConnection, error) {
return r.Storage.GetPosts(first, after, ctx)
}
// Comment is the resolver for the comment field.
func (r *queryResolver) Comment(ctx context.Context, id uint) (*model.Comment, error) {
return r.Storage.GetComment(id, ctx)
}
// Comment returns CommentResolver implementation.
func (r *Resolver) Comment() CommentResolver { return &commentResolver{r} }
// Post returns PostResolver implementation.
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type commentResolver struct{ *Resolver }
type postResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

View file

@ -1,44 +0,0 @@
type Post {
id: ID!
title: String!
author: String!
contents: String!
comments: [Comment!]!
allowComments: Boolean!
}
type Comment {
id: ID!
post: Post!
author: String!
contents: String!
replyTo: Comment
replies: [Comment!]!
}
type Query {
allPosts: [Post!]!
}
type Mutation {
addPost(input: PostInput): AddResult!
addComment(input: CommentInput): AddResult!
}
input PostInput {
title: String!
author: String!
contents: String!
allowComments: Boolean! = true
}
input CommentInput {
parentPostId: ID
parentCommentId: ID
author: String!
contents: String!
}
type AddResult {
itemId: ID!
}

View file

@ -0,0 +1,22 @@
type Mutation {
addPost(input: PostInput!): AddResult!
addComment(input: CommentInput!): AddResult!
}
input PostInput {
title: String!
author: String!
contents: String!
allowComments: Boolean! = true
}
input CommentInput {
parentPostId: ID
parentCommentId: ID
author: String!
contents: String!
}
type AddResult {
itemId: ID!
}

View file

@ -0,0 +1,25 @@
type PostsConnection {
edges: [PostsEdge!]!
pageInfo: PageInfo!
}
type CommentsConnection {
edges: [CommentsEdge!]!
pageInfo: PageInfo!
}
type PostsEdge {
cursor: ID!
node: Post!
}
type CommentsEdge {
cursor: ID!
node: Comment!
}
type PageInfo {
startCursor: ID!
endCursor: ID!
hasNextPage: Boolean!
}

View file

@ -0,0 +1,21 @@
type Query {
post(id: ID!): Post!
posts(first: ID!, after: ID!): PostsConnection!
comment(id: ID!): Comment!
}
type Post {
id: ID!
title: String!
author: String!
contents: String!
comments(first: ID!, after: ID!): CommentsConnection!
allowComments: Boolean!
}
type Comment {
id: ID!
author: String!
contents: String!
replies(first: ID!, after: ID!): CommentsConnection!
}

View file

@ -1,7 +1,12 @@
package db package db
import ( import (
"context"
"fmt"
"log"
"git.obamna.ru/erius/ozon-task/graph/model" "git.obamna.ru/erius/ozon-task/graph/model"
"github.com/99designs/gqlgen/graphql"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -15,48 +20,64 @@ func (s *Database) AddPost(input *model.PostInput) (*model.AddResult, error) {
return &model.AddResult{ItemID: &post.ID}, err return &model.AddResult{ItemID: &post.ID}, err
} }
func (s *Database) AddComment(input *model.CommentInput) (*model.AddResult, error) { func (s *Database) AddReplyToComment(input *model.CommentInput) (*model.AddResult, error) {
comment := model.CommentFromInput(input) comment := model.CommentFromInput(input)
if input.ParentPostID == nil { // multiple operations performed in one transaction
// multiple operations performed in one transaction err := s.db.Transaction(func(tx *gorm.DB) error {
err := s.db.Transaction(func(tx *gorm.DB) error { // insert comment
var parent model.Comment
// find parent comment in db
err := s.db.First(&parent, *input.ParentCommentID).Error
if err != nil {
return err
}
// set new comment fields to reference parent post and comment
comment.ReplyTo = &parent
comment.PostID = parent.PostID
// insert comment
err = s.db.Create(comment).Error
if err != nil {
return err
}
// add new reply to parent and save
parent.Replies = append(parent.Replies, comment)
err = s.db.Save(parent).Error
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
} else {
comment.PostID = *input.ParentPostID
err := s.db.Create(comment).Error err := s.db.Create(comment).Error
if err != nil { if err != nil {
return nil, err return err
} }
} // add new reply to parent
return &model.AddResult{ItemID: &comment.ID}, nil err = s.db.Table("comment_replies").Create(map[string]interface{}{
"comment_id": *input.ParentCommentID,
"reply_id": comment.ID,
}).Error
if err != nil {
return err
}
return nil
})
return &model.AddResult{ItemID: &comment.ID}, err
} }
func (s Database) GetPosts() ([]*model.Post, error) { func (s *Database) AddCommentToPost(input *model.CommentInput) (*model.AddResult, error) {
var posts []*model.Post comment := model.CommentFromInput(input)
err := s.db.Find(&posts).Error var allowComments bool
return posts, err err := s.db.Table("posts").Select("allow_comments").Where("id = ?", *input.ParentPostID).Scan(&allowComments).Error
if err != nil {
return nil, err
}
if !allowComments {
return nil, fmt.Errorf("author disabled comments for this post")
}
err = s.db.Create(comment).Error
return &model.AddResult{ItemID: &comment.ID}, err
}
func (s *Database) GetPost(id uint, ctx context.Context) (*model.Post, error) {
log.Println(graphql.CollectAllFields(ctx))
log.Println(graphql.CollectFieldsCtx(ctx, nil))
var post model.Post
err := s.db.Find(&post, id).Error
return &post, err
}
func (s *Database) GetComment(id uint, ctx context.Context) (*model.Comment, error) {
var comment model.Comment
err := s.db.Find(&comment, id).Error
return &comment, err
}
func (s *Database) GetPosts(first uint, after uint, ctx context.Context) (*model.PostsConnection, error) {
return nil, nil
}
func (s *Database) GetComments(post *model.Post, first uint, after uint, ctx context.Context) (*model.CommentsConnection, error) {
return nil, nil
}
func (s *Database) GetReplies(comment *model.Comment, first uint, after uint, ctx context.Context) (*model.CommentsConnection, error) {
return nil, nil
} }

View file

@ -8,6 +8,7 @@ import (
"git.obamna.ru/erius/ozon-task/graph/model" "git.obamna.ru/erius/ozon-task/graph/model"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
var ( var (
@ -22,7 +23,10 @@ var (
func InitPostgres() (*Database, error) { func InitPostgres() (*Database, error) {
log.Printf("connecting to PostgreSQL database at %s...", con) log.Printf("connecting to PostgreSQL database at %s...", con)
// PrepareStmt is true for caching complex sql statements when adding comments or replies // PrepareStmt is true for caching complex sql statements when adding comments or replies
db, err := gorm.Open(postgres.Open(con), &gorm.Config{PrepareStmt: true}) db, err := gorm.Open(postgres.Open(con), &gorm.Config{
PrepareStmt: true,
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil { if err != nil {
log.Printf("failed to connect to database: %s", err) log.Printf("failed to connect to database: %s", err)
return nil, err return nil, err

View file

@ -1,6 +1,9 @@
package storage package storage
import ( import (
"context"
"fmt"
"git.obamna.ru/erius/ozon-task/graph/model" "git.obamna.ru/erius/ozon-task/graph/model"
) )
@ -22,20 +25,142 @@ func InitInMemory() *InMemory {
func (s *InMemory) AddPost(input *model.PostInput) (*model.AddResult, error) { func (s *InMemory) AddPost(input *model.PostInput) (*model.AddResult, error) {
post := model.PostFromInput(input) post := model.PostFromInput(input)
post.ID = s.postId s.insertPost(post)
s.posts = append(s.posts, post)
s.postId++
return &model.AddResult{ItemID: &post.ID}, nil return &model.AddResult{ItemID: &post.ID}, nil
} }
func (s *InMemory) AddComment(input *model.CommentInput) (*model.AddResult, error) { func (s *InMemory) AddReplyToComment(input *model.CommentInput) (*model.AddResult, error) {
comment := model.CommentFromInput(input) if !s.commentExists(*input.ParentCommentID) {
comment.ID = s.commentId return nil, &IDNotFoundError{objName: "comment", id: *input.ParentCommentID}
s.comments = append(s.comments, comment) }
s.commentId++ comment, parent := model.CommentFromInput(input), s.comments[*input.ParentCommentID]
s.insertComment(comment)
parent.Replies = append(parent.Replies, comment)
return &model.AddResult{ItemID: &comment.ID}, nil return &model.AddResult{ItemID: &comment.ID}, nil
} }
func (s InMemory) GetPosts() ([]*model.Post, error) { func (s *InMemory) AddCommentToPost(input *model.CommentInput) (*model.AddResult, error) {
return s.posts, nil if !s.postExists(*input.ParentPostID) {
return nil, &IDNotFoundError{objName: "post", id: *input.ParentPostID}
}
parent := s.posts[*input.ParentPostID]
if !parent.AllowComments {
return nil, fmt.Errorf("author disabled comments for this post")
}
comment := model.CommentFromInput(input)
s.insertComment(comment)
parent.Comments = append(parent.Comments, comment)
return &model.AddResult{ItemID: &comment.ID}, nil
}
func (s *InMemory) GetPost(id uint, ctx context.Context) (*model.Post, error) {
if !s.postExists(id) {
return nil, &IDNotFoundError{objName: "post", id: id}
}
return s.posts[id], nil
}
func (s *InMemory) GetComment(id uint, ctx context.Context) (*model.Comment, error) {
if !s.commentExists(id) {
return nil, &IDNotFoundError{objName: "comment", id: id}
}
return s.comments[id], nil
}
func (s *InMemory) GetPosts(first uint, after uint, ctx context.Context) (*model.PostsConnection, error) {
if !s.postExists(after) {
return nil, &IDNotFoundError{objName: "post", id: after}
}
nextPage, until := true, after+first
if !s.postExists(until) {
nextPage = false
until = uint(len(s.posts))
}
info, edges := &model.PageInfo{
StartCursor: after,
EndCursor: until - 1,
HasNextPage: nextPage,
}, make([]*model.PostsEdge, until-after)
for i, p := range s.posts[after:until] {
edges[i] = &model.PostsEdge{
Cursor: p.ID,
Node: p,
}
}
return &model.PostsConnection{
Edges: edges,
PageInfo: info,
}, nil
}
func (s *InMemory) GetComments(post *model.Post, first uint, after uint, ctx context.Context) (*model.CommentsConnection, error) {
if !s.commentExists(after) {
return nil, &IDNotFoundError{objName: "comment", id: after}
}
nextPage, until := true, after+first
if !s.commentExists(until) {
nextPage = false
until = uint(len(s.comments))
}
info, edges := &model.PageInfo{
StartCursor: after,
EndCursor: until - 1,
HasNextPage: nextPage,
}, make([]*model.CommentsEdge, until-after)
for i, c := range post.Comments[after:until] {
edges[i] = &model.CommentsEdge{
Cursor: c.ID,
Node: c,
}
}
return &model.CommentsConnection{
Edges: edges,
PageInfo: info,
}, nil
}
func (s *InMemory) GetReplies(comment *model.Comment, first uint, after uint, ctx context.Context) (*model.CommentsConnection, error) {
if !s.commentExists(after) {
return nil, &IDNotFoundError{objName: "comment", id: after}
}
nextPage, until := true, after+first
if !s.commentExists(until) {
nextPage = false
until = uint(len(s.comments))
}
info, edges := &model.PageInfo{
StartCursor: after,
EndCursor: until - 1,
HasNextPage: nextPage,
}, make([]*model.CommentsEdge, until-after)
for i, c := range comment.Replies[after:until] {
edges[i] = &model.CommentsEdge{
Cursor: c.ID,
Node: c,
}
}
return &model.CommentsConnection{
Edges: edges,
PageInfo: info,
}, nil
}
func (s *InMemory) postExists(id uint) bool {
return id < uint(len(s.posts))
}
func (s *InMemory) commentExists(id uint) bool {
return id < uint(len(s.comments))
}
func (s *InMemory) insertComment(comment *model.Comment) {
comment.ID = s.commentId
s.comments = append(s.comments, comment)
s.commentId++
}
func (s *InMemory) insertPost(post *model.Post) {
post.ID = s.postId
s.posts = append(s.posts, post)
s.postId++
} }

View file

@ -1,6 +1,8 @@
package storage package storage
import ( import (
"context"
"fmt"
"log" "log"
"os" "os"
@ -8,20 +10,47 @@ import (
"git.obamna.ru/erius/ozon-task/internal/storage/db" "git.obamna.ru/erius/ozon-task/internal/storage/db"
) )
type IDNotFoundError struct {
objName string
id uint
}
func (e *IDNotFoundError) Error() string {
return fmt.Sprintf("%s with id %d doesn't exist", e.objName, e.id)
}
// storage types enum
const ( const (
inMemory = "inmemory" inMemory = "inmemory"
postgres = "postgres" postgres = "postgres"
) )
var storage = os.Getenv("APP_STORAGE") var storage, storageSpecified = os.LookupEnv("APP_STORAGE")
type Storage interface { type Storage interface {
AddPost(input *model.PostInput) (*model.AddResult, error) AddPost(input *model.PostInput) (*model.AddResult, error)
AddComment(input *model.CommentInput) (*model.AddResult, error)
GetPosts() ([]*model.Post, error) // assumes that input.ParentCommentID is not nil
AddReplyToComment(input *model.CommentInput) (*model.AddResult, error)
// assumes that input.ParentPostID is not nil
AddCommentToPost(input *model.CommentInput) (*model.AddResult, error)
// passing query context to analyze requested fields and prevent overfetching
GetPost(id uint, ctx context.Context) (*model.Post, error)
GetComment(id uint, ctx context.Context) (*model.Comment, error)
// returns paginated data in the form of model.*Connection (passing context to prevent overfetching)
GetPosts(first uint, after uint, ctx context.Context) (*model.PostsConnection, error)
GetComments(post *model.Post, first uint, after uint, ctx context.Context) (*model.CommentsConnection, error)
GetReplies(comment *model.Comment, first uint, after uint, ctx context.Context) (*model.CommentsConnection, error)
} }
func InitStorage() (Storage, error) { func InitStorage() (Storage, error) {
if !storageSpecified {
log.Println("APP_STORAGE isn't specified, falling back to default in-memory storage", storage)
return InitInMemory(), nil
}
log.Printf("initializing storage of type %s...", storage) log.Printf("initializing storage of type %s...", storage)
switch storage { switch storage {
case inMemory: case inMemory:
@ -29,7 +58,6 @@ func InitStorage() (Storage, error) {
case postgres: case postgres:
return db.InitPostgres() return db.InitPostgres()
default: default:
log.Printf("storage of type %s doesn't exists, falling back to default in-memory storage", storage) return nil, fmt.Errorf("storage of type %s doesn't exists, change the value of APP_STORAGE env variable", storage)
return InitInMemory(), nil
} }
} }