1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2024-11-28 18:11:17 +02:00
pocketbase/models/schema/schema_field.go
2023-08-21 12:58:18 +03:00

688 lines
18 KiB
Go

package schema
import (
"encoding/json"
"errors"
"regexp"
"strconv"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
// field value modifiers
const (
FieldValueModifierAdd string = "+"
FieldValueModifierSubtract string = "-"
)
// FieldValueModifiers returns a list with all available field modifier tokens.
func FieldValueModifiers() []string {
return []string{
FieldValueModifierAdd,
FieldValueModifierSubtract,
}
}
// commonly used field names
const (
FieldNameId string = "id"
FieldNameCreated string = "created"
FieldNameUpdated string = "updated"
FieldNameCollectionId string = "collectionId"
FieldNameCollectionName string = "collectionName"
FieldNameExpand string = "expand"
FieldNameUsername string = "username"
FieldNameEmail string = "email"
FieldNameEmailVisibility string = "emailVisibility"
FieldNameVerified string = "verified"
FieldNameTokenKey string = "tokenKey"
FieldNamePasswordHash string = "passwordHash"
FieldNameLastResetSentAt string = "lastResetSentAt"
FieldNameLastVerificationSentAt string = "lastVerificationSentAt"
)
// BaseModelFieldNames returns the field names that all models have (id, created, updated).
func BaseModelFieldNames() []string {
return []string{
FieldNameId,
FieldNameCreated,
FieldNameUpdated,
}
}
// SystemFields returns special internal field names that are usually readonly.
func SystemFieldNames() []string {
return []string{
FieldNameCollectionId,
FieldNameCollectionName,
FieldNameExpand,
}
}
// AuthFieldNames returns the reserved "auth" collection auth field names.
func AuthFieldNames() []string {
return []string{
FieldNameUsername,
FieldNameEmail,
FieldNameEmailVisibility,
FieldNameVerified,
FieldNameTokenKey,
FieldNamePasswordHash,
FieldNameLastResetSentAt,
FieldNameLastVerificationSentAt,
}
}
// All valid field types
const (
FieldTypeText string = "text"
FieldTypeNumber string = "number"
FieldTypeBool string = "bool"
FieldTypeEmail string = "email"
FieldTypeUrl string = "url"
FieldTypeEditor string = "editor"
FieldTypeDate string = "date"
FieldTypeSelect string = "select"
FieldTypeJson string = "json"
FieldTypeFile string = "file"
FieldTypeRelation string = "relation"
// Deprecated: Will be removed in v0.9+
FieldTypeUser string = "user"
)
// FieldTypes returns slice with all supported field types.
func FieldTypes() []string {
return []string{
FieldTypeText,
FieldTypeNumber,
FieldTypeBool,
FieldTypeEmail,
FieldTypeUrl,
FieldTypeEditor,
FieldTypeDate,
FieldTypeSelect,
FieldTypeJson,
FieldTypeFile,
FieldTypeRelation,
}
}
// ArraybleFieldTypes returns slice with all array value supported field types.
func ArraybleFieldTypes() []string {
return []string{
FieldTypeSelect,
FieldTypeFile,
FieldTypeRelation,
}
}
// SchemaField defines a single schema field structure.
type SchemaField struct {
System bool `form:"system" json:"system"`
Id string `form:"id" json:"id"`
Name string `form:"name" json:"name"`
Type string `form:"type" json:"type"`
Required bool `form:"required" json:"required"`
// Presentable indicates whether the field is suitable for
// visualization purposes (eg. in the Admin UI relation views).
Presentable bool `form:"presentable" json:"presentable"`
// Deprecated: This field is no-op and will be removed in future versions.
// Please use the collection.Indexes field to define a unique constraint.
Unique bool `form:"unique" json:"unique"`
Options any `form:"options" json:"options"`
}
// ColDefinition returns the field db column type definition as string.
func (f *SchemaField) ColDefinition() string {
switch f.Type {
case FieldTypeNumber:
return "NUMERIC DEFAULT 0 NOT NULL"
case FieldTypeBool:
return "BOOLEAN DEFAULT FALSE NOT NULL"
case FieldTypeJson:
return "JSON DEFAULT NULL"
default:
if opt, ok := f.Options.(MultiValuer); ok && opt.IsMultiple() {
return "JSON DEFAULT '[]' NOT NULL"
}
return "TEXT DEFAULT '' NOT NULL"
}
}
// String serializes and returns the current field as string.
func (f SchemaField) String() string {
data, _ := f.MarshalJSON()
return string(data)
}
// MarshalJSON implements the [json.Marshaler] interface.
func (f SchemaField) MarshalJSON() ([]byte, error) {
type alias SchemaField // alias to prevent recursion
f.InitOptions()
return json.Marshal(alias(f))
}
// UnmarshalJSON implements the [json.Unmarshaler] interface.
//
// The schema field options are auto initialized on success.
func (f *SchemaField) UnmarshalJSON(data []byte) error {
type alias *SchemaField // alias to prevent recursion
a := alias(f)
if err := json.Unmarshal(data, a); err != nil {
return err
}
return f.InitOptions()
}
// Validate makes `SchemaField` validatable by implementing [validation.Validatable] interface.
func (f SchemaField) Validate() error {
// init field options (if not already)
f.InitOptions()
excludeNames := BaseModelFieldNames()
// exclude special filter literals
excludeNames = append(excludeNames, "null", "true", "false", "_rowid_")
// exclude system literals
excludeNames = append(excludeNames, SystemFieldNames()...)
return validation.ValidateStruct(&f,
validation.Field(&f.Options, validation.Required, validation.By(f.checkOptions)),
validation.Field(&f.Id, validation.Required, validation.Length(5, 255)),
validation.Field(
&f.Name,
validation.Required,
validation.Length(1, 255),
validation.Match(schemaFieldNameRegex),
validation.NotIn(list.ToInterfaceSlice(excludeNames)...),
),
validation.Field(&f.Type, validation.Required, validation.In(list.ToInterfaceSlice(FieldTypes())...)),
// currently file fields cannot be unique because a proper
// hash/content check could cause performance issues
validation.Field(&f.Unique, validation.When(f.Type == FieldTypeFile, validation.Empty)),
)
}
func (f *SchemaField) checkOptions(value any) error {
v, ok := value.(FieldOptions)
if !ok {
return validation.NewError("validation_invalid_options", "Failed to initialize field options")
}
return v.Validate()
}
// InitOptions initializes the current field options based on its type.
//
// Returns error on unknown field type.
func (f *SchemaField) InitOptions() error {
if _, ok := f.Options.(FieldOptions); ok {
return nil // already inited
}
serialized, err := json.Marshal(f.Options)
if err != nil {
return err
}
var options any
switch f.Type {
case FieldTypeText:
options = &TextOptions{}
case FieldTypeNumber:
options = &NumberOptions{}
case FieldTypeBool:
options = &BoolOptions{}
case FieldTypeEmail:
options = &EmailOptions{}
case FieldTypeUrl:
options = &UrlOptions{}
case FieldTypeEditor:
options = &EditorOptions{}
case FieldTypeDate:
options = &DateOptions{}
case FieldTypeSelect:
options = &SelectOptions{}
case FieldTypeJson:
options = &JsonOptions{}
case FieldTypeFile:
options = &FileOptions{}
case FieldTypeRelation:
options = &RelationOptions{}
// Deprecated: Will be removed in v0.9+
case FieldTypeUser:
options = &UserOptions{}
default:
return errors.New("Missing or unknown field field type.")
}
if err := json.Unmarshal(serialized, options); err != nil {
return err
}
f.Options = options
return nil
}
// PrepareValue returns normalized and properly formatted field value.
func (f *SchemaField) PrepareValue(value any) any {
// init field options (if not already)
f.InitOptions()
switch f.Type {
case FieldTypeText, FieldTypeEmail, FieldTypeUrl, FieldTypeEditor:
return cast.ToString(value)
case FieldTypeJson:
val := value
if str, ok := val.(string); ok {
// in order to support seamlessly both json and multipart/form-data requests,
// the following normalization rules are applied for plain string values:
// - "true" is converted to the json `true`
// - "false" is converted to the json `false`
// - "null" is converted to the json `null`
// - "[1,2,3]" is converted to the json `[1,2,3]`
// - "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
// - numeric strings are converted to json number
// - double quoted strings are left as they are (aka. without normalizations)
// - any other string (empty string too) is double quoted
if str == "" {
val = strconv.Quote(str)
} else if str == "null" || str == "true" || str == "false" {
val = str
} else if ((str[0] >= '0' && str[0] <= '9') ||
str[0] == '"' ||
str[0] == '[' ||
str[0] == '{') &&
is.JSON.Validate(str) == nil {
val = str
} else {
val = strconv.Quote(str)
}
}
val, _ = types.ParseJsonRaw(val)
return val
case FieldTypeNumber:
return cast.ToFloat64(value)
case FieldTypeBool:
return cast.ToBool(value)
case FieldTypeDate:
val, _ := types.ParseDateTime(value)
return val
case FieldTypeSelect:
val := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*SelectOptions)
if !options.IsMultiple() {
if len(val) > 0 {
return val[len(val)-1] // the last selected
}
return ""
}
return val
case FieldTypeFile:
val := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*FileOptions)
if !options.IsMultiple() {
if len(val) > 0 {
return val[len(val)-1] // the last selected
}
return ""
}
return val
case FieldTypeRelation:
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*RelationOptions)
if !options.IsMultiple() {
if len(ids) > 0 {
return ids[len(ids)-1] // the last selected
}
return ""
}
return ids
default:
return value // unmodified
}
}
// PrepareValueWithModifier returns normalized and properly formatted field value
// by "merging" baseValue with the modifierValue based on the specified modifier (+ or -).
func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, modifierValue any) any {
resolvedValue := baseValue
switch f.Type {
case FieldTypeNumber:
switch modifier {
case FieldValueModifierAdd:
resolvedValue = cast.ToFloat64(baseValue) + cast.ToFloat64(modifierValue)
case FieldValueModifierSubtract:
resolvedValue = cast.ToFloat64(baseValue) - cast.ToFloat64(modifierValue)
}
case FieldTypeSelect, FieldTypeRelation:
switch modifier {
case FieldValueModifierAdd:
resolvedValue = append(
list.ToUniqueStringSlice(baseValue),
list.ToUniqueStringSlice(modifierValue)...,
)
case FieldValueModifierSubtract:
resolvedValue = list.SubtractSlice(
list.ToUniqueStringSlice(baseValue),
list.ToUniqueStringSlice(modifierValue),
)
}
case FieldTypeFile:
// note: file for now supports only the subtract modifier
if modifier == FieldValueModifierSubtract {
resolvedValue = list.SubtractSlice(
list.ToUniqueStringSlice(baseValue),
list.ToUniqueStringSlice(modifierValue),
)
}
}
return f.PrepareValue(resolvedValue)
}
// -------------------------------------------------------------------
// MultiValuer defines common interface methods that every multi-valued (eg. with MaxSelect) field option struct has.
type MultiValuer interface {
IsMultiple() bool
}
// FieldOptions defines common interface methods that every field option struct has.
type FieldOptions interface {
Validate() error
}
type TextOptions struct {
Min *int `form:"min" json:"min"`
Max *int `form:"max" json:"max"`
Pattern string `form:"pattern" json:"pattern"`
}
func (o TextOptions) Validate() error {
minVal := 0
if o.Min != nil {
minVal = *o.Min
}
return validation.ValidateStruct(&o,
validation.Field(&o.Min, validation.Min(0)),
validation.Field(&o.Max, validation.Min(minVal)),
validation.Field(&o.Pattern, validation.By(o.checkRegex)),
)
}
func (o *TextOptions) checkRegex(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
if _, err := regexp.Compile(v); err != nil {
return validation.NewError("validation_invalid_regex", err.Error())
}
return nil
}
// -------------------------------------------------------------------
type NumberOptions struct {
Min *float64 `form:"min" json:"min"`
Max *float64 `form:"max" json:"max"`
}
func (o NumberOptions) Validate() error {
var maxRules []validation.Rule
if o.Min != nil && o.Max != nil {
maxRules = append(maxRules, validation.Min(*o.Min))
}
return validation.ValidateStruct(&o,
validation.Field(&o.Max, maxRules...),
)
}
// -------------------------------------------------------------------
type BoolOptions struct {
}
func (o BoolOptions) Validate() error {
return nil
}
// -------------------------------------------------------------------
type EmailOptions struct {
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
}
func (o EmailOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(
&o.ExceptDomains,
validation.When(len(o.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
validation.Field(
&o.OnlyDomains,
validation.When(len(o.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
)
}
// -------------------------------------------------------------------
type UrlOptions struct {
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
}
func (o UrlOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(
&o.ExceptDomains,
validation.When(len(o.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
validation.Field(
&o.OnlyDomains,
validation.When(len(o.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
),
)
}
// -------------------------------------------------------------------
type EditorOptions struct {
}
func (o EditorOptions) Validate() error {
return nil
}
// -------------------------------------------------------------------
type DateOptions struct {
Min types.DateTime `form:"min" json:"min"`
Max types.DateTime `form:"max" json:"max"`
}
func (o DateOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.Max, validation.By(o.checkRange(o.Min, o.Max))),
)
}
func (o *DateOptions) checkRange(min types.DateTime, max types.DateTime) validation.RuleFunc {
return func(value any) error {
v, _ := value.(types.DateTime)
if v.IsZero() || min.IsZero() || max.IsZero() {
return nil // nothing to check
}
return validation.Date(types.DefaultDateLayout).
Min(min.Time()).
Max(max.Time()).
Validate(v.String())
}
}
// -------------------------------------------------------------------
type SelectOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
Values []string `form:"values" json:"values"`
}
func (o SelectOptions) Validate() error {
max := len(o.Values)
if max == 0 {
max = 1
}
return validation.ValidateStruct(&o,
validation.Field(&o.Values, validation.Required),
validation.Field(
&o.MaxSelect,
validation.Required,
validation.Min(1),
validation.Max(max),
),
)
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (o SelectOptions) IsMultiple() bool {
return o.MaxSelect > 1
}
// -------------------------------------------------------------------
type JsonOptions struct {
}
func (o JsonOptions) Validate() error {
return nil
}
// -------------------------------------------------------------------
var _ MultiValuer = (*FileOptions)(nil)
type FileOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
Thumbs []string `form:"thumbs" json:"thumbs"`
Protected bool `form:"protected" json:"protected"`
}
func (o FileOptions) Validate() error {
return validation.ValidateStruct(&o,
validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
validation.Field(&o.MaxSize, validation.Required, validation.Min(1)),
validation.Field(&o.Thumbs, validation.Each(
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
validation.Match(filesystem.ThumbSizeRegex),
)),
)
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (o FileOptions) IsMultiple() bool {
return o.MaxSelect > 1
}
// -------------------------------------------------------------------
var _ MultiValuer = (*RelationOptions)(nil)
type RelationOptions struct {
// CollectionId is the id of the related collection.
CollectionId string `form:"collectionId" json:"collectionId"`
// CascadeDelete indicates whether the root model should be deleted
// in case of delete of all linked relations.
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
// MinSelect indicates the min number of allowed relation records
// that could be linked to the main model.
//
// If nil no limits are applied.
MinSelect *int `form:"minSelect" json:"minSelect"`
// MaxSelect indicates the max number of allowed relation records
// that could be linked to the main model.
//
// If nil no limits are applied.
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
// Deprecated: This field is no-op and will be removed in future versions.
// Instead use the individula SchemaField.Presentable option for each field in the relation collection.
DisplayFields []string `form:"displayFields" json:"displayFields"`
}
func (o RelationOptions) Validate() error {
minVal := 0
if o.MinSelect != nil {
minVal = *o.MinSelect
}
return validation.ValidateStruct(&o,
validation.Field(&o.CollectionId, validation.Required),
validation.Field(&o.MinSelect, validation.Min(0)),
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(minVal)),
)
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (o RelationOptions) IsMultiple() bool {
return o.MaxSelect == nil || *o.MaxSelect > 1
}
// -------------------------------------------------------------------
// Deprecated: Will be removed in v0.9+
type UserOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
}
// Deprecated: Will be removed in v0.9+
func (o UserOptions) Validate() error {
return nil
}