package core_test import ( "encoding/json" "fmt" "slices" "testing" "github.com/pocketbase/dbx" "github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/tests" ) func ensureNoTempViews(app core.App, t *testing.T) { var total int err := app.DB().Select("count(*)"). From("sqlite_schema"). AndWhere(dbx.HashExp{"type": "view"}). AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)). Limit(1). Row(&total) if err != nil { t.Fatalf("Failed to check for temp views: %v", err) } if total > 0 { t.Fatalf("Expected all temp views to be deleted, got %d", total) } } func TestDeleteView(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() scenarios := []struct { viewName string expectError bool }{ {"", true}, {"demo1", true}, // not a view table {"missing", false}, // missing or already deleted {"view1", false}, // existing {"VieW1", false}, // view names are case insensitives } for i, s := range scenarios { err := app.DeleteView(s.viewName) hasErr := err != nil if hasErr != s.expectError { t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err) } } ensureNoTempViews(app, t) } func TestSaveView(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() scenarios := []struct { scenarioName string viewName string query string expectError bool expectColumns []string }{ { "empty name and query", "", "", true, nil, }, { "empty name", "", "select * from " + core.CollectionNameSuperusers, true, nil, }, { "empty query", "123Test", "", true, nil, }, { "invalid query", "123Test", "123 456", true, nil, }, { "missing table", "123Test", "select id from missing", true, nil, }, { "non select query", "123Test", "drop table " + core.CollectionNameSuperusers, true, nil, }, { "multiple select queries", "123Test", "select *, count(id) as c from " + core.CollectionNameSuperusers + "; select * from demo1;", true, nil, }, { "try to break the parent parenthesis", "123Test", "select *, count(id) as c from `" + core.CollectionNameSuperusers + "`)", true, nil, }, { "simple select query (+ trimmed semicolon)", "123Test", ";select *, count(id) as c from " + core.CollectionNameSuperusers + ";", false, []string{ "id", "created", "updated", "password", "tokenKey", "email", "emailVisibility", "verified", "c", }, }, { "update old view with new query", "123Test", "select 1 as test from " + core.CollectionNameSuperusers, false, []string{"test"}, }, } for _, s := range scenarios { t.Run(s.scenarioName, func(t *testing.T) { err := app.SaveView(s.viewName, s.query) hasErr := err != nil if hasErr != s.expectError { t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) } if hasErr { return } infoRows, err := app.TableInfo(s.viewName) if err != nil { t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err) } if len(s.expectColumns) != len(infoRows) { t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows)) } for _, row := range infoRows { if !slices.Contains(s.expectColumns, row.Name) { t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns) } } }) } ensureNoTempViews(app, t) } func TestCreateViewFieldsWithDiscardedNestedTransaction(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() app.RunInTransaction(func(txApp core.App) error { _, err := txApp.CreateViewFields("select id from missing") if err == nil { t.Fatal("Expected error, got nil") } return nil }) ensureNoTempViews(app, t) } func TestCreateViewFields(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() scenarios := []struct { name string query string expectError bool expectFields map[string]string // name-type pairs }{ { "empty query", "", true, nil, }, { "invalid query", "test 123456", true, nil, }, { "missing table", "select id from missing", true, nil, }, { "query with wildcard column", "select a.id, a.* from demo1 a", true, nil, }, { "wrapped query with wildcard column", "select * from (select 1 as id)", true, nil, }, { "query without id", "select text, url, created, updated from demo1", true, nil, }, { "query with comments", ` select -- test single line demo1.id, demo1.text, /* multi * line comment block */ demo1.url, demo1.created, demo1.updated from/* inline comment block with no spaces between the identifiers */demo1 -- comment before join join demo2 ON ( -- comment inside join demo2.id = demo1.id ) -- comment before where where ( -- comment inside where demo2.id = demo1.id ) `, false, map[string]string{ "id": core.FieldTypeText, "text": core.FieldTypeText, "url": core.FieldTypeURL, "created": core.FieldTypeAutodate, "updated": core.FieldTypeAutodate, }, }, { "query with all fields and quoted identifiers", ` select "id", "created", "updated", [text], ` + "`bool`" + `, "url", "select_one", "select_many", "file_one", "demo1"."file_many", ` + "`demo1`." + "`number`" + ` number_alias, "email", "datetime", "json", "rel_one", "rel_many", 'single_quoted_custom_literal' as 'single_quoted_column' from demo1 `, false, map[string]string{ "id": core.FieldTypeText, "created": core.FieldTypeAutodate, "updated": core.FieldTypeAutodate, "text": core.FieldTypeText, "bool": core.FieldTypeBool, "url": core.FieldTypeURL, "select_one": core.FieldTypeSelect, "select_many": core.FieldTypeSelect, "file_one": core.FieldTypeFile, "file_many": core.FieldTypeFile, "number_alias": core.FieldTypeNumber, "email": core.FieldTypeEmail, "datetime": core.FieldTypeDate, "json": core.FieldTypeJSON, "rel_one": core.FieldTypeRelation, "rel_many": core.FieldTypeRelation, "single_quoted_column": core.FieldTypeJSON, }, }, { "query with indirect relations fields", "select a.id, b.id as bid, b.created from demo1 as a left join demo2 b", false, map[string]string{ "id": core.FieldTypeText, "bid": core.FieldTypeRelation, "created": core.FieldTypeAutodate, }, }, { "query with multiple froms, joins and style of aliases", ` select a.id as id, b.id as bid, lj.id cid, ij.id as did, a.bool, ` + core.CollectionNameSuperusers + `.id as eid, ` + core.CollectionNameSuperusers + `.email from demo1 a, demo2 as b left join demo3 lj on lj.id = 123 inner join demo4 as ij on ij.id = 123 join ` + core.CollectionNameSuperusers + ` where 1=1 group by a.id limit 10 `, false, map[string]string{ "id": core.FieldTypeText, "bid": core.FieldTypeRelation, "cid": core.FieldTypeRelation, "did": core.FieldTypeRelation, "bool": core.FieldTypeBool, "eid": core.FieldTypeRelation, "email": core.FieldTypeEmail, }, }, { "query with casts", `select a.id, count(a.id) count, cast(a.id as int) cast_int, cast(a.id as integer) cast_integer, cast(a.id as real) cast_real, cast(a.id as decimal) cast_decimal, cast(a.id as numeric) cast_numeric, cast(a.id as text) cast_text, cast(a.id as bool) cast_bool, cast(a.id as boolean) cast_boolean, avg(a.id) avg, sum(a.id) sum, total(a.id) total, min(a.id) min, max(a.id) max from demo1 a`, false, map[string]string{ "id": core.FieldTypeText, "count": core.FieldTypeNumber, "total": core.FieldTypeNumber, "cast_int": core.FieldTypeNumber, "cast_integer": core.FieldTypeNumber, "cast_real": core.FieldTypeNumber, "cast_decimal": core.FieldTypeNumber, "cast_numeric": core.FieldTypeNumber, "cast_text": core.FieldTypeText, "cast_bool": core.FieldTypeBool, "cast_boolean": core.FieldTypeBool, // json because they are nullable "sum": core.FieldTypeJSON, "avg": core.FieldTypeJSON, "min": core.FieldTypeJSON, "max": core.FieldTypeJSON, }, }, { "query with multiline cast", `select id, cast( ( case when count(a.id) = 1 then 21 when count(a.id) = 2 then 18 else 0 end ) as int ) as cast_int from demo1 a`, false, map[string]string{ "id": core.FieldTypeText, "cast_int": core.FieldTypeNumber, }, }, { "query with case-insensitive and extra-spaced cast", `select id, CaSt( a.id aS iNt ) as cast_int from demo1 a`, false, map[string]string{ "id": core.FieldTypeText, "cast_int": core.FieldTypeNumber, }, }, { "query with reserved auth collection fields", ` select a.id, a.username, a.email, a.emailVisibility, a.verified, demo1.id relid from users a left join demo1 `, false, map[string]string{ "id": core.FieldTypeText, "username": core.FieldTypeText, "email": core.FieldTypeEmail, "emailVisibility": core.FieldTypeBool, "verified": core.FieldTypeBool, "relid": core.FieldTypeRelation, }, }, { "query with unknown fields and aliases", `select id, id as id2, text as text_alias, url as url_alias, "demo1"."bool" as bool_alias, number as number_alias, created created_alias, updated updated_alias, 123 as custom from demo1`, false, map[string]string{ "id": core.FieldTypeText, "id2": core.FieldTypeRelation, "text_alias": core.FieldTypeText, "url_alias": core.FieldTypeURL, "bool_alias": core.FieldTypeBool, "number_alias": core.FieldTypeNumber, "created_alias": core.FieldTypeAutodate, "updated_alias": core.FieldTypeAutodate, "custom": core.FieldTypeJSON, }, }, { "query with distinct and reordered id column", `select distinct id as id2, id, 123 as custom from demo1`, false, map[string]string{ "id2": core.FieldTypeRelation, "id": core.FieldTypeText, "custom": core.FieldTypeJSON, }, }, { "query with aliasing the same field multiple times", `select a.id as id, a.text as alias1, a.text as alias2, b.text as alias3, b.text as alias4 from demo1 a left join demo1 as b`, false, map[string]string{ "id": core.FieldTypeText, "alias1": core.FieldTypeText, "alias2": core.FieldTypeText, "alias3": core.FieldTypeText, "alias4": core.FieldTypeText, }, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result, err := app.CreateViewFields(s.query) hasErr := err != nil if hasErr != s.expectError { t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) } if hasErr { return } if len(s.expectFields) != len(result) { serialized, _ := json.Marshal(result) t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result), serialized) } for name, typ := range s.expectFields { field := result.GetByName(name) if field == nil { t.Fatalf("Expected to find field %s, got nil", name) } if field.Type() != typ { t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type()) } } }) } ensureNoTempViews(app, t) } func TestCreateViewFieldsWithNumberOnlyInt(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() sql := `select a.id, count(a.id) count, total(a.id) total, cast(a.id as int) cast_int, cast(a.id as integer) cast_integer, cast(a.id as real) cast_real, cast(a.id as decimal) cast_decimal, cast(a.id as numeric) cast_numeric from demo1 a` result, err := app.CreateViewFields(sql) if err != nil { t.Fatal(err) } onlyInts := map[string]bool{ "count": true, "total": false, "cast_int": true, "cast_integer": true, "cast_real": false, "cast_decimal": false, "cast_numeric": false, } totalExpected := len(onlyInts) + 1 if total := len(result); total != totalExpected { t.Fatalf("Expected %d, got %d", totalExpected, total) } for _, f := range result { if f.GetName() == "id" { continue } t.Run(f.GetName(), func(t *testing.T) { nf, ok := f.(*core.NumberField) if !ok { t.Fatalf("Expected *core.NumberField, got %v", f) } if nf.OnlyInt != onlyInts[nf.Name] { t.Fatalf("Expected OnlyInt %v, got %v", onlyInts[nf.Name], nf.OnlyInt) } }) } } func TestFindRecordByViewFile(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() prevCollection, err := app.FindCollectionByNameOrId("demo1") if err != nil { t.Fatal(err) } totalLevels := 6 // create collection view mocks fileOneAlias := "file_one one0" fileManyAlias := "file_many many0" mockCollections := make([]*core.Collection, 0, totalLevels) for i := 0; i <= totalLevels; i++ { view := new(core.Collection) view.Type = core.CollectionTypeView view.Name = fmt.Sprintf("_test_view%d", i) view.ViewQuery = fmt.Sprintf( "select id, %s, %s from %s", fileOneAlias, fileManyAlias, prevCollection.Name, ) // save view if err := app.Save(view); err != nil { t.Fatalf("Failed to save view%d: %v", i, err) } mockCollections = append(mockCollections, view) prevCollection = view fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1) fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1) } fileOneName := "test_d61b33QdDU.txt" fileManyName := "test_QZFjKjXchk.txt" expectedRecordId := "84nmscqy84lsi1t" scenarios := []struct { name string collectionNameOrId string fileFieldName string filename string expectError bool expectRecordId string }{ { "missing collection", "missing", "a", fileOneName, true, "", }, { "non-view collection", "demo1", "file_one", fileOneName, true, "", }, { "view collection after the max recursion limit", mockCollections[totalLevels-1].Name, fmt.Sprintf("one%d", totalLevels-1), fileOneName, true, "", }, { "first view collection (single file)", mockCollections[0].Name, "one0", fileOneName, false, expectedRecordId, }, { "first view collection (many files)", mockCollections[0].Name, "many0", fileManyName, false, expectedRecordId, }, { "last view collection before the recursion limit (single file)", mockCollections[totalLevels-2].Name, fmt.Sprintf("one%d", totalLevels-2), fileOneName, false, expectedRecordId, }, { "last view collection before the recursion limit (many files)", mockCollections[totalLevels-2].Name, fmt.Sprintf("many%d", totalLevels-2), fileManyName, false, expectedRecordId, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { record, err := app.FindRecordByViewFile( s.collectionNameOrId, s.fileFieldName, s.filename, ) hasErr := err != nil if hasErr != s.expectError { t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) } if hasErr { return } if record.Id != s.expectRecordId { t.Fatalf("Expected recordId %q, got %q", s.expectRecordId, record.Id) } }) } } func TestDryRunView(t *testing.T) { t.Parallel() app, _ := tests.NewTestApp() defer app.Cleanup() scenarios := []struct { name string query string sampleSize int expectError bool expectFields map[string]string // name-type pairs expectSampleIds []string // record ids of the resulting sample }{ { "empty query", "", 10, true, nil, nil, }, { "non-select query", "CREATE TABLE t1(x INT)", 10, true, nil, nil, }, { "multiple inline select statements", "select 'a' as id; select 'b' as id", 10, true, nil, nil, }, { "select with invalid formatted field name", "select 'a' as id, count(*)", // missing field alias 10, true, nil, nil, }, { "select resolving to records with missing id", "select id from (select 'a' as id UNION ALL select null as id UNION ALL select 'c' as id)", 10, true, nil, nil, }, { "select resolving to records with duplicated ids", "select id from (select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)", 10, true, nil, nil, }, { "no sample size and valid select query but with invalid records result", "select id from (select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)", 0, false, // still "valid" because there is no sample to check map[string]string{"id": "text"}, nil, }, { "sample size < total select records", "select id from (select 'a' as id UNION ALL select 'b' as id UNION ALL select 'c' as id UNION ALL select 'd' as id)", 3, false, map[string]string{"id": "text"}, []string{"a", "b", "c"}, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { result, err := app.DryRunView(s.query, s.sampleSize) hasErr := err != nil if hasErr != s.expectError { t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) } if hasErr { return } // check fields // --- if len(s.expectFields) != len(result.Fields) { serialized, _ := json.Marshal(result.Fields) t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized) } for name, typ := range s.expectFields { field := result.Fields.GetByName(name) if field == nil { t.Fatalf("Expected to find field %s, got nil", name) } if field.Type() != typ { t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type()) } } // check sample ids // --- if len(s.expectSampleIds) != len(result.Sample) { t.Fatalf("Expected %d sample records, got %d", len(s.expectSampleIds), len(result.Sample)) } for i, r := range result.Sample { if s.expectSampleIds[i] != r.Id { t.Fatalf("Expected sample record id %q, got %q at %d", s.expectSampleIds[i], r.Id, i) } } }) } ensureNoTempViews(app, t) }