Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5d39ce9
Share the .env reader across test packages
lemuelroberto Aug 22, 2026
f4595c3
Order sql/ scripts by numeric prefix
lemuelroberto Aug 22, 2026
de7d58a
Match string(double) formatting to Go's %g
lemuelroberto Aug 22, 2026
080d2fb
Add the conformance corpus runner
lemuelroberto Aug 22, 2026
9ea45db
Correct the cel-go harness claim in CLAUDE.md
lemuelroberto Aug 22, 2026
acbe0ff
Skip corpus cases whose strings contain NUL
lemuelroberto Aug 22, 2026
2f11b96
Add the CEL parser and macro engine
lemuelroberto Aug 22, 2026
bb43d4f
Add the evaluator core and stdlib part one
lemuelroberto Aug 22, 2026
ef4ccf9
Add the type checker and stdlib part two
lemuelroberto Aug 22, 2026
8f43f27
Bring CLAUDE.md up to date with the built pipeline
lemuelroberto Aug 22, 2026
5a4d264
Add the well-known types
lemuelroberto Aug 22, 2026
0d1457b
Move the wrapper types into scope
lemuelroberto Aug 22, 2026
0c574bb
Add two-var comprehensions and close out the core
lemuelroberto Aug 22, 2026
37ffbca
Add the extension libraries; corpus goes green
lemuelroberto Aug 23, 2026
69192a6
Add the cel.evaluate one-shot entry point
lemuelroberto Aug 23, 2026
309c738
Record the completed evaluator in CLAUDE.md
lemuelroberto Aug 23, 2026
e1f1a57
Give the cel-spec corpus pin a single home
lemuelroberto Aug 30, 2026
53385fe
Lift the conformance case runner out of the test
lemuelroberto Aug 30, 2026
940e4b3
Generate a conformance report from an actual run
lemuelroberto Aug 30, 2026
d5e8d96
Write down what the conformance claim excludes
lemuelroberto Aug 30, 2026
da783d7
Correct the README's status and scope
lemuelroberto Aug 30, 2026
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ POSTGRES_PORT=5432
POSTGRES_USER=cel

PREFIX=cel4postgres

# Directory holding the cel-spec / cel-go / cel-java checkouts the
# conformance suite and its oracle read. Per-machine; never hard-coded.
CEL_EXPR_DIR=
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,23 @@ jobs:

- run: go vet ./...

# The conformance suite reads the cel-spec corpus from a local
# checkout named by CEL_EXPR_DIR. Pinned by commit for the same
# reason cel-go is pinned in go.mod: the corpus defines what the
# conformance number means, so it moves only deliberately. The
# ref below is checked against internal/corpus.Pin by a test --
# move both together.
# Checked out after the gofmt step so its files are never
# formatted-checked as ours.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: cel-expr/cel-spec
ref: ba58ae5007845f3a1279b488cdeb79645ce958bb
path: .cel-expr/cel-spec

- run: go test ./... -v
env:
CEL_EXPR_DIR: ${{ github.workspace }}/.cel-expr

- name: Dump database logs
if: failure()
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
# Planning workspaces are working memory, never a deliverable.
# See CLAUDE.md -> Planning workspaces.
.claude/workspace/

# CI-only corpus checkout location
.cel-expr/
74 changes: 48 additions & 26 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ Common Expression Language (CEL). It parses, type-checks and evaluates
CEL inside PostgreSQL, with no server-side extension, no shared library,
and no procedural language beyond `plpgsql`.

**Status: scaffolding only.** What exists is the harness, not the
evaluator: `compose.yaml` (a disposable Postgres that installs `sql/`
during initdb), `sql/install.sql` (the `cel` schema, a `schema_version`
table and `cel.version()` — nothing that evaluates anything), a Go suite
whose only tests assert that the database is reachable and the schema
installed, and a CI workflow that runs them.

