Skip to content

Implementing Custom Functions

This guide explains how to implement custom functions that can be used within the query language.

Function Interface

Every custom function must implement the Function interface:

go
type Function interface {
    // Evaluate executes the function with given arguments
    Evaluate(args []interface{}) (interface{}, error)
    
    // ArgCount returns the required number of arguments
    // Return -1 for variadic functions
    ArgCount() int
}

Basic Function Types

1. Boolean Function

go
// Function that returns true/false
type IsAdminFunction struct{}

func (f *IsAdminFunction) Evaluate(args []interface{}) (interface{}, error) {
    if len(args) != 1 {
        return nil, fmt.Errorf("isAdmin expects 1 argument")
    }
    
    user, ok := args[0].(map[string]interface{})
    if !ok {
        return nil, fmt.Errorf("argument must be a user object")
    }
    
    role, ok := user["role"].(string)
    if !ok {
        return false, nil
    }
    
    return role == "admin", nil
}

func (f *IsAdminFunction) ArgCount() int {
    return 1
}

// Usage in query: @isAdmin(user)

2. Numeric Function

go
// Function that returns a number
type CountFunction struct{}

func (f *CountFunction) Evaluate(args []interface{}) (interface{}, error) {
    if len(args) != 1 {
        return nil, fmt.Errorf("count expects 1 argument")
    }
    
    arr, ok := args[0].([]interface{})
    if !ok {
        return nil, fmt.Errorf("argument must be an array")
    }
    
    return float64(len(arr)), nil
}

func (f *CountFunction) ArgCount() int {
    return 1
}

// Usage in query: @count(items) > 0

3. String Function

go
// Function that returns a string
type FormatFunction struct{}

func (f *FormatFunction) Evaluate(args []interface{}) (interface{}, error) {
    if len(args) != 2 {
        return nil, fmt.Errorf("format expects 2 arguments")
    }
    
    template, ok := args[0].(string)
    if !ok {
        return nil, fmt.Errorf("first argument must be a string")
    }
    
    value := args[1]
    
    return fmt.Sprintf(template, value), nil
}

func (f *FormatFunction) ArgCount() int {
    return 2
}

// Usage in query: @format("%s_suffix", name) == "john_suffix"

Registering Custom Functions

Functions must be registered with a FunctionRegistry before they can be used:

go
// Create a new registry
registry := expressions.NewFunctionRegistry()

// Register custom functions
registry.Register("isAdmin", &IsAdminFunction{})
registry.Register("count", &CountFunction{})
registry.Register("format", &FormatFunction{})

Function Return Types and Usage

  1. Boolean Functions

    • Return true or false
    • Can be used directly in boolean context
    • Example: @isAdmin(user)
  2. Numeric Functions

    • Return numbers (use float64 for consistency)
    • Must be used with comparison operators
    • Example: @count(items) > 0
  3. String Functions

    • Return strings
    • Must be used with comparison or string operators
    • Example: @format(name) == "value"

Error Handling Best Practices

  1. Argument Count Validation
go
func (f *MyFunction) Evaluate(args []interface{}) (interface{}, error) {
    if len(args) != f.ArgCount() {
        return nil, fmt.Errorf("function expects %d arguments, got %d", 
            f.ArgCount(), len(args))
    }
    // ... rest of implementation
}
  1. Type Assertions
go
func (f *MyFunction) Evaluate(args []interface{}) (interface{}, error) {
    value, ok := args[0].(string)
    if !ok {
        return nil, fmt.Errorf("argument must be a string")
    }
    // ... rest of implementation
}
  1. Nil Handling
go
func (f *MyFunction) Evaluate(args []interface{}) (interface{}, error) {
    if args[0] == nil {
        return nil, fmt.Errorf("argument cannot be nil")
    }
    // ... rest of implementation
}

Complete Example

Here's a complete example of a custom function that checks if a number is within a range:

go
// RangeCheckFunction checks if a number is within a given range
type RangeCheckFunction struct{}

func (f *RangeCheckFunction) Evaluate(args []interface{}) (interface{}, error) {
    // Validate argument count
    if len(args) != 3 {
        return nil, fmt.Errorf("rangeCheck expects 3 arguments (value, min, max)")
    }
    
    // Extract and validate value
    value, ok := args[0].(float64)
    if !ok {
        if intVal, ok := args[0].(int); ok {
            value = float64(intVal)
        } else {
            return nil, fmt.Errorf("first argument must be a number")
        }
    }
    
    // Extract and validate min
    min, ok := args[1].(float64)
    if !ok {
        if intMin, ok := args[1].(int); ok {
            min = float64(intMin)
        } else {
            return nil, fmt.Errorf("second argument must be a number")
        }
    }
    
    // Extract and validate max
    max, ok := args[2].(float64)
    if !ok {
        if intMax, ok := args[2].(int); ok {
            max = float64(intMax)
        } else {
            return nil, fmt.Errorf("third argument must be a number")
        }
    }
    
    // Perform range check
    return value >= min && value <= max, nil
}

func (f *RangeCheckFunction) ArgCount() int {
    return 3
}

// Usage:
// registry.Register("rangeCheck", &RangeCheckFunction{})
// Query: @rangeCheck(value, 0, 100)

Testing Custom Functions

Always test your custom functions:

go
func TestRangeCheckFunction(t *testing.T) {
    fn := &RangeCheckFunction{}
    
    tests := []struct {
        name     string
        args     []interface{}
        want     interface{}
        wantErr  bool
        errMsg   string
    }{
        {
            name: "valid range check",
            args: []interface{}{50.0, 0.0, 100.0},
            want: true,
            wantErr: false,
        },
        {
            name: "below range",
            args: []interface{}{-1.0, 0.0, 100.0},
            want: false,
            wantErr: false,
        },
        {
            name: "above range",
            args: []interface{}{101.0, 0.0, 100.0},
            want: false,
            wantErr: false,
        },
        {
            name: "invalid arg count",
            args: []interface{}{50.0, 0.0},
            want: nil,
            wantErr: true,
            errMsg: "rangeCheck expects 3 arguments (value, min, max)",
        },
        {
            name: "invalid value type",
            args: []interface{}{"50", 0.0, 100.0},
            want: nil,
            wantErr: true,
            errMsg: "first argument must be a number",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := fn.Evaluate(tt.args)
            
            if tt.wantErr {
                if err == nil {
                    t.Errorf("expected error but got none")
                    return
                }
                if err.Error() != tt.errMsg {
                    t.Errorf("got error = %v, want error = %v", 
                        err.Error(), tt.errMsg)
                }
                return
            }
            
            if err != nil {
                t.Errorf("unexpected error: %v", err)
                return
            }
            
            if got != tt.want {
                t.Errorf("got = %v, want %v", got, tt.want)
            }
        })
    }
}

Best Practices

  1. Type Safety

    • Use type assertions with fallbacks where appropriate
    • Convert numeric types to float64 for consistency
    • Provide clear error messages for type mismatches
  2. Error Handling

    • Validate argument count before processing
    • Check for nil values
    • Return descriptive error messages
    • Use fmt.Errorf for error formatting
  3. Documentation

    • Document expected argument types
    • Provide usage examples
    • Explain return value types
    • List possible error conditions
  4. Testing

    • Test happy path cases
    • Test edge cases
    • Test error conditions
    • Test type conversions
    • Test with nil values