initial v0.8 pre-release

This commit is contained in:
Gani Georgiev
2022-10-30 10:28:14 +02:00
parent 9cbb2e750e
commit 90dba45d7c
388 changed files with 21580 additions and 13603 deletions
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -49,6 +50,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
//
// Returns an error if the JWT token is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
// @todo consider caching the unverified claims
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
@@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
// IsAdminEmailUnique checks if the provided email address is not
// already in use by other admins.
func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool {
func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
if email == "" {
return false
}
var exists bool
err := dao.AdminQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
query := dao.AdminQuery().Select("count(*)").
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
Row(&exists)
Limit(1)
return err == nil && !exists
if len(excludeIds) > 0 {
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
}
var exists bool
return query.Row(&exists) == nil && !exists
}
// DeleteAdmin deletes the provided Admin model.
+32 -12
View File
@@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
id string
expectError bool
}{
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
{"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false},
{" ", true},
{"missing", true},
{"9q2trqumvlyr3bd", false},
}
for i, scenario := range scenarios {
@@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
email string
expectError bool
}{
{"", true},
{"invalid", true},
{"missing@example.com", true},
{"test@example.com", false},
@@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
expectedEmail string
expectError bool
}{
// invalid base key (password reset key for auth token)
// invalid auth token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
app.Settings().AdminPasswordResetToken.Secret,
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
app.Settings().AdminAuthToken.Secret,
"",
true,
},
// expired token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
app.Settings().AdminAuthToken.Secret,
"",
true,
},
// wrong base token (password reset token secret instead of auth secret)
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminPasswordResetToken.Secret,
"",
true,
},
// valid token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
app.Settings().AdminAuthToken.Secret,
"test@example.com",
false,
@@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result1 != 2 {
t.Fatalf("Expected 2 admins, got %d", result1)
if result1 != 3 {
t.Fatalf("Expected 3 admins, got %d", result1)
}
// delete all
@@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
}{
{"", "", false},
{"test@example.com", "", false},
{"test2@example.com", "", false},
{"test3@example.com", "", false},
{"new@example.com", "", true},
{"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true},
{"test@example.com", "sywbhecnh46rhm0", true},
}
for i, scenario := range scenarios {
@@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
if err != nil {
t.Fatal(err)
}
admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
if err != nil {
t.Fatal(err)
}
deleteErr1 := app.Dao().DeleteAdmin(admin1)
if deleteErr1 != nil {
t.Fatal(deleteErr1)
}
// cannot delete the only remaining admin
deleteErr2 := app.Dao().DeleteAdmin(admin2)
if deleteErr2 == nil {
if deleteErr2 != nil {
t.Fatal(deleteErr2)
}
// cannot delete the only remaining admin
deleteErr3 := app.Dao().DeleteAdmin(admin3)
if deleteErr3 == nil {
t.Fatal("Expected delete error, got nil")
}
+7 -7
View File
@@ -35,8 +35,8 @@ func TestDaoModelQuery(t *testing.T) {
"SELECT {{_collections}}.* FROM `_collections`",
},
{
&models.User{},
"SELECT {{_users}}.* FROM `_users`",
&models.Admin{},
"SELECT {{_admins}}.* FROM `_admins`",
},
{
&models.Request{},
@@ -64,19 +64,19 @@ func TestDaoFindById(t *testing.T) {
// missing id
{
&models.Collection{},
"00000000-075d-49fe-9d09-ea7e951000dc",
"missing",
true,
},
// existing collection id
{
&models.Collection{},
"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"wsmn24bux7wo113",
false,
},
// existing user id
// existing admin id
{
&models.User{},
"97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
&models.Admin{},
"sbmbsdb40jyxf7h",
false,
},
}
+43 -28
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
)
// CollectionQuery returns a new Collection select query.
@@ -15,6 +16,22 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Collection{})
}
// FindCollectionsByType finds all collections by the given type
func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
models := []*models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.HashExp{"type": collectionType}).
OrderBy("created ASC").
All(&models)
if err != nil {
return nil, err
}
return models, nil
}
// FindCollectionByNameOrId finds the first collection by its name or id.
func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
model := &models.Collection{}
@@ -38,38 +55,24 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
// with the provided name (case insensitive!).
//
// Note: case sensitive check because the name is used also as a table name for the records.
func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool {
func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
}
var exists bool
err := dao.CollectionQuery().
query := dao.CollectionQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
Limit(1).
Row(&exists)
Limit(1)
return err == nil && !exists
}
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
// FindCollectionsWithUserFields finds all collections that has
// at least one user schema field.
func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
result := []*models.Collection{}
var exists bool
err := dao.CollectionQuery().
InnerJoin(
"json_each(schema) as jsonField",
dbx.NewExp(
"json_extract(jsonField.value, '$.type') = {:type}",
dbx.Params{"type": schema.FieldTypeUser},
),
).
All(&result)
return result, err
return query.Row(&exists) == nil && !exists
}
// FindCollectionReferences returns information for all
@@ -78,13 +81,15 @@ func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
// If the provided collection has reference to itself then it will be
// also included in the result. To exclude it, pass the collection id
// as the excludeId argument.
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) {
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) {
collections := []*models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
All(&collections)
if err != nil {
query := dao.CollectionQuery()
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
if err := query.All(&collections); err != nil {
return nil, err
}
@@ -152,6 +157,11 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
return dao.RunInTransaction(func(txDao *Dao) error {
// set default collection type
if collection.Type == "" {
collection.Type = models.CollectionTypeBase
}
// persist the collection model
if err := txDao.Save(collection); err != nil {
return err
@@ -196,6 +206,11 @@ func (dao *Dao) ImportCollections(
imported.RefreshId()
}
// set default type if missing
if imported.Type == "" {
imported.Type = models.CollectionTypeBase
}
if existing, ok := mappedExisting[imported.GetId()]; ok {
// preserve original created date
if !existing.Created.IsZero() {
+108 -113
View File
@@ -24,6 +24,41 @@ func TestCollectionQuery(t *testing.T) {
}
}
func TestFindCollectionsByType(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionType string
expectError bool
expectTotal int
}{
{"", false, 0},
{"unknown", false, 0},
{models.CollectionTypeAuth, false, 3},
{models.CollectionTypeBase, false, 4},
}
for i, scenario := range scenarios {
collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if len(collections) != scenario.expectTotal {
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
}
for _, c := range collections {
if c.Type != scenario.collectionType {
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
}
}
}
}
func TestFindCollectionByNameOrId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -34,9 +69,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
}{
{"", true},
{"missing", true},
{"00000000-075d-49fe-9d09-ea7e951000dc", true},
{"3f2888f8-075d-49fe-9d09-ea7e951000dc", false},
{"demo", false},
{"wsmn24bux7wo113", false},
{"demo1", false},
}
for i, scenario := range scenarios {
@@ -63,9 +97,10 @@ func TestIsCollectionNameUnique(t *testing.T) {
expected bool
}{
{"", "", false},
{"demo", "", false},
{"demo1", "", false},
{"Demo1", "", false},
{"new", "", true},
{"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true},
{"demo1", "wsmn24bux7wo113", true},
}
for i, scenario := range scenarios {
@@ -76,33 +111,11 @@ func TestIsCollectionNameUnique(t *testing.T) {
}
}
func TestFindCollectionsWithUserFields(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
result, err := app.Dao().FindCollectionsWithUserFields()
if err != nil {
t.Fatal(err)
}
expectedNames := []string{"demo2", models.ProfileCollectionName}
if len(result) != len(expectedNames) {
t.Fatalf("Expected collections %v, got %v", expectedNames, result)
}
for i, col := range result {
if !list.ExistInSlice(col.Name, expectedNames) {
t.Errorf("(%d) Couldn't find %s in %v", i, col.Name, expectedNames)
}
}
}
func TestFindCollectionReferences(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo")
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
@@ -116,11 +129,18 @@ func TestFindCollectionReferences(t *testing.T) {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
}
expectedFields := []string{"onerel", "manyrels", "cascaderel"}
expectedFields := []string{
"rel_one_no_cascade",
"rel_one_no_cascade_required",
"rel_one_cascade",
"rel_many_no_cascade",
"rel_many_no_cascade_required",
"rel_many_cascade",
}
for col, fields := range result {
if col.Name != "demo2" {
t.Fatalf("Expected collection demo2, got %s", col.Name)
if col.Name != "demo4" {
t.Fatalf("Expected collection demo4, got %s", col.Name)
}
if len(fields) != len(expectedFields) {
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
@@ -138,7 +158,7 @@ func TestDeleteCollection(t *testing.T) {
defer app.Cleanup()
c0 := &models.Collection{}
c1, err := app.Dao().FindCollectionByNameOrId("demo")
c1, err := app.Dao().FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
@@ -146,18 +166,22 @@ func TestDeleteCollection(t *testing.T) {
if err != nil {
t.Fatal(err)
}
c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
c3.System = true
if err := app.Dao().Save(c3); err != nil {
t.Fatal(err)
}
scenarios := []struct {
model *models.Collection
expectError bool
}{
{c0, true},
{c1, true}, // is part of a reference
{c2, false},
{c1, false},
{c2, true}, // is part of a reference
{c3, true}, // system
}
@@ -177,6 +201,7 @@ func TestSaveCollectionCreate(t *testing.T) {
collection := &models.Collection{
Name: "new_test",
Type: models.CollectionTypeBase,
Schema: schema.NewSchema(
&schema.SchemaField{
Type: schema.FieldTypeText,
@@ -239,7 +264,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
// check if the records table has the schema fields
expectedColumns := []string{"id", "created", "updated", "title_update", "test"}
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
columns, err := app.Dao().GetTableColumns(collection.Name)
if err != nil {
t.Fatal(err)
@@ -262,13 +287,14 @@ func TestImportCollections(t *testing.T) {
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
expectError bool
expectCollectionsCount int
beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
}{
{
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 5,
expectCollectionsCount: 7,
},
{
name: "check db constraints",
@@ -277,7 +303,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
expectCollectionsCount: 7,
},
{
name: "minimal collection import",
@@ -286,7 +312,7 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
expectCollectionsCount: 8,
},
{
name: "minimal collection import + failed beforeRecordsSync",
@@ -298,7 +324,7 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
expectCollectionsCount: 7,
},
{
name: "minimal collection import + successful beforeRecordsSync",
@@ -310,13 +336,13 @@ func TestImportCollections(t *testing.T) {
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
expectCollectionsCount: 8,
},
{
name: "new + update + delete system collection",
jsonData: `[
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"id":"wsmn24bux7wo113",
"name":"demo",
"schema":[
{
@@ -346,50 +372,49 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 5,
expectCollectionsCount: 7,
},
{
name: "new + update + delete non-system collection",
jsonData: `[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
"id": "kpv709sk2lqbqk8",
"system": true,
"name": "nologin",
"type": "auth",
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": [],
"manageRule": "@request.auth.collectionName = 'users'",
"minPasswordLength": 8,
"onlyEmailDomains": [],
"requireEmail": true
},
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"schema": [
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
"id": "x8zzktwe",
"name": "name",
"type": "text",
"system": false,
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"id":"wsmn24bux7wo113",
"name":"demo",
"schema":[
{
@@ -427,38 +452,8 @@ func TestImportCollections(t *testing.T) {
name: "test with deleteMissing: false",
jsonData: `[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":true
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"bool"
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"id":"wsmn24bux7wo113",
"name":"demo1",
"schema":[
{
"id":"_2hlxbmp",
@@ -506,14 +501,14 @@ func TestImportCollections(t *testing.T) {
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
expectCollectionsCount: 8,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"profiles": 6,
"demo": 3,
"demo2": 14,
"demo3": 1,
"demo4": 6,
"nologin": 1,
"demo1": 15,
"demo2": 2,
"demo3": 2,
"demo4": 11,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
+14 -31
View File
@@ -12,13 +12,16 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.ExternalAuth{})
}
/// FindAllExternalAuthsByUserId returns all ExternalAuth models
/// linked to the provided userId.
func (dao *Dao) FindAllExternalAuthsByUserId(userId string) ([]*models.ExternalAuth, error) {
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
/// linked to the provided auth record.
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
auths := []*models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{"userId": userId}).
AndWhere(dbx.HashExp{
"collectionId": authRecord.Collection().Id,
"recordId": authRecord.Id,
}).
OrderBy("created ASC").
All(&auths)
@@ -50,15 +53,16 @@ func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models
return model, nil
}
// FindExternalAuthByUserIdAndProvider returns the first available
// ExternalAuth model for the specified userId and provider.
func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*models.ExternalAuth, error) {
// FindExternalAuthByRecordAndProvider returns the first available
// ExternalAuth model for the specified record data and provider.
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
model := &models.ExternalAuth{}
err := dao.ExternalAuthQuery().
AndWhere(dbx.HashExp{
"userId": userId,
"provider": provider,
"collectionId": authRecord.Collection().Id,
"recordId": authRecord.Id,
"provider": provider,
}).
Limit(1).
One(model)
@@ -74,7 +78,7 @@ func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*m
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
// extra check the model data in case the provider's API response
// has changed and no longer returns the expected fields
if model.UserId == "" || model.Provider == "" || model.ProviderId == "" {
if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
return errors.New("Missing required ExternalAuth fields.")
}
@@ -82,27 +86,6 @@ func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
}
// DeleteExternalAuth deletes the provided ExternalAuth model.
//
// The delete may fail if the linked user doesn't have an email and
// there are no other linked ExternalAuth models available.
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
user, err := dao.FindUserById(model.UserId)
if err != nil {
return err
}
// if the user doesn't have an email, make sure that there
// is at least one other external auth relation available
if user.Email == "" {
allExternalAuths, err := dao.FindAllExternalAuthsByUserId(user.Id)
if err != nil {
return err
}
if len(allExternalAuths) <= 1 {
return errors.New("You cannot delete the only available external auth relation because the user doesn't have an email address.")
}
}
return dao.Delete(model)
}
+38 -44
View File
@@ -19,7 +19,7 @@ func TestExternalAuthQuery(t *testing.T) {
}
}
func TestFindAllExternalAuthsByUserId(t *testing.T) {
func TestFindAllExternalAuthsByRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -27,16 +27,20 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
userId string
expectedCount int
}{
{"", 0},
{"missing", 0},
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", 0},
{"cx9u0dh2udo8xol", 2},
{"oap640cot4yru2s", 0},
{"4q1xlclmfloku33", 2},
}
for i, s := range scenarios {
auths, err := app.Dao().FindAllExternalAuthsByUserId(s.userId)
record, err := app.Dao().FindRecordById("users", s.userId)
if err != nil {
t.Errorf("(%d) Unexpected error %v", i, err)
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
continue
}
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
continue
}
@@ -45,8 +49,8 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
}
for _, auth := range auths {
if auth.UserId != s.userId {
t.Errorf("(%d) Expected all auths to be linked to userId %s, got %v", i, s.userId, auth)
if auth.RecordId != record.Id {
t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
}
}
}
@@ -65,8 +69,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
{"github", "", ""},
{"github", "id1", ""},
{"github", "id2", ""},
{"google", "id1", "abcdefghijklmn0"},
{"gitlab", "id2", "abcdefghijklmn1"},
{"google", "test123", "clmflokuq1xl341"},
{"gitlab", "test123", "dlmflokuq1xl342"},
}
for i, s := range scenarios {
@@ -85,7 +89,7 @@ func TestFindExternalAuthByProvider(t *testing.T) {
}
}
func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -94,17 +98,19 @@ func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
provider string
expectedId string
}{
{"", "", ""},
{"", "github", ""},
{"123456", "github", ""}, // missing user and provider record
{"123456", "google", ""}, // missing user but existing provider record
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "google", ""},
{"cx9u0dh2udo8xol", "google", "abcdefghijklmn0"},
{"cx9u0dh2udo8xol", "gitlab", "abcdefghijklmn1"},
{"bgs820n361vj1qd", "google", ""},
{"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
{"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
}
for i, s := range scenarios {
auth, err := app.Dao().FindExternalAuthByUserIdAndProvider(s.userId, s.provider)
record, err := app.Dao().FindRecordById("users", s.userId)
if err != nil {
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
continue
}
auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
hasErr := err != nil
expectErr := s.expectedId == ""
@@ -130,9 +136,10 @@ func TestSaveExternalAuth(t *testing.T) {
}
auth := &models.ExternalAuth{
UserId: "97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
Provider: "test",
ProviderId: "test_id",
RecordId: "o1y0dd0spd786md",
CollectionId: "v851q4r790rhknl",
Provider: "test",
ProviderId: "test_id",
}
if err := app.Dao().SaveExternalAuth(auth); err != nil {
@@ -154,42 +161,29 @@ func TestDeleteExternalAuth(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
user, err := app.Dao().FindUserById("cx9u0dh2udo8xol")
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
auths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Fatal(err)
}
if err := app.Dao().DeleteExternalAuth(auths[0]); err != nil {
t.Fatalf("Failed to delete the first ExternalAuth relation, got \n%v", err)
}
if err := app.Dao().DeleteExternalAuth(auths[1]); err == nil {
t.Fatal("Expected delete to fail, got nil")
}
// update the user model and try again
user.Email = "test_new@example.com"
if err := app.Dao().SaveUser(user); err != nil {
t.Fatal(err)
}
// try to delete auths[1] again
if err := app.Dao().DeleteExternalAuth(auths[1]); err != nil {
t.Fatalf("Failed to delete the last ExternalAuth relation, got \n%v", err)
for _, auth := range auths {
if err := app.Dao().DeleteExternalAuth(auth); err != nil {
t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
}
}
// check if the relations were really deleted
newAuths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
t.Fatal(err)
}
if len(newAuths) != 0 {
t.Fatalf("Expected all user %s ExternalAuth relations to be deleted, got \n%v", user.Id, newAuths)
t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
}
}
+281 -95
View File
@@ -8,9 +8,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// RecordQuery returns a new Record select query.
@@ -23,16 +25,24 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
// FindRecordById finds the Record model by its id.
func (dao *Dao) FindRecordById(
collection *models.Collection,
collectionNameOrId string,
recordId string,
filter func(q *dbx.SelectQuery) error,
optFilters ...func(q *dbx.SelectQuery) error,
) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
tableName := collection.Name
query := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{tableName + ".id": recordId})
if filter != nil {
for _, filter := range optFilters {
if filter == nil {
continue
}
if err := filter(query); err != nil {
return nil, err
}
@@ -49,16 +59,25 @@ func (dao *Dao) FindRecordById(
// FindRecordsByIds finds all Record models by the provided ids.
// If no records are found, returns an empty slice.
func (dao *Dao) FindRecordsByIds(
collection *models.Collection,
collectionNameOrId string,
recordIds []string,
filter func(q *dbx.SelectQuery) error,
optFilters ...func(q *dbx.SelectQuery) error,
) ([]*models.Record, error) {
tableName := collection.Name
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection).
AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...))
AndWhere(dbx.In(
collection.Name+".id",
list.ToInterfaceSlice(recordIds)...,
))
if filter != nil {
for _, filter := range optFilters {
if filter == nil {
continue
}
if err := filter(query); err != nil {
return nil, err
}
@@ -72,24 +91,34 @@ func (dao *Dao) FindRecordsByIds(
return models.NewRecordsFromNullStringMaps(collection, rows), nil
}
// FindRecordsByExpr finds all records by the provided db expression.
// If no records are found, returns an empty slice.
// FindRecordsByExpr finds all records by the specified db expression.
//
// Returns all collection records if no expressions are provided.
//
// Returns an empty slice if no records are found.
//
// Example:
// expr := dbx.HashExp{"email": "test@example.com"}
// dao.FindRecordsByExpr(collection, expr)
func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) {
if expr == nil {
return nil, errors.New("Missing filter expression")
// expr1 := dbx.HashExp{"email": "test@example.com"}
// expr2 := dbx.HashExp{"status": "active"}
// dao.FindRecordsByExpr("example", expr1, expr2)
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
query := dao.RecordQuery(collection)
// add only the non-nil expressions
for _, expr := range exprs {
if expr != nil {
query.AndWhere(expr)
}
}
rows := []dbx.NullStringMap{}
err := dao.RecordQuery(collection).
AndWhere(expr).
All(&rows)
if err != nil {
if err := query.All(&rows); err != nil {
return nil, err
}
@@ -98,11 +127,16 @@ func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expres
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) {
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return nil, err
}
row := dbx.NullStringMap{}
err := dao.RecordQuery(collection).
AndWhere(dbx.HashExp{key: value}).
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(row)
@@ -115,85 +149,193 @@ func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string,
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
//
// For correctness, if the collection is "auth" and the key is "username",
// the unique check will be case insensitive.
//
// NB! Array values (eg. from multiple select fields) are matched
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
// depends on the elements order. Or in other words the following values
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
func (dao *Dao) IsRecordValueUnique(
collection *models.Collection,
collectionNameOrId string,
key string,
value any,
excludeId string,
excludeIds ...string,
) bool {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil {
return false
}
var expr dbx.Expression
if collection.IsAuth() && key == schema.FieldNameUsername {
expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(cast.ToString(value)),
})
} else {
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
default:
normalizedVal = val
}
expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
}
query := dao.RecordQuery(collection).
Select("count(*)").
AndWhere(expr).
Limit(1)
if len(excludeIds) > 0 {
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
}
var exists bool
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
default:
normalizedVal = val
}
err := dao.RecordQuery(collection).
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.HashExp{key: normalizedVal}).
Limit(1).
Row(&exists)
return err == nil && !exists
return query.Row(&exists) == nil && !exists
}
// FindUserRelatedRecords returns all records that has a reference
// to the provided User model (via the user shema field).
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
if user.Id == "" {
return []*models.Record{}, nil
}
collections, err := dao.FindCollectionsWithUserFields()
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
//
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
}
result := []*models.Record{}
for _, collection := range collections {
userFields := []*schema.SchemaField{}
// prepare fields options
if err := collection.Schema.InitFieldsOptions(); err != nil {
return nil, err
}
// extract user fields
for _, field := range collection.Schema.Fields() {
if field.Type == schema.FieldTypeUser {
userFields = append(userFields, field)
}
}
// fetch records associated to the user
exprs := []dbx.Expression{}
for _, field := range userFields {
exprs = append(exprs, dbx.HashExp{field.Name: user.Id})
}
rows := []dbx.NullStringMap{}
if err := dao.RecordQuery(collection).AndWhere(dbx.Or(exprs...)).All(&rows); err != nil {
return nil, err
}
records := models.NewRecordsFromNullStringMaps(collection, rows)
result = append(result, records...)
// check required claims
id, _ := unverifiedClaims["id"].(string)
collectionId, _ := unverifiedClaims["collectionId"].(string)
if id == "" || collectionId == "" {
return nil, errors.New("Missing or invalid token claims.")
}
return result, nil
record, err := dao.FindRecordById(collectionId, id)
if err != nil {
return nil, err
}
if !record.Collection().IsAuth() {
return nil, errors.New("The token is not associated to an auth collection record.")
}
verificationKey := record.TokenKey() + baseTokenKey
// verify token signature
if _, err := security.ParseJWT(token, verificationKey); err != nil {
return nil, err
}
return record, nil
}
// FindAuthRecordByEmail finds the auth record associated with the provided email.
//
// Returns an error if it is not an auth collection or the record is not found.
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil || !collection.IsAuth() {
return nil, errors.New("Missing or not an auth collection.")
}
row := dbx.NullStringMap{}
err = dao.RecordQuery(collection).
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
Limit(1).
One(row)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
}
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
//
// Returns an error if it is not an auth collection or the record is not found.
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
if err != nil || !collection.IsAuth() {
return nil, errors.New("Missing or not an auth collection.")
}
row := dbx.NullStringMap{}
err = dao.RecordQuery(collection).
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
"username": strings.ToLower(username),
})).
Limit(1).
One(row)
if err != nil {
return nil, err
}
return models.NewRecordFromNullStringMap(collection, row), nil
}
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
// and return a new "unique" username with appended random numeric part
// (eg. "existingName" -> "existingName583").
//
// The same username will be returned if the provided string is already unique.
func (dao *Dao) SuggestUniqueAuthRecordUsername(
collectionNameOrId string,
baseUsername string,
excludeIds ...string,
) string {
username := baseUsername
for i := 0; i < 10; i++ { // max 10 attempts
isUnique := dao.IsRecordValueUnique(
collectionNameOrId,
schema.FieldNameUsername,
username,
excludeIds...,
)
if isUnique {
break // already unique
}
username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
}
return username
}
// SaveRecord upserts the provided Record model.
func (dao *Dao) SaveRecord(record *models.Record) error {
if record.Collection().IsAuth() {
if record.Username() == "" {
return errors.New("Unable to save auth record without username.")
}
// Cross-check that the auth record id is unique for all auth collections.
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
if err != nil {
return fmt.Errorf("Unable to fetch the auth collections for cross-id unique check: %v", err)
}
for _, collection := range authCollections {
if record.Collection().Id == collection.Id {
continue // skip current collection (sqlite will do the check for us)
}
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
if !isUnique {
return errors.New("The auth record ID must be unique across all auth collections.")
}
}
}
return dao.Save(record)
}
@@ -206,8 +348,8 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
// reference in another record (aka. cannot be deleted or set to NULL).
func (dao *Dao) DeleteRecord(record *models.Record) error {
// check for references
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
refs, err := dao.FindCollectionReferences(record.Collection(), "")
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction.
refs, err := dao.FindCollectionReferences(record.Collection())
if err != nil {
return err
}
@@ -217,6 +359,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
// just unset the record id from any relation field values (if they are not required)
// -----------------------------------------------------------
return dao.RunInTransaction(func(txDao *Dao) error {
// delete/update references
for refCollection, fields := range refs {
for _, field := range fields {
options, _ := field.Options.(*schema.RelationOptions)
@@ -234,7 +377,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
for _, refRecord := range refRecords {
ids := refRecord.GetStringSliceDataValue(field.Name)
ids := refRecord.GetStringSlice(field.Name)
// unset the record id
for i := len(ids) - 1; i >= 0; i-- {
@@ -259,7 +402,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
// save the reference changes
refRecord.SetDataValue(field.Name, field.PrepareValue(ids))
refRecord.Set(field.Name, field.PrepareValue(ids))
if err := txDao.SaveRecord(refRecord); err != nil {
return err
}
@@ -267,6 +410,17 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
}
// delete linked external auths
if record.Collection().IsAuth() {
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
"collectionId": record.Collection().Id,
"recordId": record.Id,
}).Execute()
if err != nil {
return err
}
}
return txDao.Delete(record)
})
}
@@ -279,9 +433,26 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// create
if oldCollection == nil {
cols := map[string]string{
schema.ReservedFieldNameId: "TEXT PRIMARY KEY",
schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`,
schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`,
schema.FieldNameId: "TEXT PRIMARY KEY",
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
// ensure that the new collection has an id
if !newCollection.HasId() {
newCollection.RefreshId()
newCollection.MarkAsNew()
}
tableName := newCollection.Name
@@ -292,15 +463,30 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
// create table
_, tableErr := dao.DB().CreateTable(tableName, cols).Execute()
if tableErr != nil {
return tableErr
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add index on the base `created` column
_, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute()
if indexErr != nil {
return indexErr
// add named index on the base `created` column
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := dao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
return err
}
}
return nil
@@ -315,7 +501,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
if err != nil {
return err
}
+104 -21
View File
@@ -3,11 +3,16 @@ package daos
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
)
// MaxExpandDepth specifies the max allowed nested expand depth path.
@@ -40,10 +45,13 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
return failed
}
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
// notes:
// - fetchFunc must be non-nil func
// - all records are expected to be from the same collection
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
// - indirect expands are supported only with single relation fields
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
if fetchFunc == nil {
return errors.New("Relation records fetchFunc is not set.")
@@ -53,29 +61,104 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
return nil
}
parts := strings.SplitN(expandPath, ".", 2)
// extract the relation field (if exist)
mainCollection := records[0].Collection()
relField := mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil || relField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
}
relField.InitOptions()
relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
if !ok {
return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
var relField *schema.SchemaField
var relFieldOptions *schema.RelationOptions
var relCollection *models.Collection
parts := strings.SplitN(expandPath, ".", 2)
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
if len(matches) == 3 {
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
if indirectRel == nil {
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
}
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
}
indirectRelField.InitOptions()
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
}
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
// for now don't allow multi-relation indirect fields expand
// due to eventual poor query performance with large data sets.
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
}
recordIds := make([]any, len(records))
for _, record := range records {
recordIds = append(recordIds, record.Id)
}
indirectRecords, err := dao.FindRecordsByExpr(
indirectRel.Id,
dbx.In(inflector.Columnify(matches[2]), recordIds...),
)
if err != nil {
return err
}
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
for _, indirectRecord := range indirectRecords {
recId := indirectRecord.GetString(matches[2])
if recId != "" {
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
}
}
// add the indirect relation ids as a new relation field value
for _, record := range records {
relIds, ok := mappedIndirectRecordIds[record.Id]
if ok && len(relIds) > 0 {
record.Set(parts[0], relIds)
}
}
relFieldOptions = &schema.RelationOptions{
MaxSelect: nil,
CollectionId: indirectRel.Id,
}
if indirectRelField.Unique {
relFieldOptions.MaxSelect = types.Pointer(1)
}
// indirect relation
relField = &schema.SchemaField{
Id: "indirect_" + security.RandomString(3),
Type: schema.FieldTypeRelation,
Name: parts[0],
Options: relFieldOptions,
}
relCollection = indirectRel
} else {
// direct relation
relField = mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil || relField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
}
relField.InitOptions()
relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
if relFieldOptions == nil {
return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
}
relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
if relCollection == nil {
return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
}
}
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
if err != nil {
return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId)
}
// ---------------------------------------------------------------
// extract the id of the relations to expand
relIds := make([]string, 0, len(records))
for _, record := range records {
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
relIds = append(relIds, record.GetStringSlice(relField.Name)...)
}
// fetch rels
@@ -99,7 +182,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
for _, model := range records {
relIds := model.GetStringSliceDataValue(relField.Name)
relIds := model.GetStringSlice(relField.Name)
validRels := make([]*models.Record, 0, len(relIds))
for _, id := range relIds {
@@ -112,7 +195,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue // no valid relations
}
expandData := model.GetExpand()
expandData := model.Expand()
// normalize access to the previously expanded rel records (if any)
var oldExpandedRels []*models.Record
@@ -133,8 +216,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
continue
}
oldRelExpand := oldExpandedRel.GetExpand()
newRelExpand := rel.GetExpand()
oldRelExpand := oldExpandedRel.Expand()
newRelExpand := rel.Expand()
for k, v := range oldRelExpand {
newRelExpand[k] = v
}
@@ -143,7 +226,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
// update the expanded data
if relFieldOptions.MaxSelect == 1 {
if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
expandData[relField.Name] = validRels[0]
} else {
expandData[relField.Name] = validRels
+171 -101
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
@@ -16,152 +17,173 @@ func TestExpandRecords(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct {
testName string
collectionIdOrName string
recordIds []string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
// empty records
{
"empty records",
"",
[]string{},
[]string{"onerel", "manyrels.onerel.manyrels"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
// empty expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
"empty expand",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
// empty fetchFunc
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"onerel", "manyrels.onerel.manyrels"},
"empty fetchFunc",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
// fetchFunc with error
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"onerel", "manyrels.onerel.manyrels"},
"fetchFunc with error",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error")
},
0,
2,
},
// missing relation field
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"invalid"},
"missing relation field",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
// existing, but non-relation type field
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
"existing, but non-relation type field",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
// invalid/missing second level expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"manyrels.invalid"},
"invalid/missing second level expand",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
// expand normalizations
{
"expand normalizations",
"demo4",
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
[]string{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
"b84cd893-7119-43c9-8505-3c4e22da28a9",
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
"self_rel_many", "self_rel_many.",
" self_rel_many ", "",
},
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
9,
0,
},
// expand multiple relations sharing a common root path
{
"single expand",
"users",
[]string{
"i15r5aa28ad06c8",
"bgs820n361vj1qd",
"4q1xlclmfloku33",
"oap640cot4yru2s", // no rels
},
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel.onerel"},
[]string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
4,
0,
},
// single expand
{
[]string{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
"b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels
},
[]string{"manyrels"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
2,
0,
},
// maxExpandDepth reached
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"},
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
"maxExpandDepth reached",
"demo4",
[]string{"qzaqccwrmva4o1n"},
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
6,
0,
},
{
"simple indirect expand",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{"demo4(rel_one_no_cascade_required)"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
1,
0,
},
{
"nested indirect expand",
"demo3",
[]string{"lcl9d87w22ml6jy"},
[]string{
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
5,
0,
},
}
for i, s := range scenarios {
for _, s := range scenarios {
ids := list.ToUniqueStringSlice(s.recordIds)
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
if len(failed) != s.expectExpandFailures {
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(records)
encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, "@expand")
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
@@ -170,109 +192,157 @@ func TestExpandRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct {
testName string
collectionIdOrName string
recordId string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
// empty expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
"empty expand",
"demo4",
"i9naidtvr6qsgb4",
[]string{},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
// empty fetchFunc
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"onerel", "manyrels.onerel.manyrels"},
"empty fetchFunc",
"demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
nil,
0,
2,
},
// fetchFunc with error
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"onerel", "manyrels.onerel.manyrels"},
"fetchFunc with error",
"demo4",
"i9naidtvr6qsgb4",
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return nil, errors.New("test error")
},
0,
2,
},
// invalid missing first level expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"invalid"},
"missing relation field",
"demo4",
"i9naidtvr6qsgb4",
[]string{"missing"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
// invalid missing second level expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels.invalid"},
"existing, but non-relation type field",
"demo4",
"i9naidtvr6qsgb4",
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
1,
},
// expand normalizations
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "},
"invalid/missing second level expand",
"demo4",
"qzaqccwrmva4o1n",
[]string{"rel_one_no_cascade.title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
3,
0,
},
// single expand
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
1,
},
{
"expand normalizations",
"demo4",
"qzaqccwrmva4o1n",
[]string{
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
"self_rel_many", "self_rel_many.",
" self_rel_many ", "",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
8,
0,
},
// maxExpandDepth reached
{
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
"no rels to expand",
"users",
"oap640cot4yru2s",
[]string{"rel"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
0,
0,
},
{
"maxExpandDepth reached",
"demo4",
"qzaqccwrmva4o1n",
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
6,
0,
},
{
"simple indirect expand",
"demo3",
"lcl9d87w22ml6jy",
[]string{"demo4(rel_one_no_cascade_required)"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
1,
0,
},
{
"nested indirect expand",
"demo3",
"lcl9d87w22ml6jy",
[]string{
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
},
5,
0,
},
}
for i, s := range scenarios {
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
for _, s := range scenarios {
record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
if len(failed) != s.expectExpandFailures {
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(record)
encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, "@expand")
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
+406 -128
View File
@@ -3,6 +3,8 @@ package daos_test
import (
"errors"
"fmt"
"regexp"
"strings"
"testing"
"github.com/pocketbase/dbx"
@@ -16,7 +18,10 @@ func TestRecordQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
@@ -30,30 +35,50 @@ func TestFindRecordById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
id string
filter func(q *dbx.SelectQuery) error
expectError bool
collectionIdOrName string
id string
filter1 func(q *dbx.SelectQuery) error
filter2 func(q *dbx.SelectQuery) error
expectError bool
}{
{"00000000-bafd-48f7-b8b7-090638afe209", nil, true},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", nil, false},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
{"demo2", "missing", nil, nil, true},
{"missing", "0yxhwia2amd8gec", nil, nil, true},
{"demo2", "0yxhwia2amd8gec", nil, nil, false},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "missing"})
return nil
}, true},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
}, nil, true},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
return errors.New("test error")
}, nil, true},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "test3"})
return nil
}, nil, false},
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "test3"})
return nil
}, func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"active": false})
return nil
}, true},
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "lorem"})
{"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"title": "test3"})
return nil
}, func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"active": true})
return nil
}, false},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter)
record, err := app.Dao().FindRecordById(
scenario.collectionIdOrName,
scenario.id,
scenario.filter1,
scenario.filter2,
)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -70,25 +95,34 @@ func TestFindRecordsByIds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
ids []string
filter func(q *dbx.SelectQuery) error
expectTotal int
expectError bool
collectionIdOrName string
ids []string
filter1 func(q *dbx.SelectQuery) error
filter2 func(q *dbx.SelectQuery) error
expectTotal int
expectError bool
}{
{[]string{}, nil, 0, false},
{[]string{"00000000-bafd-48f7-b8b7-090638afe209"}, nil, 0, false},
{[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209"}, nil, 1, false},
{"demo2", []string{}, nil, nil, 0, false},
{"demo2", []string{""}, nil, nil, 0, false},
{"demo2", []string{"missing"}, nil, nil, 0, false},
{"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
{"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
{"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
{
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
"demo2",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
nil,
nil,
2,
false,
},
{
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
"demo2",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
return nil // empty filter
},
func(q *dbx.SelectQuery) error {
return errors.New("test error")
},
@@ -96,9 +130,25 @@ func TestFindRecordsByIds(t *testing.T) {
true,
},
{
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
"demo2",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.Like("title", "test").Match(true, true))
q.AndWhere(dbx.HashExp{"active": true})
return nil
},
nil,
1,
false,
},
{
"sz5l5z67tg7gku0",
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.HashExp{"active": true})
return nil
},
func(q *dbx.SelectQuery) error {
q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
return nil
},
1,
@@ -107,7 +157,12 @@ func TestFindRecordsByIds(t *testing.T) {
}
for i, scenario := range scenarios {
records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter)
records, err := app.Dao().FindRecordsByIds(
scenario.collectionIdOrName,
scenario.ids,
scenario.filter1,
scenario.filter2,
)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -131,35 +186,53 @@ func TestFindRecordsByExpr(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
expression dbx.Expression
expectIds []string
expectError bool
collectionIdOrName string
expressions []dbx.Expression
expectIds []string
expectError bool
}{
{
"missing",
nil,
[]string{},
true,
},
{
dbx.HashExp{"id": 123},
"demo2",
nil,
[]string{
"achvryl401bhse3",
"llvuca81nly1qls",
"0yxhwia2amd8gec",
},
false,
},
{
"demo2",
[]dbx.Expression{
nil,
dbx.HashExp{"id": "123"},
},
[]string{},
false,
},
{
dbx.Like("title", "test").Match(true, true),
"sz5l5z67tg7gku0",
[]dbx.Expression{
dbx.Like("title", "test").Match(true, true),
dbx.HashExp{"active": true},
},
[]string{
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"577bd676-aacb-4072-b7da-99d00ee210a4",
"achvryl401bhse3",
"0yxhwia2amd8gec",
},
false,
},
}
for i, scenario := range scenarios {
records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression)
records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -183,42 +256,52 @@ func TestFindFirstRecordByData(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
scenarios := []struct {
key string
value any
expectId string
expectError bool
collectionIdOrName string
key string
value any
expectId string
expectError bool
}{
{
"missing",
"id",
"llvuca81nly1qls",
"llvuca81nly1qls",
true,
},
{
"demo2",
"",
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"llvuca81nly1qls",
"",
true,
},
{
"demo2",
"id",
"invalid",
"",
true,
},
{
"demo2",
"id",
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
"llvuca81nly1qls",
"llvuca81nly1qls",
false,
},
{
"sz5l5z67tg7gku0",
"title",
"lorem",
"b5c2ffc2-bafd-48f7-b8b7-090638afe209",
"test3",
"0yxhwia2amd8gec",
false,
},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value)
record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -236,32 +319,44 @@ func TestIsRecordValueUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
testManyRelsId1 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
testManyRelsId2 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
testManyRelsId1 := "bgs820n361vj1qd"
testManyRelsId2 := "4q1xlclmfloku33"
testManyRelsId3 := "oap640cot4yru2s"
scenarios := []struct {
key string
value any
excludeId string
expected bool
collectionIdOrName string
key string
value any
excludeIds []string
expected bool
}{
{"", "", "", false},
{"missing", "unique", "", false},
{"title", "unique", "", true},
{"title", "demo1", "", false},
{"title", "demo1", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", true},
{"manyrels", []string{testManyRelsId2}, "", false},
{"manyrels", []any{testManyRelsId2}, "", false},
// with exclude
{"manyrels", []string{testManyRelsId1, testManyRelsId2}, "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", true},
// reverse order
{"manyrels", []string{testManyRelsId2, testManyRelsId1}, "", true},
{"demo2", "", "", nil, false},
{"demo2", "", "", []string{""}, false},
{"demo2", "missing", "unique", nil, false},
{"demo2", "title", "unique", nil, true},
{"demo2", "title", "unique", []string{}, true},
{"demo2", "title", "unique", []string{""}, true},
{"demo2", "title", "test1", []string{""}, false},
{"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
{"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
// mixed json array order
{"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
// username special case-insensitive match
{"users", "username", "test2_username", nil, false},
{"users", "username", "TEST2_USERNAME", nil, false},
{"users", "username", "new_username", nil, true},
{"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
}
for i, scenario := range scenarios {
result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId)
result := app.Dao().IsRecordValueUnique(
scenario.collectionIdOrName,
scenario.key,
scenario.value,
scenario.excludeIds...,
)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
@@ -269,43 +364,164 @@ func TestIsRecordValueUnique(t *testing.T) {
}
}
func TestFindUserRelatedRecords(t *testing.T) {
func TestFindAuthRecordByToken(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
u0 := &models.User{}
u1, _ := app.Dao().FindUserByEmail("test3@example.com")
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
scenarios := []struct {
user *models.User
expectedIds []string
token string
baseKey string
expectedEmail string
expectError bool
}{
{u0, []string{}},
{u1, []string{
"94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2
"fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile
}},
{u2, []string{
"b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile
}},
// invalid auth token
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
app.Settings().RecordAuthToken.Secret,
"",
true,
},
// expired token
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
app.Settings().RecordAuthToken.Secret,
"",
true,
},
// wrong base key (password reset token secret instead of auth secret)
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
app.Settings().RecordPasswordResetToken.Secret,
"",
true,
},
// valid token and base key but with deleted/missing collection
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
app.Settings().RecordAuthToken.Secret,
"test@example.com",
true,
},
// valid token
{
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
app.Settings().RecordAuthToken.Secret,
"test@example.com",
false,
},
}
for i, scenario := range scenarios {
records, err := app.Dao().FindUserRelatedRecords(scenario.user)
if err != nil {
t.Fatal(err)
}
record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
if len(records) != len(scenario.expectedIds) {
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
for _, r := range records {
if !list.ExistInSlice(r.Id, scenario.expectedIds) {
t.Errorf("(%d) Couldn't find %s in %v", i, r.Id, scenario.expectedIds)
}
if !scenario.expectError && record.Email() != scenario.expectedEmail {
t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
}
}
}
func TestFindAuthRecordByEmail(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
email string
expectError bool
}{
{"missing", "test@example.com", true},
{"demo2", "test@example.com", true},
{"users", "missing@example.com", true},
{"users", "test@example.com", false},
{"clients", "test2@example.com", false},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && record.Email() != scenario.email {
t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
}
}
}
func TestFindAuthRecordByUsername(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
username string
expectError bool
}{
{"missing", "test_username", true},
{"demo2", "test_username", true},
{"users", "missing", true},
{"users", "test2_username", false},
{"users", "TEST2_USERNAME", false}, // case insensitive check
{"clients", "clients43362", false},
}
for i, scenario := range scenarios {
record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) {
t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
}
}
}
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
collectionIdOrName string
baseUsername string
expectedPattern string
}{
// missing collection
{"missing", "test2_username", `^test2_username\d{12}$`},
// not an auth collection
{"demo2", "test2_username", `^test2_username\d{12}$`},
// auth collection with unique base username
{"users", "new_username", `^new_username$`},
{"users", "NEW_USERNAME", `^NEW_USERNAME$`},
// auth collection with existing username
{"users", "test2_username", `^test2_username\d{3}$`},
{"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
}
for i, scenario := range scenarios {
username := app.Dao().SuggestUniqueAuthRecordUsername(
scenario.collectionIdOrName,
scenario.baseUsername,
)
pattern, err := regexp.Compile(scenario.expectedPattern)
if err != nil {
t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
}
if !pattern.MatchString(username) {
t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
}
}
}
@@ -314,32 +530,64 @@ func TestSaveRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
// create
// ---
r1 := models.NewRecord(collection)
r1.SetDataValue("title", "test_new")
r1.Set("title", "test_new")
err1 := app.Dao().SaveRecord(r1)
if err1 != nil {
t.Fatal(err1)
}
newR1, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_new")
if newR1 == nil || newR1.Id != r1.Id || newR1.GetStringDataValue("title") != r1.GetStringDataValue("title") {
t.Errorf("Expected to find record %v, got %v", r1, newR1)
newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
t.Fatalf("Expected to find record %v, got %v", r1, newR1)
}
// update
// ---
r2, _ := app.Dao().FindFirstRecordByData(collection, "id", "b5c2ffc2-bafd-48f7-b8b7-090638afe209")
r2.SetDataValue("title", "test_update")
r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
r2.Set("title", "test_update")
err2 := app.Dao().SaveRecord(r2)
if err2 != nil {
t.Fatal(err2)
}
newR2, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_update")
if newR2 == nil || newR2.Id != r2.Id || newR2.GetStringDataValue("title") != r2.GetStringDataValue("title") {
t.Errorf("Expected to find record %v, got %v", r2, newR2)
newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
t.Fatalf("Expected to find record %v, got %v", r2, newR2)
}
}
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
// base collection test
r1 := models.NewRecord(baseCollection)
r1.Set("title", "test_new")
r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
r1.MarkAsNew()
if err := app.Dao().SaveRecord(r1); err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
// auth collection test
r2 := models.NewRecord(authCollection)
r2.Set("username", "test_new")
r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
r2.MarkAsNew()
if err := app.Dao().SaveRecord(r2); err == nil {
t.Fatal("Expected error, got nil")
}
// try again with unique id
r2.Set("id", "unique_id")
if err := app.Dao().SaveRecord(r2); err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
}
@@ -347,41 +595,50 @@ func TestDeleteRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
demo, _ := app.Dao().FindCollectionByNameOrId("demo")
demo2, _ := app.Dao().FindCollectionByNameOrId("demo2")
demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
// delete unsaved record
// ---
rec1 := models.NewRecord(demo)
err1 := app.Dao().DeleteRecord(rec1)
if err1 == nil {
t.Fatal("(rec1) Didn't expect to succeed deleting new record")
rec0 := models.NewRecord(demoCollection)
if err := app.Dao().DeleteRecord(rec0); err == nil {
t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
}
// delete existing record + external auths
// ---
rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
if err := app.Dao().DeleteRecord(rec1); err != nil {
t.Fatalf("(rec1) Expected nil, got error %v", err)
}
// check if it was really deleted
if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
}
// check if the external auths were deleted
if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
}
// delete existing record while being part of a non-cascade required relation
// ---
rec2, _ := app.Dao().FindFirstRecordByData(demo, "id", "848a1dea-5ddd-42d6-a00d-030547bffcfe")
err2 := app.Dao().DeleteRecord(rec2)
if err2 == nil {
rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
if err := app.Dao().DeleteRecord(rec2); err == nil {
t.Fatalf("(rec2) Expected error, got nil")
}
// delete existing record
// delete existing record + cascade
// ---
rec3, _ := app.Dao().FindFirstRecordByData(demo, "id", "577bd676-aacb-4072-b7da-99d00ee210a4")
err3 := app.Dao().DeleteRecord(rec3)
if err3 != nil {
t.Fatalf("(rec3) Expected nil, got error %v", err3)
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
if err := app.Dao().DeleteRecord(rec3); err != nil {
t.Fatalf("(rec3) Expected nil, got error %v", err)
}
// check if it was really deleted
rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil)
rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
if rec3 != nil {
t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
}
// check if the operation cascaded
rel, _ := app.Dao().FindFirstRecordByData(demo2, "id", "63c2ab80-84ab-4057-a592-4604a731f78f")
rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
if rel != nil {
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
}
@@ -391,16 +648,16 @@ func TestSyncRecordTableSchema(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo")
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
updatedCollection.Name = "demo_renamed"
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
updatedCollection.Schema.AddField(
&schema.SchemaField{
Name: "new_field",
@@ -421,6 +678,7 @@ func TestSyncRecordTableSchema(t *testing.T) {
expectedTableName string
expectedColumns []string
}{
// new base collection
{
&models.Collection{
Name: "new_table",
@@ -435,12 +693,32 @@ func TestSyncRecordTableSchema(t *testing.T) {
"new_table",
[]string{"id", "created", "updated", "test"},
},
// new auth collection
{
&models.Collection{
Name: "new_table_auth",
Type: models.CollectionTypeAuth,
Schema: schema.NewSchema(
&schema.SchemaField{
Name: "test",
Type: schema.FieldTypeText,
},
),
},
nil,
"new_table_auth",
[]string{
"id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
},
},
// no changes
{
oldCollection,
oldCollection,
"demo",
[]string{"id", "created", "updated", "title", "file"},
"demo3",
[]string{"id", "created", "updated", "title", "active"},
},
// renamed table, deleted column, renamed columnd and new column
{
+5 -5
View File
@@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
tests.MockRequestLogsData(app)
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
now := time.Now().UTC().Format(types.DefaultDateLayout)
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
@@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
date string
expectedTotal int
}{
{"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time
{"2022-05-01 11:00:00.000", 1}, // only 1 request should have left
{"2022-05-03 11:00:00.000", 0}, // no more requests should have left
{"2022-05-04 11:00:00.000", 0}, // no more requests should have left
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
}
for i, scenario := range scenarios {
-282
View File
@@ -1,282 +0,0 @@
package daos
import (
"database/sql"
"errors"
"fmt"
"log"
"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/security"
)
// UserQuery returns a new User model select query.
func (dao *Dao) UserQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.User{})
}
// LoadProfile loads the profile record associated to the provided user.
func (dao *Dao) LoadProfile(user *models.User) error {
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
if err != nil {
return err
}
profile, err := dao.FindFirstRecordByData(collection, models.ProfileCollectionUserFieldName, user.Id)
if err != nil && err != sql.ErrNoRows {
return err
}
user.Profile = profile
return nil
}
// LoadProfiles loads the profile records associated to the provided users list.
func (dao *Dao) LoadProfiles(users []*models.User) error {
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
if err != nil {
return err
}
// extract user ids
ids := make([]string, len(users))
usersMap := map[string]*models.User{}
for i, user := range users {
ids[i] = user.Id
usersMap[user.Id] = user
}
profiles, err := dao.FindRecordsByExpr(collection, dbx.HashExp{
models.ProfileCollectionUserFieldName: list.ToInterfaceSlice(ids),
})
if err != nil {
return err
}
// populate each user.Profile member
for _, profile := range profiles {
userId := profile.GetStringDataValue(models.ProfileCollectionUserFieldName)
user, ok := usersMap[userId]
if !ok {
continue
}
user.Profile = profile
}
return nil
}
// FindUserById finds a single User model by its id.
//
// This method also auto loads the related user profile record
// into the found model.
func (dao *Dao) FindUserById(id string) (*models.User, error) {
model := &models.User{}
err := dao.UserQuery().
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
// try to load the user profile (if exist)
if err := dao.LoadProfile(model); err != nil {
log.Println(err)
}
return model, nil
}
// FindUserByEmail finds a single User model by its non-empty email address.
//
// This method also auto loads the related user profile record
// into the found model.
func (dao *Dao) FindUserByEmail(email string) (*models.User, error) {
model := &models.User{}
err := dao.UserQuery().
AndWhere(dbx.Not(dbx.HashExp{"email": ""})).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
One(model)
if err != nil {
return nil, err
}
// try to load the user profile (if exist)
if err := dao.LoadProfile(model); err != nil {
log.Println(err)
}
return model, nil
}
// FindUserByToken finds the user associated with the provided JWT token.
// Returns an error if the JWT token is invalid or expired.
//
// This method also auto loads the related user profile record
// into the found model.
func (dao *Dao) FindUserByToken(token string, baseTokenKey string) (*models.User, error) {
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
if err != nil {
return nil, err
}
// check required claims
id, _ := unverifiedClaims["id"].(string)
if id == "" {
return nil, errors.New("Missing or invalid token claims.")
}
user, err := dao.FindUserById(id)
if err != nil || user == nil {
return nil, err
}
verificationKey := user.TokenKey + baseTokenKey
// verify token signature
if _, err := security.ParseJWT(token, verificationKey); err != nil {
return nil, err
}
return user, nil
}
// IsUserEmailUnique checks if the provided email address is not
// already in use by other users.
func (dao *Dao) IsUserEmailUnique(email string, excludeId string) bool {
if email == "" {
return false
}
var exists bool
err := dao.UserQuery().
Select("count(*)").
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
Row(&exists)
return err == nil && !exists
}
// DeleteUser deletes the provided User model.
//
// This method will also cascade the delete operation to all
// Record models that references the provided User model
// (delete or set to NULL, depending on the related user shema field settings).
//
// The delete operation may fail if the user is part of a required
// reference in another Record model (aka. cannot be deleted or set to NULL).
func (dao *Dao) DeleteUser(user *models.User) error {
// fetch related records
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
relatedRecords, err := dao.FindUserRelatedRecords(user)
if err != nil {
return err
}
return dao.RunInTransaction(func(txDao *Dao) error {
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the user related fields (if they are not required)
// -----------------------------------------------------------
recordsLoop:
for _, record := range relatedRecords {
var needSave bool
for _, field := range record.Collection().Schema.Fields() {
if field.Type != schema.FieldTypeUser {
continue // not a user field
}
ids := record.GetStringSliceDataValue(field.Name)
// unset the user id
for i := len(ids) - 1; i >= 0; i-- {
if ids[i] == user.Id {
ids = append(ids[:i], ids[i+1:]...)
break
}
}
options, _ := field.Options.(*schema.UserOptions)
// cascade delete
// (only if there are no other user references in case of multiple select)
if options.CascadeDelete && len(ids) == 0 {
if err := txDao.DeleteRecord(record); err != nil {
return err
}
// no need to further iterate the user fields (the record is deleted)
continue recordsLoop
}
if field.Required && len(ids) == 0 {
return fmt.Errorf("Failed delete the user because a record exist with required user reference to the current model (%q, %q).", record.Id, record.Collection().Name)
}
// apply the reference changes
record.SetDataValue(field.Name, field.PrepareValue(ids))
needSave = true
}
if needSave {
if err := txDao.SaveRecord(record); err != nil {
return err
}
}
}
// -----------------------------------------------------------
return txDao.Delete(user)
})
}
// SaveUser upserts the provided User model.
//
// An empty profile record will be created if the user
// doesn't have a profile record set yet.
func (dao *Dao) SaveUser(user *models.User) error {
profileCollection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
if err != nil {
return err
}
// fetch the related user profile record (if exist)
var userProfile *models.Record
if user.HasId() {
userProfile, _ = dao.FindFirstRecordByData(
profileCollection,
models.ProfileCollectionUserFieldName,
user.Id,
)
}
return dao.RunInTransaction(func(txDao *Dao) error {
if err := txDao.Save(user); err != nil {
return err
}
// create default/empty profile record if doesn't exist
if userProfile == nil {
userProfile = models.NewRecord(profileCollection)
userProfile.SetDataValue(models.ProfileCollectionUserFieldName, user.Id)
if err := txDao.Save(userProfile); err != nil {
return err
}
user.Profile = userProfile
}
return nil
})
}
-275
View File
@@ -1,275 +0,0 @@
package daos_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestUserQuery(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
expected := "SELECT {{_users}}.* FROM `_users`"
sql := app.Dao().UserQuery().Build().SQL()
if sql != expected {
t.Errorf("Expected sql %s, got %s", expected, sql)
}
}
func TestLoadProfile(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// try to load missing profile (shouldn't return an error)
// ---
newUser := &models.User{}
err1 := app.Dao().LoadProfile(newUser)
if err1 != nil {
t.Fatalf("Expected nil, got error %v", err1)
}
// try to load existing profile
// ---
existingUser, _ := app.Dao().FindUserByEmail("test@example.com")
existingUser.Profile = nil // reset
err2 := app.Dao().LoadProfile(existingUser)
if err2 != nil {
t.Fatal(err2)
}
if existingUser.Profile == nil {
t.Fatal("Expected user profile to be loaded, got nil")
}
if existingUser.Profile.GetStringDataValue("name") != "test" {
t.Fatalf("Expected profile.name to be 'test', got %s", existingUser.Profile.GetStringDataValue("name"))
}
}
func TestLoadProfiles(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
u0 := &models.User{}
u1, _ := app.Dao().FindUserByEmail("test@example.com")
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
users := []*models.User{u0, u1, u2}
err := app.Dao().LoadProfiles(users)
if err != nil {
t.Fatal(err)
}
if u0.Profile != nil {
t.Errorf("Expected profile to be nil for u0, got %v", u0.Profile)
}
if u1.Profile == nil {
t.Errorf("Expected profile to be set for u1, got nil")
}
if u2.Profile == nil {
t.Errorf("Expected profile to be set for u2, got nil")
}
}
func TestFindUserById(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
id string
expectError bool
}{
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", false},
}
for i, scenario := range scenarios {
user, err := app.Dao().FindUserById(scenario.id)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if user != nil && user.Id != scenario.id {
t.Errorf("(%d) Expected user with id %s, got %s", i, scenario.id, user.Id)
}
}
}
func TestFindUserByEmail(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
email string
expectError bool
}{
{"", true},
{"invalid", true},
{"missing@example.com", true},
{"test@example.com", false},
}
for i, scenario := range scenarios {
user, err := app.Dao().FindUserByEmail(scenario.email)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && user.Email != scenario.email {
t.Errorf("(%d) Expected user with email %s, got %s", i, scenario.email, user.Email)
}
}
}
func TestFindUserByToken(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
token string
baseKey string
expectedEmail string
expectError bool
}{
// invalid base key (password reset key for auth token)
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
app.Settings().UserPasswordResetToken.Secret,
"",
true,
},
// expired token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.RrSG5NwysI38DEZrIQiz3lUgI6sEuYGTll_jLRbBSiw",
app.Settings().UserAuthToken.Secret,
"",
true,
},
// valid token
{
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
app.Settings().UserAuthToken.Secret,
"test@example.com",
false,
},
}
for i, scenario := range scenarios {
user, err := app.Dao().FindUserByToken(scenario.token, scenario.baseKey)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
continue
}
if !scenario.expectError && user.Email != scenario.expectedEmail {
t.Errorf("(%d) Expected user model %s, got %s", i, scenario.expectedEmail, user.Email)
}
}
}
func TestIsUserEmailUnique(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
email string
excludeId string
expected bool
}{
{"", "", false},
{"test@example.com", "", false},
{"new@example.com", "", true},
{"test@example.com", "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", true},
}
for i, scenario := range scenarios {
result := app.Dao().IsUserEmailUnique(scenario.email, scenario.excludeId)
if result != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
}
}
}
func TestDeleteUser(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// try to delete unsaved user
// ---
err1 := app.Dao().DeleteUser(&models.User{})
if err1 == nil {
t.Fatal("Expected error, got nil")
}
// try to delete existing user
// ---
user, _ := app.Dao().FindUserByEmail("test3@example.com")
err2 := app.Dao().DeleteUser(user)
if err2 != nil {
t.Fatalf("Expected nil, got error %v", err2)
}
// check if the delete operation was cascaded to the profiles collection (record delete)
profilesCol, _ := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
profile, _ := app.Dao().FindRecordById(profilesCol, user.Profile.Id, nil)
if profile != nil {
t.Fatalf("Expected user profile to be deleted, got %v", profile)
}
// check if delete operation was cascaded to the related demo2 collection (null set)
demo2Col, _ := app.Dao().FindCollectionByNameOrId("demo2")
record, _ := app.Dao().FindRecordById(demo2Col, "94568ca2-0bee-49d7-b749-06cb97956fd9", nil)
if record == nil {
t.Fatal("Expected to found related record, got nil")
}
if record.GetStringDataValue("user") != "" {
t.Fatalf("Expected user field to be set to empty string, got %v", record.GetStringDataValue("user"))
}
}
func TestSaveUser(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create
// ---
u1 := &models.User{}
u1.Email = "new@example.com"
u1.SetPassword("123456")
err1 := app.Dao().SaveUser(u1)
if err1 != nil {
t.Fatal(err1)
}
u1, refreshErr1 := app.Dao().FindUserByEmail("new@example.com")
if refreshErr1 != nil {
t.Fatalf("Expected user with email new@example.com to have been created, got error %v", refreshErr1)
}
if u1.Profile == nil {
t.Fatalf("Expected creating a user to create also an empty profile record")
}
// update
// ---
u2, _ := app.Dao().FindUserByEmail("test@example.com")
u2.Email = "test_update@example.com"
err2 := app.Dao().SaveUser(u2)
if err2 != nil {
t.Fatal(err2)
}
u2, refreshErr2 := app.Dao().FindUserByEmail("test_update@example.com")
if u2 == nil {
t.Fatalf("Couldn't find user with email test_update@example.com (%v)", refreshErr2)
}
}