No parser, checker, evaluator, registry table or conformance runner has
been written. Everything about those in the sections below describes the
design the next commits are meant to realise, not code you can read.
When you implement a piece of it, replace the prescriptive wording here
with what the code actually does — and when the code and this file
disagree, the code is right and this file is a bug.
**Status: the evaluator is complete and the in-scope conformance
corpus is green.** The pipeline is `sql/010_registry.sql` (the four
registry tables), `020_values.sql` (tagged values, equality,
comparison), `030_parse.sql` (lexer, Pratt parser, macro engine),
`040_check.sql` (checker with overload-id binding), `050_eval.sql`
(evaluator core and the public `eval`/`evaluate` entry points),
`060_stdlib.sql` (standard library rows and impls), `070_wkt.sql`
(timestamps, durations, wrappers, Struct/Value/ListValue), and the
extension libraries `100`–`170` (two-var comprehensions, optionals,
strings, math, lists, encoders, bindings, network), each visible
only under its own env name. Every in-scope conformance file passes
on a fresh install; skips are a named list the suite prints (proto
descriptor material and NUL-in-string cases). Unknown propagation
is covered by its own suite against cel-go partial evaluation.
When the code and this file disagree, the code is right and this
file is a bug.

"Zero-dependency" is the product claim and the design constraint: a
consumer installs cel4postgres by running SQL scripts against a database
Expand All @@ -40,11 +43,18 @@ comprehensions), overflow and conversion semantics, and error/unknown
propagation.

**Out of scope (v1):** protobuf messages, field selection over messages,
proto2/proto3 presence semantics, enums, and the wrapper types
(`Int32Value` and family). These require a descriptor pool inside
Postgres and buy nothing for the JSON-shaped data cel4postgres targets.
The corresponding conformance files (`proto2`, `proto3`, `enums`,
`wrappers`, `proto2_ext`) are out of scope with them.
proto2/proto3 presence semantics, and enums. These require a
descriptor pool inside Postgres and buy nothing for the JSON-shaped
data cel4postgres targets. The corresponding conformance files
(`proto2`, `proto3`, `enums`, `wrappers`, `proto2_ext`) are out of
scope with them.

The wrapper types (`Int32Value` and family) turned out not to need a
descriptor pool — they are JSON-shaped, constructed by registered
type rows like every other WKT — so they are in scope and
implemented in `sql/070_wkt.sql`. The `wrappers` conformance file
stays skipped only because its cases also need proto3 `TestAllTypes`
and `Any` unpacking.

