mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-02-16 01:19:46 +02:00
366 lines
10 KiB
Go
366 lines
10 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
|
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
|
"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.
|
|
//
|
|
// This method is automatically invoked as part of a collection create/update/delete operation.
|
|
func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollection *Collection) error {
|
|
if newCollection.IsView() {
|
|
return nil // nothing to sync since views don't have records table
|
|
}
|
|
|
|
txErr := app.RunInTransaction(func(txApp App) error {
|
|
// create
|
|
// -----------------------------------------------------------
|
|
if oldCollection == nil || !app.HasTable(oldCollection.Name) {
|
|
tableName := newCollection.Name
|
|
|
|
fields := newCollection.Fields
|
|
|
|
cols := make(map[string]string, len(fields))
|
|
|
|
// add fields definition
|
|
for _, field := range fields {
|
|
cols[field.GetName()] = field.ColumnType(app)
|
|
}
|
|
|
|
// create table
|
|
if _, err := txApp.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return createCollectionIndexes(txApp, newCollection)
|
|
}
|
|
|
|
// update
|
|
// -----------------------------------------------------------
|
|
oldTableName := oldCollection.Name
|
|
newTableName := newCollection.Name
|
|
oldFields := oldCollection.Fields
|
|
newFields := newCollection.Fields
|
|
|
|
needTableRename := !strings.EqualFold(oldTableName, newTableName)
|
|
|
|
var needIndexesUpdate bool
|
|
if needTableRename ||
|
|
oldFields.String() != newFields.String() ||
|
|
oldCollection.Indexes.String() != newCollection.Indexes.String() {
|
|
needIndexesUpdate = true
|
|
}
|
|
|
|
if needIndexesUpdate {
|
|
// drop old indexes (if any)
|
|
if err := dropCollectionIndexes(txApp, oldCollection); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// check for renamed table
|
|
if needTableRename {
|
|
_, err := txApp.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// check for deleted columns
|
|
for _, oldField := range oldFields {
|
|
if f := newFields.GetById(oldField.GetId()); f != nil {
|
|
continue // exist
|
|
}
|
|
|
|
_, err := txApp.DB().DropColumn(newTableName, oldField.GetName()).Execute()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to drop column %s - %w", oldField.GetName(), err)
|
|
}
|
|
}
|
|
|
|
// check for new or renamed columns
|
|
toRename := map[string]string{}
|
|
for _, field := range newFields {
|
|
oldField := oldFields.GetById(field.GetId())
|
|
// 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 less ambiguous experience.
|
|
|
|
if oldField == nil {
|
|
tempName := field.GetName() + security.PseudorandomString(5)
|
|
toRename[tempName] = field.GetName()
|
|
|
|
// add
|
|
_, err := txApp.DB().AddColumn(newTableName, tempName, field.ColumnType(txApp)).Execute()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add column %s - %w", field.GetName(), err)
|
|
}
|
|
} else if oldField.GetName() != field.GetName() {
|
|
tempName := field.GetName() + security.PseudorandomString(5)
|
|
toRename[tempName] = field.GetName()
|
|
|
|
// rename
|
|
_, err := txApp.DB().RenameColumn(newTableName, oldField.GetName(), tempName).Execute()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rename column %s - %w", oldField.GetName(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// set the actual columns name
|
|
for tempName, actualName := range toRename {
|
|
_, err := txApp.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := normalizeSingleVsMultipleFieldChanges(txApp, newCollection, oldCollection); err != nil {
|
|
return err
|
|
}
|
|
|
|
if needIndexesUpdate {
|
|
return createCollectionIndexes(txApp, newCollection)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
// run optimize per the SQLite recommendations
|
|
// (https://www.sqlite.org/pragma.html#pragma_optimize)
|
|
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
|
|
if optimizeErr != nil {
|
|
app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeSingleVsMultipleFieldChanges(app App, newCollection *Collection, oldCollection *Collection) error {
|
|
if newCollection.IsView() || oldCollection == nil {
|
|
return nil // view or not an update
|
|
}
|
|
|
|
return app.RunInTransaction(func(txApp App) error {
|
|
for _, newField := range newCollection.Fields {
|
|
// allow to continue even if there is no old field for the cases
|
|
// when a new field is added and there are already inserted data
|
|
var isOldMultiple bool
|
|
if oldField := oldCollection.Fields.GetById(newField.GetId()); oldField != nil {
|
|
if mv, ok := oldField.(MultiValuer); ok {
|
|
isOldMultiple = mv.IsMultiple()
|
|
}
|
|
}
|
|
|
|
var isNewMultiple bool
|
|
if mv, ok := newField.(MultiValuer); ok {
|
|
isNewMultiple = mv.IsMultiple()
|
|
}
|
|
|
|
if isOldMultiple == isNewMultiple {
|
|
continue // no change
|
|
}
|
|
|
|
// -------------------------------------------------------
|
|
// update the field column definition
|
|
// -------------------------------------------------------
|
|
|
|
// temporary drop all views to prevent reference errors during the columns renaming
|
|
// (this is used as an "alternative" to the writable_schema PRAGMA)
|
|
views := []struct {
|
|
Name string `db:"name"`
|
|
SQL string `db:"sql"`
|
|
}{}
|
|
err := txApp.DB().Select("name", "sql").
|
|
From("sqlite_master").
|
|
AndWhere(dbx.NewExp("sql is not null")).
|
|
AndWhere(dbx.HashExp{"type": "view"}).
|
|
All(&views)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, view := range views {
|
|
err = txApp.DeleteView(view.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
originalName := newField.GetName()
|
|
oldTempName := "_" + newField.GetName() + security.PseudorandomString(5)
|
|
|
|
// rename temporary the original column to something else to allow inserting a new one in its place
|
|
_, err = txApp.DB().RenameColumn(newCollection.Name, originalName, oldTempName).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// reinsert the field column with the new type
|
|
_, err = txApp.DB().AddColumn(newCollection.Name, originalName, newField.ColumnType(txApp)).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var copyQuery *dbx.Query
|
|
|
|
if !isOldMultiple && isNewMultiple {
|
|
// single -> multiple (convert to array)
|
|
copyQuery = txApp.DB().NewQuery(fmt.Sprintf(
|
|
`UPDATE {{%s}} set [[%s]] = (
|
|
CASE
|
|
WHEN COALESCE([[%s]], '') = ''
|
|
THEN '[]'
|
|
ELSE (
|
|
CASE
|
|
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
|
THEN [[%s]]
|
|
ELSE json_array([[%s]])
|
|
END
|
|
)
|
|
END
|
|
)`,
|
|
newCollection.Name,
|
|
originalName,
|
|
oldTempName,
|
|
oldTempName,
|
|
oldTempName,
|
|
oldTempName,
|
|
oldTempName,
|
|
))
|
|
} else {
|
|
// multiple -> single (keep only the last element)
|
|
//
|
|
// note: for file fields the actual file objects are not
|
|
// deleted allowing additional custom handling via migration
|
|
copyQuery = txApp.DB().NewQuery(fmt.Sprintf(
|
|
`UPDATE {{%s}} set [[%s]] = (
|
|
CASE
|
|
WHEN COALESCE([[%s]], '[]') = '[]'
|
|
THEN ''
|
|
ELSE (
|
|
CASE
|
|
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
|
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
|
ELSE [[%s]]
|
|
END
|
|
)
|
|
END
|
|
)`,
|
|
newCollection.Name,
|
|
originalName,
|
|
oldTempName,
|
|
oldTempName,
|
|
oldTempName,
|
|
oldTempName,
|
|
oldTempName,
|
|
))
|
|
}
|
|
|
|
// copy the normalized values
|
|
_, err = copyQuery.Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// drop the original column
|
|
_, err = txApp.DB().DropColumn(newCollection.Name, oldTempName).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// restore views
|
|
for _, view := range views {
|
|
_, err = txApp.DB().NewQuery(view.SQL).Execute()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func dropCollectionIndexes(app App, collection *Collection) error {
|
|
if collection.IsView() {
|
|
return nil // views don't have indexes
|
|
}
|
|
|
|
return app.RunInTransaction(func(txApp App) error {
|
|
for _, raw := range collection.Indexes {
|
|
parsed := dbutils.ParseIndex(raw)
|
|
|
|
if !parsed.IsValid() {
|
|
continue
|
|
}
|
|
|
|
if _, err := app.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func createCollectionIndexes(app App, collection *Collection) error {
|
|
if collection.IsView() {
|
|
return nil // views don't have indexes
|
|
}
|
|
|
|
return app.RunInTransaction(func(txApp App) error {
|
|
// upsert new indexes
|
|
//
|
|
// note: we are returning validation errors because the indexes cannot be
|
|
// easily validated in a form, aka. before persisting the related
|
|
// collection record table changes
|
|
errs := validation.Errors{}
|
|
for i, idx := range collection.Indexes {
|
|
parsed := dbutils.ParseIndex(idx)
|
|
|
|
// ensure that the index is always for the current collection
|
|
parsed.TableName = collection.Name
|
|
|
|
if !parsed.IsValid() {
|
|
errs[strconv.Itoa(i)] = validation.NewError(
|
|
"validation_invalid_index_expression",
|
|
"Invalid CREATE INDEX expression.",
|
|
)
|
|
continue
|
|
}
|
|
|
|
if _, err := txApp.DB().NewQuery(parsed.Build()).Execute(); err != nil {
|
|
errs[strconv.Itoa(i)] = validation.NewError(
|
|
"validation_invalid_index_expression",
|
|
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
|
).SetParams(map[string]any{
|
|
"indexName": parsed.IndexName,
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return validation.Errors{"indexes": errs}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|