Files
base/core/collection_model.go
zeekay 23cf129160 Field types for people and money, and the caller's org authority in rules
Seven field types a CRM record needs, each one JSON column whose parts a
filter, a sort or an aggregate reaches by name: currency (amountMicros,
currencyCode), emails, phones, links, address, fullName and actor, plus
rating. Empty is NULL. Emails, phones and links normalize what is typed and
can keep their primary part unique. An actor is stamped by the server from
the writer the save's context states; nothing a request sends sets it.

Every field carries a label, description, icon and position, a select's
values carry a label, colour and position, and a base collection carries a
label, plural, icon, description and an opt-in history flag.

Editor HTML is sanitized before it is written.

A rule reads the caller's authority in the org it acts in:
@request.auth.admin and @request.auth.role, off the token. An IAM-mirrored
auth record is never saved, so its fields are now read off the record
rather than joined against a row that is not there, which also makes
@request.auth.org_id match for the first time. A host that opens one Base
per org names it, and a token acting in another org is refused there.
2026-10-18 20:41:15 -07:00

1081 lines
29 KiB
Go

package core
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/hanzoai/base/tools/dbutils"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/security"
"github.com/hanzoai/base/tools/types"
"github.com/hanzoai/dbx"
"github.com/spf13/cast"
)
var (
_ Model = (*Collection)(nil)
_ DBExporter = (*Collection)(nil)
_ FilesManager = (*Collection)(nil)
)
const (
CollectionTypeBase = "base"
CollectionTypeAuth = "auth"
CollectionTypeView = "view"
)
const systemHookIdCollection = "__hzCollectionSystemHook__"
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
func (app *BaseApp) registerCollectionHooks() {
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelCreate().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelCreateExecute().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelAfterCreateSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelAfterCreateError().Bind(&hook.Handler[*ModelErrorEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelUpdate().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelUpdateExecute().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelAfterUpdateError().Bind(&hook.Handler[*ModelErrorEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelDelete().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelDeleteExecute().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelEvent) error {
if ce, ok := newCollectionEventFromModelEvent(me); ok {
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
syncModelEventWithCollectionEvent(me, ce)
defer syncCollectionEventWithModelEvent(ce, me)
return me.Next()
})
syncModelEventWithCollectionEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
app.OnModelAfterDeleteError().Bind(&hook.Handler[*ModelErrorEvent]{
Id: systemHookIdCollection,
Func: func(me *ModelErrorEvent) error {
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
syncModelErrorEventWithCollectionErrorEvent(me, ce)
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
return me.Next()
})
syncModelErrorEventWithCollectionErrorEvent(me, ce)
return err
}
return me.Next()
},
Priority: -99,
})
// --------------------------------------------------------------
app.OnCollectionValidate().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdCollection,
Func: onCollectionValidate,
Priority: 99,
})
app.OnCollectionCreate().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdCollection,
Func: onCollectionSave,
Priority: -99,
})
app.OnCollectionUpdate().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdCollection,
Func: onCollectionSave,
Priority: -99,
})
app.OnCollectionCreateExecute().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdCollection,
Func: onCollectionSaveExecute,
// execute as latest as possible, aka. closer to the db action to minimize the transactions lock time
Priority: 99,
})
app.OnCollectionUpdateExecute().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdCollection,
Func: onCollectionSaveExecute,
Priority: 99, // execute as latest as possible, aka. closer to the db action to minimize the transactions lock time
})
app.OnCollectionDeleteExecute().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdCollection,
Func: onCollectionDeleteExecute,
Priority: 99, // execute as latest as possible, aka. closer to the db action to minimize the transactions lock time
})
// reload cache on failure
// ---
onErrorReloadCachedCollections := func(ce *CollectionErrorEvent) error {
if err := ce.App.ReloadCachedCollections(); err != nil {
ce.App.Logger().Warn("Failed to reload collections cache after collection change error", "error", err)
}
return ce.Next()
}
app.OnCollectionAfterCreateError().Bind(&hook.Handler[*CollectionErrorEvent]{
Id: systemHookIdCollection,
Func: onErrorReloadCachedCollections,
Priority: -99,
})
app.OnCollectionAfterUpdateError().Bind(&hook.Handler[*CollectionErrorEvent]{
Id: systemHookIdCollection,
Func: onErrorReloadCachedCollections,
Priority: -99,
})
app.OnCollectionAfterDeleteError().Bind(&hook.Handler[*CollectionErrorEvent]{
Id: systemHookIdCollection,
Func: onErrorReloadCachedCollections,
Priority: -99,
})
// ---
app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
Id: systemHookIdCollection,
Func: func(e *BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
if err := e.App.ReloadCachedCollections(); err != nil {
return fmt.Errorf("failed to load collections cache: %w", err)
}
return nil
},
Priority: 99, // execute as latest as possible
})
}
// @todo experiment eventually replacing the rules *string with a struct?
type baseCollection struct {
BaseModel
disableIntegrityChecks bool
autogeneratedId string
ListRule *string `db:"listRule" json:"listRule" form:"listRule"`
ViewRule *string `db:"viewRule" json:"viewRule" form:"viewRule"`
CreateRule *string `db:"createRule" json:"createRule" form:"createRule"`
UpdateRule *string `db:"updateRule" json:"updateRule" form:"updateRule"`
DeleteRule *string `db:"deleteRule" json:"deleteRule" form:"deleteRule"`
// RawOptions represents the raw serialized collection option loaded from the DB.
// NB! This field shouldn't be modified manually. It is automatically updated
// with the collection type specific option before save.
RawOptions types.JSONRaw `db:"options" json:"-" xml:"-" form:"-"`
Name string `db:"name" json:"name" form:"name"`
Type string `db:"type" json:"type" form:"type"`
Fields FieldsList `db:"fields" json:"fields" form:"fields"`
Indexes types.JSONArray[string] `db:"indexes" json:"indexes" form:"indexes"`
Created types.DateTime `db:"created" json:"createdAt"`
Updated types.DateTime `db:"updated" json:"updatedAt"`
// System prevents the collection rename, deletion and rules change.
// It is used primarily for internal purposes for collections like "_superusers", "_externalAuths", etc.
System bool `db:"system" json:"system" form:"system"`
}
// Collection defines the table, fields and various options related to a set of records.
type Collection struct {
baseCollection
collectionBaseOptions
collectionAuthOptions
collectionViewOptions
}
// NewCollection initializes and returns a new Collection model with the specified type and name.
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewCollection(typ, name string, optId ...string) *Collection {
switch typ {
case CollectionTypeAuth:
return NewAuthCollection(name, optId...)
case CollectionTypeView:
return NewViewCollection(name, optId...)
default:
return NewBaseCollection(name, optId...)
}
}
// NewBaseCollection initializes and returns a new "base" Collection model.
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewBaseCollection(name string, optId ...string) *Collection {
m := &Collection{}
m.Name = name
m.Type = CollectionTypeBase
// @todo consider removing once inferred composite literals are supported
if len(optId) > 0 {
m.Id = optId[0]
}
m.initDefaultId()
m.initDefaultFields()
return m
}
// NewViewCollection initializes and returns a new "view" Collection model.
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewViewCollection(name string, optId ...string) *Collection {
m := &Collection{}
m.Name = name
m.Type = CollectionTypeView
// @todo consider removing once inferred composite literals are supported
if len(optId) > 0 {
m.Id = optId[0]
}
m.initDefaultId()
m.initDefaultFields()
return m
}
// NewAuthCollection initializes and returns a new "auth" Collection model.
//
// It also loads the minimal default configuration for the collection
// (eg. system fields, indexes, type specific options, etc.).
func NewAuthCollection(name string, optId ...string) *Collection {
m := &Collection{}
m.Name = name
m.Type = CollectionTypeAuth
// @todo consider removing once inferred composite literals are supported
if len(optId) > 0 {
m.Id = optId[0]
}
m.initDefaultId()
m.initDefaultFields()
m.setDefaultAuthOptions()
return m
}
// TableName returns the Collection model SQL table name.
func (m *Collection) TableName() string {
return "_collections"
}
// BaseFilesPath returns the storage dir path used by the collection.
func (m *Collection) BaseFilesPath() string {
return m.Id
}
// IsBase checks if the current collection has "base" type.
func (m *Collection) IsBase() bool {
return m.Type == CollectionTypeBase
}
// IsAuth checks if the current collection has "auth" type.
func (m *Collection) IsAuth() bool {
return m.Type == CollectionTypeAuth
}
// IsView checks if the current collection has "view" type.
func (m *Collection) IsView() bool {
return m.Type == CollectionTypeView
}
// IntegrityChecks toggles the current collection integrity checks (ex. checking references on delete).
func (m *Collection) IntegrityChecks(enable bool) {
m.disableIntegrityChecks = !enable
}
// PostScan implements the [dbx.PostScanner] interface to auto unmarshal
// the raw serialized options into the concrete type specific fields.
func (m *Collection) PostScan() error {
if err := m.BaseModel.PostScan(); err != nil {
return err
}
return m.unmarshalRawOptions()
}
func (m *Collection) unmarshalRawOptions() error {
raw, err := m.RawOptions.MarshalJSON()
if err != nil {
return nil
}
switch m.Type {
case CollectionTypeView:
return json.Unmarshal(raw, &m.collectionViewOptions)
case CollectionTypeAuth:
return json.Unmarshal(raw, &m.collectionAuthOptions)
case CollectionTypeBase:
return json.Unmarshal(raw, &m.collectionBaseOptions)
}
return nil
}
// UnmarshalJSON implements the [json.Unmarshaler] interface.
//
// For new/"blank" Collection models it replaces the model with a factory
// instance and then unmarshal the provided data one on top of it.
func (m *Collection) UnmarshalJSON(b []byte) error {
type alias Collection // prevent recursion
// initialize the default fields
// (e.g. in case the collection was NOT created using the designated factories)
if m.IsNew() && m.Type == "" {
minimal := &struct {
Type string `json:"type"`
Name string `json:"name"`
Id string `json:"id"`
}{}
if err := json.Unmarshal(b, minimal); err != nil {
return err
}
blank := NewCollection(minimal.Type, minimal.Name, minimal.Id)
*m = *blank
}
// A token secret is minted where it is signed with: by the factory above
// for a new collection, by [Collection.RotateTokenSecrets] after that.
// MarshalJSON emits none of them, so a document carries none either and
// the secrets already on the model are what an unmarshal leaves behind.
tokens := m.tokens()
kept := make([]string, len(tokens))
for i, t := range tokens {
kept[i] = t.Secret
}
err := json.Unmarshal(b, (*alias)(m))
for i, t := range m.tokens() {
t.Secret = kept[i]
}
return err
}
// MarshalJSON implements the [json.Marshaler] interface.
//
// Note that non-type related fields are ignored from the serialization
// (ex. for "view" collections the "auth" fields are skipped).
func (m Collection) MarshalJSON() ([]byte, error) {
switch m.Type {
case CollectionTypeView:
return json.Marshal(struct {
baseCollection
collectionViewOptions
}{m.baseCollection, m.collectionViewOptions})
case CollectionTypeAuth:
alias := struct {
baseCollection
collectionAuthOptions
}{m.baseCollection, m.collectionAuthOptions}
// ensure that it is always returned as array
if alias.OAuth2.Providers == nil {
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
}
// hide secret keys from the serialization
alias.AuthToken.Secret = ""
alias.FileToken.Secret = ""
alias.EmailChangeToken.Secret = ""
alias.VerificationToken.Secret = ""
for i := range alias.OAuth2.Providers {
alias.OAuth2.Providers[i].ClientSecret = ""
}
return json.Marshal(alias)
default:
return json.Marshal(struct {
baseCollection
collectionBaseOptions
}{m.baseCollection, m.collectionBaseOptions})
}
}
// String returns a string representation of the current collection.
func (m Collection) String() string {
raw, _ := json.Marshal(m)
return string(raw)
}
// DBExport prepares and exports the current collection data for db persistence.
func (m *Collection) DBExport(app App) (map[string]any, error) {
result := map[string]any{
"id": m.Id,
"type": m.Type,
"listRule": m.ListRule,
"viewRule": m.ViewRule,
"createRule": m.CreateRule,
"updateRule": m.UpdateRule,
"deleteRule": m.DeleteRule,
"name": m.Name,
"fields": m.Fields,
"indexes": m.Indexes,
"system": m.System,
"created": m.Created,
"updated": m.Updated,
"options": `{}`,
}
switch m.Type {
case CollectionTypeView:
if raw, err := types.ParseJSONRaw(m.collectionViewOptions); err == nil {
result["options"] = raw
} else {
return nil, err
}
case CollectionTypeAuth:
if raw, err := types.ParseJSONRaw(m.collectionAuthOptions); err == nil {
result["options"] = raw
} else {
return nil, err
}
case CollectionTypeBase:
if raw, err := types.ParseJSONRaw(m.collectionBaseOptions); err == nil {
result["options"] = raw
} else {
return nil, err
}
}
return result, nil
}
// GetIndex returns s single Collection index expression by its name.
func (m *Collection) GetIndex(name string) string {
for _, idx := range m.Indexes {
if strings.EqualFold(dbutils.ParseIndex(idx).IndexName, name) {
return idx
}
}
return ""
}
// AddIndex adds a new index into the current collection.
//
// If the collection has an existing index matching the new name it will be replaced with the new one.
func (m *Collection) AddIndex(name string, unique bool, columnsExpr string, optWhereExpr string) {
m.RemoveIndex(name)
var idx strings.Builder
idx.WriteString("CREATE ")
if unique {
idx.WriteString("UNIQUE ")
}
idx.WriteString("INDEX `")
idx.WriteString(name)
idx.WriteString("` ")
idx.WriteString("ON `")
idx.WriteString(m.Name)
idx.WriteString("` (")
idx.WriteString(columnsExpr)
idx.WriteString(")")
if optWhereExpr != "" {
idx.WriteString(" WHERE ")
idx.WriteString(optWhereExpr)
}
m.Indexes = append(m.Indexes, idx.String())
}
// RemoveIndex removes a single index with the specified name from the current collection.
func (m *Collection) RemoveIndex(name string) {
for i, idx := range m.Indexes {
if strings.EqualFold(dbutils.ParseIndex(idx).IndexName, name) {
m.Indexes = append(m.Indexes[:i], m.Indexes[i+1:]...)
return
}
}
}
// delete hook
// -------------------------------------------------------------------
func onCollectionDeleteExecute(e *CollectionEvent) error {
if e.Collection.System {
return fmt.Errorf("[%s] system collections cannot be deleted", e.Collection.Name)
}
defer func() {
if err := e.App.ReloadCachedCollections(); err != nil {
e.App.Logger().Warn("Failed to reload collections cache", "error", err)
}
}()
if !e.Collection.disableIntegrityChecks {
// ensure that there aren't any existing references.
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
references, err := e.App.FindCollectionReferences(e.Collection, e.Collection.Id)
if err != nil {
return fmt.Errorf("[%s] failed to check collection references: %w", e.Collection.Name, err)
}
if total := len(references); total > 0 {
names := make([]string, 0, len(references))
for ref := range references {
names = append(names, ref.Name)
}
return fmt.Errorf("[%s] failed to delete due to existing relation references: %s", e.Collection.Name, strings.Join(names, ", "))
}
}
originalApp := e.App
txErr := e.App.RunInTransaction(func(txApp App) error {
e.App = txApp
// delete the related view or records table
if e.Collection.IsView() {
if err := txApp.DeleteView(e.Collection.Name); err != nil {
return err
}
} else {
if err := txApp.DeleteTable(e.Collection.Name); err != nil {
return err
}
}
if !e.Collection.disableIntegrityChecks {
// trigger views resave to check for dependencies
if err := resaveViewsWithChangedFields(txApp, e.Collection.Id); err != nil {
return fmt.Errorf("[%s] failed to delete due to existing view dependency: %w", e.Collection.Name, err)
}
}
// delete
return e.Next()
})
e.App = originalApp
return txErr
}
// save hook
// -------------------------------------------------------------------
func (c *Collection) idChecksum() string {
return "hbc_" + crc32Checksum(c.Type+c.Name)
}
func (c *Collection) initDefaultId() {
if c.Id == "" {
c.Id = c.idChecksum()
c.autogeneratedId = c.Id
}
}
func (c *Collection) updateGeneratedIdIfExists(app App) {
if !c.IsNew() ||
// the id was explicitly cleared
c.Id == "" ||
// the id was manually set
c.Id != c.autogeneratedId {
return
}
// generate an up-to-date checksum
newId := c.idChecksum()
// add a number to the current id (if already exists)
for i := 2; i < 1000; i++ {
var exists int
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
if exists == 0 {
break
}
newId = c.idChecksum() + strconv.Itoa(i)
}
// no change
if c.Id == newId {
return
}
// replace the old id in the index names (if any)
for i, idx := range c.Indexes {
parsed := dbutils.ParseIndex(idx)
original := parsed.IndexName
parsed.IndexName = strings.ReplaceAll(parsed.IndexName, c.Id, newId)
if parsed.IndexName != original {
c.Indexes[i] = parsed.Build(app.Dialect())
}
}
// update model id
c.Id = newId
}
func onCollectionSave(e *CollectionEvent) error {
if e.Collection.Type == "" {
e.Collection.Type = CollectionTypeBase
}
if e.Collection.IsNew() {
e.Collection.initDefaultId()
e.Collection.Created = types.NowDateTime()
}
e.Collection.Updated = types.NowDateTime()
// recreate the fields list to ensure that all normalizations
// like default field id are applied
e.Collection.Fields = NewFieldsList(e.Collection.Fields...)
e.Collection.initDefaultFields()
if e.Collection.IsAuth() {
e.Collection.unsetMissingOAuth2MappedFields()
}
e.Collection.updateGeneratedIdIfExists(e.App)
return e.Next()
}
func onCollectionSaveExecute(e *CollectionEvent) error {
defer func() {
if err := e.App.ReloadCachedCollections(); err != nil {
e.App.Logger().Warn("Failed to reload collections cache", "error", err)
}
}()
var oldCollection *Collection
if !e.Collection.IsNew() {
var err error
oldCollection, err = e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
if err != nil {
return err
}
// invalidate previously issued auth tokens on auth rule change
if oldCollection.AuthRule != e.Collection.AuthRule &&
cast.ToString(oldCollection.AuthRule) != cast.ToString(e.Collection.AuthRule) {
e.Collection.AuthToken.Secret = security.RandomString(50)
}
}
originalApp := e.App
txErr := e.App.RunInTransaction(func(txApp App) error {
e.App = txApp
isView := e.Collection.IsView()
// ensures that the view collection shema is properly loaded
if isView {
query := e.Collection.ViewQuery
// generate collection fields list from the query
viewFields, err := e.App.CreateViewFields(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil {
if err := e.App.DeleteView(oldCollection.Name); err != nil {
return err
}
}
// wrap view query if necessary
query, err = normalizeViewQueryId(e.App, query)
if err != nil {
return fmt.Errorf("failed to normalize view query id: %w", err)
}
// (re)create the view
if err := e.App.SaveView(e.Collection.Name, query); err != nil {
return err
}
// updates newCollection.Fields based on the generated view table info and query
e.Collection.Fields = viewFields
}
// save the Collection model
if err := e.Next(); err != nil {
return err
}
// sync the changes with the related records table
if !isView {
if err := e.App.SyncRecordTableSchema(e.Collection, oldCollection); err != nil {
// note: don't wrap to allow propagating indexes validation.Errors
return err
}
}
return nil
})
e.App = originalApp
if txErr != nil {
return txErr
}
// trigger an update for all views with changed fields as a result of the current collection save
// (ignoring view errors to allow users to update the query from the UI)
resaveViewsWithChangedFields(e.App, e.Collection.Id)
return nil
}
func (c *Collection) initDefaultFields() {
switch c.Type {
case CollectionTypeBase:
c.initIdField()
case CollectionTypeAuth:
c.initIdField()
c.initTokenKeyField()
c.initEmailField()
c.initEmailVisibilityField()
c.initVerifiedField()
case CollectionTypeView:
// view fields are autogenerated
}
}
func (c *Collection) initIdField() {
field, _ := c.Fields.GetByName(FieldNameId).(*TextField)
if field == nil {
// create default field
field = &TextField{
Name: FieldNameId,
System: true,
PrimaryKey: true,
Required: true,
Min: 15,
Max: 36,
Pattern: `^[a-z0-9\-]+$`,
AutogeneratePattern: `[a-z0-9]{15}`,
}
// prepend it
c.Fields = NewFieldsList(append([]Field{field}, c.Fields...)...)
} else {
// enforce system defaults
field.System = true
field.Required = true
field.PrimaryKey = true
field.Hidden = false
if field.Pattern == "" {
field.Pattern = defaultLowercaseRecordIdPattern
}
}
}
func (c *Collection) initTokenKeyField() {
field, _ := c.Fields.GetByName(FieldNameTokenKey).(*TextField)
if field == nil {
// load default field
c.Fields.Add(&TextField{
Name: FieldNameTokenKey,
System: true,
Hidden: true,
Min: 30,
Max: 60,
Required: true,
AutogeneratePattern: `[a-zA-Z0-9]{50}`,
})
} else {
// enforce system defaults
field.System = true
field.Hidden = true
field.Required = true
}
// ensure that there is a unique index for the field
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX %s ON %s (%s)",
c.fieldIndexName(FieldNameTokenKey),
c.Name,
FieldNameTokenKey,
))
}
}
func (c *Collection) initEmailField() {
field, _ := c.Fields.GetByName(FieldNameEmail).(*EmailField)
if field == nil {
// load default field
c.Fields.Add(&EmailField{
Name: FieldNameEmail,
System: true,
Required: true,
})
} else {
// enforce system defaults
field.System = true
field.Hidden = false // managed by the emailVisibility flag
}
// ensure that there is a unique index for the email field
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
c.Indexes = append(c.Indexes, fmt.Sprintf(
"CREATE UNIQUE INDEX %s ON %s (%s) WHERE [[%s]] != ''",
c.fieldIndexName(FieldNameEmail),
c.Name,
FieldNameEmail,
FieldNameEmail,
))
}
}
func (c *Collection) initEmailVisibilityField() {
field, _ := c.Fields.GetByName(FieldNameEmailVisibility).(*BoolField)
if field == nil {
// load default field
c.Fields.Add(&BoolField{
Name: FieldNameEmailVisibility,
System: true,
})
} else {
// enforce system defaults
field.System = true
}
}
func (c *Collection) initVerifiedField() {
field, _ := c.Fields.GetByName(FieldNameVerified).(*BoolField)
if field == nil {
// load default field
c.Fields.Add(&BoolField{
Name: FieldNameVerified,
System: true,
})
} else {
// enforce system defaults
field.System = true
}
}
func (c *Collection) fieldIndexName(field string) string {
name := "idx_" + field + "_"
if c.Id != "" {
name += c.Id
} else if c.Name != "" {
name += c.Name
} else {
name += security.PseudorandomStringWithAlphabet(10, DefaultIdAlphabet)
}
if len(name) > 64 {
return name[:64]
}
return name
}