15/03/2021 - GO, MONGODB
In this example we are going to work with "tags" and "posts" in "blog" domain. You can normally store tags in the post document as an array field. However, this would quickly lead to repetition of the tag data. In order to avoid this, you can store tag references inside the post document as an array field. Given the number of tags per post is small with limited growth, storing the tag reference inside the post document is feasible. It is a classic 1-n relationship. See example below.
use blog
db.createCollection("tags")
db.tags.createIndex({"uuid":1},{unique:true,name:"UQ_uuid"})
db.tags.createIndex({"name":1},{unique:true,name:"UQ_name"})
db.createCollection("posts")
db.posts.createIndex({"uuid":1},{unique:true,name:"UQ_uuid"})
[
{
"_id": {
"$oid": "604f463ed42b77e01e7f03db"
},
"uuid": "07131782-0090-40cf-b530-68e774669826",
"name": "tech",
"description": "Technology related stuff"
},
{
"_id": {
"$oid": "604f463ed42b77e01e7f03dc"
},
"uuid": "a1e57bc6-de13-432d-ad22-7a3f5ba07b80",
"name": "sport",
"description": "Any kind of sport"
},
{
"_id": {
"$oid": "604f463ed42b77e01e7f03dd"
},
"uuid": "4f489d03-e4c9-444a-ba09-076d0584c96e",
"name": "travel",
"description": "Travel or holiday related stuff"
}
]
[
{
"_id": {
"$oid": "604f4831601401a8de9dfe1a"
},
"uuid": "acda18b2-f5c9-4736-8201-d1dc68c59354",
"subject": "Tech in football",
"text": "VAR ruined football so far!",
"created_at": {
"$date": "2021-03-15T11:42:41.171Z"
},
"tags": [
"07131782-0090-40cf-b530-68e774669826",
"a1e57bc6-de13-432d-ad22-7a3f5ba07b80"
]
},
{
"_id": {
"$oid": "604f4891601401a8de9dfe1b"
},
"uuid": "368f4fba-b1d1-4c58-9682-74ceedd276e9",
"subject": "Covid on holiday",
"text": "Do not take your Covid on holiday with you :)",
"created_at": {
"$date": "2021-03-15T11:44:17.613Z"
},
"tags": [
"4f489d03-e4c9-444a-ba09-076d0584c96e"
]
},
{
"_id": {
"$oid": "604f494e601401a8de9dfe1c"
},
"uuid": "ba87a063-7379-4b13-b7a5-d986f2bed43c",
"subject": "Pasta or not to pasta?",
"text": "Let's talk about what pasta is easiest to make.",
"created_at": {
"$date": "2021-03-15T11:47:26.607Z"
},
"tags": null
}
]
package storage
import (
"context"
"time"
)
type PostStorer interface {
Insert(ctx context.Context, post PostWrite) error
Find(ctx context.Context, uuid string) (PostRead, error)
}
type PostWrite struct {
UUID string `bson:"uuid"`
Subject string `bson:"subject"`
Text string `bson:"text"`
CreatedAt time.Time `bson:"created_at"`
TagUUIDs []string `bson:"tags"`
}
type PostRead struct {
ID string `bson:"_id"`
UUID string `bson:"uuid"`
Subject string `bson:"subject"`
Text string `bson:"text"`
CreatedAt time.Time `bson:"created_at"`
Tags []Tag `bson:"tags"`
}
type Tag struct {
ID string `bson:"_id"`
UUID string `bson:"uuid"`
Name string `bson:"name"`
Description string `bson:"description"`
}
package mongodb
import (
"context"
"log"
"time"
"github.com/you/mongo/internal/pkg/domain"
"github.com/you/mongo/internal/pkg/storage"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
var _ storage.PostStorer = PostStorage{}
type PostStorage struct {
Database *mongo.Database
Timeout time.Duration
}
func (p PostStorage) Insert(ctx context.Context, post storage.PostWrite) error {
ctx, cancel := context.WithTimeout(ctx, p.Timeout)
defer cancel()
if _, err := p.Database.Collection("posts").InsertOne(ctx, post); err != nil {
log.Println(err)
if er, ok := err.(mongo.WriteException); ok && er.WriteErrors[0].Code == 11000 {
return domain.ErrConflict
}
return domain.ErrInternal
}
return nil
}
func (p PostStorage) Find(ctx context.Context, uuid string) (storage.PostRead, error) {
ctx, cancel := context.WithTimeout(ctx, p.Timeout)
defer cancel()
// Tags are not sorted here!
// qry := []bson.M{
// {
// "$match": bson.M{
// "uuid": uuid,
// },
// },
// {
// "$lookup": bson.M{
// "from": "tags", // Child collection to join
// "localField": "tags", // Parent collection reference holding field
// "foreignField": "uuid", // Child collection reference field
// "as": "tags", // Arbitrary field name to store result set
// },
// },
// }
// Tags are asc sorted here!
qry := []bson.M{
{
"$match": bson.M{
"uuid": uuid,
},
},
{
"$lookup": bson.M{
// Define the tags collection for the join.
"from": "tags",
// Specify the variable to use in the pipeline stage.
"let": bson.M{
"tags": "$tags",
},
"pipeline": []bson.M{
// Select only the relevant tags from the tags collection.
// Otherwise all the tags are selected.
{
"$match": bson.M{
"$expr": bson.M{
"$in": []interface{}{
"$uuid",
"$$tags",
},
},
},
},
// Sort tags by their name field in asc. -1 = desc
{
"$sort": bson.M{
"name": 1,
},
},
},
// Use tags as the field name to match struct field.
"as": "tags",
},
},
}
cur, err := p.Database.Collection("posts").Aggregate(ctx, qry)
if err != nil {
log.Println(err)
return storage.PostRead{}, domain.ErrInternal
}
var pos []storage.PostRead
if err := cur.All(context.Background(), &pos); err != nil {
log.Println(err)
return storage.PostRead{}, domain.ErrInternal
}
defer cur.Close(context.Background())
if err := cur.Err(); err != nil {
log.Println(err)
return storage.PostRead{}, domain.ErrInternal
}
if len(pos) == 0 {
return storage.PostRead{}, domain.ErrNotFound
}
return pos[0], nil
}
package post
import "time"
// Request
type Create struct {
Subject string `json:"subject"`
Text string `json:"text"`
Tags []string `json:"tags"`
}
// Response
type Response struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Subject string `json:"subject"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
Tags []Tag `json:"tags"`
}
type Tag struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
}
As you can see this file is doing a lot and there isn't really a proper request validation. You should refactor it as per your needs.
package post
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/you/mongo/internal/pkg/domain"
"github.com/you/mongo/internal/pkg/storage"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
)
type Controller struct {
Storage storage.PostStorer
}
// POST /api/v1/posts
func (c Controller) Create(w http.ResponseWriter, r *http.Request) {
var req Create
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
id := uuid.New().String()
err := c.Storage.Insert(r.Context(), storage.PostWrite{
UUID: id,
Subject: fmt.Sprintf("%s - %d", req.Subject, time.Now().UTC().Nanosecond()),
Text: fmt.Sprintf("%s - %d", req.Text, time.Now().UTC().Nanosecond()),
CreatedAt: time.Now(),
TagUUIDs: req.Tags,
})
if err != nil {
switch err {
case domain.ErrConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(id))
}
// GET /api/v1/posts/:uuid
func (c Controller) Find(w http.ResponseWriter, r *http.Request) {
id := httprouter.ParamsFromContext(r.Context()).ByName("uuid")
com, err := c.Storage.Find(r.Context(), id)
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}
res := Response{
ID: com.ID,
UUID: com.UUID,
Subject: com.Subject,
Text: com.Text,
CreatedAt: com.CreatedAt,
Tags: make([]Tag, len(com.Tags)),
}
for i, tag := range com.Tags {
res.Tags[i].ID = tag.ID
res.Tags[i].UUID = tag.UUID
res.Tags[i].Name = tag.Name
res.Tags[i].Description = tag.Description
}
body, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(body)
}
curl --request GET 'http://localhost:3000/api/v1/posts/acda18b2-f5c9-4736-8201-d1dc68c59354'
{
"id": "604f4831601401a8de9dfe1a",
"uuid": "acda18b2-f5c9-4736-8201-d1dc68c59354",
"subject": "Tech in football",
"text": "VAR ruined football so far!",
"created_at": "2021-03-15T11:42:41.171Z",
"tags": [
{
"ID": "604f463ed42b77e01e7f03dc",
"uuid": "a1e57bc6-de13-432d-ad22-7a3f5ba07b80",
"name": "sport",
"description": "Any kind of sport"
},
{
"ID": "604f463ed42b77e01e7f03db",
"uuid": "07131782-0090-40cf-b530-68e774669826",
"name": "tech",
"description": "Technology related stuff"
}
]
}
curl --request POST 'http://localhost:3000/api/v1/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"subject": "Subject 1",
"text": "Text 1",
"tags": [
"07131782-0090-40cf-b530-68e774669826"
]
}'
49329796-9f04-44d1-99fb-49128bba7a9c