pgxcel converts a checked CEL AST into
a Postgres WHERE fragment with positional bind placeholders. It is
deliberately small: one walker over the standard CEL expression
protobuf, a fail-closed identifier allow-list, and time.Time /
time.Duration bindings for the timestamp(...) / duration(...)
literals.
The package accepts any *cel.Ast regardless of how it was produced.
That includes ASTs translated back from an AIP-160
filter via cel.CheckedExprToAst, so the same transpiler powers both
direct CEL and AIP filtering on top of Postgres.
go get github.com/pgx-contrib/pgxcelenv, _ := cel.NewEnv(
cel.Variable("name", cel.StringType),
cel.Variable("age", cel.IntType),
)
ast, iss := env.Compile(`name == "Alice" && age > 30`)
if iss.Err() != nil {
return iss.Err()
}
columns := map[string]string{
"name": "users.name",
"age": "users.age",
}
where, args, err := pgxcel.Transpile(ast, pgxcel.WithColumns(columns))
// where: ("users"."name" = $1 AND "users"."age" > $2)
// args: []any{"Alice", int64(30)}pgxcel.WithColumns(map[string]string)— the path → DB-column allow-list. Lookup is fail-closed: any identifier the AST references that is not in the map causesTranspileto return an error. When omitted, every ident in the AST errors. Never feed user input as a column name; the value of each map entry is emitted into the SQL after only identifier quoting.pgxcel.WithFunctions(map[string]string)— alias → canonical function-name map applied before dispatch. Use it to feed in ASTs produced by parsers other than cel-go (for example einride/aip-go emits"="/"AND"/"NOT"instead of the cel-go operator names). Unknown aliases pass through unchanged.pgxcel.WithParamOffset(int)— the first placeholder number. Defaults to1. Use a higher value when splicing the fragment into a query that already has bound values.
A nil ast returns ("", nil, nil). An unchecked ast
(ast.IsChecked() == false) returns an error.
| CEL | Postgres fragment |
|---|---|
==, !=, <, <=, >, >= |
col op $N (or col op col) |
&&, || |
(lhs AND rhs) / (lhs OR rhs) |
! |
(NOT expr) |
x in [a, b, c] |
x IN ($1, $2, $3) (empty → FALSE) |
s.contains(x) |
s LIKE '%' || $N || '%' |
s.startsWith(x) |
s LIKE $N || '%' |
s.endsWith(x) |
s LIKE '%' || $N |
s.matches(re) |
s ~ $N (POSIX regex) |
timestamp("2025-01-02T03:04:05Z") |
$N bound as time.Time |
duration("1h30m") |
$N bound as time.Duration |
unary -<literal> |
bound as signed numeric literal |
go test ./...
go vet ./...