[#7396] added nullable JSVM type helpers

This commit is contained in:
Gani Georgiev
2025-12-17 19:27:17 +02:00
parent e7af58efac
commit 27cb36ffd7
35 changed files with 3223 additions and 3034 deletions

View File

@@ -431,6 +431,32 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue
})
// nullable helpers usually used as DynamicModel shape values
vm.Set("nullString", func() *string {
var v string
return &v
})
vm.Set("nullFloat", func() *float64 {
var v float64
return &v
})
vm.Set("nullInt", func() *int64 {
var v int64
return &v
})
vm.Set("nullBool", func() *bool {
var v bool
return &v
})
vm.Set("nullArray", func() *types.JSONArray[any] {
var v types.JSONArray[any]
return &v
})
vm.Set("nullObject", func() *types.JSONMap[any] {
var v types.JSONMap[any]
return &v
})
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
var instance *core.Record
@@ -1100,12 +1126,19 @@ var cachedDynamicModelStructs = store.New[string, reflect.Type](nil)
// on the specified "shape".
//
// The "shape" values are used as defaults and could be of type:
// - int (ex. 0)
// - float (ex. -0)
// - string (ex. "")
// - bool (ex. false)
// - slice (ex. [])
// - map (ex. map[string]any{})
//
// - int64 (ex.: 0)
// - *int64 (ex.: nullInt())
// - float64 (ex.: -0)
// - *float64 (ex.: nullFloat())
// - string (ex.: "")
// - *string (ex.: nullString())
// - bool (ex.: false)
// - *bool (ex.: nullBool())
// - slice/arr (ex.: [])
// - *slice/arr (ex.: nullArray())
// - map (ex.: {})
// - *map (ex.: nullObject())
//
// Example:
//
@@ -1141,6 +1174,9 @@ func newDynamicModel(shape map[string]any) any {
newV.Scan(raw)
v = newV
vt = reflect.TypeOf(newV)
case reflect.Pointer:
// for pointers always fallback to nil as their default value
v = nil
}
hash.WriteString(k)
@@ -1169,6 +1205,9 @@ func newDynamicModel(shape map[string]any) any {
// load default values into the new model
for i, item := range info {
if item.value == nil {
continue
}
elem.Field(i).Set(reflect.ValueOf(item.value))
}

View File

@@ -46,7 +46,7 @@ func TestBaseBindsCount(t *testing.T) {
vm := goja.New()
baseBinds(vm)
testBindsCount(vm, "this", 35, t)
testBindsCount(vm, "this", 41, t)
}
func TestBaseBindsSleep(t *testing.T) {
@@ -1162,44 +1162,89 @@ func TestLoadingDynamicModel(t *testing.T) {
_, err := vm.RunString(`
let result = new DynamicModel({
text: "",
bool: false,
number: 0,
select_many: [],
json: [],
// custom map-like field
obj: {},
string: "",
nullString: nullString(),
nullStringEmpty: nullString(),
bool: false,
nullBool: nullBool(),
nullBoolEmpty: nullBool(),
int: 0,
nullInt: nullInt(),
nullIntEmpty: nullInt(),
float: -0,
nullFloat: nullFloat(),
nullFloatEmpty: nullFloat(),
array: [],
nullArray: nullArray(),
nullArrayEmpty: nullArray(),
object: {},
nullObject: nullObject(),
nullObjectEmpty: nullObject(),
})
const expectations = {
"string": "a",
"nullString": "b",
"nullStringEmpty": null,
"bool": false,
"nullBool": true,
"nullBoolEmpty": null,
"int": 1,
"nullInt": 2,
"nullIntEmpty": null,
"float": 1.1,
"nullFloat": 1.2,
"nullFloatEmpty": null,
"array": [1,2],
"nullArray": [3,4],
"nullArrayEmpty": null,
"object": {a:1},
"nullObject": {a:2},
"nullObjectEmpty": null,
};
// constuct dummy SELECT column value literals based on the expectations
const selectColumns = [];
for (const col in expectations) {
const val = expectations[col]
if (val === null) {
selectColumns.push("null as [[" + col + "]]")
} else if (typeof val === "string") {
selectColumns.push("'" + val + "' as [[" + col + "]]")
} else if (typeof val === "object") {
selectColumns.push("'" + JSON.stringify(val) + "' as [[" + col + "]]")
} else {
selectColumns.push(val + " as [[" + col + "]]")
}
}
$app.db()
.select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj")
.from("demo1")
.where($dbx.hashExp({"id": "84nmscqy84lsi1t"}))
.limit(1)
.newQuery("SELECT " + selectColumns.join(", "))
.one(result)
if (result.text != "test") {
throw new Error('Expected text "test", got ' + result.text);
}
for (const col in expectations) {
let expVal = expectations[col];
let resVal = result[col];
if (result.bool != true) {
throw new Error('Expected bool true, got ' + result.bool);
}
if (expVal !== null && typeof expVal === "object") {
expVal = JSON.stringify(expVal)
resVal = JSON.stringify(resVal)
}
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 (expVal != resVal) {
throw new Error("Expected '" + col + "' value " + expVal + ", got " + resVal);
}
}
`)
if err != nil {

File diff suppressed because it is too large Load Diff

View File

@@ -259,27 +259,78 @@ declare function arrayOf<T>(model: T): Array<T>;
*
* Caveats:
* - In order to use 0 as double/float initialization number you have to negate it (` + "`-0`" + `).
* - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + `).
* - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + ` even if in the model shape and in the DB table the column is capitalized).
* - Objects are loaded into types.JSONMap, meaning that they need to be accessed with ` + "`get(key)`" + ` (e.g. ` + "`model.meta.get('something')`" + `).
* - For describing nullable types you can use the ` + "`null*()`" + ` helpers - ` + "`nullString()`" + `, ` + "`nullInt()`" + `, ` + "`nullFloat()`" + `, ` + "`nullBool()`" + `, ` + "`nullArray()`" + `, ` + "`nullObject()`" + `.
*
* Example:
*
* ` + "```" + `js
* const model = new DynamicModel({
* name: ""
* age: 0, // int64
* totalSpent: -0, // float64
* active: false,
* Roles: [], // maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
* meta: {}
* name: "" // or nullString() if nullable
* age: 0, // or nullInt() if nullable
* totalSpent: -0, // or nullFloat() if nullable
* active: false, // or nullBool() if nullable
* Roles: [], // or nullArray() if nullable; maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
* meta: {}, // or nullObject() if nullable
* })
* ` + "```" + `
*
* @group PocketBase
*/
declare class DynamicModel {
[key: string]: any;
constructor(shape?: { [key:string]: any })
}
/**
* nullString creates an empty Go string pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` string value.
*
* @group PocketBase
*/
declare function nullString(): string;
/**
* nullInt creates an empty Go int64 pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` int value.
*
* @group PocketBase
*/
declare function nullInt(): number;
/**
* nullFloat creates an empty Go float64 pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` float value.
*
* @group PocketBase
*/
declare function nullFloat(): number;
/**
* nullBool creates an empty Go bool pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` bool value.
*
* @group PocketBase
*/
declare function nullBool(): boolean;
/**
* nullArray creates an empty Go types.JSONArray pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` JSON array value.
*
* @group PocketBase
*/
declare function nullArray(): Array<any>;
/**
* nullObject creates an empty Go types.JSONMap pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` JSON object value.
*
* @group PocketBase
*/
declare function nullObject(): { get(key:string):any; set(key:string,value:any):void };
interface Context extends context.Context{} // merge
/**
* Context creates a new empty Go context.Context.