mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-01-10 00:43:36 +02:00
492 lines
12 KiB
Go
492 lines
12 KiB
Go
package schema
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
|
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
|
"github.com/go-ozzo/ozzo-validation/v4/is"
|
|
"github.com/pocketbase/pocketbase/tools/list"
|
|
"github.com/pocketbase/pocketbase/tools/types"
|
|
"github.com/spf13/cast"
|
|
)
|
|
|
|
var schemaFieldNameRegex = regexp.MustCompile(`^\#?\w+$`)
|
|
|
|
// reserved internal field names
|
|
const (
|
|
ReservedFieldNameId = "id"
|
|
ReservedFieldNameCreated = "created"
|
|
ReservedFieldNameUpdated = "updated"
|
|
)
|
|
|
|
// ReservedFieldNames returns slice with reserved/system field names.
|
|
func ReservedFieldNames() []string {
|
|
return []string{
|
|
ReservedFieldNameId,
|
|
ReservedFieldNameCreated,
|
|
ReservedFieldNameUpdated,
|
|
}
|
|
}
|
|
|
|
// All valid field types
|
|
const (
|
|
FieldTypeText string = "text"
|
|
FieldTypeNumber string = "number"
|
|
FieldTypeBool string = "bool"
|
|
FieldTypeEmail string = "email"
|
|
FieldTypeUrl string = "url"
|
|
FieldTypeDate string = "date"
|
|
FieldTypeSelect string = "select"
|
|
FieldTypeJson string = "json"
|
|
FieldTypeFile string = "file"
|
|
FieldTypeRelation string = "relation"
|
|
FieldTypeUser string = "user"
|
|
)
|
|
|
|
// FieldTypes returns slice with all supported field types.
|
|
func FieldTypes() []string {
|
|
return []string{
|
|
FieldTypeText,
|
|
FieldTypeNumber,
|
|
FieldTypeBool,
|
|
FieldTypeEmail,
|
|
FieldTypeUrl,
|
|
FieldTypeDate,
|
|
FieldTypeSelect,
|
|
FieldTypeJson,
|
|
FieldTypeFile,
|
|
FieldTypeRelation,
|
|
FieldTypeUser,
|
|
}
|
|
}
|
|
|
|
// ArraybleFieldTypes returns slice with all array value supported field types.
|
|
func ArraybleFieldTypes() []string {
|
|
return []string{
|
|
FieldTypeSelect,
|
|
FieldTypeFile,
|
|
FieldTypeRelation,
|
|
FieldTypeUser,
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
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 "REAL DEFAULT 0"
|
|
case FieldTypeBool:
|
|
return "Boolean DEFAULT FALSE"
|
|
case FieldTypeJson:
|
|
return "JSON DEFAULT NULL"
|
|
default:
|
|
return "TEXT DEFAULT ''"
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
// add commonly used filter literals to the exlude names list
|
|
excludeNames := ReservedFieldNames()
|
|
excludeNames = append(excludeNames, "null", "true", "false")
|
|
|
|
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 FieldTypeDate:
|
|
options = &DateOptions{}
|
|
case FieldTypeSelect:
|
|
options = &SelectOptions{}
|
|
case FieldTypeJson:
|
|
options = &JsonOptions{}
|
|
case FieldTypeFile:
|
|
options = &FileOptions{}
|
|
case FieldTypeRelation:
|
|
options = &RelationOptions{}
|
|
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:
|
|
return cast.ToString(value)
|
|
case FieldTypeJson:
|
|
val, _ := types.ParseJsonRaw(value)
|
|
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.MaxSelect <= 1 {
|
|
if len(val) > 0 {
|
|
return val[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return val
|
|
case FieldTypeFile:
|
|
val := list.ToUniqueStringSlice(value)
|
|
|
|
options, _ := f.Options.(*FileOptions)
|
|
if options.MaxSelect <= 1 {
|
|
if len(val) > 0 {
|
|
return val[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return val
|
|
case FieldTypeRelation:
|
|
ids := list.ToUniqueStringSlice(value)
|
|
|
|
options, _ := f.Options.(*RelationOptions)
|
|
if options.MaxSelect <= 1 {
|
|
if len(ids) > 0 {
|
|
return ids[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return ids
|
|
case FieldTypeUser:
|
|
ids := list.ToUniqueStringSlice(value)
|
|
|
|
options, _ := f.Options.(*UserOptions)
|
|
if options.MaxSelect <= 1 {
|
|
if len(ids) > 0 {
|
|
return ids[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return ids
|
|
default:
|
|
return value // unmodified
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
// FieldOptions interfaces that defines common methods that every field options 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 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 {
|
|
return validation.ValidateStruct(&o,
|
|
validation.Field(&o.Values, validation.Required),
|
|
validation.Field(
|
|
&o.MaxSelect,
|
|
validation.Required,
|
|
validation.Min(1),
|
|
validation.Max(len(o.Values)),
|
|
),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type JsonOptions struct {
|
|
}
|
|
|
|
func (o JsonOptions) Validate() error {
|
|
return 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"`
|
|
}
|
|
|
|
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.Match(regexp.MustCompile(`^[1-9]\d*x[1-9]\d*$`)))),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type RelationOptions struct {
|
|
MaxSelect int `form:"maxSelect" json:"maxSelect"`
|
|
CollectionId string `form:"collectionId" json:"collectionId"`
|
|
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
|
|
}
|
|
|
|
func (o RelationOptions) Validate() error {
|
|
return validation.ValidateStruct(&o,
|
|
validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
|
|
validation.Field(&o.CollectionId, validation.Required),
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
type UserOptions struct {
|
|
MaxSelect int `form:"maxSelect" json:"maxSelect"`
|
|
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
|
|
}
|
|
|
|
func (o UserOptions) Validate() error {
|
|
return validation.ValidateStruct(&o,
|
|
validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
|
|
)
|
|
}
|