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:
Egor 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
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
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
interval: 1s
timeout: 5s
retries: 5
volumes:

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -2,8 +2,34 @@
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 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 {
}

View file

@ -12,31 +12,26 @@ import (
)
// AddPost is the resolver for the add_post field.
func (r *mutationResolver) AddPost(ctx context.Context, input *model.PostInput) (*model.AddResult, error) {
return r.Storage.AddPost(input)
func (r *mutationResolver) AddPost(ctx context.Context, input model.PostInput) (*model.AddResult, error) {
return r.Storage.AddPost(&input)
}
// 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 {
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 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.
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 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
import (
"context"
"fmt"
"log"
"git.obamna.ru/erius/ozon-task/graph/model"
"github.com/99designs/gqlgen/graphql"
"gorm.io/gorm"
)
@ -15,48 +20,64 @@ func (s *Database) AddPost(input *model.PostInput) (*model.AddResult, error) {
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)
if input.ParentPostID == nil {
// multiple operations performed in one transaction
err := s.db.Transaction(func(tx *gorm.DB) error {
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
// multiple operations performed in one transaction
err := s.db.Transaction(func(tx *gorm.DB) error {
// insert comment
err := s.db.Create(comment).Error
if err != nil {
return nil, err
return err
}
}
return &model.AddResult{ItemID: &comment.ID}, nil
// add new reply to parent
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) {
var posts []*model.Post
err := s.db.Find(&posts).Error
return posts, err
func (s *Database) AddCommentToPost(input *model.CommentInput) (*model.AddResult, error) {
comment := model.CommentFromInput(input)
var allowComments bool
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"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
@ -22,7 +23,10 @@ var (
func InitPostgres() (*Database, error) {
log.Printf("connecting to PostgreSQL database at %s...", con)
// 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 {
log.Printf("failed to connect to database: %s", err)
return nil, err

View file

@ -1,6 +1,9 @@
package storage
import (
"context"
"fmt"
"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) {
post := model.PostFromInput(input)
post.ID = s.postId
s.posts = append(s.posts, post)
s.postId++
s.insertPost(post)
return &model.AddResult{ItemID: &post.ID}, nil
}
func (s *InMemory) AddComment(input *model.CommentInput) (*model.AddResult, error) {
comment := model.CommentFromInput(input)
comment.ID = s.commentId
s.comments = append(s.comments, comment)
s.commentId++
func (s *InMemory) AddReplyToComment(input *model.CommentInput) (*model.AddResult, error) {
if !s.commentExists(*input.ParentCommentID) {
return nil, &IDNotFoundError{objName: "comment", id: *input.ParentCommentID}
}
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
}
func (s InMemory) GetPosts() ([]*model.Post, error) {
return s.posts, nil
func (s *InMemory) AddCommentToPost(input *model.CommentInput) (*model.AddResult, error) {
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
import (
"context"
"fmt"
"log"
"os"
@ -8,20 +10,47 @@ import (
"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 (
inMemory = "inmemory"
postgres = "postgres"
)
var storage = os.Getenv("APP_STORAGE")
var storage, storageSpecified = os.LookupEnv("APP_STORAGE")
type Storage interface {
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) {
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)
switch storage {
case inMemory:
@ -29,7 +58,6 @@ func InitStorage() (Storage, error) {
case postgres:
return db.InitPostgres()
default:
log.Printf("storage of type %s doesn't exists, falling back to default in-memory storage", storage)
return InitInMemory(), nil
return nil, fmt.Errorf("storage of type %s doesn't exists, change the value of APP_STORAGE env variable", storage)
}
}