Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions builtin/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math/rand"
"reflect"
"sort"
"strings"
Expand Down Expand Up @@ -512,6 +513,51 @@ var Builtins = []*Function{
args = args[1:]
}

// Handle epoch timestamp (numeric input)
switch v := args[0].(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
epoch := runtime.ToInt64(v)
var t time.Time

// Check if it's milliseconds (timestamp > year 2001)
// Unix timestamp for Jan 1, 2001 is 978307200
if epoch > 978307200000 {
// Treat as milliseconds
t = time.Unix(epoch/1000, (epoch%1000)*1000000)
} else {
// Treat as seconds
t = time.Unix(epoch, 0)
}

if tz != nil {
t = t.In(tz)
}
return t, nil

case float32, float64:
epoch := runtime.ToFloat64(v)
var t time.Time

// Check if it's milliseconds
if epoch > 978307200000 {
// Treat as milliseconds
sec := int64(epoch / 1000)
nsec := int64((epoch - float64(sec*1000)) * 1000000)
t = time.Unix(sec, nsec)
} else {
// Treat as seconds (can have fractional part)
sec := int64(epoch)
nsec := int64((epoch - float64(sec)) * 1000000000)
t = time.Unix(sec, nsec)
}

if tz != nil {
t = t.In(tz)
}
return t, nil
}

// Handle string input (existing functionality)
date := args[0].(string)
if len(args) == 2 {
layout := args[1].(string)
Expand Down Expand Up @@ -1066,4 +1112,78 @@ var Builtins = []*Function{
},
Types: types(new(func(int) int)),
},
{
Name: "random",
Func: func(args ...any) (any, error) {
if len(args) == 0 {
return nil, fmt.Errorf("invalid number of arguments for random (expected 1 or 2, got 0)")
}
if len(args) > 2 {
return nil, fmt.Errorf("invalid number of arguments for random (expected 1 or 2, got %d)", len(args))
}

// Convert arguments to int
var min, max int
var err error

if len(args) == 1 {
// random(max) - generates random number from 0 to max-1
max, err = toInt(args[0])
if err != nil {
return nil, fmt.Errorf("invalid argument for random: %v", err)
}
if max <= 0 {
return nil, fmt.Errorf("invalid argument for random: max must be positive, got %d", max)
}
return rand.Intn(max), nil
} else {
// random(min, max) - generates random number from min to max-1
min, err = toInt(args[0])
if err != nil {
return nil, fmt.Errorf("invalid first argument for random: %v", err)
}
max, err = toInt(args[1])
if err != nil {
return nil, fmt.Errorf("invalid second argument for random: %v", err)
}
if max <= min {
return nil, fmt.Errorf("invalid arguments for random: max must be greater than min, got min=%d, max=%d", min, max)
}
return min + rand.Intn(max-min), nil
}
},
Types: types(
new(func(int) int),
new(func(float64) int),
new(func(string) int),
new(func(int, int) int),
new(func(float64, int) int),
new(func(int, float64) int),
new(func(float64, float64) int),
new(func(string, int) int),
new(func(int, string) int),
new(func(string, string) int),
),
Validate: func(args []reflect.Type) (reflect.Type, error) {
if len(args) == 0 {
return anyType, fmt.Errorf("invalid number of arguments for random (expected 1 or 2, got 0)")
}
if len(args) > 2 {
return anyType, fmt.Errorf("invalid number of arguments for random (expected 1 or 2, got %d)", len(args))
}

// Validate that arguments can be converted to int
for i, arg := range args {
switch kind(arg) {
case reflect.Interface, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64, reflect.String:
// These types can be converted to int
default:
return anyType, fmt.Errorf("invalid argument %d for random (type %s)", i+1, arg)
}
}
return integerType, nil
},
},
}
134 changes: 134 additions & 0 deletions builtin/builtin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ func TestBuiltin(t *testing.T) {
{`flatten([["a", "b"], [1, 2, [3, [[[["c", "d"], "e"]]], 4]]])`, []any{"a", "b", 1, 2, 3, "c", "d", "e", 4}},
{`uniq([1, 15, "a", 2, 3, 5, 2, "a", 2, "b"])`, []any{1, 15, "a", 2, 3, 5, "b"}},
{`uniq([[1, 2], "a", 2, 3, [1, 2], [1, 3]])`, []any{[]any{1, 2}, "a", 2, 3, []any{1, 3}}},
{`random(10) >= 0 && random(10) < 10`, true},
{`random(1, 10) >= 1 && random(1, 10) < 10`, true},
{`random(5.5) >= 0 && random(5.5) < 5`, true},
{`random(1.5, 5.5) >= 1 && random(1.5, 5.5) < 5`, true},
{`random("10") >= 0 && random("10") < 10`, true},
{`random("1", "10") >= 1 && random("1", "10") < 10`, true},
{`random(5.9) >= 0 && random(5.9) < 5`, true},
{`random(0.5, 3.5) >= 0 && random(0.5, 3.5) < 3`, true},
}

