learning category repo

This commit is contained in:
Aleksandr Trushkin
2024-03-16 17:44:43 +03:00
parent 938d3cd307
commit 88a3cae4fa
13 changed files with 506 additions and 45 deletions

View File

@ -0,0 +1,103 @@
package xslices
import (
"sync"
)
func NewLRU[K comparable, T any](capacity int) *lru[K, T] {
return &lru[K, T]{
items: make(map[K]*lruNode[K, T], capacity),
capacity: capacity,
}
}
type lruNode[K comparable, T any] struct {
key K
value T
next *lruNode[K, T]
prev *lruNode[K, T]
}
type lru[K comparable, T any] struct {
items map[K]*lruNode[K, T]
length int
capacity int
first *lruNode[K, T]
last *lruNode[K, T]
mu sync.RWMutex
}
func (l *lru[K, T]) Push(key K, value T) {
l.mu.Lock()
defer l.mu.Unlock()
node, ok := l.items[key]
if ok {
l.bumpUnsafe(key, node)
return
}
node = &lruNode[K, T]{
key: key,
value: value,
next: l.first,
}
if l.first != nil {
l.first.prev = node
}
if l.last == nil {
l.last = node
}
l.first = node
l.items[key] = node
if l.length == l.capacity && l.last != nil {
deletedNode := l.last
delete(l.items, deletedNode.key)
l.last = l.last.prev
return
}
l.length++
}
func (l *lru[K, T]) Get(key K) (T, bool) {
l.mu.Lock()
defer l.mu.Unlock()
node, ok := l.items[key]
if !ok {
var t T
return t, false
}
out := node.value
l.bumpUnsafe(key, node)
return out, true
}
func (l *lru[K, T]) bumpUnsafe(key K, node *lruNode[K, T]) {
if l.first == node {
return
}
if node.next != nil {
node.next.prev = node.prev
}
if node.prev != nil {
node.prev.next = node.next
}
node.next = l.first
l.first.prev = node
l.first = node
node.prev = nil
}

View File

@ -0,0 +1,23 @@
package xslices
import "testing"
func TestLRU(t *testing.T) {
lru := NewLRU[int, string](3)
for i := 0; i < 4; i++ {
lru.Push(i, "v")
}
for i := 0; i < 4; i++ {
_, found := lru.Get(i)
if i == 0 {
if found {
t.Error("expected value to be flushed out of cache")
}
continue
}
if !found {
t.Errorf("expected value %d to be found", i)
}
}
}

View File

@ -0,0 +1,9 @@
package adapters
type domainer[T any] interface {
AsDomain() T
}
func asDomainFunc[T any, U domainer[T]](in U) (out T) {
return in.AsDomain()
}

View File

@ -0,0 +1,60 @@
package adapters
import (
"context"
"log/slog"
"os"
"time"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/migrations/sqlite"
"github.com/stretchr/testify/suite"
)
type sqliteBaseSuite struct {
suite.Suite
// TODO: make baseTestSuite that provides this kind of things
ctx context.Context
cancel context.CancelFunc
log *slog.Logger
connection *sqliteConnection
}
func (s *sqliteBaseSuite) SetupSuite() {
s.ctx, s.cancel = context.WithCancel(context.Background())
s.log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelDebug,
}))
connection, err := NewSqliteConnection(s.ctx, config.Sqlite{
DSN: ":memory:",
ShutdownTimeout: time.Second * 3,
}, s.log)
s.Require().NoError(err)
s.connection = connection
db := s.connection.db
err = sqlite.RunMigrations(s.ctx, db.DB, s.log.With(slog.String("component", "migrator")))
s.Require().NoError(err)
}
func (s *sqliteBaseSuite) TearDownSuite() {
s.cancel()
err := s.connection.Close()
s.Require().NoError(err)
}
func (s *sqliteBaseSuite) TearDownTest() {
db := s.connection.db
for _, query := range []string{
"DELETE FROM learning_categories",
"DELETE FROM courses",
} {
_, err := db.ExecContext(s.ctx, query)
s.Require().NoError(err, "cleaning up database")
}
}

View File

