Files
base/core/collection_record_table_sync.go
zeekay b5183f8c5e A multi-valued field can be added on PostgreSQL, and composites read as text
Adding a field the collection did not have ran it through the single to
multiple conversion, which read the fresh JSON column as text. SQLite let
that pass; PostgreSQL refused it (`COALESCE(jsonb, '')`), so no select,
relation or file field with several values could be added to an existing
collection there, and the CRM module could not install. A new field has
no old shape to convert, so it is skipped.

A composite value is read as text like a JSON field, so comparing a whole
value with null is IS NULL and a LIKE compares text on both engines.

The engine corpus installs the CRM and filters its values on both.
2026-10-18 22:08:03 -07:00

348 lines
9.8 KiB
Go

package core
import (
"fmt"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/base/tools/dbutils"
"github.com/hanzoai/base/tools/security"
"github.com/hanzoai/dbx"
)
// 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
}
// refresh the planner's statistics now that the table's shape changed
_, optimizeErr := app.NonconcurrentDB().NewQuery(app.Dialect().Optimize()).Execute()
if optimizeErr != nil {
app.Logger().Warn("Failed to refresh statistics after record table sync", "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 {
// A field the collection did not have was just added as a column of
// its own type, holding its default in every row, so there is no
// old shape to convert. Converting it anyway read the fresh JSON
// column as text, which PostgreSQL refuses (`COALESCE(jsonb, '')`),
// so adding any multi-valued field to a collection failed there.
oldField := oldCollection.Fields.GetById(newField.GetId())
if oldField == nil {
continue
}
var isOldMultiple bool
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(txApp.Dialect().Catalog()).
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
d := txApp.Dialect()
old := "[[" + oldTempName + "]]"
if !isOldMultiple && isNewMultiple {
// single -> multiple (convert to array)
copyQuery = txApp.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE WHEN COALESCE(%s, '') = '' THEN '[]' ELSE %s END
)`,
newCollection.Name,
originalName,
old,
d.Array(old),
))
} else {
// multiple -> single (keep only the last element; wrapping
// first is what makes a column that already held a bare
// scalar keep its value rather than read as an empty array)
//
// 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 %s END
)`,
newCollection.Name,
originalName,
old,
d.Last(d.Array(old)),
))
}
// 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
}
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
if 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(txApp.Dialect())).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()),
)
continue
}
}
if len(errs) > 0 {
return validation.Errors{"indexes": errs}
}
return nil
})
}