Skip to content
Merged
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
30 changes: 13 additions & 17 deletions cel2sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"log/slog"
"math"
"regexp"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -172,8 +173,8 @@ func WithMaxOutputLength(maxLength int) ConvertOption {
// Result represents the output of a CEL to SQL conversion with parameterized queries.
// It contains the SQL string with placeholders ($1, $2, etc.) and the corresponding parameter values.
type Result struct {
SQL string // The generated SQL WHERE clause with placeholders
Parameters []interface{} // Parameter values in order ($1, $2, etc.)
SQL string // The generated SQL WHERE clause with placeholders
Parameters []any // Parameter values in order ($1, $2, etc.)
}

// Convert converts a CEL AST to a PostgreSQL SQL WHERE clause condition.
Expand Down Expand Up @@ -312,13 +313,13 @@ type converter struct {
schemas map[string]pg.Schema
ctx context.Context
logger *slog.Logger
depth int // Current recursion depth
maxDepth int // Maximum allowed recursion depth
maxOutputLen int // Maximum allowed SQL output length
comprehensionDepth int // Current comprehension nesting depth
parameterize bool // Enable parameterized output
parameters []interface{} // Collected parameters for parameterized queries
paramCount int // Parameter counter for placeholders ($1, $2, etc.)
depth int // Current recursion depth
maxDepth int // Maximum allowed recursion depth
maxOutputLen int // Maximum allowed SQL output length
comprehensionDepth int // Current comprehension nesting depth
parameterize bool // Enable parameterized output
parameters []any // Collected parameters for parameterized queries
paramCount int // Parameter counter for placeholders ($1, $2, etc.)
}

// checkContext checks if the context has been cancelled or expired.
Expand Down Expand Up @@ -1293,10 +1294,7 @@ func (con *converter) callSubstring(target *exprpb.Expr, args []*exprpb.Expr) er
if endConst := endExpr.GetConstExpr(); endConst != nil {
start := startConst.GetInt64Value()
end := endConst.GetInt64Value()
length := end - start
if length < 0 {
length = 0
}
length := max(end-start, 0)
con.str.WriteString(strconv.FormatInt(length, 10))
} else {
// End is dynamic, start is constant
Expand Down Expand Up @@ -2524,10 +2522,8 @@ func (con *converter) isDirectJSONFieldAccess(operand *exprpb.Expr, _ string) bo

// Check if the parent field is a known JSON column
jsonFields := []string{"metadata", "properties", "content", "structure", "taxonomy", "analytics", "classification"}
for _, jsonField := range jsonFields {
if parentField == jsonField {
return true
}
if slices.Contains(jsonFields, parentField) {
return true
}
}

Expand Down
2 changes: 1 addition & 1 deletion errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func newConversionError(userMsg string, internalDetails string) *ConversionError
}

// newConversionErrorf creates a ConversionError with formatted internal details
func newConversionErrorf(userMsg string, internalFormat string, args ...interface{}) *ConversionError {
func newConversionErrorf(userMsg string, internalFormat string, args ...any) *ConversionError {
return &ConversionError{
UserMessage: userMsg,
InternalDetails: fmt.Sprintf(internalFormat, args...),
Expand Down
23 changes: 5 additions & 18 deletions json.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"slices"

exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)
Expand Down Expand Up @@ -85,26 +86,14 @@ func (con *converter) needsNumericCasting(identName string) bool {
// Common iteration variable names that come from numeric JSON arrays
numericIterationVars := []string{"score", "value", "num", "amount", "count", "level"}

for _, numericVar := range numericIterationVars {
if identName == numericVar {
return true
}
}

return false
return slices.Contains(numericIterationVars, identName)
}

// isNumericJSONField checks if a JSON field name typically contains numeric values
func (con *converter) isNumericJSONField(fieldName string) bool {
numericFields := []string{"level", "score", "value", "count", "amount", "price", "rating", "age", "size", "capacity", "megapixels", "cores", "threads", "ram", "storage", "vram", "weight", "frequency", "helpful"}

for _, numericField := range numericFields {
if fieldName == numericField {
return true
}
}

return false
return slices.Contains(numericFields, fieldName)
}

// isNestedJSONAccess checks if this is nested JSON field access like settings.permissions
Expand Down Expand Up @@ -184,10 +173,8 @@ func (con *converter) isJSONObjectFieldAccess(expr *exprpb.Expr) bool {
jsonObjectVars := []string{"attr", "item", "element", "obj", "feature", "review"}
identName := identExpr.GetName()

for _, jsonVar := range jsonObjectVars {
if identName == jsonVar {
return true
}
if slices.Contains(jsonObjectVars, identName) {
return true
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion parameterized_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ func TestParameterizedQueryPerformance(t *testing.T) {
const iterations = 100
var totalDuration time.Duration

for i := 0; i < iterations; i++ {
for range iterations {
start := time.Now()
var count int
err := db.QueryRow(query, result.Parameters...).Scan(&count)
Expand Down
58 changes: 29 additions & 29 deletions parameterized_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,29 @@ func TestConvertParameterized(t *testing.T) {
celExpr string
wantSQL string
wantParamCount int
wantParams []interface{}
wantParams []any
}{
// String parameters
{
name: "simple string equality",
celExpr: `name == "John"`,
wantSQL: "name = $1",
wantParamCount: 1,
wantParams: []interface{}{"John"},
wantParams: []any{"John"},
},
{
name: "multiple string parameters",
celExpr: `name == "John" && name != "Jane"`,
wantSQL: "name = $1 AND name != $2",
wantParamCount: 2,
wantParams: []interface{}{"John", "Jane"},
wantParams: []any{"John", "Jane"},
},
{
name: "string with escaped quotes",
celExpr: `name == "O'Brien"`,
wantSQL: "name = $1",
wantParamCount: 1,
wantParams: []interface{}{"O'Brien"},
wantParams: []any{"O'Brien"},
},

// Integer parameters
Expand All @@ -61,21 +61,21 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `age == 18`,
wantSQL: "age = $1",
wantParamCount: 1,
wantParams: []interface{}{int64(18)},
wantParams: []any{int64(18)},
},
{
name: "integer comparison",
celExpr: `age > 21 && age < 65`,
wantSQL: "age > $1 AND age < $2",
wantParamCount: 2,
wantParams: []interface{}{int64(21), int64(65)},
wantParams: []any{int64(21), int64(65)},
},
{
name: "negative integer",
celExpr: `age == -5`,
wantSQL: "age = $1",
wantParamCount: 1,
wantParams: []interface{}{int64(-5)},
wantParams: []any{int64(-5)},
},

// Double parameters
Expand All @@ -84,14 +84,14 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `salary == 50000.50`,
wantSQL: "salary = $1",
wantParamCount: 1,
wantParams: []interface{}{50000.50},
wantParams: []any{50000.50},
},
{
name: "double comparison",
celExpr: `salary >= 30000.0 && salary <= 100000.0`,
wantSQL: "salary >= $1 AND salary <= $2",
wantParamCount: 2,
wantParams: []interface{}{30000.0, 100000.0},
wantParams: []any{30000.0, 100000.0},
},

// Boolean and NULL constants (kept inline)
Expand All @@ -114,7 +114,7 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `active == true && age == 18`,
wantSQL: "active IS TRUE AND age = $1",
wantParamCount: 1,
wantParams: []interface{}{int64(18)},
wantParams: []any{int64(18)},
},

// Bytes parameters
Expand All @@ -123,7 +123,7 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `data == b"hello"`,
wantSQL: "data = $1",
wantParamCount: 1,
wantParams: []interface{}{[]byte("hello")},
wantParams: []any{[]byte("hello")},
},

// Complex expressions with multiple parameters
Expand All @@ -132,21 +132,21 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `name == "John" && age >= 18 && salary > 50000.0`,
wantSQL: "name = $1 AND age >= $2 AND salary > $3",
wantParamCount: 3,
wantParams: []interface{}{"John", int64(18), 50000.0},
wantParams: []any{"John", int64(18), 50000.0},
},
{
name: "complex OR expression",
celExpr: `name == "John" || name == "Jane" || age == 25`,
wantSQL: "name = $1 OR name = $2 OR age = $3",
wantParamCount: 3,
wantParams: []interface{}{"John", "Jane", int64(25)},
wantParams: []any{"John", "Jane", int64(25)},
},
{
name: "nested parentheses with params",
celExpr: `(name == "John" && age == 18) || (name == "Jane" && age == 21)`,
wantSQL: "name = $1 AND age = $2 OR name = $3 AND age = $4",
wantParamCount: 4,
wantParams: []interface{}{"John", int64(18), "Jane", int64(21)},
wantParams: []any{"John", int64(18), "Jane", int64(21)},
},

// Parameter ordering test
Expand All @@ -155,7 +155,7 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `name == "First" && age == 1 && salary == 100.0 && name != "Second"`,
wantSQL: "name = $1 AND age = $2 AND salary = $3 AND name != $4",
wantParamCount: 4,
wantParams: []interface{}{"First", int64(1), 100.0, "Second"},
wantParams: []any{"First", int64(1), 100.0, "Second"},
},

// Empty parameter list
Expand Down Expand Up @@ -188,7 +188,7 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `name.contains("oh")`,
wantSQL: "POSITION($1 IN name) > 0",
wantParamCount: 1,
wantParams: []interface{}{"oh"},
wantParams: []any{"oh"},
},

// IN operator with parameters
Expand All @@ -197,14 +197,14 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `age in [18, 21, 25]`,
wantSQL: "age = ANY(ARRAY[$1, $2, $3])",
wantParamCount: 3,
wantParams: []interface{}{int64(18), int64(21), int64(25)},
wantParams: []any{int64(18), int64(21), int64(25)},
},
{
name: "string IN with array literal",
celExpr: `name in ["John", "Jane", "Bob"]`,
wantSQL: "name = ANY(ARRAY[$1, $2, $3])",
wantParamCount: 3,
wantParams: []interface{}{"John", "Jane", "Bob"},
wantParams: []any{"John", "Jane", "Bob"},
},

// Ternary operator with parameters
Expand All @@ -213,7 +213,7 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `age > 18 ? "adult" : "minor"`,
wantSQL: "CASE WHEN age > $1 THEN $2 ELSE $3 END",
wantParamCount: 3,
wantParams: []interface{}{int64(18), "adult", "minor"},
wantParams: []any{int64(18), "adult", "minor"},
},

// Type casting with parameters
Expand All @@ -222,7 +222,7 @@ func TestConvertParameterized(t *testing.T) {
celExpr: `string(age) == "18"`,
wantSQL: "CAST(age AS TEXT) = $1",
wantParamCount: 1,
wantParams: []interface{}{"18"},
wantParams: []any{"18"},
},
}