@ -1,16 +1,12 @@
package adapters
import (
"context"
"log/slog"
"os"
"testing"
"time"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/migrations/sqlite"
"github.com/stretchr/testify/suite"
)
@ -19,46 +15,7 @@ func TestSqliteCourseRepository(t *testing.T) {
}
type sqliteCourseRepositorySuite struct {
suite.Suite
// TODO: make baseTestSuite that provides this kind of things
ctx context.Context
cancel context.CancelFunc
log *slog.Logger
connection *sqliteConnection
}
func (s *sqliteCourseRepositorySuite) SetupSuite() {
s.ctx, s.cancel = context.WithCancel(context.Background())
s.log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelDebug,
}))
connection, err := NewSqliteConnection(s.ctx, config.Sqlite{
DSN: ":memory:",
ShutdownTimeout: time.Second * 3,
}, s.log)
s.Require().NoError(err)
s.connection = connection
db := s.connection.db
err = sqlite.RunMigrations(s.ctx, db.DB, s.log.With(slog.String("component", "migrator")))
s.Require().NoError(err)
}
func (s *sqliteCourseRepositorySuite) TearDownSuite() {
s.cancel()
err := s.connection.Close()
s.Require().NoError(err)
}
func (s *sqliteCourseRepositorySuite) TearDownTest() {
db := s.connection.db
_, err := db.ExecContext(s.ctx, "DELETE FROM courses")
s.Require().NoError(err, "cleaning up database")
sqliteBaseSuite
}
func (s *sqliteCourseRepositorySuite) TestCreateCourse() {

View File

@ -0,0 +1,111 @@
package adapters
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/jmoiron/sqlx"
)
var (
learningCategoryColumns = []string{
"id",
"logo",
"courses_count",
}
learningCategoryColumnsStr = strings.Join(learningCategoryColumns, ",")
)
type learningCategoryDB struct {
ID string `db:"id"`
Logo sql.NullString `db:"logo"`
CoursesCount int `db:"courses_count"`
}
func (l learningCategoryDB) AsDomain() domain.LearningCategory {
return domain.LearningCategory{
ID: l.ID,
Logo: nullStringAsDomain(l.Logo),
CoursesCount: l.CoursesCount,
}
}
func (c *sqliteConnection) LearningCategory() *sqliteLearingCategoryRepository {
return &sqliteLearingCategoryRepository{
db: c.db,
log: c.log.With("repository", "learning_categories"),
}
}
type sqliteLearingCategoryRepository struct {
db *sqlx.DB
log *slog.Logger
}
func (r *sqliteLearingCategoryRepository) Upsert(ctx context.Context, c domain.LearningCategory) error {
const queryTemplate = "INSERT INTO learning_categories (%s)" +
" VALUES (%s)" +
" ON CONFLICT(id) DO UPDATE" +
" SET id = excluded.id" +
", logo = excluded.logo" +
", courses_count = excluded.courses_count"
query := fmt.Sprintf(
queryTemplate,
learningCategoryColumnsStr,
strings.TrimSuffix(strings.Repeat("?,", len(learningCategoryColumns)), ","),
)
_, err := r.db.ExecContext(
ctx, query,
c.ID,
nullableValueAsString(c.Logo),
c.CoursesCount,
)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
}
func (r *sqliteLearingCategoryRepository) List(ctx context.Context) (out []domain.LearningCategory, err error) {
const queryTemplate = "SELECT %s FROM learning_categories;"
query := fmt.Sprintf(queryTemplate, learningCategoryColumnsStr)
var categories []learningCategoryDB
err = r.db.SelectContext(ctx, &categories, query)
if err != nil {
return nil, fmt.Errorf("executing query: %w", err)
}
out = xslices.Map(categories, asDomainFunc)
return out, nil
}
func (r *sqliteLearingCategoryRepository) Get(ctx context.Context, id string) (domain.LearningCategory, error) {
const queryTemplate = "SELECT %s FROM learning_categories WHERE id = ?;"
query := fmt.Sprintf(queryTemplate, learningCategoryColumnsStr)
var cdb learningCategoryDB
err := r.db.GetContext(ctx, &cdb, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.LearningCategory{}, domain.ErrNotFound
}
return domain.LearningCategory{}, fmt.Errorf("executing query: %w", err)
}
return cdb.AsDomain(), nil
}

View File

