From 88a3cae4fae4d85869b960aa663c8d8e1168c69a Mon Sep 17 00:00:00 2001 From: Aleksandr Trushkin Date: Sat, 16 Mar 2024 17:44:43 +0300 Subject: [PATCH] learning category repo --- internal/common/xslices/lru.go | 103 ++++++++++++++++ internal/common/xslices/lru_test.go | 23 ++++ internal/kurious/adapters/adapters.go | 9 ++ internal/kurious/adapters/sqlite_basetest.go | 60 ++++++++++ .../adapters/sqlite_course_repository_test.go | 47 +------- .../sqlite_learning_category_repository.go | 111 ++++++++++++++++++ ...qlite_learning_category_repository_test.go | 100 ++++++++++++++++ .../kurious/adapters/sqlite_organization.go | 34 ++++++ .../kurious/adapters/ydb_course_repository.go | 4 + internal/kurious/domain/category.go | 9 ++ internal/kurious/domain/error.go | 12 ++ internal/kurious/domain/repository.go | 20 ++++ .../sqlite/002_learning_and_organization.sql | 19 +++ 13 files changed, 506 insertions(+), 45 deletions(-) create mode 100644 internal/common/xslices/lru.go create mode 100644 internal/common/xslices/lru_test.go create mode 100644 internal/kurious/adapters/adapters.go create mode 100644 internal/kurious/adapters/sqlite_basetest.go create mode 100644 internal/kurious/adapters/sqlite_learning_category_repository.go create mode 100644 internal/kurious/adapters/sqlite_learning_category_repository_test.go create mode 100644 internal/kurious/adapters/sqlite_organization.go create mode 100644 internal/kurious/domain/category.go create mode 100644 internal/kurious/domain/error.go create mode 100644 migrations/sqlite/002_learning_and_organization.sql diff --git a/internal/common/xslices/lru.go b/internal/common/xslices/lru.go new file mode 100644 index 0000000..208d2c3 --- /dev/null +++ b/internal/common/xslices/lru.go @@ -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 +} diff --git a/internal/common/xslices/lru_test.go b/internal/common/xslices/lru_test.go new file mode 100644 index 0000000..f0c42bc --- /dev/null +++ b/internal/common/xslices/lru_test.go @@ -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) + } + } +} diff --git a/internal/kurious/adapters/adapters.go b/internal/kurious/adapters/adapters.go new file mode 100644 index 0000000..72dae86 --- /dev/null +++ b/internal/kurious/adapters/adapters.go @@ -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() +} diff --git a/internal/kurious/adapters/sqlite_basetest.go b/internal/kurious/adapters/sqlite_basetest.go new file mode 100644 index 0000000..6026b86 --- /dev/null +++ b/internal/kurious/adapters/sqlite_basetest.go @@ -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") + } +} diff --git a/internal/kurious/adapters/sqlite_course_repository_test.go b/internal/kurious/adapters/sqlite_course_repository_test.go index 8a0b84a..9b8b9b5 100644 --- a/internal/kurious/adapters/sqlite_course_repository_test.go +++ b/internal/kurious/adapters/sqlite_course_repository_test.go @@ -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() { diff --git a/internal/kurious/adapters/sqlite_learning_category_repository.go b/internal/kurious/adapters/sqlite_learning_category_repository.go new file mode 100644 index 0000000..9ac134b --- /dev/null +++ b/internal/kurious/adapters/sqlite_learning_category_repository.go @@ -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 +} diff --git a/internal/kurious/adapters/sqlite_learning_category_repository_test.go b/internal/kurious/adapters/sqlite_learning_category_repository_test.go new file mode 100644 index 0000000..1a7711b --- /dev/null +++ b/internal/kurious/adapters/sqlite_learning_category_repository_test.go @@ -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) +} diff --git a/internal/kurious/adapters/sqlite_organization.go b/internal/kurious/adapters/sqlite_organization.go new file mode 100644 index 0000000..4e9081d --- /dev/null +++ b/internal/kurious/adapters/sqlite_organization.go @@ -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), + } +} diff --git a/internal/kurious/adapters/ydb_course_repository.go b/internal/kurious/adapters/ydb_course_repository.go index 3bd695c..86c3e88 100644 --- a/internal/kurious/adapters/ydb_course_repository.go +++ b/internal/kurious/adapters/ydb_course_repository.go @@ -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, diff --git a/internal/kurious/domain/category.go b/internal/kurious/domain/category.go new file mode 100644 index 0000000..964b472 --- /dev/null +++ b/internal/kurious/domain/category.go @@ -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 +} diff --git a/internal/kurious/domain/error.go b/internal/kurious/domain/error.go new file mode 100644 index 0000000..ce3f41c --- /dev/null +++ b/internal/kurious/domain/error.go @@ -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) +} diff --git a/internal/kurious/domain/repository.go b/internal/kurious/domain/repository.go index fceceb3..ef6b10b 100644 --- a/internal/kurious/domain/repository.go +++ b/internal/kurious/domain/repository.go @@ -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 +} diff --git a/migrations/sqlite/002_learning_and_organization.sql b/migrations/sqlite/002_learning_and_organization.sql new file mode 100644 index 0000000..b6f22de --- /dev/null +++ b/migrations/sqlite/002_learning_and_organization.sql @@ -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);