diff --git a/options.go b/options.go index b2a205c..fddebc6 100644 --- a/options.go +++ b/options.go @@ -10,12 +10,20 @@ type Options struct { // If this is set to true leading spaces and tabs are stripped from the // start of a line to a block. Defaults to false LStripBlocks bool + + // If this is set to true the "+" operator treats a numeric string and a + // number as numbers and performs arithmetic addition (e.g. "10" + 5 == 15), + // restoring pongo2 v4 behavior. Defaults to false, in which case "+" + // performs string concatenation whenever either operand is a string (the + // v5/v6 behavior, e.g. "10" + 5 == "105"). See issue #342. + NumericStringArithmetic bool } func newOptions() *Options { return &Options{ - TrimBlocks: false, - LStripBlocks: false, + TrimBlocks: false, + LStripBlocks: false, + NumericStringArithmetic: false, } } @@ -23,6 +31,7 @@ func newOptions() *Options { func (opt *Options) Update(other *Options) *Options { opt.TrimBlocks = other.TrimBlocks opt.LStripBlocks = other.LStripBlocks + opt.NumericStringArithmetic = other.NumericStringArithmetic return opt } diff --git a/parser_expression.go b/parser_expression.go index 4891fac..697b2ec 100644 --- a/parser_expression.go +++ b/parser_expression.go @@ -3,6 +3,7 @@ package pongo2 import ( "fmt" "math" + "strings" ) type Expression struct { @@ -251,6 +252,19 @@ func (expr *simpleExpression) Evaluate(ctx *ExecutionContext) (*Value, error) { } switch expr.opToken.Val { case "+": + // If one operand is a number and the other is a numeric string, + // treat both as numbers and do arithmetic (fixes issue #342). + // This is opt-in (restores pongo2 v4 behavior); when disabled the + // default v5/v6 string-concatenation behavior below is used. + if ctx.template.Options.NumericStringArithmetic && + ((result.IsNumber() && t2.CanBeNumber()) || (t2.IsNumber() && result.CanBeNumber())) { + if result.IsFloat() || t2.IsFloat() || (result.IsString() && strings.Contains(result.String(), ".")) || (t2.IsString() && strings.Contains(t2.String(), ".")) { + // Result will be a float + return AsValue(result.Float() + t2.Float()), nil + } + // Result will be an integer + return AsValue(result.Integer() + t2.Integer()), nil + } if result.IsString() || t2.IsString() { // Result will be a string return AsValue(result.String() + t2.String()), nil diff --git a/pongo2_issues_test.go b/pongo2_issues_test.go index b914288..0a01ab9 100644 --- a/pongo2_issues_test.go +++ b/pongo2_issues_test.go @@ -416,6 +416,128 @@ func TestIssue209(t *testing.T) { } } +func TestIssue342(t *testing.T) { + // Test that, with the opt-in NumericStringArithmetic option enabled, adding + // a numeric string and a number results in arithmetic addition. + // Bug: In v6, "10" + 5 returns "105" (string concatenation). + // With the option on, "10" + 5 returns 15 (arithmetic addition) as in v4. + // The option is off by default; see TestIssue342DefaultIsConcatenation. + // See: https://github.com/flosch/pongo2/issues/342 + + tests := []struct { + name string + template string + context pongo2.Context + expected string + }{ + { + name: "numeric string + integer", + template: "{{ a + b }}", + context: pongo2.Context{"a": "10", "b": 5}, + expected: "15", + }, + { + name: "integer + numeric string", + template: "{{ a + b }}", + context: pongo2.Context{"a": 5, "b": "10"}, + expected: "15", + }, + { + name: "numeric string + float", + template: "{{ a + b }}", + context: pongo2.Context{"a": "10.5", "b": 2.5}, + expected: "13.000000", + }, + { + name: "float + numeric string", + template: "{{ a + b }}", + context: pongo2.Context{"a": 2.5, "b": "10.5"}, + expected: "13.000000", + }, + { + name: "non-numeric string + integer stays string concatenation", + template: "{{ a + b }}", + context: pongo2.Context{"a": "hello", "b": 5}, + expected: "hello5", + }, + { + name: "integer + non-numeric string stays string concatenation", + template: "{{ a + b }}", + context: pongo2.Context{"a": 5, "b": "hello"}, + expected: "5hello", + }, + { + name: "two strings remain concatenation", + template: "{{ a + b }}", + context: pongo2.Context{"a": "hello", "b": "world"}, + expected: "helloworld", + }, + { + name: "two numeric strings remain concatenation", + template: "{{ a + b }}", + context: pongo2.Context{"a": "10", "b": "5"}, + expected: "105", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tpl, err := pongo2.FromString(tt.template) + if err != nil { + t.Fatalf("failed to parse template: %v", err) + } + + // Opt in to the v4 numeric-string arithmetic behavior. + tpl.Options.NumericStringArithmetic = true + + result, err := tpl.Execute(tt.context) + if err != nil { + t.Fatalf("failed to execute template: %v", err) + } + + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestIssue342DefaultIsConcatenation(t *testing.T) { + // With the NumericStringArithmetic option left at its default (false), the + // "+" operator keeps the v5/v6 string-concatenation behavior. This guards + // against the fix re-introducing a breaking change for existing users. + // See: https://github.com/flosch/pongo2/issues/342 + + tests := []struct { + name string + context pongo2.Context + expected string + }{ + {"numeric string + integer", pongo2.Context{"a": "10", "b": 5}, "105"}, + {"integer + numeric string", pongo2.Context{"a": 5, "b": "10"}, "510"}, + {"numeric string + float", pongo2.Context{"a": "10.5", "b": 2.5}, "10.52.500000"}, + {"non-numeric string + integer", pongo2.Context{"a": "hello", "b": 5}, "hello5"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tpl, err := pongo2.FromString("{{ a + b }}") + if err != nil { + t.Fatalf("failed to parse template: %v", err) + } + + result, err := tpl.Execute(tt.context) + if err != nil { + t.Fatalf("failed to execute template: %v", err) + } + + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + func TestIssue237(t *testing.T) { // Test that ifchanged with else clause works correctly when content doesn't change. // Bug: ifchanged without watched expressions would panic or not render else block diff --git a/value.go b/value.go index 0f2d712..a56253f 100644 --- a/value.go +++ b/value.go @@ -82,6 +82,19 @@ func (v *Value) IsNumber() bool { return v.IsInteger() || v.IsFloat() } +// CanBeNumber checks whether the value is either a number or a string +// that can be parsed as a number. +func (v *Value) CanBeNumber() bool { + if v.IsNumber() { + return true + } + if v.IsString() { + _, err := strconv.ParseFloat(v.getResolvedValue().String(), 64) + return err == nil + } + return false +} + // IsTime checks whether the underlying value is a time.Time. func (v *Value) IsTime() bool { _, ok := v.Interface().(time.Time)