**Out of core, in by registration:** every cel-go extension library
(`strings`, `math`, `lists`, `sets`, `encoders`, `bindings`,
Expand Down Expand Up @@ -97,9 +107,10 @@ anything.
## Architecture

```
cel.parse(source text, env text) → ast jsonb
cel.check(ast jsonb, env text) → ast jsonb
cel.eval(ast jsonb, activation jsonb, env text) → value jsonb
cel.parse(source text, env text) → ast jsonb
cel.check(ast jsonb, env text[, options jsonb]) → ast jsonb
cel.eval(ast jsonb, activation jsonb, env text[, options jsonb])
→ value jsonb
cel.evaluate(source text, activation jsonb, env text) → value jsonb
```

Expand All @@ -109,6 +120,14 @@ installation has to serve the spec-conformant environment, the OpenFGA
dialect and a client's own dialect simultaneously, and a global would
collapse them.

`options` carries per-call context that is not part of the
environment: the namespace `container` and, for `check`, extra ident
declarations (`decls`). `eval` accepts a container because unchecked
evaluation resolves names at runtime — the conformance corpus's
disable_check container cases cannot pass without it. Checked ASTs
ignore it: the checker has already rewritten every name to its
qualified form. The plain three-argument forms remain as wrappers.

Macros expand during `parse`. Overloads resolve during `check`, which
binds an **overload id** into the AST. `eval` dispatches on that bound id
and never on runtime types.
Expand Down Expand Up @@ -213,10 +232,13 @@ is ambiguous, cel-go evaluating the same expression is the tiebreak — a
claim about CEL semantics that has not been run against cel-go is a
hypothesis.

**Each file runs under the `env` its features require**, mirroring how
cel-go's own `conformance/conformance_test.go` enables extensions
selectively: `basic`/`comparisons`/`logic` under `standard`,
`string_ext` under `standard + strings`, and so on. A file that passes
**Each file runs under the `env` its features require**:
`basic`/`comparisons`/`logic` under `standard`, `string_ext` under
`standard + strings`, and so on. This is deliberately stricter than
cel-go's own `conformance_test.go`, which (measured) builds one
environment with every extension enabled globally — per-file envs are
the property the registry design exists to prove, and the oracle is
configured per-file the same way when used as tiebreak. A file that passes
only because the default environment quietly gained an extension is not
a passing file — that is the failure mode the env parameter exists to
prevent.
Expand Down Expand Up @@ -300,7 +322,7 @@ burns an hour re-reading 3 000 assertions.

Infrastructure failures must never reach the suite as test failures.
`--wait` cannot return before the schema is in place, a failing
`install.sql` aborts container startup rather than yielding a running
`sql/` script aborts container startup rather than yielding a running
database, and `internal/testdb` names the command that fixes an
unreachable database instead of reporting a bare connection error — a
red conformance run should never be ambiguous about which of the two
Expand Down
95 changes: 68 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
> CEL, natively in Postgres: a zero-dependency PL/pgSQL evaluator for
> Google's Common Expression Language.

**Status: scaffolding.** The development environment, the schema
installer and the test harness exist and are green. The evaluator does
not — there is no parser, checker or evaluator yet, and nothing reads
the [cel-spec][cel-spec] conformance corpus. See [CLAUDE.md](CLAUDE.md)
for the design the next commits are building toward.
**Status: the evaluator is complete and the in-scope conformance
corpus is green.** Parser, checker and evaluator install as plain SQL,
with the standard library, the well-known types and eight extension
libraries. Every in-scope case of the [cel-spec][cel-spec] corpus
passes on a fresh install, with nothing omitted silently — see
[docs/CONFORMANCE.md](docs/CONFORMANCE.md) for what that claim covers
and [the generated report](docs/conformance-report.md) for the
numbers.

## Why

Expand Down Expand Up @@ -37,9 +40,34 @@ actually there, so when the command returns, the database is ready to
use:

```bash
docker compose exec postgres psql -U cel -d cel -c 'SELECT cel.version()'
docker compose exec postgres psql -U cel -d cel \
-c "SELECT cel.evaluate('[1, 2, 3].exists(n, n % 2 == 0)', '{}', 'standard')"
```

```
evaluate
---------------------------
{"v": true, "@t": "bool"}
```

Values are tagged JSON in and out, and the third argument names the
environment. Expressions with free variables need those variables
declared, which means the staged form — `parse`, `check` with
declarations, `eval` with an activation:

```sql
SELECT cel.eval(
cel.check(cel.parse('size(x) > 2', 'standard'), 'standard',
'{"decls": [{"name": "x", "type": {"kind": "string"}}]}'),
'{"x": {"@t": "string", "v": "abc"}}', 'standard');
```

Every stage is pure — nothing writes, so all of them work on standbys
and in read-only transactions, and a compiled AST can be cached in a
table of your own. `parse` is `IMMUTABLE`; `check`, `eval` and
`evaluate` are `STABLE`, since they read the registry. All are
`PARALLEL SAFE`.

Run the test suite — no Go toolchain needed on your machine:

```bash
Expand Down Expand Up @@ -95,44 +123,57 @@ is faster to iterate on. Go 1.26 or newer:
```bash
docker compose up -d
go test ./...
go test ./... -v -run TestSchemaInstalled
go test ./conformance/... -run TestSimple/basic
go test ./conformance/... -run TestSimple/basic/self_eval_zeroish/self_eval_int_zero
```

Both paths run the same tests against the same database.
Both paths run the same tests against the same database. The
conformance suite reads the corpus from a local cel-spec checkout
named by `CEL_EXPR_DIR`, and regenerating the report after a change
is one command:

```bash
go run ./internal/cmd/confreport
```

## Installing into your own database

`sql/install.sql` is an ordinary, idempotent SQL script. Nothing about
it is specific to the compose setup:
The `sql/` scripts are ordinary, idempotent SQL, ordered by their
numeric prefix. Nothing about them is specific to the compose setup:

```bash
psql -v ON_ERROR_STOP=1 -f sql/install.sql "$YOUR_DATABASE_URL"
for f in sql/*.sql; do
psql -v ON_ERROR_STOP=1 -f "$f" "$YOUR_DATABASE_URL"
done
```

It needs a role that may create the `cel` schema. It does not need
superuser.

## Scope

Targeting the cel-spec core language over JSON-representable types, plus
the well-known types (`Timestamp`, `Duration`, `Any`, `Struct`). The
protobuf message surface — message construction, field presence, enums,
wrapper types — is out of scope.
The cel-spec core language over JSON-representable types, plus the
well-known types — `Timestamp`, `Duration`, `Any`, `Struct`, `Value`,
`ListValue` and the `Int32Value`-family wrappers, all of which are
JSON-shaped and need no descriptor pool. The rest of the protobuf
message surface — message construction, field presence, enums — is out
of scope: it needs descriptors inside PostgreSQL and buys nothing for
the JSON-shaped data this targets.

Extension libraries (`strings`, `math`, `lists`, `sets`, `encoders`,
`bindings`, `optionals`) and the OpenFGA dialect (`ipaddress`,
`in_cidr`) are not enabled by default; they register into the evaluator
rather than modifying it, and consumers will be able to register their
own the same way.
The extension libraries (`strings`, `math`, `lists`, `encoders`,
`bindings`, `optionals`, two-variable comprehensions, `network`) are
implemented, each behind its own environment name and none enabled by
default. They register into the evaluator rather than modifying it, and
consumers can register their own the same way.

Conformance is measured against the [cel-spec][cel-spec] corpus with
[cel-go][cel-go] as the behavioural reference, pinned at `v0.32.0`. The
target is 100% of what is in scope, with anything skipped named
explicitly rather than quietly dropped.

The pin is exact and deliberate: which expressions the two
implementations agree on moves with the cel-go version, so upgrading it
means re-measuring conformance, not bumping a dependency.
[cel-go][cel-go] as the behavioural reference, both pinned exactly:
which expressions two implementations agree on moves with the version
of either, so an upgrade re-measures conformance rather than bumping a
dependency. [docs/CONFORMANCE.md](docs/CONFORMANCE.md) states what is
excluded and every place cel4postgres deliberately answers differently
from cel-go; [docs/conformance-report.md](docs/conformance-report.md)
is generated from a run and lists every case not attempted, by name.

## References

Expand Down
5 changes: 5 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ services:
postgres:
condition: service_healthy
environment:
CEL_EXPR_DIR: /cel-expr
DATABASE_URL: postgres://${POSTGRES_USER:-cel}:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-cel}?sslmode=disable
GOCACHE: /tmp/go-build
GOFLAGS: -mod=mod
Expand All @@ -62,4 +63,8 @@ services:
- test
volumes:
- ./:/src
# The conformance corpus, read-only, from the host's checkout
# directory (.env). An empty mount just makes the suite name
# CEL_EXPR_DIR as the fix.
- ${CEL_EXPR_DIR:-./.cel-expr}:/cel-expr:ro
working_dir: /src
34 changes: 34 additions & 0 deletions conformance/envs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package conformance

// fileEnvs names the environment each corpus file runs under, as a
// comma-separated union of registered env names. Files absent here run
// under plain "standard". Extension files enable exactly the extension
// they exercise and nothing more: a file that passes only because the
// default environment quietly gained an extension is not a passing
// file.
//
// macros2 was measured (Phase 0): every one of its 46 cases uses the
// two-var comprehension macros and none uses optional syntax, so the
// whole file takes two_var_comprehensions and nothing else.
var fileEnvs = map[string]string{
"string_ext": "standard,strings",
"math_ext": "standard,math",
"lists_ext": "standard,lists",
"encoders_ext": "standard,encoders",
"bindings_ext": "standard,bindings",
"optionals": "standard,optionals",
"macros2": "standard,two_var_comprehensions",
"network_ext": "standard,network",
// type_deduction's flexible_type_parameter_assignment and
// legacy_nullable_types sections deduce optional_type values;
// everything else in the file is standard.
"type_deduction": "standard,optionals",
}

// EnvFor returns the env parameter for a corpus file.
func EnvFor(file string) string {
if env, ok := fileEnvs[file]; ok {
return env
}
return "standard"
}
Loading