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)), ) }