added plugins subpackage and added basic support for js migrations

This commit is contained in:
Gani Georgiev
2022-11-26 09:05:52 +02:00
parent 3e1a19685b
commit d8963c6fc3
19 changed files with 889 additions and 120 deletions
+154
View File
@@ -0,0 +1,154 @@
package migratecmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
)
const migrationsTable = "_migrations"
// tidyMigrationsTable cleanups the migrations table by removing all
// entries with deleted migration files.
func (p *plugin) tidyMigrationsTable() error {
names, filesErr := p.getAllMigrationNames()
if filesErr != nil {
return fmt.Errorf("failed to fetch migration files list: %v", filesErr)
}
_, tidyErr := p.app.Dao().DB().Delete(migrationsTable, dbx.NotIn("file", list.ToInterfaceSlice(names)...)).Execute()
if tidyErr != nil {
return fmt.Errorf("failed to delete last automigrates from the db: %v", tidyErr)
}
return nil
}
func (p *plugin) onCollectionChange() func(*core.ModelEvent) error {
return func(e *core.ModelEvent) error {
if e.Model.TableName() != "_collections" {
return nil // not a collection
}
collections := []*models.Collection{}
if err := p.app.Dao().CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("failed to fetch collections list: %v", err)
}
if len(collections) == 0 {
return errors.New("missing collections to automigrate")
}
names, err := p.getAllMigrationNames()
if err != nil {
return fmt.Errorf("failed to fetch migration files list: %v", err)
}
// delete last consequitive automigrates
lastAutomigrates := []string{}
for i := len(names) - 1; i >= 0; i-- {
migrationFile := names[i]
if !strings.Contains(migrationFile, "_automigrate.") {
break
}
lastAutomigrates = append(lastAutomigrates, migrationFile)
}
if len(lastAutomigrates) > 0 {
// delete last automigrates from the db
_, err := p.app.Dao().DB().Delete(migrationsTable, dbx.In("file", list.ToInterfaceSlice(lastAutomigrates)...)).Execute()
if err != nil {
return fmt.Errorf("failed to delete last automigrates from the db: %v", err)
}
// delete last automigrates from the filesystem
for _, f := range lastAutomigrates {
if err := os.Remove(filepath.Join(p.options.Dir, f)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete last automigrates from the filesystem: %v", err)
}
}
}
var template string
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsSnapshotTemplate(collections)
} else {
template, templateErr = p.goSnapshotTemplate(collections)
}
if templateErr != nil {
return fmt.Errorf("failed to resolve template: %v", templateErr)
}
// add a comment to not edit the template
template = ("// Do not edit by hand since this file is autogenerated and may get overwritten.\n" +
"// If you want to do further changes, create a new non '_automigrate' file instead.\n" + template)
appliedTime := time.Now().Unix()
fileDest := filepath.Join(p.options.Dir, fmt.Sprintf("%d_automigrate.%s", appliedTime, p.options.TemplateLang))
// ensure that the local migrations dir exist
if err := os.MkdirAll(p.options.Dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create migration dir: %v", err)
}
return os.WriteFile(fileDest, []byte(template), 0644)
}
}
// getAllMigrationNames return both applied and new local migration file names.
func (p *plugin) getAllMigrationNames() ([]string, error) {
names := []string{}
for _, migration := range m.AppMigrations.Items() {
names = append(names, migration.File)
}
localFiles, err := p.getLocalMigrationNames()
if err != nil {
return nil, err
}
for _, name := range localFiles {
if !list.ExistInSlice(name, names) {
names = append(names, name)
}
}
sort.Slice(names, func(i int, j int) bool {
return names[i] < names[j]
})
return names, nil
}
// getLocalMigrationNames returns a list with all local migration files
//
// Returns an empty slice if the migrations directory doesn't exist.
func (p *plugin) getLocalMigrationNames() ([]string, error) {
files, err := os.ReadDir(p.options.Dir)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
result := make([]string, 0, len(files))
for _, f := range files {
if f.IsDir() {
continue
}
result = append(result, f.Name())
}
return result, nil
}
+210
View File
@@ -0,0 +1,210 @@
package migratecmd
import (
"fmt"
"log"
"os"
"path"
"path/filepath"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
type Options struct {
Dir string // the directory with user defined migrations
AutoMigrate bool
TemplateLang string
}
type plugin struct {
app core.App
options *Options
}
func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) {
if err := Register(app, rootCmd, options); err != nil {
panic(err)
}
}
func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
p := &plugin{app: app}
if options != nil {
p.options = options
} else {
p.options = &Options{}
}
if p.options.TemplateLang == "" {
p.options.TemplateLang = TemplateLangGo
}
if p.options.Dir == "" {
if p.options.TemplateLang == TemplateLangJS {
p.options.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations")
} else {
p.options.Dir = filepath.Join(p.app.DataDir(), "../migrations")
}
}
// attach the migrate command
if rootCmd != nil {
rootCmd.AddCommand(p.createCommand())
}
// watch for collection changes
if p.options.AutoMigrate {
// @todo replace with AfterBootstrap
p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
if err := p.tidyMigrationsTable(); err != nil && p.app.IsDebug() {
log.Println("Failed to tidy the migrations table.")
}
return nil
})
p.app.OnModelAfterCreate().Add(p.onCollectionChange())
p.app.OnModelAfterUpdate().Add(p.onCollectionChange())
p.app.OnModelAfterDelete().Add(p.onCollectionChange())
}
return nil
}
func (p *plugin) createCommand() *cobra.Command {
const cmdDesc = `Supported arguments are:
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create name [folder] - creates new blank migration template file.
- collections [folder] - creates new migration file with the latest local collections snapshot (similar to the automigrate but allows editing).
`
command := &cobra.Command{
Use: "migrate",
Short: "Executes app DB migration scripts",
ValidArgs: []string{"up", "down", "create", "collections"},
Long: cmdDesc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
// additional commands
// ---
if cmd == "create" {
if err := p.migrateCreateHandler("", args[1:]); err != nil {
log.Fatal(err)
}
return
}
if cmd == "collections" {
if err := p.migrateCollectionsHandler(args[1:]); err != nil {
log.Fatal(err)
}
return
}
// ---
runner, err := migrate.NewRunner(p.app.DB(), migrations.AppMigrations)
if err != nil {
log.Fatal(err)
}
if err := runner.Run(args...); err != nil {
log.Fatal(err)
}
},
}
return command
}
func (p *plugin) migrateCreateHandler(template string, args []string) error {
if len(args) < 1 {
return fmt.Errorf("Missing migration file name")
}
name := args[0]
var dir string
if len(args) == 2 {
dir = args[1]
}
if dir == "" {
dir = p.options.Dir
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.options.TemplateLang),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// get default create template
if template == "" {
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsCreateTemplate()
} else {
template, templateErr = p.goCreateTemplate()
}
if templateErr != nil {
return fmt.Errorf("Failed to resolve create template: %v\n", templateErr)
}
}
// ensure that the migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
// save the migration file
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q: %v\n", resultFilePath, err)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
}
func (p *plugin) migrateCollectionsHandler(args []string) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
collections := []*models.Collection{}
if err := p.app.Dao().CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
var template string
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsSnapshotTemplate(collections)
} else {
template, templateErr = p.goSnapshotTemplate(collections)
}
if templateErr != nil {
return fmt.Errorf("Failed to resolve template: %v", templateErr)
}
return p.migrateCreateHandler(template, createArgs)
}
+112
View File
@@ -0,0 +1,112 @@
package migratecmd
import (
"encoding/json"
"fmt"
"path/filepath"
"github.com/pocketbase/pocketbase/models"
)
const (
TemplateLangJS = "js"
TemplateLangGo = "go"
)
// -------------------------------------------------------------------
// JavaScript templates
// -------------------------------------------------------------------
func (p *plugin) jsCreateTemplate() (string, error) {
const template = `migrate((db) => {
// add up queries...
}, (db) => {
// add down queries...
})
`
return template, nil
}
func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, error) {
jsonData, err := json.MarshalIndent(collections, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %v", err)
}
const template = `migrate((db) => {
const snapshot = %s;
const collections = snapshot.map((item) => unmarshal(item, new Collection()));
return Dao(db).importCollections(collections, true, null);
}, (db) => {
return null;
})
`
return fmt.Sprintf(template, string(jsonData)), nil
}
// -------------------------------------------------------------------
// Go templates
// -------------------------------------------------------------------
func (p *plugin) goCreateTemplate() (string, error) {
const template = `package %s
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
return fmt.Sprintf(template, filepath.Base(p.options.Dir)), nil
}
func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) {
jsonData, err := json.MarshalIndent(collections, "\t", "\t\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %v", err)
}
const template = `package %s
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 := ` + "`%s`" + `
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
return nil
})
}
`
return fmt.Sprintf(template, filepath.Base(p.options.Dir), string(jsonData)), nil
}