diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 4a6cf64..1cb906b 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -1,18 +1,18 @@ -version: v1.0 -name: Go -agent: - machine: - type: e1-standard-2 - os_image: ubuntu2004 -blocks: - - name: Test - task: - jobs: - - name: go test - commands: - - sem-version go 1.18 - - export GOPATH=~/go - - 'export PATH=/home/semaphore/go/bin:$PATH' - - checkout - - go get ./... - - go test ./... +version: v1.0 +name: Go +agent: + machine: + type: e1-standard-2 + os_image: ubuntu2004 +blocks: + - name: Test + task: + jobs: + - name: go test + commands: + - sem-version go 1.18 + - export GOPATH=~/go + - 'export PATH=/home/semaphore/go/bin:$PATH' + - checkout + - go get ./... + - go test ./... diff --git a/README.md b/README.md index f147c14..384472a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Please use the [issue tracker](https://github.com/flosch/pongo2/issues) if you'r - [Advanced C-like expressions](https://github.com/flosch/pongo2/blob/master/template_tests/expressions.tpl). - [Complex function calls within expressions](https://github.com/flosch/pongo2/blob/master/template_tests/function_calls_wrapper.tpl). - [Easy API to create new filters and tags](http://godoc.org/github.com/flosch/pongo2#RegisterFilter) ([including parsing arguments](http://godoc.org/github.com/flosch/pongo2#Parser)) +- [Customizing variable resolution](http://godoc.org/github.com/flosch/pongo2#hdr-Variable_resolution) - Additional features: - Macros including importing macros from other files (see [template_tests/macro.tpl](https://github.com/flosch/pongo2/blob/master/template_tests/macro.tpl)) - [Template sandboxing](https://godoc.org/github.com/flosch/pongo2#TemplateSet) ([directory patterns](http://golang.org/pkg/path/filepath/#Match), banned tags/filters) diff --git a/context.go b/context.go index 525ac3a..a2e5ac6 100644 --- a/context.go +++ b/context.go @@ -26,6 +26,54 @@ func SetAutoescape(newValue bool) { // {{ myfunc("test", 42) }} // {{ user.name }} // {{ pongo2.version }} +// +// Variable resolution +// +// By the default sub variables are resolved by +// 1. Field names inside structs +// 2. Keys of values inside maps +// 3. Indexes of values inside slices +// +// This behavior can be customized using either struct tag "pongo2" like: +// type MyStruct struct { +// FieldA string `pongo2:"uh"` +// FieldB string `pongo2:"yeah"` +// } +// +// my.tmpl: +// {{ myStruct.uh }} {{ myStruct.yeah }} +// +// ...or by implementing NamedFieldResolver or IndexedFieldResolver. +// +// type MyStruct struct { +// fieldA string +// } +// +// // GetNamedField implements NamedFieldResolver +// func (s MyStruct) GetNamedField(s string) (interface{}, error) { +// switch s { +// case "uh": +// return s.fieldA, nil +// case "yeah": +// return "YEAH!", nil +// default: +// return nil, pongo2.ErrNoSuchField +// } +// +// // GetNamedField implements IndexedFieldResolver +// func (s MyStruct) GetIndexedField(s int) (interface{}, error) { +// switch s { +// case 0: +// return s.fieldA, nil +// case 1: +// return "YEAH!", nil +// default: +// return nil, pongo2.ErrNoSuchField +// } +// +// my.tmpl: +// {{ myStruct.uh }} {{ myStruct.yeah }} +// {{ myStruct.0 }} {{ myStruct.1 }} type Context map[string]any func (c Context) checkForValidIdentifiers() *Error { diff --git a/variable.go b/variable.go index 96e047e..07522cb 100644 --- a/variable.go +++ b/variable.go @@ -8,6 +8,30 @@ import ( "strings" ) +var ( + ErrNoSuchField = errors.New("no such field") +) + +// NamedFieldResolver can be implemented by every value inside a Context. +// By default, reflection is used to resolve fields of a struct or a map. +type NamedFieldResolver interface { + // GetNamedField will be called with the requested field name. + // Any error will lead to the immediate interruption of the evaluation; + // except in ErrNoSuchField: In this case the default evaluation process will + // continue (reflection). + GetNamedField(string) (interface{}, error) +} + +// IndexedFieldResolver can be implemented by every value inside a Context. +// By default, reflection is used to resolve index of a slice. +type IndexedFieldResolver interface { + // GetIndexedField will be called with the requested field index. + // Any error will lead to the immediate interruption of the evaluation; + // except in ErrNoSuchField: In this case the default evaluation process will + // continue (reflection). + GetIndexedField(int) (interface{}, error) +} + const ( varTypeInt = iota varTypeIdent @@ -304,76 +328,25 @@ func (vr *variableResolver) resolve(ctx *ExecutionContext) (*Value, error) { } // Look up which part must be called now + var resolver func(*ExecutionContext, reflect.Value, *variablePart) (_ reflect.Value, done bool, _ error) switch part.typ { case varTypeInt: - // Calling an index is only possible for: - // * slices/arrays/strings - switch current.Kind() { - case reflect.String, reflect.Array, reflect.Slice: - if part.i >= 0 && current.Len() > part.i { - current = current.Index(part.i) - } else { - // In Django, exceeding the length of a list is just empty. - return AsValue(nil), nil - } - default: - return nil, fmt.Errorf("can't access an index on type %s (variable %s)", - current.Kind().String(), vr.String()) - } + resolver = vr.resolveIndexedField case varTypeIdent: - // Calling a field or key - switch current.Kind() { - case reflect.Struct: - current = current.FieldByName(part.s) - case reflect.Map: - current = current.MapIndex(reflect.ValueOf(part.s)) - default: - return nil, fmt.Errorf("can't access a field by name on type %s (variable %s)", - current.Kind().String(), vr.String()) - } + resolver = vr.resolveNamedField case varTypeSubscript: - // Calling an index is only possible for: - // * slices/arrays/strings - switch current.Kind() { - case reflect.String, reflect.Array, reflect.Slice: - sv, err := part.subscript.Evaluate(ctx) - if err != nil { - return nil, err - } - si := sv.Integer() - if si >= 0 && current.Len() > si { - current = current.Index(si) - } else { - // In Django, exceeding the length of a list is just empty. - return AsValue(nil), nil - } - // Calling a field or key - case reflect.Struct: - sv, err := part.subscript.Evaluate(ctx) - if err != nil { - return nil, err - } - current = current.FieldByName(sv.String()) - case reflect.Map: - sv, err := part.subscript.Evaluate(ctx) - if err != nil { - return nil, err - } - if sv.IsNil() { - return AsValue(nil), nil - } - if sv.val.Type().AssignableTo(current.Type().Key()) { - current = current.MapIndex(sv.val) - } else { - return AsValue(nil), nil - } - default: - return nil, fmt.Errorf("can't access an index on type %s (variable %s)", - current.Kind().String(), vr.String()) - } + resolver = vr.resolveSubscriptField default: panic("unimplemented") } + + if v, done, err := resolver(ctx, current, part); err != nil { + return nil, err + } else if done { + return &Value{val: v}, nil + } else { + current = v + } } } @@ -524,6 +497,137 @@ func (vr *variableResolver) Evaluate(ctx *ExecutionContext) (*Value, *Error) { return value, nil } +func (vr *variableResolver) resolveNamedField(_ *ExecutionContext, of reflect.Value, by *variablePart) (_ reflect.Value, done bool, _ error) { + // If current does implement NamedFieldResolver, call it with the actual field name. + if fr, ok := of.Interface().(NamedFieldResolver); ok { + if val, err := fr.GetNamedField(by.s); err == ErrNoSuchField { + // Continue with reflection, below... + } else if err != nil { + return reflect.Value{}, false, fmt.Errorf("can't access field %s on type %s (variable %s): %w", + by.s, of.Kind().String(), vr.String(), err) + } else { + return reflect.ValueOf(val), false, nil + } + } + + // Calling a field or key + switch of.Kind() { + case reflect.Struct: + return vr.resolveStructField(of, by.s), false, nil + case reflect.Map: + return of.MapIndex(reflect.ValueOf(by.s)), false, nil + default: + return reflect.Value{}, false, fmt.Errorf("can't access a field by name on type %s (variable %s)", + of.Kind().String(), vr.String()) + } +} + +func (vr *variableResolver) resolveStructField(of reflect.Value, name string) reflect.Value { + t := of.Type() + nf := t.NumField() + var byAliasIndex, byNameIndex []int + for i := 0; i < nf; i++ { + f := t.Field(i) + // Only respect exported field to prevent security issues in templates. + if f.IsExported() { + // We remember this field if its name matches. + if f.Name == name { + byNameIndex = f.Index + } + alias := vr.resolveStructFieldTag(f) + // We remember this field if its alias via tag matches. + if alias == name { + byAliasIndex = f.Index + } + } + } + + if byAliasIndex != nil { + return of.FieldByIndex(byAliasIndex) + } + if byNameIndex != nil { + return of.FieldByIndex(byNameIndex) + } + return reflect.Value{} +} + +func (vr *variableResolver) resolveStructFieldTag(of reflect.StructField) (alias string) { + plain := of.Tag.Get("pongo2") + plainParts := strings.SplitN(plain, ",", 2) + return strings.TrimSpace(plainParts[0]) +} + +func (vr *variableResolver) resolveIndexedField(_ *ExecutionContext, of reflect.Value, by *variablePart) (_ reflect.Value, done bool, _ error) { + // If current does implement IndexedFieldResolver, call it with the actual index. + if fr, ok := of.Interface().(IndexedFieldResolver); ok { + if val, err := fr.GetIndexedField(by.i); err == ErrNoSuchField { + // Continue with reflection, below... + } else if err != nil { + return reflect.Value{}, false, fmt.Errorf("can't access index %d on type %s (variable %s): %w", + by.i, of.Kind().String(), vr.String(), err) + } else { + return reflect.ValueOf(val), false, nil + } + } + + // Calling an index is only possible for: + // * slices/arrays/strings + switch of.Kind() { + case reflect.String, reflect.Array, reflect.Slice: + if by.i >= 0 && of.Len() > by.i { + return of.Index(by.i), false, nil + } else { + // In Django, exceeding the length of a list is just empty. + return reflect.ValueOf(nil), true, nil + } + default: + return reflect.Value{}, false, fmt.Errorf("can't access an index on type %s (variable %s)", + of.Kind().String(), vr.String()) + } +} + +func (vr *variableResolver) resolveSubscriptField(ctx *ExecutionContext, of reflect.Value, by *variablePart) (_ reflect.Value, done bool, _ error) { + // Calling an index is only possible for: + // * slices/arrays/strings + switch of.Kind() { + case reflect.String, reflect.Array, reflect.Slice: + sv, err := by.subscript.Evaluate(ctx) + if err != nil { + return reflect.Value{}, false, err + } + si := sv.Integer() + if si >= 0 && of.Len() > si { + return of.Index(si), false, nil + } else { + // In Django, exceeding the length of a list is just empty. + return reflect.ValueOf(nil), true, nil + } + // Calling a field or key + case reflect.Struct: + sv, err := by.subscript.Evaluate(ctx) + if err != nil { + return reflect.Value{}, false, err + } + return of.FieldByName(sv.String()), false, nil + case reflect.Map: + sv, err := by.subscript.Evaluate(ctx) + if err != nil { + return reflect.Value{}, false, err + } + if sv.IsNil() { + return reflect.ValueOf(nil), true, nil + } + if sv.val.Type().AssignableTo(of.Type().Key()) { + return of.MapIndex(sv.val), false, nil + } else { + return reflect.ValueOf(nil), true, nil + } + default: + return reflect.Value{}, false, fmt.Errorf("can't access an index on type %s (variable %s)", + of.Kind().String(), vr.String()) + } +} + func (v *nodeFilteredVariable) FilterApplied(name string) bool { for _, filter := range v.filterChain { if filter.name == name { diff --git a/variable_test.go b/variable_test.go new file mode 100644 index 0000000..ab6b3b6 --- /dev/null +++ b/variable_test.go @@ -0,0 +1,210 @@ +package pongo2_test + +import ( + "errors" + "testing" + + "github.com/flosch/pongo2/v6" +) + +func TestVariables_Named(t *testing.T) { + tests := map[string]struct { + template string + contextObject interface{} + want string + wantErr string + }{ + "Named_ByReflection": { + template: "[{{ obj.Foo }}]", + contextObject: testVariablesStructSimple{Foo: "someFoo"}, + want: "[someFoo]", + }, + "Named_ByReflectionMethod": { + template: "[{{ obj.GetBar }}]", + contextObject: testVariablesStructSimple{hiddenBar: "someBar"}, + want: "[someBar]", + }, + "Named_ByReflectionFunc": { + template: "[{{ obj.SomeFuncVar }}]", + contextObject: testVariablesStructSimple{SomeFuncVar: func() string { return "fromFunc" }}, + want: "[fromFunc]", + }, + "Named_ByReflectionNotExported": { + template: "[{{ obj.hiddenBar }}]", + contextObject: testVariablesStructSimple{hiddenBar: "someBar"}, + want: "[]", + }, + "Named_ByReflectionMethodNotExported": { + template: "[{{ obj.hiddenGetBar }}]", + contextObject: testVariablesStructSimple{hiddenBar: "someBar"}, + want: "[]", + }, + "Named_ByNamed": { + template: "[{{ obj.foo }}]", + contextObject: testVariablesStructNamed{hiddenFoo: "someFoo"}, + want: "[someFoo]", + }, + "Named_ByNamedFallback": { + template: "[{{ obj.Bar }}]", + contextObject: testVariablesStructNamed{Bar: "someBar"}, + want: "[someBar]", + }, + "Named_ByNamedFailing": { + template: "[{{ obj.explode }}]", + contextObject: testVariablesStructNamed{}, + wantErr: "[Error (where: execution) in | Line 1 Col 5 near 'obj'] can't access field explode on type struct (variable obj.explode): expected", + }, + "Named_ByNamedFunc": { + template: "[{{ obj.func }}]", + contextObject: testVariablesStructNamed{}, + want: "[fromFunc]", + }, + "Named_ByNamedAliased": { + template: "[{{ obj.aliased }}]", + contextObject: testVariablesStructNamed{Aliased1: "expected"}, + want: "[expected]", + }, + "Named_ByNamedAliasedConflicting": { + template: "[{{ obj.AliasedConflicting }}]", + contextObject: testVariablesStructNamed{Aliased2: "expected", AliasedConflicting: "not expected, because overwritten by Aliased2"}, + want: "[expected]", + }, + "Indexed_ByReflection": { + template: "[{{ obj.1 }}]", + contextObject: []string{"a", "b", "c"}, + want: "[b]", + }, + "Indexed_ByReflectionFunc": { + template: "[{{ obj.0 }}]", + contextObject: []interface{}{func() string { return "fromFunc" }}, + want: "[fromFunc]", + }, + "Indexed_ByIndexedOnSlice": { + template: "[{{ obj.1 }}]", + contextObject: testVariablesSliceIndexed{"a", "b", "c"}, + want: "[theField]", + }, + "Indexed_ByIndexedFallbackOnSlice": { + template: "[{{ obj.0 }}]", + contextObject: testVariablesSliceIndexed{"a", "b", "c"}, + want: "[a]", + }, + "Indexed_ByIndexedFailingOnSlice": { + template: "[{{ obj.2 }}]", + contextObject: testVariablesSliceIndexed{"a", "b", "c"}, + wantErr: "[Error (where: execution) in | Line 1 Col 5 near 'obj'] can't access index 2 on type slice (variable obj.2): expected", + }, + "Indexed_ByIndexedOnStruct": { + template: "[{{ obj.1 }}]", + contextObject: testVariablesStructIndexed{hiddenFoo: "someFoo"}, + want: "[someFoo]", + }, + "Indexed_ByIndexedFallbackDoesNotWorkOnStruct": { + template: "[{{ obj.0 }}]", + contextObject: testVariablesStructIndexed{hiddenFoo: "someFoo"}, + wantErr: "[Error (where: execution) in | Line 1 Col 5 near 'obj'] can't access an index on type struct (variable obj.0)", + }, + "Indexed_ByIndexedFailingOnStruct": { + template: "[{{ obj.2 }}]", + contextObject: testVariablesStructIndexed{hiddenFoo: "someFoo"}, + wantErr: "[Error (where: execution) in | Line 1 Col 5 near 'obj'] can't access index 2 on type struct (variable obj.2): expected", + }, + "Indexed_ByIndexedFunc": { + template: "[{{ obj.3 }}]", + contextObject: testVariablesStructIndexed{}, + want: "[fromFunc]", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tpl, _ := pongo2.FromString(tt.template) + got, err := tpl.Execute(map[string]interface{}{ + "obj": tt.contextObject, + }) + if err != nil { + if err.Error() != tt.wantErr { + t.Errorf("Template.Execute() error = %v, expected error: %v", err, tt.wantErr) + return + } + } + if got != tt.want { + t.Errorf("Template.Execute() = %v, want %v", got, tt.want) + } + }) + } +} + +type testVariablesStructSimple struct { + Foo string + hiddenBar string + SomeFuncVar func() string +} + +func (tv testVariablesStructSimple) GetBar() string { + return tv.hiddenBar +} + +func (tv testVariablesStructSimple) hiddenGetBar() string { + return tv.hiddenBar +} + +type testVariablesStructNamed struct { + hiddenFoo string + Bar string + + Aliased1 string `pongo2:"aliased"` + Aliased2 string `pongo2:"AliasedConflicting"` + AliasedConflicting string +} + +func (tv testVariablesStructNamed) GetNamedField(s string) (interface{}, error) { + switch s { + case "foo": + return tv.hiddenFoo, nil + case "explode": + return nil, errTestExpected + case "func": + return func() string { + return "fromFunc" + }, nil + default: + return nil, pongo2.ErrNoSuchField + } +} + +type testVariablesStructIndexed struct { + hiddenFoo string +} + +func (tv testVariablesStructIndexed) GetIndexedField(s int) (interface{}, error) { + switch s { + case 1: + return tv.hiddenFoo, nil + case 2: + return nil, errTestExpected + case 3: + return func() string { + return "fromFunc" + }, nil + default: + return nil, pongo2.ErrNoSuchField + } +} + +type testVariablesSliceIndexed []string + +func (tv testVariablesSliceIndexed) GetIndexedField(s int) (interface{}, error) { + switch s { + case 1: + return "theField", nil + case 2: + return nil, errTestExpected + default: + return nil, pongo2.ErrNoSuchField + } +} + +var ( + errTestExpected = errors.New("expected") +)