merge newui branch
This commit is contained in:
@@ -267,6 +267,15 @@ type App interface {
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
|
||||
|
||||
// DryRunView executes the provided query by creating a temporary view
|
||||
// collection and returning a sample of the resulting query records (if valid).
|
||||
//
|
||||
// The same caveats from CreateViewFields apply here too.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error)
|
||||
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
|
||||
|
||||
|
||||
@@ -817,19 +817,19 @@ func TestCollectionDBExport(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
"unknown",
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeBase,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeView,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeAuth,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1576,60 +1576,62 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
|
||||
viewName := "test_wrapping"
|
||||
|
||||
// note: some of the queries use "limit 0" because the tested field value could be empty
|
||||
// which will trigger the extra sample records validation that are not important for this test
|
||||
scenarios := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"no wrapping - text field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
"no wrapping - id field",
|
||||
"select id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - id field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
"no wrapping - text field",
|
||||
"select text as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"no wrapping - relation field",
|
||||
"select rel_one as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
|
||||
"select rel_one as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"no wrapping - select field",
|
||||
"select select_many as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
|
||||
"select select_many as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"no wrapping - email field",
|
||||
"select email as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
|
||||
"select email as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"no wrapping - datetime field",
|
||||
"select datetime as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
|
||||
"select datetime as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"no wrapping - url field",
|
||||
"select url as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
|
||||
"select url as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field",
|
||||
"select bool as id, text as txt, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
|
||||
"select bool as id, text as txt, url from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1 limit 0))",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field (different order)",
|
||||
"select text as txt, url, bool as id from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1))",
|
||||
"select text as txt, url, bool as id from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1 limit 0))",
|
||||
},
|
||||
{
|
||||
"wrapping - json field",
|
||||
"select json as id, text, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
|
||||
"select json as id, text, url from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1 limit 0))",
|
||||
},
|
||||
{
|
||||
"wrapping - numeric id",
|
||||
|
||||
@@ -41,6 +41,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
|
||||
},
|
||||
expectedErrors: []string{"fields", "viewQuery"},
|
||||
},
|
||||
{
|
||||
name: "view with valid query but empty sample id",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
c := core.NewViewCollection("new_auth")
|
||||
c.ViewQuery = "select '' as id"
|
||||
return c, nil
|
||||
},
|
||||
expectedErrors: []string{"viewQuery"},
|
||||
},
|
||||
{
|
||||
name: "view with valid query but duplicated sample id",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
c := core.NewViewCollection("new_auth")
|
||||
c.ViewQuery = "(select 'a' as id union all select 'a' as id union all select 'c' as id)"
|
||||
return c, nil
|
||||
},
|
||||
expectedErrors: []string{"viewQuery"},
|
||||
},
|
||||
{
|
||||
name: "view with valid query",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
|
||||
@@ -314,11 +314,15 @@ func (cv *collectionValidator) checkViewQuery(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if _, err := cv.app.CreateViewFields(v); err != nil {
|
||||
return validation.NewError(
|
||||
"validation_invalid_view_query",
|
||||
fmt.Sprintf("Invalid query - %s", err.Error()),
|
||||
)
|
||||
_, err := cv.app.DryRunView(v, 10)
|
||||
if err != nil {
|
||||
rawErr := err.Error()
|
||||
if len(rawErr) > 500 {
|
||||
// restrict just as an extra precaution
|
||||
rawErr = rawErr[:500]
|
||||
}
|
||||
|
||||
return validation.NewError("validation_invalid_view_query", "Invalid query - "+rawErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -57,6 +58,9 @@ type baseCollectionEventData struct {
|
||||
Collection *Collection
|
||||
}
|
||||
|
||||
// @todo consider storing the original collection name and use that as a tag
|
||||
// to avoid the ambiguity when the collection is being modified (#7613);
|
||||
// for new collection also maybe return empty tags?
|
||||
func (e *baseCollectionEventData) Tags() []string {
|
||||
if e.Collection == nil {
|
||||
return nil
|
||||
@@ -125,6 +129,21 @@ type ServeEvent struct {
|
||||
//
|
||||
// Set it to nil if you want to skip the installer.
|
||||
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
|
||||
|
||||
// @todo experimental
|
||||
//
|
||||
// UIExtensions is a list with the superuser UI extensions.
|
||||
UIExtensions []UIExtension
|
||||
}
|
||||
|
||||
type UIExtension struct {
|
||||
// Name is the name of the extension.
|
||||
// It is also used as path segment for the registered public extension endpoint
|
||||
// (e.g. /_/extensions/{name}/*)
|
||||
Name string
|
||||
|
||||
// FS is the extension file system.
|
||||
FS fs.FS
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -184,6 +184,26 @@ type RecordInterceptor interface {
|
||||
) error
|
||||
}
|
||||
|
||||
// DefaultFieldHelpValidationRule performs base validation on a field's "help" value.
|
||||
func DefaultFieldHelpValidationRule(value any) error {
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
rules := []validation.Rule{
|
||||
validation.Length(1, 300),
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if err := r.Validate(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultFieldIdValidationRule performs base validation on a field id value.
|
||||
func DefaultFieldIdValidationRule(value any) error {
|
||||
v, ok := value.(string)
|
||||
|
||||
@@ -46,12 +46,12 @@ type AutodateField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
|
||||
// OnCreate auto sets the current datetime as field value on record create.
|
||||
OnCreate bool `form:"onCreate" json:"onCreate"`
|
||||
|
||||
|
||||
@@ -36,11 +36,15 @@ type BoolField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Required will require the field value to be always "true".
|
||||
Required bool `form:"required" json:"required"`
|
||||
@@ -120,5 +124,6 @@ func (f *BoolField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,4 +147,5 @@ func TestBoolFieldValidateValue(t *testing.T) {
|
||||
func TestBoolFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeBool)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeBool)
|
||||
testDefaultFieldHelpValidation[core.BoolField](t)
|
||||
}
|
||||
|
||||
@@ -36,11 +36,15 @@ type DateField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Min specifies the min allowed field value.
|
||||
//
|
||||
@@ -148,6 +152,7 @@ func (f *DateField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.Max, validation.By(f.checkRange(f.Min, f.Max))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestDateFieldValidateValue(t *testing.T) {
|
||||
func TestDateFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeDate)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeDate)
|
||||
testDefaultFieldHelpValidation[core.DateField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -41,11 +41,15 @@ type EditorField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
@@ -148,6 +152,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ func TestEditorFieldValidateValue(t *testing.T) {
|
||||
func TestEditorFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeEditor)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeEditor)
|
||||
testDefaultFieldHelpValidation[core.EditorField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -39,11 +39,15 @@ type EmailField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// ExceptDomains will require the email domain to NOT be included in the listed ones.
|
||||
//
|
||||
@@ -155,6 +159,7 @@ func (f *EmailField) ValidateSettings(ctx context.Context, app App, collection *
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(
|
||||
&f.ExceptDomains,
|
||||
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
|
||||
@@ -182,6 +182,7 @@ func TestEmailFieldValidateValue(t *testing.T) {
|
||||
func TestEmailFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeEmail)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeEmail)
|
||||
testDefaultFieldHelpValidation[core.EmailField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -88,11 +88,15 @@ type FileField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
|
||||
//
|
||||
@@ -223,6 +227,7 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Thumbs, validation.Each(
|
||||
|
||||
@@ -443,6 +443,7 @@ func TestFileFieldValidateValue(t *testing.T) {
|
||||
func TestFileFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeFile)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeFile)
|
||||
testDefaultFieldHelpValidation[core.FileField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -46,11 +46,15 @@ type GeoPointField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
|
||||
Required bool `form:"required" json:"required"`
|
||||
@@ -144,5 +148,6 @@ func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collectio
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,4 +199,5 @@ func TestGeoPointFieldValidateValue(t *testing.T) {
|
||||
func TestGeoPointFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldHelpValidation[core.GeoPointField](t)
|
||||
}
|
||||
|
||||
@@ -45,11 +45,15 @@ type JSONField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
@@ -181,6 +185,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeJSON)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeJSON)
|
||||
testDefaultFieldHelpValidation[core.JSONField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -48,11 +48,15 @@ type NumberField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Min specifies the min allowed field value.
|
||||
//
|
||||
@@ -173,6 +177,7 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.Min, validation.By(f.checkOnlyInt)),
|
||||
validation.Field(&f.Max, maxRules...),
|
||||
)
|
||||
|
||||
@@ -214,6 +214,7 @@ func TestNumberFieldValidateValue(t *testing.T) {
|
||||
func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeNumber)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeNumber)
|
||||
testDefaultFieldHelpValidation[core.NumberField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -61,11 +61,17 @@ type PasswordField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// @todo remove
|
||||
//
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Pattern specifies an optional regex pattern to match against the field value.
|
||||
//
|
||||
@@ -209,6 +215,7 @@ func (f *PasswordField) ValidateSettings(ctx context.Context, app App, collectio
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.Min, validation.Min(1), validation.Max(71)),
|
||||
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)),
|
||||
validation.Field(&f.Cost, validation.Min(bcrypt.MinCost), validation.Max(bcrypt.MaxCost)),
|
||||
|
||||
@@ -287,6 +287,7 @@ func TestPasswordFieldValidateValue(t *testing.T) {
|
||||
func TestPasswordFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypePassword)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypePassword)
|
||||
testDefaultFieldHelpValidation[core.PasswordField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -66,11 +66,15 @@ type RelationField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// CollectionId is the id of the related collection.
|
||||
CollectionId string `form:"collectionId" json:"collectionId"`
|
||||
@@ -237,6 +241,7 @@ func (f *RelationField) ValidateSettings(ctx context.Context, app App, collectio
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.CollectionId, validation.Required, validation.By(f.checkCollectionId(app, collection))),
|
||||
validation.Field(&f.MinSelect, validation.Min(0)),
|
||||
validation.Field(&f.MaxSelect, validation.When(f.MinSelect > 0, validation.Required), validation.Min(f.MinSelect)),
|
||||
|
||||
@@ -348,6 +348,7 @@ func TestRelationFieldValidateValue(t *testing.T) {
|
||||
func TestRelationFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeRelation)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeRelation)
|
||||
testDefaultFieldHelpValidation[core.RelationField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -66,11 +66,15 @@ type SelectField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Values specifies the list of accepted values.
|
||||
Values []string `form:"values" json:"values"`
|
||||
@@ -216,6 +220,7 @@ func (f *SelectField) ValidateSettings(ctx context.Context, app App, collection
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.Values, validation.Required),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(max)),
|
||||
)
|
||||
|
||||
@@ -337,6 +337,7 @@ func TestSelectFieldValidateValue(t *testing.T) {
|
||||
func TestSelectFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeSelect)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeSelect)
|
||||
testDefaultFieldHelpValidation[core.SelectField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -2,6 +2,8 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -113,7 +115,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
|
||||
|
||||
hasErr := errs["id"] != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -254,7 +256,64 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
|
||||
|
||||
hasErr := errs["name"] != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testDefaultFieldHelpValidation[T any](t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := core.NewBaseCollection("test_collection")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
json string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty value",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"< max limit",
|
||||
`{"help":"abc"}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"= max limit",
|
||||
`{"help":"` + strings.Repeat("a", 300) + `"}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"> max limit",
|
||||
`{"help":"` + strings.Repeat("a", 301) + `"}`,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run("[help] "+s.name, func(t *testing.T) {
|
||||
var zeroField T
|
||||
|
||||
field, ok := reflect.New(reflect.TypeOf(zeroField)).Interface().(core.Field)
|
||||
if !ok {
|
||||
t.Fatalf("Expected core.Field instance, got %T", zeroField)
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(s.json), &field)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
errs, _ := field.ValidateSettings(context.Background(), app, collection).(validation.Errors)
|
||||
|
||||
hasErr := errs["help"] != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,11 +72,15 @@ type TextField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Min specifies the minimum required string characters.
|
||||
//
|
||||
@@ -283,6 +287,7 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
validation.By(DefaultFieldNameValidationRule),
|
||||
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
|
||||
),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
|
||||
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
|
||||
|
||||
@@ -381,6 +381,7 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
func TestTextFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeText)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeText)
|
||||
testDefaultFieldHelpValidation[core.TextField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -39,11 +39,15 @@ type URLField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// ExceptDomains will require the URL domain to NOT be included in the listed ones.
|
||||
//
|
||||
@@ -156,6 +160,7 @@ func (f *URLField) ValidateSettings(ctx context.Context, app App, collection *Co
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(
|
||||
&f.ExceptDomains,
|
||||
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
|
||||
@@ -182,6 +182,7 @@ func TestURLFieldValidateValue(t *testing.T) {
|
||||
func TestURLFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeURL)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeURL)
|
||||
testDefaultFieldHelpValidation[core.URLField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -473,13 +473,13 @@ func TestFieldsListScan(t *testing.T) {
|
||||
"only the minimum field options",
|
||||
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
},
|
||||
{
|
||||
"all field options",
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -523,13 +523,13 @@ func TestFieldsListJSON(t *testing.T) {
|
||||
"only the minimum field options",
|
||||
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
},
|
||||
{
|
||||
"all field options",
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ func newDefaultSettings() *Settings {
|
||||
isNew: true,
|
||||
settings: settings{
|
||||
Meta: MetaConfig{
|
||||
AccentColor: "#1055c9",
|
||||
AppName: "Acme",
|
||||
AppURL: "http://localhost:8090",
|
||||
HideControls: false,
|
||||
@@ -513,6 +514,11 @@ func checkCronExpression(value any) error {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MetaConfig struct {
|
||||
// @todo experimental
|
||||
//
|
||||
// AccentColor specify the UI "accent" color (HEX).
|
||||
AccentColor string `form:"accentColor" json:"accentColor"`
|
||||
|
||||
AppName string `form:"appName" json:"appName"`
|
||||
AppURL string `form:"appURL" json:"appURL"`
|
||||
SenderName string `form:"senderName" json:"senderName"`
|
||||
@@ -523,6 +529,7 @@ type MetaConfig struct {
|
||||
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c MetaConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor),
|
||||
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&c.AppURL, validation.Required, is.URL),
|
||||
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
|
||||
valueStr = string(export["value"].([]byte))
|
||||
}
|
||||
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
if valueStr != expected {
|
||||
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
|
||||
}
|
||||
@@ -93,6 +93,8 @@ func TestSettings_DBExport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsMerge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s1 := &core.Settings{}
|
||||
s1.Meta.AppURL = "app_url" // should be unset
|
||||
|
||||
@@ -126,6 +128,8 @@ func TestSettingsMerge(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsClone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s1 := &core.Settings{}
|
||||
s1.Meta.AppName = "test_name"
|
||||
|
||||
@@ -156,6 +160,8 @@ func TestSettingsClone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsMarshalJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
settings := &core.Settings{}
|
||||
|
||||
// control fields
|
||||
@@ -174,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
|
||||
if rawStr != expected {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
|
||||
@@ -230,6 +236,8 @@ func TestSettingsValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMetaConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.MetaConfig
|
||||
@@ -248,12 +256,14 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
{
|
||||
"invalid data",
|
||||
core.MetaConfig{
|
||||
AccentColor: "#fff",
|
||||
AppName: strings.Repeat("a", 300),
|
||||
AppURL: "test",
|
||||
SenderName: strings.Repeat("a", 300),
|
||||
SenderAddress: "invalid_email",
|
||||
},
|
||||
[]string{
|
||||
"accentColor",
|
||||
"appName",
|
||||
"appURL",
|
||||
"senderName",
|
||||
@@ -263,6 +273,7 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
{
|
||||
"valid data",
|
||||
core.MetaConfig{
|
||||
AccentColor: "#ffffff",
|
||||
AppName: "test",
|
||||
AppURL: "https://example.com",
|
||||
SenderName: "test",
|
||||
@@ -282,6 +293,8 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogsConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.LogsConfig
|
||||
@@ -314,6 +327,8 @@ func TestLogsConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSMTPConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.SMTPConfig
|
||||
@@ -373,6 +388,8 @@ func TestSMTPConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestS3ConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.S3Config
|
||||
@@ -444,6 +461,8 @@ func TestS3ConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.BackupsConfig
|
||||
@@ -499,6 +518,8 @@ func TestBackupsConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBatchConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.BatchConfig
|
||||
@@ -554,6 +575,8 @@ func TestBatchConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitsConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.RateLimitsConfig
|
||||
@@ -699,6 +722,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitsFindRateLimitRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := core.RateLimitsConfig{
|
||||
Rules: []core.RateLimitRule{
|
||||
{Label: "abc"},
|
||||
@@ -753,6 +778,8 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitRuleValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
rule core.RateLimitRule
|
||||
@@ -860,6 +887,8 @@ func TestRateLimitRuleValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitRuleDurationTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
rule core.RateLimitRule
|
||||
expected time.Duration
|
||||
@@ -880,6 +909,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitRuleString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
rule core.RateLimitRule
|
||||
|
||||
123
core/view.go
123
core/view.go
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -36,17 +37,14 @@ func (app *BaseApp) DeleteView(dangerousViewName string) error {
|
||||
func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
|
||||
return app.RunInTransaction(func(txApp App) error {
|
||||
// delete old view (if exists)
|
||||
if err := txApp.DeleteView(dangerousViewName); err != nil {
|
||||
err := txApp.DeleteView(dangerousViewName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
|
||||
|
||||
// try to loosely detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(dangerousSelectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
dangerousSelectQuery, err = normalizeViewSelectQuery(dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
@@ -54,7 +52,8 @@ func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery stri
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery)
|
||||
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
_, err = txApp.DB().NewQuery(viewQuery).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -124,6 +123,76 @@ func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, e
|
||||
return result, txErr
|
||||
}
|
||||
|
||||
type DryRunViewResult struct {
|
||||
Fields FieldsList `json:"fields"`
|
||||
Sample []*Record `json:"sample"`
|
||||
}
|
||||
|
||||
// DryRunView executes the provided query by creating a temporary view
|
||||
// collection and returning a sample of the resulting query records (if valid).
|
||||
//
|
||||
// The same caveats from CreateViewFields apply here too.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
func (app *BaseApp) DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error) {
|
||||
dangerousSelectQuery, err := normalizeViewSelectQuery(dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields, err := app.CreateViewFields(dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tempName := "temp_view_" + security.RandomString(5)
|
||||
tempCollection := NewViewCollection(tempName)
|
||||
tempCollection.Fields = fields
|
||||
|
||||
// validate generated view fields
|
||||
ctx := context.Background()
|
||||
for i, f := range fields {
|
||||
err = f.ValidateSettings(ctx, app, tempCollection)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid field %q (%d): %w", f.GetName(), i, err)
|
||||
}
|
||||
}
|
||||
|
||||
records := []*Record{}
|
||||
|
||||
err = app.RecordQuery(tempCollection).
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution
|
||||
From("(SELECT * FROM (" + dangerousSelectQuery + ")) as " + tempName).
|
||||
Limit(int64(sampleSize)).
|
||||
All(&records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve query records: %w", err)
|
||||
}
|
||||
|
||||
// warn for possible empty or duplicated record ids found in the sample
|
||||
// (it is not intended for security and it is here to quickly provide a
|
||||
// helpful error message without doing multiple query executions)
|
||||
ids := make(map[string]struct{}, len(records))
|
||||
for _, r := range records {
|
||||
if r.Id == "" {
|
||||
return nil, errors.New("the query could return records with empty or invalid ids")
|
||||
}
|
||||
|
||||
if _, ok := ids[r.Id]; ok {
|
||||
return nil, errors.New("the query could return records with non-unique ids")
|
||||
}
|
||||
|
||||
ids[r.Id] = struct{}{}
|
||||
}
|
||||
|
||||
return &DryRunViewResult{
|
||||
Fields: fields,
|
||||
Sample: records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
|
||||
view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
|
||||
@@ -198,6 +267,20 @@ func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fi
|
||||
// Raw query to schema helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// loosely normalizes the specified view query and warn against multiple inline statements
|
||||
// (the check is not perfect and it is NOT intended as a security measure; it is done primarily to provide a helpful error message)
|
||||
func normalizeViewSelectQuery(dangerousSelectQuery string) (string, error) {
|
||||
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
|
||||
|
||||
tk := tokenizer.NewFromString(dangerousSelectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return "", errors.New("multiple statements are not supported")
|
||||
}
|
||||
|
||||
return dangerousSelectQuery, nil
|
||||
}
|
||||
|
||||
type queryField struct {
|
||||
// field is the final resolved field.
|
||||
field Field
|
||||
@@ -212,12 +295,26 @@ type queryField struct {
|
||||
}
|
||||
|
||||
func defaultViewField(name string) Field {
|
||||
if name == FieldNameId {
|
||||
return defaultViewIdField()
|
||||
}
|
||||
|
||||
return &JSONField{
|
||||
Name: name,
|
||||
MaxSize: 1, // unused for views
|
||||
}
|
||||
}
|
||||
|
||||
func defaultViewIdField() Field {
|
||||
return &TextField{
|
||||
Name: FieldNameId,
|
||||
System: true,
|
||||
Required: true,
|
||||
PrimaryKey: true,
|
||||
Pattern: `^[a-z0-9]+$`,
|
||||
}
|
||||
}
|
||||
|
||||
var castRegex = regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
|
||||
|
||||
func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
|
||||
@@ -245,13 +342,7 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
|
||||
// pk (always assume text field for now)
|
||||
if col.alias == FieldNameId {
|
||||
result[col.alias] = &queryField{
|
||||
field: &TextField{
|
||||
Name: col.alias,
|
||||
System: true,
|
||||
Required: true,
|
||||
PrimaryKey: true,
|
||||
Pattern: `^[a-z0-9]+$`,
|
||||
},
|
||||
field: defaultViewIdField(),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -732,3 +732,129 @@ func TestFindRecordByViewFile(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 '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 '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 '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 '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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user