From 3733278d8c51977e18c06b5da152b51d3e8cd424 Mon Sep 17 00:00:00 2001 From: Gitea Date: Sat, 9 Dec 2023 00:33:12 +0300 Subject: [PATCH] setup parser --- Taskfile.yml | 6 +- cmd/background/config.go | 41 +++ cmd/background/main.go | 94 +++++++ cmd/dev/sravnicli/products.go | 12 +- go.mod | 1 + go.sum | 2 + internal/common/client/sravni/client.go | 62 ++--- internal/common/client/sravni/entities.go | 20 +- internal/common/generator/ids.go | 12 + internal/common/xcontext/log.go | 4 + internal/common/xlog/slog.go | 37 +++ internal/common/xlog/xlog.go | 67 +++++ internal/common/xslice/filter.go | 36 +++ internal/common/xslice/filter_test.go | 52 ++++ internal/common/xslice/foreach.go | 7 + internal/common/xslice/map.go | 10 + .../kurious/adapters/ydb_course_repository.go | 146 +++++++++- internal/kurious/app/app.go | 5 +- internal/kurious/app/command/createcourse.go | 34 +++ internal/kurious/domain/repository.go | 1 + internal/kurious/ports/background.go | 129 +++++++++ .../kurious/ports/background/synchandler.go | 260 ++++++++++++++++++ internal/kurious/ports/cron.go | 43 --- internal/kurious/service/service.go | 5 +- 24 files changed, 986 insertions(+), 100 deletions(-) create mode 100644 cmd/background/config.go create mode 100644 cmd/background/main.go create mode 100644 internal/common/generator/ids.go create mode 100644 internal/common/xlog/slog.go create mode 100644 internal/common/xlog/xlog.go create mode 100644 internal/common/xslice/filter.go create mode 100644 internal/common/xslice/filter_test.go create mode 100644 internal/common/xslice/foreach.go create mode 100644 internal/common/xslice/map.go create mode 100644 internal/kurious/ports/background.go create mode 100644 internal/kurious/ports/background/synchandler.go delete mode 100644 internal/kurious/ports/cron.go diff --git a/Taskfile.yml b/Taskfile.yml index 0ae9799..454029a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,7 +24,11 @@ tasks: test: cmds: - go test ./internal/... - build: + build_background: + cmds: + - go build -o $GOBIN/sravnibackground -v -ldflags '{{.LDFLAGS}}' cmd/background/*.go + deps: [check, test] + build_dev_cli: cmds: - go build -o $GOBIN/sravnicli -v -ldflags '{{.LDFLAGS}}' cmd/dev/sravnicli/*.go deps: [check, test] diff --git a/cmd/background/config.go b/cmd/background/config.go new file mode 100644 index 0000000..f18fa84 --- /dev/null +++ b/cmd/background/config.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "git.loyso.art/frx/kurious/internal/common/config" +) + +type Config struct { + Log config.Log `json:"log"` + YDB config.YDB `json:"ydb"` + SyncSravniCron string `json:"sync_sravni_cron"` + + DebugHTTP bool `json:"debug_http"` +} + +func readFromFile(path string, defaultConfigF func() Config) (Config, error) { + out := defaultConfigF() + payload, err := os.ReadFile(path) + if err != nil { + return out, fmt.Errorf("opening file: %w", err) + } + + err = json.Unmarshal(payload, &out) + if err != nil { + return out, fmt.Errorf("decoding as json: %w", err) + } + + return out, nil +} + +func defaultConfig() Config { + return Config{ + Log: config.Log{ + Level: config.LogLevelInfo, + Format: config.LogFormatText, + }, + } +} diff --git a/cmd/background/main.go b/cmd/background/main.go new file mode 100644 index 0000000..81fe258 --- /dev/null +++ b/cmd/background/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "time" + + "golang.org/x/sync/errgroup" + + "git.loyso.art/frx/kurious/internal/common/client/sravni" + "git.loyso.art/frx/kurious/internal/common/config" + "git.loyso.art/frx/kurious/internal/common/xcontext" + "git.loyso.art/frx/kurious/internal/common/xlog" + "git.loyso.art/frx/kurious/internal/kurious/ports" + "git.loyso.art/frx/kurious/internal/kurious/service" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + err := app(ctx) + if err != nil { + println(err.Error()) + os.Exit(1) + } +} + +func app(ctx context.Context) error { + var cfgpath string + if len(os.Args) > 1 { + cfgpath = os.Args[1] + } else { + cfgpath = "config.json" + } + + cfg, err := readFromFile(cfgpath, defaultConfig) + if err != nil { + return fmt.Errorf("reading config from file: %w", err) + } + + log := config.NewSLogger(cfg.Log) + + app, err := service.NewApplication(ctx, service.ApplicationConfig{ + LogConfig: cfg.Log, + YDB: cfg.YDB, + }) + if err != nil { + return fmt.Errorf("making new application: %w", err) + } + + sravniClient, err := sravni.NewClient(ctx, log, cfg.DebugHTTP) + if err != nil { + return fmt.Errorf("making sravni client: %w", err) + } + + bgProcess := ports.NewBackgroundProcess(ctx, log) + err = bgProcess.RegisterSyncSravniHandler(ctx, app, sravniClient, cfg.SyncSravniCron) + if err != nil { + return fmt.Errorf("registering sync sravni handler: %w", err) + } + + xcontext.LogInfo(ctx, log, "schedule", xlog.StringerAttr("untils", bgProcess.GetNextRunStats())) + + eg, egctx := errgroup.WithContext(ctx) + + xcontext.LogInfo(ctx, log, "running routines") + eg.Go(func() error { + xcontext.LogInfo(ctx, log, "running bgprocess") + defer xcontext.LogInfo(ctx, log, "finished bprocess") + + bgProcess.Run() + return nil + }) + + eg.Go(func() error { + xcontext.LogInfo(ctx, log, "running cancelation waiter") + defer xcontext.LogInfo(ctx, log, "finished cancelation waiter") + <-egctx.Done() + sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*15) + defer sdcancel() + + return bgProcess.Shutdown(sdctx) + }) + + err = eg.Wait() + if err != nil { + return err + } + + return nil +} diff --git a/cmd/dev/sravnicli/products.go b/cmd/dev/sravnicli/products.go index 8a1a719..8e7eb8e 100644 --- a/cmd/dev/sravnicli/products.go +++ b/cmd/dev/sravnicli/products.go @@ -13,8 +13,8 @@ import ( ) const ( - learningTypeOptName = "learning_type" - courseThematicOptName = "course_thematic" + learningTypeOptName = "learning-type" + courseThematicOptName = "course-thematic" ) func setupAPICommand(ctx context.Context) cli.Command { @@ -98,13 +98,11 @@ func (a *listProductsAction) parse(args []string, options map[string]string) err a.params.learningType, ok = options[learningTypeOptName] if !ok { - return errors.SimpleError("learning_type is empty") - } - a.params.courseThematic, ok = options[courseThematicOptName] - if !ok { - return errors.SimpleError("course_thematic is empty") + return errors.SimpleError(learningTypeOptName + " is empty") } + a.params.courseThematic = options[courseThematicOptName] + if value, ok := options[limitOption.Key()]; ok { a.params.limit, _ = strconv.Atoi(value) } diff --git a/go.mod b/go.mod index c1b01da..a713b10 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( golang.org/x/sync v0.5.0 // indirect golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect diff --git a/go.sum b/go.sum index 4d121c1..cc06d98 100644 --- a/go.sum +++ b/go.sum @@ -1092,6 +1092,8 @@ golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/common/client/sravni/client.go b/internal/common/client/sravni/client.go index 27254e6..4cc167d 100644 --- a/internal/common/client/sravni/client.go +++ b/internal/common/client/sravni/client.go @@ -11,6 +11,7 @@ import ( "git.loyso.art/frx/kurious/internal/common/errors" "git.loyso.art/frx/kurious/pkg/slices" + "git.loyso.art/frx/kurious/pkg/xdefault" "github.com/go-resty/resty/v2" "golang.org/x/net/html" @@ -76,6 +77,7 @@ type ListEducationProductsParams struct { LearningType string CoursesThematics string + SortBy string Limit int Offset int } @@ -116,6 +118,8 @@ func (c *client) ListEducationalProducts( const defaultLimit = 1 const defaultSortProp = "advertising.position" const defaultSortDirection = "asc" + // TODO: find out should it be settable + const productName = "learning-courses" if err = c.checkClientInited(); err != nil { return result, err } @@ -123,33 +127,28 @@ func (c *client) ListEducationalProducts( if !c.validLearningTypes.hasValue(params.LearningType) { return result, errors.NewValidationError("learning_type", "bad value") } - if !c.validCourseThematics.hasValue(params.CoursesThematics) { + if params.CoursesThematics != "" && !c.validCourseThematics.hasValue(params.CoursesThematics) { return result, errors.NewValidationError("courses_thematics", "bad value") } reqParams := ListEducationProductsRequest{ - LearningType: []string{ - params.LearningType, - }, - CoursesThematics: []string{ - params.CoursesThematics, - }, + LearningType: valueAsArray(params.LearningType), + CoursesThematics: valueAsArray(params.CoursesThematics), + ProductName: productName, + Fields: defaultProductFields, + SortProperty: defaultSortProp, // mayber sort by price? + SortDirection: defaultSortDirection, + NotSubIsWebinar: strconv.FormatBool(true), + NotB2B: strconv.FormatBool(true), + IsMix: false, // not sure why, but for better parsing + MixRepeated: true, // looks like this option should force to exclude duplicates + AdvertisingOnly: false, // If true, it will show only paid items. + Location: "", // TODO: get and fill location? + Fingerprint: "", // not sure it should be set. + OfferTypes: []string{}, // for more precise filter but not needed. - Fields: defaultProductFields, - SortProperty: defaultSortProp, // mayber sort by price? - SortDirection: defaultSortDirection, - NotSubIsWebinar: strconv.FormatBool(true), - NotB2B: strconv.FormatBool(true), - IsMix: true, // not sure why, but for better parsing - MixRepeated: true, // looks like this option should force to exclude duplicates - AdvertisingOnly: false, // If true, it will show only paid items. - Location: "", // TODO: get and fill location? - Fingerprint: "", // not sure it should be set. - ProductName: "", // looks like it does not affects anything - OfferTypes: nil, // for more precise filter but not needed. - - Limit: defaultLimit, - Offset: 0, + Limit: xdefault.WithFallback(params.Limit, defaultLimit), + Offset: params.Offset, } req := c.http.R(). @@ -353,17 +352,6 @@ func (qs querySet) exactSubset(values ...string) ([]string, error) { } -// func (qs querySet) subset(values ...string) []string { -// out := make([]string, 0, len(values)) -// for _, value := range values { -// if qs.hasValue(value) { -// out = append(out, value) -// } -// } -// -// return out -// } - func newQuerySet(values ...string) querySet { qs := querySet{ values: make([]string, len(values)), @@ -377,3 +365,11 @@ func newQuerySet(values ...string) querySet { return qs } + +func valueAsArray(value string) []string { + if value == "" { + return nil + } + + return []string{value} +} diff --git a/internal/common/client/sravni/entities.go b/internal/common/client/sravni/entities.go index 9898818..6c33526 100644 --- a/internal/common/client/sravni/entities.go +++ b/internal/common/client/sravni/entities.go @@ -102,9 +102,9 @@ func (p *PageState) Clone() *PageState { type CourseDiscount struct { PromoCode string `json:"promoCode"` PromoCodeType string `json:"promoCodeType"` - Percent int `json:"percent"` + Percent any `json:"percent"` EndDate time.Time `json:"endDate"` - EndTime any `json:"endTime"` + EndTime time.Time `json:"endTime"` } type CourseAdvertising struct { @@ -140,17 +140,17 @@ type Course struct { Discount CourseDiscount `json:"discount"` Link string `json:"link"` Learningtype []string `json:"learningtype"` - DateStart any `json:"dateStart"` - TimeStart any `json:"timeStart"` - TimeAllHour any `json:"timeAllHour"` - TimeAllDay any `json:"timeAllDay"` - TimeAllMonth int `json:"timeAllMonth"` + DateStart *time.Time `json:"dateStart"` + TimeStart *time.Time `json:"timeStart"` + TimeAllHour *float64 `json:"timeAllHour"` + TimeAllDay *float64 `json:"timeAllDay"` + TimeAllMonth *float64 `json:"timeAllMonth"` IsTermApproximately bool `json:"isTermApproximately"` DictionaryFormatFilterNew []string `json:"dictionaryFormatFilterNew"` DictionaryLevelFilterNew []string `json:"dictionaryLevelFilterNew"` - Price int `json:"price"` - PriceAll int `json:"priceAll"` - PriceInstallment int `json:"priceInstallment"` + Price float64 `json:"price"` + PriceAll float64 `json:"priceAll"` + PriceInstallment float64 `json:"priceInstallment"` CourseImage string `json:"courseImage"` WithoutDiscountPrice int `json:"withoutDiscountPrice"` Advertising CourseAdvertising `json:"advertising"` diff --git a/internal/common/generator/ids.go b/internal/common/generator/ids.go new file mode 100644 index 0000000..51bec65 --- /dev/null +++ b/internal/common/generator/ids.go @@ -0,0 +1,12 @@ +package generator + +import ( + "crypto/rand" + "encoding/hex" +) + +func RandomInt64ID() string { + data := [8]byte{} + _, _ = rand.Read(data[:]) + return hex.EncodeToString(data[:]) +} diff --git a/internal/common/xcontext/log.go b/internal/common/xcontext/log.go index 79a587c..1217616 100644 --- a/internal/common/xcontext/log.go +++ b/internal/common/xcontext/log.go @@ -34,6 +34,10 @@ func LogError(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.A log.LogAttrs(ctx, slog.LevelError, msg, append(attrs, getLogFields(ctx)...)...) } +func LogWithError(ctx context.Context, log *slog.Logger, err error, msg string, attrs ...slog.Attr) { + LogError(ctx, log, msg, append(attrs, slog.Any("err", err))...) +} + func getLogFields(ctx context.Context) []slog.Attr { store, _ := ctx.Value(ctxLogKey{}).(ctxLogAttrStore) return store.attrs diff --git a/internal/common/xlog/slog.go b/internal/common/xlog/slog.go new file mode 100644 index 0000000..282882e --- /dev/null +++ b/internal/common/xlog/slog.go @@ -0,0 +1,37 @@ +package xlog + +import ( + "fmt" + "log/slog" +) + +func LevelAsSLogLevel(level Level) slog.Level { + switch level { + case LevelDebug: + return slog.LevelDebug + case LevelInfo: + return slog.LevelInfo + case LevelWarn: + return slog.LevelWarn + case LevelError: + return slog.LevelError + default: + panic("unsupported level " + level.String()) + } +} + +func StringerAttr(name string, value fmt.Stringer) slog.Attr { + return slog.Any(name, StringerValue(value)) +} + +func StringerValue(s fmt.Stringer) slog.LogValuer { + return stringerValue{s} +} + +type stringerValue struct { + fmt.Stringer +} + +func (s stringerValue) LogValue() slog.Value { + return slog.StringValue(s.String()) +} diff --git a/internal/common/xlog/xlog.go b/internal/common/xlog/xlog.go new file mode 100644 index 0000000..9526c3e --- /dev/null +++ b/internal/common/xlog/xlog.go @@ -0,0 +1,67 @@ +package xlog + +import ( + "bytes" + "errors" +) + +type Level uint8 + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +func (l *Level) UnmarshalText(data []byte) error { + switch levelstr := string(bytes.ToLower(data)); levelstr { + case "debug": + *l = LevelDebug + case "info": + *l = LevelInfo + case "warn": + *l = LevelWarn + case "error": + *l = LevelError + default: + return errors.New("unsupported level " + levelstr) + } + + return nil +} + +func (l Level) String() string { + switch l { + case LevelDebug: + return "debug" + case LevelInfo: + return "info" + case LevelWarn: + return "warn" + case LevelError: + return "error" + default: + return "" + } +} + +type Format uint8 + +const ( + FormatText Format = iota + FormatJSON +) + +func (f *Format) UnmarshalText(data []byte) error { + switch formatstr := string(bytes.ToLower(data)); formatstr { + case "debug": + *f = FormatText + case "info": + *f = FormatJSON + default: + return errors.New("unsupported format " + formatstr) + } + + return nil +} diff --git a/internal/common/xslice/filter.go b/internal/common/xslice/filter.go new file mode 100644 index 0000000..7d266c5 --- /dev/null +++ b/internal/common/xslice/filter.go @@ -0,0 +1,36 @@ +package xslice + +func Filter[T any](values []T, f func(T) bool) []T { + out := make([]T, 0, len(values)) + for _, value := range values { + if f(value) { + out = append(out, value) + } + } + + return out +} + +func FilterInplace[T any](values []T, match func(T) bool) (newlen int) { + next := 0 + for i := 0; i < len(values); i++ { + value := values[i] + if !match(value) { + continue + } + + if next != i { + values[next] = value + } + + next++ + } + + return next +} + +func Not[T any](f func(T) bool) func(T) bool { + return func(t T) bool { + return !f(t) + } +} diff --git a/internal/common/xslice/filter_test.go b/internal/common/xslice/filter_test.go new file mode 100644 index 0000000..08945cb --- /dev/null +++ b/internal/common/xslice/filter_test.go @@ -0,0 +1,52 @@ +package xslice_test + +import ( + "testing" + + "git.loyso.art/frx/kurious/internal/common/xslice" +) + +func TestFilterInplace(t *testing.T) { + tt := []struct { + name string + in []int + check func(int) bool + expLen int + }{{ + name: "empty", + in: nil, + check: nil, + expLen: 0, + }, { + name: "all match", + in: []int{1, 2, 3}, + check: func(int) bool { + return true + }, + expLen: 3, + }, { + name: "all not match", + in: []int{1, 2, 3}, + check: func(int) bool { + return false + }, + expLen: 0, + }, { + name: "some filtered out", + in: []int{1, 2, 3, 4}, + check: func(v int) bool { + return v%2 == 0 + }, + expLen: 2, + }} + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + gotLen := xslice.FilterInplace(tc.in, tc.check) + if gotLen != tc.expLen { + t.Errorf("exp %d got %d", tc.expLen, gotLen) + } + }) + } +} diff --git a/internal/common/xslice/foreach.go b/internal/common/xslice/foreach.go new file mode 100644 index 0000000..01380e4 --- /dev/null +++ b/internal/common/xslice/foreach.go @@ -0,0 +1,7 @@ +package xslice + +func ForEach[T any](items []T, f func(T)) { + for _, item := range items { + f(item) + } +} diff --git a/internal/common/xslice/map.go b/internal/common/xslice/map.go new file mode 100644 index 0000000..4189501 --- /dev/null +++ b/internal/common/xslice/map.go @@ -0,0 +1,10 @@ +package xslice + +func Map[T, U any](in []T, f func(T) U) []U { + out := make([]U, len(in)) + for i, value := range in { + out[i] = f(value) + } + + return out +} diff --git a/internal/kurious/adapters/ydb_course_repository.go b/internal/kurious/adapters/ydb_course_repository.go index 5fe819c..dfb722e 100644 --- a/internal/kurious/adapters/ydb_course_repository.go +++ b/internal/kurious/adapters/ydb_course_repository.go @@ -79,8 +79,78 @@ type ydbCourseRepository struct { log *slog.Logger } -func (r *ydbCourseRepository) List(ctx context.Context, params domain.ListCoursesParams) ([]domain.Course, error) { - return nil, nil +func (r *ydbCourseRepository) List(ctx context.Context, params domain.ListCoursesParams) (courses []domain.Course, err error) { + const queryName = "list" + + courses = make([]domain.Course, 0, 4_000) + readTx := table.TxControl( + table.BeginTx( + table.WithOnlineReadOnly(), + ), + table.CommitTx(), + ) + err = r.db.Table().Do( + ctx, + func(ctx context.Context, s table.Session) error { + start := time.Now() + defer func() { + since := time.Since(start) + xcontext.LogInfo( + ctx, r.log, + "executed query", + slog.String("name", queryName), + slog.Duration("elapsed", since), + ) + }() + + _, res, err := s.Execute( + ctx, + readTx, + `SELECT + id, + external_id, + source_type, + source_name, + organization_id, + origin_link, + image_link, + name, + description, + full_price, + discount, + duration, + starts_at, + created_at, + updated_at, + deleted_at + FROM + courses + `, + table.NewQueryParameters(), + options.WithCollectStatsModeBasic(), + ) + if err != nil { + return fmt.Errorf("executing: %w", err) + } + + for res.NextResultSet(ctx) { + for res.NextRow() { + var cdb courseDB + _ = res.ScanNamed(cdb.getNamedValues()...) + courses = append(courses, mapCourseDB(cdb)) + } + } + if err = res.Err(); err != nil { + return err + } + return nil + }, + table.WithIdempotent()) + if err != nil { + return nil, err + } + + return courses, err } func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain.Course, err error) { @@ -194,6 +264,69 @@ func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value { ) } +func (r *ydbCourseRepository) CreateBatch(ctx context.Context, params ...domain.CreateCourseParams) 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 { + listValues := mapSlice(params, createCourseParamsAsStruct) + queryParams := table.NewQueryParameters( + table.ValueParam("$courseData", types.ListValue(listValues...)), + ) + _, _, err := s.Execute(ctx, writeTx, upsertQuery, queryParams) + if err != nil { + return fmt.Errorf("executing query: %w", err) + } + + return nil + }) + + return err +} + func (r *ydbCourseRepository) Create(ctx context.Context, params domain.CreateCourseParams) (domain.Course, error) { // -- PRAGMA TablePathPrefix("courses"); const upsertQuery = `DECLARE $courseData AS List