diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e105484..0ac404b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,8 +55,8 @@ new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config) ``` -- (@todo docs) Added new optional JavaScript app hooks binding via [goja](https://github.com/dop251/goja). - There are available by default with the prebuilt executable if you add a `*.pb.js` file in `pb_hooks` directory. +- (@todo docs) Added new experimental JavaScript app hooks binding via [goja](https://github.com/dop251/goja). + They are available by default with the prebuilt executable if you add a `*.pb.js` file in `pb_hooks` directory. To enable them as part of a custom Go build: ```go jsvm.MustRegisterHooks(app core.App, config jsvm.HooksConfig{}) @@ -64,6 +64,8 @@ - Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`. +- Added `types.JsonMap.Get(k)` and `types.JsonMap.Set(k, v)` helpers for the cases where the type aliased direct map access is not allowed (eg. in [goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods)). + ## v0.16.6-WIP diff --git a/plugins/jsvm/vm.go b/plugins/jsvm/vm.go index 2064944e..52feb797 100644 --- a/plugins/jsvm/vm.go +++ b/plugins/jsvm/vm.go @@ -22,6 +22,7 @@ import ( "path/filepath" "reflect" "regexp" + "strings" "github.com/dop251/goja" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -36,6 +37,7 @@ import ( "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/types" ) func baseBinds(vm *goja.Runtime) { @@ -43,24 +45,24 @@ func baseBinds(vm *goja.Runtime) { // override primitive class constructors to return pointers // (this is useful when unmarshaling or scaning a db result) - vm.Set("_numberPointer", func(arg float64) *float64 { + vm.Set("__numberPointer", func(arg float64) *float64 { return &arg }) - vm.Set("_stringPointer", func(arg string) *string { + vm.Set("__stringPointer", func(arg string) *string { return &arg }) - vm.Set("_boolPointer", func(arg bool) *bool { + vm.Set("__boolPointer", func(arg bool) *bool { return &arg }) vm.RunString(` this.Number = function(arg) { - return _numberPointer(arg) + return __numberPointer(arg) } this.String = function(arg) { - return _stringPointer(arg) + return __stringPointer(arg) } this.Boolean = function(arg) { - return _boolPointer(arg) + return __boolPointer(arg) } `) @@ -77,6 +79,50 @@ func baseBinds(vm *goja.Runtime) { return dest, nil }) + // temporary helper to properly return the length of an array + // see https://github.com/dop251/goja/issues/521 + vm.Set("len", func(val any) int { + rv := reflect.ValueOf(val) + rk := rv.Kind() + + if rk == reflect.Ptr { + rv = rv.Elem() + rk = rv.Kind() + } + + if rk == reflect.Slice || rk == reflect.Array { + return rv.Len() + } + + return 0 + }) + + vm.Set("DynamicModel", func(call goja.ConstructorCall) *goja.Object { + shape, ok := call.Argument(0).Export().(map[string]any) + if !ok || len(shape) == 0 { + panic("missing shape data") + } + + instance := newDynamicModel(shape) + instanceValue := vm.ToValue(instance).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue + }) + + vm.Set("DynamicList", func(call goja.ConstructorCall) *goja.Object { + shape, ok := call.Argument(0).Export().(map[string]any) + if !ok || len(shape) == 0 { + panic("missing shape data") + } + + instance := newDynamicList(shape) + instanceValue := vm.ToValue(instance).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue + }) + vm.Set("Record", func(call goja.ConstructorCall) *goja.Object { var instance *models.Record @@ -364,3 +410,71 @@ func filesContent(dirPath string, pattern string) (map[string][]byte, error) { return result, nil } + +// newDynamicList creates a new dynamic slice of structs with fields based +// on the specified "shape". +// +// Example: +// +// m := newDynamicList(map[string]any{ +// "title": "", +// "total": 0, +// }) +func newDynamicList(shape map[string]any) any { + m := newDynamicModel(shape) + mt := reflect.TypeOf(m) + st := reflect.SliceOf(mt) + elem := reflect.New(st).Elem() + + return elem.Addr().Interface() +} + +// newDynamicModel creates a new dynamic struct with fields based +// on the specified "shape". +// +// Example: +// +// m := newDynamicModel(map[string]any{ +// "title": "", +// "total": 0, +// }) +func newDynamicModel(shape map[string]any) any { + shapeValues := make([]reflect.Value, 0, len(shape)) + structFields := make([]reflect.StructField, 0, len(shape)) + + for k, v := range shape { + vt := reflect.TypeOf(v) + + switch kind := vt.Kind(); kind { + case reflect.Map: + raw, _ := json.Marshal(v) + newV := types.JsonMap{} + newV.Scan(raw) + v = newV + vt = reflect.TypeOf(v) + case reflect.Slice, reflect.Array: + raw, _ := json.Marshal(v) + newV := types.JsonArray[any]{} + newV.Scan(raw) + v = newV + vt = reflect.TypeOf(newV) + } + + shapeValues = append(shapeValues, reflect.ValueOf(v)) + + structFields = append(structFields, reflect.StructField{ + Name: strings.ToUpper(k), // ensures that the field is exportable + Type: vt, + Tag: reflect.StructTag(`db:"` + k + `" json:"` + k + `"`), + }) + } + + st := reflect.StructOf(structFields) + elem := reflect.New(st).Elem() + + for i, v := range shapeValues { + elem.Field(i).Set(v) + } + + return elem.Addr().Interface() +} diff --git a/plugins/jsvm/vm_test.go b/plugins/jsvm/vm_test.go index 90749cdf..c9f3bd20 100644 --- a/plugins/jsvm/vm_test.go +++ b/plugins/jsvm/vm_test.go @@ -24,7 +24,7 @@ func TestBaseBindsCount(t *testing.T) { vm := goja.New() baseBinds(vm) - testBindsCount(vm, "this", 12, t) + testBindsCount(vm, "this", 15, t) } func TestBaseBindsUnmarshal(t *testing.T) { @@ -626,3 +626,105 @@ func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T) t.Fatalf("Expected %d %s binds, got %d", count, namespace, total) } } + +func TestLoadingDynamicModel(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + dbxBinds(vm) + vm.Set("$app", app) + + _, err := vm.RunString(` + let result = new DynamicModel({ + text: "", + bool: false, + number: 0, + select_many: [], + json: [], + // custom map-like field + obj: {}, + }) + + $app.dao().db() + .select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj") + .from("demo1") + .where($dbx.hashExp({"id": "84nmscqy84lsi1t"})) + .limit(1) + .one(result) + + if (result.text != "test") { + throw new Error('Expected text "test", got ' + result.text); + } + + if (result.bool != true) { + throw new Error('Expected bool true, got ' + result.bool); + } + + if (result.number != 123456) { + throw new Error('Expected number 123456, got ' + result.number); + } + + if (result.select_many.length != 2 || result.select_many[0] != "optionB" || result.select_many[1] != "optionC") { + throw new Error('Expected select_many ["optionB", "optionC"], got ' + result.select_many); + } + + if (result.json.length != 3 || result.json[0] != 1 || result.json[1] != 2 || result.json[2] != 3) { + throw new Error('Expected json [1, 2, 3], got ' + result.json); + } + + if (result.obj.get("test") != 1) { + throw new Error('Expected obj.get("test") 1, got ' + JSON.stringify(result.obj)); + } + `) + if err != nil { + t.Fatal(err) + } +} + +func TestLoadingDynamicList(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + dbxBinds(vm) + vm.Set("$app", app) + + _, err := vm.RunString(` + let result = new DynamicList({ + id: "", + text: "", + }) + + $app.dao().db() + .select("id", "text") + .from("demo1") + .where($dbx.exp("id='84nmscqy84lsi1t' OR id='al1h9ijdeojtsjy'")) + .limit(2) + .orderBy("text ASC") + .all(result) + + if (len(result) != 2) { + throw new Error('Expected 2 list items, got ' + result.length); + } + + if (result[0].id != "84nmscqy84lsi1t") { + throw new Error('Expected 0.id "84nmscqy84lsi1t", got ' + result[0].id); + } + if (result[0].text != "test") { + throw new Error('Expected 0.text "test", got ' + result[0].text); + } + + if (result[1].id != "al1h9ijdeojtsjy") { + throw new Error('Expected 1.id "al1h9ijdeojtsjy", got ' + result[1].id); + } + if (result[1].text != "test2") { + throw new Error('Expected 1.text "test2", got ' + result[1].text); + } + `) + if err != nil { + t.Fatal(err) + } +}