[hw-8] add: repo layer

This commit is contained in:
3ybactuk
2025-07-25 23:04:31 +03:00
committed by 3ybacTuK
parent c1e8934646
commit 6420eaf3d7
25 changed files with 4194 additions and 6 deletions

View File

@@ -1,12 +1,41 @@
BINDIR=${CURDIR}/bin
BINDIR=${CURDIR}/../bin
PACKAGE=route256/comments
MIGRATIONS_FOLDER := ./db/migrations/
LOCAL_DB_NAME := route256
LOCAL_DB_DSN := postgresql://user:password@localhost:5433/route256?sslmode=disable
PROD_USER := loms-user
PROD_PASS := loms-password
PROD_DB := postgres-master
PROD_MIGRATIONS := ./comments/db/migrations/
build: bindir
echo "build comments"
go build -o ${BINDIR}/comments cmd/server/main.go
bindir:
mkdir -p ${BINDIR}
build: bindir
echo "build comments"
# Used for CI
run-migrations:
echo "run migrations"
$(GOOSE) -dir $(PROD_MIGRATIONS) postgres "postgresql://$(PROD_USER):$(PROD_PASS)@$(PROD_DB):5432/comments_db?sslmode=disable" up
db-create-migration:
$(BINDIR)/goose -dir $(MIGRATIONS_FOLDER) create -s $(n) sql
db-migrate:
$(BINDIR)/goose -dir $(MIGRATIONS_FOLDER) postgres "$(LOCAL_DB_DSN)" up
db-migrate-down:
$(BINDIR)/goose -dir $(MIGRATIONS_FOLDER) postgres "$(LOCAL_DB_DSN)" down
db-reset-local:
psql -c "drop database if exists \"$(LOCAL_DB_NAME)\""
psql -c "create database \"$(LOCAL_DB_NAME)\""
make db-migrate
.PHONY: generate-sqlc
generate-sqlc:
$(BINDIR)/sqlc generate

View File

@@ -0,0 +1,10 @@
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
sku BIGINT NOT NULL,
text VARCHAR(255) NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT now()
);
CREATE INDEX ON comments(sku, created_at DESC, user_id ASC);
CREATE INDEX ON comments(user_id, created_at DESC);

View File

@@ -1,3 +1,16 @@
module route256/comments
go 1.23.1
require github.com/go-playground/validator/v10 v10.27.0
require (
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

28
comments/go.sum Normal file
View File

@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,87 @@
package postgres
// From https://gitlab.ozon.dev/go/classroom-18/students/week-4-workshop/-/blob/master/internal/infra/postgres/tx.go
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/opentracing/opentracing-go"
)
// Tx транзакция.
type Tx pgx.Tx
type txKey struct{}
func ctxWithTx(ctx context.Context, tx pgx.Tx) context.Context {
return context.WithValue(ctx, txKey{}, tx)
}
func TxFromCtx(ctx context.Context) (pgx.Tx, bool) {
tx, ok := ctx.Value(txKey{}).(pgx.Tx)
return tx, ok
}
type TxManager struct {
write *pgxpool.Pool
read *pgxpool.Pool
}
func NewTxManager(write, read *pgxpool.Pool) *TxManager {
return &TxManager{
write: write,
read: read,
}
}
// WithTransaction выполняет fn в транзакции с дефолтным уровнем изоляции.
func (m *TxManager) WriteWithTransaction(ctx context.Context, fn func(ctx context.Context) error) (err error) {
return m.withTx(ctx, m.write, pgx.TxOptions{}, fn)
}
func (m *TxManager) ReadWithTransaction(ctx context.Context, fn func(ctx context.Context) error) (err error) {
return m.withTx(ctx, m.read, pgx.TxOptions{}, fn)
}
// WithTransaction выполняет fn в транзакции с уровнем изоляции RepeatableRead.
func (m *TxManager) WriteWithRepeatableRead(ctx context.Context, fn func(ctx context.Context) error) (err error) {
return m.withTx(ctx, m.write, pgx.TxOptions{IsoLevel: pgx.RepeatableRead}, fn)
}
func (m *TxManager) ReadWithRepeatableRead(ctx context.Context, fn func(ctx context.Context) error) (err error) {
return m.withTx(ctx, m.read, pgx.TxOptions{IsoLevel: pgx.RepeatableRead}, fn)
}
// WithTx выполняет fn в транзакции.
func (m *TxManager) withTx(ctx context.Context, pool *pgxpool.Pool, options pgx.TxOptions, fn func(ctx context.Context) error) (err error) {
var span opentracing.Span
span, ctx = opentracing.StartSpanFromContext(ctx, "Transaction")
defer span.Finish()
tx, err := pool.BeginTx(ctx, options)
if err != nil {
return
}
ctx = ctxWithTx(ctx, tx)
defer func() {
if p := recover(); p != nil {
// a panic occurred, rollback and repanic
_ = tx.Rollback(ctx)
panic(p)
} else if err != nil {
// something went wrong, rollback
_ = tx.Rollback(ctx)
} else {
// all good, commit
err = tx.Commit(ctx)
}
}()
err = fn(ctx)
return
}

