learning category repo
This commit is contained in:
103
internal/common/xslices/lru.go
Normal file
103
internal/common/xslices/lru.go
Normal 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
|
||||
}
|
||||
23
internal/common/xslices/lru_test.go
Normal file
23
internal/common/xslices/lru_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
internal/kurious/adapters/adapters.go
Normal file
9
internal/kurious/adapters/adapters.go
Normal 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()
|
||||
}
|
||||
60
internal/kurious/adapters/sqlite_basetest.go
Normal file
60
internal/kurious/adapters/sqlite_basetest.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
111
internal/kurious/adapters/sqlite_learning_category_repository.go
Normal file
111
internal/kurious/adapters/sqlite_learning_category_repository.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
34
internal/kurious/adapters/sqlite_organization.go
Normal file
34
internal/kurious/adapters/sqlite_organization.go
Normal 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),
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
9
internal/kurious/domain/category.go
Normal file
9
internal/kurious/domain/category.go
Normal 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
|
||||
}
|
||||
12
internal/kurious/domain/error.go
Normal file
12
internal/kurious/domain/error.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
19
migrations/sqlite/002_learning_and_organization.sql
Normal file
19
migrations/sqlite/002_learning_and_organization.sql
Normal 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);
|
||||
Reference in New Issue
Block a user