From 0e57e7352dc7ce815d62bbb0ab40d9600d8bdd1e Mon Sep 17 00:00:00 2001 From: Daniel Richter Date: Fri, 16 Jan 2026 10:58:58 -0800 Subject: [PATCH 1/2] Add regression fix for numeric string concatenation --- parser_expression.go | 11 ++++++ pongo2_issues_test.go | 81 +++++++++++++++++++++++++++++++++++++++++++ value.go | 13 +++++++ 3 files changed, 105 insertions(+) diff --git a/parser_expression.go b/parser_expression.go index 4891fac..01cb8e3 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,16 @@ 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). + if (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..8591d2a 100644 --- a/pongo2_issues_test.go +++ b/pongo2_issues_test.go @@ -416,6 +416,87 @@ func TestIssue209(t *testing.T) { } } +func TestIssue342(t *testing.T) { + // Test that adding a numeric string and a number results in arithmetic addition. + // Bug: In v6, "10" + 5 returns "105" (string concatenation). + // Expected: "10" + 5 should return 15 (arithmetic addition) as in v4. + // 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) + } + + 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) From be5d43d099c6b99d2448ed997b37774876e9ff7b Mon Sep 17 00:00:00 2001 From: Daniel Richter Date: Sun, 7 Jun 2026 14:41:49 +0000 Subject: [PATCH 2/2] feat: make numeric-string arithmetic opt-in via Options flag The "+" fix for issue #342 restored v4 behavior ("10" + 5 == 15), but that flips the v5/v6 string-concatenation behavior ("10" + 5 == "105"), which is itself a breaking change for current users. Gate the arithmetic path behind a new Options.NumericStringArithmetic flag (default false), so the v5/v6 behavior is preserved unless a caller explicitly opts in. This makes the fix non-breaking and mergeable. Add TestIssue342DefaultIsConcatenation to lock in the default behavior; existing TestIssue342 now enables the flag. Co-Authored-By: Claude Opus 4.8 --- options.go | 13 +++++++++++-- parser_expression.go | 5 ++++- pongo2_issues_test.go | 45 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 5 deletions(-) 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 01cb8e3..697b2ec 100644 --- a/parser_expression.go +++ b/parser_expression.go @@ -254,7 +254,10 @@ func (expr *simpleExpression) Evaluate(ctx *ExecutionContext) (*Value, error) { case "+": // If one operand is a number and the other is a numeric string, // treat both as numbers and do arithmetic (fixes issue #342). - if (result.IsNumber() && t2.CanBeNumber()) || (t2.IsNumber() && result.CanBeNumber()) { + // 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 diff --git a/pongo2_issues_test.go b/pongo2_issues_test.go index 8591d2a..0a01ab9 100644 --- a/pongo2_issues_test.go +++ b/pongo2_issues_test.go @@ -417,9 +417,11 @@ func TestIssue209(t *testing.T) { } func TestIssue342(t *testing.T) { - // Test that adding a numeric string and a number results in arithmetic addition. + // 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). - // Expected: "10" + 5 should return 15 (arithmetic addition) as in v4. + // 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 { @@ -485,6 +487,45 @@ func TestIssue342(t *testing.T) { 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)