initial public commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
var _ Model = (*Admin)(nil)
|
||||
|
||||
type Admin struct {
|
||||
BaseAccount
|
||||
|
||||
Avatar int `db:"avatar" json:"avatar"`
|
||||
}
|
||||
|
||||
func (m *Admin) TableName() string {
|
||||
return "_admins"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestAdminTableName(t *testing.T) {
|
||||
m := models.Admin{}
|
||||
if m.TableName() != "_admins" {
|
||||
t.Fatalf("Unexpected table name, got %q", m.TableName())
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// Package models implements all PocketBase DB models.
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ColumnValueMapper defines an interface for custom db model data serialization.
|
||||
type ColumnValueMapper interface {
|
||||
// ColumnValueMap returns the data to be used when persisting the model.
|
||||
ColumnValueMap() map[string]any
|
||||
}
|
||||
|
||||
// Model defines an interface with common methods that all db models should have.
|
||||
type Model interface {
|
||||
TableName() string
|
||||
HasId() bool
|
||||
GetId() string
|
||||
GetCreated() types.DateTime
|
||||
GetUpdated() types.DateTime
|
||||
RefreshId()
|
||||
RefreshCreated()
|
||||
RefreshUpdated()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// BaseModel
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// BaseModel defines common fields and methods used by all other models.
|
||||
type BaseModel struct {
|
||||
Id string `db:"id" json:"id"`
|
||||
Created types.DateTime `db:"created" json:"created"`
|
||||
Updated types.DateTime `db:"updated" json:"updated"`
|
||||
}
|
||||
|
||||
// HasId returns whether the model has a nonzero primary key (aka. id).
|
||||
func (m *BaseModel) HasId() bool {
|
||||
return m.GetId() != ""
|
||||
}
|
||||
|
||||
// GetId returns the model's id.
|
||||
func (m *BaseModel) GetId() string {
|
||||
return m.Id
|
||||
}
|
||||
|
||||
// GetCreated returns the model's Created datetime.
|
||||
func (m *BaseModel) GetCreated() types.DateTime {
|
||||
return m.Created
|
||||
}
|
||||
|
||||
// GetCreated returns the model's Updated datetime.
|
||||
func (m *BaseModel) GetUpdated() types.DateTime {
|
||||
return m.Updated
|
||||
}
|
||||
|
||||
// RefreshId generates and sets a new model id.
|
||||
//
|
||||
// The generated id is a cryptographically random 15 characters length string
|
||||
// (could change in the future).
|
||||
func (m *BaseModel) RefreshId() {
|
||||
m.Id = security.RandomString(15)
|
||||
}
|
||||
|
||||
// RefreshCreated updates the model's Created field with the current datetime.
|
||||
func (m *BaseModel) RefreshCreated() {
|
||||
m.Created = types.NowDateTime()
|
||||
}
|
||||
|
||||
// RefreshCreated updates the model's Created field with the current datetime.
|
||||
func (m *BaseModel) RefreshUpdated() {
|
||||
m.Updated = types.NowDateTime()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// BaseAccount
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// BaseAccount defines common fields and methods used by auth models (aka. users and admins).
|
||||
type BaseAccount struct {
|
||||
BaseModel
|
||||
|
||||
Email string `db:"email" json:"email"`
|
||||
TokenKey string `db:"tokenKey" json:"-"`
|
||||
PasswordHash string `db:"passwordHash" json:"-"`
|
||||
LastResetSentAt types.DateTime `db:"lastResetSentAt" json:"lastResetSentAt"`
|
||||
}
|
||||
|
||||
// ValidatePassword validates a plain password against the model's password.
|
||||
func (m *BaseAccount) ValidatePassword(password string) bool {
|
||||
bytePassword := []byte(password)
|
||||
bytePasswordHash := []byte(m.PasswordHash)
|
||||
|
||||
// comparing the password with the hash
|
||||
err := bcrypt.CompareHashAndPassword(bytePasswordHash, bytePassword)
|
||||
|
||||
// nil means it is a match
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SetPassword sets cryptographically secure string to `model.Password`.
|
||||
//
|
||||
// Additionally this method also resets the LastResetSentAt and the TokenKey fields.
|
||||
func (m *BaseAccount) SetPassword(password string) error {
|
||||
if password == "" {
|
||||
return errors.New("The provided plain password is empty")
|
||||
}
|
||||
|
||||
// hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.PasswordHash = string(hashedPassword)
|
||||
m.LastResetSentAt = types.DateTime{} // reset
|
||||
|
||||
// invalidate previously issued tokens
|
||||
m.RefreshTokenKey()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshTokenKey generates and sets new random token key.
|
||||
func (m *BaseAccount) RefreshTokenKey() {
|
||||
m.TokenKey = security.RandomString(50)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestBaseModelHasId(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
model models.BaseModel
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
models.BaseModel{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
models.BaseModel{Id: ""},
|
||||
false,
|
||||
},
|
||||
{
|
||||
models.BaseModel{Id: "abc"},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
result := s.model.HasId()
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModelGetId(t *testing.T) {
|
||||
m0 := models.BaseModel{}
|
||||
if m0.GetId() != "" {
|
||||
t.Fatalf("Expected zero id value, got %v", m0.GetId())
|
||||
}
|
||||
|
||||
id := "abc"
|
||||
m1 := models.BaseModel{Id: id}
|
||||
if m1.GetId() != id {
|
||||
t.Fatalf("Expected id %v, got %v", id, m1.GetId())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModelRefreshId(t *testing.T) {
|
||||
m := models.BaseModel{}
|
||||
m.RefreshId()
|
||||
|
||||
if m.GetId() == "" {
|
||||
t.Fatalf("Expected nonempty id value, got %v", m.GetId())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModelCreated(t *testing.T) {
|
||||
m := models.BaseModel{}
|
||||
|
||||
if !m.GetCreated().IsZero() {
|
||||
t.Fatalf("Expected zero datetime, got %v", m.GetCreated())
|
||||
}
|
||||
|
||||
m.RefreshCreated()
|
||||
|
||||
if m.GetCreated().IsZero() {
|
||||
t.Fatalf("Expected non-zero datetime, got %v", m.GetCreated())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModelUpdated(t *testing.T) {
|
||||
m := models.BaseModel{}
|
||||
|
||||
if !m.GetUpdated().IsZero() {
|
||||
t.Fatalf("Expected zero datetime, got %v", m.GetUpdated())
|
||||
}
|
||||
|
||||
m.RefreshUpdated()
|
||||
|
||||
if m.GetUpdated().IsZero() {
|
||||
t.Fatalf("Expected non-zero datetime, got %v", m.GetUpdated())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// BaseAccount tests
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func TestBaseAccountValidatePassword(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
account models.BaseAccount
|
||||
password string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
// empty passwordHash + empty pass
|
||||
models.BaseAccount{},
|
||||
"",
|
||||
false,
|
||||
},
|
||||
{
|
||||
// empty passwordHash + nonempty pass
|
||||
models.BaseAccount{},
|
||||
"123456",
|
||||
false,
|
||||
},
|
||||
{
|
||||
// nonempty passwordHash + empty pass
|
||||
models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
|
||||
"",
|
||||
false,
|
||||
},
|
||||
{
|
||||
// nonempty passwordHash + wrong pass
|
||||
models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
|
||||
"654321",
|
||||
false,
|
||||
},
|
||||
{
|
||||
// nonempty passwordHash + correct pass
|
||||
models.BaseAccount{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
|
||||
"123456",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
result := s.account.ValidatePassword(s.password)
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAccountSetPassword(t *testing.T) {
|
||||
m := models.BaseAccount{
|
||||
// 123456
|
||||
PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv.",
|
||||
LastResetSentAt: types.NowDateTime(),
|
||||
TokenKey: "test",
|
||||
}
|
||||
|
||||
// empty pass
|
||||
err1 := m.SetPassword("")
|
||||
if err1 == nil {
|
||||
t.Fatal("Expected empty password error")
|
||||
}
|
||||
|
||||
err2 := m.SetPassword("654321")
|
||||
if err2 != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err2)
|
||||
}
|
||||
|
||||
if !m.ValidatePassword("654321") {
|
||||
t.Fatalf("Password is invalid")
|
||||
}
|
||||
|
||||
if m.TokenKey == "test" {
|
||||
t.Fatalf("Expected TokenKey to change, got %v", m.TokenKey)
|
||||
}
|
||||
|
||||
if !m.LastResetSentAt.IsZero() {
|
||||
t.Fatalf("Expected LastResetSentAt to be zero datetime, got %v", m.LastResetSentAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAccountRefreshTokenKey(t *testing.T) {
|
||||
m := models.BaseAccount{TokenKey: "test"}
|
||||
|
||||
m.RefreshTokenKey()
|
||||
|
||||
// empty pass
|
||||
if m.TokenKey == "" || m.TokenKey == "test" {
|
||||
t.Fatalf("Expected TokenKey to change, got %q", m.TokenKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import "github.com/pocketbase/pocketbase/models/schema"
|
||||
|
||||
var _ Model = (*Collection)(nil)
|
||||
|
||||
type Collection struct {
|
||||
BaseModel
|
||||
|
||||
Name string `db:"name" json:"name"`
|
||||
System bool `db:"system" json:"system"`
|
||||
Schema schema.Schema `db:"schema" json:"schema"`
|
||||
ListRule *string `db:"listRule" json:"listRule"`
|
||||
ViewRule *string `db:"viewRule" json:"viewRule"`
|
||||
CreateRule *string `db:"createRule" json:"createRule"`
|
||||
UpdateRule *string `db:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `db:"deleteRule" json:"deleteRule"`
|
||||
}
|
||||
|
||||
func (m *Collection) TableName() string {
|
||||
return "_collections"
|
||||
}
|
||||
|
||||
// BaseFilesPath returns the storage dir path used by the collection.
|
||||
func (m *Collection) BaseFilesPath() string {
|
||||
return m.Id
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestCollectionTableName(t *testing.T) {
|
||||
m := models.Collection{}
|
||||
if m.TableName() != "_collections" {
|
||||
t.Fatalf("Unexpected table name, got %q", m.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionBaseFilesPath(t *testing.T) {
|
||||
m := models.Collection{}
|
||||
|
||||
m.RefreshId()
|
||||
|
||||
expected := m.Id
|
||||
if m.BaseFilesPath() != expected {
|
||||
t.Fatalf("Expected path %s, got %s", expected, m.BaseFilesPath())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
var _ Model = (*Param)(nil)
|
||||
|
||||
const (
|
||||
ParamAppSettings = "settings"
|
||||
)
|
||||
|
||||
type Param struct {
|
||||
BaseModel
|
||||
|
||||
Key string `db:"key" json:"key"`
|
||||
Value types.JsonRaw `db:"value" json:"value"`
|
||||
}
|
||||
|
||||
func (m *Param) TableName() string {
|
||||
return "_params"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestParamTableName(t *testing.T) {
|
||||
m := models.Param{}
|
||||
if m.TableName() != "_params" {
|
||||
t.Fatalf("Unexpected table name, got %q", m.TableName())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var _ Model = (*Record)(nil)
|
||||
var _ ColumnValueMapper = (*Record)(nil)
|
||||
|
||||
type Record struct {
|
||||
BaseModel
|
||||
|
||||
collection *Collection
|
||||
data map[string]any
|
||||
expand map[string]any
|
||||
}
|
||||
|
||||
// NewRecord initializes a new empty Record model.
|
||||
func NewRecord(collection *Collection) *Record {
|
||||
return &Record{
|
||||
collection: collection,
|
||||
data: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewRecordFromNullStringMap initializes a single new Record model
|
||||
// with data loaded from the provided NullStringMap.
|
||||
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
|
||||
resultMap := map[string]any{}
|
||||
|
||||
for _, field := range collection.Schema.Fields() {
|
||||
var rawValue any
|
||||
|
||||
nullString, ok := data[field.Name]
|
||||
if !ok || !nullString.Valid {
|
||||
rawValue = nil
|
||||
} else {
|
||||
rawValue = nullString.String
|
||||
}
|
||||
|
||||
resultMap[field.Name] = rawValue
|
||||
}
|
||||
|
||||
record := NewRecord(collection)
|
||||
|
||||
// load base mode fields
|
||||
resultMap[schema.ReservedFieldNameId] = data[schema.ReservedFieldNameId].String
|
||||
resultMap[schema.ReservedFieldNameCreated] = data[schema.ReservedFieldNameCreated].String
|
||||
resultMap[schema.ReservedFieldNameUpdated] = data[schema.ReservedFieldNameUpdated].String
|
||||
|
||||
if err := record.Load(resultMap); err != nil {
|
||||
log.Println("Failed to unmarshal record:", err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// NewRecordsFromNullStringMaps initializes a new Record model for
|
||||
// each row in the provided NullStringMap slice.
|
||||
func NewRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) []*Record {
|
||||
result := []*Record{}
|
||||
|
||||
for _, row := range rows {
|
||||
result = append(result, NewRecordFromNullStringMap(collection, row))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Returns the table name associated to the current Record model.
|
||||
func (m *Record) TableName() string {
|
||||
return m.collection.Name
|
||||
}
|
||||
|
||||
// Returns the Collection model associated to the current Record model.
|
||||
func (m *Record) Collection() *Collection {
|
||||
return m.collection
|
||||
}
|
||||
|
||||
// GetExpand returns a shallow copy of the optional `expand` data
|
||||
// attached to the current Record model.
|
||||
func (m *Record) GetExpand() map[string]any {
|
||||
return shallowCopy(m.expand)
|
||||
}
|
||||
|
||||
// SetExpand assigns the provided data to `record.expand`.
|
||||
func (m *Record) SetExpand(data map[string]any) {
|
||||
m.expand = shallowCopy(data)
|
||||
}
|
||||
|
||||
// Data returns a shallow copy of the currently loaded record's data.
|
||||
func (m *Record) Data() map[string]any {
|
||||
return shallowCopy(m.data)
|
||||
}
|
||||
|
||||
// SetDataValue sets the provided key-value data pair for the current Record model.
|
||||
//
|
||||
// This method does nothing if the record doesn't have a `key` field.
|
||||
func (m *Record) SetDataValue(key string, value any) {
|
||||
if m.data == nil {
|
||||
m.data = map[string]any{}
|
||||
}
|
||||
|
||||
field := m.Collection().Schema.GetFieldByName(key)
|
||||
if field != nil {
|
||||
m.data[key] = field.PrepareValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDataValue returns the current record's data value for `key`.
|
||||
//
|
||||
// Returns nil if data value with `key` is not found or set.
|
||||
func (m *Record) GetDataValue(key string) any {
|
||||
return m.data[key]
|
||||
}
|
||||
|
||||
// GetBoolDataValue returns the data value for `key` as a bool.
|
||||
func (m *Record) GetBoolDataValue(key string) bool {
|
||||
return cast.ToBool(m.GetDataValue(key))
|
||||
}
|
||||
|
||||
// GetStringDataValue returns the data value for `key` as a string.
|
||||
func (m *Record) GetStringDataValue(key string) string {
|
||||
return cast.ToString(m.GetDataValue(key))
|
||||
}
|
||||
|
||||
// GetIntDataValue returns the data value for `key` as an int.
|
||||
func (m *Record) GetIntDataValue(key string) int {
|
||||
return cast.ToInt(m.GetDataValue(key))
|
||||
}
|
||||
|
||||
// GetFloatDataValue returns the data value for `key` as a float64.
|
||||
func (m *Record) GetFloatDataValue(key string) float64 {
|
||||
return cast.ToFloat64(m.GetDataValue(key))
|
||||
}
|
||||
|
||||
// GetTimeDataValue returns the data value for `key` as a [time.Time] instance.
|
||||
func (m *Record) GetTimeDataValue(key string) time.Time {
|
||||
return cast.ToTime(m.GetDataValue(key))
|
||||
}
|
||||
|
||||
// GetDateTimeDataValue returns the data value for `key` as a DateTime instance.
|
||||
func (m *Record) GetDateTimeDataValue(key string) types.DateTime {
|
||||
d, _ := types.ParseDateTime(m.GetDataValue(key))
|
||||
return d
|
||||
}
|
||||
|
||||
// GetStringSliceDataValue returns the data value for `key` as a slice of unique strings.
|
||||
func (m *Record) GetStringSliceDataValue(key string) []string {
|
||||
return list.ToUniqueStringSlice(m.GetDataValue(key))
|
||||
}
|
||||
|
||||
// BaseFilesPath returns the storage dir path used by the record.
|
||||
func (m *Record) BaseFilesPath() string {
|
||||
return fmt.Sprintf("%s/%s", m.Collection().BaseFilesPath(), m.Id)
|
||||
}
|
||||
|
||||
// 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) *schema.SchemaField {
|
||||
for _, field := range m.Collection().Schema.Fields() {
|
||||
if field.Type == schema.FieldTypeFile {
|
||||
names := m.GetStringSliceDataValue(field.Name)
|
||||
if list.ExistInSlice(filename, names) {
|
||||
return field
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load bulk loads the provided data into the current Record model.
|
||||
func (m *Record) Load(data map[string]any) error {
|
||||
if data[schema.ReservedFieldNameId] != nil {
|
||||
id, err := cast.ToStringE(data[schema.ReservedFieldNameId])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Id = id
|
||||
}
|
||||
|
||||
if data[schema.ReservedFieldNameCreated] != nil {
|
||||
m.Created, _ = types.ParseDateTime(data[schema.ReservedFieldNameCreated])
|
||||
}
|
||||
|
||||
if data[schema.ReservedFieldNameUpdated] != nil {
|
||||
m.Updated, _ = types.ParseDateTime(data[schema.ReservedFieldNameUpdated])
|
||||
}
|
||||
|
||||
for k, v := range data {
|
||||
m.SetDataValue(k, v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ColumnValueMap implements [ColumnValueMapper] interface.
|
||||
func (m *Record) ColumnValueMap() map[string]any {
|
||||
result := map[string]any{}
|
||||
for key := range m.data {
|
||||
result[key] = m.normalizeDataValueForDB(key)
|
||||
}
|
||||
|
||||
// set base model fields
|
||||
result[schema.ReservedFieldNameId] = m.Id
|
||||
result[schema.ReservedFieldNameCreated] = m.Created
|
||||
result[schema.ReservedFieldNameUpdated] = m.Updated
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// PublicExport exports only the record fields that are safe to be public.
|
||||
//
|
||||
// This method also skips the "hidden" fields, aka. fields prefixed with `#`.
|
||||
func (m *Record) PublicExport() map[string]any {
|
||||
result := skipHiddenFields(m.data)
|
||||
|
||||
// set base model fields
|
||||
result[schema.ReservedFieldNameId] = m.Id
|
||||
result[schema.ReservedFieldNameCreated] = m.Created
|
||||
result[schema.ReservedFieldNameUpdated] = m.Updated
|
||||
|
||||
// add helper collection fields
|
||||
result["@collectionId"] = m.collection.Id
|
||||
result["@collectionName"] = m.collection.Name
|
||||
|
||||
// add expand (if set)
|
||||
if m.expand != nil {
|
||||
result["@expand"] = m.expand
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return m.Load(result)
|
||||
}
|
||||
|
||||
// normalizeDataValueForDB returns the `key` data value formatted for db storage.
|
||||
func (m *Record) normalizeDataValueForDB(key string) any {
|
||||
val := m.GetDataValue(key)
|
||||
|
||||
switch ids := val.(type) {
|
||||
case []string:
|
||||
// encode strings slice
|
||||
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
|
||||
case []any:
|
||||
// encode interfaces slice
|
||||
return append(types.JsonArray{}, ids...)
|
||||
default:
|
||||
// no changes
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// shallowCopy shallow copy data into a new map.
|
||||
func shallowCopy(data map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
|
||||
for k, v := range data {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// skipHiddenFields returns a new data map without the "#" prefixed fields.
|
||||
func skipHiddenFields(data map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
|
||||
for key, val := range data {
|
||||
// ignore "#" prefixed fields
|
||||
if strings.HasPrefix(key, "#") {
|
||||
continue
|
||||
}
|
||||
result[key] = val
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestNewRecord(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
if m.Collection().Id != collection.Id {
|
||||
t.Fatalf("Expected collection with id %v, got %v", collection.Id, m.Collection().Id)
|
||||
}
|
||||
|
||||
if len(m.Data()) != 0 {
|
||||
t.Fatalf("Expected empty data, got %v", m.Data())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecordFromNullStringMap(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Name: "test",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Type: schema.FieldTypeBool,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field4",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field5",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
Values: []string{"test1", "test2"},
|
||||
MaxSelect: 1,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field6",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{
|
||||
MaxSelect: 2,
|
||||
MaxSize: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
data := dbx.NullStringMap{
|
||||
"id": sql.NullString{
|
||||
String: "c23eb053-d07e-4fbe-86b3-b8ac31982e9a",
|
||||
Valid: true,
|
||||
},
|
||||
"created": sql.NullString{
|
||||
String: "2022-01-01 10:00:00.123",
|
||||
Valid: true,
|
||||
},
|
||||
"updated": sql.NullString{
|
||||
String: "2022-01-01 10:00:00.456",
|
||||
Valid: true,
|
||||
},
|
||||
"field1": sql.NullString{
|
||||
String: "test",
|
||||
Valid: true,
|
||||
},
|
||||
"field2": sql.NullString{
|
||||
String: "test",
|
||||
Valid: false, // test invalid db serialization
|
||||
},
|
||||
"field3": sql.NullString{
|
||||
String: "true",
|
||||
Valid: true,
|
||||
},
|
||||
"field4": sql.NullString{
|
||||
String: "123.123",
|
||||
Valid: true,
|
||||
},
|
||||
"field5": sql.NullString{
|
||||
String: `["test1","test2"]`, // will select only the first elem
|
||||
Valid: true,
|
||||
},
|
||||
"field6": sql.NullString{
|
||||
String: "test", // will be converted to slice
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
m := models.NewRecordFromNullStringMap(collection, data)
|
||||
encoded, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test","field2":null,"field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"c23eb053-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecordsFromNullStringMaps(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Name: "test",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
data := []dbx.NullStringMap{
|
||||
{
|
||||
"id": sql.NullString{
|
||||
String: "11111111-d07e-4fbe-86b3-b8ac31982e9a",
|
||||
Valid: true,
|
||||
},
|
||||
"created": sql.NullString{
|
||||
String: "2022-01-01 10:00:00.123",
|
||||
Valid: true,
|
||||
},
|
||||
"updated": sql.NullString{
|
||||
String: "2022-01-01 10:00:00.456",
|
||||
Valid: true,
|
||||
},
|
||||
"field1": sql.NullString{
|
||||
String: "test1",
|
||||
Valid: true,
|
||||
},
|
||||
"field2": sql.NullString{
|
||||
String: "123",
|
||||
Valid: false, // test invalid db serialization
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": sql.NullString{
|
||||
String: "22222222-d07e-4fbe-86b3-b8ac31982e9a",
|
||||
Valid: true,
|
||||
},
|
||||
"field1": sql.NullString{
|
||||
String: "test2",
|
||||
Valid: true,
|
||||
},
|
||||
"field2": sql.NullString{
|
||||
String: "123",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := models.NewRecordsFromNullStringMaps(collection, data)
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `[{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test1","field2":null,"id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"},{"@collectionId":"","@collectionName":"test","created":"","field1":"test2","field2":123,"id":"22222222-d07e-4fbe-86b3-b8ac31982e9a","updated":""}]`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordCollection(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
collection.RefreshId()
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
if m.Collection().Id != collection.Id {
|
||||
t.Fatalf("Expected collection with id %v, got %v", collection.Id, m.Collection().Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordTableName(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
collection.Name = "test"
|
||||
collection.RefreshId()
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
if m.TableName() != collection.Name {
|
||||
t.Fatalf("Expected table %q, got %q", collection.Name, m.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordExpand(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
data := map[string]any{"test": 123}
|
||||
|
||||
m.SetExpand(data)
|
||||
|
||||
// change the original data to check if it was shallow copied
|
||||
data["test"] = 456
|
||||
|
||||
expand := m.GetExpand()
|
||||
if v, ok := expand["test"]; !ok || v != 123 {
|
||||
t.Fatalf("Expected expand.test to be %v, got %v", 123, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordLoadAndData(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
data := map[string]any{
|
||||
"id": "11111111-d07e-4fbe-86b3-b8ac31982e9a",
|
||||
"created": "2022-01-01 10:00:00.123",
|
||||
"updated": "2022-01-01 10:00:00.456",
|
||||
"field": "test",
|
||||
"unknown": "test",
|
||||
}
|
||||
|
||||
m.Load(data)
|
||||
|
||||
// change some of original data fields to check if they were shallow copied
|
||||
data["id"] = "22222222-d07e-4fbe-86b3-b8ac31982e9a"
|
||||
data["field"] = "new_test"
|
||||
|
||||
expectedData := `{"field":"test"}`
|
||||
encodedData, _ := json.Marshal(m.Data())
|
||||
if string(encodedData) != expectedData {
|
||||
t.Fatalf("Expected data %v, got \n%v", expectedData, string(encodedData))
|
||||
}
|
||||
|
||||
expectedModel := `{"@collectionId":"","@collectionName":"","created":"2022-01-01 10:00:00.123","field":"test","id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
|
||||
encodedModel, _ := json.Marshal(m)
|
||||
if string(encodedModel) != expectedModel {
|
||||
t.Fatalf("Expected model %v, got \n%v", expectedModel, string(encodedModel))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSetDataValue(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
m.SetDataValue("unknown", 123)
|
||||
m.SetDataValue("field", 123) // test whether PrepareValue will be called and casted to string
|
||||
|
||||
data := m.Data()
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("Expected only 1 data field to be set, got %v", data)
|
||||
}
|
||||
|
||||
if v, ok := data["field"]; !ok || v != "123" {
|
||||
t.Fatalf("Expected field to be %v, got %v", "123", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetDataValue(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
),
|
||||
}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
m.SetDataValue("field2", 123)
|
||||
|
||||
// missing
|
||||
v0 := m.GetDataValue("missing")
|
||||
if v0 != nil {
|
||||
t.Fatalf("Unexpected value for key 'missing'")
|
||||
}
|
||||
|
||||
// existing - not set
|
||||
v1 := m.GetDataValue("field1")
|
||||
if v1 != nil {
|
||||
t.Fatalf("Unexpected value for key 'field1'")
|
||||
}
|
||||
|
||||
// existing - set
|
||||
v2 := m.GetDataValue("field2")
|
||||
if v2 != 123.0 {
|
||||
t.Fatalf("Expected 123.0, got %v", v2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetBoolDataValue(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected bool
|
||||
}{
|
||||
{nil, false},
|
||||
{"", false},
|
||||
{0, false},
|
||||
{1, true},
|
||||
{[]string{"true"}, false},
|
||||
{time.Now(), false},
|
||||
{"test", false},
|
||||
{"false", false},
|
||||
{"true", true},
|
||||
{false, false},
|
||||
{true, true},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetBoolDataValue("test")
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetStringDataValue(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected string
|
||||
}{
|
||||
{nil, ""},
|
||||
{"", ""},
|
||||
{0, "0"},
|
||||
{1.4, "1.4"},
|
||||
{[]string{"true"}, ""},
|
||||
{map[string]int{"test": 1}, ""},
|
||||
{[]byte("abc"), "abc"},
|
||||
{"test", "test"},
|
||||
{false, "false"},
|
||||
{true, "true"},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetStringDataValue("test")
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetIntDataValue(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected int
|
||||
}{
|
||||
{nil, 0},
|
||||
{"", 0},
|
||||
{[]string{"true"}, 0},
|
||||
{map[string]int{"test": 1}, 0},
|
||||
{time.Now(), 0},
|
||||
{"test", 0},
|
||||
{123, 123},
|
||||
{2.4, 2},
|
||||
{"123", 123},
|
||||
{"123.5", 0},
|
||||
{false, 0},
|
||||
{true, 1},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetIntDataValue("test")
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetFloatDataValue(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected float64
|
||||
}{
|
||||
{nil, 0},
|
||||
{"", 0},
|
||||
{[]string{"true"}, 0},
|
||||
{map[string]int{"test": 1}, 0},
|
||||
{time.Now(), 0},
|
||||
{"test", 0},
|
||||
{123, 123},
|
||||
{2.4, 2.4},
|
||||
{"123", 123},
|
||||
{"123.5", 123.5},
|
||||
{false, 0},
|
||||
{true, 1},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetFloatDataValue("test")
|
||||
if result != s.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetTimeDataValue(t *testing.T) {
|
||||
nowTime := time.Now()
|
||||
testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000")
|
||||
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected time.Time
|
||||
}{
|
||||
{nil, time.Time{}},
|
||||
{"", time.Time{}},
|
||||
{false, time.Time{}},
|
||||
{true, time.Time{}},
|
||||
{"test", time.Time{}},
|
||||
{[]string{"true"}, time.Time{}},
|
||||
{map[string]int{"test": 1}, time.Time{}},
|
||||
{1641024040, testTime},
|
||||
{"2022-01-01 08:00:40.000", testTime},
|
||||
{nowTime, nowTime},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetTimeDataValue("test")
|
||||
if !result.Equal(s.expected) {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetDateTimeDataValue(t *testing.T) {
|
||||
nowTime := time.Now()
|
||||
testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000")
|
||||
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected time.Time
|
||||
}{
|
||||
{nil, time.Time{}},
|
||||
{"", time.Time{}},
|
||||
{false, time.Time{}},
|
||||
{true, time.Time{}},
|
||||
{"test", time.Time{}},
|
||||
{[]string{"true"}, time.Time{}},
|
||||
{map[string]int{"test": 1}, time.Time{}},
|
||||
{1641024040, testTime},
|
||||
{"2022-01-01 08:00:40.000", testTime},
|
||||
{nowTime, nowTime},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetDateTimeDataValue("test")
|
||||
if !result.Time().Equal(s.expected) {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetStringSliceDataValue(t *testing.T) {
|
||||
nowTime := time.Now()
|
||||
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected []string
|
||||
}{
|
||||
{nil, []string{}},
|
||||
{"", []string{}},
|
||||
{false, []string{"false"}},
|
||||
{true, []string{"true"}},
|
||||
{nowTime, []string{}},
|
||||
{123, []string{"123"}},
|
||||
{"test", []string{"test"}},
|
||||
{map[string]int{"test": 1}, []string{}},
|
||||
{`["test1", "test2"]`, []string{"test1", "test2"}},
|
||||
{[]int{123, 123, 456}, []string{"123", "456"}},
|
||||
{[]string{"test", "test", "123"}, []string{"test", "123"}},
|
||||
}
|
||||
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test"},
|
||||
),
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("test", s.value)
|
||||
|
||||
result := m.GetStringSliceDataValue("test")
|
||||
|
||||
if len(result) != len(s.expected) {
|
||||
t.Errorf("(%d) Expected %d elements, got %d: %v", i, len(s.expected), len(result), result)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, v := range result {
|
||||
if !list.ExistInSlice(v, s.expected) {
|
||||
t.Errorf("(%d) Cannot find %v in %v", i, v, s.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordBaseFilesPath(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
collection.RefreshId()
|
||||
collection.Name = "test"
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
m.RefreshId()
|
||||
|
||||
expected := collection.BaseFilesPath() + "/" + m.Id
|
||||
result := m.BaseFilesPath()
|
||||
|
||||
if result != expected {
|
||||
t.Fatalf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFindFileFieldByFile(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{
|
||||
MaxSelect: 1,
|
||||
MaxSize: 1,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{
|
||||
MaxSelect: 2,
|
||||
MaxSize: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
m.SetDataValue("field1", "test")
|
||||
m.SetDataValue("field2", "test.png")
|
||||
m.SetDataValue("field3", []string{"test1.png", "test2.png"})
|
||||
|
||||
scenarios := []struct {
|
||||
filename string
|
||||
expectField string
|
||||
}{
|
||||
{"", ""},
|
||||
{"test", ""},
|
||||
{"test2", ""},
|
||||
{"test.png", "field2"},
|
||||
{"test2.png", "field3"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
result := m.FindFileFieldByFile(s.filename)
|
||||
|
||||
var fieldName string
|
||||
if result != nil {
|
||||
fieldName = result.Name
|
||||
}
|
||||
|
||||
if s.expectField != fieldName {
|
||||
t.Errorf("(%d) Expected field %v, got %v", i, s.expectField, result)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordColumnValueMap(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{
|
||||
MaxSelect: 1,
|
||||
MaxSize: 1,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "#field3",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
MaxSelect: 2,
|
||||
Values: []string{"test1", "test2", "test3"},
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field4",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: 2,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
id1 := "11111111-1e32-4c94-ae06-90c25fcf6791"
|
||||
id2 := "22222222-1e32-4c94-ae06-90c25fcf6791"
|
||||
created, _ := types.ParseDateTime("2022-01-01 10:00:30.123")
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
m.Id = id1
|
||||
m.Created = created
|
||||
m.SetDataValue("field1", "test")
|
||||
m.SetDataValue("field2", "test.png")
|
||||
m.SetDataValue("#field3", []string{"test1", "test2"})
|
||||
m.SetDataValue("field4", []string{id1, id2, id1})
|
||||
|
||||
result := m.ColumnValueMap()
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"#field3":["test1","test2"],"created":"2022-01-01 10:00:30.123","field1":"test","field2":"test.png","field4":["11111111-1e32-4c94-ae06-90c25fcf6791","22222222-1e32-4c94-ae06-90c25fcf6791"],"id":"11111111-1e32-4c94-ae06-90c25fcf6791","updated":""}`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPublicExport(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Name: "test",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{
|
||||
MaxSelect: 1,
|
||||
MaxSize: 1,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "#field3",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
MaxSelect: 2,
|
||||
Values: []string{"test1", "test2", "test3"},
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
created, _ := types.ParseDateTime("2022-01-01 10:00:30.123")
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
|
||||
m.Created = created
|
||||
m.SetDataValue("field1", "test")
|
||||
m.SetDataValue("field2", "test.png")
|
||||
m.SetDataValue("#field3", []string{"test1", "test2"})
|
||||
m.SetExpand(map[string]any{"test": 123})
|
||||
|
||||
result := m.PublicExport()
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"@collectionId":"","@collectionName":"test","@expand":{"test":123},"created":"2022-01-01 10:00:30.123","field1":"test","field2":"test.png","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","updated":""}`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMarshalJSON(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Name: "test",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{
|
||||
MaxSelect: 1,
|
||||
MaxSize: 1,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "#field3",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
MaxSelect: 2,
|
||||
Values: []string{"test1", "test2", "test3"},
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
created, _ := types.ParseDateTime("2022-01-01 10:00:30.123")
|
||||
|
||||
m := models.NewRecord(collection)
|
||||
m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
|
||||
m.Created = created
|
||||
m.SetDataValue("field1", "test")
|
||||
m.SetDataValue("field2", "test.png")
|
||||
m.SetDataValue("#field3", []string{"test1", "test2"})
|
||||
m.SetExpand(map[string]any{"test": 123})
|
||||
|
||||
encoded, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"@collectionId":"","@collectionName":"test","@expand":{"test":123},"created":"2022-01-01 10:00:30.123","field1":"test","field2":"test.png","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","updated":""}`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUnmarshalJSON(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "field",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
m.UnmarshalJSON([]byte(`{
|
||||
"id": "11111111-d07e-4fbe-86b3-b8ac31982e9a",
|
||||
"created": "2022-01-01 10:00:00.123",
|
||||
"updated": "2022-01-01 10:00:00.456",
|
||||
"field": "test",
|
||||
"unknown": "test"
|
||||
}`))
|
||||
|
||||
expected := `{"@collectionId":"","@collectionName":"","created":"2022-01-01 10:00:00.123","field":"test","id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
|
||||
encoded, _ := json.Marshal(m)
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected model %v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
var _ Model = (*Request)(nil)
|
||||
|
||||
// list with the supported values for `Request.Auth`
|
||||
const (
|
||||
RequestAuthGuest = "guest"
|
||||
RequestAuthUser = "user"
|
||||
RequestAuthAdmin = "admin"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
BaseModel
|
||||
|
||||
Url string `db:"url" json:"url"`
|
||||
Method string `db:"method" json:"method"`
|
||||
Status int `db:"status" json:"status"`
|
||||
Auth string `db:"auth" json:"auth"`
|
||||
Ip string `db:"ip" json:"ip"`
|
||||
Referer string `db:"referer" json:"referer"`
|
||||
UserAgent string `db:"userAgent" json:"userAgent"`
|
||||
Meta types.JsonMap `db:"meta" json:"meta"`
|
||||
}
|
||||
|
||||
func (m *Request) TableName() string {
|
||||
return "_requests"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestRequestTableName(t *testing.T) {
|
||||
m := models.Request{}
|
||||
if m.TableName() != "_requests" {
|
||||
t.Fatalf("Unexpected table name, got %q", m.TableName())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package schema implements custom Schema and SchemaField datatypes
|
||||
// for handling the Collection schema definitions.
|
||||
package schema
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// NewSchema creates a new Schema instance with the provided fields.
|
||||
func NewSchema(fields ...*SchemaField) Schema {
|
||||
s := Schema{}
|
||||
|
||||
for _, f := range fields {
|
||||
s.AddField(f)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Schema defines a dynamic db schema as a slice of `SchemaField`s.
|
||||
type Schema struct {
|
||||
fields []*SchemaField
|
||||
}
|
||||
|
||||
// Fields returns the registered schema fields.
|
||||
func (s *Schema) Fields() []*SchemaField {
|
||||
return s.fields
|
||||
}
|
||||
|
||||
// InitFieldsOptions calls `InitOptions()` for all schema fields.
|
||||
func (s *Schema) InitFieldsOptions() error {
|
||||
for _, field := range s.Fields() {
|
||||
if err := field.InitOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone creates a deep clone of the current schema.
|
||||
func (s *Schema) Clone() (*Schema, error) {
|
||||
copyRaw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &Schema{}
|
||||
if err := json.Unmarshal(copyRaw, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AsMap returns a map with all registered schema field.
|
||||
// The returned map is indexed with each field name.
|
||||
func (s *Schema) AsMap() map[string]*SchemaField {
|
||||
result := map[string]*SchemaField{}
|
||||
|
||||
for _, field := range s.fields {
|
||||
result[field.Name] = field
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetFieldById returns a single field by its id.
|
||||
func (s *Schema) GetFieldById(id string) *SchemaField {
|
||||
for _, field := range s.fields {
|
||||
if field.Id == id {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFieldByName returns a single field by its name.
|
||||
func (s *Schema) GetFieldByName(name string) *SchemaField {
|
||||
for _, field := range s.fields {
|
||||
if field.Name == name {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveField removes a single schema field by its id.
|
||||
//
|
||||
// This method does nothing if field with `id` doesn't exist.
|
||||
func (s *Schema) RemoveField(id string) {
|
||||
for i, field := range s.fields {
|
||||
if field.Id == id {
|
||||
s.fields = append(s.fields[:i], s.fields[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddField registers the provided newField to the current schema.
|
||||
//
|
||||
// If field with `newField.Id` already exist, the existing field is
|
||||
// replaced with the new one.
|
||||
//
|
||||
// Otherwise the new field is appended to the other schema fields.
|
||||
func (s *Schema) AddField(newField *SchemaField) {
|
||||
if newField.Id == "" {
|
||||
// set default id
|
||||
newField.Id = strings.ToLower(security.RandomString(8))
|
||||
}
|
||||
|
||||
for i, field := range s.fields {
|
||||
// replace existing
|
||||
if field.Id == newField.Id {
|
||||
s.fields[i] = newField
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// add new field
|
||||
s.fields = append(s.fields, newField)
|
||||
}
|
||||
|
||||
// Validate makes Schema validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// Internally calls each individual field's validator and additionally
|
||||
// checks for invalid renamed fields and field name duplications.
|
||||
func (s Schema) Validate() error {
|
||||
return validation.Validate(&s.fields, validation.Required, validation.By(func(value any) error {
|
||||
fields := s.fields // use directly the schema value to avoid unnecesary interface casting
|
||||
|
||||
if len(fields) == 0 {
|
||||
return validation.NewError("validation_invalid_schema", "Invalid schema format.")
|
||||
}
|
||||
|
||||
ids := []string{}
|
||||
names := []string{}
|
||||
for i, field := range fields {
|
||||
if list.ExistInSlice(field.Id, ids) {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.Errors{
|
||||
"id": validation.NewError(
|
||||
"validation_duplicated_field_id",
|
||||
"Duplicated or invalid schema field id",
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// field names are used as db columns and should be case insensitive
|
||||
nameLower := strings.ToLower(field.Name)
|
||||
|
||||
if list.ExistInSlice(nameLower, names) {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.Errors{
|
||||
"name": validation.NewError(
|
||||
"validation_duplicated_field_name",
|
||||
"Duplicated or invalid schema field name",
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
ids = append(ids, field.Id)
|
||||
names = append(names, nameLower)
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
func (s Schema) MarshalJSON() ([]byte, error) {
|
||||
if s.fields == nil {
|
||||
s.fields = []*SchemaField{}
|
||||
}
|
||||
return json.Marshal(s.fields)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the [json.Unmarshaler] interface.
|
||||
//
|
||||
// On success, all schema field options are auto initialized.
|
||||
func (s *Schema) UnmarshalJSON(data []byte) error {
|
||||
fields := []*SchemaField{}
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.fields = []*SchemaField{}
|
||||
|
||||
for _, f := range fields {
|
||||
s.AddField(f)
|
||||
}
|
||||
|
||||
return s.InitFieldsOptions()
|
||||
}
|
||||
|
||||
// Value implements the [driver.Valuer] interface.
|
||||
func (s Schema) Value() (driver.Value, error) {
|
||||
if len(s.fields) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(s.fields)
|
||||
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// Scan implements [sql.Scanner] interface to scan the provided value
|
||||
// into the current Schema instance.
|
||||
func (s *Schema) Scan(value any) error {
|
||||
var data []byte
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
// no cast needed
|
||||
case []byte:
|
||||
data = v
|
||||
case string:
|
||||
data = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("Failed to unmarshal Schema value %q.", value)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
data = []byte("[]")
|
||||
}
|
||||
|
||||
return s.UnmarshalJSON(data)
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
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: // string
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cast.ToString(value)
|
||||
case FieldTypeJson: // string
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
val, _ := types.ParseJsonRaw(value)
|
||||
return val
|
||||
case FieldTypeNumber: // nil, int or float
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return cast.ToFloat64(value)
|
||||
case FieldTypeBool: // bool
|
||||
return cast.ToBool(value)
|
||||
case FieldTypeDate: // string, DateTime or time.Time
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
val, _ := types.ParseDateTime(value)
|
||||
return val
|
||||
case FieldTypeSelect: // nil, string or slice of strings
|
||||
val := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*SelectOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return val
|
||||
case FieldTypeFile: // nil, string or slice of strings
|
||||
val := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*FileOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return val
|
||||
case FieldTypeRelation: // nil, string or slice of strings
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*RelationOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return ids
|
||||
case FieldTypeUser: // nil, string or slice of strings
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*UserOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
func TestNewSchemaAndFields(t *testing.T) {
|
||||
testSchema := schema.NewSchema(
|
||||
&schema.SchemaField{Id: "id1", Name: "test1"},
|
||||
&schema.SchemaField{Name: "test2"},
|
||||
&schema.SchemaField{Id: "id1", Name: "test1_new"}, // should replace the original id1 field
|
||||
)
|
||||
|
||||
fields := testSchema.Fields()
|
||||
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("Expected 2 fields, got %d (%v)", len(fields), fields)
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if f.Id == "" {
|
||||
t.Fatalf("Expected field id to be set, found empty id for field %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
if fields[0].Name != "test1_new" {
|
||||
t.Fatalf("Expected field with name test1_new, got %s", fields[0].Name)
|
||||
}
|
||||
|
||||
if fields[1].Name != "test2" {
|
||||
t.Fatalf("Expected field with name test2, got %s", fields[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaInitFieldsOptions(t *testing.T) {
|
||||
f0 := &schema.SchemaField{Name: "test1", Type: "unknown"}
|
||||
schema0 := schema.NewSchema(f0)
|
||||
|
||||
err0 := schema0.InitFieldsOptions()
|
||||
if err0 == nil {
|
||||
t.Fatalf("Expected unknown field schema to fail, got nil")
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeEmail}
|
||||
schema1 := schema.NewSchema(f1, f2)
|
||||
|
||||
err1 := schema1.InitFieldsOptions()
|
||||
if err1 != nil {
|
||||
t.Fatal(err1)
|
||||
}
|
||||
|
||||
if _, ok := f1.Options.(*schema.TextOptions); !ok {
|
||||
t.Fatalf("Failed to init f1 options")
|
||||
}
|
||||
|
||||
if _, ok := f2.Options.(*schema.EmailOptions); !ok {
|
||||
t.Fatalf("Failed to init f2 options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaClone(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeEmail}
|
||||
s1 := schema.NewSchema(f1, f2)
|
||||
|
||||
s2, err := s1.Clone()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s1Encoded, _ := s1.MarshalJSON()
|
||||
s2Encoded, _ := s2.MarshalJSON()
|
||||
|
||||
if string(s1Encoded) != string(s2Encoded) {
|
||||
t.Fatalf("Expected the cloned schema to be equal, got %v VS\n %v", s1, s2)
|
||||
}
|
||||
|
||||
// change in one schema shouldn't result to change in the other
|
||||
// (aka. check if it is a deep clone)
|
||||
s1.Fields()[0].Name = "test1_update"
|
||||
if s2.Fields()[0].Name != "test1" {
|
||||
t.Fatalf("Expected s2 field name to not change, got %q", s2.Fields()[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaAsMap(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeEmail}
|
||||
testSchema := schema.NewSchema(f1, f2)
|
||||
|
||||
result := testSchema.AsMap()
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("Expected 2 map elements, got %d (%v)", len(result), result)
|
||||
}
|
||||
|
||||
expectedIndexes := []string{f1.Name, f2.Name}
|
||||
|
||||
for _, index := range expectedIndexes {
|
||||
if _, ok := result[index]; !ok {
|
||||
t.Fatalf("Missing index %q", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaGetFieldByName(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeText}
|
||||
testSchema := schema.NewSchema(f1, f2)
|
||||
|
||||
// missing field
|
||||
result1 := testSchema.GetFieldByName("missing")
|
||||
if result1 != nil {
|
||||
t.Fatalf("Found unexpected field %v", result1)
|
||||
}
|
||||
|
||||
// existing field
|
||||
result2 := testSchema.GetFieldByName("test1")
|
||||
if result2 == nil || result2.Name != "test1" {
|
||||
t.Fatalf("Cannot find field with Name 'test1', got %v ", result2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaGetFieldById(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Id: "id1", Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Id: "id2", Name: "test2", Type: schema.FieldTypeText}
|
||||
testSchema := schema.NewSchema(f1, f2)
|
||||
|
||||
// missing field id
|
||||
result1 := testSchema.GetFieldById("test1")
|
||||
if result1 != nil {
|
||||
t.Fatalf("Found unexpected field %v", result1)
|
||||
}
|
||||
|
||||
// existing field id
|
||||
result2 := testSchema.GetFieldById("id2")
|
||||
if result2 == nil || result2.Id != "id2" {
|
||||
t.Fatalf("Cannot find field with id 'id2', got %v ", result2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaRemoveField(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Id: "id1", Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Id: "id2", Name: "test2", Type: schema.FieldTypeText}
|
||||
f3 := &schema.SchemaField{Id: "id3", Name: "test3", Type: schema.FieldTypeText}
|
||||
testSchema := schema.NewSchema(f1, f2, f3)
|
||||
|
||||
testSchema.RemoveField("id2")
|
||||
testSchema.RemoveField("test3") // should do nothing
|
||||
|
||||
expected := []string{"test1", "test3"}
|
||||
|
||||
if len(testSchema.Fields()) != len(expected) {
|
||||
t.Fatalf("Expected %d, got %d (%v)", len(expected), len(testSchema.Fields()), testSchema)
|
||||
}
|
||||
|
||||
for _, name := range expected {
|
||||
if f := testSchema.GetFieldByName(name); f == nil {
|
||||
t.Fatalf("Missing field %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaAddField(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{Id: "f2Id", Name: "test2", Type: schema.FieldTypeText}
|
||||
f3 := &schema.SchemaField{Id: "f3Id", Name: "test3", Type: schema.FieldTypeText}
|
||||
testSchema := schema.NewSchema(f1, f2, f3)
|
||||
|
||||
f2New := &schema.SchemaField{Id: "f2Id", Name: "test2_new", Type: schema.FieldTypeEmail}
|
||||
f4 := &schema.SchemaField{Name: "test4", Type: schema.FieldTypeUrl}
|
||||
|
||||
testSchema.AddField(f2New)
|
||||
testSchema.AddField(f4)
|
||||
|
||||
if len(testSchema.Fields()) != 4 {
|
||||
t.Fatalf("Expected %d, got %d (%v)", 4, len(testSchema.Fields()), testSchema)
|
||||
}
|
||||
|
||||
// check if each field has id
|
||||
for _, f := range testSchema.Fields() {
|
||||
if f.Id == "" {
|
||||
t.Fatalf("Expected field id to be set, found empty id for field %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// check if f2 field was replaced
|
||||
if f := testSchema.GetFieldById("f2Id"); f == nil || f.Type != schema.FieldTypeEmail {
|
||||
t.Fatalf("Expected f2 field to be replaced, found %v", f)
|
||||
}
|
||||
|
||||
// check if f4 was added
|
||||
if f := testSchema.GetFieldByName("test4"); f == nil || f.Name != "test4" {
|
||||
t.Fatalf("Expected f4 field to be added, found %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaValidate(t *testing.T) {
|
||||
// emulate duplicated field ids
|
||||
duplicatedIdsSchema := schema.NewSchema(
|
||||
&schema.SchemaField{Id: "id1", Name: "test1", Type: schema.FieldTypeText},
|
||||
&schema.SchemaField{Id: "id2", Name: "test2", Type: schema.FieldTypeText},
|
||||
)
|
||||
duplicatedIdsSchema.Fields()[1].Id = "id1" // manually set existing id
|
||||
|
||||
scenarios := []struct {
|
||||
schema schema.Schema
|
||||
expectError bool
|
||||
}{
|
||||
// no fields
|
||||
{
|
||||
schema.NewSchema(),
|
||||
true,
|
||||
},
|
||||
// duplicated field ids
|
||||
{
|
||||
duplicatedIdsSchema,
|
||||
true,
|
||||
},
|
||||
// duplicated field names (case insensitive)
|
||||
{
|
||||
schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test", Type: schema.FieldTypeText},
|
||||
&schema.SchemaField{Name: "TeSt", Type: schema.FieldTypeText},
|
||||
),
|
||||
true,
|
||||
},
|
||||
// failure - base individual fields validation
|
||||
{
|
||||
schema.NewSchema(
|
||||
&schema.SchemaField{Name: "", Type: schema.FieldTypeText},
|
||||
),
|
||||
true,
|
||||
},
|
||||
// success - base individual fields validation
|
||||
{
|
||||
schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test", Type: schema.FieldTypeText},
|
||||
),
|
||||
false,
|
||||
},
|
||||
// failure - individual field options validation
|
||||
{
|
||||
schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test", Type: schema.FieldTypeFile},
|
||||
),
|
||||
true,
|
||||
},
|
||||
// success - individual field options validation
|
||||
{
|
||||
schema.NewSchema(
|
||||
&schema.SchemaField{Name: "test", Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1, MaxSize: 1}},
|
||||
),
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
err := s.schema.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaMarshalJSON(t *testing.T) {
|
||||
f1 := &schema.SchemaField{Id: "f1id", Name: "test1", Type: schema.FieldTypeText}
|
||||
f2 := &schema.SchemaField{
|
||||
Id: "f2id",
|
||||
Name: "test2",
|
||||
Type: schema.FieldTypeText,
|
||||
Options: &schema.TextOptions{Pattern: "test"},
|
||||
}
|
||||
testSchema := schema.NewSchema(f1, f2)
|
||||
|
||||
result, err := testSchema.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"id":"f2id","name":"test2","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`
|
||||
|
||||
if string(result) != expected {
|
||||
t.Fatalf("Expected %s, got %s", expected, string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaUnmarshalJSON(t *testing.T) {
|
||||
encoded := `[{"system":false,"id":"fid1", "name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"name":"test2","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`
|
||||
testSchema := schema.Schema{}
|
||||
testSchema.AddField(&schema.SchemaField{Name: "tempField", Type: schema.FieldTypeUrl})
|
||||
err := testSchema.UnmarshalJSON([]byte(encoded))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fields := testSchema.Fields()
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("Expected 2 fields, found %v", fields)
|
||||
}
|
||||
|
||||
f1 := testSchema.GetFieldByName("test1")
|
||||
if f1 == nil {
|
||||
t.Fatal("Expected to find field 'test1', got nil")
|
||||
}
|
||||
if f1.Id != "fid1" {
|
||||
t.Fatalf("Expected fid1 id, got %s", f1.Id)
|
||||
}
|
||||
_, ok := f1.Options.(*schema.TextOptions)
|
||||
if !ok {
|
||||
t.Fatal("'test1' field options are not inited.")
|
||||
}
|
||||
|
||||
f2 := testSchema.GetFieldByName("test2")
|
||||
if f2 == nil {
|
||||
t.Fatal("Expected to find field 'test2', got nil")
|
||||
}
|
||||
if f2.Id == "" {
|
||||
t.Fatal("Expected f2 id to be set, got empty string")
|
||||
}
|
||||
o2, ok := f2.Options.(*schema.TextOptions)
|
||||
if !ok {
|
||||
t.Fatal("'test2' field options are not inited.")
|
||||
}
|
||||
if o2.Pattern != "test" {
|
||||
t.Fatalf("Expected pattern to be %q, got %q", "test", o2.Pattern)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaValue(t *testing.T) {
|
||||
// empty schema
|
||||
s1 := schema.Schema{}
|
||||
v1, err := s1.Value()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v1 != nil {
|
||||
t.Fatalf("Expected nil, got %v", v1)
|
||||
}
|
||||
|
||||
// schema with fields
|
||||
f1 := &schema.SchemaField{Id: "f1id", Name: "test1", Type: schema.FieldTypeText}
|
||||
s2 := schema.NewSchema(f1)
|
||||
|
||||
v2, err := s2.Value()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`
|
||||
|
||||
if v2 != expected {
|
||||
t.Fatalf("Expected %v, got %v", expected, v2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaScan(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
data any
|
||||
expectError bool
|
||||
expectJson string
|
||||
}{
|
||||
{nil, false, "[]"},
|
||||
{"", false, "[]"},
|
||||
{[]byte{}, false, "[]"},
|
||||
{"[]", false, "[]"},
|
||||
{"invalid", true, "[]"},
|
||||
{123, true, "[]"},
|
||||
// no field type
|
||||
{`[{}]`, true, `[]`},
|
||||
// unknown field type
|
||||
{
|
||||
`[{"system":false,"id":"123","name":"test1","type":"unknown","required":false,"unique":false}]`,
|
||||
true,
|
||||
`[]`,
|
||||
},
|
||||
// without options
|
||||
{
|
||||
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false}]`,
|
||||
false,
|
||||
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
},
|
||||
// with options
|
||||
{
|
||||
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
|
||||
false,
|
||||
`[{"system":false,"id":"123","name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
testSchema := schema.Schema{}
|
||||
|
||||
err := testSchema.Scan(s.data)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
json, _ := testSchema.MarshalJSON()
|
||||
if string(json) != s.expectJson {
|
||||
t.Errorf("(%d) Expected json %v, got %v", i, s.expectJson, string(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
var _ Model = (*User)(nil)
|
||||
|
||||
const (
|
||||
// The name of the system user profiles collection.
|
||||
ProfileCollectionName = "profiles"
|
||||
|
||||
// The name of the user field from the system user profiles collection.
|
||||
ProfileCollectionUserFieldName = "userId"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
BaseAccount
|
||||
|
||||
Verified bool `db:"verified" json:"verified"`
|
||||
LastVerificationSentAt types.DateTime `db:"lastVerificationSentAt" json:"lastVerificationSentAt"`
|
||||
|
||||
// profile rel
|
||||
Profile *Record `db:"-" json:"profile"`
|
||||
}
|
||||
|
||||
func (m *User) TableName() string {
|
||||
return "_users"
|
||||
}
|
||||
|
||||
// AsMap returns the current user data as a plain map
|
||||
// (including the profile relation, if loaded).
|
||||
func (m *User) AsMap() (map[string]any, error) {
|
||||
userBytes, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[string]any{}
|
||||
if err := json.Unmarshal(userBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUserTableName(t *testing.T) {
|
||||
m := models.User{}
|
||||
if m.TableName() != "_users" {
|
||||
t.Fatalf("Unexpected table name, got %q", m.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAsMap(t *testing.T) {
|
||||
date, _ := types.ParseDateTime("2022-01-01 01:12:23.456")
|
||||
|
||||
m := models.User{}
|
||||
m.Id = "210a896c-1e32-4c94-ae06-90c25fcf6791"
|
||||
m.Email = "test@example.com"
|
||||
m.PasswordHash = "test"
|
||||
m.LastResetSentAt = date
|
||||
m.Updated = date
|
||||
m.RefreshTokenKey()
|
||||
|
||||
result, err := m.AsMap()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"created":"","email":"test@example.com","id":"210a896c-1e32-4c94-ae06-90c25fcf6791","lastResetSentAt":"2022-01-01 01:12:23.456","lastVerificationSentAt":"","profile":null,"updated":"2022-01-01 01:12:23.456","verified":false}`
|
||||
if string(encoded) != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user