diff --git a/apis/collection.go b/apis/collection.go index d4e8af9a..cff3afe0 100644 --- a/apis/collection.go +++ b/apis/collection.go @@ -21,7 +21,7 @@ func BindCollectionApi(app core.App, rg *echo.Group) { subGroup.GET("/:collection", api.view) subGroup.PATCH("/:collection", api.update) subGroup.DELETE("/:collection", api.delete) - subGroup.POST("/import", api.bulkImport) + subGroup.PUT("/import", api.bulkImport) } type collectionApi struct { diff --git a/forms/admin_upsert.go b/forms/admin_upsert.go index 143e3afa..a4690003 100644 --- a/forms/admin_upsert.go +++ b/forms/admin_upsert.go @@ -4,32 +4,58 @@ import ( validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/forms/validators" "github.com/pocketbase/pocketbase/models" ) -// AdminUpsert defines an admin upsert (create/update) form. +// AdminUpsert specifies a [models.Admin] upsert (create/update) form. type AdminUpsert struct { - app core.App - admin *models.Admin - isCreate bool + config AdminUpsertConfig + admin *models.Admin + Id string `form:"id" json:"id"` Avatar int `form:"avatar" json:"avatar"` Email string `form:"email" json:"email"` Password string `form:"password" json:"password"` PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` } -// NewAdminUpsert creates new upsert form for the provided admin model -// (pass an empty admin model instance (`&models.Admin{}`) for create). +// AdminUpsertConfig is the [AdminUpsert] factory initializer config. +// +// NB! Dao is a required struct member. +type AdminUpsertConfig struct { + Dao *daos.Dao +} + +// NewAdminUpsert creates a new [AdminUpsert] form with initializer +// config created from the provided [core.App] and [models.Admin] instances +// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`). +// +// This factory method is used primarily for convenience (and backward compatibility). +// If you want to submit the form as part of another transaction, use +// [NewAdminUpsertWithConfig] with Dao configured to your txDao. func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert { + return NewAdminUpsertWithConfig(AdminUpsertConfig{ + Dao: app.Dao(), + }, admin) +} + +// NewAdminUpsertWithConfig creates a new [AdminUpsert] form +// with the provided config and [models.Admin] instance or panics on invalid configuration +// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`). +func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert { form := &AdminUpsert{ - app: app, - admin: admin, - isCreate: !admin.HasId(), + config: config, + admin: admin, + } + + if form.config.Dao == nil || form.admin == nil { + panic("Invalid initializer config or nil upsert model.") } // load defaults + form.Id = admin.Id form.Avatar = admin.Avatar form.Email = admin.Email @@ -39,6 +65,13 @@ func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert { // Validate makes the form validatable by implementing [validation.Validatable] interface. func (form *AdminUpsert) Validate() error { return validation.ValidateStruct(form, + validation.Field( + &form.Id, + validation.When( + form.admin.IsNew(), + validation.Length(models.DefaultIdLength, models.DefaultIdLength), + ).Else(validation.In(form.admin.Id)), + ), validation.Field( &form.Avatar, validation.Min(0), @@ -53,7 +86,7 @@ func (form *AdminUpsert) Validate() error { ), validation.Field( &form.Password, - validation.When(form.isCreate, validation.Required), + validation.When(form.admin.IsNew(), validation.Required), validation.Length(10, 100), ), validation.Field( @@ -67,7 +100,7 @@ func (form *AdminUpsert) Validate() error { func (form *AdminUpsert) checkUniqueEmail(value any) error { v, _ := value.(string) - if form.app.Dao().IsAdminEmailUnique(v, form.admin.Id) { + if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) { return nil } @@ -83,6 +116,12 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error { return err } + // custom insertion id can be set only on create + if form.admin.IsNew() && form.Id != "" { + form.admin.MarkAsNew() + form.admin.SetId(form.Id) + } + form.admin.Avatar = form.Avatar form.admin.Email = form.Email @@ -91,6 +130,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error { } return runInterceptors(func() error { - return form.app.Dao().SaveAdmin(form.admin) + return form.config.Dao.SaveAdmin(form.admin) }, interceptors...) } diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go index 0435692f..e4babdd9 100644 --- a/forms/collection_upsert.go +++ b/forms/collection_upsert.go @@ -39,7 +39,8 @@ type CollectionUpsertConfig struct { } // NewCollectionUpsert creates a new [CollectionUpsert] form with initializer -// config created from the provided [core.App] and [models.Collection] instances. +// config created from the provided [core.App] and [models.Collection] instances +// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`). // // This factory method is used primarily for convenience (and backward compatibility). // If you want to submit the form as part of another transaction, use @@ -52,7 +53,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio // NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form // with the provided config and [models.Collection] instance or panics on invalid configuration -// (for create you could pass a pointer to an empty Collection instance - `&models.Collection{}`). +// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`). func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert { form := &CollectionUpsert{ config: config, @@ -88,7 +89,10 @@ func (form *CollectionUpsert) Validate() error { return validation.ValidateStruct(form, validation.Field( &form.Id, - validation.Length(models.DefaultIdLength, models.DefaultIdLength), + validation.When( + form.collection.IsNew(), + validation.Length(models.DefaultIdLength, models.DefaultIdLength), + ).Else(validation.In(form.collection.Id)), ), validation.Field( &form.System, diff --git a/forms/record_upsert.go b/forms/record_upsert.go index 7e57c7b9..b4a9547e 100644 --- a/forms/record_upsert.go +++ b/forms/record_upsert.go @@ -9,40 +9,74 @@ import ( "regexp" "strconv" + validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/forms/validators" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/rest" "github.com/spf13/cast" ) -// RecordUpsert defines a Record upsert form. +// RecordUpsert specifies a [models.Record] upsert (create/update) form. type RecordUpsert struct { - app core.App + config RecordUpsertConfig record *models.Record - isCreate bool filesToDelete []string // names list filesToUpload []*rest.UploadedFile + Id string `form:"id" json:"id"` Data map[string]any `json:"data"` } -// NewRecordUpsert creates a new Record upsert form. -// (pass a new Record model instance (`models.NewRecord(...)`) for create). +// RecordUpsertConfig is the [RecordUpsert] factory initializer config. +// +// NB! Dao and FilesystemFactory are required struct members. +type RecordUpsertConfig struct { + Dao *daos.Dao + FilesystemFactory func() (*filesystem.System, error) + IsDebug bool +} + +// NewRecordUpsert creates a new [RecordUpsert] form with initializer +// config created from the provided [core.App] and [models.Record] instances +// (for create you could pass a pointer to an empty Record - `&models.Record{}`). +// +// This factory method is used primarily for convenience (and backward compatibility). +// If you want to submit the form as part of another transaction, use +// [NewRecordUpsertWithConfig] with Dao configured to your txDao. func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert { + return NewRecordUpsertWithConfig(RecordUpsertConfig{ + Dao: app.Dao(), + FilesystemFactory: app.NewFilesystem, + IsDebug: app.IsDebug(), + }, record) +} + +// NewRecordUpsertWithConfig creates a new [RecordUpsert] form +// with the provided config and [models.Record] instance or panics on invalid configuration +// (for create you could pass a pointer to an empty Record - `&models.Record{}`). +func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) *RecordUpsert { form := &RecordUpsert{ - app: app, + config: config, record: record, - isCreate: !record.HasId(), filesToDelete: []string{}, filesToUpload: []*rest.UploadedFile{}, } + if form.config.Dao == nil || + form.config.FilesystemFactory == nil || + form.record == nil { + panic("Invalid initializer config or nil upsert model.") + } + + form.Id = record.Id + form.Data = map[string]any{} for _, field := range record.Collection().Schema.Fields() { form.Data[field.Name] = record.GetDataValue(field.Name) @@ -136,6 +170,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error { return err } + if id, ok := requestData["id"]; ok { + form.Id = cast.ToString(id) + } + // extend base data with the extracted one extendedData := form.record.Data() rawData, err := json.Marshal(requestData) @@ -202,7 +240,7 @@ func (form *RecordUpsert) LoadData(r *http.Request) error { // check if there are any new uploaded form files files, err := rest.FindUploadedFiles(r, key) if err != nil { - if form.app.IsDebug() { + if form.config.IsDebug { log.Printf("%q uploaded file error: %v\n", key, err) } @@ -234,8 +272,23 @@ func (form *RecordUpsert) LoadData(r *http.Request) error { // Validate makes the form validatable by implementing [validation.Validatable] interface. func (form *RecordUpsert) Validate() error { + // base form fields validator + baseFieldsErrors := validation.ValidateStruct(form, + validation.Field( + &form.Id, + validation.When( + form.record.IsNew(), + validation.Length(models.DefaultIdLength, models.DefaultIdLength), + ).Else(validation.In(form.record.Id)), + ), + ) + if baseFieldsErrors != nil { + return baseFieldsErrors + } + + // record data validator dataValidator := validators.NewRecordDataValidator( - form.app.Dao(), + form.config.Dao, form.record, form.filesToUpload, ) @@ -252,12 +305,18 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error return err } + // custom insertion id can be set only on create + if form.record.IsNew() && form.Id != "" { + form.record.MarkAsNew() + form.record.SetId(form.Id) + } + // bulk load form data if err := form.record.Load(form.Data); err != nil { return err } - return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { + return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error { tx, ok := txDao.DB().(*dbx.Tx) if !ok { return errors.New("failed to get transaction db") @@ -285,13 +344,19 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error { return err } + // custom insertion id can be set only on create + if form.record.IsNew() && form.Id != "" { + form.record.MarkAsNew() + form.record.SetId(form.Id) + } + // bulk load form data if err := form.record.Load(form.Data); err != nil { return err } return runInterceptors(func() error { - return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { + return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error { // persist record model if err := txDao.SaveRecord(form.record); err != nil { return err @@ -322,7 +387,7 @@ func (form *RecordUpsert) processFilesToUpload() error { return errors.New("The record is not persisted yet.") } - fs, err := form.app.NewFilesystem() + fs, err := form.config.FilesystemFactory() if err != nil { return err } @@ -358,7 +423,7 @@ func (form *RecordUpsert) processFilesToDelete() error { return errors.New("The record is not persisted yet.") } - fs, err := form.app.NewFilesystem() + fs, err := form.config.FilesystemFactory() if err != nil { return err } diff --git a/forms/user_upsert.go b/forms/user_upsert.go index 7dbd12ec..b7a3c6a1 100644 --- a/forms/user_upsert.go +++ b/forms/user_upsert.go @@ -6,33 +6,63 @@ import ( validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/forms/validators" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/types" ) -// UserUpsert defines a user upsert (create/update) form. +// UserUpsert specifies a [models.User] upsert (create/update) form. type UserUpsert struct { - app core.App - user *models.User - isCreate bool + config UserUpsertConfig + user *models.User + Id string `form:"id" json:"id"` Email string `form:"email" json:"email"` Password string `form:"password" json:"password"` PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` } -// NewUserUpsert creates new upsert form for the provided user model -// (pass an empty user model instance (`&models.User{}`) for create). +// UserUpsertConfig is the [UserUpsert] factory initializer config. +// +// NB! Dao and Settings are required struct members. +type UserUpsertConfig struct { + Dao *daos.Dao + Settings *core.Settings +} + +// NewUserUpsert creates a new [UserUpsert] form with initializer +// config created from the provided [core.App] and [models.User] instances +// (for create you could pass a pointer to an empty User - `&models.User{}`). +// +// This factory method is used primarily for convenience (and backward compatibility). +// If you want to submit the form as part of another transaction, use +// [NewUserUpsertWithConfig] with Dao configured to your txDao. func NewUserUpsert(app core.App, user *models.User) *UserUpsert { + return NewUserUpsertWithConfig(UserUpsertConfig{ + Dao: app.Dao(), + Settings: app.Settings(), + }, user) +} + +// NewUserUpsertWithConfig creates a new [UserUpsert] form +// with the provided config and [models.User] instance or panics on invalid configuration +// (for create you could pass a pointer to an empty User - `&models.User{}`). +func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUpsert { form := &UserUpsert{ - app: app, - user: user, - isCreate: !user.HasId(), + config: config, + user: user, + } + + if form.config.Dao == nil || + form.config.Settings == nil || + form.user == nil { + panic("Invalid initializer config or nil upsert model.") } // load defaults + form.Id = user.Id form.Email = user.Email return form @@ -40,9 +70,14 @@ func NewUserUpsert(app core.App, user *models.User) *UserUpsert { // Validate makes the form validatable by implementing [validation.Validatable] interface. func (form *UserUpsert) Validate() error { - config := form.app.Settings() - return validation.ValidateStruct(form, + validation.Field( + &form.Id, + validation.When( + form.user.IsNew(), + validation.Length(models.DefaultIdLength, models.DefaultIdLength), + ).Else(validation.In(form.user.Id)), + ), validation.Field( &form.Email, validation.Required, @@ -53,12 +88,12 @@ func (form *UserUpsert) Validate() error { ), validation.Field( &form.Password, - validation.When(form.isCreate, validation.Required), - validation.Length(config.EmailAuth.MinPasswordLength, 100), + validation.When(form.user.IsNew(), validation.Required), + validation.Length(form.config.Settings.EmailAuth.MinPasswordLength, 100), ), validation.Field( &form.PasswordConfirm, - validation.When(form.isCreate || form.Password != "", validation.Required), + validation.When(form.user.IsNew() || form.Password != "", validation.Required), validation.By(validators.Compare(form.Password)), ), ) @@ -67,7 +102,7 @@ func (form *UserUpsert) Validate() error { func (form *UserUpsert) checkUniqueEmail(value any) error { v, _ := value.(string) - if v == "" || form.app.Dao().IsUserEmailUnique(v, form.user.Id) { + if v == "" || form.config.Dao.IsUserEmailUnique(v, form.user.Id) { return nil } @@ -81,8 +116,8 @@ func (form *UserUpsert) checkEmailDomain(value any) error { } domain := val[strings.LastIndex(val, "@")+1:] - only := form.app.Settings().EmailAuth.OnlyDomains - except := form.app.Settings().EmailAuth.ExceptDomains + only := form.config.Settings.EmailAuth.OnlyDomains + except := form.config.Settings.EmailAuth.ExceptDomains // only domains check if len(only) > 0 && !list.ExistInSlice(domain, only) { @@ -110,7 +145,13 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error { form.user.SetPassword(form.Password) } - if !form.isCreate && form.Email != form.user.Email { + // custom insertion id can be set only on create + if form.user.IsNew() && form.Id != "" { + form.user.MarkAsNew() + form.user.SetId(form.Id) + } + + if !form.user.IsNew() && form.Email != form.user.Email { form.user.Verified = false form.user.LastVerificationSentAt = types.DateTime{} // reset } @@ -118,6 +159,6 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error { form.user.Email = form.Email return runInterceptors(func() error { - return form.app.Dao().SaveUser(form.user) + return form.config.Dao.SaveUser(form.user) }, interceptors...) }