View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package sqlc
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,17 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package sqlc
import (
"github.com/jackc/pgx/v5/pgtype"
)
type Comment struct {
ID int64
UserID int64
Sku int64
Text string
CreatedAt pgtype.Timestamp
}

View File

@@ -0,0 +1,19 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package sqlc
import (
"context"
)
type Querier interface {
GetCommentByID(ctx context.Context, id int64) (*Comment, error)
InsertComment(ctx context.Context, arg *InsertCommentParams) (*Comment, error)
ListCommentsBySku(ctx context.Context, sku int64) ([]*Comment, error)
ListCommentsByUser(ctx context.Context, userID int64) ([]*Comment, error)
UpdateComment(ctx context.Context, arg *UpdateCommentParams) (*Comment, error)
}
var _ Querier = (*Queries)(nil)

View File

@@ -0,0 +1,24 @@
-- name: InsertComment :one
INSERT INTO comments (user_id, sku, text) VALUES ($1, $2, $3)
RETURNING id, user_id, sku, text, created_at;
-- name: GetCommentByID :one
SELECT id, user_id, sku, text, created_at FROM comments WHERE id = $1;
-- name: UpdateComment :one
UPDATE comments
SET text = $2
WHERE id = $1
RETURNING id, user_id, sku, text, created_at;
-- name: ListCommentsBySku :many
SELECT id, user_id, sku, text, created_at
FROM comments
WHERE sku = $1
ORDER BY created_at DESC, user_id ASC;
-- name: ListCommentsByUser :many
SELECT id, user_id, sku, text, created_at
FROM comments
WHERE user_id = $1
ORDER BY created_at DESC;

View File

