Select Git revision
item.go 22.49 KiB
package items
import (
"context"
"fmt"
"time"
"git.perx.ru/perxis/perxis-go/pkg/data"
"git.perx.ru/perxis/perxis-go/pkg/errors"
"git.perx.ru/perxis/perxis-go/pkg/locales"
"git.perx.ru/perxis/perxis-go/pkg/schema"
"git.perx.ru/perxis/perxis-go/pkg/schema/field"
"git.perx.ru/perxis/perxis-go/pkg/schema/localizer"
"git.perx.ru/perxis/perxis-go/pkg/schema/walk"
pb "git.perx.ru/perxis/perxis-go/proto/items"
"github.com/mitchellh/mapstructure"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
var (
ErrNotSystemField = errors.New("not a system field")
ErrIncorrectValue = errors.New("incorrect value")
ErrIncorrectField = errors.New("incorrect field")
ErrReservedField = errors.New("cannot use reserved field name")
)
type State int
func (s State) String() string {
switch s {
case StateDraft:
return "Draft"
case StateArchived:
return "Archived"
case StateChanged:
return "Changed"
case StatePublished:
return "Published"
}
return "Unknown"
}
const (
StateDraft State = iota
StatePublished
StateChanged
StateArchived
StateMax = StateArchived
SoftDeleteSeparator = "___"
)
var PermissionsAllowAny = &Permissions{
Edit: true,
Archive: true,
Publish: true,
SoftDelete: true,
HardDelete: true,
}
// SystemFields - системные поля Item
var SystemFields = []string{
"id",
"space_id",
"env_id",
"collection_id",
"state",
"created_rev_at",
"created_by",
"created_at",
"updated_at",
"updated_by",
"revision_id",
"revision_description",
"data",
"translations",
"translations_ids",
"locale_id",
"deleted",
"hidden",
"template",
"search_score",
}
type Permissions struct {
Edit bool
Archive bool
Publish bool
SoftDelete bool
HardDelete bool
}
type Item struct {
ID string `json:"id" bson:"_id"` // ID - Идентификатор записи. Автоматически генерируется системой при сохранении первой ревизии.
SpaceID string `json:"spaceId" bson:"-"`
EnvID string `json:"envId" bson:"-"`
CollectionID string `json:"collectionId" bson:"-"`
State State `json:"state" bson:"state"`
CreatedRevAt time.Time `json:"createdRevAt,omitempty" bson:"created_rev_at,omitempty"`
CreatedBy string `json:"createdBy,omitempty" bson:"created_by,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty" bson:"created_at,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty" bson:"updated_at,omitempty"`
UpdatedBy string `json:"updatedBy,omitempty" bson:"updated_by,omitempty"`
Data map[string]interface{} `json:"data" bson:"data"`
// При создании или обновлении идентификатор локали в котором создается запись, опционально.
// Если указан, то создается перевод для указанного языка, поле translations игнорируется
LocaleID string `json:"localeId,omitempty" bson:"-"`
// Позволяет одновременно установить/получить несколько переводов и производить манипуляции с переводами
// Ключами является идентификатор локали, значениями - данные переводы
// При обновлении не происходит валидация или модификация каждого из переводов в соответствие со схемой,
// поэтому обновление через поле `translations` стоит выполнять с аккуратностью
// Для удаления переводов реализована следующая логика:
// - {"lang":nil|{}} - сброс перевода для языка
// - {"lang":map{...}} - установка перевода для языка
// - {"lang":map{...}, "*":nil} - установка перевода для языка, сброс остальных переводов
// - {"*":nil} - сброс всех переводов
Translations map[string]map[string]interface{} `json:"translations,omitempty" bson:"translations,omitempty"`
// Список идентификаторов локалей, для которых есть переводы.
// Соответствует ключам в translations
TranslationsIDs []string `json:"translationsIds,omitempty" bson:"translations_ids,omitempty"`
RevisionID string `json:"revId,omitempty" bson:"revision_id"`
RevisionDescription string `json:"revDescription,omitempty" bson:"revision_description"`
Permissions *Permissions `json:"permissions,omitempty" bson:"-"`
// Релеватность элемента при полнотекстовом поиске
SearchScore float64 `json:"searchScore,omitempty" bson:"search_score,omitempty"`
Deleted bool `json:"deleted,omitempty" bson:"deleted,omitempty"`
Hidden bool `json:"hidden,omitempty" bson:"hidden,omitempty"`
Template bool `json:"template,omitempty" bson:"template,omitempty"`
}
func NewItem(spaceID, envID, collID, id string, data map[string]interface{}, translations map[string]map[string]interface{}) *Item {
return &Item{
ID: id,
SpaceID: spaceID,
EnvID: envID,
CollectionID: collID,
Data: data,
Translations: translations,
}
}
// GetID возвращает идентификатор записи
func (i *Item) GetID() string {
return i.ID
}
func (i *Item) GetSpaceID() string {
return i.SpaceID
}
func (i *Item) Clone() *Item {
itm := *i
itm.Data = data.CloneMap(i.Data)
if i.Translations != nil {
itm.Translations = make(map[string]map[string]interface{}, len(i.Translations))
for t, m := range i.Translations {
itm.Translations[t] = data.CloneMap(m)
}
}
return &itm
}
// ToMap конвертирует текущий элемент в map[string]any.
// DEPRECATED, используйте ToMap.
func (i *Item) ToMap() map[string]interface{} {
return map[string]interface{}{
"id": i.ID,
"space_id": i.SpaceID,
"env_id": i.EnvID,
"collection_id": i.CollectionID,
"state": i.State,
"created_rev_at": i.CreatedRevAt,
"created_by": i.CreatedBy,
"created_at": i.CreatedAt,
"updated_at": i.UpdatedAt,
"updated_by": i.UpdatedBy,
"revision_id": i.RevisionID,
"revision_description": i.RevisionDescription,
"data": i.Data,
"locale_id": i.LocaleID,
"translations": i.Translations,
"translations_ids": i.TranslationsIDs,
"deleted": i.Deleted,
"hidden": i.Hidden,
"template": i.Template,
"search_score": i.SearchScore,
}
}
// ToMap конвертирует переданный Item в map[string]any, кодируя данные согласно схеме.
// Вычисляемые поля удаляются из результата.
func ToMap(item *Item, sch *schema.Schema) (map[string]any, error) {
if item == nil {
return nil, errors.New("item must not be nil")
}
if sch == nil {
return nil, errors.New("schema must not be nil")
}
output := make(map[string]any)
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
Result: &output,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create decoder")
}
item, err = item.Encode(context.Background(), sch)
if err != nil {
return nil, errors.Wrap(err, "failed to encode item by schema")
}
err = decoder.Decode(item)
if err != nil {
return nil, errors.Wrap(err, "failed to encode item")
}
// Кодируем системные поля со временем
output["createdRevAt"] = item.CreatedRevAt.Format(time.RFC3339)
output["createdAt"] = item.CreatedAt.Format(time.RFC3339)
output["updatedAt"] = item.UpdatedAt.Format(time.RFC3339)
// Удаляем вычисляемые поля
delete(output, "permissions")
delete(output, "searchScore")
return output, nil
}
// FromMap конвертирует переданный map[string]any в Item, декодируя данные согласно схеме.
// Вычисляемые поля игнорируются при декодировании.
func FromMap(input map[string]any, sch *schema.Schema) (*Item, error) {
if len(input) == 0 {
return nil, errors.New("input map must not be empty or nil")
}
if sch == nil {
return nil, errors.New("schema must not be nil")
}
item := &Item{}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339),
TagName: "json",
Result: item,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create decoder")
}
// Удаляем вычисляемые поля
delete(input, "permissions")
delete(input, "searchScore")
err = decoder.Decode(input)
if err != nil {
return nil, errors.Wrap(err, "failed to decode")
}
item, err = item.Decode(context.Background(), sch)
if err != nil {
return nil, errors.Wrap(err, "failed to decode item by schema")
}
return item, nil
}
// SetData устанавливает перевод в нужное поле записи
func (i *Item) SetData(dt map[string]interface{}, localeID string) {
if localeID == "" || localeID == locales.DefaultID {
i.Data = dt
return
}
if i.Translations == nil {
i.Translations = map[string]map[string]interface{}{localeID: dt}
if !data.Contains(localeID, i.TranslationsIDs) {
i.TranslationsIDs = append(i.TranslationsIDs, localeID)
}
}
return
}
// GetData возвращает полные локализованные данные записи
func (i *Item) GetData(localeID string) map[string]interface{} {
if localeID == "" || localeID == locales.DefaultID {
return i.Data
}
if i.Translations != nil {
return i.Translations[localeID]
}
return nil
}
func (i *Item) AddTranslations(translations map[string]map[string]interface{}) {
if i.Translations == nil {
i.Translations = make(map[string]map[string]interface{}, len(translations))
}
for l, t := range translations {
i.Translations[l] = t
}
for k := range translations {
if !data.Contains(k, i.TranslationsIDs) {
i.TranslationsIDs = append(i.TranslationsIDs, k)
}
}
}
func (i *Item) Localize(localizer *localizer.Localizer) (err error) {
if localizer == nil {
return nil
}
i.Data, err = localizer.Localize(i.Data, i.Translations)
if err != nil {
return err
}
i.LocaleID = localizer.LocaleID()
i.Translations = nil
return nil
}
// // UnsetTranslation устанавливает значение перевода в nil, для сброса перевода для языка
// // "localeID":map{...}, "*":nil} - установка перевода для языка, сброс остальных переводов
// // {"*":nil} -сброс всех переводов
// func (i *Item) UnsetTranslation(localeID string) {
// if i.Translations == nil {
// i.Translations = make(map[string]map[string]interface{})
// }
//
// i.Translations[localeID] = nil
// }
//
// // SetTranslation устанавливает перевод для языка
// func (i *Item) SetTranslation(dt map[string]interface{}, localeID string) {
// if i.Translations == nil {
// i.Translations = make(map[string]map[string]interface{})
// }
//
// i.Translations[localeID] = dt
// }
func (i *Item) Encode(ctx context.Context, s *schema.Schema) (*Item, error) {
res := *i
if i.Data != nil {
dt, err := schema.Encode(ctx, s, i.Data)
if err != nil {
// return errors.WithField(err, "data")
return nil, err
}
res.Data = dt.(map[string]interface{})
}
if len(i.Translations) > 0 {
res.Translations = make(map[string]map[string]interface{}, len(i.Translations))
for l, v := range i.Translations {
if len(v) == 0 {
res.Translations[l] = v
continue
}
dt, err := schema.Encode(ctx, s, v)
if err != nil {
return nil, err
}
res.Translations[l] = dt.(map[string]interface{})
}
}
return &res, nil
}
func (i *Item) Decode(ctx context.Context, s *schema.Schema) (*Item, error) {
res := *i
var err error
if i.Data != nil {
res.Data, err = s.Decode(ctx, i.Data)
if err != nil {
return nil, err
}
}
if len(i.Translations) > 0 {
res.Translations = make(map[string]map[string]interface{}, len(i.Translations))
for l, v := range i.Translations {
if len(v) == 0 {
res.Translations[l] = v
continue
}
dt, err := schema.Decode(ctx, s, v)
if err != nil {
return nil, err
}
res.Translations[l] = dt.(map[string]interface{})
}
}
return &res, nil
}
type ProcessDataFunc func(ctx context.Context, sch *schema.Schema, data map[string]interface{}) (map[string]interface{}, error)
func (i *Item) ProcessData(ctx context.Context, sch *schema.Schema, fn ProcessDataFunc, locales ...*locales.Locale) (*Item, error) {
res := *i
if i.Data != nil {
dt, err := fn(ctx, sch, res.Data)
if err != nil {
return nil, errors.WithField(err, "data")
}
res.Data = dt
}
tr := make(map[string]map[string]interface{})
for _, l := range locales {
itm := *i
err := itm.Localize(localizer.NewLocalizer(localizer.Config{Schema: sch, Locales: locales, LocaleID: l.ID}))
if err != nil {
return nil, errors.WithField(err, fmt.Sprintf("translations.%s", l.ID))
}
dt, err := fn(ctx, sch, itm.Data)
if err != nil {
return nil, errors.WithField(err, fmt.Sprintf("translations.%s", l.ID))
}
tr[l.ID] = dt
}
res.Translations = nil
if len(tr) > 0 {
res.Translations = tr
}
return &res, nil
}
// IsSystemField возвращает являться ли поле системным
func IsSystemField(field string) bool {
return data.Contains(field, SystemFields)
}
// SetSystemField устанавливает значение системного поля
func (i *Item) SetSystemField(field string, value interface{}) error {
var ok bool
switch field {
case "id":
i.ID, ok = value.(string)
case "space_id":
i.SpaceID, ok = value.(string)
case "env_id":
i.EnvID, ok = value.(string)
case "collection_id":
i.CollectionID, ok = value.(string)
case "created_rev_at":
i.CreatedRevAt, ok = value.(time.Time)
case "created_by":
i.CreatedBy, ok = value.(string)
case "created_at":
i.CreatedAt, ok = value.(time.Time)
case "updated_by":
i.UpdatedBy, ok = value.(string)
case "updated_at":
i.UpdatedAt, ok = value.(time.Time)
case "revision_id":
i.RevisionID, ok = value.(string)
case "revision_description":
i.RevisionDescription, ok = value.(string)
case "hidden":
i.Hidden, ok = value.(bool)
case "deleted":
i.Deleted, ok = value.(bool)
case "template":
i.Template, ok = value.(bool)
case "search_score":
i.SearchScore, ok = value.(float64)
case "locale_id":
i.LocaleID, ok = value.(string)
default:
return ErrNotSystemField
}
if !ok {
return ErrIncorrectValue
}
return nil
}
// GetSystem возвращает значение системного поля
func (i *Item) GetSystem(field string) (any, error) {
switch field {
case "id":
return i.ID, nil
case "space_id":
return i.SpaceID, nil
case "env_id":
return i.EnvID, nil
case "collection_id":
return i.CollectionID, nil
case "created_rev_at":
return i.CreatedRevAt, nil
case "created_by":
return i.CreatedBy, nil
case "created_at":
return i.CreatedAt, nil
case "updated_by":
return i.UpdatedBy, nil
case "updated_at":
return i.UpdatedAt, nil
case "revision_id":
return i.RevisionID, nil
case "revision_description":
return i.RevisionDescription, nil
case "hidden":
return i.Hidden, nil
case "deleted":
return i.Deleted, nil
case "template":
return i.Template, nil
case "search_score":
return i.SearchScore, nil
case "state":
return i.State, nil
case "locale_id":
return i.LocaleID, nil
}
return nil, ErrNotSystemField
}
func (i *Item) setItemData(field string, value interface{}) error {
if i.Data == nil {
i.Data = make(map[string]any)
}
return data.Set(field, i.Data, value)
}
func (i *Item) getItemData(field string) (any, error) {
if i.Data != nil {
if v, ok := data.Get(field, i.Data); ok {
return v, nil
}
}
return nil, ErrIncorrectField
}
// Set устанавливает значение поля
func (i *Item) Set(field string, value interface{}) error {
if err := i.SetSystemField(field, value); !errors.Is(err, ErrNotSystemField) {
return errors.Wrapf(err, "fail to set system field '%s' value", field)
}
return i.setItemData(field, value)
}
// Get возвращает значение поля
func (i *Item) Get(field string) (any, error) {
if v, err := i.GetSystem(field); err == nil {
return v, err
}
return i.getItemData(field)
}
// Delete удаляет значение поля Data
func (i *Item) Delete(field string) error {
// Если data == nil, то нет необходимости выполнять удаление
if i.Data == nil {
return nil
}
return data.Delete(field, i.Data)
}
// GetSystemField возвращает описание поля для системных аттрибутов Item
func GetSystemField(fld string) (*field.Field, error) {
switch fld {
case "id", "space_id", "env_id", "collection_id", "revision_id", "revision_description", "locale_id":
return field.String(), nil
case "created_rev_at", "created_at", "updated_at", "published_at":
return field.Time(), nil
case "created_by", "updated_by", "published_by":
return field.String(), nil
case "hidden", "deleted", "template":
return field.Bool(), nil
case "search_score":
return field.Number(field.NumberFormatFloat), nil
case "state":
return field.Number(field.NumberFormatInt), nil
}
return nil, ErrNotSystemField
}
// GetField возвращает значение поля
func GetField(field string, sch *schema.Schema) (*field.Field, error) {
if f, err := GetSystemField(field); err == nil {
return f, err
}
f := sch.GetField(field)
if f == nil {
return nil, ErrIncorrectField
}
return f, nil
}
// GetSystemNamedFields возвращает описание всех системных полей Item
func GetSystemNamedFields() []field.NamedField {
fields := make([]field.NamedField, 0, len(SystemFields))
for _, n := range SystemFields {
f := field.NamedField{Name: n}
f.Field, _ = GetSystemField(n)
fields = append(fields, f)
}
return fields
}
func ItemToProto(item *Item) *pb.Item {
if item == nil {
return nil
}
protoItem := &pb.Item{
Id: item.ID,
SpaceId: item.SpaceID,
EnvId: item.EnvID,
CollectionId: item.CollectionID,
State: pb.Item_State(item.State),
CreatedBy: item.CreatedBy,
UpdatedBy: item.UpdatedBy,
RevisionId: item.RevisionID,
RevisionDescription: item.RevisionDescription,
LocaleId: item.LocaleID,
TranslationsIds: item.TranslationsIDs,
Hidden: item.Hidden,
Template: item.Template,
Deleted: item.Deleted,
SearchScore: item.SearchScore,
}
if item.Data != nil {
protoItem.Data, _ = structpb.NewStruct(item.Data)
}
if item.Translations != nil {
protoItem.Translations = make(map[string]*structpb.Struct, len(item.Translations))
for k, v := range item.Translations {
var t *structpb.Struct
if v != nil {
t, _ = structpb.NewStruct(v)
}
protoItem.Translations[k] = t
}
}
protoItem.CreatedRevAt = timestamppb.New(item.CreatedRevAt)
protoItem.CreatedAt = timestamppb.New(item.CreatedAt)
protoItem.UpdatedAt = timestamppb.New(item.UpdatedAt)
if item.Permissions != nil {
protoItem.Permissions = &pb.Permissions{
Edit: item.Permissions.Edit,
Archive: item.Permissions.Archive,
Publish: item.Permissions.Publish,
SoftDelete: item.Permissions.SoftDelete,
HardDelete: item.Permissions.HardDelete,
}
}
return protoItem
}
func ItemFromProto(protoItem *pb.Item) *Item {
if protoItem == nil {
return nil
}
item := &Item{
ID: protoItem.Id,
SpaceID: protoItem.SpaceId,
EnvID: protoItem.EnvId,
CollectionID: protoItem.CollectionId,
State: State(protoItem.State),
CreatedBy: protoItem.CreatedBy,
UpdatedBy: protoItem.UpdatedBy,
RevisionID: protoItem.RevisionId,
RevisionDescription: protoItem.RevisionDescription,
LocaleID: protoItem.LocaleId,
TranslationsIDs: protoItem.TranslationsIds,
Hidden: protoItem.Hidden,
Template: protoItem.Template,
Deleted: protoItem.Deleted,
SearchScore: protoItem.SearchScore,
}
if protoItem.Data != nil {
item.Data = protoItem.Data.AsMap()
}
if protoItem.Translations != nil {
item.Translations = make(map[string]map[string]interface{}, len(protoItem.Translations))
}
if protoItem.Translations != nil {
for k, v := range protoItem.Translations {
// При proto.Marshal/Unmarshal `nil`-значение превращается в `Struct{}`, поэтому логика
// много смысла не несет, но хотя бы здесь сохраним различие пустого и nil значений
var t map[string]interface{}
if v != nil {
t = v.AsMap()
}
item.Translations[k] = t
}
}
if protoItem.Permissions != nil {
item.Permissions = &Permissions{
Edit: protoItem.Permissions.Edit,
Archive: protoItem.Permissions.Archive,
Publish: protoItem.Permissions.Publish,
SoftDelete: protoItem.Permissions.SoftDelete,
HardDelete: protoItem.Permissions.HardDelete,
}
}
item.CreatedRevAt = protoItem.CreatedRevAt.AsTime()
item.CreatedAt = protoItem.CreatedAt.AsTime()
item.UpdatedAt = protoItem.UpdatedAt.AsTime()
return item
}
func GetItemIDs(arr []*Item) []string {
res := make([]string, len(arr))
for i, e := range arr {
res[i] = e.ID
}
return res
}
func MergeItemData(ctx context.Context, sch *schema.Schema, origData, updData map[string]any) (map[string]any, error) {
if origData == nil {
return updData, nil
}
w := walk.NewWalker(sch, &walk.WalkConfig{})
w.DefaultFn = func(c *walk.WalkContext) error {
if c.Src == nil || c.Dst != nil {
return nil
}
c.Dst = c.Src
c.Changed = true
return nil
}
res, _, err := w.DataWalk(ctx, updData, origData)
if err != nil {
return nil, err
}
v, ok := res.(map[string]any)
if !ok {
return nil, fmt.Errorf("expected map[string]interface{}, got %[1]T, %[1]v", res)
}
return v, nil
}