1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-03-20 06:21:06 +02:00
pocketbase/core/collection_record_table_sync.go

347 lines
9.8 KiB
Go
Raw Normal View History

2024-09-29 19:23:19 +03:00
package core
import (
"fmt"
"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 {
return fmt.Errorf("failed to run optimize after the fields changes: %w", optimizeErr)
}
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 {
// temporary disable the schema error checks to prevent view and trigger errors
// when "altering" (aka. deleting and recreating) the non-normalized columns
if _, err := txApp.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
return err
}
// executed with defer to make sure that the pragma is always reverted
// in case of an error and when nested transactions are used
defer txApp.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
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 column definition by:
// 1. inserting a new column with the new definition
// 2. copy normalized values from the original column to the new one
// 3. drop the original column
// 4. rename the new column to the original column
// -------------------------------------------------------
originalName := newField.GetName()
tempName := "_" + newField.GetName() + security.PseudorandomString(5)
_, err := txApp.DB().AddColumn(newCollection.Name, tempName, 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,
tempName,
originalName,
originalName,
originalName,
originalName,
originalName,
))
} 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,
tempName,
originalName,
originalName,
originalName,
originalName,
originalName,
))
}
// copy the normalized values
if _, err := copyQuery.Execute(); err != nil {
return err
}
// drop the original column
if _, err := txApp.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
return err
}
// rename the new column back to the original
if _, err := txApp.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
return err
}
}
// revert the pragma and reload the schema
_, revertErr := txApp.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
return revertErr
})
}
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
})
}