@ -0,0 +1,100 @@
package adapters
import (
"testing"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/stretchr/testify/suite"
)
func TestSqliteLearningCategories(t *testing.T) {
suite.Run(t, new(sqliteLearningCategoriesRepositorySuite))
}
type sqliteLearningCategoriesRepositorySuite struct {
sqliteBaseSuite
}
func (s *sqliteLearningCategoriesRepositorySuite) TestGet() {
var cdb learningCategoryDB
err := s.connection.db.GetContext(
s.ctx, &cdb,
"INSERT INTO learning_categories (id, logo, courses_count) VALUES (?,?,?) RETURNING *",
"test-id", "test-url-logo", 42,
)
s.Require().NoError(err)
expectedCategory := cdb.AsDomain()
lr := s.connection.LearningCategory()
got, err := lr.Get(s.ctx, expectedCategory.ID)
s.NoError(err)
s.Equal(expectedCategory, got)
}
func (s *sqliteLearningCategoriesRepositorySuite) TestList() {
stmt, err := s.connection.db.PrepareContext(s.ctx, "INSERT INTO learning_categories (id, logo, courses_count) VALUES (?,?,?)")
s.NoError(err)
for _, args := range [][]any{
{"test-id-1", "test-url-1", 1},
{"test-id-2", "test-url-2", 2},
} {
_, err = stmt.ExecContext(s.ctx, args...)
s.NoError(err)
}
gotCategories, err := s.connection.LearningCategory().List(s.ctx)
s.NoError(err)
s.Len(gotCategories, 2)
expCategories := []domain.LearningCategory{
{
ID: "test-id-1",
Logo: nullable.NewValue("test-url-1"),
CoursesCount: 1,
},
{
ID: "test-id-2",
Logo: nullable.NewValue("test-url-2"),
CoursesCount: 2,
},
}
if gotCategories[0].ID != "test-id-1" {
gotCategories[0], gotCategories[1] = gotCategories[1], gotCategories[0]
}
s.ElementsMatch(expCategories, gotCategories)
}
func (s *sqliteLearningCategoriesRepositorySuite) TestUpsert() {
const categoryID = "test-id-1"
repo := s.connection.LearningCategory()
gotCategory, err := repo.Get(s.ctx, categoryID)
s.ErrorIs(err, domain.ErrNotFound)
createdCategory := domain.LearningCategory{
ID: categoryID,
Logo: nullable.NewValue("test-url-1"),
CoursesCount: 1,
}
err = repo.Upsert(s.ctx, createdCategory)
s.NoError(err)
gotCategory, err = repo.Get(s.ctx, categoryID)
s.NoError(err)
s.Equal(createdCategory, gotCategory)
createdCategory.Logo = nullable.NewValue("test-url-2")
err = repo.Upsert(s.ctx, createdCategory)
s.NoError(err)
gotCategory, err = repo.Get(s.ctx, categoryID)
s.NoError(err)
s.Equal(createdCategory, gotCategory)
}

View File

@ -0,0 +1,34 @@
package adapters
import (
"database/sql"
"time"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type organizationDB struct {
ID string `db:"id"`
ExternalID sql.NullString `db:"external_id"`
Alias string `db:"alias"`
Name string `db:"name"`
Site string `db:"site"`
Logo string `db:"logo"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt sql.NullTime `db:"deleted_at"`
}
func (o *organizationDB) AsDomain() domain.Organization {
return domain.Organization{
ID: o.ID,
ExternalID: nullStringAsDomain(o.ExternalID),
Alias: o.Alias,
Name: o.Name,
Site: o.Site,
LogoLink: o.Logo,
CreatedAt: o.CreatedAt,
UpdatedAt: o.UpdatedAt,
DeletedAt: nullTimeAsDomain(o.DeletedAt),
}
}

View File

@ -109,6 +109,10 @@ func (conn *YDBConnection) Close() error {
return conn.Driver.Close(ctx)
}
func (conn *YDBConnection) LearningCategory() domain.LearningCategoryRepository {
return domain.NotImplementedLearningCategory{}
}
func (conn *YDBConnection) CourseRepository() *ydbCourseRepository {
return &ydbCourseRepository{
db: conn.Driver,

View File

@ -0,0 +1,9 @@
package domain
import "git.loyso.art/frx/kurious/internal/common/nullable"
type LearningCategory struct {
ID string
Logo nullable.Value[string]
CoursesCount int
}

View File

@ -0,0 +1,12 @@
package domain
const (
ErrNotFound PlainError = "not found"
ErrNotImplemented PlainError = "not implemented"
)
type PlainError string
func (err PlainError) Error() string {
return string(err)
}

View File

@ -92,3 +92,23 @@ type OrganizationRepository interface {
Create(context.Context, CreateOrganizationParams) (Organization, error)
Delete(ctx context.Context, id string) error
}
//go:generate mockery --name LearningCategoryRepository
type LearningCategoryRepository interface {
Upsert(context.Context, LearningCategory) error
List(context.Context) ([]LearningCategory, error)
Get(context.Context, string) (LearningCategory, error)
}
type NotImplementedLearningCategory struct{}
func (NotImplementedLearningCategory) Upsert(context.Context, LearningCategory) error {
return ErrNotImplemented
}
func (NotImplementedLearningCategory) List(context.Context) ([]LearningCategory, error) {
return nil, ErrNotImplemented
}
func (NotImplementedLearningCategory) Get(context.Context, string) (LearningCategory, error) {
return LearningCategory{}, ErrNotImplemented
}

View File

@ -0,0 +1,19 @@
CREATE TABLE learning_categories (
id TEXT PRIMARY KEY,
logo TEXT NULL,
courses_count INT NOT NULL DEFAULT 0
);
create table organization (
id TEXT PRIMARY KEY,
external_id TEXT NULL,
alias TEXT NOT NULL,
name TEXT NOT NULL,
site TEXT NOT NULL,
logo TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE INDEX idx_organization_external_id ON organization (external_id);