diff --git a/docs/_sidebar.md b/docs/_sidebar.md index e5099f0..5d2f1b7 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -4,3 +4,4 @@ - Modules - [Std Modules](modules/std.md) + - [Orm](modules/orm.md) \ No newline at end of file diff --git a/docs/modules/orm.md b/docs/modules/orm.md new file mode 100644 index 0000000..3938ae6 --- /dev/null +++ b/docs/modules/orm.md @@ -0,0 +1,181 @@ +# Orm + +The Orm module is designed to help you easily set up and manage your gorm instance using configuration files. This module is highly modular and can be integrated seamlessly with other tools and services, such as Otel, NewRelic, etc. It is compatible with any SQL database. The module provides key components including orm.GormProvider, *gorm.DB, and tx.Transactioner, each serving a specific purpose to streamline database operations in your application. + +## GormProvider + +GormProvider is a utility that simplifies the process of retrieving a *gorm.DB instance. It is the preferred method for accessing the gorm.DB instance, rather than injecting it directly via a constructor. + + +The Get method in GormProvider checks the context for an existing transaction. If a transaction is found, it returns that specific instance. Otherwise, it provides a gorm.DB instance with the given context +This allows for consistent and context-aware access to your database, ensuring that operations respect transactional boundaries when necessary. +```golang +func (p GormProvider) Get(ctx context.Context) *gorm.DB { + if found := tx.From(ctx); found != nil { + return found + } + return p.db.WithContext(ctx) +} +``` + +## Transactioner +Transactioner is a utility struct designed to facilitate transaction management within gorm by utilizing context. This is particularly useful in services where multiple operations need to be executed as part of a single transaction. + + +### Example Usage + +The following is an example of a service that uses Transactioner to manage a series of database operations within a single transaction: + +```golang +type Service struct { + txr tx.Transactioner + repo Repository +} + +func NewService(txr tx.Transactioner, repo Repositroy) *Service { + return &service{txr: txr, repo: repo} +} + +func (s *service) Save(parentctx context.Context) error { + return s.txr.Transaction(parentctx, func(ctx context.Context) error { + if err := repo.SaveOne(ctx); err != nilĀ { + return err + } + + if err := repo.SaveTwo(ctx); err != nil { + return err + } + + return nil + }) +} +``` +In this example, the Save method encapsulates multiple operations (SaveOne and SaveTwo) within a single transaction. If any operation fails, the entire transaction is rolled back, maintaining data integrity. + + +## Drivers +Drivers in this module provide an abstraction layer for different gorm or sql/db dialects, allowing the Orm module to work with various SQL databases. Each driver can have its own unique configuration and options, making it flexible to adapt to different database setups. + + +### Postgres +It is a `gorm` Postgres driver abstraction for Chaki + +#### Usage +```golang +func main() { + app := chaki.New() + + app.Use( + orm.Module(postgresdriver.New()), + ) + + app.Provide( + newRepository, + ) +} + + +type Repository struct { + gp orm.GormProvider +} + +func newRepository(gp orm.GormProvider) *Repository { + return &repository{ + gp: gp, + } +} + +func (repo *repository) someOperation(ctx context.Context) { + db := repo.gp.Get(ctx) + // you can use gorm instance +} + +``` + +#### Config +```yaml +gorm: + host: "string" #required + port: "number" #required + username: "string" #required + password: "string" #required + +``` + + +## Repository + +The Repository interface is an abstraction built on top of gorm, designed to minimize repetitive code for common database operations. This interface provides a standardized set of methods for interacting with the database, such as finding, saving, updating, and deleting records. + +```go + +type Repository[Id IdType, T any] interface { + FindById(ctx context.Context, id Id) (*T, error) + FindOne(ctx context.Context, q query.Query) (*T, error) + FindAll(ctx context.Context, q query.Query) ([]*T, error) + Update(ctx context.Context, q query.Query, update any) error + Save(ctx context.Context, t *T) (*T, error) + SaveAll(ctx context.Context, ts []*T) error + DeleteById(ctx context.Context, id Id) error + Delete(ctx context.Context, q query.Query) error + ListPageable(ctx context.Context, q query.Query, req PageableRequest) (*PageableResponse[*T], error) + ParseQuery(ctx context.Context, q query.Query) *gorm.DB + Context(ctx context.Context) *gorm.DB +} + +``` + +### Usage +```golang +// Model +type Foo struct { + Id int `gorm:"primaryKey"` + Bar string +} + +func NewRepository(gp orm.GormProvider) repository.Repository[int,Foo] { + return repository.New[int, Foo](gp) +} + +type Service struct { + fooRepo repository.Repository[int,Foo] +} + +func (s *service) GetFoo(ctx context.Context, id int) (*Foo, error) { + return s.fooRepo.FindById(ctx, id) +} + +``` + +### With Composition +Composition allows you to extend the base repository with custom methods, giving you the flexibility to add specific queries or operations while still leveraging the core repository functionality. + +```golang +type FooRepository struct { + repository.Repository[int, Foo] +} + +func NewRepository(gp gorm.Provider) *FooRepository { + return &FooRepository{ + Repository: repository.New[int, Foo] + } +} + +// Custom repo function +func (repo *FooRepository) AdvenceQuery(ctx context.Context, id int) (*Foo, error) { + // you can acces all base class functionalities here + return repo.FindById(ctx, id) +} + +func (repo *FooRepository) DirectGormQuery(ctx context.Context) error { + // you can get gorm instance here + gormdb := repo.Context(ctx) + + // gorm operations... + + return nil +} + +``` + +In this case, the FooRepository struct extends the base repository and adds custom methods like AdvenceQuery and DirectGormQuery, providing additional functionality while maintaining access to the core repository methods. \ No newline at end of file diff --git a/example/server-with-postgres/config.yaml b/example/server-with-postgres/config.yaml new file mode 100644 index 0000000..96e33ef --- /dev/null +++ b/example/server-with-postgres/config.yaml @@ -0,0 +1,5 @@ +gorm: + host: + port: + username: + password: \ No newline at end of file diff --git a/example/server-with-postgres/main.go b/example/server-with-postgres/main.go new file mode 100644 index 0000000..d5b0d2d --- /dev/null +++ b/example/server-with-postgres/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + + "github.com/Trendyol/chaki" + "github.com/Trendyol/chaki/logger" + "github.com/Trendyol/chaki/modules/orm" + postgresdriver "github.com/Trendyol/chaki/modules/orm/driver/postgres" + "github.com/Trendyol/chaki/modules/otel" + otelserver "github.com/Trendyol/chaki/modules/otel/server" + "github.com/Trendyol/chaki/modules/server" + "github.com/Trendyol/chaki/modules/server/controller" + "github.com/Trendyol/chaki/modules/server/middlewares" + "github.com/Trendyol/chaki/modules/server/route" + "github.com/Trendyol/chaki/modules/swagger" +) + +func main() { + app := chaki.New() + + app.WithOption( + chaki.WithConfigPath("config.yaml"), + ) + + app.Use( + otel.Module(), + orm.Module(postgresdriver.New()), + otelserver.Module(), + server.Module(), + swagger.Module(), + ) + + app.Provide( + middlewares.ErrHandler, + + newFooRepository, + newHelloController, + ) + + if err := app.Start(); err != nil { + logger.Fatal(err) + } +} + +type getFooReq struct { + Id int `query:"id"` +} + +type HelloController struct { + *controller.Base + repo *fooRepository +} + +func newHelloController(repo *fooRepository) controller.Controller { + return &HelloController{ + Base: controller.New("Hello Controller").SetPrefix("/hello"), + repo: repo, + } +} + +func (c *HelloController) Routes() []route.Route { + return []route.Route{ + route.Get("/", c.getFoo).Name("Get Foo"), + } +} + +func (c *HelloController) getFoo(ctx context.Context, req getFooReq) (*foo, error) { + logger.From(ctx).Info("trace id and spand id will be logged wiht message") + return c.repo.getFoo(ctx, req.Id) +} + +type foo struct { + Id int `gorm:"primaryKey"` + Bar string `gorm:"bar"` +} + +type fooRepository struct { + gp orm.GormProvider +} + +func newFooRepository(gp orm.GormProvider) *fooRepository { + return &fooRepository{gp} +} + +func (repo *fooRepository) getFoo(ctx context.Context, id int) (*foo, error) { + f := &foo{} + err := repo.gp.Get(ctx).Where("id = ?", id).Find(f).Error + if err != nil { + return nil, err + } + return f, nil +} diff --git a/go.mod b/go.mod index d6aefa6..47ca337 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 toolchain go1.21.1 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Trendyol/kafka-konsumer/v2 v2.3.3 github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 github.com/go-playground/validator/v10 v10.22.0 @@ -23,6 +24,8 @@ require ( go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20240119083558-1b970713d09a golang.org/x/sync v0.6.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.11 ) require ( @@ -43,6 +46,12 @@ require ( github.com/gofiber/adaptor/v2 v2.2.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.17.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect diff --git a/go.sum b/go.sum index 01bd6fa..be963d1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Trendyol/kafka-cronsumer v1.5.3 h1:I3x7KUceHlae69MyBYx6Vj1ctMexeIKEUq2xNg0wvG8= github.com/Trendyol/kafka-cronsumer v1.5.3/go.mod h1:VpweJmKY+6dppFhzWOZDbZfxBNuJkSxB12CcuZWBNFU= github.com/Trendyol/kafka-konsumer/v2 v2.3.3 h1:9l4ODhLkn7/MjiJzrItakAgz0ZbcW9fSb6I1I1oH8Kw= @@ -60,8 +62,21 @@ github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25d github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -130,6 +145,8 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -244,3 +261,7 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/modules/orm/driver/driver.go b/modules/orm/driver/driver.go new file mode 100644 index 0000000..4cb6418 --- /dev/null +++ b/modules/orm/driver/driver.go @@ -0,0 +1,10 @@ +package driver + +import ( + "github.com/Trendyol/chaki/config" + "gorm.io/gorm" +) + +type Driver interface { + BuildGorm(cfg *config.Config) (*gorm.DB, error) +} diff --git a/modules/orm/driver/postgres/postgres.go b/modules/orm/driver/postgres/postgres.go new file mode 100644 index 0000000..e35096d --- /dev/null +++ b/modules/orm/driver/postgres/postgres.go @@ -0,0 +1,38 @@ +package postgresdriver + +import ( + "fmt" + + "github.com/Trendyol/chaki/config" + "github.com/Trendyol/chaki/modules/orm/driver" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type postgresDriver struct{} + +func New() driver.Driver { + return &postgresDriver{} +} + +func (d *postgresDriver) BuildGorm(cfg *config.Config) (*gorm.DB, error) { + ormCfg := cfg.Of("gorm") + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable", + ormCfg.GetString("host"), + ormCfg.GetString("user"), + ormCfg.GetString("password"), + ormCfg.GetString("dbname"), + ormCfg.GetInt("port"), + ) + + pc := postgres.Config{ + DSN: dsn, + } + + if key := "dialect.drivername"; ormCfg.Exists(key) { + pc.DriverName = ormCfg.GetString(key) + } + + return gorm.Open(postgres.New(pc)) +} diff --git a/modules/orm/module.go b/modules/orm/module.go new file mode 100644 index 0000000..61160dd --- /dev/null +++ b/modules/orm/module.go @@ -0,0 +1,38 @@ +package orm + +import ( + "github.com/Trendyol/chaki" + "github.com/Trendyol/chaki/as" + "github.com/Trendyol/chaki/module" + "github.com/Trendyol/chaki/modules/orm/driver" + "github.com/Trendyol/chaki/modules/orm/tx" +) + +const ModuleName = "chaki-orm-module" + +var ( + asGromWrapper = as.Struct[GormWrapper]("gormwrappers") +) + +func Module(d driver.Driver, opts ...Option) *module.Module { + m := module.New(ModuleName) + o := buildOptions(opts...) + o.driver = d + + m.Provide( + chaki.Valuer(o), + asGromWrapper.Grouper(), + newGorm, + newGormProvider, + tx.NewTransactioner, + ) + + m.AddProvideHook( + module.ProvideHook{ + Wrap: asGromWrapper.Value, + Match: asGromWrapper.Match, + }, + ) + + return m +} diff --git a/modules/orm/option.go b/modules/orm/option.go new file mode 100644 index 0000000..6d72c3b --- /dev/null +++ b/modules/orm/option.go @@ -0,0 +1,21 @@ +package orm + +import "github.com/Trendyol/chaki/modules/orm/driver" + +type options struct { + driver driver.Driver +} + +type Option interface { + Apply(*options) *options +} + +func buildOptions(opts ...Option) *options { + o := &options{} + + for _, opt := range opts { + o = opt.Apply(o) + } + + return o +} diff --git a/modules/orm/orm.go b/modules/orm/orm.go new file mode 100644 index 0000000..15ff0d6 --- /dev/null +++ b/modules/orm/orm.go @@ -0,0 +1,46 @@ +package orm + +import ( + "context" + + "github.com/Trendyol/chaki/config" + "github.com/Trendyol/chaki/modules/orm/tx" + "github.com/Trendyol/chaki/util/wrapper" + "gorm.io/gorm" +) + +type GormWrapper wrapper.Wrapper[*gorm.DB] + +type GormProvider interface { + Get(ctx context.Context) *gorm.DB +} + +type provider struct { + db *gorm.DB +} + +func newGorm(cfg *config.Config, opts *options, wrappers []GormWrapper) (*gorm.DB, error) { + db, err := opts.driver.BuildGorm(cfg) + if err != nil { + return nil, err + } + + for _, wr := range wrappers { + db = wr(db) + } + + return db, err +} + +func newGormProvider(db *gorm.DB, _ *options) GormProvider { + return &provider{ + db: db, + } +} + +func (p *provider) Get(ctx context.Context) *gorm.DB { + if found := tx.From(ctx); found != nil { + return found + } + return p.db.WithContext(ctx) +} diff --git a/modules/orm/orm_test.go b/modules/orm/orm_test.go new file mode 100644 index 0000000..3b15350 --- /dev/null +++ b/modules/orm/orm_test.go @@ -0,0 +1,47 @@ +package orm + +import ( + "context" + "testing" + + "github.com/Trendyol/chaki/modules/orm/ormtest" + "github.com/Trendyol/chaki/modules/orm/tx" + "github.com/stretchr/testify/assert" +) + +func Test_provider_Get(t *testing.T) { + t.Run("it should get default gorm instance when context has no tx", func(t *testing.T) { + // Given + var ( + db, _, err = ormtest.NewPostgresMock() + ctx = context.Background() + gp = newGormProvider(db, &options{}) + ) + + // When + got := gp.Get(ctx) + + // Then + assert.NoError(t, err) + assert.NotNil(t, got) + }) + + t.Run("it should get gorm instance from when context has tx", func(t *testing.T) { + // Given + var ( + db, _, err = ormtest.NewPostgresMock() + txdb = db.Begin() + ctx = tx.WithContext(context.Background(), txdb) + gp = newGormProvider(db, &options{}) + ) + + defer txdb.Rollback() + + // When + got := gp.Get(ctx) + + // Then + assert.NoError(t, err) + assert.Equal(t, txdb, got) + }) +} diff --git a/modules/orm/ormtest/mock.go b/modules/orm/ormtest/mock.go new file mode 100644 index 0000000..fb276a3 --- /dev/null +++ b/modules/orm/ormtest/mock.go @@ -0,0 +1,31 @@ +package ormtest + +import ( + "database/sql" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func NewMock(dialectorGenerator func(db *sql.DB) gorm.Dialector) (*gorm.DB, sqlmock.Sqlmock, error) { + db, mock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + gormDB, err := gorm.Open(dialectorGenerator(db)) + if err != nil { + return nil, nil, err + } + + return gormDB, mock, nil +} + +func NewPostgresMock() (*gorm.DB, sqlmock.Sqlmock, error) { + return NewMock(func(db *sql.DB) gorm.Dialector { + return postgres.New(postgres.Config{ + Conn: db, + }) + }) +} diff --git a/modules/orm/query/query.go b/modules/orm/query/query.go new file mode 100644 index 0000000..dec4950 --- /dev/null +++ b/modules/orm/query/query.go @@ -0,0 +1,34 @@ +package query + +import ( + "fmt" + + "github.com/Trendyol/chaki/util/wrapper" + "gorm.io/gorm" +) + +type Query wrapper.Wrapper[*gorm.DB] + +func ByValue(field string, val any) Query { + return byMatch(field, "=", val) +} + +func ByIn[T any](field string, val []T) Query { + return byMatch(field, "IN", val) +} + +func ById[T any](id T) Query { + return func(d *gorm.DB) *gorm.DB { return d.Where(id) } +} + +func ByModel(m any) Query { + return func(d *gorm.DB) *gorm.DB { + return d.Where(m) + } +} + +func byMatch(field string, op string, val any) Query { + return func(d *gorm.DB) *gorm.DB { + return d.Where(fmt.Sprintf("%s %s ?", field, op), val) + } +} diff --git a/modules/orm/repository/page.go b/modules/orm/repository/page.go new file mode 100644 index 0000000..2f8fbf1 --- /dev/null +++ b/modules/orm/repository/page.go @@ -0,0 +1,15 @@ +package repository + +type PageableResponse[T any] struct { + Page int + Size int + Data []T + TotalCount int + TotalPage int +} + +type PageableRequest struct { + Page int + Size int + Sort []string +} diff --git a/modules/orm/repository/repository.go b/modules/orm/repository/repository.go new file mode 100644 index 0000000..81284c2 --- /dev/null +++ b/modules/orm/repository/repository.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "strings" + + "github.com/Trendyol/chaki/modules/orm" + "github.com/Trendyol/chaki/modules/orm/query" + "golang.org/x/sync/errgroup" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type IdType interface { + comparable +} + +var ( + ErrRecordNotFound = gorm.ErrRecordNotFound +) + +type Repository[Id IdType, T any] interface { + FindById(ctx context.Context, id Id) (*T, error) + FindOne(ctx context.Context, q query.Query) (*T, error) + FindAll(ctx context.Context, q query.Query) ([]*T, error) + Update(ctx context.Context, q query.Query, update any) error + Save(ctx context.Context, t *T) (*T, error) + SaveAll(ctx context.Context, ts []*T) error + DeleteById(ctx context.Context, id Id) error + Delete(ctx context.Context, q query.Query) error + ListPageable(ctx context.Context, q query.Query, req PageableRequest) (*PageableResponse[*T], error) + ParseQuery(ctx context.Context, q query.Query) *gorm.DB + Context(ctx context.Context) *gorm.DB +} + +type repository[Id IdType, T any] struct { + gp orm.GormProvider +} + +func New[Id IdType, T any](gp orm.GormProvider) Repository[Id, T] { + return &repository[Id, T]{ + gp: gp, + } +} + +func (r *repository[Id, T]) FindById(ctx context.Context, id Id) (*T, error) { + return r.FindOne(ctx, query.ById(id)) +} + +func (r *repository[Id, T]) FindOne(ctx context.Context, q query.Query) (*T, error) { + t := new(T) + err := r.ParseQuery(ctx, q).First(t).Error + return t, err +} + +func (r *repository[Id, T]) FindAll(ctx context.Context, q query.Query) ([]*T, error) { + a := make([]*T, 0) + err := r.ParseQuery(ctx, q).Find(&a).Error + return a, err +} + +func (r *repository[Id, T]) Update(ctx context.Context, q query.Query, update any) error { + return r.ParseQuery(ctx, q).Updates(update).Error +} + +func (r *repository[Id, T]) Save(ctx context.Context, t *T) (*T, error) { + err := r.Context(ctx).Clauses(clause.Returning{}).Save(t).Error + return t, err +} + +func (r *repository[Id, T]) SaveAll(ctx context.Context, ts []*T) error { + return r.Context(ctx).Save(ts).Error +} + +func (r *repository[Id, T]) DeleteById(ctx context.Context, id Id) error { + return r.Delete(ctx, query.ById(id)) +} + +func (r *repository[Id, T]) Delete(ctx context.Context, q query.Query) error { + return r.ParseQuery(ctx, q).Delete(new(T)).Error +} + +func (r *repository[Id, T]) ListPageable(ctx context.Context, q query.Query, req PageableRequest) (*PageableResponse[*T], error) { + var ( + offset = req.Page * req.Size + sort = strings.Join(req.Sort, ", ") + countq = r.ParseQuery(ctx, q) + resq = r.ParseQuery(ctx, q).Offset(offset).Limit(req.Size) + found = make([]*T, 0, req.Size) + count int64 + eg errgroup.Group + ) + + if sort != "" { + resq.Order(sort) + } + + eg.Go(func() error { + return resq.Find(&found).Error + }) + + eg.Go(func() error { + return countq.Count(&count).Error + }) + + if err := eg.Wait(); err != nil { + return nil, err + } + + return &PageableResponse[*T]{ + Page: req.Page, + Size: req.Size, + Data: found, + TotalCount: int(count), + }, nil + +} + +func (r *repository[Id, T]) ParseQuery(ctx context.Context, q query.Query) *gorm.DB { + return q(r.Context(ctx)) +} + +func (r *repository[Id, T]) Context(ctx context.Context) *gorm.DB { + return r.gp.Get(ctx).Model(new(T)) +} diff --git a/modules/orm/repository/repository_test.go b/modules/orm/repository/repository_test.go new file mode 100644 index 0000000..7660db2 --- /dev/null +++ b/modules/orm/repository/repository_test.go @@ -0,0 +1,211 @@ +package repository + +import ( + "context" + "database/sql/driver" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Trendyol/chaki/modules/orm" + "github.com/Trendyol/chaki/modules/orm/ormtest" + "github.com/Trendyol/chaki/modules/orm/query" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +type testGormProvider struct{ db *gorm.DB } + +func (p *testGormProvider) Get(context.Context) *gorm.DB { return p.db } + +type testModel struct { + Id int `gorm:"primaryKey"` + Foo string `gorm:"foo"` +} + +// gorm method for getting table name +func (tm *testModel) TableName() string { + return "test_table" +} + +func newTestRepository() (Repository[int, testModel], sqlmock.Sqlmock) { + var ( + db, mock, _ = ormtest.NewPostgresMock() + gp orm.GormProvider = &testGormProvider{db} + repo = New[int, testModel](gp) + ) + return repo, mock +} + +func buildRows(rows ...*testModel) *sqlmock.Rows { + r := sqlmock.NewRows([]string{"id", "foo"}) + for _, row := range rows { + idv, _ := driver.Int32.ConvertValue(row.Id) + foov, _ := driver.String.ConvertValue(row.Foo) + r.AddRow(idv, foov) + } + return r +} + +func Test_respository_FindById(t *testing.T) { + t.Run("it should run find by id query when valid requested", func(t *testing.T) { + // Given + repo, mock := newTestRepository() + m := &testModel{ + Id: 12, + Foo: "test", + } + mock. + ExpectQuery(`SELECT \* FROM "test_table" WHERE "test_table"\."id" = \$1`). + WithArgs(12, 1). // id and limit statement arguments + WillReturnRows(buildRows(m)) + + // When + got, err := repo.FindById(context.Background(), 12) + + // Then + assert.NoError(t, err) + assert.Equal(t, m, got) + + }) +} + +func Test_respository_FindOne(t *testing.T) { + t.Run("it should run find one query when valid requested", func(t *testing.T) { + // Given + repo, mock := newTestRepository() + m := &testModel{ + Id: 12, + Foo: "test", + } + + testQ := query.Query(func(d *gorm.DB) *gorm.DB { + return d.Where(&testModel{ + Foo: "test", + }) + }) + + mock. + ExpectQuery(`SELECT \* FROM "test_table" WHERE "test_table"\."foo" = \$1`). + WithArgs("test", 1). // id and limit statement arguments + WillReturnRows(buildRows(m)) + + // When + got, err := repo.FindOne(context.Background(), testQ) + + // Then + assert.NoError(t, err) + assert.Equal(t, m, got) + + }) +} + +func Test_respository_FindAll(t *testing.T) { + t.Run("it should run find all when valid requested", func(t *testing.T) { + // Given + repo, mock := newTestRepository() + testModels := []*testModel{ + {12, "test"}, + {35, "test"}, + } + + testQ := query.Query(func(d *gorm.DB) *gorm.DB { + return d.Where(&testModel{ + Foo: "test", + }) + }) + + mock. + ExpectQuery(`SELECT \* FROM "test_table" WHERE "test_table"\."foo" = \$1`). + WithArgs("test"). + WillReturnRows(buildRows(testModels...)) + + // When + got, err := repo.FindAll(context.Background(), testQ) + + // Then + assert.NoError(t, err) + assert.Equal(t, testModels, got) + + }) +} + +func Test_respository_Update(t *testing.T) { + t.Run("it should run update query when valid requested", func(t *testing.T) { + // Given + repo, mock := newTestRepository() + + u := &testModel{ + Foo: "test", + } + + mock.ExpectBegin() + mock. + ExpectExec(`UPDATE "test_table" SET "foo"=\$1 WHERE "test_table"\."id" = \$2`). + WithArgs("test", 12). + WillReturnResult(driver.RowsAffected(1)) + mock.ExpectCommit() + + // When + err := repo.Update(context.Background(), query.ById(12), u) + + // Then + assert.NoError(t, err) + + }) +} + +func Test_respository_SaveAll(t *testing.T) { + t.Run("it should run save all query when valid requested", func(t *testing.T) { + // Given + repo, mock := newTestRepository() + + u := []*testModel{ + { + Id: 12, + Foo: "test", + }, + { + Id: 14, + Foo: "test_2", + }, + } + + mock.ExpectBegin() + mock. + ExpectQuery(`INSERT INTO "test_table" \("foo","id"\) VALUES \(\$1,\$2\),\(\$3,\$4\) ON CONFLICT \("id"\) DO UPDATE SET "foo"="excluded"\."foo"`). + WithArgs("test", 12, "test_2", 14). + WillReturnRows(buildRows(u...)) + mock.ExpectCommit() + + // When + err := repo.SaveAll(context.Background(), u) + + // Then + assert.NoError(t, err) + + }) +} + +func Test_respository_Delete(t *testing.T) { + t.Run("it should run delete query when valid requested", func(t *testing.T) { + // Given + repo, mock := newTestRepository() + + q := query.ById(12) + + mock.ExpectBegin() + mock. + ExpectExec(`DELETE FROM "test_table" WHERE "test_table"\."id" = \$1`). + WithArgs(12). + WillReturnResult(driver.RowsAffected(1)) + + mock.ExpectCommit() + + // When + err := repo.Delete(context.Background(), q) + + // Then + assert.NoError(t, err) + + }) +} diff --git a/modules/orm/tx/mock/txmock.go b/modules/orm/tx/mock/txmock.go new file mode 100644 index 0000000..7507f9d --- /dev/null +++ b/modules/orm/tx/mock/txmock.go @@ -0,0 +1,19 @@ +package txmock + +import ( + "context" + "database/sql" + + "github.com/Trendyol/chaki/modules/orm/tx" +) + +type transactioner struct { +} + +func New() tx.Transactioner { + return &transactioner{} +} + +func (t *transactioner) Transaction(ctx context.Context, f func(context.Context) error, opts ...*sql.TxOptions) error { + return f(ctx) +} diff --git a/modules/orm/tx/txer.go b/modules/orm/tx/txer.go new file mode 100644 index 0000000..5007358 --- /dev/null +++ b/modules/orm/tx/txer.go @@ -0,0 +1,39 @@ +package tx + +import ( + "context" + "database/sql" + + "github.com/Trendyol/chaki/util/appctx" + "gorm.io/gorm" +) + +var txCtxValuer = appctx.NewValuer[*gorm.DB]("tx_gorm_key") + +func From(ctx context.Context) *gorm.DB { + return txCtxValuer.Get(ctx) +} + +func WithContext(parent context.Context, tx *gorm.DB) context.Context { + return txCtxValuer.Set(parent, tx) +} + +type Transactioner interface { + Transaction(ctx context.Context, f func(ctx context.Context) error, opts ...*sql.TxOptions) error +} + +type txer struct { + db *gorm.DB +} + +func NewTransactioner(db *gorm.DB) Transactioner { + return &txer{ + db: db, + } +} + +func (txr *txer) Transaction(ctx context.Context, f func(ctx context.Context) error, opts ...*sql.TxOptions) error { + return txr.db.Transaction(func(tx *gorm.DB) error { + return f(txCtxValuer.Set(ctx, tx.WithContext(ctx))) + }, opts...) +} diff --git a/modules/orm/tx/txer_test.go b/modules/orm/tx/txer_test.go new file mode 100644 index 0000000..3fae40b --- /dev/null +++ b/modules/orm/tx/txer_test.go @@ -0,0 +1,74 @@ +package tx + +import ( + "context" + "database/sql/driver" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Trendyol/chaki/modules/orm/ormtest" + "github.com/stretchr/testify/assert" +) + +type testEntity struct{ Id int } + +func (te *testEntity) TableName() string { return "test" } + +func Test_txer_Transaction(t *testing.T) { + t.Run("it should begin tx and commit when no error returned", func(t *testing.T) { + // Given + var ( + db, mock, err = ormtest.NewPostgresMock() + txr = NewTransactioner(db) + q = `.*` + ctx = context.Background() + got = &testEntity{} + ) + + rowval, _ := driver.Int32.ConvertValue(12) + + // config for enable mocking db transactions + db.PrepareStmt = true + + assert.NoError(t, err) + + mock.ExpectBegin() + mock.ExpectQuery(q).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(rowval)) + mock.ExpectCommit() + + // When + err = txr.Transaction(ctx, func(ctx context.Context) error { + return From(ctx).Where(q).First(got).Error + }) + + // Then + assert.Equal(t, 12, got.Id) + assert.NoError(t, err) + }) + + t.Run("it should begin tx and rollback when error occured", func(t *testing.T) { + // Given + var ( + db, mock, err = ormtest.NewPostgresMock() + txr = NewTransactioner(db) + ctx = context.Background() + errTest = errors.New("test error") + ) + + // config for enable mocking db transactions + db.PrepareStmt = true + + assert.NoError(t, err) + mock.ExpectBegin() + mock.ExpectRollback() + + // When + err = txr.Transaction(ctx, func(ctx context.Context) error { + return errTest + }) + + // Then + assert.Equal(t, errTest, err) + }) +}