diff --git a/daos/collection.go b/daos/collection.go index f565c37f..78ceaf7a 100644 --- a/daos/collection.go +++ b/daos/collection.go @@ -1,6 +1,8 @@ package daos import ( + "bytes" + "encoding/json" "errors" "fmt" "strings" @@ -175,7 +177,9 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error { switch collection.Type { case models.CollectionTypeView: - return txDao.saveViewCollection(collection, oldCollection) + if err := txDao.saveViewCollection(collection, oldCollection); err != nil { + return err + } default: // persist the collection model if err := txDao.Save(collection); err != nil { @@ -183,8 +187,16 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error { } // sync the changes with the related records table - return txDao.SyncRecordTableSchema(collection, oldCollection) + if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil { + return err + } } + + // trigger an update for all views with changed schema as a result of the current collection save + // (ignoring view errors to allow users to update the query from the UI) + txDao.resaveViewsWithChangedSchema(collection.Id) + + return nil }) } @@ -329,7 +341,7 @@ func (dao *Dao) ImportCollections( // // This method returns an error if newCollection is not a "view". func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollection *models.Collection) error { - if newCollection.IsAuth() { + if !newCollection.IsView() { return errors.New("not a view collection") } @@ -337,7 +349,7 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti query := newCollection.ViewOptions().Query // generate collection schema from the query - schema, err := txDao.CreateViewSchema(query) + viewSchema, err := txDao.CreateViewSchema(query) if err != nil { return err } @@ -354,8 +366,52 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti return err } - newCollection.Schema = schema + newCollection.Schema = viewSchema return txDao.Save(newCollection) }) } + +// resaveViewsWithChangedSchema updates all view collections with changed schemas. +func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error { + collections, err := dao.FindCollectionsByType(models.CollectionTypeView) + if err != nil { + return err + } + + return dao.RunInTransaction(func(txDao *Dao) error { + for _, collection := range collections { + if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) { + continue + } + + query := collection.ViewOptions().Query + + // generate a new schema from the query + newSchema, err := txDao.CreateViewSchema(query) + if err != nil { + return err + } + + encodedNewSchema, err := json.Marshal(newSchema) + if err != nil { + return err + } + + encodedOldSchema, err := json.Marshal(collection.Schema) + if err != nil { + return err + } + + if bytes.EqualFold(encodedNewSchema, encodedOldSchema) { + continue // no changes + } + + if err := txDao.saveViewCollection(collection, nil); err != nil { + return err + } + } + + return nil + }) +} diff --git a/daos/collection_test.go b/daos/collection_test.go index 9ac51010..c50c8cff 100644 --- a/daos/collection_test.go +++ b/daos/collection_test.go @@ -11,6 +11,7 @@ import ( "github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/types" ) func TestCollectionQuery(t *testing.T) { @@ -305,6 +306,66 @@ func TestSaveCollectionUpdate(t *testing.T) { } } +// indirect update of a field used in view should cause view(s) update +func TestSaveCollectionIndirectViewsUpdate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, err := app.Dao().FindCollectionByNameOrId("demo1") + if err != nil { + t.Fatal(err) + } + + // update MaxSelect fields + { + relMany := collection.Schema.GetFieldByName("rel_many") + relManyOpt := relMany.Options.(*schema.RelationOptions) + relManyOpt.MaxSelect = types.Pointer(1) + + fileOne := collection.Schema.GetFieldByName("file_one") + fileOneOpt := fileOne.Options.(*schema.FileOptions) + fileOneOpt.MaxSelect = 10 + + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + } + + // check view1 schema + { + view1, err := app.Dao().FindCollectionByNameOrId("view1") + if err != nil { + t.Fatal(err) + } + + relMany := view1.Schema.GetFieldByName("rel_many") + relManyOpt := relMany.Options.(*schema.RelationOptions) + if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 { + t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect) + } + + fileOne := view1.Schema.GetFieldByName("file_one") + fileOneOpt := fileOne.Options.(*schema.FileOptions) + if fileOneOpt.MaxSelect != 10 { + t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect) + } + } + + // check view2 schema + { + view2, err := app.Dao().FindCollectionByNameOrId("view2") + if err != nil { + t.Fatal(err) + } + + relMany := view2.Schema.GetFieldByName("rel_many") + relManyOpt := relMany.Options.(*schema.RelationOptions) + if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 { + t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect) + } + } +} + func TestImportCollections(t *testing.T) { totalCollections := 10 diff --git a/daos/record_table_sync.go b/daos/record_table_sync.go index ffd9d9e0..b84ad94f 100644 --- a/daos/record_table_sync.go +++ b/daos/record_table_sync.go @@ -19,9 +19,9 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle // 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", + schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL", + schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL", + schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL", } if newCollection.IsAuth() { @@ -154,7 +154,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle return err } - return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames) + return txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames) }) } @@ -248,7 +248,7 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti }) } -func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error { +func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error { if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 { return nil // nothing to sync } diff --git a/migrations/1640988000_init.go b/migrations/1640988000_init.go index eddbddd2..f7121e4b 100644 --- a/migrations/1640988000_init.go +++ b/migrations/1640988000_init.go @@ -42,8 +42,8 @@ func init() { [[tokenKey]] TEXT UNIQUE NOT NULL, [[passwordHash]] TEXT NOT NULL, [[lastResetSentAt]] TEXT DEFAULT "" NOT NULL, - [[created]] TEXT DEFAULT "" NOT NULL, - [[updated]] TEXT DEFAULT "" NOT NULL + [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL, + [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL ); CREATE TABLE {{_collections}} ( @@ -58,8 +58,8 @@ func init() { [[updateRule]] TEXT DEFAULT NULL, [[deleteRule]] TEXT DEFAULT NULL, [[options]] JSON DEFAULT "{}" NOT NULL, - [[created]] TEXT DEFAULT "" NOT NULL, - [[updated]] TEXT DEFAULT "" NOT NULL + [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL, + [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL ); CREATE TABLE {{_params}} ( @@ -76,8 +76,8 @@ func init() { [[recordId]] TEXT NOT NULL, [[provider]] TEXT NOT NULL, [[providerId]] TEXT NOT NULL, - [[created]] TEXT DEFAULT "" NOT NULL, - [[updated]] TEXT DEFAULT "" NOT NULL, + [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL, + [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL, --- FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE ); diff --git a/migrations/logs/1640988000_init.go b/migrations/logs/1640988000_init.go index ea2ceb2f..67d10f8a 100644 --- a/migrations/logs/1640988000_init.go +++ b/migrations/logs/1640988000_init.go @@ -20,8 +20,8 @@ func init() { [[referer]] TEXT DEFAULT "" NOT NULL, [[userAgent]] TEXT DEFAULT "" NOT NULL, [[meta]] JSON DEFAULT "{}" NOT NULL, - [[created]] TEXT DEFAULT "" NOT NULL, - [[updated]] TEXT DEFAULT "" NOT NULL + [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL, + [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL ); CREATE INDEX _request_status_idx on {{_requests}} ([[status]]); diff --git a/tests/data/logs.db b/tests/data/logs.db index 765c37f3..0dc93ab0 100644 Binary files a/tests/data/logs.db and b/tests/data/logs.db differ