updated random_test
This commit is contained in:
parent
55b439cb1c
commit
0eeae9de80
239
daos/record.go
239
daos/record.go
|
@ -362,55 +362,84 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||||
// run all consequent DeleteRecord requests synchroniously
|
// run all consequent DeleteRecord requests synchroniously
|
||||||
// to minimize SQLITE_BUSY errors
|
// to minimize SQLITE_BUSY errors
|
||||||
if len(refs) > 0 {
|
if len(refs) > 0 {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := dao.Block(ctx); err != nil {
|
if err := dao.Block(ctx); err != nil {
|
||||||
return err
|
// ignore blocking and try to run directly...
|
||||||
}
|
} else {
|
||||||
defer dao.Continue()
|
defer dao.Continue()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||||
// always delete the record first to ensure that there will be no "A<->B"
|
// manually trigger delete on any linked external auth to ensure
|
||||||
// relations to prevent deadlock when calling DeleteRecord recursively
|
// that the `OnModel*` hooks are triggered.
|
||||||
|
//
|
||||||
|
// note: the select is outside of the transaction to minimize
|
||||||
|
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||||
|
if record.Collection().IsAuth() {
|
||||||
|
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, auth := range externalAuths {
|
||||||
|
if err := txDao.DeleteExternalAuth(auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the record before the relation references to ensure that there
|
||||||
|
// will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
|
||||||
if err := txDao.Delete(record); err != nil {
|
if err := txDao.Delete(record); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if related records has to be deleted (if `CascadeDelete` is set)
|
return txDao.cascadeRecordDelete(record, refs)
|
||||||
// OR
|
})
|
||||||
// just unset the record id from any relation field values (if they are not required)
|
}
|
||||||
|
|
||||||
|
// cascadeRecordDelete triggers cascade deletion for the provided references
|
||||||
|
// and split the work to a batched set of go routines.
|
||||||
|
//
|
||||||
|
// NB! This method is expected to be called inside a transaction.
|
||||||
|
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||||
|
|
||||||
for refCollection, fields := range refs {
|
for refCollection, fields := range refs {
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
// fetch all referenced records
|
|
||||||
rows := []dbx.NullStringMap{}
|
|
||||||
recordTableName := inflector.Columnify(refCollection.Name)
|
recordTableName := inflector.Columnify(refCollection.Name)
|
||||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||||
err := txDao.RecordQuery(refCollection).
|
query := dao.RecordQuery(refCollection).
|
||||||
Distinct(true).
|
Distinct(true).
|
||||||
LeftJoin(fmt.Sprintf(
|
LeftJoin(fmt.Sprintf(
|
||||||
// note: the case is used to normalize value access for single and multiple relations.
|
// note: the case is used to normalize value access for single and multiple relations.
|
||||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||||
), nil).
|
), nil).
|
||||||
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})).
|
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id})).
|
||||||
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": record.Id}).
|
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||||
All(&rows)
|
|
||||||
if err != nil {
|
// trigger cascade for each 1000 rel items until there is none
|
||||||
|
batchSize := 1000
|
||||||
|
for {
|
||||||
|
rows := []dbx.NullStringMap{}
|
||||||
|
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
total := len(rows)
|
total := len(rows)
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan error)
|
|
||||||
perPage := 200
|
perPage := 200
|
||||||
pages := int(math.Ceil(float64(total) / float64(perPage)))
|
pages := int(math.Ceil(float64(total) / float64(perPage)))
|
||||||
|
|
||||||
|
batchErr := func() error {
|
||||||
|
ch := make(chan error)
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
for i := 0; i < pages; i++ {
|
for i := 0; i < pages; i++ {
|
||||||
var chunks []dbx.NullStringMap
|
var chunks []dbx.NullStringMap
|
||||||
if len(rows) <= perPage {
|
if len(rows) <= perPage {
|
||||||
|
@ -423,35 +452,38 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
refRecords := models.NewRecordsFromNullStringMaps(refCollection, chunks)
|
refRecords := models.NewRecordsFromNullStringMaps(refCollection, chunks)
|
||||||
ch <- txDao.deleteRefRecords(record, refRecords, field)
|
ch <- dao.deleteRefRecords(mainRecord, refRecords, field)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < pages; i++ {
|
for i := 0; i < pages; i++ {
|
||||||
if err := <-ch; err != nil {
|
if err := <-ch; err != nil {
|
||||||
close(ch)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete linked external auths
|
|
||||||
if record.Collection().IsAuth() {
|
|
||||||
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
|
|
||||||
"collectionId": record.Collection().Id,
|
|
||||||
"recordId": record.Id,
|
|
||||||
}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
}()
|
||||||
|
|
||||||
|
if batchErr != nil {
|
||||||
|
return batchErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if total < batchSize {
|
||||||
|
break // no more items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
|
||||||
|
// OR
|
||||||
|
// just unset the record id from any relation field values (if they are not required).
|
||||||
|
//
|
||||||
|
// NB! This method is expected to be called inside a transaction.
|
||||||
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
|
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
|
||||||
options, _ := field.Options.(*schema.RelationOptions)
|
options, _ := field.Options.(*schema.RelationOptions)
|
||||||
if options == nil {
|
if options == nil {
|
||||||
|
@ -492,140 +524,3 @@ func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncRecordTableSchema compares the two provided collections
|
|
||||||
// and applies the necessary related record table changes.
|
|
||||||
//
|
|
||||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
|
||||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
|
||||||
// create
|
|
||||||
if oldCollection == nil {
|
|
||||||
cols := map[string]string{
|
|
||||||
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
|
||||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
|
||||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
|
||||||
}
|
|
||||||
|
|
||||||
if newCollection.IsAuth() {
|
|
||||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
|
||||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
|
||||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
|
||||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
|
||||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
|
||||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
|
||||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
|
||||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure that the new collection has an id
|
|
||||||
if !newCollection.HasId() {
|
|
||||||
newCollection.RefreshId()
|
|
||||||
newCollection.MarkAsNew()
|
|
||||||
}
|
|
||||||
|
|
||||||
tableName := newCollection.Name
|
|
||||||
|
|
||||||
// add schema field definitions
|
|
||||||
for _, field := range newCollection.Schema.Fields() {
|
|
||||||
cols[field.Name] = field.ColDefinition()
|
|
||||||
}
|
|
||||||
|
|
||||||
// create table
|
|
||||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// add named index on the base `created` column
|
|
||||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// add named unique index on the email and tokenKey columns
|
|
||||||
if newCollection.IsAuth() {
|
|
||||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
|
||||||
`
|
|
||||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
|
||||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
|
||||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
|
||||||
`,
|
|
||||||
newCollection.Id, tableName,
|
|
||||||
newCollection.Id, tableName,
|
|
||||||
newCollection.Id, tableName,
|
|
||||||
)).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// update
|
|
||||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
|
||||||
oldTableName := oldCollection.Name
|
|
||||||
newTableName := newCollection.Name
|
|
||||||
oldSchema := oldCollection.Schema
|
|
||||||
newSchema := newCollection.Schema
|
|
||||||
|
|
||||||
// check for renamed table
|
|
||||||
if !strings.EqualFold(oldTableName, newTableName) {
|
|
||||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for deleted columns
|
|
||||||
for _, oldField := range oldSchema.Fields() {
|
|
||||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
|
||||||
continue // exist
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for new or renamed columns
|
|
||||||
toRename := map[string]string{}
|
|
||||||
for _, field := range newSchema.Fields() {
|
|
||||||
oldField := oldSchema.GetFieldById(field.Id)
|
|
||||||
// Note:
|
|
||||||
// We are using a temporary column name when adding or renaming columns
|
|
||||||
// to ensure that there are no name collisions in case there is
|
|
||||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
|
||||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
|
||||||
|
|
||||||
if oldField == nil {
|
|
||||||
tempName := field.Name + security.PseudorandomString(5)
|
|
||||||
toRename[tempName] = field.Name
|
|
||||||
|
|
||||||
// add
|
|
||||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if oldField.Name != field.Name {
|
|
||||||
tempName := field.Name + security.PseudorandomString(5)
|
|
||||||
toRename[tempName] = field.Name
|
|
||||||
|
|
||||||
// rename
|
|
||||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// set the actual columns name
|
|
||||||
for tempName, actualName := range toRename {
|
|
||||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,147 @@
|
||||||
|
package daos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncRecordTableSchema compares the two provided collections
|
||||||
|
// and applies the necessary related record table changes.
|
||||||
|
//
|
||||||
|
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||||
|
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||||
|
// create
|
||||||
|
if oldCollection == nil {
|
||||||
|
cols := map[string]string{
|
||||||
|
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||||
|
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||||
|
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||||
|
}
|
||||||
|
|
||||||
|
if newCollection.IsAuth() {
|
||||||
|
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||||
|
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||||
|
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||||
|
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||||
|
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||||
|
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||||
|
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||||
|
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the new collection has an id
|
||||||
|
if !newCollection.HasId() {
|
||||||
|
newCollection.RefreshId()
|
||||||
|
newCollection.MarkAsNew()
|
||||||
|
}
|
||||||
|
|
||||||
|
tableName := newCollection.Name
|
||||||
|
|
||||||
|
// add schema field definitions
|
||||||
|
for _, field := range newCollection.Schema.Fields() {
|
||||||
|
cols[field.Name] = field.ColDefinition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// create table
|
||||||
|
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add named index on the base `created` column
|
||||||
|
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add named unique index on the email and tokenKey columns
|
||||||
|
if newCollection.IsAuth() {
|
||||||
|
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||||
|
`
|
||||||
|
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||||
|
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||||
|
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||||
|
`,
|
||||||
|
newCollection.Id, tableName,
|
||||||
|
newCollection.Id, tableName,
|
||||||
|
newCollection.Id, tableName,
|
||||||
|
)).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// update
|
||||||
|
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||||
|
oldTableName := oldCollection.Name
|
||||||
|
newTableName := newCollection.Name
|
||||||
|
oldSchema := oldCollection.Schema
|
||||||
|
newSchema := newCollection.Schema
|
||||||
|
|
||||||
|
// check for renamed table
|
||||||
|
if !strings.EqualFold(oldTableName, newTableName) {
|
||||||
|
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for deleted columns
|
||||||
|
for _, oldField := range oldSchema.Fields() {
|
||||||
|
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||||
|
continue // exist
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for new or renamed columns
|
||||||
|
toRename := map[string]string{}
|
||||||
|
for _, field := range newSchema.Fields() {
|
||||||
|
oldField := oldSchema.GetFieldById(field.Id)
|
||||||
|
// Note:
|
||||||
|
// We are using a temporary column name when adding or renaming columns
|
||||||
|
// to ensure that there are no name collisions in case there is
|
||||||
|
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||||
|
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||||
|
|
||||||
|
if oldField == nil {
|
||||||
|
tempName := field.Name + security.PseudorandomString(5)
|
||||||
|
toRename[tempName] = field.Name
|
||||||
|
|
||||||
|
// add
|
||||||
|
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if oldField.Name != field.Name {
|
||||||
|
tempName := field.Name + security.PseudorandomString(5)
|
||||||
|
toRename[tempName] = field.Name
|
||||||
|
|
||||||
|
// rename
|
||||||
|
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the actual columns name
|
||||||
|
for tempName, actualName := range toRename {
|
||||||
|
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,119 @@
|
||||||
|
package daos_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncRecordTableSchema(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updatedCollection.Name = "demo_renamed"
|
||||||
|
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||||
|
updatedCollection.Schema.AddField(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Name: "new_field",
|
||||||
|
Type: schema.FieldTypeEmail,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updatedCollection.Schema.AddField(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||||
|
Name: "title_renamed",
|
||||||
|
Type: schema.FieldTypeEmail,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
newCollection *models.Collection
|
||||||
|
oldCollection *models.Collection
|
||||||
|
expectedTableName string
|
||||||
|
expectedColumns []string
|
||||||
|
}{
|
||||||
|
// new base collection
|
||||||
|
{
|
||||||
|
&models.Collection{
|
||||||
|
Name: "new_table",
|
||||||
|
Schema: schema.NewSchema(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Name: "test",
|
||||||
|
Type: schema.FieldTypeText,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
"new_table",
|
||||||
|
[]string{"id", "created", "updated", "test"},
|
||||||
|
},
|
||||||
|
// new auth collection
|
||||||
|
{
|
||||||
|
&models.Collection{
|
||||||
|
Name: "new_table_auth",
|
||||||
|
Type: models.CollectionTypeAuth,
|
||||||
|
Schema: schema.NewSchema(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Name: "test",
|
||||||
|
Type: schema.FieldTypeText,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
"new_table_auth",
|
||||||
|
[]string{
|
||||||
|
"id", "created", "updated", "test",
|
||||||
|
"username", "email", "verified", "emailVisibility",
|
||||||
|
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// no changes
|
||||||
|
{
|
||||||
|
oldCollection,
|
||||||
|
oldCollection,
|
||||||
|
"demo3",
|
||||||
|
[]string{"id", "created", "updated", "title", "active"},
|
||||||
|
},
|
||||||
|
// renamed table, deleted column, renamed columnd and new column
|
||||||
|
{
|
||||||
|
updatedCollection,
|
||||||
|
oldCollection,
|
||||||
|
"demo_renamed",
|
||||||
|
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, scenario := range scenarios {
|
||||||
|
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("(%d) %v", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||||
|
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||||
|
if len(cols) != len(scenario.expectedColumns) {
|
||||||
|
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cols {
|
||||||
|
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||||
|
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,10 +11,12 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/models/schema"
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordQuery(t *testing.T) {
|
func TestRecordQuery(t *testing.T) {
|
||||||
|
@ -665,111 +667,125 @@ func TestDeleteRecord(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncRecordTableSchema(t *testing.T) {
|
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|
||||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
if err := createMockBatchProcessingData(app.Dao()); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
|
||||||
if err != nil {
|
// find and delete the first c1 record to trigger cascade
|
||||||
|
mainRecord, _ := app.Dao().FindRecordById("c1", "a")
|
||||||
|
if err := app.Dao().DeleteRecord(mainRecord); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
updatedCollection.Name = "demo_renamed"
|
|
||||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
|
||||||
updatedCollection.Schema.AddField(
|
|
||||||
&schema.SchemaField{
|
|
||||||
Name: "new_field",
|
|
||||||
Type: schema.FieldTypeEmail,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
updatedCollection.Schema.AddField(
|
|
||||||
&schema.SchemaField{
|
|
||||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
|
||||||
Name: "title_renamed",
|
|
||||||
Type: schema.FieldTypeEmail,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
scenarios := []struct {
|
// check if the main record was deleted
|
||||||
newCollection *models.Collection
|
_, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
|
||||||
oldCollection *models.Collection
|
if err == nil {
|
||||||
expectedTableName string
|
t.Fatal("The main record wasn't deleted")
|
||||||
expectedColumns []string
|
|
||||||
}{
|
|
||||||
// new base collection
|
|
||||||
{
|
|
||||||
&models.Collection{
|
|
||||||
Name: "new_table",
|
|
||||||
Schema: schema.NewSchema(
|
|
||||||
&schema.SchemaField{
|
|
||||||
Name: "test",
|
|
||||||
Type: schema.FieldTypeText,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
"new_table",
|
|
||||||
[]string{"id", "created", "updated", "test"},
|
|
||||||
},
|
|
||||||
// new auth collection
|
|
||||||
{
|
|
||||||
&models.Collection{
|
|
||||||
Name: "new_table_auth",
|
|
||||||
Type: models.CollectionTypeAuth,
|
|
||||||
Schema: schema.NewSchema(
|
|
||||||
&schema.SchemaField{
|
|
||||||
Name: "test",
|
|
||||||
Type: schema.FieldTypeText,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
"new_table_auth",
|
|
||||||
[]string{
|
|
||||||
"id", "created", "updated", "test",
|
|
||||||
"username", "email", "verified", "emailVisibility",
|
|
||||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// no changes
|
|
||||||
{
|
|
||||||
oldCollection,
|
|
||||||
oldCollection,
|
|
||||||
"demo3",
|
|
||||||
[]string{"id", "created", "updated", "title", "active"},
|
|
||||||
},
|
|
||||||
// renamed table, deleted column, renamed columnd and new column
|
|
||||||
{
|
|
||||||
updatedCollection,
|
|
||||||
oldCollection,
|
|
||||||
"demo_renamed",
|
|
||||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, scenario := range scenarios {
|
// check if the c2 rel fields were updated
|
||||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
|
||||||
|
if err != nil || len(c2Records) == 0 {
|
||||||
|
t.Fatalf("Failed to fetch c2 records: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range c2Records {
|
||||||
|
ids := r.GetStringSlice("rel")
|
||||||
|
if len(ids) != 1 || ids[0] != "b" {
|
||||||
|
t.Fatalf("Expected only 'b' rel id, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if all c3 relations were deleted
|
||||||
|
c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("(%d) %v", i, err)
|
t.Fatalf("Failed to fetch c3 records: %v", err)
|
||||||
continue
|
}
|
||||||
|
if total := len(c3Records); total != 0 {
|
||||||
|
t.Fatalf("Expected c3 records to be deleted, found %d", total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
func createMockBatchProcessingData(dao *daos.Dao) error {
|
||||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
// create mock collection without relation
|
||||||
|
c1 := &models.Collection{}
|
||||||
|
c1.Id = "c1"
|
||||||
|
c1.Name = c1.Id
|
||||||
|
c1.Schema = schema.NewSchema(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Name: "text",
|
||||||
|
Type: schema.FieldTypeText,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err := dao.SaveCollection(c1); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
// create mock collection with a multi-rel field
|
||||||
if len(cols) != len(scenario.expectedColumns) {
|
c2 := &models.Collection{}
|
||||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
c2.Id = "c2"
|
||||||
|
c2.Name = c2.Id
|
||||||
|
c2.Schema = schema.NewSchema(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Name: "rel",
|
||||||
|
Type: schema.FieldTypeRelation,
|
||||||
|
Options: &schema.RelationOptions{
|
||||||
|
MaxSelect: types.Pointer(10),
|
||||||
|
CollectionId: "c1",
|
||||||
|
CascadeDelete: false, // should unset all rel fields
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err := dao.SaveCollection(c2); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range cols {
|
// create mock collection with a single-rel field
|
||||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
c3 := &models.Collection{}
|
||||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
c3.Id = "c3"
|
||||||
}
|
c3.Name = c3.Id
|
||||||
|
c3.Schema = schema.NewSchema(
|
||||||
|
&schema.SchemaField{
|
||||||
|
Name: "rel",
|
||||||
|
Type: schema.FieldTypeRelation,
|
||||||
|
Options: &schema.RelationOptions{
|
||||||
|
MaxSelect: types.Pointer(1),
|
||||||
|
CollectionId: "c1",
|
||||||
|
CascadeDelete: true, // should delete all c3 records
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err := dao.SaveCollection(c3); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert mock records
|
||||||
|
c1RecordA := models.NewRecord(c1)
|
||||||
|
c1RecordA.Id = "a"
|
||||||
|
if err := dao.Save(c1RecordA); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c1RecordB := models.NewRecord(c1)
|
||||||
|
c1RecordB.Id = "b"
|
||||||
|
if err := dao.Save(c1RecordB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := 0; i < 2400; i++ {
|
||||||
|
c2Record := models.NewRecord(c2)
|
||||||
|
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
|
||||||
|
if err := dao.Save(c2Record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c3Record := models.NewRecord(c3)
|
||||||
|
c3Record.Set("rel", c1RecordA.Id)
|
||||||
|
if err := dao.Save(c3Record); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ func testRandomStringWithAlphabet(t *testing.T, randomFunc func(n int, alphabet
|
||||||
expectPattern string
|
expectPattern string
|
||||||
}{
|
}{
|
||||||
{"0123456789_", `[0-9_]+`},
|
{"0123456789_", `[0-9_]+`},
|
||||||
{"abcdef", `[abcdef]+`},
|
{"abcdef123", `[abcdef123]+`},
|
||||||
{"!@#$%^&*()", `[\!\@\#\$\%\^\&\*\(\)]+`},
|
{"!@#$%^&*()", `[\!\@\#\$\%\^\&\*\(\)]+`},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue