package core import ( "bytes" "context" "encoding/json" "errors" "fmt" "maps" "slices" "sort" "strings" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core/validators" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/store" "github.com/pocketbase/pocketbase/tools/types" "github.com/spf13/cast" ) // used as a workaround by some fields for persisting local state between various events // (for now is kept private and cannot be changed or cloned outside of the core package) const internalCustomFieldKeyPrefix = "@pbInternal" var ( _ Model = (*Record)(nil) _ HookTagger = (*Record)(nil) _ DBExporter = (*Record)(nil) _ FilesManager = (*Record)(nil) ) type Record struct { collection *Collection originalData map[string]any customVisibility *store.Store[bool] data *store.Store[any] expand *store.Store[any] BaseModel exportCustomData bool ignoreEmailVisibility bool ignoreUnchangedFields bool } const systemHookIdRecord = "__pbRecordSystemHook__" func (app *BaseApp) registerRecordHooks() { app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelCreate().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelCreateExecute().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelAfterCreateSuccess().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelAfterCreateError().Bind(&hook.Handler[*ModelErrorEvent]{ Id: systemHookIdRecord, Func: func(me *ModelErrorEvent) error { if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok { return me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error { syncModelErrorEventWithRecordErrorEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelUpdate().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelUpdateExecute().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelAfterUpdateError().Bind(&hook.Handler[*ModelErrorEvent]{ Id: systemHookIdRecord, Func: func(me *ModelErrorEvent) error { if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok { return me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error { syncModelErrorEventWithRecordErrorEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelDelete().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelDeleteExecute().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{ Id: systemHookIdRecord, Func: func(me *ModelEvent) error { if re, ok := newRecordEventFromModelEvent(me); ok { return me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error { syncModelEventWithRecordEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) app.OnModelAfterDeleteError().Bind(&hook.Handler[*ModelErrorEvent]{ Id: systemHookIdRecord, Func: func(me *ModelErrorEvent) error { if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok { return me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error { syncModelErrorEventWithRecordErrorEvent(me, re) return me.Next() }) } return me.Next() }, Priority: -99, }) // --------------------------------------------------------------- app.OnRecordValidate().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionValidate, func() error { return onRecordValidate(e) }, ) }, Priority: 99, }) app.OnRecordCreate().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionCreate, e.Next, ) }, Priority: -99, }) app.OnRecordCreateExecute().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionCreateExecute, func() error { return onRecordSaveExecute(e) }, ) }, Priority: 99, }) app.OnRecordAfterCreateSuccess().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionAfterCreate, e.Next, ) }, Priority: -99, }) app.OnRecordAfterCreateError().Bind(&hook.Handler[*RecordErrorEvent]{ Id: systemHookIdRecord, Func: func(e *RecordErrorEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionAfterCreateError, e.Next, ) }, Priority: -99, }) app.OnRecordUpdate().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionUpdate, e.Next, ) }, Priority: -99, }) app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionUpdateExecute, func() error { return onRecordSaveExecute(e) }, ) }, Priority: 99, }) app.OnRecordAfterUpdateSuccess().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionAfterUpdate, e.Next, ) }, Priority: -99, }) app.OnRecordAfterUpdateError().Bind(&hook.Handler[*RecordErrorEvent]{ Id: systemHookIdRecord, Func: func(e *RecordErrorEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionAfterUpdateError, e.Next, ) }, Priority: -99, }) app.OnRecordDelete().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionDelete, func() error { if e.Record.Collection().IsView() { return errors.New("view records cannot be deleted") } return e.Next() }, ) }, Priority: -99, }) app.OnRecordDeleteExecute().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionDeleteExecute, func() error { return onRecordDeleteExecute(e) }, ) }, Priority: 99, }) app.OnRecordAfterDeleteSuccess().Bind(&hook.Handler[*RecordEvent]{ Id: systemHookIdRecord, Func: func(e *RecordEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionAfterDelete, e.Next, ) }, Priority: -99, }) app.OnRecordAfterDeleteError().Bind(&hook.Handler[*RecordErrorEvent]{ Id: systemHookIdRecord, Func: func(e *RecordErrorEvent) error { return e.Record.callFieldInterceptors( e.Context, e.App, InterceptorActionAfterDeleteError, e.Next, ) }, Priority: -99, }) } // ------------------------------------------------------------------- // newRecordFromNullStringMap initializes a single new Record model // with data loaded from the provided NullStringMap. // // Note that this method is intended to load and Scan data from a database row result. func newRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) (*Record, error) { record := NewRecord(collection) var fieldName string for _, field := range collection.Fields { fieldName = field.GetName() nullString, ok := data[fieldName] var value any var err error if ok && nullString.Valid { value, err = field.PrepareValue(record, nullString.String) } else { value, err = field.PrepareValue(record, nil) } if err != nil { return nil, err } // we load only the original data to avoid unnecessary copying the same data into the record.data store // (it is also the reason why we don't invoke PostScan on the record itself) record.originalData[fieldName] = value if fieldName == FieldNameId { record.Id = cast.ToString(value) } } record.BaseModel.PostScan() return record, nil } // newRecordsFromNullStringMaps initializes a new Record model for // each row in the provided NullStringMap slice. // // Note that this method is intended to load and Scan data from a database rows result. func newRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) ([]*Record, error) { result := make([]*Record, len(rows)) var err error for i, row := range rows { result[i], err = newRecordFromNullStringMap(collection, row) if err != nil { return nil, err } } return result, nil } // ------------------------------------------------------------------- // NewRecord initializes a new empty Record model. func NewRecord(collection *Collection) *Record { record := &Record{ collection: collection, data: store.New[any](nil), customVisibility: store.New[bool](nil), originalData: make(map[string]any, len(collection.Fields)), } // initialize default field values for _, field := range collection.Fields { if field.GetName() == FieldNameId { continue } value, _ := field.PrepareValue(record, nil) record.originalData[field.GetName()] = value } return record } // Collection returns the Collection model associated with the current Record model. // // NB! The returned collection is only for read purposes and it shouldn't be modified // because it could have unintended side-effects on other Record models from the same collection. func (m *Record) Collection() *Collection { return m.collection } // TableName returns the table name associated with the current Record model. func (m *Record) TableName() string { return m.collection.Name } // PostScan implements the [dbx.PostScanner] interface. // // It essentially refreshes/updates the current Record original state // as if the model was fetched from the databases for the first time. // // Or in other words, it means that m.Original().FieldsData() will have // the same values as m.Record().FieldsData(). func (m *Record) PostScan() error { if m.Id == "" { return errors.New("missing record primary key") } if err := m.BaseModel.PostScan(); err != nil { return err } m.originalData = m.FieldsData() return nil } // HookTags returns the hook tags associated with the current record. func (m *Record) HookTags() []string { return []string{m.collection.Name, m.collection.Id} } // BaseFilesPath returns the storage dir path used by the record. func (m *Record) BaseFilesPath() string { id := cast.ToString(m.LastSavedPK()) if id == "" { id = m.Id } return m.collection.BaseFilesPath() + "/" + id } // Original returns a shallow copy of the current record model populated // with its ORIGINAL db data state (aka. right after PostScan()) // and everything else reset to the defaults. // // If record was created using NewRecord() the original will be always // a blank record (until PostScan() is invoked). func (m *Record) Original() *Record { newRecord := NewRecord(m.collection) newRecord.originalData = maps.Clone(m.originalData) if newRecord.originalData[FieldNameId] != nil { newRecord.lastSavedPK = cast.ToString(newRecord.originalData[FieldNameId]) newRecord.Id = newRecord.lastSavedPK } return newRecord } // Fresh returns a shallow copy of the current record model populated // with its LATEST data state and everything else reset to the defaults // (aka. no expand, no unknown fields and with default visibility flags). func (m *Record) Fresh() *Record { newRecord := m.Original() // note: this will also load the Id field though m.Get newRecord.Load(m.FieldsData()) return newRecord } // Clone returns a shallow copy of the current record model with all of // its collection and unknown fields data, expand and flags copied. // // use [Record.Fresh()] instead if you want a copy with only the latest // collection fields data and everything else reset to the defaults. func (m *Record) Clone() *Record { newRecord := m.Original() newRecord.Id = m.Id newRecord.exportCustomData = m.exportCustomData newRecord.ignoreEmailVisibility = m.ignoreEmailVisibility newRecord.ignoreUnchangedFields = m.ignoreUnchangedFields newRecord.customVisibility.Reset(m.customVisibility.GetAll()) newRecord.Load(m.data.GetAll()) if m.expand != nil { newRecord.SetExpand(m.expand.GetAll()) } return newRecord } // Expand returns a shallow copy of the current Record model expand data (if any). func (m *Record) Expand() map[string]any { if m.expand == nil { // return a dummy initialized map to avoid assignment to nil map errors return map[string]any{} } return m.expand.GetAll() } // SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied). func (m *Record) SetExpand(expand map[string]any) { if m.expand == nil { m.expand = store.New[any](nil) } m.expand.Reset(expand) } // MergeExpand merges recursively the provided expand data into // the current model's expand (if any). // // Note that if an expanded prop with the same key is a slice (old or new expand) // then both old and new records will be merged into a new slice (aka. a :merge: [b,c] => [a,b,c]). // Otherwise the "old" expanded record will be replace with the "new" one (aka. a :merge: aNew => aNew). func (m *Record) MergeExpand(expand map[string]any) { // nothing to merge if len(expand) == 0 { return } // no old expand if m.expand == nil { m.expand = store.New(expand) return } oldExpand := m.expand.GetAll() for key, new := range expand { old, ok := oldExpand[key] if !ok { oldExpand[key] = new continue } var wasOldSlice bool var oldSlice []*Record switch v := old.(type) { case *Record: oldSlice = []*Record{v} case []*Record: wasOldSlice = true oldSlice = v default: // invalid old expand data -> assign directly the new // (no matter whether new is valid or not) oldExpand[key] = new continue } var wasNewSlice bool var newSlice []*Record switch v := new.(type) { case *Record: newSlice = []*Record{v} case []*Record: wasNewSlice = true newSlice = v default: // invalid new expand data -> skip continue } oldIndexed := make(map[string]*Record, len(oldSlice)) for _, oldRecord := range oldSlice { oldIndexed[oldRecord.Id] = oldRecord } for _, newRecord := range newSlice { oldRecord := oldIndexed[newRecord.Id] if oldRecord != nil { // note: there is no need to update oldSlice since oldRecord is a reference oldRecord.MergeExpand(newRecord.Expand()) } else { // missing new entry oldSlice = append(oldSlice, newRecord) } } if wasOldSlice || wasNewSlice || len(oldSlice) == 0 { oldExpand[key] = oldSlice } else { oldExpand[key] = oldSlice[0] } } m.expand.Reset(oldExpand) } // FieldsData returns a shallow copy ONLY of the collection's fields record's data. func (m *Record) FieldsData() map[string]any { result := make(map[string]any, len(m.collection.Fields)) for _, field := range m.collection.Fields { result[field.GetName()] = m.Get(field.GetName()) } return result } // CustomData returns a shallow copy ONLY of the custom record fields data, // aka. fields that are neither defined by the collection, nor special system ones. // // Note that custom fields prefixed with "@pbInternal" are always skipped. func (m *Record) CustomData() map[string]any { if m.data == nil { return nil } fields := m.Collection().Fields knownFields := make(map[string]struct{}, len(fields)) for _, f := range fields { knownFields[f.GetName()] = struct{}{} } result := map[string]any{} rawData := m.data.GetAll() for k, v := range rawData { if _, ok := knownFields[k]; !ok { // skip internal custom fields if strings.HasPrefix(k, internalCustomFieldKeyPrefix) { continue } result[k] = v } } return result } // WithCustomData toggles the export/serialization of custom data fields // (false by default). func (m *Record) WithCustomData(state bool) *Record { m.exportCustomData = state return m } // IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check. func (m *Record) IgnoreEmailVisibility(state bool) *Record { m.ignoreEmailVisibility = state return m } // IgnoreUnchangedFields toggles the flag to ignore the unchanged fields // from the DB export for the UPDATE SQL query. // // This could be used if you want to save only the record fields that you've changed // without overwrite other untouched fields in case of concurrent update. func (m *Record) IgnoreUnchangedFields(state bool) *Record { m.ignoreUnchangedFields = state return m } // Set sets the provided key-value data pair into the current Record // model directly as it is WITHOUT NORMALIZATIONS. // // See also [Record.Set]. func (m *Record) SetRaw(key string, value any) { if key == FieldNameId { m.Id = cast.ToString(value) } m.data.Set(key, value) } // SetIfFieldExists sets the provided key-value data pair into the current Record model // ONLY if key is existing Collection field name/modifier. // // This method does nothing if key is not a known Collection field name/modifier. // // On success returns the matched Field, otherwise - nil. // // To set any key-value, including custom/unknown fields, use the [Record.Set] method. func (m *Record) SetIfFieldExists(key string, value any) Field { for _, field := range m.Collection().Fields { ff, ok := field.(SetterFinder) if ok { setter := ff.FindSetter(key) if setter != nil { setter(m, value) return field } } // fallback to the default field PrepareValue method for direct match if key == field.GetName() { value, _ = field.PrepareValue(m, value) m.SetRaw(key, value) return field } } return nil } // Set sets the provided key-value data pair into the current Record model. // // If the record collection has field with name matching the provided "key", // the value will be further normalized according to the field setter(s). func (m *Record) Set(key string, value any) { switch key { case FieldNameExpand: // for backward-compatibility with earlier versions m.SetExpand(cast.ToStringMap(value)) default: field := m.SetIfFieldExists(key, value) if field == nil { // custom key - set it without any transformations m.SetRaw(key, value) } } } func (m *Record) GetRaw(key string) any { if key == FieldNameId { return m.Id } if v, ok := m.data.GetOk(key); ok { return v } return m.originalData[key] } // Get returns a normalized single record model data value for "key". func (m *Record) Get(key string) any { switch key { case FieldNameExpand: // for backward-compatibility with earlier versions return m.Expand() default: for _, field := range m.Collection().Fields { gm, ok := field.(GetterFinder) if !ok { continue // no custom getters } getter := gm.FindGetter(key) if getter != nil { return getter(m) } } return m.GetRaw(key) } } // Load bulk loads the provided data into the current Record model. func (m *Record) Load(data map[string]any) { for k, v := range data { m.Set(k, v) } } // GetBool returns the data value for "key" as a bool. func (m *Record) GetBool(key string) bool { return cast.ToBool(m.Get(key)) } // GetString returns the data value for "key" as a string. func (m *Record) GetString(key string) string { return cast.ToString(m.Get(key)) } // GetInt returns the data value for "key" as an int. func (m *Record) GetInt(key string) int { return cast.ToInt(m.Get(key)) } // GetFloat returns the data value for "key" as a float64. func (m *Record) GetFloat(key string) float64 { return cast.ToFloat64(m.Get(key)) } // GetDateTime returns the data value for "key" as a DateTime instance. func (m *Record) GetDateTime(key string) types.DateTime { d, _ := types.ParseDateTime(m.Get(key)) return d } // GetStringSlice returns the data value for "key" as a slice of non-zero unique strings. func (m *Record) GetStringSlice(key string) []string { return list.ToUniqueStringSlice(m.Get(key)) } // GetUploadedFiles returns the uploaded files for the provided "file" field key, // (aka. the current [*filesytem.File] values) so that you can apply further // validations or modifications (including changing the file name or content before persisting). // // Example: // // files := record.GetUploadedFiles("documents") // for _, f := range files { // f.Name = "doc_" + f.Name // add a prefix to each file name // } // app.Save(record) // the files are pointers so the applied changes will transparently reflect on the record value func (m *Record) GetUploadedFiles(key string) []*filesystem.File { if !strings.HasSuffix(key, ":uploaded") { key += ":uploaded" } values, _ := m.Get(key).([]*filesystem.File) return values } // Retrieves the "key" json field value and unmarshals it into "result". // // Example // // result := struct { // FirstName string `json:"first_name"` // }{} // err := m.UnmarshalJSONField("my_field_name", &result) func (m *Record) UnmarshalJSONField(key string, result any) error { return json.Unmarshal([]byte(m.GetString(key)), &result) } // ExpandedOne retrieves a single relation Record from the already // loaded expand data of the current model. // // If the requested expand relation is multiple, this method returns // only first available Record from the expanded relation. // // Returns nil if there is no such expand relation loaded. func (m *Record) ExpandedOne(relField string) *Record { if m.expand == nil { return nil } rel := m.expand.Get(relField) switch v := rel.(type) { case *Record: return v case []*Record: if len(v) > 0 { return v[0] } } return nil } // ExpandedAll retrieves a slice of relation Records from the already // loaded expand data of the current model. // // If the requested expand relation is single, this method normalizes // the return result and will wrap the single model as a slice. // // Returns nil slice if there is no such expand relation loaded. func (m *Record) ExpandedAll(relField string) []*Record { if m.expand == nil { return nil } rel := m.expand.Get(relField) switch v := rel.(type) { case *Record: return []*Record{v} case []*Record: return v } return nil } // FindFileFieldByFile returns the first file type field for which // any of the record's data contains the provided filename. func (m *Record) FindFileFieldByFile(filename string) *FileField { for _, field := range m.Collection().Fields { if field.Type() != FieldTypeFile { continue } f, ok := field.(*FileField) if !ok { continue } filenames := m.GetStringSlice(f.GetName()) if slices.Contains(filenames, filename) { return f } } return nil } // DBExport implements the [DBExporter] interface and returns a key-value // map with the data to be persisted when saving the Record in the database. func (m *Record) DBExport(app App) (map[string]any, error) { result, err := m.dbExport() if err != nil { return nil, err } // remove exported fields that haven't changed // (with exception of the id column) if !m.IsNew() && m.ignoreUnchangedFields { oldResult, err := m.Original().dbExport() if err != nil { return nil, err } for oldK, oldV := range oldResult { if oldK == idColumn { continue } newV, ok := result[oldK] if ok && areValuesEqual(newV, oldV) { delete(result, oldK) } } } return result, nil } func (m *Record) dbExport() (map[string]any, error) { fields := m.Collection().Fields result := make(map[string]any, len(fields)) for _, field := range fields { if f, ok := field.(DriverValuer); ok { v, err := f.DriverValue(m) if err != nil { return nil, err } result[field.GetName()] = v } else { result[field.GetName()] = m.GetRaw(field.GetName()) } } return result, nil } func areValuesEqual(a any, b any) bool { switch av := a.(type) { case string: bv, ok := b.(string) return ok && bv == av case bool: bv, ok := b.(bool) return ok && bv == av case float32: bv, ok := b.(float32) return ok && bv == av case float64: bv, ok := b.(float64) return ok && bv == av case uint: bv, ok := b.(uint) return ok && bv == av case uint8: bv, ok := b.(uint8) return ok && bv == av case uint16: bv, ok := b.(uint16) return ok && bv == av case uint32: bv, ok := b.(uint32) return ok && bv == av case uint64: bv, ok := b.(uint64) return ok && bv == av case int: bv, ok := b.(int) return ok && bv == av case int8: bv, ok := b.(int8) return ok && bv == av case int16: bv, ok := b.(int16) return ok && bv == av case int32: bv, ok := b.(int32) return ok && bv == av case int64: bv, ok := b.(int64) return ok && bv == av case []byte: bv, ok := b.([]byte) return ok && bytes.Equal(av, bv) case []string: bv, ok := b.([]string) return ok && slices.Equal(av, bv) case []int: bv, ok := b.([]int) return ok && slices.Equal(av, bv) case []int32: bv, ok := b.([]int32) return ok && slices.Equal(av, bv) case []int64: bv, ok := b.([]int64) return ok && slices.Equal(av, bv) case []float32: bv, ok := b.([]float32) return ok && slices.Equal(av, bv) case []float64: bv, ok := b.([]float64) return ok && slices.Equal(av, bv) case types.JSONArray[string]: bv, ok := b.(types.JSONArray[string]) return ok && slices.Equal(av, bv) case types.JSONRaw: bv, ok := b.(types.JSONRaw) return ok && bytes.Equal(av, bv) default: aRaw, err := json.Marshal(a) if err != nil { return false } bRaw, err := json.Marshal(b) if err != nil { return false } return bytes.Equal(aRaw, bRaw) } } // Hide hides the specified fields from the public safe serialization of the record. func (record *Record) Hide(fieldNames ...string) *Record { for _, name := range fieldNames { record.customVisibility.Set(name, false) } return record } // Unhide forces to unhide the specified fields from the public safe serialization // of the record (even when the collection field itself is marked as hidden). func (record *Record) Unhide(fieldNames ...string) *Record { for _, name := range fieldNames { record.customVisibility.Set(name, true) } return record } // PublicExport exports only the record fields that are safe to be public. // // To export unknown data fields you need to set record.WithCustomData(true). // // For auth records, to force the export of the email field you need to set // record.IgnoreEmailVisibility(true). func (record *Record) PublicExport() map[string]any { export := make(map[string]any, len(record.collection.Fields)+3) var isVisible, hasCustomVisibility bool customVisibility := record.customVisibility.GetAll() // export schema fields for _, f := range record.collection.Fields { isVisible, hasCustomVisibility = customVisibility[f.GetName()] if !hasCustomVisibility { isVisible = !f.GetHidden() } if !isVisible { continue } export[f.GetName()] = record.Get(f.GetName()) } // export custom fields if record.exportCustomData { for k, v := range record.CustomData() { isVisible, hasCustomVisibility = customVisibility[k] if !hasCustomVisibility || isVisible { export[k] = v } } } if record.Collection().IsAuth() { // always hide the password and tokenKey fields delete(export, FieldNamePassword) delete(export, FieldNameTokenKey) if !record.ignoreEmailVisibility && !record.GetBool(FieldNameEmailVisibility) { delete(export, FieldNameEmail) } } // add helper collection reference fields isVisible, hasCustomVisibility = customVisibility[FieldNameCollectionId] if !hasCustomVisibility || isVisible { export[FieldNameCollectionId] = record.collection.Id } isVisible, hasCustomVisibility = customVisibility[FieldNameCollectionName] if !hasCustomVisibility || isVisible { export[FieldNameCollectionName] = record.collection.Name } // add expand (if non-nil) isVisible, hasCustomVisibility = customVisibility[FieldNameExpand] if (!hasCustomVisibility || isVisible) && record.expand != nil { export[FieldNameExpand] = record.expand.GetAll() } return export } // MarshalJSON implements the [json.Marshaler] interface. // // Only the data exported by `PublicExport()` will be serialized. func (m Record) MarshalJSON() ([]byte, error) { return json.Marshal(m.PublicExport()) } // UnmarshalJSON implements the [json.Unmarshaler] interface. func (m *Record) UnmarshalJSON(data []byte) error { result := map[string]any{} if err := json.Unmarshal(data, &result); err != nil { return err } m.Load(result) return nil } // ReplaceModifiers returns a new map with applied modifier // values based on the current record and the specified data. // // The resolved modifier keys will be removed. // // Multiple modifiers will be applied one after another, // while reusing the previous base key value result (ex. 1; -5; +2 => -2). // // Note that because Go doesn't guaranteed the iteration order of maps, // we would explicitly apply shorter keys first for a more consistent and reproducible behavior. // // Example usage: // // newData := record.ReplaceModifiers(data) // // record: {"field": 10} // // data: {"field+": 5} // // result: {"field": 15} func (m *Record) ReplaceModifiers(data map[string]any) map[string]any { if len(data) == 0 { return data } dataCopy := maps.Clone(data) recordCopy := m.Fresh() // key orders is not guaranteed so sortedDataKeys := make([]string, 0, len(data)) for k := range data { sortedDataKeys = append(sortedDataKeys, k) } sort.SliceStable(sortedDataKeys, func(i int, j int) bool { return len(sortedDataKeys[i]) < len(sortedDataKeys[j]) }) for _, k := range sortedDataKeys { field := recordCopy.SetIfFieldExists(k, data[k]) if field != nil { // delete the original key in case it is with a modifer (ex. "items+") delete(dataCopy, k) // store the transformed value under the field name dataCopy[field.GetName()] = recordCopy.Get(field.GetName()) } } return dataCopy } // ------------------------------------------------------------------- func (m *Record) callFieldInterceptors( ctx context.Context, app App, actionName string, actionFunc func() error, ) error { // the firing order of the fields doesn't matter for _, field := range m.Collection().Fields { if f, ok := field.(RecordInterceptor); ok { oldfn := actionFunc actionFunc = func() error { return f.Intercept(ctx, app, m, actionName, oldfn) } } } return actionFunc() } func onRecordValidate(e *RecordEvent) error { errs := validation.Errors{} for _, f := range e.Record.Collection().Fields { if err := f.ValidateValue(e.Context, e.App, e.Record); err != nil { errs[f.GetName()] = err } } if len(errs) > 0 { return errs } return e.Next() } func onRecordSaveExecute(e *RecordEvent) error { if e.Record.Collection().IsAuth() { // ensure that the token key is different on password change old := e.Record.Original() if !e.Record.IsNew() && old.TokenKey() == e.Record.TokenKey() && old.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) { e.Record.RefreshTokenKey() } // cross-check that the auth record id is unique across all auth collections. authCollections, err := e.App.FindAllCollections(CollectionTypeAuth) if err != nil { return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err) } for _, collection := range authCollections { if e.Record.Collection().Id == collection.Id { continue // skip current collection (sqlite will do the check for us) } record, _ := e.App.FindRecordById(collection, e.Record.Id) if record != nil { return validation.Errors{ FieldNameId: validation.NewError("validation_invalid_auth_id", "Invalid or duplicated auth record id."), } } } } err := e.Next() if err == nil { return nil } return validators.NormalizeUniqueIndexError( err, e.Record.Collection().Name, e.Record.Collection().Fields.FieldNames(), ) } func onRecordDeleteExecute(e *RecordEvent) error { // fetch rel references (if any) // // note: the select is outside of the transaction to minimize // SQLITE_BUSY errors when mixing read&write in a single transaction refs, err := e.App.FindCollectionReferences(e.Record.Collection()) if err != nil { return err } originalApp := e.App txErr := e.App.RunInTransaction(func(txApp App) error { e.App = txApp // delete the record before the relation references to ensure that there // will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively if err := e.Next(); err != nil { return err } return cascadeRecordDelete(txApp, e.Record, refs) }) e.App = originalApp return txErr } // cascadeRecordDelete triggers cascade deletion for the provided references. // // NB! This method is expected to be called from inside of a transaction. func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Field) error { // Sort the refs keys to ensure that the cascade events firing order is always the same. // This is not necessary for the operation to function correctly but it helps having deterministic output during testing. sortedRefKeys := make([]*Collection, 0, len(refs)) for k := range refs { sortedRefKeys = append(sortedRefKeys, k) } sort.Slice(sortedRefKeys, func(i, j int) bool { return sortedRefKeys[i].Name < sortedRefKeys[j].Name }) for _, refCollection := range sortedRefKeys { fields, ok := refs[refCollection] if refCollection.IsView() || !ok { continue // skip missing or view collections } for _, field := range fields { recordTableName := inflector.Columnify(refCollection.Name) prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName()) query := app.RecordQuery(refCollection) if opt, ok := field.(MultiValuer); !ok || !opt.IsMultiple() { query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id}) } else { query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf( `SELECT 1 FROM json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) {{__je__}} WHERE [[__je__.value]]={:jevalue}`, prefixedFieldName, prefixedFieldName, prefixedFieldName, ), dbx.Params{ "jevalue": mainRecord.Id, }))) } if refCollection.Id == mainRecord.Collection().Id { query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id})) } // trigger cascade for each batchSize rel items until there is none batchSize := 4000 rows := make([]*Record, 0, batchSize) for { if err := query.Limit(int64(batchSize)).All(&rows); err != nil { return err } total := len(rows) if total == 0 { break } err := deleteRefRecords(app, mainRecord, rows, field) if err != nil { return err } if total < batchSize { break // no more items } rows = rows[:0] // keep allocated memory } } } return nil } // deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set) // OR // just unset the record id from any relation field values (if they are not required). // // NB! This method is expected to be called from inside of a transaction. func deleteRefRecords(app App, mainRecord *Record, refRecords []*Record, field Field) error { relField, _ := field.(*RelationField) if relField == nil { return errors.New("only RelationField is supported at the moment, got " + field.Type()) } for _, refRecord := range refRecords { ids := refRecord.GetStringSlice(relField.Name) // unset the record id for i := len(ids) - 1; i >= 0; i-- { if ids[i] == mainRecord.Id { ids = append(ids[:i], ids[i+1:]...) break } } // cascade delete the reference // (only if there are no other active references in case of multiple select) if relField.CascadeDelete && len(ids) == 0 { if err := app.Delete(refRecord); err != nil { return err } // no further actions are needed (the reference is deleted) continue } if relField.Required && len(ids) == 0 { return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name) } // save the reference changes // (without validation because it is possible that another relation field to have a reference to a previous deleted record) refRecord.Set(relField.Name, ids) if err := app.SaveNoValidate(refRecord); err != nil { return err } } return nil }