diff --git a/cmd/dev/sravnicli/core.go b/cmd/dev/sravnicli/core.go index 8199ed6..a4f17f2 100644 --- a/cmd/dev/sravnicli/core.go +++ b/cmd/dev/sravnicli/core.go @@ -15,14 +15,13 @@ import ( "github.com/teris-io/cli" ) -const ( - debugOptName = "verbose" - jsonOptName = "json" +var ( + limitOption = cli.NewOption("limit", "Limits amount of items to return").WithType(cli.TypeInt) + offsetOption = cli.NewOption("offset", "Offsets items to return").WithType(cli.TypeInt) + debugOption = cli.NewOption("verbose", "Enables debug logging").WithChar('v').WithType(cli.TypeBool) + jsonOption = cli.NewOption("json", "Sets output as json").WithType(cli.TypeBool) ) -var limitOption = cli.NewOption("limit", "Limits amount of items to return").WithType(cli.TypeInt) -var offsetOption = cli.NewOption("offset", "Offsets items to return").WithType(cli.TypeInt) - type actionWrapper func(next cli.Action) cli.Action func buildCLICommand(f func() cli.Command) cliCommand { @@ -92,9 +91,29 @@ func actionWrapperParseConfig(next cli.Action) cli.Action { } } +func isJSONFormatEnabled(options map[string]string) bool { + _, ok := options[jsonOption.Key()] + return ok +} + +type outputEncoderF func(any) error + +func makeOutputEncoder(options map[string]string) outputEncoderF { + if isJSONFormatEnabled(options) { + out := json.NewEncoder(defaultOutput) + out.SetIndent("", " ") + return out.Encode + } + + return outputEncoderF(func(a any) error { + _, err := fmt.Fprintf(defaultOutput, "%#v", a) + return err + }) +} + func makeLogger(options map[string]string) *slog.Logger { level := slog.LevelInfo - if _, ok := options[debugOptName]; ok { + if _, ok := options[debugOption.Key()]; ok { level = slog.LevelDebug } @@ -103,7 +122,7 @@ func makeLogger(options map[string]string) *slog.Logger { } var h slog.Handler - if _, ok := options[jsonOptName]; ok { + if isJSONFormatEnabled(options) { h = slog.NewJSONHandler(os.Stdout, &opts) } else { h = slog.NewTextHandler(os.Stdout, &opts) @@ -113,7 +132,7 @@ func makeLogger(options map[string]string) *slog.Logger { } func makeSravniClient(ctx context.Context, log *slog.Logger, options map[string]string) (sravni.Client, error) { - _, isDebug := options[debugOptName] + _, isDebug := options[debugOption.Key()] client, err := sravni.NewClient(ctx, log, isDebug) if err != nil { return nil, fmt.Errorf("making new client: %w", err) @@ -125,6 +144,8 @@ func makeSravniClient(ctx context.Context, log *slog.Logger, options map[string] type baseAction struct { ctx context.Context log *slog.Logger + + out outputEncoderF } func (ba *baseAction) getYDBConnection() (*adapters.YDBConnection, error) { @@ -142,6 +163,7 @@ func (ba *baseAction) getYDBConnection() (*adapters.YDBConnection, error) { func (ba *baseAction) parse(_ []string, options map[string]string) (err error) { ba.log = makeLogger(options).With(slog.String("component", "action")) + ba.out = makeOutputEncoder(options) return nil } diff --git a/cmd/dev/sravnicli/ydb.go b/cmd/dev/sravnicli/ydb.go index 9875952..bfb1e5b 100644 --- a/cmd/dev/sravnicli/ydb.go +++ b/cmd/dev/sravnicli/ydb.go @@ -5,9 +5,12 @@ import ( stderrors "errors" "fmt" "log/slog" + "strconv" + "time" "git.loyso.art/frx/kurious/internal/common/errors" "git.loyso.art/frx/kurious/internal/common/xcontext" + "git.loyso.art/frx/kurious/internal/kurious/domain" "github.com/teris-io/cli" ) @@ -22,12 +25,29 @@ func setupYDBCommand(ctx context.Context) cli.Command { coursesGet := buildCLICommand(func() cli.Command { return cli.NewCommand("get", "Fetches one or more courses"). - WithArg(cli.NewArg("ids", "List of course ids").AsOptional()). + WithArg(cli.NewArg("ids", "List of course ids").AsOptional().WithType(cli.TypeString)). WithAction(newYDBCoursesGetAction(ctx)) }) + coursesCreate := buildCLICommand(func() cli.Command { + return cli.NewCommand("create", "Creates course"). + WithOption(cli.NewOption("generate-id", "Generates id").WithType(cli.TypeBool)). + WithOption(cli.NewOption("name", "Sets course name")). + WithOption(cli.NewOption("source-name", "Sets source name")). + WithOption(cli.NewOption("organization-id", "Sets organization id")). + WithOption(cli.NewOption("origin-link", "Sets origin link")). + WithOption(cli.NewOption("image-link", "Sets image link")). + WithOption(cli.NewOption("desc", "Sets description link")). + WithOption(cli.NewOption("full-price", "Sets full price").WithType(cli.TypeNumber)). + WithOption(cli.NewOption("discount", "Sets discount").WithType(cli.TypeNumber)). + WithOption(cli.NewOption("duration", "Sets duration")). + WithOption(cli.NewOption("starts-at", "Sets starts at")). + WithAction(NewYDBCoursesCreateAction(ctx)) + }) + courses := cli.NewCommand("courses", "Courses commands"). - WithCommand(coursesGet) + WithCommand(coursesGet). + WithCommand(coursesCreate) return cli.NewCommand("ydb", "YDB related actions"). WithCommand(migration). @@ -70,6 +90,127 @@ func (a *ydbMigrateApplyAction) handle() error { return nil } +func NewYDBCoursesCreateAction(ctx context.Context) cli.Action { + action := &ydbCoursesCreateAction{ + baseAction: newBaseAction(ctx), + } + + return asCLIAction(action) +} + +type ydbCoursesCreateAction struct { + *baseAction + + generateID bool + id string + name string + sourceName string + organizationID string + originLink string + imageLink string + description string + fullPrice float64 + discount float64 + duration time.Duration + startsAt time.Time +} + +func (a *ydbCoursesCreateAction) getCreateParams() domain.CreateCourseParams { + if a.generateID { + a.id = strconv.FormatInt(time.Now().Unix(), 10) + } + + out := domain.CreateCourseParams{ + ID: a.id, + Name: a.name, + SourceType: domain.SourceTypeParsed, + OrganizationID: a.organizationID, + OriginLink: a.originLink, + ImageLink: a.imageLink, + Description: a.description, + FullPrice: a.fullPrice, + Discount: a.discount, + Duration: a.duration, + StartsAt: a.startsAt, + } + if a.sourceName != "" { + out.SourceName.Set(a.sourceName) + } + + return out +} + +func (a *ydbCoursesCreateAction) parse(params []string, options map[string]string) error { + err := a.baseAction.parse(params, options) + if err != nil { + return err + } + if err != nil { + return fmt.Errorf("parsing full-price: %w", err) + } + a.discount, err = strconv.ParseFloat(options["discount"], 64) + if err != nil { + return fmt.Errorf("parsing discount: %w", err) + } + a.fullPrice, err = strconv.ParseFloat(options["full-price"], 64) + if err != nil { + return fmt.Errorf("parsing full-price: %w", err) + } + if genid, ok := options["generate-id"]; ok { + a.generateID, err = strconv.ParseBool(genid) + if err != nil { + return fmt.Errorf("parsing generate-id: %w", err) + } + } + if duration, ok := options["duration"]; ok { + a.duration, err = time.ParseDuration(duration) + if err != nil { + return fmt.Errorf("parsing duration: %w", err) + } + } + if startsAt, ok := options["starts-at"]; ok { + a.startsAt, err = time.Parse(time.RFC3339, startsAt) + if err != nil { + return fmt.Errorf("parsing time: %w", err) + } + } + + a.name = options["name"] + a.sourceName = options["source-name"] + a.organizationID = options["organization-id"] + a.originLink = options["origin-link"] + a.imageLink = options["image-link"] + a.description = options["desc"] + + return nil +} + +func (a *ydbCoursesCreateAction) handle() error { + ydbConn, err := a.getYDBConnection() + if err != nil { + return err + } + defer func() { + errClose := ydbConn.Close() + if errClose != nil { + xcontext.LogError(a.ctx, a.log, "unable to close repository", slog.Any("error", err)) + if err == nil { + err = errClose + } + } + }() + + params := a.getCreateParams() + repository := ydbConn.CourseRepository() + + _, err = repository.Create(a.ctx, params) + if err != nil { + return fmt.Errorf("creating course: %w", err) + } + + return nil +} + type ydbCoursesGetAction struct { *baseAction @@ -116,6 +257,7 @@ func (a *ydbCoursesGetAction) handle() error { }() repository := ydbConn.CourseRepository() + courses := make([]domain.Course, 0, len(a.courseIDs)) for _, courseID := range a.courseIDs { course, err := repository.Get(a.ctx, courseID) if err != nil && !stderrors.Is(err, errors.ErrNotFound) { @@ -125,7 +267,15 @@ func (a *ydbCoursesGetAction) handle() error { continue } - xcontext.LogInfo(a.ctx, a.log, "fetched course", slog.Any("item", course)) + xcontext.LogInfo(a.ctx, a.log, "fetched course", slog.String("id", courseID)) + courses = append(courses, course) + } + + for _, course := range courses { + err = a.out(course) + if err != nil { + xcontext.LogError(a.ctx, a.log, "unable to write course", slog.Any("error", err)) + } } return nil diff --git a/internal/kurious/adapters/ydb_course_repository.go b/internal/kurious/adapters/ydb_course_repository.go index 4954569..5fe819c 100644 --- a/internal/kurious/adapters/ydb_course_repository.go +++ b/internal/kurious/adapters/ydb_course_repository.go @@ -128,7 +128,7 @@ func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain starts_at, created_at, updated_at, - deleted_at, + deleted_at FROM courses WHERE @@ -171,8 +171,89 @@ func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (d return domain.Course{}, nil } -func (r *ydbCourseRepository) Create(context.Context, domain.CreateCourseParams) (domain.Course, error) { - return domain.Course{}, nil +func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value { + st := mapSourceTypeFromDomain(params.SourceType) + now := time.Now() + return types.StructValue( + types.StructFieldValue("id", types.TextValue(params.ID)), + types.StructFieldValue("name", types.TextValue(params.Name)), + types.StructFieldValue("source_type", types.TextValue(st)), + types.StructFieldValue("source_name", types.NullableTextValue(params.SourceName.ValutPtr())), + types.StructFieldValue("external_id", types.NullableTextValue(params.ExternalID.ValutPtr())), + 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.NullableDatetimeValue(nil)), + ) +} + +func (r *ydbCourseRepository) Create(ctx context.Context, params domain.CreateCourseParams) (domain.Course, error) { + // -- PRAGMA TablePathPrefix("courses"); + const upsertQuery = `DECLARE $courseData AS List, + name: Text, + source_type: Text, + source_name: Optional, + organization_id: Text, + origin_link: Text, + image_link: Text, + description: Text, + full_price: Double, + discount: Double, + duration: Interval, + starts_at: Datetime, + created_at: Datetime, + updated_at: Datetime, + deleted_at: Optional>>; + + REPLACE INTO + courses + SELECT + id, + external_id, + name, + source_type, + source_name, + organization_id, + origin_link, + image_link, + description, + full_price, + discount, + duration, + starts_at, + created_at, + updated_at, + deleted_at + FROM AS_TABLE($courseData);` + + writeTx := table.TxControl( + table.BeginTx( + table.WithSerializableReadWrite(), + ), + table.CommitTx(), + ) + err := r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error { + queryParams := table.NewQueryParameters( + table.ValueParam("$courseData", types.ListValue(createCourseParamsAsStruct(params))), + ) + _, _, err := s.Execute(ctx, writeTx, upsertQuery, queryParams) + if err != nil { + return fmt.Errorf("executing query: %w", err) + } + + return nil + }) + + return domain.Course{}, err } func (r *ydbCourseRepository) Delete(ctx context.Context, id string) error { @@ -184,7 +265,7 @@ func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error { return s.CreateTable( ctx, path.Join(r.db.Name(), "courses"), - options.WithColumn("id", types.TypeString), + options.WithColumn("id", types.TypeText), options.WithColumn("external_id", types.Optional(types.TypeText)), options.WithColumn("name", types.TypeText), options.WithColumn("source_type", types.TypeText), @@ -193,8 +274,8 @@ func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error { options.WithColumn("origin_link", types.TypeText), options.WithColumn("image_link", types.TypeText), options.WithColumn("description", types.TypeText), - options.WithColumn("full_price", types.TypeFloat), - options.WithColumn("discount", types.TypeFloat), + 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), @@ -227,6 +308,7 @@ type courseDB struct { func (c *courseDB) getNamedValues() []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), @@ -234,6 +316,8 @@ func (c *courseDB) getNamedValues() []named.Value { 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), @@ -248,9 +332,8 @@ const ( sourceTypeParsed = "p" ) -func mapCourseDB(cdb courseDB) domain.Course { - var st domain.SourceType - switch cdb.SourceType { +func mapSourceTypeToDomain(in string) (st domain.SourceType) { + switch in { case sourceTypeUnknown: st = domain.SourceTypeUnset case sourceTypeManual: @@ -259,6 +342,26 @@ func mapCourseDB(cdb courseDB) domain.Course { st = domain.SourceTypeParsed } + return st +} + +func mapSourceTypeFromDomain(in domain.SourceType) string { + var st string + switch in { + case domain.SourceTypeManual: + st = sourceTypeManual + case domain.SourceTypeParsed: + st = sourceTypeParsed + default: + st = sourceTypeUnknown + } + + return st +} + +func mapCourseDB(cdb courseDB) domain.Course { + st := mapSourceTypeToDomain(cdb.SourceType) + return domain.Course{ ID: cdb.ID, ExternalID: nullable.NewValuePtr(cdb.ExternalID), diff --git a/internal/kurious/app/command/createcourse.go b/internal/kurious/app/command/createcourse.go index f22bd97..849a27a 100644 --- a/internal/kurious/app/command/createcourse.go +++ b/internal/kurious/app/command/createcourse.go @@ -13,14 +13,14 @@ import ( type CreateCourse struct { ID string - Name string - Description string ExternalID nullable.Value[string] + Name string SourceType domain.SourceType SourceName nullable.Value[string] OrganizationID string OriginLink string ImageLink string + Description string FullPrice float64 Discount float64 Duration time.Duration diff --git a/internal/kurious/domain/repository.go b/internal/kurious/domain/repository.go index d60041f..10263f8 100644 --- a/internal/kurious/domain/repository.go +++ b/internal/kurious/domain/repository.go @@ -15,14 +15,14 @@ type ListCoursesParams struct { type CreateCourseParams struct { ID string - Name string - Description string ExternalID nullable.Value[string] + Name string SourceType SourceType SourceName nullable.Value[string] OrganizationID string OriginLink string ImageLink string + Description string FullPrice float64 Discount float64 Duration time.Duration