From 33539452de6fc7287bbab3fb6139e823eaec1e1c Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Mon, 28 Nov 2022 19:59:17 +0200 Subject: [PATCH] added automigrate tests --- plugins/migratecmd/automigrate.go | 21 +- plugins/migratecmd/migratecmd.go | 8 + plugins/migratecmd/migratecmd_test.go | 697 ++++++++++++++++++++++++++ plugins/migratecmd/templates.go | 196 +++++--- 4 files changed, 841 insertions(+), 81 deletions(-) create mode 100644 plugins/migratecmd/migratecmd_test.go diff --git a/plugins/migratecmd/automigrate.go b/plugins/migratecmd/automigrate.go index 38bb95b4..cbb56874 100644 --- a/plugins/migratecmd/automigrate.go +++ b/plugins/migratecmd/automigrate.go @@ -15,9 +15,7 @@ import ( "github.com/pocketbase/pocketbase/tools/list" ) -const migrationsTable = "_migrations" -const automigrateSuffix = "_automigrate" -const collectionsCacheKey = "_automigrate_collections" +const collectionsCacheKey = "migratecmd_collections" // onCollectionChange handles the automigration snapshot generation on // collection change event (create/update/delete). @@ -27,6 +25,7 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { return nil // not a collection } + // @todo replace with the OldModel when added to the ModelEvent oldCollections, err := p.getCachedCollections() if err != nil { return err @@ -50,8 +49,18 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { return fmt.Errorf("failed to resolve template: %v", templateErr) } + var action string + switch { + case new == nil: + action = "deleted_" + old.Name + case old == nil: + action = "created_" + new.Name + default: + action = "updated_" + old.Name + } + appliedTime := time.Now().Unix() - fileDest := filepath.Join(p.options.Dir, fmt.Sprintf("%d_automigrate.%s", appliedTime, p.options.TemplateLang)) + fileDest := filepath.Join(p.options.Dir, fmt.Sprintf("%d_%s.%s", appliedTime, action, p.options.TemplateLang)) // ensure that the local migrations dir exist if err := os.MkdirAll(p.options.Dir, os.ModePerm); err != nil { @@ -69,6 +78,10 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { } func (p *plugin) refreshCachedCollections() error { + if p.app.Dao() == nil { + return errors.New("app is not initialized yet") + } + var collections []*models.Collection if err := p.app.Dao().CollectionQuery().All(&collections); err != nil { return err diff --git a/plugins/migratecmd/migratecmd.go b/plugins/migratecmd/migratecmd.go index b667b28b..53ebfd72 100644 --- a/plugins/migratecmd/migratecmd.go +++ b/plugins/migratecmd/migratecmd.go @@ -72,11 +72,19 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error { // watch for collection changes if p.options.Automigrate { + // refresh the cache right after app bootstap p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { p.refreshCachedCollections() return nil }) + // refresh the cache to ensure that it constains the latest changes + // when migrations are applied on server start + p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + p.refreshCachedCollections() + return nil + }) + p.app.OnModelAfterCreate().Add(p.afterCollectionChange()) p.app.OnModelAfterUpdate().Add(p.afterCollectionChange()) p.app.OnModelAfterDelete().Add(p.afterCollectionChange()) diff --git a/plugins/migratecmd/migratecmd_test.go b/plugins/migratecmd/migratecmd_test.go new file mode 100644 index 00000000..aab5bc6b --- /dev/null +++ b/plugins/migratecmd/migratecmd_test.go @@ -0,0 +1,697 @@ +package migratecmd_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/plugins/migratecmd" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestAutomigrateCollectionCreate(t *testing.T) { + scenarios := []struct { + lang string + expectedTemplate string + }{ + { + migratecmd.TemplateLangJS, + ` +migrate((db) => { + const collection = unmarshal({ + "id": "new_id", + "created": "2022-01-01 00:00:00.000Z", + "updated": "2022-01-01 00:00:00.000Z", + "name": "new_name", + "type": "auth", + "system": true, + "schema": [], + "listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0", + "viewRule": "id = \"1\"", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": { + "allowEmailAuth": false, + "allowOAuth2Auth": false, + "allowUsernameAuth": false, + "exceptEmailDomains": null, + "manageRule": "created > 0", + "minPasswordLength": 20, + "onlyEmailDomains": null, + "requireEmail": false + } + }, new Collection()); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("new_id"); + + return dao.deleteCollection(collection); +}) +`, + }, + { + migratecmd.TemplateLangGo, + ` +package _test_migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := ` + "`" + `{ + "id": "new_id", + "created": "2022-01-01 00:00:00.000Z", + "updated": "2022-01-01 00:00:00.000Z", + "name": "new_name", + "type": "auth", + "system": true, + "schema": [], + "listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `", + "viewRule": "id = \"1\"", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": { + "allowEmailAuth": false, + "allowOAuth2Auth": false, + "allowUsernameAuth": false, + "exceptEmailDomains": null, + "manageRule": "created > 0", + "minPasswordLength": 20, + "onlyEmailDomains": null, + "requireEmail": false + } + }` + "`" + ` + + collection := &models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return daos.New(db).SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("new_id") + if err != nil { + return err + } + + return dao.DeleteCollection(collection) + }) +} +`, + }, + } + + for i, s := range scenarios { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + + migratecmd.MustRegister(app, nil, &migratecmd.Options{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + + // @todo remove after collections cache is replaced + app.Bootstrap() + + collection := &models.Collection{} + collection.Id = "new_id" + collection.Name = "new_name" + collection.Type = models.CollectionTypeAuth + collection.System = true + collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z") + collection.Updated = collection.Created + collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0") + collection.ViewRule = types.Pointer(`id = "1"`) + collection.SetOptions(models.CollectionAuthOptions{ + ManageRule: types.Pointer("created > 0"), + MinPasswordLength: 20, + }) + collection.MarkAsNew() + + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatalf("[%d] Failed to save collection, got %v", i, err) + } + + files, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err) + } + + if total := len(files); total != 1 { + t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total) + } + + expectedName := "_created_new_name." + s.lang + if !strings.Contains(files[0].Name(), expectedName) { + t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name()) + } + + fullPath := filepath.Join(migrationsDir, files[0].Name()) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err) + } + + if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) { + t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v) + } + } +} + +func TestAutomigrateCollectionDelete(t *testing.T) { + scenarios := []struct { + lang string + expectedTemplate string + }{ + { + migratecmd.TemplateLangJS, + ` +migrate((db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("test123"); + + return dao.deleteCollection(collection); +}, (db) => { + const collection = unmarshal({ + "id": "test123", + "created": "2022-01-01 00:00:00.000Z", + "updated": "2022-01-01 00:00:00.000Z", + "name": "test456", + "type": "auth", + "system": false, + "schema": [], + "listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0", + "viewRule": "id = \"1\"", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": { + "allowEmailAuth": false, + "allowOAuth2Auth": false, + "allowUsernameAuth": false, + "exceptEmailDomains": null, + "manageRule": "created > 0", + "minPasswordLength": 20, + "onlyEmailDomains": null, + "requireEmail": false + } + }, new Collection()); + + return Dao(db).saveCollection(collection); +}) +`, + }, + { + migratecmd.TemplateLangGo, + ` +package _test_migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("test123") + if err != nil { + return err + } + + return dao.DeleteCollection(collection) + }, func(db dbx.Builder) error { + jsonData := ` + "`" + `{ + "id": "test123", + "created": "2022-01-01 00:00:00.000Z", + "updated": "2022-01-01 00:00:00.000Z", + "name": "test456", + "type": "auth", + "system": false, + "schema": [], + "listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `", + "viewRule": "id = \"1\"", + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": { + "allowEmailAuth": false, + "allowOAuth2Auth": false, + "allowUsernameAuth": false, + "exceptEmailDomains": null, + "manageRule": "created > 0", + "minPasswordLength": 20, + "onlyEmailDomains": null, + "requireEmail": false + } + }` + "`" + ` + + collection := &models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return daos.New(db).SaveCollection(collection) + }) +} +`, + }, + } + + for i, s := range scenarios { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + + migratecmd.MustRegister(app, nil, &migratecmd.Options{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + + // create dummy collection + collection := &models.Collection{} + collection.Id = "test123" + collection.Name = "test456" + collection.Type = models.CollectionTypeAuth + collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z") + collection.Updated = collection.Created + collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0") + collection.ViewRule = types.Pointer(`id = "1"`) + collection.SetOptions(models.CollectionAuthOptions{ + ManageRule: types.Pointer("created > 0"), + MinPasswordLength: 20, + }) + collection.MarkAsNew() + + // use different dao to avoid triggering automigrate while saving the dummy collection + if err := daos.New(app.DB()).SaveCollection(collection); err != nil { + t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err) + } + + // @todo remove after collections cache is replaced + app.Bootstrap() + + // delete the newly created dummy collection + if err := app.Dao().DeleteCollection(collection); err != nil { + t.Fatalf("[%d] Failed to delete dummy collection, got %v", i, err) + } + + files, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err) + } + + if total := len(files); total != 1 { + t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total) + } + + expectedName := "_deleted_test456." + s.lang + if !strings.Contains(files[0].Name(), expectedName) { + t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name()) + } + + fullPath := filepath.Join(migrationsDir, files[0].Name()) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err) + } + + if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) { + t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v) + } + } +} + +func TestAutomigrateCollectionUpdate(t *testing.T) { + scenarios := []struct { + lang string + expectedTemplate string + }{ + { + migratecmd.TemplateLangJS, + ` +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("test123") + + collection.name = "test456_update" + collection.type = "base" + collection.listRule = null + collection.deleteRule = "updated > 0 && @request.auth.id != ''" + collection.options = {} + + // remove + collection.schema.removeField("f3_id") + + // add + collection.schema.addField(unmarshal({ + "system": false, + "id": "f4_id", + "name": "f4_name", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "` + "`" + `test backtick` + "`" + `123" + } + }, new SchemaField())) + + // update + collection.schema.addField(unmarshal({ + "system": false, + "id": "f2_id", + "name": "f2_name_new", + "type": "number", + "required": false, + "unique": true, + "options": { + "min": 10, + "max": null + } + }, new SchemaField())) + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("test123") + + collection.name = "test456" + collection.type = "auth" + collection.listRule = "@request.auth.id != '' && created > 0" + collection.deleteRule = null + collection.options = { + "allowEmailAuth": false, + "allowOAuth2Auth": false, + "allowUsernameAuth": false, + "exceptEmailDomains": null, + "manageRule": "created > 0", + "minPasswordLength": 20, + "onlyEmailDomains": null, + "requireEmail": false + } + + // add + collection.schema.addField(unmarshal({ + "system": false, + "id": "f3_id", + "name": "f3_name", + "type": "bool", + "required": false, + "unique": false, + "options": {} + }, new SchemaField())) + + // remove + collection.schema.removeField("f4_id") + + // update + collection.schema.addField(unmarshal({ + "system": false, + "id": "f2_id", + "name": "f2_name", + "type": "number", + "required": false, + "unique": true, + "options": { + "min": 10, + "max": null + } + }, new SchemaField())) + + return dao.saveCollection(collection) +}) +`, + }, + { + migratecmd.TemplateLangGo, + ` +package _test_migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/types" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("test123") + if err != nil { + return err + } + + collection.Name = "test456_update" + + collection.Type = "base" + + collection.ListRule = nil + + collection.DeleteRule = types.Pointer("updated > 0 && @request.auth.id != ''") + + options := map[string]any{} + json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options) + collection.SetOptions(options) + + // remove + collection.Schema.RemoveField("f3_id") + + // add + new_f4_name := &schema.SchemaField{} + json.Unmarshal([]byte(` + "`" + `{ + "system": false, + "id": "f4_id", + "name": "f4_name", + "type": "text", + "required": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": ` + "\"` + \"`\" + `test backtick` + \"`\" + `123\"" + ` + } + }` + "`" + `), new_f4_name) + collection.Schema.AddField(new_f4_name) + + // update + edit_f2_name_new := &schema.SchemaField{} + json.Unmarshal([]byte(` + "`" + `{ + "system": false, + "id": "f2_id", + "name": "f2_name_new", + "type": "number", + "required": false, + "unique": true, + "options": { + "min": 10, + "max": null + } + }` + "`" + `), edit_f2_name_new) + collection.Schema.AddField(edit_f2_name_new) + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("test123") + if err != nil { + return err + } + + collection.Name = "test456" + + collection.Type = "auth" + + collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0") + + collection.DeleteRule = nil + + options := map[string]any{} + json.Unmarshal([]byte(` + "`" + `{ + "allowEmailAuth": false, + "allowOAuth2Auth": false, + "allowUsernameAuth": false, + "exceptEmailDomains": null, + "manageRule": "created > 0", + "minPasswordLength": 20, + "onlyEmailDomains": null, + "requireEmail": false + }` + "`" + `), &options) + collection.SetOptions(options) + + // add + del_f3_name := &schema.SchemaField{} + json.Unmarshal([]byte(` + "`" + `{ + "system": false, + "id": "f3_id", + "name": "f3_name", + "type": "bool", + "required": false, + "unique": false, + "options": {} + }` + "`" + `), del_f3_name) + collection.Schema.AddField(del_f3_name) + + // remove + collection.Schema.RemoveField("f4_id") + + // update + edit_f2_name_new := &schema.SchemaField{} + json.Unmarshal([]byte(` + "`" + `{ + "system": false, + "id": "f2_id", + "name": "f2_name", + "type": "number", + "required": false, + "unique": true, + "options": { + "min": 10, + "max": null + } + }` + "`" + `), edit_f2_name_new) + collection.Schema.AddField(edit_f2_name_new) + + return dao.SaveCollection(collection) + }) +} +`, + }, + } + + for i, s := range scenarios { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + + migratecmd.MustRegister(app, nil, &migratecmd.Options{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + + // create dummy collection + collection := &models.Collection{} + collection.Id = "test123" + collection.Name = "test456" + collection.Type = models.CollectionTypeAuth + collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z") + collection.Updated = collection.Created + collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0") + collection.ViewRule = types.Pointer(`id = "1"`) + collection.SetOptions(models.CollectionAuthOptions{ + ManageRule: types.Pointer("created > 0"), + MinPasswordLength: 20, + }) + collection.MarkAsNew() + collection.Schema.AddField(&schema.SchemaField{ + Id: "f1_id", + Name: "f1_name", + Type: schema.FieldTypeText, + Required: true, + }) + collection.Schema.AddField(&schema.SchemaField{ + Id: "f2_id", + Name: "f2_name", + Type: schema.FieldTypeNumber, + Unique: true, + Options: &schema.NumberOptions{ + Min: types.Pointer(10.0), + }, + }) + collection.Schema.AddField(&schema.SchemaField{ + Id: "f3_id", + Name: "f3_name", + Type: schema.FieldTypeBool, + }) + + // use different dao to avoid triggering automigrate while saving the dummy collection + if err := daos.New(app.DB()).SaveCollection(collection); err != nil { + t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err) + } + + // @todo remove after collections cache is replaced + app.Bootstrap() + + collection.Name = "test456_update" + collection.Type = models.CollectionTypeBase + collection.DeleteRule = types.Pointer(`updated > 0 && @request.auth.id != ''`) + collection.ListRule = nil + collection.NormalizeOptions() + collection.Schema.RemoveField("f3_id") + collection.Schema.AddField(&schema.SchemaField{ + Id: "f4_id", + Name: "f4_name", + Type: schema.FieldTypeText, + Options: &schema.TextOptions{ + Pattern: "`test backtick`123", + }, + }) + f := collection.Schema.GetFieldById("f2_id") + f.Name = "f2_name_new" + + // save the changes and trigger automigrate + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatalf("[%d] Failed to delete dummy collection, got %v", i, err) + } + + files, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err) + } + + if total := len(files); total != 1 { + t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total) + } + + expectedName := "_updated_test456." + s.lang + if !strings.Contains(files[0].Name(), expectedName) { + t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name()) + } + + fullPath := filepath.Join(migrationsDir, files[0].Name()) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err) + } + + if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) { + t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v) + } + } +} diff --git a/plugins/migratecmd/templates.go b/plugins/migratecmd/templates.go index 61e31506..356af428 100644 --- a/plugins/migratecmd/templates.go +++ b/plugins/migratecmd/templates.go @@ -33,7 +33,7 @@ func (p *plugin) jsBlankTemplate() (string, error) { } func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, error) { - jsonData, err := json.MarshalIndent(collections, " ", " ") + jsonData, err := marhshalWithoutEscape(collections, " ", " ") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %v", err) } @@ -53,7 +53,7 @@ func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, e } func (p *plugin) jsCreateTemplate(collection *models.Collection) (string, error) { - jsonData, err := json.MarshalIndent(collection, " ", " ") + jsonData, err := marhshalWithoutEscape(collection, " ", " ") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %v", err) } @@ -74,7 +74,7 @@ func (p *plugin) jsCreateTemplate(collection *models.Collection) (string, error) } func (p *plugin) jsDeleteTemplate(collection *models.Collection) (string, error) { - jsonData, err := json.MarshalIndent(collection, " ", " ") + jsonData, err := marhshalWithoutEscape(collection, " ", " ") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %v", err) } @@ -181,11 +181,11 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) } // Options - rawNewOptions, err := json.MarshalIndent(new.Options, " ", " ") + rawNewOptions, err := marhshalWithoutEscape(new.Options, " ", " ") if err != nil { return "", err } - rawOldOptions, err := json.MarshalIndent(old.Options, " ", " ") + rawOldOptions, err := marhshalWithoutEscape(old.Options, " ", " ") if err != nil { return "", err } @@ -194,32 +194,53 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) downParts = append(downParts, fmt.Sprintf("%s.options = %s", varName, rawOldOptions)) } + // ensure new line between regular and collection fields + if len(upParts) > 0 { + upParts[len(upParts)-1] += "\n" + } + if len(downParts) > 0 { + downParts[len(downParts)-1] += "\n" + } + // Schema - // --- + // ----------------------------------------------------------------- + // deleted fields for _, oldField := range old.Schema.Fields() { if new.Schema.GetFieldById(oldField.Id) != nil { continue // exist } - rawOldField, err := json.MarshalIndent(oldField, " ", " ") + + rawOldField, err := marhshalWithoutEscape(oldField, " ", " ") if err != nil { return "", err } - upParts = append(upParts, fmt.Sprintf("%s.schema.removeField(%q)", varName, oldField.Id)) - downParts = append(downParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))", varName, rawOldField)) + + upParts = append(upParts, "// remove") + upParts = append(upParts, fmt.Sprintf("%s.schema.removeField(%q)\n", varName, oldField.Id)) + + downParts = append(downParts, "// add") + downParts = append(downParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))\n", varName, rawOldField)) } + // created fields for _, newField := range new.Schema.Fields() { if old.Schema.GetFieldById(newField.Id) != nil { continue // exist } - rawNewField, err := json.MarshalIndent(newField, " ", " ") + + rawNewField, err := marhshalWithoutEscape(newField, " ", " ") if err != nil { return "", err } - upParts = append(upParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))", varName, rawNewField)) - downParts = append(downParts, fmt.Sprintf("%s.schema.removeField(%q)", varName, newField.Id)) + + upParts = append(upParts, "// add") + upParts = append(upParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))\n", varName, rawNewField)) + + downParts = append(downParts, "// remove") + downParts = append(downParts, fmt.Sprintf("%s.schema.removeField(%q)\n", varName, newField.Id)) } + // modified fields for _, newField := range new.Schema.Fields() { oldField := old.Schema.GetFieldById(newField.Id) @@ -227,12 +248,12 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) continue } - rawNewField, err := json.MarshalIndent(newField, " ", " ") + rawNewField, err := marhshalWithoutEscape(newField, " ", " ") if err != nil { return "", err } - rawOldField, err := json.MarshalIndent(oldField, " ", " ") + rawOldField, err := marhshalWithoutEscape(oldField, " ", " ") if err != nil { return "", err } @@ -241,12 +262,14 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) continue // no change } - upParts = append(upParts, "// upsert") - upParts = append(upParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))", varName, rawNewField)) - downParts = append(downParts, "// upsert") - downParts = append(downParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))", varName, rawOldField)) + upParts = append(upParts, "// update") + upParts = append(upParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))\n", varName, rawNewField)) + + downParts = append(downParts, "// update") + downParts = append(downParts, fmt.Sprintf("%s.schema.addField(unmarshal(%s, new SchemaField()))\n", varName, rawOldField)) } - // --- + + // ----------------------------------------------------------------- up := strings.Join(upParts, "\n ") down := strings.Join(downParts, "\n ") @@ -255,20 +278,24 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) const dao = new Dao(db) const collection = dao.findCollectionByNameOrId(%q) - %s; + %s return dao.saveCollection(collection) }, (db) => { const dao = new Dao(db) const collection = dao.findCollectionByNameOrId(%q) - %s; + %s return dao.saveCollection(collection) }) ` - return fmt.Sprintf(template, old.Id, up, new.Id, down), nil + return fmt.Sprintf( + template, + old.Id, strings.TrimSpace(up), + new.Id, strings.TrimSpace(down), + ), nil } // ------------------------------------------------------------------- @@ -300,7 +327,7 @@ func init() { } func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) { - jsonData, err := json.MarshalIndent(collections, "\t", "\t\t") + jsonData, err := marhshalWithoutEscape(collections, "\t\t", "\t") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %v", err) } @@ -312,8 +339,8 @@ import ( "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" ) func init() { @@ -331,11 +358,15 @@ func init() { }) } ` - return fmt.Sprintf(template, filepath.Base(p.options.Dir), string(jsonData)), nil + return fmt.Sprintf( + template, + filepath.Base(p.options.Dir), + escapeBacktick(string(jsonData)), + ), nil } func (p *plugin) goCreateTemplate(collection *models.Collection) (string, error) { - jsonData, err := json.MarshalIndent(collection, "\t", "\t\t") + jsonData, err := marhshalWithoutEscape(collection, "\t\t", "\t") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %v", err) } @@ -347,15 +378,15 @@ import ( "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" ) func init() { m.Register(func(db dbx.Builder) error { jsonData := ` + "`%s`" + ` - collection := *models.Collection{} + collection := &models.Collection{} if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { return err } @@ -377,13 +408,13 @@ func init() { return fmt.Sprintf( template, filepath.Base(p.options.Dir), - string(jsonData), + escapeBacktick(string(jsonData)), collection.Id, ), nil } func (p *plugin) goDeleteTemplate(collection *models.Collection) (string, error) { - jsonData, err := json.MarshalIndent(collection, "\t", "\t\t") + jsonData, err := marhshalWithoutEscape(collection, "\t\t", "\t") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %v", err) } @@ -395,8 +426,8 @@ import ( "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" ) func init() { @@ -412,7 +443,7 @@ func init() { }, func(db dbx.Builder) error { jsonData := ` + "`%s`" + ` - collection := *models.Collection{} + collection := &models.Collection{} if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { return err } @@ -426,7 +457,7 @@ func init() { template, filepath.Base(p.options.Dir), collection.Id, - string(jsonData), + escapeBacktick(string(jsonData)), ), nil } @@ -446,9 +477,6 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) upParts := []string{} downParts := []string{} varName := "collection" - var importSchema bool - var importTypes bool - if old.Name != new.Name { upParts = append(upParts, fmt.Sprintf("%s.Name = %q\n", varName, new.Name)) downParts = append(downParts, fmt.Sprintf("%s.Name = %q\n", varName, old.Name)) @@ -470,11 +498,9 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) if old.ListRule != new.ListRule { if old.ListRule != nil && new.ListRule == nil { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.ListRule = nil\n", varName)) downParts = append(downParts, fmt.Sprintf("%s.ListRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.ListRule))) } else if old.ListRule == nil && new.ListRule != nil || *old.ListRule != *new.ListRule { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.ListRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.ListRule))) downParts = append(downParts, fmt.Sprintf("%s.ListRule = nil\n", varName)) } @@ -482,11 +508,9 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) if old.ViewRule != new.ViewRule { if old.ViewRule != nil && new.ViewRule == nil { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.ViewRule = nil\n", varName)) downParts = append(downParts, fmt.Sprintf("%s.ViewRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.ViewRule))) } else if old.ViewRule == nil && new.ViewRule != nil || *old.ViewRule != *new.ViewRule { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.ViewRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.ViewRule))) downParts = append(downParts, fmt.Sprintf("%s.ViewRule = nil\n", varName)) } @@ -494,11 +518,9 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) if old.CreateRule != new.CreateRule { if old.CreateRule != nil && new.CreateRule == nil { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.CreateRule = nil\n", varName)) downParts = append(downParts, fmt.Sprintf("%s.CreateRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.CreateRule))) } else if old.CreateRule == nil && new.CreateRule != nil || *old.CreateRule != *new.CreateRule { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.CreateRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.CreateRule))) downParts = append(downParts, fmt.Sprintf("%s.CreateRule = nil\n", varName)) } @@ -506,11 +528,9 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) if old.UpdateRule != new.UpdateRule { if old.UpdateRule != nil && new.UpdateRule == nil { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.UpdateRule = nil\n", varName)) downParts = append(downParts, fmt.Sprintf("%s.UpdateRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.UpdateRule))) } else if old.UpdateRule == nil && new.UpdateRule != nil || *old.UpdateRule != *new.UpdateRule { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.UpdateRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.UpdateRule))) downParts = append(downParts, fmt.Sprintf("%s.UpdateRule = nil\n", varName)) } @@ -518,32 +538,30 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) if old.DeleteRule != new.DeleteRule { if old.DeleteRule != nil && new.DeleteRule == nil { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.DeleteRule = nil\n", varName)) downParts = append(downParts, fmt.Sprintf("%s.DeleteRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.DeleteRule))) } else if old.DeleteRule == nil && new.DeleteRule != nil || *old.DeleteRule != *new.DeleteRule { - importTypes = true upParts = append(upParts, fmt.Sprintf("%s.DeleteRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.DeleteRule))) downParts = append(downParts, fmt.Sprintf("%s.DeleteRule = nil\n", varName)) } } // Options - rawNewOptions, err := json.MarshalIndent(new.Options, "\t\t", "\t") + rawNewOptions, err := marhshalWithoutEscape(new.Options, "\t\t", "\t") if err != nil { return "", err } - rawOldOptions, err := json.MarshalIndent(old.Options, "\t\t", "\t") + rawOldOptions, err := marhshalWithoutEscape(old.Options, "\t\t", "\t") if err != nil { return "", err } if !bytes.Equal(rawNewOptions, rawOldOptions) { upParts = append(upParts, "options := map[string]any{}") - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", rawNewOptions)) + upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawNewOptions)))) upParts = append(upParts, fmt.Sprintf("%s.SetOptions(options)\n", varName)) // --- downParts = append(downParts, "options := map[string]any{}") - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", rawOldOptions)) + downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawOldOptions)))) downParts = append(downParts, fmt.Sprintf("%s.SetOptions(options)\n", varName)) } @@ -555,12 +573,11 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) continue // exist } - rawOldField, err := json.MarshalIndent(oldField, "\t\t", "\t") + rawOldField, err := marhshalWithoutEscape(oldField, "\t\t", "\t") if err != nil { return "", err } - importSchema = true fieldVar := fmt.Sprintf("del_%s", oldField.Name) upParts = append(upParts, "// remove") @@ -568,7 +585,7 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) downParts = append(downParts, "// add") downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", rawOldField, fieldVar)) + downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar)) downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) } @@ -578,17 +595,16 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) continue // exist } - rawNewField, err := json.MarshalIndent(newField, "\t\t", "\t") + rawNewField, err := marhshalWithoutEscape(newField, "\t\t", "\t") if err != nil { return "", err } - importSchema = true fieldVar := fmt.Sprintf("new_%s", newField.Name) upParts = append(upParts, "// add") upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", rawNewField, fieldVar)) + upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar)) upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) downParts = append(downParts, "// remove") @@ -602,12 +618,12 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) continue } - rawNewField, err := json.MarshalIndent(newField, "\t\t", "\t") + rawNewField, err := marhshalWithoutEscape(newField, "\t\t", "\t") if err != nil { return "", err } - rawOldField, err := json.MarshalIndent(oldField, "\t\t", "\t") + rawOldField, err := marhshalWithoutEscape(oldField, "\t\t", "\t") if err != nil { return "", err } @@ -616,17 +632,16 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) continue // no change } - importSchema = true fieldVar := fmt.Sprintf("edit_%s", newField.Name) - upParts = append(upParts, "// upsert") + upParts = append(upParts, "// update") upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", rawNewField, fieldVar)) + upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar)) upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) - downParts = append(downParts, "// upsert") + downParts = append(downParts, "// update") downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", rawOldField, fieldVar)) + downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar)) downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) } // --------------------------------------------------------------- @@ -634,14 +649,30 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) up := strings.Join(upParts, "\n\t\t") down := strings.Join(downParts, "\n\t\t") + var optImports string + + combined := up + down + + if strings.Contains(combined, "json.Unmarshal(") || + strings.Contains(combined, "json.Marshal(") { + optImports += "\n\t\"encoding/json\"\n" + } + + optImports += "\n\t\"github.com/pocketbase/dbx\"" + optImports += "\n\t\"github.com/pocketbase/pocketbase/daos\"" + optImports += "\n\tm \"github.com/pocketbase/pocketbase/migrations\"" + + if strings.Contains(combined, "schema.SchemaField{") { + optImports += "\n\t\"github.com/pocketbase/pocketbase/models/schema\"" + } + + if strings.Contains(combined, "types.Pointer(") { + optImports += "\n\t\"github.com/pocketbase/pocketbase/tools/types\"" + } + const template = `package %s -import ( - "encoding/json" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - m "github.com/pocketbase/pocketbase/migrations"%s +import (%s ) func init() { @@ -671,14 +702,6 @@ func init() { } ` - var optImports string - if importSchema { - optImports += "\n\t\"github.com/pocketbase/pocketbase/models/schema\"" - } - if importTypes { - optImports += "\n\t\"github.com/pocketbase/pocketbase/tools/types\"" - } - return fmt.Sprintf( template, filepath.Base(p.options.Dir), @@ -687,3 +710,22 @@ func init() { new.Id, strings.TrimSpace(down), ), nil } + +func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) { + raw, err := json.MarshalIndent(v, prefix, indent) + if err != nil { + return nil, err + } + + // unescape escaped unicode characters + unescaped, err := strconv.Unquote(strings.Replace(strconv.Quote(string(raw)), `\\u`, `\u`, -1)) + if err != nil { + return nil, err + } + + return []byte(unescaped), nil +} + +func escapeBacktick(v string) string { + return strings.ReplaceAll(v, "`", "` + \"`\" + `") +}