Expand Down Expand Up @@ -273,28 +273,28 @@ func TestConvertParameterized_JSONFields(t *testing.T) {
celExpr string
wantSQL string
wantParamCount int
wantParams []interface{}
wantParams []any
}{
{
name: "JSON field comparison with parameter",
celExpr: `usr.metadata.username == "john_doe"`,
wantSQL: "usr.metadata->>'username' = $1",
wantParamCount: 1,
wantParams: []interface{}{"john_doe"},
wantParams: []any{"john_doe"},
},
{
name: "nested JSON field comparison",
celExpr: `usr.metadata.settings.theme == "dark"`,
wantSQL: "usr.metadata->'settings'->>'theme' = $1",
wantParamCount: 1,
wantParams: []interface{}{"dark"},
wantParams: []any{"dark"},
},
{
name: "JSON and regular field with parameters",
celExpr: `usr.name == "John" && usr.metadata.age == "25"`,
wantSQL: "usr.name = $1 AND usr.metadata->>'age' = $2",
wantParamCount: 2,
wantParams: []interface{}{"John", "25"},
wantParams: []any{"John", "25"},
},
}

Expand Down Expand Up @@ -339,35 +339,35 @@ func TestConvertParameterized_Comprehensions(t *testing.T) {
celExpr string
wantSQL string
wantParamCount int
wantParams []interface{}
wantParams []any
}{
{
name: "all() with parameterized predicate",
celExpr: `scores.all(x, x > 50)`,
wantSQL: "NOT EXISTS (SELECT 1 FROM UNNEST(scores) AS x WHERE NOT (x > $1))",
wantParamCount: 1,
wantParams: []interface{}{int64(50)},
wantParams: []any{int64(50)},
},
{
name: "exists() with parameterized predicate",
celExpr: `scores.exists(x, x == 100)`,
wantSQL: "EXISTS (SELECT 1 FROM UNNEST(scores) AS x WHERE x = $1)",
wantParamCount: 1,
wantParams: []interface{}{int64(100)},
wantParams: []any{int64(100)},
},
{
name: "exists_one() with parameterized predicate",
celExpr: `scores.exists_one(x, x == 42)`,
wantSQL: "(SELECT COUNT(*) FROM UNNEST(scores) AS x WHERE x = $1) = 1",
wantParamCount: 1,
wantParams: []interface{}{int64(42)},
wantParams: []any{int64(42)},
},
{
name: "map() with parameterized transform",
celExpr: `scores.map(x, x + 10).exists(y, y == 110)`,
wantSQL: "EXISTS (SELECT 1 FROM UNNEST(ARRAY(SELECT x + $1 FROM UNNEST(scores) AS x)) AS y WHERE y = $2)",
wantParamCount: 2,
wantParams: []interface{}{int64(10), int64(110)},
wantParams: []any{int64(10), int64(110)},
},
}

Expand Down
Loading
Loading