diff --git a/CHANGELOG.md b/CHANGELOG.md index 74597a45..07754529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - (@todo docs) Added support for advanced unique constraints and indexes management ([#345](https://github.com/pocketbase/pocketbase/issues/345), [#544](https://github.com/pocketbase/pocketbase/issues/544)) +- Deprecated `SchemaField.Unique`. Unique constraints are now managed via indexes. + The `Unique` field is a no-op and will be removed in future version. + - Optimized single relation lookups. - Normalized record values on `maxSelect` field option change (`select`, `file`, `relation`). @@ -17,6 +20,9 @@ - Added option to explicitly set the record id from the Admin UI ([#2118](https://github.com/pocketbase/pocketbase/issues/2118)). +- **!** Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`. + If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`. + - **!** Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)). - **!** Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)). diff --git a/daos/record.go b/daos/record.go index 733d453f..4957ce28 100644 --- a/daos/record.go +++ b/daos/record.go @@ -247,9 +247,9 @@ func (dao *Dao) IsRecordValueUnique( var normalizedVal any switch val := value.(type) { case []string: - normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...) + normalizedVal = append(types.JsonArray[string]{}, val...) case []any: - normalizedVal = append(types.JsonArray{}, val...) + normalizedVal = append(types.JsonArray[any]{}, val...) default: normalizedVal = val } diff --git a/daos/record_expand.go b/daos/record_expand.go index ec8881be..f20d8328 100644 --- a/daos/record_expand.go +++ b/daos/record_expand.go @@ -273,7 +273,7 @@ func normalizeExpands(paths []string) []string { func isRelFieldUnique(collection *models.Collection, fieldName string) bool { for _, idx := range collection.Indexes { - parsed := dbutils.ParseIndex(idx.(string)) + parsed := dbutils.ParseIndex(idx) if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) { return true } diff --git a/daos/record_table_sync.go b/daos/record_table_sync.go index 06c0294a..e42698d0 100644 --- a/daos/record_table_sync.go +++ b/daos/record_table_sync.go @@ -12,7 +12,6 @@ import ( "github.com/pocketbase/pocketbase/tools/dbutils" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/security" - "github.com/spf13/cast" ) // SyncRecordTableSchema compares the two provided collections @@ -309,7 +308,7 @@ func (dao *Dao) dropCollectionIndex(collection *models.Collection) error { return dao.RunInTransaction(func(txDao *Dao) error { for _, raw := range collection.Indexes { - parsed := dbutils.ParseIndex(cast.ToString(raw)) + parsed := dbutils.ParseIndex(raw) if !parsed.IsValid() { continue @@ -342,8 +341,7 @@ func (dao *Dao) createCollectionIndexes(collection *models.Collection) error { // record table changes errs := validation.Errors{} for i, idx := range collection.Indexes { - idxString := cast.ToString(idx) - parsed := dbutils.ParseIndex(idxString) + parsed := dbutils.ParseIndex(idx) // ensure that the index is always for the current collection parsed.TableName = collection.Name diff --git a/daos/record_table_sync_test.go b/daos/record_table_sync_test.go index 4161c1c6..f231d42b 100644 --- a/daos/record_table_sync_test.go +++ b/daos/record_table_sync_test.go @@ -40,7 +40,7 @@ func TestSyncRecordTableSchema(t *testing.T) { Type: schema.FieldTypeEmail, }, ) - updatedCollection.Indexes = types.JsonArray{"create index idx_title_renamed on anything (title_renamed)"} + updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"} scenarios := []struct { name string @@ -75,7 +75,7 @@ func TestSyncRecordTableSchema(t *testing.T) { Type: schema.FieldTypeText, }, ), - Indexes: types.JsonArray{"create index idx_auth_test on anything (email, username)"}, + Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"}, }, nil, []string{ diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go index 2eeef80d..af556b92 100644 --- a/forms/collection_upsert.go +++ b/forms/collection_upsert.go @@ -27,18 +27,18 @@ type CollectionUpsert struct { dao *daos.Dao collection *models.Collection - Id string `form:"id" json:"id"` - Type string `form:"type" json:"type"` - Name string `form:"name" json:"name"` - System bool `form:"system" json:"system"` - Schema schema.Schema `form:"schema" json:"schema"` - Indexes []string `form:"indexes" json:"indexes"` - ListRule *string `form:"listRule" json:"listRule"` - ViewRule *string `form:"viewRule" json:"viewRule"` - CreateRule *string `form:"createRule" json:"createRule"` - UpdateRule *string `form:"updateRule" json:"updateRule"` - DeleteRule *string `form:"deleteRule" json:"deleteRule"` - Options types.JsonMap `form:"options" json:"options"` + Id string `form:"id" json:"id"` + Type string `form:"type" json:"type"` + Name string `form:"name" json:"name"` + System bool `form:"system" json:"system"` + Schema schema.Schema `form:"schema" json:"schema"` + Indexes types.JsonArray[string] `form:"indexes" json:"indexes"` + ListRule *string `form:"listRule" json:"listRule"` + ViewRule *string `form:"viewRule" json:"viewRule"` + CreateRule *string `form:"createRule" json:"createRule"` + UpdateRule *string `form:"updateRule" json:"updateRule"` + DeleteRule *string `form:"deleteRule" json:"deleteRule"` + Options types.JsonMap `form:"options" json:"options"` } // NewCollectionUpsert creates a new [CollectionUpsert] form with initializer @@ -59,7 +59,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio form.Type = form.collection.Type form.Name = form.collection.Name form.System = form.collection.System - form.Indexes = list.ToUniqueStringSlice(form.collection.Indexes) + form.Indexes = form.collection.Indexes form.ListRule = form.collection.ListRule form.ViewRule = form.collection.ViewRule form.CreateRule = form.collection.CreateRule @@ -388,7 +388,7 @@ func (form *CollectionUpsert) checkRule(value any) error { } func (form *CollectionUpsert) checkIndexes(value any) error { - v, _ := value.([]string) + v, _ := value.(types.JsonArray[string]) for i, rawIndex := range v { parsed := dbutils.ParseIndex(rawIndex) @@ -510,12 +510,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Col form.collection.Schema = form.Schema // normalize indexes format - form.collection.Indexes = types.JsonArray{} - for _, rawIdx := range form.Indexes { - form.collection.Indexes = append( - form.collection.Indexes, - dbutils.ParseIndex(rawIdx).Build(), - ) + form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes)) + for i, rawIdx := range form.Indexes { + form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build() } } diff --git a/models/collection.go b/models/collection.go index ac81c381..b347e2c4 100644 --- a/models/collection.go +++ b/models/collection.go @@ -23,11 +23,11 @@ const ( type Collection struct { BaseModel - Name string `db:"name" json:"name"` - Type string `db:"type" json:"type"` - System bool `db:"system" json:"system"` - Schema schema.Schema `db:"schema" json:"schema"` - Indexes types.JsonArray `db:"indexes" json:"indexes"` + Name string `db:"name" json:"name"` + Type string `db:"type" json:"type"` + System bool `db:"system" json:"system"` + Schema schema.Schema `db:"schema" json:"schema"` + Indexes types.JsonArray[string] `db:"indexes" json:"indexes"` // rules ListRule *string `db:"listRule" json:"listRule"` diff --git a/models/collection_test.go b/models/collection_test.go index 97b784e6..d752a549 100644 --- a/models/collection_test.go +++ b/models/collection_test.go @@ -79,7 +79,7 @@ func TestCollectionMarshalJSON(t *testing.T) { }, { "unknown type + non empty options", - models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}, Indexes: types.JsonArray{"idx_test"}}, + models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}, Indexes: types.JsonArray[string]{"idx_test"}}, `{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"indexes":["idx_test"],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`, }, { diff --git a/models/record.go b/models/record.go index b01852c5..13d29788 100644 --- a/models/record.go +++ b/models/record.go @@ -630,16 +630,16 @@ func (m *Record) getNormalizeDataValueForDB(key string) any { switch ids := val.(type) { case []string: // encode string slice - return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...) + return append(types.JsonArray[string]{}, ids...) case []int: // encode int slice - return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...) + return append(types.JsonArray[int]{}, ids...) case []float64: // encode float64 slice - return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...) + return append(types.JsonArray[float64]{}, ids...) case []any: // encode interface slice - return append(types.JsonArray{}, ids...) + return append(types.JsonArray[any]{}, ids...) default: // no changes return val diff --git a/plugins/migratecmd/migratecmd_test.go b/plugins/migratecmd/migratecmd_test.go index e6dd0179..afe2655c 100644 --- a/plugins/migratecmd/migratecmd_test.go +++ b/plugins/migratecmd/migratecmd_test.go @@ -149,7 +149,7 @@ func init() { collection.Updated = collection.Created collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0") collection.ViewRule = types.Pointer(`id = "1"`) - collection.Indexes = types.JsonArray{"create index test on new_name (id)"} + collection.Indexes = types.JsonArray[string]{"create index test on new_name (id)"} collection.SetOptions(models.CollectionAuthOptions{ ManageRule: types.Pointer("created > 0"), MinPasswordLength: 20, @@ -318,7 +318,7 @@ func init() { collection.Updated = collection.Created collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0") collection.ViewRule = types.Pointer(`id = "1"`) - collection.Indexes = types.JsonArray{"create index test on test456 (id)"} + collection.Indexes = types.JsonArray[string]{"create index test on test456 (id)"} collection.SetOptions(models.CollectionAuthOptions{ ManageRule: types.Pointer("created > 0"), MinPasswordLength: 20, @@ -642,7 +642,7 @@ func init() { collection.Updated = collection.Created collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0") collection.ViewRule = types.Pointer(`id = "1"`) - collection.Indexes = types.JsonArray{"create index test1 on test456 (f1_name)"} + collection.Indexes = types.JsonArray[string]{"create index test1 on test456 (f1_name)"} collection.SetOptions(models.CollectionAuthOptions{ ManageRule: types.Pointer("created > 0"), MinPasswordLength: 20, @@ -681,7 +681,7 @@ func init() { collection.Type = models.CollectionTypeBase collection.DeleteRule = types.Pointer(`updated > 0 && @request.auth.id != ''`) collection.ListRule = nil - collection.Indexes = types.JsonArray{ + collection.Indexes = types.JsonArray[string]{ "create index test1 on test456_update (f1_name)", } collection.NormalizeOptions() diff --git a/tools/list/list_test.go b/tools/list/list_test.go index a31c98d8..4270335e 100644 --- a/tools/list/list_test.go +++ b/tools/list/list_test.go @@ -254,7 +254,7 @@ func TestToUniqueStringSlice(t *testing.T) { {[]any{0, 1, "test", ""}, []string{"0", "1", "test"}}, {[]string{"test1", "test2", "test1"}, []string{"test1", "test2"}}, {`["test1", "test2", "test2"]`, []string{"test1", "test2"}}, - {types.JsonArray{"test1", "test2", "test1"}, []string{"test1", "test2"}}, + {types.JsonArray[string]{"test1", "test2", "test1"}, []string{"test1", "test2"}}, } for i, scenario := range scenarios { diff --git a/tools/types/json_array.go b/tools/types/json_array.go index 2b555268..f7d3cf67 100644 --- a/tools/types/json_array.go +++ b/tools/types/json_array.go @@ -7,30 +7,32 @@ import ( ) // JsonArray defines a slice that is safe for json and db read/write. -type JsonArray []any +type JsonArray[T any] []T + +// internal alias to prevent recursion during marshalization. +type jsonArrayAlias[T any] JsonArray[T] // MarshalJSON implements the [json.Marshaler] interface. -func (m JsonArray) MarshalJSON() ([]byte, error) { - type alias JsonArray // prevent recursion +func (m JsonArray[T]) MarshalJSON() ([]byte, error) { // initialize an empty map to ensure that `[]` is returned as json if m == nil { - m = JsonArray{} + m = JsonArray[T]{} } - return json.Marshal(alias(m)) + return json.Marshal(jsonArrayAlias[T](m)) } // Value implements the [driver.Valuer] interface. -func (m JsonArray) Value() (driver.Value, error) { +func (m JsonArray[T]) Value() (driver.Value, error) { data, err := json.Marshal(m) return string(data), err } // Scan implements [sql.Scanner] interface to scan the provided value -// into the current `JsonArray` instance. -func (m *JsonArray) Scan(value any) error { +// into the current JsonArray[T] instance. +func (m *JsonArray[T]) Scan(value any) error { var data []byte switch v := value.(type) { case nil: diff --git a/tools/types/json_array_test.go b/tools/types/json_array_test.go index bbe239a0..9ab4e5a4 100644 --- a/tools/types/json_array_test.go +++ b/tools/types/json_array_test.go @@ -2,6 +2,7 @@ package types_test import ( "database/sql/driver" + "encoding/json" "testing" "github.com/pocketbase/pocketbase/tools/types" @@ -9,14 +10,14 @@ import ( func TestJsonArrayMarshalJSON(t *testing.T) { scenarios := []struct { - json types.JsonArray + json json.Marshaler expected string }{ - {nil, "[]"}, - {types.JsonArray{}, `[]`}, - {types.JsonArray{1, 2, 3}, `[1,2,3]`}, - {types.JsonArray{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, - {types.JsonArray{1, "test"}, `[1,"test"]`}, + {new(types.JsonArray[any]), "[]"}, + {types.JsonArray[any]{}, `[]`}, + {types.JsonArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JsonArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JsonArray[any]{1, "test"}, `[1,"test"]`}, } for i, s := range scenarios { @@ -33,14 +34,14 @@ func TestJsonArrayMarshalJSON(t *testing.T) { func TestJsonArrayValue(t *testing.T) { scenarios := []struct { - json types.JsonArray + json driver.Valuer expected driver.Value }{ - {nil, `[]`}, - {types.JsonArray{}, `[]`}, - {types.JsonArray{1, 2, 3}, `[1,2,3]`}, - {types.JsonArray{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, - {types.JsonArray{1, "test"}, `[1,"test"]`}, + {new(types.JsonArray[any]), `[]`}, + {types.JsonArray[any]{}, `[]`}, + {types.JsonArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JsonArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JsonArray[any]{1, "test"}, `[1,"test"]`}, } for i, s := range scenarios { @@ -77,7 +78,7 @@ func TestJsonArrayScan(t *testing.T) { } for i, s := range scenarios { - arr := types.JsonArray{} + arr := types.JsonArray[any]{} scanErr := arr.Scan(s.value) hasErr := scanErr != nil