@@ -0,0 +1,142 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: query.sql
package sqlc
import (
"context"
)
const getCommentByID = `-- name: GetCommentByID :one
SELECT id, user_id, sku, text, created_at FROM comments WHERE id = $1
`
func (q *Queries) GetCommentByID(ctx context.Context, id int64) (*Comment, error) {
row := q.db.QueryRow(ctx, getCommentByID, id)
var i Comment
err := row.Scan(
&i.ID,
&i.UserID,
&i.Sku,
&i.Text,
&i.CreatedAt,
)
return &i, err
}
const insertComment = `-- name: InsertComment :one
INSERT INTO comments (user_id, sku, text) VALUES ($1, $2, $3)
RETURNING id, user_id, sku, text, created_at
`
type InsertCommentParams struct {
UserID int64
Sku int64
Text string
}
func (q *Queries) InsertComment(ctx context.Context, arg *InsertCommentParams) (*Comment, error) {
row := q.db.QueryRow(ctx, insertComment, arg.UserID, arg.Sku, arg.Text)
var i Comment
err := row.Scan(
&i.ID,
&i.UserID,
&i.Sku,
&i.Text,
&i.CreatedAt,
)
return &i, err
}
const listCommentsBySku = `-- name: ListCommentsBySku :many
SELECT id, user_id, sku, text, created_at
FROM comments
WHERE sku = $1
ORDER BY created_at DESC, user_id ASC
`
func (q *Queries) ListCommentsBySku(ctx context.Context, sku int64) ([]*Comment, error) {
rows, err := q.db.Query(ctx, listCommentsBySku, sku)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Comment
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Sku,
&i.Text,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsByUser = `-- name: ListCommentsByUser :many
SELECT id, user_id, sku, text, created_at
FROM comments
WHERE user_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListCommentsByUser(ctx context.Context, userID int64) ([]*Comment, error) {
rows, err := q.db.Query(ctx, listCommentsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Comment
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Sku,
&i.Text,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateComment = `-- name: UpdateComment :one
UPDATE comments
SET text = $2
WHERE id = $1
RETURNING id, user_id, sku, text, created_at
`
type UpdateCommentParams struct {
ID int64
Text string
}
func (q *Queries) UpdateComment(ctx context.Context, arg *UpdateCommentParams) (*Comment, error) {
row := q.db.QueryRow(ctx, updateComment, arg.ID, arg.Text)
var i Comment
err := row.Scan(
&i.ID,
&i.UserID,
&i.Sku,
&i.Text,
&i.CreatedAt,
)
return &i, err
}

View File

@@ -0,0 +1,148 @@
package sqlc
import (
"context"
"errors"
"sort"
"route256/comments/internal/domain/entity"
"route256/comments/internal/domain/model"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
type commentsRepo struct {
shard1 *pgxpool.Pool
shard2 *pgxpool.Pool
}
func NewCommentsRepository(shard1, shard2 *pgxpool.Pool) *commentsRepo {
return &commentsRepo{
shard1: shard1,
shard2: shard2,
}
}
func (r *commentsRepo) pickShard(sku int64) *pgxpool.Pool {
if sku%2 == 0 {
return r.shard1
}
return r.shard2
}
func (r *commentsRepo) GetCommentByID(ctx context.Context, id int64) (*Comment, error) {
q1 := New(r.shard1)
c, err := q1.GetCommentByID(ctx, id)
switch {
case err == nil:
return c, nil
case errors.Is(err, pgx.ErrNoRows):
log.Trace().Msgf("comment with id %d not found in shard 1", id)
default:
return nil, err
}
q2 := New(r.shard2)
c2, err2 := q2.GetCommentByID(ctx, id)
switch {
case err2 == nil:
return c2, nil
case errors.Is(err2, pgx.ErrNoRows):
return nil, model.ErrCommentNotFound
default:
return nil, err2
}
}
func (r *commentsRepo) InsertComment(ctx context.Context, comment *entity.Comment) (*Comment, error) {
shard := r.pickShard(comment.SKU)
q := New(shard)
req := &InsertCommentParams{
UserID: comment.UserID,
Sku: comment.SKU,
Text: comment.Text,
}
c, err := q.InsertComment(ctx, req)
if err != nil {
return nil, err
}
return c, nil
}
func (r *commentsRepo) ListCommentsBySku(ctx context.Context, sku int64) ([]*Comment, error) {
shard := r.pickShard(sku)
q := New(shard)
list, err := q.ListCommentsBySku(ctx, sku)
if err != nil {
return nil, err
}
out := make([]*Comment, len(list))
copy(out, list)
return out, nil
}
func (r *commentsRepo) ListCommentsByUser(ctx context.Context, userID int64) ([]*Comment, error) {
q1 := New(r.shard1)
l1, err1 := q1.ListCommentsByUser(ctx, userID)
if err1 != nil {
return nil, err1
}
q2 := New(r.shard2)
l2, err2 := q2.ListCommentsByUser(ctx, userID)
if err2 != nil {
return nil, err2
}
merged := make([]*Comment, 0, len(l1)+len(l2))
merged = append(merged, l1...)
merged = append(merged, l2...)
sort.Slice(merged, func(i, j int) bool {
if merged[i].CreatedAt.Time.Equal(merged[j].CreatedAt.Time) {
return merged[i].UserID < merged[j].UserID
}
return merged[i].CreatedAt.Time.After(merged[j].CreatedAt.Time)
})
return merged, nil
}
func (r *commentsRepo) UpdateComment(ctx context.Context, comment *entity.Comment) (*Comment, error) {
req := &UpdateCommentParams{
ID: comment.ID,
Text: comment.Text,
}
q1 := New(r.shard1)
c, err := q1.UpdateComment(ctx, req)
switch {
case err == nil:
return c, nil
case errors.Is(err, pgx.ErrNoRows):
log.Trace().Msgf("comment with id %d not found in shard 1", req.ID)
default:
return nil, err
}
q2 := New(r.shard2)
c2, err2 := q2.UpdateComment(ctx, req)
switch {
case err2 == nil:
return c2, nil
case errors.Is(err2, pgx.ErrNoRows):
return nil, model.ErrCommentNotFound
default:
return nil, err2
}
}

View File

@@ -0,0 +1,9 @@
package entity
type Comment struct {
ID int64
UserID int64
SKU int64
CreatedAt string
Text string
}

View File

@@ -0,0 +1,22 @@
package model
import (
"fmt"
"time"
)
type Comment struct {
ID int64 `validate:"gt=0"`
UserID int64 `validate:"gt=0"`
SKU int64 `validate:"gt=0"`
CreatedAt time.Time
Text string `validate:"lte=255,gt=0"`
}
func (c *Comment) Validate() error {
if err := validate.Struct(c); err != nil {
return fmt.Errorf("invalid requested values: %w", err)
}
return nil
}

View File

@@ -0,0 +1,5 @@
package model
import "errors"
var ErrCommentNotFound = errors.New("comment not found")

View File

@@ -0,0 +1,9 @@
package model
import "github.com/go-playground/validator/v10"
var validate *validator.Validate
func init() {
validate = validator.New()
}

View File

@@ -0,0 +1,3 @@
package service
// TODO

15
comments/sqlc.yaml Normal file
View File

@@ -0,0 +1,15 @@
version: "2"
sql:
- engine: "postgresql"
queries: "infra/repository/sqlc/query.sql"
schema: "db/migrations"
gen:
go:
package: "sqlc"
out: "infra/repository/sqlc"
sql_package: "pgx/v5"
emit_interface: true
emit_pointers_for_null_types: true
emit_result_struct_pointers: true
emit_params_struct_pointers: true
omit_unused_structs: true