Files
kurious/internal/kurious/adapters/ydb_course_repository.go

691 lines
22 KiB
Go

package adapters
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/ydb-platform/ydb-go-sdk/v3"
"github.com/ydb-platform/ydb-go-sdk/v3/table"
"github.com/ydb-platform/ydb-go-sdk/v3/table/options"
"github.com/ydb-platform/ydb-go-sdk/v3/table/result"
"github.com/ydb-platform/ydb-go-sdk/v3/table/result/named"
"github.com/ydb-platform/ydb-go-sdk/v3/table/types"
"github.com/ydb-platform/ydb-go-sdk/v3/trace"
yc "github.com/ydb-platform/ydb-go-yc"
)
const (
defaultShutdownTimeout = time.Second * 10
coursesTableName = "courses"
)
// YDBConnection wraps a ydb.Driver with application-level lifecycle.
type YDBConnection struct {
*ydb.Driver
log *slog.Logger
shutdownTimeout time.Duration
}
// NewYDBConnection opens a YDB connection using v3 SDK patterns.
// See: https://ydb.tech/docs/ru/dev/example-app/go/?version=v26.1
func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*YDBConnection, error) {
opts := make([]ydb.Option, 0, 3)
switch auth := cfg.Auth.(type) {
case config.YCAuthIAMToken:
opts = append(opts, ydb.WithAccessTokenCredentials(auth.Token))
case config.YCAuthCAKeysFile:
opts = append(opts,
yc.WithInternalCA(),
yc.WithServiceAccountKeyFileCredentials(auth.Path),
)
}
if cfg.DebugYDB {
opts = append(opts, ydb.WithTraceDriver(trace.Driver{}))
}
db, err := ydb.Open(
ctx,
cfg.DSN,
opts...,
)
if err != nil {
return nil, fmt.Errorf("opening connection: %w", err)
}
// Verify connectivity with a discovery call
err = db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
return nil
}, table.WithIdempotent())
if err != nil {
return nil, fmt.Errorf("discovery check: %w", err)
}
return &YDBConnection{
Driver: db,
shutdownTimeout: xdefault.WithFallback(cfg.ShutdownDuration, defaultShutdownTimeout),
log: log,
}, nil
}
// Close gracefully shuts down the YDB driver.
func (conn *YDBConnection) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), conn.shutdownTimeout)
defer cancel()
return conn.Driver.Close(ctx)
}
// Organization returns a not-implemented stub for YDB (no organization table yet).
func (conn *YDBConnection) Organization() domain.OrganizationRepository {
return NotImplementedOrganizationRepository{}
}
// LearningCategory returns a not-implemented stub for YDB (no learning_category table yet).
func (conn *YDBConnection) LearningCategory() domain.LearningCategoryRepository {
return NotImplementedLearningCategory{}
}
// CourseRepository returns a YDB-backed CourseRepository.
func (conn *YDBConnection) CourseRepository() *ydbCourseRepository {
return &ydbCourseRepository{
db: conn.Driver,
log: conn.log.With(slog.String("repository", "course")),
}
}
// ydbCourseRepository implements domain.CourseRepository backed by YDB.
type ydbCourseRepository struct {
db *ydb.Driver
log *slog.Logger
}
// List returns courses matching the given filters with cursor-based pagination.
func (r *ydbCourseRepository) List(
ctx context.Context,
params domain.ListCoursesParams,
) (result domain.ListCoursesResult, err error) {
const limit = 1000
if params.Limit == 0 {
params.Limit = limit
}
query := buildListQuery(params)
txParams := buildListQueryParams(params)
xcontext.LogInfo(ctx, r.log, "query prepared", slog.String("query", query))
readTx := table.OnlineReadOnlyTxControl()
err = r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
_, res, err := s.Execute(
ctx, readTx, query, txParams,
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing list query: %w", err)
}
return scanCoursesResult(ctx, res, &result)
},
table.WithIdempotent(),
)
if err != nil {
return domain.ListCoursesResult{}, err
}
return result, nil
}
// ListLearningTypes returns distinct learning type IDs.
func (r *ydbCourseRepository) ListLearningTypes(
ctx context.Context,
) (result domain.ListLearningTypeResult, err error) {
const querySelect = `SELECT DISTINCT learning_type FROM courses`
readTx := table.OnlineReadOnlyTxControl()
err = r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
_, res, err := s.Execute(
ctx, readTx, querySelect, table.NewQueryParameters(),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing list learning types query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var lt string
if err = res.Scan(&lt); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.LearningTypeIDs = append(result.LearningTypeIDs, lt)
}
return res.Err()
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
// ListCourseThematics returns distinct course thematic IDs for a learning type.
func (r *ydbCourseRepository) ListCourseThematics(
ctx context.Context,
params domain.ListCourseThematicsParams,
) (result domain.ListCourseThematicsResult, err error) {
query := `SELECT DISTINCT course_thematic FROM courses WHERE 1=1`
txParams := table.NewQueryParameters()
if params.LearningTypeID != "" {
query += ` AND learning_type = $learning_type`
txParams = table.NewQueryParameters(
table.ValueParam("$learning_type", types.TextValue(params.LearningTypeID)),
)
}
readTx := table.OnlineReadOnlyTxControl()
err = r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
_, res, err := s.Execute(
ctx, readTx, query, txParams,
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing list course thematics query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var ct string
if err = res.Scan(&ct); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.CourseThematicIDs = append(result.CourseThematicIDs, ct)
}
return res.Err()
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
// ListStatistics returns course statistics grouped by learning type, thematic, and organization.
func (r *ydbCourseRepository) ListStatistics(
ctx context.Context,
params domain.ListStatisticsParams,
) (result domain.ListStatisticsResult, err error) {
query := `SELECT learning_type, course_thematic, organization_id, COUNT(id) AS count FROM courses WHERE 1=1`
txParams := make([]table.ParameterOption, 0)
if params.LearningTypeID != "" {
query += ` AND learning_type = $learning_type`
txParams = append(txParams, table.ValueParam("$learning_type", types.TextValue(params.LearningTypeID)))
}
if params.CourseThematicID != "" {
query += ` AND course_thematic = $course_thematic`
txParams = append(txParams, table.ValueParam("$course_thematic", types.TextValue(params.CourseThematicID)))
}
if params.OrganizaitonID != "" {
query += ` AND organization_id = $organization_id`
txParams = append(txParams, table.ValueParam("$organization_id", types.TextValue(params.OrganizaitonID)))
}
query += ` GROUP BY learning_type, course_thematic, organization_id ORDER BY count DESC`
readTx := table.OnlineReadOnlyTxControl()
err = r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
_, res, err := s.Execute(
ctx, readTx, query, table.NewQueryParameters(txParams...),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing statistics query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var stat domain.StatisticUnit
if err = res.Scan(&stat.LearningTypeID, &stat.CourseThematicID, &stat.OrganizationID, &stat.Count); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.LearningTypeStatistics = append(result.LearningTypeStatistics, stat)
}
return res.Err()
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
// Get retrieves a single course by ID.
func (r *ydbCourseRepository) Get(
ctx context.Context,
id string,
) (course domain.Course, err error) {
const querySelect = `DECLARE $id AS Text;
SELECT
id, external_id, source_type, source_name,
course_thematic, learning_type, organization_id,
origin_link, image_link, name, description,
full_price, discount, duration, starts_at,
created_at, updated_at, deleted_at
FROM courses WHERE id = $id`
readTx := table.OnlineReadOnlyTxControl()
err = r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
_, res, err := s.Execute(
ctx, readTx, querySelect,
table.NewQueryParameters(
table.ValueParam("$id", types.TextValue(id)),
),
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing get query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return errors.ErrNotFound
}
for res.NextRow() {
var cdb courseDB
if err = res.ScanNamed(cdb.namedValues()...); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
course = mapCourseDB(cdb)
}
return res.Err()
},
table.WithIdempotent(),
)
return course, err
}
// GetByExternalID finds a course by its external ID.
func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) {
const querySelect = `DECLARE $external_id AS Text;
SELECT
id, external_id, source_type, source_name,
course_thematic, learning_type, organization_id,
origin_link, image_link, name, description,
full_price, discount, duration, starts_at,
created_at, updated_at, deleted_at
FROM courses WHERE external_id = $external_id`
readTx := table.OnlineReadOnlyTxControl()
var course domain.Course
err := r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
_, res, err := s.Execute(
ctx, readTx, querySelect,
table.NewQueryParameters(
table.ValueParam("$external_id", types.TextValue(id)),
),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing get by external id query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return errors.ErrNotFound
}
for res.NextRow() {
var cdb courseDB
if err = res.ScanNamed(cdb.namedValues()...); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
course = mapCourseDB(cdb)
}
return res.Err()
},
table.WithIdempotent(),
)
return course, err
}
// CreateBatch inserts or replaces multiple courses using BulkUpsert.
// This is the recommended v3 pattern for batch writes.
func (r *ydbCourseRepository) CreateBatch(ctx context.Context, params ...domain.CreateCourseParams) error {
values := make([]types.Value, 0, len(params))
for _, p := range params {
values = append(values, createCourseAsStructValue(p))
}
return r.db.Table().Do(ctx,
func(ctx context.Context, s table.Session) error {
return s.BulkUpsert(ctx, coursesTableName,
types.ListValue(values...),
)
},
table.WithIdempotent(),
)
}
// Create inserts or replaces a single course.
func (r *ydbCourseRepository) Create(ctx context.Context, params domain.CreateCourseParams) (domain.Course, error) {
err := r.CreateBatch(ctx, params)
if err != nil {
return domain.Course{}, err
}
return domain.Course{}, nil
}
// Delete removes a course by ID.
func (r *ydbCourseRepository) Delete(ctx context.Context, id string) error {
const queryDelete = `DECLARE $id AS Text;
DELETE FROM courses WHERE id = $id`
return r.db.Table().DoTx(ctx,
func(ctx context.Context, tx table.TransactionActor) error {
_, err := tx.Execute(ctx, queryDelete,
table.NewQueryParameters(
table.ValueParam("$id", types.TextValue(id)),
),
)
return err
},
table.WithIdempotent(),
)
}
// UpdateCourseDescription updates the description field of a course.
func (r *ydbCourseRepository) UpdateCourseDescription(ctx context.Context, id, description string) error {
const queryUpdate = `
DECLARE $id AS Text;
DECLARE $description AS Text;
DECLARE $updated_at AS Datetime;
UPDATE courses SET description = $description, updated_at = $updated_at WHERE id = $id`
return r.db.Table().DoTx(ctx,
func(ctx context.Context, tx table.TransactionActor) error {
_, err := tx.Execute(ctx, queryUpdate,
table.NewQueryParameters(
table.ValueParam("$id", types.TextValue(id)),
table.ValueParam("$description", types.TextValue(description)),
table.ValueParam("$updated_at", types.DatetimeValueFromTime(time.Now())),
),
)
return err
},
table.WithIdempotent(),
)
}
// CreateCourseTable creates the courses table in YDB.
func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error {
return r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
return s.CreateTable(ctx, coursesTableName,
options.WithColumn("id", types.TypeText),
options.WithColumn("external_id", types.Optional(types.TypeText)),
options.WithColumn("name", types.TypeText),
options.WithColumn("source_type", types.TypeText),
options.WithColumn("source_name", types.Optional(types.TypeText)),
options.WithColumn("course_thematic", types.TypeText),
options.WithColumn("learning_type", types.TypeText),
options.WithColumn("organization_id", types.TypeText),
options.WithColumn("origin_link", types.TypeText),
options.WithColumn("image_link", types.TypeText),
options.WithColumn("description", types.TypeText),
options.WithColumn("full_price", types.TypeDouble),
options.WithColumn("discount", types.TypeDouble),
options.WithColumn("duration", types.TypeInterval),
options.WithColumn("starts_at", types.TypeDatetime),
options.WithColumn("created_at", types.TypeDatetime),
options.WithColumn("updated_at", types.TypeDatetime),
options.WithColumn("deleted_at", types.Optional(types.TypeDatetime)),
options.WithPrimaryKeyColumn("id"),
)
})
}
// ---------------------------------------------------------------------------
// DB row type and mapping helpers
// ---------------------------------------------------------------------------
// courseDB represents a YDB row scanned from the courses table.
type courseDB struct {
ID string
ExternalID *string
Name string
SourceType string
SourceName *string
CourseThematic string
LearningType string
OrganizationID string
OriginLink string
ImageLink string
Description string
FullPrice float64
Discount float64
Duration time.Duration
StartAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
func (c *courseDB) namedValues() []named.Value {
return []named.Value{
named.Required("id", &c.ID),
named.Required("name", &c.Name),
named.Optional("external_id", &c.ExternalID),
named.Required("source_type", &c.SourceType),
named.Optional("source_name", &c.SourceName),
named.Required("course_thematic", &c.CourseThematic),
named.Required("learning_type", &c.LearningType),
named.Required("organization_id", &c.OrganizationID),
named.Required("origin_link", &c.OriginLink),
named.Required("image_link", &c.ImageLink),
named.Required("description", &c.Description),
named.Required("full_price", &c.FullPrice),
named.Required("discount", &c.Discount),
named.Required("duration", &c.Duration),
named.Required("starts_at", &c.StartAt),
named.Required("created_at", &c.CreatedAt),
named.Required("updated_at", &c.UpdatedAt),
named.Optional("deleted_at", &c.DeletedAt),
}
}
func mapCourseDB(cdb courseDB) domain.Course {
return domain.Course{
ID: cdb.ID,
ExternalID: nullable.NewValuePtr(cdb.ExternalID),
Name: cdb.Name,
SourceType: mapSourceTypeToDomain(cdb.SourceType),
SourceName: nullable.NewValuePtr(cdb.SourceName),
ThematicID: cdb.CourseThematic,
LearningTypeID: cdb.LearningType,
OrganizationID: cdb.OrganizationID,
OriginLink: cdb.OriginLink,
ImageLink: cdb.ImageLink,
Description: cdb.Description,
FullPrice: cdb.FullPrice,
Discount: cdb.Discount,
Duration: cdb.Duration,
StartsAt: cdb.StartAt,
CreatedAt: cdb.CreatedAt,
UpdatedAt: cdb.UpdatedAt,
DeletedAt: nullable.NewValuePtr(cdb.DeletedAt),
}
}
// ---------------------------------------------------------------------------
// Source type mapping (domain <-> DB)
// ---------------------------------------------------------------------------
const (
sourceTypeUnknown = ""
sourceTypeManual = "m"
sourceTypeParsed = "p"
)
func mapSourceTypeToDomain(in string) (st domain.SourceType) {
switch in {
case sourceTypeManual:
st = domain.SourceTypeManual
case sourceTypeParsed:
st = domain.SourceTypeParsed
}
return st
}
func mapSourceTypeFromDomain(in domain.SourceType) string {
switch in {
case domain.SourceTypeManual:
return sourceTypeManual
case domain.SourceTypeParsed:
return sourceTypeParsed
default:
return sourceTypeUnknown
}
}
// ---------------------------------------------------------------------------
// Query builders
// ---------------------------------------------------------------------------
var coursesFields = []string{
"id", "external_id", "source_type", "source_name",
"course_thematic", "learning_type", "organization_id",
"origin_link", "image_link", "name", "description",
"full_price", "discount", "duration", "starts_at",
"created_at", "updated_at", "deleted_at",
}
var coursesFieldsStr = strings.Join(coursesFields, ", ")
func buildListQuery(params domain.ListCoursesParams) string {
var sb strings.Builder
sb.WriteString("DECLARE $limit AS Int32;")
sb.WriteString("\n")
sb.WriteString("DECLARE $id AS Text;")
sb.WriteString("\n")
if params.CourseThematic != "" {
sb.WriteString("DECLARE $course_thematic AS Text;\n")
}
if params.LearningType != "" {
sb.WriteString("DECLARE $learning_type AS Text;\n")
}
if params.OrganizationID != "" {
sb.WriteString("DECLARE $organization_id AS Text;\n")
}
sb.WriteString("SELECT ")
sb.WriteString(coursesFieldsStr)
sb.WriteString(" FROM courses WHERE id > $id")
if params.LearningType != "" {
sb.WriteString(" AND learning_type = $learning_type")
}
if params.CourseThematic != "" {
sb.WriteString(" AND course_thematic = $course_thematic")
}
if params.OrganizationID != "" {
sb.WriteString(" AND organization_id = $organization_id")
}
sb.WriteString(" ORDER BY learning_type, course_thematic, id LIMIT $limit")
return sb.String()
}
func buildListQueryParams(params domain.ListCoursesParams) *table.QueryParameters {
opts := make([]table.ParameterOption, 0, 5)
opts = append(opts, table.ValueParam("$limit", types.Int32Value(int32(params.Limit))))
opts = append(opts, table.ValueParam("$id", types.TextValue("")))
if params.CourseThematic != "" {
opts = append(opts, table.ValueParam("$course_thematic", types.TextValue(params.CourseThematic)))
}
if params.LearningType != "" {
opts = append(opts, table.ValueParam("$learning_type", types.TextValue(params.LearningType)))
}
if params.OrganizationID != "" {
opts = append(opts, table.ValueParam("$organization_id", types.TextValue(params.OrganizationID)))
}
return table.NewQueryParameters(opts...)
}
func scanCoursesResult(ctx context.Context, res result.Result, result *domain.ListCoursesResult) error {
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var cdb courseDB
if err := res.ScanNamed(cdb.namedValues()...); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.Courses = append(result.Courses, mapCourseDB(cdb))
}
if err := res.Err(); err != nil {
return err
}
if len(result.Courses) > 0 {
result.NextPageToken = result.Courses[len(result.Courses)-1].ID
}
return nil
}
// ---------------------------------------------------------------------------
// Struct value builders for BulkUpsert
// ---------------------------------------------------------------------------
func createCourseAsStructValue(params domain.CreateCourseParams) types.Value {
now := time.Now()
st := mapSourceTypeFromDomain(params.SourceType)
return types.StructValue(
types.StructFieldValue("id", types.TextValue(params.ID)),
types.StructFieldValue("external_id", nullableTextValue(params.ExternalID)),
types.StructFieldValue("name", types.TextValue(params.Name)),
types.StructFieldValue("source_type", types.TextValue(st)),
types.StructFieldValue("source_name", nullableTextValue(params.SourceName)),
types.StructFieldValue("course_thematic", types.TextValue(params.CourseThematic)),
types.StructFieldValue("learning_type", types.TextValue(params.LearningType)),
types.StructFieldValue("organization_id", types.TextValue(params.OrganizationID)),
types.StructFieldValue("origin_link", types.TextValue(params.OriginLink)),
types.StructFieldValue("image_link", types.TextValue(params.ImageLink)),
types.StructFieldValue("description", types.TextValue(params.Description)),
types.StructFieldValue("full_price", types.DoubleValue(params.FullPrice)),
types.StructFieldValue("discount", types.DoubleValue(params.Discount)),
types.StructFieldValue("duration", types.IntervalValueFromDuration(params.Duration)),
types.StructFieldValue("starts_at", types.DatetimeValueFromTime(params.StartsAt)),
types.StructFieldValue("created_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullValue(types.Optional(types.TypeDatetime))),
)
}
func nullableTextValue(v nullable.Value[string]) types.Value {
if v.Valid() {
return types.OptionalValue(types.TextValue(v.Value()))
}
return types.NullValue(types.Optional(types.TypeText))
}