package core_test import ( "encoding/json" "errors" "strings" "testing" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/list" ) func TestExpandRecords(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() scenarios := []struct { testName string collectionIdOrName string recordIds []string expands []string fetchFunc core.ExpandFetchFunc expectNonemptyExpandProps int expectExpandFailures int }{ { "empty records", "", []string{}, []string{"self_rel_one", "self_rel_many.self_rel_one"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 0, }, { "empty expand", "demo4", []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, []string{}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 0, }, { "fetchFunc with error", "demo4", []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, []string{"self_rel_one", "self_rel_many.self_rel_one"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return nil, errors.New("test error") }, 0, 2, }, { "missing relation field", "demo4", []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, []string{"missing"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 1, }, { "existing, but non-relation type field", "demo4", []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, []string{"title"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 1, }, { "invalid/missing second level expand", "demo4", []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, []string{"rel_one_no_cascade.title"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 1, }, { "with nil fetchfunc", "users", []string{ "bgs820n361vj1qd", "4q1xlclmfloku33", "oap640cot4yru2s", // no rels }, []string{"rel"}, nil, 2, 0, }, { "expand normalizations", "demo4", []string{"i9naidtvr6qsgb4", "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 *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 9, 0, }, { "single expand", "users", []string{ "bgs820n361vj1qd", "4q1xlclmfloku33", "oap640cot4yru2s", // no rels }, []string{"rel"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 2, 0, }, { "with nil fetchfunc", "users", []string{ "bgs820n361vj1qd", "4q1xlclmfloku33", "oap640cot4yru2s", // no rels }, []string{"rel"}, nil, 2, 0, }, { "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 *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 6, 0, }, { "simple back single relation field expand (deprecated syntax)", "demo3", []string{"lcl9d87w22ml6jy"}, []string{"demo4(rel_one_no_cascade_required)"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 1, 0, }, { "simple back expand via single relation field", "demo3", []string{"lcl9d87w22ml6jy"}, []string{"demo4_via_rel_one_no_cascade_required"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 1, 0, }, { "nested back expand via single relation field", "demo3", []string{"lcl9d87w22ml6jy"}, []string{ "demo4_via_rel_one_no_cascade_required.self_rel_many.self_rel_many.self_rel_one", }, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 5, 0, }, { "nested back expand via multiple relation field", "demo3", []string{"lcl9d87w22ml6jy"}, []string{ "demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required", }, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 7, 0, }, { "expand multiple relations sharing a common path", "demo4", []string{"qzaqccwrmva4o1n"}, []string{ "rel_one_no_cascade", "rel_many_no_cascade", "self_rel_many.self_rel_one.rel_many_cascade", "self_rel_many.self_rel_one.rel_many_no_cascade_required", }, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 5, 0, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { ids := list.ToUniqueStringSlice(s.recordIds) records, _ := app.FindRecordsByIds(s.collectionIdOrName, ids) failed := app.ExpandRecords(records, s.expands, s.fetchFunc) if len(failed) != s.expectExpandFailures { t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed) } encoded, _ := json.Marshal(records) encodedStr := string(encoded) totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`) totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`) totalNonemptyExpands := totalExpandProps - totalEmptyExpands if s.expectNonemptyExpandProps != totalNonemptyExpands { t.Errorf("Expected %d expand props, got %d\n%v", s.expectNonemptyExpandProps, totalNonemptyExpands, encodedStr) } }) } } func TestExpandRecord(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() scenarios := []struct { testName string collectionIdOrName string recordId string expands []string fetchFunc core.ExpandFetchFunc expectNonemptyExpandProps int expectExpandFailures int }{ { "empty expand", "demo4", "i9naidtvr6qsgb4", []string{}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 0, }, { "fetchFunc with error", "demo4", "i9naidtvr6qsgb4", []string{"self_rel_one", "self_rel_many.self_rel_one"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return nil, errors.New("test error") }, 0, 2, }, { "missing relation field", "demo4", "i9naidtvr6qsgb4", []string{"missing"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 1, }, { "existing, but non-relation type field", "demo4", "i9naidtvr6qsgb4", []string{"title"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 1, }, { "invalid/missing second level expand", "demo4", "qzaqccwrmva4o1n", []string{"rel_one_no_cascade.title"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 0, 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 *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 8, 0, }, { "no rels to expand", "users", "oap640cot4yru2s", []string{"rel"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.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 *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 6, 0, }, { "simple indirect expand via single relation field (deprecated syntax)", "demo3", "lcl9d87w22ml6jy", []string{"demo4(rel_one_no_cascade_required)"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 1, 0, }, { "simple indirect expand via single relation field", "demo3", "lcl9d87w22ml6jy", []string{"demo4_via_rel_one_no_cascade_required"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 1, 0, }, { "nested indirect expand via single relation field", "demo3", "lcl9d87w22ml6jy", []string{ "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one", }, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 5, 0, }, { "nested indirect expand via single relation field", "demo3", "lcl9d87w22ml6jy", []string{ "demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required", }, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }, 7, 0, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { record, _ := app.FindRecordById(s.collectionIdOrName, s.recordId) failed := app.ExpandRecord(record, s.expands, s.fetchFunc) if len(failed) != s.expectExpandFailures { t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed) } encoded, _ := json.Marshal(record) encodedStr := string(encoded) totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`) totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`) totalNonemptyExpands := totalExpandProps - totalEmptyExpands if s.expectNonemptyExpandProps != totalNonemptyExpands { t.Errorf("Expected %d expand props, got %d\n%v", s.expectNonemptyExpandProps, totalNonemptyExpands, encodedStr) } }) } } func TestBackRelationExpandSingeVsArrayResult(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() record, err := app.FindRecordById("demo3", "7nwo8tuiatetxdm") if err != nil { t.Fatal(err) } // non-unique indirect expand { errs := app.ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }) if len(errs) > 0 { t.Fatal(errs) } result, ok := record.Expand()["demo4_via_rel_one_cascade"].([]*core.Record) if !ok { t.Fatalf("Expected the expanded result to be a slice, got %v", result) } } // unique indirect expand { // mock a unique constraint for the rel_one_cascade field // --- demo4, err := app.FindCollectionByNameOrId("demo4") if err != nil { t.Fatal(err) } demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)") if err := app.Save(demo4); err != nil { t.Fatalf("Failed to mock unique constraint: %v", err) } // --- errs := app.ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *core.Collection, ids []string) ([]*core.Record, error) { return app.FindRecordsByIds(c.Id, ids, nil) }) if len(errs) > 0 { t.Fatal(errs) } result, ok := record.Expand()["demo4_via_rel_one_cascade"].(*core.Record) if !ok { t.Fatalf("Expected the expanded result to be a single model, got %v", result) } } }