for _, test := range tests {
Expand Down Expand Up @@ -243,6 +251,16 @@ func TestBuiltin_errors(t *testing.T) {
{`timezone(nil)`, "cannot use nil as argument (type string) to call timezone (1:10)"},
{`flatten([1, 2], [3, 4])`, "invalid number of arguments (expected 1, got 2)"},
{`flatten(1)`, "cannot flatten int"},
{`random()`, "invalid number of arguments for random (expected 1 or 2, got 0)"},
{`random(1, 2, 3)`, "invalid number of arguments for random (expected 1 or 2, got 3)"},
{`random(0)`, "invalid argument for random: max must be positive, got 0"},
{`random(-1)`, "invalid argument for random: max must be positive, got -1"},
{`random(5, 5)`, "invalid arguments for random: max must be greater than min, got min=5, max=5"},
{`random(10, 5)`, "invalid arguments for random: max must be greater than min, got min=10, max=5"},
{`random(true)`, "invalid argument 1 for random (type bool)"},
{`random([1, 2])`, "invalid argument 1 for random (type []interface {})"},
{`random("invalid")`, "cannot convert string 'invalid' to int"},
{`random(1, "invalid")`, "cannot convert string 'invalid' to int"},
}
for _, test := range errorTests {
t.Run(test.input, func(t *testing.T) {
Expand Down Expand Up @@ -722,3 +740,119 @@ func TestBuiltin_with_deref(t *testing.T) {
})
}
}

func TestBuiltin_random(t *testing.T) {
env := map[string]any{}

// Test single argument (max)
t.Run("single argument", func(t *testing.T) {
tests := []struct {
input string
min int
max int
}{
{`random(10)`, 0, 10},
{`random(5)`, 0, 5},
{`random(1)`, 0, 1},
}

for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
program, err := expr.Compile(test.input, expr.Env(env))
require.NoError(t, err)

// Run multiple times to ensure range is correct
for i := 0; i < 100; i++ {
out, err := expr.Run(program, env)
require.NoError(t, err)

result, ok := out.(int)
require.True(t, ok, "expected int result")
assert.GreaterOrEqual(t, result, test.min)
assert.Less(t, result, test.max)
}
})
}
})

// Test two arguments (min, max)
t.Run("two arguments", func(t *testing.T) {
tests := []struct {
input string
min int
max int
}{
{`random(1, 10)`, 1, 10},
{`random(5, 15)`, 5, 15},
{`random(-5, 5)`, -5, 5},
}

for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
program, err := expr.Compile(test.input, expr.Env(env))
require.NoError(t, err)

// Run multiple times to ensure range is correct
for i := 0; i < 100; i++ {
out, err := expr.Run(program, env)
require.NoError(t, err)

result, ok := out.(int)
require.True(t, ok, "expected int result")
assert.GreaterOrEqual(t, result, test.min)
assert.Less(t, result, test.max)
}
})
}
})

// Test type conversion
t.Run("type conversion", func(t *testing.T) {
tests := []struct {
input string
min int
max int
}{
{`random(5.5)`, 0, 5}, // float to int (truncated)
{`random(1.5, 5.5)`, 1, 5}, // floats to int (truncated)
{`random("10")`, 0, 10}, // string to int
{`random("1", "10")`, 1, 10}, // strings to int
{`random(5.9)`, 0, 5}, // float with decimal
{`random(0.5, 3.5)`, 0, 3}, // floats with decimals
}

for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
program, err := expr.Compile(test.input, expr.Env(env))
require.NoError(t, err)

// Run multiple times to ensure range is correct
for i := 0; i < 50; i++ {
out, err := expr.Run(program, env)
require.NoError(t, err)

result, ok := out.(int)
require.True(t, ok, "expected int result")
assert.GreaterOrEqual(t, result, test.min)
assert.Less(t, result, test.max)
}
})
}
})

// Test edge cases
t.Run("edge cases", func(t *testing.T) {
// Test with very small ranges
program, err := expr.Compile(`random(1, 2)`, expr.Env(env))
require.NoError(t, err)

out, err := expr.Run(program, env)
require.NoError(t, err)
assert.Equal(t, 1, out) // Should always return 1 for range [1, 2)

// Test with zero max (should error)
_, err = expr.Eval(`random(0)`, env)
assert.Error(t, err)
assert.Contains(t, err.Error(), "max must be positive")
})
}
15 changes: 15 additions & 0 deletions builtin/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package builtin
import (
"fmt"
"reflect"
"strconv"
"time"

"github.com/expr-lang/expr/internal/deref"
Expand Down Expand Up @@ -63,6 +64,20 @@ func toInt(val any) (int, error) {
return int(v), nil
case uint64:
return int(v), nil
case float32:
return int(v), nil
case float64:
return int(v), nil
case string:
// Try to parse as int first
if i, err := strconv.Atoi(v); err == nil {
return i, nil
}
// Try to parse as float then convert to int
if f, err := strconv.ParseFloat(v, 64); err == nil {
return int(f), nil
}
return 0, fmt.Errorf("cannot convert string '%s' to int", v)
default:
return 0, fmt.Errorf("cannot use %T as argument (type int)", val)
}
Expand Down
Loading