Files
pocketbase/core/view_test.go

867 lines
19 KiB
Go

package core_test
import (
"encoding/json"
"fmt"
"slices"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/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)
}