diff --git a/contracts/database/factory/factory.go b/contracts/database/factory/factory.go new file mode 100644 index 000000000..31da15ca1 --- /dev/null +++ b/contracts/database/factory/factory.go @@ -0,0 +1,5 @@ +package factory + +type Factory interface { + Definition() map[string]any +} diff --git a/contracts/database/orm/factory.go b/contracts/database/orm/factory.go new file mode 100644 index 000000000..d37d4b450 --- /dev/null +++ b/contracts/database/orm/factory.go @@ -0,0 +1,9 @@ +package orm + +//go:generate mockery --name=Factory +type Factory interface { + Times(count int) Factory + Create(value any) error + CreateQuietly(value any) error + Make(value any) error +} diff --git a/contracts/database/orm/mocks/Factory.go b/contracts/database/orm/mocks/Factory.go new file mode 100644 index 000000000..a1c0bb8cc --- /dev/null +++ b/contracts/database/orm/mocks/Factory.go @@ -0,0 +1,85 @@ +// Code generated by mockery v2.30.1. DO NOT EDIT. + +package mocks + +import ( + orm "github.com/goravel/framework/contracts/database/orm" + mock "github.com/stretchr/testify/mock" +) + +// Factory is an autogenerated mock type for the Factory type +type Factory struct { + mock.Mock +} + +// Create provides a mock function with given fields: value +func (_m *Factory) Create(value interface{}) error { + ret := _m.Called(value) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// CreateQuietly provides a mock function with given fields: value +func (_m *Factory) CreateQuietly(value interface{}) error { + ret := _m.Called(value) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Make provides a mock function with given fields: value +func (_m *Factory) Make(value interface{}) error { + ret := _m.Called(value) + + var r0 error + if rf, ok := ret.Get(0).(func(interface{}) error); ok { + r0 = rf(value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Times provides a mock function with given fields: count +func (_m *Factory) Times(count int) orm.Factory { + ret := _m.Called(count) + + var r0 orm.Factory + if rf, ok := ret.Get(0).(func(int) orm.Factory); ok { + r0 = rf(count) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(orm.Factory) + } + } + + return r0 +} + +// NewFactory creates a new instance of Factory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *Factory { + mock := &Factory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/contracts/database/orm/mocks/Orm.go b/contracts/database/orm/mocks/Orm.go index 4fb62d3d5..a0c653267 100644 --- a/contracts/database/orm/mocks/Orm.go +++ b/contracts/database/orm/mocks/Orm.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.30.1. DO NOT EDIT. package mocks @@ -37,6 +37,10 @@ func (_m *Orm) DB() (*sql.DB, error) { ret := _m.Called() var r0 *sql.DB + var r1 error + if rf, ok := ret.Get(0).(func() (*sql.DB, error)); ok { + return rf() + } if rf, ok := ret.Get(0).(func() *sql.DB); ok { r0 = rf() } else { @@ -45,7 +49,6 @@ func (_m *Orm) DB() (*sql.DB, error) { } } - var r1 error if rf, ok := ret.Get(1).(func() error); ok { r1 = rf() } else { @@ -55,6 +58,22 @@ func (_m *Orm) DB() (*sql.DB, error) { return r0, r1 } +// Factory provides a mock function with given fields: +func (_m *Orm) Factory() orm.Factory { + ret := _m.Called() + + var r0 orm.Factory + if rf, ok := ret.Get(0).(func() orm.Factory); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(orm.Factory) + } + } + + return r0 +} + // Observe provides a mock function with given fields: model, observer func (_m *Orm) Observe(model interface{}, observer orm.Observer) { _m.Called(model, observer) @@ -106,13 +125,12 @@ func (_m *Orm) WithContext(ctx context.Context) orm.Orm { return r0 } -type mockConstructorTestingTNewOrm interface { +// NewOrm creates a new instance of Orm. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOrm(t interface { mock.TestingT Cleanup(func()) -} - -// NewOrm creates a new instance of Orm. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewOrm(t mockConstructorTestingTNewOrm) *Orm { +}) *Orm { mock := &Orm{} mock.Mock.Test(t) diff --git a/contracts/database/orm/orm.go b/contracts/database/orm/orm.go index 5297a3552..cee490d0b 100644 --- a/contracts/database/orm/orm.go +++ b/contracts/database/orm/orm.go @@ -10,6 +10,7 @@ type Orm interface { Connection(name string) Orm DB() (*sql.DB, error) Query() Query + Factory() Factory Observe(model any, observer Observer) Transaction(txFunc func(tx Transaction) error) error WithContext(ctx context.Context) Orm diff --git a/database/console/factory_make_command.go b/database/console/factory_make_command.go new file mode 100644 index 000000000..19a460b7d --- /dev/null +++ b/database/console/factory_make_command.go @@ -0,0 +1,97 @@ +package console + +import ( + "os" + "path/filepath" + "strings" + + "github.com/gookit/color" + + "github.com/goravel/framework/contracts/console" + "github.com/goravel/framework/contracts/console/command" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/str" +) + +type FactoryMakeCommand struct { +} + +func NewFactoryMakeCommand() *FactoryMakeCommand { + return &FactoryMakeCommand{} +} + +// Signature The name and signature of the console command. +func (receiver *FactoryMakeCommand) Signature() string { + return "make:factory" +} + +// Description The console command description. +func (receiver *FactoryMakeCommand) Description() string { + return "Create a new factory class" +} + +// Extend The console command extend. +func (receiver *FactoryMakeCommand) Extend() command.Extend { + return command.Extend{ + Category: "make", + } +} + +// Handle Execute the console command. +func (receiver *FactoryMakeCommand) Handle(ctx console.Context) error { + name := ctx.Argument(0) + if name == "" { + color.Redln("Not enough arguments (missing: name)") + + return nil + } + + if err := file.Create(receiver.getPath(name), receiver.populateStub(receiver.getStub(), name)); err != nil { + return err + } + + color.Greenln("Factory created successfully") + + return nil +} + +func (receiver *FactoryMakeCommand) getStub() string { + return Stubs{}.Factory() +} + +// populateStub Populate the place-holders in the command stub. +func (receiver *FactoryMakeCommand) populateStub(stub string, name string) string { + modelName, packageName, _ := parseName(name, "factories") + + stub = strings.ReplaceAll(stub, "DummyFactory", str.Case2Camel(modelName)) + stub = strings.ReplaceAll(stub, "DummyPackage", packageName) + + return stub +} + +// getPath Get the full path to the command. +func (receiver *FactoryMakeCommand) getPath(name string) string { + pwd, _ := os.Getwd() + + modelName, _, folderPath := parseName(name, "factories") + + return filepath.Join(pwd, "database", "factories", folderPath, str.Camel2Case(modelName)+".go") +} + +// parseName Parse the name to get the model name, package name and folder path. +func parseName(name string, packageName string) (string, string, string) { + name = strings.TrimSuffix(name, ".go") + + segments := strings.Split(name, "/") + + modelName := segments[len(segments)-1] + + folderPath := "" + + if len(segments) > 1 { + folderPath = filepath.Join(segments[:len(segments)-1]...) + packageName = segments[len(segments)-2] + } + + return modelName, packageName, folderPath +} diff --git a/database/console/factory_make_command_test.go b/database/console/factory_make_command_test.go new file mode 100644 index 000000000..510a663a6 --- /dev/null +++ b/database/console/factory_make_command_test.go @@ -0,0 +1,31 @@ +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + consolemocks "github.com/goravel/framework/contracts/console/mocks" + "github.com/goravel/framework/support/file" +) + +func TestFactoryMakeCommand(t *testing.T) { + factoryMakeCommand := &FactoryMakeCommand{} + mockContext := &consolemocks.Context{} + mockContext.On("Argument", 0).Return("").Once() + assert.Nil(t, factoryMakeCommand.Handle(mockContext)) + + mockContext.On("Argument", 0).Return("UserFactory").Once() + assert.Nil(t, factoryMakeCommand.Handle(mockContext)) + assert.True(t, file.Exists("database/factories/user_factory.go")) + assert.True(t, file.Contain("database/factories/user_factory.go", "package factories")) + assert.True(t, file.Contain("database/factories/user_factory.go", "type UserFactory struct")) + assert.Nil(t, file.Remove("database")) + + mockContext.On("Argument", 0).Return("subdir/DemoFactory").Once() + assert.Nil(t, factoryMakeCommand.Handle(mockContext)) + assert.True(t, file.Exists("database/factories/subdir/demo_factory.go")) + assert.True(t, file.Contain("database/factories/subdir/demo_factory.go", "package subdir")) + assert.True(t, file.Contain("database/factories/subdir/demo_factory.go", "type DemoFactory struct")) + assert.Nil(t, file.Remove("database")) +} diff --git a/database/console/stubs.go b/database/console/stubs.go index 78c9077e8..ac57aeca8 100644 --- a/database/console/stubs.go +++ b/database/console/stubs.go @@ -89,3 +89,16 @@ func (s *DummySeeder) Run() error { } ` } + +func (r Stubs) Factory() string { + return `package DummyPackage + +type DummyFactory struct { +} + +// Definition Define the model's default state. +func (f *DummyFactory) Definition() map[string]any { + return nil +} +` +} diff --git a/database/gorm/factory.go b/database/gorm/factory.go new file mode 100644 index 000000000..6fb78127e --- /dev/null +++ b/database/gorm/factory.go @@ -0,0 +1,130 @@ +package gorm + +import ( + "errors" + "fmt" + "reflect" + + "github.com/mitchellh/mapstructure" + + "github.com/goravel/framework/contracts/database/factory" + ormcontract "github.com/goravel/framework/contracts/database/orm" +) + +type FactoryImpl struct { + count *int // number of models to generate + query ormcontract.Query // query instance +} + +func NewFactoryImpl(query ormcontract.Query) *FactoryImpl { + return &FactoryImpl{ + query: query, + } +} + +// Times Specify the number of models you wish to create / make. +func (f *FactoryImpl) Times(count int) ormcontract.Factory { + return f.newInstance(map[string]any{"count": count}) +} + +// Create a model and persist it in the database. +func (f *FactoryImpl) Create(value any) error { + if err := f.Make(value); err != nil { + return err + } + return f.query.Create(value) +} + +// CreateQuietly create a model and persist it in the database without firing any events. +func (f *FactoryImpl) CreateQuietly(value any) error { + if err := f.Make(value); err != nil { + return err + } + return f.query.WithoutEvents().Create(value) +} + +// Make a model instance that's not persisted in the database. +func (f *FactoryImpl) Make(value any) error { + reflectValue := reflect.Indirect(reflect.ValueOf(value)) + switch reflectValue.Kind() { + case reflect.Array, reflect.Slice: + count := 1 + if f.count != nil { + count = *f.count + } + for i := 0; i < count; i++ { + elemValue := reflect.New(reflectValue.Type().Elem()).Interface() + attributes, err := f.getRawAttributes(elemValue) + if err != nil { + return err + } + if attributes == nil { + return errors.New("failed to get raw attributes") + } + if err := mapstructure.Decode(attributes, elemValue); err != nil { + return err + } + reflectValue = reflect.Append(reflectValue, reflect.ValueOf(elemValue).Elem()) + } + reflect.ValueOf(value).Elem().Set(reflectValue) + return nil + default: + attributes, err := f.getRawAttributes(value) + if err != nil { + return err + } + if attributes == nil { + return errors.New("failed to get raw attributes") + } + if err := mapstructure.Decode(attributes, value); err != nil { + return err + } + return nil + } +} + +func (f *FactoryImpl) getRawAttributes(value any) (any, error) { + modelFactoryMethod := reflect.ValueOf(value).MethodByName("Factory") + if !modelFactoryMethod.IsValid() { + return nil, errors.New("factory method not found") + } + if !modelFactoryMethod.IsValid() { + return nil, errors.New("factory method not found for value type " + reflect.TypeOf(value).String()) + } + factoryResult := modelFactoryMethod.Call(nil) + if len(factoryResult) == 0 { + return nil, errors.New("factory method returned nothing") + } + factoryInstance, ok := factoryResult[0].Interface().(factory.Factory) + if !ok { + expectedType := reflect.TypeOf((*factory.Factory)(nil)).Elem() + return nil, fmt.Errorf("factory method does not return a factory instance (expected %v)", expectedType) + } + definitionMethod := reflect.ValueOf(factoryInstance).MethodByName("Definition") + if !definitionMethod.IsValid() { + return nil, errors.New("definition method not found in factory instance") + } + definitionResult := definitionMethod.Call(nil) + if len(definitionResult) == 0 { + return nil, errors.New("definition method returned nothing") + } + + return definitionResult[0].Interface(), nil +} + +// newInstance create a new factory instance. +func (f *FactoryImpl) newInstance(attributes ...map[string]any) ormcontract.Factory { + instance := &FactoryImpl{ + count: f.count, + query: f.query, + } + + if len(attributes) > 0 { + attr := attributes[0] + if count, ok := attr["count"].(int); ok { + instance.count = &count + } + } + + return instance +} diff --git a/database/gorm/factory_test.go b/database/gorm/factory_test.go new file mode 100644 index 000000000..71f787898 --- /dev/null +++ b/database/gorm/factory_test.go @@ -0,0 +1,125 @@ +package gorm + +import ( + "log" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + ormcontract "github.com/goravel/framework/contracts/database/orm" +) + +type UserFactory struct { +} + +func (u *UserFactory) Definition() map[string]any { + faker := gofakeit.New(0) + return map[string]any{ + "name": faker.Name(), + "avatar": faker.Email(), + "created_at": faker.Date(), + "updated_at": faker.Date(), + } +} + +type FactoryTestSuite struct { + suite.Suite + factory *FactoryImpl + query ormcontract.Query +} + +func TestFactoryTestSuite(t *testing.T) { + if testing.Short() { + t.Skip("Skipping tests of using docker") + } + + mysqlDocker := NewMysqlDocker() + mysqlPool, mysqlResource, mysqlQuery, err := mysqlDocker.New() + if err != nil { + log.Fatalf("Init mysql error: %s", err) + } + suite.Run(t, &FactoryTestSuite{ + query: mysqlQuery, + }) + assert.Nil(t, mysqlPool.Purge(mysqlResource)) +} + +func (s *FactoryTestSuite) SetupTest() { + s.factory = NewFactoryImpl(s.query) +} + +func (s *FactoryTestSuite) TestTimes() { + var user []User + s.Nil(s.factory.Times(2).Make(&user)) + s.True(len(user) == 2) + s.True(len(user[0].Name) > 0) + s.True(len(user[1].Name) > 0) +} + +func (s *FactoryTestSuite) TestCreate() { + var user []User + s.Nil(s.factory.Create(&user)) + s.True(len(user) == 1) + s.True(user[0].ID > 0) + s.True(len(user[0].Name) > 0) + + var user1 User + s.Nil(s.factory.Create(&user1)) + s.NotNil(user1) + s.True(user1.ID > 0) + + var user3 []User + s.Nil(s.factory.Times(2).Create(&user3)) + s.True(len(user3) == 2) + s.True(user3[0].ID > 0) + s.True(user3[1].ID > 0) + s.True(len(user3[0].Name) > 0) + s.True(len(user3[1].Name) > 0) +} + +func (s *FactoryTestSuite) TestCreateQuietly() { + var user []User + s.Nil(s.factory.CreateQuietly(&user)) + s.True(len(user) == 1) + s.True(user[0].ID > 0) + s.True(len(user[0].Name) > 0) + + var user1 User + s.Nil(s.factory.CreateQuietly(&user1)) + s.NotNil(user1) + s.True(user1.ID > 0) + + var user3 []User + s.Nil(s.factory.Times(2).CreateQuietly(&user3)) + s.True(len(user3) == 2) + s.True(user3[0].ID > 0) + s.True(user3[1].ID > 0) + s.True(len(user3[0].Name) > 0) + s.True(len(user3[1].Name) > 0) +} + +func (s *FactoryTestSuite) TestMake() { + var user []User + s.Nil(s.factory.Make(&user)) + s.True(len(user) == 1) + s.True(len(user[0].Name) > 0) +} + +func (s *FactoryTestSuite) TestGetRawAttributes() { + var author Author + attributes, err := s.factory.getRawAttributes(&author) + s.NotNil(err) + s.Nil(attributes) + + var house House + attributes, err = s.factory.getRawAttributes(&house) + s.NotNil(err) + s.Nil(attributes) + + var user User + attributes, err = s.factory.getRawAttributes(&user) + s.Nil(err) + s.NotNil(attributes) +} diff --git a/database/gorm/query_test.go b/database/gorm/query_test.go index 61899f29e..df84f1c8d 100644 --- a/database/gorm/query_test.go +++ b/database/gorm/query_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/suite" _ "gorm.io/driver/postgres" + "github.com/goravel/framework/contracts/database/factory" ormcontract "github.com/goravel/framework/contracts/database/orm" databasedb "github.com/goravel/framework/database/db" "github.com/goravel/framework/database/orm" @@ -35,6 +36,10 @@ type User struct { Roles []*Role `gorm:"many2many:role_user"` } +func (u *User) Factory() factory.Factory { + return &UserFactory{} +} + func (u *User) DispatchesEvents() map[ormcontract.EventType]func(ormcontract.Event) error { return map[ormcontract.EventType]func(ormcontract.Event) error{ ormcontract.EventCreating: func(event ormcontract.Event) error { @@ -287,6 +292,10 @@ type House struct { HouseableType string } +func (h *House) Factory() string { + return "house" +} + type Phone struct { orm.Model Name string diff --git a/database/orm.go b/database/orm.go index 383b4f935..b81d8d50d 100644 --- a/database/orm.go +++ b/database/orm.go @@ -72,6 +72,10 @@ func (r *OrmImpl) Query() ormcontract.Query { return r.query } +func (r *OrmImpl) Factory() ormcontract.Factory { + return databasegorm.NewFactoryImpl(r.query) +} + func (r *OrmImpl) Observe(model any, observer ormcontract.Observer) { orm.Observers = append(orm.Observers, orm.Observer{ Model: model, diff --git a/database/orm_test.go b/database/orm_test.go index 3f462ab64..c593710ad 100644 --- a/database/orm_test.go +++ b/database/orm_test.go @@ -138,6 +138,14 @@ func (s *OrmSuite) TestQuery() { } } +func (s *OrmSuite) TestFactory() { + s.NotNil(s.orm.Factory()) + + for _, connection := range connections { + s.NotNil(s.orm.Connection(connection.String()).Factory()) + } +} + func (s *OrmSuite) TestObserve() { s.orm.Observe(User{}, &UserObserver{}) diff --git a/database/service_provider.go b/database/service_provider.go index 943ba78ab..315f57f41 100644 --- a/database/service_provider.go +++ b/database/service_provider.go @@ -52,5 +52,6 @@ func (database *ServiceProvider) registerCommands(app foundation.Application) { console.NewObserverMakeCommand(), console.NewSeedCommand(config, seeder), console.NewSeederMakeCommand(), + console.NewFactoryMakeCommand(), }) } diff --git a/go.mod b/go.mod index b58db2074..81091f3c8 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/RichardKnop/logging v0.0.0-20190827224416-1a693bdd4fae // indirect github.com/aws/aws-sdk-go v1.37.16 // indirect + github.com/brianvoe/gofakeit/v6 v6.23.0 github.com/cenkalti/backoff/v4 v4.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect diff --git a/go.sum b/go.sum index c53ffd691..2ad512372 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/aws/aws-sdk-go v1.37.16 h1:Q4YOP2s00NpB9wfmTDZArdcLRuG9ijbnoAwTW3ivle github.com/aws/aws-sdk-go v1.37.16/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/brianvoe/gofakeit/v6 v6.23.0 h1:pgVhyWpYq4e0GEVCh2gdZnS/nBX+8SnyTBliHg5xjks= +github.com/brianvoe/gofakeit/v6 v6.23.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM= github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=