From 5d39ce98d5b64983f4f4602637366e1e4284c5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 18:45:41 -0300 Subject: [PATCH 01/21] Share the .env reader across test packages The conformance suite now needs a second configuration value from .env (CEL_EXPR_DIR, the reference-checkout directory), and testdb's private loader could not serve it. Extracting the reader keeps a single definition of "process environment first, then .env" instead of two parsers that could drift on quoting or precedence. --- internal/dotenv/dotenv.go | 85 ++++++++++++++++++++++++++++ internal/testdb/testdb.go | 84 ++++----------------------- sql/{install.sql => 000_install.sql} | 0 3 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 internal/dotenv/dotenv.go rename sql/{install.sql => 000_install.sql} (100%) diff --git a/internal/dotenv/dotenv.go b/internal/dotenv/dotenv.go new file mode 100644 index 0000000..5ee8c0d --- /dev/null +++ b/internal/dotenv/dotenv.go @@ -0,0 +1,85 @@ +// Package dotenv reads configuration the way the whole toolchain +// agrees to: the process environment first, then the repo's .env file. +// +// Compose reads .env from the repo root automatically; keeping the Go +// suites on the same file means a value changed there applies to both +// sides, and CI -- which exports everything in the process environment +// -- needs no file at all. +package dotenv + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/emfga/cel4postgres/internal/repo" +) + +// Lookup returns the value for key, preferring the process +// environment over the repo's .env file. A missing key returns "". +func Lookup(key string) (string, error) { + if value, ok := os.LookupEnv(key); ok && value != "" { + return value, nil + } + + env, err := load() + if err != nil { + return "", err + } + + return env[key], nil +} + +// load parses the repo's .env file. A missing file is not an error -- +// every variable has a default or arrives via the environment. +func load() (map[string]string, error) { + env := map[string]string{} + + root, err := repo.Root() + if err != nil { + return nil, err + } + + file, err := os.Open(filepath.Join(root, ".env")) + if os.IsNotExist(err) { + return env, nil + } + if err != nil { + return nil, fmt.Errorf("open .env: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + key, value, ok := parseLine(scanner.Text()) + if ok { + env[key] = value + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read .env: %w", err) + } + + return env, nil +} + +// parseLine reads one KEY=VALUE line, ignoring comments and blanks. +func parseLine(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + return "", "", false + } + + key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + + return key, value, key != "" +} diff --git a/internal/testdb/testdb.go b/internal/testdb/testdb.go index e0938bf..56126ab 100644 --- a/internal/testdb/testdb.go +++ b/internal/testdb/testdb.go @@ -11,35 +11,33 @@ package testdb import ( - "bufio" "context" "fmt" "net" "net/url" - "os" - "path/filepath" "strings" "time" "github.com/jackc/pgx/v5" - "github.com/emfga/cel4postgres/internal/repo" + "github.com/emfga/cel4postgres/internal/dotenv" ) // DSN is the connection string for the test database. func DSN() (string, error) { - env, err := loadEnv() - if err != nil { + if dsn, err := dotenv.Lookup("DATABASE_URL"); err != nil { return "", err - } - - if dsn := env["DATABASE_URL"]; dsn != "" { + } else if dsn != "" { return dsn, nil } missing := []string{} + var lookupErr error get := func(key string) string { - value := env[key] + value, err := dotenv.Lookup(key) + if err != nil { + lookupErr = err + } if value == "" { missing = append(missing, key) } @@ -52,6 +50,9 @@ func DSN() (string, error) { port := get("POSTGRES_PORT") database := get("POSTGRES_DB") + if lookupErr != nil { + return "", lookupErr + } if len(missing) > 0 { return "", fmt.Errorf( "missing %s: set them in .env (copy .env.example) or in the environment", @@ -92,66 +93,3 @@ func Connect(ctx context.Context) (*pgx.Conn, error) { return conn, nil } - -// loadEnv returns the process environment overlaid on the repo's .env -// file. Process environment wins, so CI needs no file at all. -func loadEnv() (map[string]string, error) { - env := map[string]string{} - - root, err := repo.Root() - if err != nil { - return nil, err - } - - file, err := os.Open(filepath.Join(root, ".env")) - if err == nil { - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - key, value, ok := parseLine(scanner.Text()) - if ok { - env[key] = value - } - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read .env: %w", err) - } - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("open .env: %w", err) - } - - for _, key := range []string{ - "DATABASE_URL", - "POSTGRES_DB", - "POSTGRES_HOST", - "POSTGRES_PASSWORD", - "POSTGRES_PORT", - "POSTGRES_USER", - } { - if value, ok := os.LookupEnv(key); ok && value != "" { - env[key] = value - } - } - - return env, nil -} - -// parseLine reads one KEY=VALUE line, ignoring comments and blanks. -func parseLine(line string) (string, string, bool) { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - return "", "", false - } - - key, value, ok := strings.Cut(line, "=") - if !ok { - return "", "", false - } - - key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) - value = strings.TrimSpace(value) - value = strings.Trim(value, `"'`) - - return key, value, key != "" -} diff --git a/sql/install.sql b/sql/000_install.sql similarity index 100% rename from sql/install.sql rename to sql/000_install.sql From f4595c3935be946f14071f84ca24ee0dfaf8dedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 18:45:51 -0300 Subject: [PATCH 02/21] Order sql/ scripts by numeric prefix initdb runs /docker-entrypoint-initdb.d alphabetically, and the evaluator will arrive as numbered scripts (020_values, 030_parse, ...) that must run after the schema exists. Renaming install.sql to 000_install.sql makes alphabetical order the install order, so compose and a bare psql loop agree without an orchestrating script. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 61e383f..1587ab0 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,13 @@ Both paths run the same tests against the same database. ## 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 From de7d58ac50cf452fc1cfe44caab6069d3023eaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 18:45:51 -0300 Subject: [PATCH 03/21] Match string(double) formatting to Go's %g The plan was bare float8::text, measured as agreeing with cel-go on five probes. The committed fuzz comparison falsified that on its first run (74/4096 random doubles): Go's shortest %g switches to scientific notation at decimal exponent >= 6 while Postgres stays plain up to e+14, and on values whose shorter form lands exactly on a round-trip tie Go accepts the short form while Postgres's Ryu prints one extra digit. cel-go's string(double) is fmt %g (common/types/double.go:141, v0.32.0), so parity requires re-rendering: cel._double_text applies the exponent rule and shortens digits while the result still casts back to the same double. The fuzz test now holds this function to %g continuously in CI; a mismatch reopens the question with a concrete value in hand. --- conformance/format_test.go | 87 ++++++++++++++++++++++++ sql/020_values.sql | 131 +++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 conformance/format_test.go create mode 100644 sql/020_values.sql diff --git a/conformance/format_test.go b/conformance/format_test.go new file mode 100644 index 0000000..00a503a --- /dev/null +++ b/conformance/format_test.go @@ -0,0 +1,87 @@ +package conformance + +import ( + "context" + "fmt" + "math" + "math/rand/v2" + "testing" + "time" + + "github.com/emfga/cel4postgres/internal/testdb" +) + +// string(double) is implemented by cel._double_text, which must match +// Go's %g exactly, because that is what cel-go's string(double) emits +// (common/types/double.go:141, pinned v0.32.0). The workspace ruling +// on I2 committed this test: it fuzzes that claim on every CI run +// rather than trusting a handful of probes -- and its very first run +// proved bare float8::text insufficient (Go switches to scientific +// notation at e+06, Postgres at e+15), which is why the function +// exists. A mismatch here reopens the formatting question with a +// concrete value in hand. +// +// Non-finite values are excluded by design: they never reach the +// Postgres formatter (the evaluator emits +Inf/-Inf/NaN itself from +// the tagged sentinel strings). +func TestStringDoubleFormatParity(t *testing.T) { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + + // A fresh seed every run is the point (continuous fuzzing); the + // log line is what makes a red run reproducible. + seed := uint64(time.Now().UnixNano()) + t.Logf("seed %d", seed) + rng := rand.New(rand.NewPCG(seed, 0)) + + const n = 4096 + values := make([]float64, 0, n) + for len(values) < n { + v := math.Float64frombits(rng.Uint64()) + if math.IsNaN(v) || math.IsInf(v, 0) { + continue + } + values = append(values, v) + } + // The corners the probes covered stay covered, plus the plain-to- + // scientific boundary the first fuzz run exposed. + values = append(values, + 0.0, math.Copysign(0, -1), 1.0, 123.456, -4.5e-3, + 1e21, 0.000001, math.MaxFloat64, math.SmallestNonzeroFloat64, + 999999.9, 1e6, 1234567.8, 1e15, 999999999999999.0, 0.0001, + ) + + var formatted []string + err = conn.QueryRow(ctx, + "SELECT array_agg(cel._double_text(v) ORDER BY ord) "+ + "FROM unnest($1::float8[]) WITH ORDINALITY AS t(v, ord)", + values, + ).Scan(&formatted) + if err != nil { + t.Fatalf("format via Postgres: %v", err) + } + if len(formatted) != len(values) { + t.Fatalf("Postgres returned %d strings for %d values", + len(formatted), len(values)) + } + + mismatches := 0 + for i, v := range values { + want := fmt.Sprintf("%g", v) + if formatted[i] != want { + mismatches++ + if mismatches <= 10 { + t.Errorf("float8 %b (%v): Postgres %q, Go %%g %q", + v, v, formatted[i], want) + } + } + } + if mismatches > 10 { + t.Errorf("%d mismatches in total", mismatches) + } +} diff --git a/sql/020_values.sql b/sql/020_values.sql new file mode 100644 index 0000000..77dea23 --- /dev/null +++ b/sql/020_values.sql @@ -0,0 +1,131 @@ +-- cel4postgres -- value helpers. +-- +-- Run after 000_install.sql. Everything here is a pure function over +-- tagged jsonb values or their scalar payloads; nothing reads a table. + +BEGIN; + +-- Renders a finite double the way CEL's string(double) must: cel-go +-- delegates to Go's %g (common/types/double.go:141, pinned v0.32.0). +-- Postgres's own float8 output is close but measurably different in +-- two ways (both found by the fuzz test in conformance/format_test.go, +-- which holds this function to Go %g on every CI run): +-- +-- 1. Notation threshold. Go's shortest %g switches to scientific +-- notation when the decimal exponent is < -4 or >= 6 (strconv +-- ftoa.go: "use precision 6 for this decision"); Postgres stays +-- plain up to e+14. +-- +-- 2. Halfway digits. Both emit shortest-round-trip digits, but when +-- a shorter form lands exactly halfway between two doubles and +-- ties-to-even resolves back to the value, Go accepts it and +-- Postgres's Ryu does not (e.g. 4.468743327960138e+16, which Go +-- prints with 16 digits and Postgres with 17). Hence the +-- shortening loop: drop a digit while the result still casts +-- back to the same double. +-- +-- Non-finite doubles never reach this function: the evaluator carries +-- them as the tagged strings "Infinity"/"-Infinity"/"NaN" and renders +-- their CEL text itself. +CREATE OR REPLACE FUNCTION cel._double_text(v float8) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE STRICT +SET search_path = cel, pg_temp +AS $$ +DECLARE + t text := v::text; + neg text := ''; + mant text; + int_part text; + frac text; + digits text; + e int; + m numeric; + cand text; + cand_e int; + shortened text; +BEGIN + IF t LIKE '-%' THEN + neg := '-'; + t := substr(t, 2); + END IF; + + -- Normalize to (digits, e): significant digits with no trailing + -- zeros, and the decimal exponent of the leading digit. + IF t LIKE '%e%' THEN + mant := split_part(t, 'e', 1); + e := split_part(t, 'e', 2)::int; + digits := replace(mant, '.', ''); + ELSE + int_part := split_part(t, '.', 1); + frac := split_part(t, '.', 2); + IF int_part = '0' THEN + -- 0, or 0.00123-style: exponent from the leading zeros. + digits := ltrim(frac, '0'); + IF digits = '' THEN + RETURN neg || '0'; + END IF; + e := -(length(frac) - length(digits)) - 1; + ELSE + digits := int_part || frac; + e := length(int_part) - 1; + END IF; + END IF; + digits := rtrim(digits, '0'); + IF digits = '' THEN + digits := '0'; + e := 0; + END IF; + + -- Shorten while a rounded-off form still round-trips (point 2). + WHILE length(digits) > 1 LOOP + m := round(digits::numeric / 10); + cand := m::text; + cand_e := e + length(cand) - (length(digits) - 1); + cand := rtrim(cand, '0'); + IF cand = '' THEN + cand := '0'; + END IF; + -- A candidate rounded up past 1.7976931348623157e308 would make + -- the round-trip cast raise instead of miss; it cannot be the + -- shortest form of any finite double, so stop shortening there. + IF cand_e > 308 OR (cand_e = 308 + AND rpad(cand, 17, '0') > '17976931348623157') THEN + EXIT; + END IF; + shortened := substr(cand, 1, 1) + || CASE WHEN length(cand) > 1 + THEN '.' || substr(cand, 2) + ELSE '' END + || 'e' || cand_e::text; + EXIT WHEN (neg || shortened)::float8 IS DISTINCT FROM v; + digits := cand; + e := cand_e; + END LOOP; + + -- Render per Go's %g rule (point 1). + IF e < -4 OR e >= 6 THEN + RETURN neg || substr(digits, 1, 1) + || CASE WHEN length(digits) > 1 + THEN '.' || substr(digits, 2) + ELSE '' END + || CASE WHEN e < 0 THEN 'e-' ELSE 'e+' END + -- At least two exponent digits, as Go prints (lpad would + -- truncate three-digit exponents). + || CASE WHEN abs(e) < 10 THEN '0' ELSE '' END + || abs(e)::text; + END IF; + + IF e >= 0 THEN + int_part := rpad(substr(digits, 1, e + 1), e + 1, '0'); + frac := substr(digits, e + 2); + RETURN neg || int_part + || CASE WHEN frac <> '' THEN '.' || frac ELSE '' END; + END IF; + + RETURN neg || '0.' || repeat('0', -e - 1) || digits; +END; +$$; + +COMMIT; From 080d2fb253b1fa44e6cd634ff625a467e7e55831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 18:46:05 -0300 Subject: [PATCH 04/21] Add the conformance corpus runner TestSimple walks every cel-spec simple-test file as file/section/case subtests, so the single-file and single-case selectors work from this first runner commit. Each case runs the parse/check/eval stages separately against the database and fails naming the stage that broke -- today every non-skipped case fails with "cel.parse is not installed", which is the point: the corpus is measurable before the evaluator exists, and an infrastructure problem can never masquerade as a conformance result. What is not attempted is printed, never implied: six files skip with reasons, and 258 descriptor-dependent cases inside included files are named in a generated list (internal/cmd/skipgen scans the corpus for the proto2/proto3 test-message names; a test regenerates and diffs it so the list cannot drift from the checkout). Expected values convert through internal/codec into the tagged-jsonb value shape, with the comparison rules the corpus implies: map entries order-agnostic, NaN equal to NaN, timestamps by instant, eval_error by existence only, and exact int64/uint64 round-trips via json.Number. Each file runs under the env union its features require -- deliberately stricter than cel-go's own harness, which enables every extension globally -- and oracle.Options builds the same composition on the reference side so a disputed case is refereed under identical environments. macros2 was measured to need exactly two_var_comprehensions. CI pins the cel-spec checkout 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. --- .env.example | 4 + .github/workflows/ci.yml | 14 ++ .gitignore | 3 + compose.yaml | 5 + conformance/envs.go | 30 +++ conformance/simple_test.go | 383 +++++++++++++++++++++++++++++++++ conformance/skiplist.go | 105 +++++++++ conformance/skipped_cases.go | 268 +++++++++++++++++++++++ go.mod | 4 +- internal/cmd/skipgen/main.go | 41 ++++ internal/codec/codec.go | 248 +++++++++++++++++++++ internal/codec/codec_test.go | 178 +++++++++++++++ internal/codec/compare.go | 222 +++++++++++++++++++ internal/codec/type.go | 172 +++++++++++++++ internal/corpus/corpus.go | 118 ++++++++++ internal/corpus/corpus_test.go | 60 ++++++ internal/oracle/options.go | 46 ++++ internal/oracle/oracle.go | 8 +- 18 files changed, 1904 insertions(+), 5 deletions(-) create mode 100644 conformance/envs.go create mode 100644 conformance/simple_test.go create mode 100644 conformance/skiplist.go create mode 100644 conformance/skipped_cases.go create mode 100644 internal/cmd/skipgen/main.go create mode 100644 internal/codec/codec.go create mode 100644 internal/codec/codec_test.go create mode 100644 internal/codec/compare.go create mode 100644 internal/codec/type.go create mode 100644 internal/corpus/corpus.go create mode 100644 internal/corpus/corpus_test.go create mode 100644 internal/oracle/options.go diff --git a/.env.example b/.env.example index e317181..c337f59 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bba47c..08c3bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,21 @@ 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. + # 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() diff --git a/.gitignore b/.gitignore index 23e40f5..3e44385 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/compose.yaml b/compose.yaml index 71c1159..d4f4136 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 @@ -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 diff --git a/conformance/envs.go b/conformance/envs.go new file mode 100644 index 0000000..7cde75d --- /dev/null +++ b/conformance/envs.go @@ -0,0 +1,30 @@ +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", +} + +// EnvFor returns the env parameter for a corpus file. +func EnvFor(file string) string { + if env, ok := fileEnvs[file]; ok { + return env + } + return "standard" +} diff --git a/conformance/simple_test.go b/conformance/simple_test.go new file mode 100644 index 0000000..34984d7 --- /dev/null +++ b/conformance/simple_test.go @@ -0,0 +1,383 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "testing" + + test "cel.dev/expr/conformance/test" + "github.com/jackc/pgx/v5" + + "github.com/emfga/cel4postgres/internal/codec" + "github.com/emfga/cel4postgres/internal/corpus" + "github.com/emfga/cel4postgres/internal/testdb" +) + +// TestMain prints the skip report before the run: every file-level +// skip with its reason, and the count of case-level skips. Coverage +// must never shrink silently, so what is not attempted is announced +// on every run, not discoverable only with -v. +func TestMain(m *testing.M) { + files := make([]string, 0, len(SkippedFiles)) + for file := range SkippedFiles { + files = append(files, file) + } + sort.Strings(files) + + fmt.Printf("conformance: skipping %d corpus files:\n", len(files)) + for _, file := range files { + fmt.Printf(" %-12s %s\n", file, SkippedFiles[file]) + } + fmt.Printf( + "conformance: skipping %d cases inside included files "+ + "(conformance/skipped_cases.go)\n", + len(skippedCases), + ) + + os.Exit(m.Run()) +} + +// TestSkipListCurrent keeps the committed skip list identical to what +// the generator derives from the corpus, so the two cannot drift. +func TestSkipListCurrent(t *testing.T) { + want, err := GenerateSkippedCases() + if err != nil { + t.Fatal(err) + } + + if RenderSkippedCases(want) != RenderSkippedCases(skippedCases) { + t.Fatal( + "skipped_cases.go is stale for this corpus checkout: " + + "regenerate with: go run ./internal/cmd/skipgen", + ) + } +} + +// TestSimple runs the cel-spec simple conformance corpus. Subtests are +// named /
/ so the single-file and single-case +// selectors work: +// +// go test ./conformance/... -run TestSimple/basic +// go test ./conformance/... -run TestSimple/basic/self_eval_zeroish/self_eval_int_zero +func TestSimple(t *testing.T) { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + + // Probed once so a not-yet-implemented stage fails each case with + // one clear line instead of thousands of undefined-function SQL + // errors, and so a missing function can never masquerade as a CEL + // error an eval_error case would spuriously pass on. + stages, err := installedStages(ctx, conn) + if err != nil { + t.Fatal(err) + } + + files, err := corpus.Files() + if err != nil { + t.Fatal(err) + } + + for _, file := range files { + t.Run(file, func(t *testing.T) { + if reason, ok := SkippedFiles[file]; ok { + t.Skip(reason) + } + + parsed, err := corpus.Load(file) + if err != nil { + t.Fatal(err) + } + + for _, section := range parsed.GetSection() { + t.Run(section.GetName(), func(t *testing.T) { + for _, tc := range section.GetTest() { + t.Run(tc.GetName(), func(t *testing.T) { + runCase(t, ctx, conn, stages, file, + section.GetName(), tc) + }) + } + }) + } + }) + } +} + +// stageSet records which cel.* entry points exist in the database, so +// failures name the missing stage rather than surfacing SQL errors. +type stageSet struct { + parse, check, eval bool +} + +func installedStages( + ctx context.Context, conn *pgx.Conn, +) (stageSet, error) { + var stages stageSet + err := conn.QueryRow(ctx, + `SELECT to_regprocedure('cel.parse(text, text)') IS NOT NULL, + to_regprocedure('cel.check(jsonb, text, jsonb)') + IS NOT NULL, + to_regprocedure('cel.eval(jsonb, jsonb, text)') + IS NOT NULL`, + ).Scan(&stages.parse, &stages.check, &stages.eval) + if err != nil { + return stages, fmt.Errorf("probe installed stages: %w", err) + } + return stages, nil +} + +// expectsError reports whether the case's result matcher is +// eval_error. Parse and check failures pass exactly these cases; the +// corpus never asserts on error message text (measured -- plumbing +// carries a deliberately bogus one), so existence is the whole test. +func expectsError(tc *test.SimpleTest) bool { + _, ok := tc.GetResultMatcher().(*test.SimpleTest_EvalError) + return ok +} + +// stageErrors extracts the {"errors": [...]} failure shape that +// cel.parse and cel.check return instead of an envelope. +func stageErrors(envelope any) ([]any, bool) { + m, ok := envelope.(map[string]any) + if !ok { + return nil, false + } + errs, ok := m["errors"].([]any) + return errs, ok +} + +func runCase( + t *testing.T, + ctx context.Context, + conn *pgx.Conn, + stages stageSet, + file, section string, + tc *test.SimpleTest, +) { + if reason := skipReason(file, section, tc.GetName()); reason != "" { + t.Skip(reason) + } + + env := EnvFor(file) + + // Parse. + if !stages.parse { + t.Fatal("parse stage: cel.parse(text, text) is not installed") + } + var raw []byte + err := conn.QueryRow(ctx, + "SELECT cel.parse($1, $2)", tc.GetExpr(), env, + ).Scan(&raw) + if err != nil { + t.Fatalf("parse stage: %v", err) + } + ast, err := codec.Decode(raw) + if err != nil { + t.Fatalf("parse stage: %v", err) + } + if errs, failed := stageErrors(ast); failed { + if expectsError(tc) { + return + } + t.Fatalf("parse stage: expression rejected: %v", errs) + } + + // Check. + if !tc.GetDisableCheck() { + if !stages.check { + t.Fatal( + "check stage: cel.check(jsonb, text, jsonb) " + + "is not installed", + ) + } + options, err := checkOptions(tc) + if err != nil { + t.Fatalf("check stage: %v", err) + } + err = conn.QueryRow(ctx, + "SELECT cel.check($1, $2, $3)", raw, env, options, + ).Scan(&raw) + if err != nil { + t.Fatalf("check stage: %v", err) + } + ast, err = codec.Decode(raw) + if err != nil { + t.Fatalf("check stage: %v", err) + } + if errs, failed := stageErrors(ast); failed { + if expectsError(tc) { + return + } + t.Fatalf("check stage: expression rejected: %v", errs) + } + } + + if tc.GetCheckOnly() { + compareDeducedType(t, ast, tc) + return + } + + // Eval. + if !stages.eval { + t.Fatal( + "eval stage: cel.eval(jsonb, jsonb, text) is not installed", + ) + } + activation, err := activationJSON(tc) + if err != nil { + t.Fatalf("eval stage: %v", err) + } + var rawResult []byte + err = conn.QueryRow(ctx, + "SELECT cel.eval($1, $2, $3)", raw, activation, env, + ).Scan(&rawResult) + if err != nil { + t.Fatalf("eval stage: %v", err) + } + got, err := codec.Decode(rawResult) + if err != nil { + t.Fatalf("eval stage: %v", err) + } + + compareResult(t, got, rawResult, tc) + + // typed_result compares the deduced type in addition to the value. + if _, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult); ok { + compareDeducedType(t, ast, tc) + } +} + +// checkOptions builds the options argument of cel.check (decision 7): +// the case's container and its type_env ident declarations. +func checkOptions(tc *test.SimpleTest) ([]byte, error) { + options := map[string]any{} + if tc.GetContainer() != "" { + options["container"] = tc.GetContainer() + } + if typeEnv := tc.GetTypeEnv(); len(typeEnv) > 0 { + decls := []any{} + for _, decl := range typeEnv { + converted, err := codec.FromDecl(decl) + if err != nil { + return nil, err + } + decls = append(decls, converted) + } + options["decls"] = decls + } + return json.Marshal(options) +} + +// activationJSON builds cel.eval's activation: binding name to tagged +// value, always tagged -- the runner never sends plain JSON the +// evaluator would have to guess about. +func activationJSON(tc *test.SimpleTest) ([]byte, error) { + activation := map[string]any{} + for name, value := range tc.GetBindings() { + tagged, err := codec.FromExprValue(value) + if err != nil { + return nil, fmt.Errorf("binding %q: %w", name, err) + } + activation[name] = tagged + } + return json.Marshal(activation) +} + +// compareResult applies the case's result matcher. A missing matcher +// defaults to value: bool true (measured, workspace doc 01). +func compareResult( + t *testing.T, got any, rawResult []byte, tc *test.SimpleTest, +) { + switch matcher := tc.GetResultMatcher().(type) { + case *test.SimpleTest_Value: + want, err := codec.FromValue(matcher.Value) + if err != nil { + t.Fatalf("convert expected value: %v", err) + } + if !codec.Equal(want, got) { + wantJSON, _ := json.Marshal(want) + t.Fatalf("result mismatch:\n want %s\n got %s", + wantJSON, rawResult) + } + case *test.SimpleTest_EvalError: + if kind, _ := taggedKind(got); kind != "error" { + t.Fatalf("expected an error value, got %s", rawResult) + } + case *test.SimpleTest_TypedResult: + want, err := codec.FromValue(matcher.TypedResult.GetResult()) + if err != nil { + t.Fatalf("convert expected value: %v", err) + } + if !codec.Equal(want, got) { + wantJSON, _ := json.Marshal(want) + t.Fatalf("result mismatch:\n want %s\n got %s", + wantJSON, rawResult) + } + case nil: + want := map[string]any{"@t": "bool", "v": true} + if !codec.Equal(want, got) { + t.Fatalf("expected bool true, got %s", rawResult) + } + default: + // unknown / any_unknowns / any_eval_errors: zero corpus + // cases use them today (measured); a corpus update that + // introduces one must extend the runner, not pass silently. + t.Fatalf("unsupported result matcher %T", matcher) + } +} + +// compareDeducedType compares the checked AST's root type against the +// typed_result matcher's deduced type (check_only cases, +// type_deduction file). +func compareDeducedType(t *testing.T, ast any, tc *test.SimpleTest) { + typed, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult) + if !ok { + t.Fatalf("check_only case without typed_result matcher") + } + + want, err := codec.FromType(typed.TypedResult.GetDeducedType()) + if err != nil { + t.Fatalf("convert expected type: %v", err) + } + + envelope, ok := ast.(map[string]any) + if !ok { + t.Fatalf("checked AST is not an object") + } + root, ok := envelope["expr"].(map[string]any) + if !ok { + t.Fatalf("checked AST has no root expression") + } + rootID, ok := root["id"].(json.Number) + if !ok { + t.Fatalf("root expression has no id") + } + types, ok := envelope["types"].(map[string]any) + if !ok { + t.Fatalf("AST is not checked: no types map") + } + + got := types[rootID.String()] + wantJSON, _ := json.Marshal(want) + gotJSON, _ := json.Marshal(got) + if string(wantJSON) != string(gotJSON) { + t.Fatalf("deduced type mismatch:\n want %s\n got %s", + wantJSON, gotJSON) + } +} + +func taggedKind(v any) (string, bool) { + m, ok := v.(map[string]any) + if !ok { + return "", false + } + kind, ok := m["@t"].(string) + return kind, ok +} diff --git a/conformance/skiplist.go b/conformance/skiplist.go new file mode 100644 index 0000000..32fdacc --- /dev/null +++ b/conformance/skiplist.go @@ -0,0 +1,105 @@ +// Package conformance runs the cel-spec conformance corpus against +// cel4postgres. The non-test files here are the parts the skip-list +// generator (internal/cmd/skipgen) also needs: what is skipped, and +// under which environment each file runs. +package conformance + +import ( + "fmt" + "go/format" + "sort" + "strings" + + "github.com/emfga/cel4postgres/internal/corpus" +) + +// SkippedFiles names the corpus files the suite does not attempt at +// all, each with its reason. The suite prints this list on every run: +// the difference between "not implemented" and "not attempted" must +// stay visible, and coverage must never shrink silently. +var SkippedFiles = map[string]string{ + "proto2": "requires protobuf descriptors", + "proto3": "requires protobuf descriptors", + "enums": "requires protobuf descriptors", + "wrappers": "requires proto3 TestAllTypes + Any unpacking", + "proto2_ext": "requires proto2 extensions", + "block_ext": "cel-go-internal cel.block form; no consumer", +} + +// skipReason returns the reason a case is skipped, or "" to run it. +func skipReason(file, section, name string) string { + if reason, ok := SkippedFiles[file]; ok { + return reason + } + return skippedCases[file+"/"+section+"/"+name] +} + +// GenerateSkippedCases scans the corpus for cases inside included +// files that need protobuf descriptors. It is the single source of +// the generated skipped_cases.go (via internal/cmd/skipgen) and of +// the test that keeps that file current, so the committed list cannot +// drift from the corpus. +func GenerateSkippedCases() (map[string]string, error) { + files, err := corpus.Files() + if err != nil { + return nil, err + } + + skipped := map[string]string{} + for _, file := range files { + if _, ok := SkippedFiles[file]; ok { + continue + } + parsed, err := corpus.Load(file) + if err != nil { + return nil, err + } + for _, section := range parsed.GetSection() { + for _, test := range section.GetTest() { + if corpus.DescriptorDependent(test) { + key := file + "/" + section.GetName() + + "/" + test.GetName() + skipped[key] = "requires protobuf descriptors" + } + } + } + } + + return skipped, nil +} + +// RenderSkippedCases renders the generated map as the Go source of +// skipped_cases.go. +func RenderSkippedCases(skipped map[string]string) string { + keys := make([]string, 0, len(skipped)) + for key := range skipped { + keys = append(keys, key) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString( + "// Code generated by internal/cmd/skipgen; DO NOT EDIT.\n" + + "//\n" + + "// Every entry is a case inside an included corpus file " + + "whose content\n" + + "// references the descriptor-backed test messages. " + + "Regenerate with:\n" + + "//\n" + + "//\tgo run ./internal/cmd/skipgen\n" + + "package conformance\n\n" + + "var skippedCases = map[string]string{\n", + ) + for _, key := range keys { + fmt.Fprintf(&b, "\t%q: %q,\n", key, skipped[key]) + } + b.WriteString("}\n") + + source, err := format.Source([]byte(b.String())) + if err != nil { + // The template is static; a formatting failure means the + // template itself broke, which the skip-list test catches. + return b.String() + } + return string(source) +} diff --git a/conformance/skipped_cases.go b/conformance/skipped_cases.go new file mode 100644 index 0000000..b91bb05 --- /dev/null +++ b/conformance/skipped_cases.go @@ -0,0 +1,268 @@ +// Code generated by internal/cmd/skipgen; DO NOT EDIT. +// +// Every entry is a case inside an included corpus file whose content +// references the descriptor-backed test messages. Regenerate with: +// +// go run ./internal/cmd/skipgen +package conformance + +var skippedCases = map[string]string{ + "comparisons/eq_literal/not_eq_dyn_proto2_msg_null": "requires protobuf descriptors", + "comparisons/eq_literal/not_eq_dyn_proto3_msg_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_bool_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_bool_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_bytes_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_bytes_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_double_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_double_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_float_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_float_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_int32_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_int32_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_int64_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_int64_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto2": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto2_any_unpack_bytewise_fallback_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto2_any_unpack_bytewise_fallback_not_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto2_any_unpack_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto2_any_unpack_not_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto2_missing_fields_neq": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto3": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto3_any_unpack_bytewise_fallback_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto3_any_unpack_bytewise_fallback_not_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto3_any_unpack_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto3_any_unpack_not_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto3_missing_fields_neq": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto_different_types": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_proto_nan_equal": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_string_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_string_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_uint32_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_uint32_proto3_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_uint64_proto2_null": "requires protobuf descriptors", + "comparisons/eq_wrapper/eq_uint64_proto3_null": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto2": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto2_any_unpack": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto2_any_unpack_bytewise_fallback": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto2_missing_fields_neq": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto3": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto3_any_unpack": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto3_any_unpack_bytewise_fallback": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto3_missing_fields_neq": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto_different_types": "requires protobuf descriptors", + "comparisons/ne_literal/ne_proto_nan_not_equal": "requires protobuf descriptors", + "dynamic/any/field_assign_proto2": "requires protobuf descriptors", + "dynamic/any/field_assign_proto3": "requires protobuf descriptors", + "dynamic/any/field_read_proto2": "requires protobuf descriptors", + "dynamic/any/field_read_proto3": "requires protobuf descriptors", + "dynamic/any/literal": "requires protobuf descriptors", + "dynamic/any/literal_no_field_access": "requires protobuf descriptors", + "dynamic/any/var": "requires protobuf descriptors", + "dynamic/bool/field_assign_proto2": "requires protobuf descriptors", + "dynamic/bool/field_assign_proto2_false": "requires protobuf descriptors", + "dynamic/bool/field_assign_proto3": "requires protobuf descriptors", + "dynamic/bool/field_assign_proto3_false": "requires protobuf descriptors", + "dynamic/bytes/field_assign_proto2": "requires protobuf descriptors", + "dynamic/bytes/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/bytes/field_assign_proto3": "requires protobuf descriptors", + "dynamic/bytes/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/complex/any_list_map": "requires protobuf descriptors", + "dynamic/double/field_assign_proto2": "requires protobuf descriptors", + "dynamic/double/field_assign_proto2_range": "requires protobuf descriptors", + "dynamic/double/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/double/field_assign_proto3": "requires protobuf descriptors", + "dynamic/double/field_assign_proto3_range": "requires protobuf descriptors", + "dynamic/double/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/double/field_read_proto2": "requires protobuf descriptors", + "dynamic/double/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/double/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/double/field_read_proto3": "requires protobuf descriptors", + "dynamic/double/field_read_proto3_unset": "requires protobuf descriptors", + "dynamic/double/field_read_proto3_zero": "requires protobuf descriptors", + "dynamic/float/field_assign_proto2": "requires protobuf descriptors", + "dynamic/float/field_assign_proto2_range": "requires protobuf descriptors", + "dynamic/float/field_assign_proto2_round_to_zero": "requires protobuf descriptors", + "dynamic/float/field_assign_proto2_subnorm": "requires protobuf descriptors", + "dynamic/float/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/float/field_assign_proto3": "requires protobuf descriptors", + "dynamic/float/field_assign_proto3_range": "requires protobuf descriptors", + "dynamic/float/field_assign_proto3_round_to_zero": "requires protobuf descriptors", + "dynamic/float/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/float/field_read_proto2": "requires protobuf descriptors", + "dynamic/float/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/float/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/float/field_read_proto3": "requires protobuf descriptors", + "dynamic/float/field_read_proto3_unset": "requires protobuf descriptors", + "dynamic/float/field_read_proto3_zero": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto2": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto2_max": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto2_min": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto2_range": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto3": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto3_max": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto3_min": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto3_range": "requires protobuf descriptors", + "dynamic/int32/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/int32/field_read_proto2": "requires protobuf descriptors", + "dynamic/int32/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/int32/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/int32/field_read_proto3": "requires protobuf descriptors", + "dynamic/int32/field_read_proto3_unset": "requires protobuf descriptors", + "dynamic/int32/field_read_proto3_zero": "requires protobuf descriptors", + "dynamic/int64/field_assign_proto2": "requires protobuf descriptors", + "dynamic/int64/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/int64/field_assign_proto3": "requires protobuf descriptors", + "dynamic/int64/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/list/field_assign_proto2": "requires protobuf descriptors", + "dynamic/list/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/list/field_assign_proto3": "requires protobuf descriptors", + "dynamic/list/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/list/field_read_proto2": "requires protobuf descriptors", + "dynamic/list/field_read_proto2_empty": "requires protobuf descriptors", + "dynamic/list/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/list/field_read_proto3": "requires protobuf descriptors", + "dynamic/list/field_read_proto3_empty": "requires protobuf descriptors", + "dynamic/list/field_read_proto3_unset": "requires protobuf descriptors", + "dynamic/string/field_assign_proto2": "requires protobuf descriptors", + "dynamic/string/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/string/field_assign_proto3": "requires protobuf descriptors", + "dynamic/string/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/struct/field_assign_proto2": "requires protobuf descriptors", + "dynamic/struct/field_assign_proto2_bad": "requires protobuf descriptors", + "dynamic/struct/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/struct/field_assign_proto3": "requires protobuf descriptors", + "dynamic/struct/field_assign_proto3_bad": "requires protobuf descriptors", + "dynamic/struct/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/struct/field_read_proto2": "requires protobuf descriptors", + "dynamic/struct/field_read_proto2_empty": "requires protobuf descriptors", + "dynamic/struct/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/struct/field_read_proto3": "requires protobuf descriptors", + "dynamic/struct/field_read_proto3_empty": "requires protobuf descriptors", + "dynamic/struct/field_read_proto3_unset": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto2": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto2_max": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto2_range": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto3": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto3_max": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto3_range": "requires protobuf descriptors", + "dynamic/uint32/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/uint32/field_read_proto2": "requires protobuf descriptors", + "dynamic/uint32/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/uint32/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/uint64/field_assign_proto2": "requires protobuf descriptors", + "dynamic/uint64/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/uint64/field_assign_proto3": "requires protobuf descriptors", + "dynamic/uint64/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/uint64/field_read_proto2": "requires protobuf descriptors", + "dynamic/uint64/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/uint64/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/value_bool/field_assign_proto2": "requires protobuf descriptors", + "dynamic/value_bool/field_assign_proto2_false": "requires protobuf descriptors", + "dynamic/value_bool/field_assign_proto3": "requires protobuf descriptors", + "dynamic/value_bool/field_assign_proto3_false": "requires protobuf descriptors", + "dynamic/value_bool/field_read_proto2": "requires protobuf descriptors", + "dynamic/value_bool/field_read_proto2_false": "requires protobuf descriptors", + "dynamic/value_bool/field_read_proto3": "requires protobuf descriptors", + "dynamic/value_bool/field_read_proto3_false": "requires protobuf descriptors", + "dynamic/value_list/field_assign_proto2": "requires protobuf descriptors", + "dynamic/value_list/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/value_list/field_assign_proto3": "requires protobuf descriptors", + "dynamic/value_list/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/value_list/field_read_proto2": "requires protobuf descriptors", + "dynamic/value_list/field_read_proto2_empty": "requires protobuf descriptors", + "dynamic/value_list/field_read_proto3": "requires protobuf descriptors", + "dynamic/value_list/field_read_proto3_empty": "requires protobuf descriptors", + "dynamic/value_null/field_assign_proto2": "requires protobuf descriptors", + "dynamic/value_null/field_assign_proto3": "requires protobuf descriptors", + "dynamic/value_null/field_read_proto2": "requires protobuf descriptors", + "dynamic/value_null/field_read_proto2_unset": "requires protobuf descriptors", + "dynamic/value_null/field_read_proto3": "requires protobuf descriptors", + "dynamic/value_null/field_read_proto3_unset": "requires protobuf descriptors", + "dynamic/value_number/field_assign_proto2": "requires protobuf descriptors", + "dynamic/value_number/field_assign_proto2_zero": "requires protobuf descriptors", + "dynamic/value_number/field_assign_proto3": "requires protobuf descriptors", + "dynamic/value_number/field_assign_proto3_zero": "requires protobuf descriptors", + "dynamic/value_number/field_read_proto2": "requires protobuf descriptors", + "dynamic/value_number/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/value_number/field_read_proto3": "requires protobuf descriptors", + "dynamic/value_number/field_read_proto3_zero": "requires protobuf descriptors", + "dynamic/value_string/field_assign_proto2": "requires protobuf descriptors", + "dynamic/value_string/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/value_string/field_assign_proto3": "requires protobuf descriptors", + "dynamic/value_string/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/value_string/field_read_proto2": "requires protobuf descriptors", + "dynamic/value_string/field_read_proto2_zero": "requires protobuf descriptors", + "dynamic/value_string/field_read_proto3": "requires protobuf descriptors", + "dynamic/value_string/field_read_proto3_zero": "requires protobuf descriptors", + "dynamic/value_struct/field_assign_proto2": "requires protobuf descriptors", + "dynamic/value_struct/field_assign_proto2_empty": "requires protobuf descriptors", + "dynamic/value_struct/field_assign_proto3": "requires protobuf descriptors", + "dynamic/value_struct/field_assign_proto3_empty": "requires protobuf descriptors", + "dynamic/value_struct/field_read_proto2": "requires protobuf descriptors", + "dynamic/value_struct/field_read_proto2_empty": "requires protobuf descriptors", + "dynamic/value_struct/field_read_proto3": "requires protobuf descriptors", + "dynamic/value_struct/field_read_proto3_empty": "requires protobuf descriptors", + "optionals/optionals/empty_struct_optindex_hasValue": "requires protobuf descriptors", + "optionals/optionals/has_optional_ofNonZeroValue_struct_optional_ofNonZeroValue_map_optindex_field": "requires protobuf descriptors", + "optionals/optionals/optional_empty_struct_optindex_hasValue": "requires protobuf descriptors", + "optionals/optionals/optional_ofNonZeroValue_struct_optional_ofNonZeroValue_map_optindex_field": "requires protobuf descriptors", + "optionals/optionals/optional_struct_optindex_index_value": "requires protobuf descriptors", + "optionals/optionals/optional_struct_optindex_value": "requires protobuf descriptors", + "optionals/optionals/struct_list_optindex_field": "requires protobuf descriptors", + "optionals/optionals/struct_map_optindex_field": "requires protobuf descriptors", + "optionals/optionals/struct_map_optindex_field_nested": "requires protobuf descriptors", + "optionals/optionals/struct_optindex_value": "requires protobuf descriptors", + "optionals/optionals/struct_optional_ofNonZeroValue_map_optindex_field": "requires protobuf descriptors", + "parse/comments/new_line_terminated": "requires protobuf descriptors", + "parse/nest/message_literal": "requires protobuf descriptors", + "parse/repeat/message_literal": "requires protobuf descriptors", + "parse/repeat/select": "requires protobuf descriptors", + "parse/struct_field_names/as": "requires protobuf descriptors", + "parse/struct_field_names/break": "requires protobuf descriptors", + "parse/struct_field_names/const": "requires protobuf descriptors", + "parse/struct_field_names/continue": "requires protobuf descriptors", + "parse/struct_field_names/else": "requires protobuf descriptors", + "parse/struct_field_names/for": "requires protobuf descriptors", + "parse/struct_field_names/function": "requires protobuf descriptors", + "parse/struct_field_names/if": "requires protobuf descriptors", + "parse/struct_field_names/import": "requires protobuf descriptors", + "parse/struct_field_names/let": "requires protobuf descriptors", + "parse/struct_field_names/loop": "requires protobuf descriptors", + "parse/struct_field_names/namespace": "requires protobuf descriptors", + "parse/struct_field_names/package": "requires protobuf descriptors", + "parse/struct_field_names/return": "requires protobuf descriptors", + "parse/struct_field_names/var": "requires protobuf descriptors", + "parse/struct_field_names/void": "requires protobuf descriptors", + "parse/struct_field_names/while": "requires protobuf descriptors", + "parse/whitespace/carriage_returns": "requires protobuf descriptors", + "parse/whitespace/new_lines": "requires protobuf descriptors", + "parse/whitespace/new_pages": "requires protobuf descriptors", + "parse/whitespace/spaces": "requires protobuf descriptors", + "parse/whitespace/tabs": "requires protobuf descriptors", + "string_ext/format_errors/object inside list": "requires protobuf descriptors", + "string_ext/format_errors/object inside map": "requires protobuf descriptors", + "string_ext/format_errors/object not allowed": "requires protobuf descriptors", + "type_deduction/complex_initializers/struct": "requires protobuf descriptors", + "type_deduction/field_access/enum_field": "requires protobuf descriptors", + "type_deduction/field_access/enum_map_field": "requires protobuf descriptors", + "type_deduction/field_access/int_field": "requires protobuf descriptors", + "type_deduction/field_access/map_bool_int": "requires protobuf descriptors", + "type_deduction/field_access/repeated_enum_field": "requires protobuf descriptors", + "type_deduction/field_access/repeated_int_field": "requires protobuf descriptors", + "type_deduction/flexible_type_parameter_assignment/comprehension_type_var_aliasing": "requires protobuf descriptors", + "type_deduction/flexible_type_parameter_assignment/list_parameters_do_not_unify": "requires protobuf descriptors", + "type_deduction/flexible_type_parameter_assignment/overload_type_var_aliasing": "requires protobuf descriptors", + "type_deduction/legacy_nullable_types/null_assignable_to_duration_parameter_candidate": "requires protobuf descriptors", + "type_deduction/legacy_nullable_types/null_assignable_to_message_parameter_candidate": "requires protobuf descriptors", + "type_deduction/legacy_nullable_types/null_assignable_to_timestamp_parameter_candidate": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_dyn_promotion": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_dyn_promotion_2": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_null_assignable": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_primitive_assignable": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_promotion": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_promotion_2": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_ternary_parameter_assignment": "requires protobuf descriptors", + "type_deduction/wrappers/wrapper_ternary_parameter_assignment_2": "requires protobuf descriptors", +} diff --git a/go.mod b/go.mod index 5ea7a80..7d2f30e 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.26 require ( cel.dev/cel-go v0.32.0 + cel.dev/expr v0.25.1 github.com/jackc/pgx/v5 v5.10.0 + google.golang.org/protobuf v1.36.10 ) require ( - cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -19,5 +20,4 @@ require ( golang.org/x/text v0.29.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect - google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/internal/cmd/skipgen/main.go b/internal/cmd/skipgen/main.go new file mode 100644 index 0000000..640950c --- /dev/null +++ b/internal/cmd/skipgen/main.go @@ -0,0 +1,41 @@ +// Command skipgen regenerates conformance/skipped_cases.go from the +// corpus. Run it after moving the cel-spec checkout to a new commit; +// the diff of the generated file is how a corpus update is reviewed. +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/emfga/cel4postgres/conformance" + "github.com/emfga/cel4postgres/internal/repo" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "skipgen:", err) + os.Exit(1) + } +} + +func run() error { + skipped, err := conformance.GenerateSkippedCases() + if err != nil { + return err + } + + root, err := repo.Root() + if err != nil { + return err + } + + path := filepath.Join(root, "conformance", "skipped_cases.go") + source := conformance.RenderSkippedCases(skipped) + if err := os.WriteFile(path, []byte(source), 0o644); err != nil { + return err + } + + fmt.Printf("wrote %d skipped cases to %s\n", len(skipped), path) + return nil +} diff --git a/internal/codec/codec.go b/internal/codec/codec.go new file mode 100644 index 0000000..fc8b989 --- /dev/null +++ b/internal/codec/codec.go @@ -0,0 +1,248 @@ +// Package codec converts between the conformance corpus's protobuf +// value/type messages and the tagged-jsonb representation cel4postgres +// uses (workspace docs 02 and 03: tag key "@t", maps as entry arrays, +// timestamps as {s, n, tz}, non-finite doubles as strings). +// +// Numbers are carried as json.Number end to end so int64/uint64 +// payloads never pass through a float64. +package codec + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + + expr "cel.dev/expr" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// Tagged is one CEL value in the tagged-jsonb shape. +type Tagged = map[string]any + +func tag(kind string, payload any) Tagged { + return Tagged{"@t": kind, "v": payload} +} + +func intNum(v int64) json.Number { + return json.Number(strconv.FormatInt(v, 10)) +} + +func uintNum(v uint64) json.Number { + return json.Number(strconv.FormatUint(v, 10)) +} + +// doublePayload renders a float64 the way the value spec requires: +// non-finite values as the three sentinel strings, finite values as a +// shortest-round-trip number. +func doublePayload(v float64) any { + switch { + case math.IsNaN(v): + return "NaN" + case math.IsInf(v, 1): + return "Infinity" + case math.IsInf(v, -1): + return "-Infinity" + } + return json.Number(strconv.FormatFloat(v, 'g', -1, 64)) +} + +// FromExprValue converts a corpus binding or expected result. Errors +// and unknowns are values in this representation (workspace doc 02), +// so all three ExprValue kinds map to a tag. +func FromExprValue(v *expr.ExprValue) (Tagged, error) { + switch kind := v.GetKind().(type) { + case *expr.ExprValue_Value: + return FromValue(kind.Value) + case *expr.ExprValue_Error: + msg := "" + if errs := kind.Error.GetErrors(); len(errs) > 0 { + msg = errs[0].GetMessage() + } + return tag("error", map[string]any{"msg": msg}), nil + case *expr.ExprValue_Unknown: + ids := []any{} + for _, id := range kind.Unknown.GetExprs() { + ids = append(ids, intNum(id)) + } + return tag("unknown", ids), nil + } + return nil, fmt.Errorf("ExprValue with no kind") +} + +// FromValue converts a corpus Value proto to a tagged value. +func FromValue(v *expr.Value) (Tagged, error) { + switch kind := v.GetKind().(type) { + case *expr.Value_NullValue: + return tag("null", nil), nil + case *expr.Value_BoolValue: + return tag("bool", kind.BoolValue), nil + case *expr.Value_Int64Value: + return tag("int", intNum(kind.Int64Value)), nil + case *expr.Value_Uint64Value: + return tag("uint", uintNum(kind.Uint64Value)), nil + case *expr.Value_DoubleValue: + return tag("double", doublePayload(kind.DoubleValue)), nil + case *expr.Value_StringValue: + return tag("string", kind.StringValue), nil + case *expr.Value_BytesValue: + encoded := base64.StdEncoding.EncodeToString(kind.BytesValue) + return tag("bytes", encoded), nil + case *expr.Value_TypeValue: + return tag("type", kind.TypeValue), nil + case *expr.Value_ListValue: + elems := []any{} + for _, e := range kind.ListValue.GetValues() { + tagged, err := FromValue(e) + if err != nil { + return nil, err + } + elems = append(elems, tagged) + } + return tag("list", elems), nil + case *expr.Value_MapValue: + entries := []any{} + for _, e := range kind.MapValue.GetEntries() { + k, err := FromValue(e.GetKey()) + if err != nil { + return nil, err + } + val, err := FromValue(e.GetValue()) + if err != nil { + return nil, err + } + entries = append(entries, map[string]any{"k": k, "v": val}) + } + return tag("map", entries), nil + case *expr.Value_ObjectValue: + message, err := kind.ObjectValue.UnmarshalNew() + if err != nil { + return nil, fmt.Errorf( + "unpack Any %q: requires protobuf descriptors (%w)", + kind.ObjectValue.GetTypeUrl(), err, + ) + } + return fromWellKnown(message) + case *expr.Value_EnumValue: + return nil, fmt.Errorf("enum values require protobuf descriptors") + } + return nil, fmt.Errorf("Value with no kind") +} + +// fromWellKnown converts the well-known-type messages that are in +// scope without a descriptor pool (decision 3). Anything else is a +// descriptor-dependent case the skip list already names. +func fromWellKnown(message any) (Tagged, error) { + switch m := message.(type) { + case *timestamppb.Timestamp: + // Proto timestamps are UTC by definition, hence offset 0. + return tag("timestamp", map[string]any{ + "s": intNum(m.GetSeconds()), + "n": intNum(int64(m.GetNanos())), + "tz": intNum(0), + }), nil + case *durationpb.Duration: + nanos := m.GetSeconds()*1_000_000_000 + int64(m.GetNanos()) + return tag("duration", intNum(nanos)), nil + case *wrapperspb.BoolValue: + return tag("bool", m.GetValue()), nil + case *wrapperspb.Int32Value: + return tag("int", intNum(int64(m.GetValue()))), nil + case *wrapperspb.Int64Value: + return tag("int", intNum(m.GetValue())), nil + case *wrapperspb.UInt32Value: + return tag("uint", uintNum(uint64(m.GetValue()))), nil + case *wrapperspb.UInt64Value: + return tag("uint", uintNum(m.GetValue())), nil + case *wrapperspb.FloatValue: + return tag("double", doublePayload(float64(m.GetValue()))), nil + case *wrapperspb.DoubleValue: + return tag("double", doublePayload(m.GetValue())), nil + case *wrapperspb.StringValue: + return tag("string", m.GetValue()), nil + case *wrapperspb.BytesValue: + encoded := base64.StdEncoding.EncodeToString(m.GetValue()) + return tag("bytes", encoded), nil + case *structpb.Value: + return fromJSON(m), nil + case *structpb.Struct: + return fromJSONStruct(m), nil + case *structpb.ListValue: + return fromJSONList(m), nil + } + return nil, fmt.Errorf( + "object value %T requires protobuf descriptors", message, + ) +} + +// fromJSON applies google.protobuf.Value semantics: JSON numbers are +// CEL doubles, objects are map(string, dyn), arrays are list(dyn). +func fromJSON(v *structpb.Value) Tagged { + switch kind := v.GetKind().(type) { + case *structpb.Value_NullValue, nil: + return tag("null", nil) + case *structpb.Value_BoolValue: + return tag("bool", kind.BoolValue) + case *structpb.Value_NumberValue: + return tag("double", doublePayload(kind.NumberValue)) + case *structpb.Value_StringValue: + return tag("string", kind.StringValue) + case *structpb.Value_StructValue: + return fromJSONStruct(kind.StructValue) + case *structpb.Value_ListValue: + return fromJSONList(kind.ListValue) + } + return tag("null", nil) +} + +func fromJSONStruct(s *structpb.Struct) Tagged { + // Field order in a proto map is nondeterministic; sort so the + // conversion is stable. Comparison is order-agnostic anyway. + fields := s.GetFields() + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + + entries := []any{} + for _, name := range names { + entries = append(entries, map[string]any{ + "k": tag("string", name), + "v": fromJSON(fields[name]), + }) + } + return tag("map", entries) +} + +func fromJSONList(l *structpb.ListValue) Tagged { + elems := []any{} + for _, e := range l.GetValues() { + elems = append(elems, fromJSON(e)) + } + return tag("list", elems) +} + +// Marshal renders a tagged value as JSON for sending to Postgres. +func Marshal(v Tagged) ([]byte, error) { + return json.Marshal(v) +} + +// Decode parses JSON returned by Postgres, keeping numbers as +// json.Number so int64/uint64 payloads survive exactly. +func Decode(data []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + + var v any + if err := decoder.Decode(&v); err != nil { + return nil, fmt.Errorf("decode tagged value: %w", err) + } + return v, nil +} diff --git a/internal/codec/codec_test.go b/internal/codec/codec_test.go new file mode 100644 index 0000000..30ed8e7 --- /dev/null +++ b/internal/codec/codec_test.go @@ -0,0 +1,178 @@ +package codec + +import ( + "encoding/json" + "math" + "testing" + + expr "cel.dev/expr" +) + +func decode(t *testing.T, s string) any { + t.Helper() + v, err := Decode([]byte(s)) + if err != nil { + t.Fatalf("decode %s: %v", s, err) + } + return v +} + +// Conversions round-trip through JSON exactly the way the runner uses +// them: convert the proto, marshal, decode, compare. +func TestFromValueRoundTrip(t *testing.T) { + cases := []struct { + name string + value *expr.Value + want string + }{ + { + "int64 min", + &expr.Value{Kind: &expr.Value_Int64Value{ + Int64Value: math.MinInt64, + }}, + `{"@t":"int","v":-9223372036854775808}`, + }, + { + "uint64 max", + &expr.Value{Kind: &expr.Value_Uint64Value{ + Uint64Value: math.MaxUint64, + }}, + `{"@t":"uint","v":18446744073709551615}`, + }, + { + "negative infinity", + &expr.Value{Kind: &expr.Value_DoubleValue{ + DoubleValue: math.Inf(-1), + }}, + `{"@t":"double","v":"-Infinity"}`, + }, + { + "bytes", + &expr.Value{Kind: &expr.Value_BytesValue{ + BytesValue: []byte("ab"), + }}, + `{"@t":"bytes","v":"YWI="}`, + }, + { + "null", + &expr.Value{Kind: &expr.Value_NullValue{}}, + `{"@t":"null","v":null}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tagged, err := FromValue(tc.value) + if err != nil { + t.Fatal(err) + } + got, err := json.Marshal(tagged) + if err != nil { + t.Fatal(err) + } + if !Equal(decode(t, tc.want), decode(t, string(got))) { + t.Errorf("got %s, want %s", got, tc.want) + } + }) + } +} + +func TestEqual(t *testing.T) { + cases := []struct { + name string + want string + got string + equal bool + }{ + { + "NaN matches NaN", + `{"@t":"double","v":"NaN"}`, `{"@t":"double","v":"NaN"}`, + true, + }, + { + "kinds never cross", + `{"@t":"int","v":1}`, `{"@t":"uint","v":1}`, + false, + }, + { + "double never matches int", + `{"@t":"double","v":1}`, `{"@t":"int","v":1}`, + false, + }, + { + "map entry order is irrelevant", + `{"@t":"map","v":[ + {"k":{"@t":"string","v":"a"},"v":{"@t":"int","v":1}}, + {"k":{"@t":"string","v":"b"},"v":{"@t":"int","v":2}}]}`, + `{"@t":"map","v":[ + {"k":{"@t":"string","v":"b"},"v":{"@t":"int","v":2}}, + {"k":{"@t":"string","v":"a"},"v":{"@t":"int","v":1}}]}`, + true, + }, + { + "map sizes must match", + `{"@t":"map","v":[]}`, + `{"@t":"map","v":[ + {"k":{"@t":"int","v":1},"v":{"@t":"int","v":1}}]}`, + false, + }, + { + "list order matters", + `{"@t":"list","v":[{"@t":"int","v":1},{"@t":"int","v":2}]}`, + `{"@t":"list","v":[{"@t":"int","v":2},{"@t":"int","v":1}]}`, + false, + }, + { + "error matches on existence only", + `{"@t":"error","v":{"msg":"foo"}}`, + `{"@t":"error","v":{"msg":"divide by zero","id":3}}`, + true, + }, + { + "timestamp compares by instant, not offset", + `{"@t":"timestamp","v":{"s":60,"n":5,"tz":0}}`, + `{"@t":"timestamp","v":{"s":60,"n":5,"tz":-480}}`, + true, + }, + { + "timestamp nanos matter", + `{"@t":"timestamp","v":{"s":60,"n":5,"tz":0}}`, + `{"@t":"timestamp","v":{"s":60,"n":6,"tz":0}}`, + false, + }, + { + "int64 extremes compare exactly", + `{"@t":"int","v":9223372036854775807}`, + `{"@t":"int","v":9223372036854775806}`, + false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Equal(decode(t, tc.want), decode(t, tc.got)) + if got != tc.equal { + t.Errorf("Equal = %v, want %v", got, tc.equal) + } + }) + } +} + +func TestFromTypeWellKnownNormalization(t *testing.T) { + converted, err := FromType(&expr.Type{ + TypeKind: &expr.Type_MessageType{ + MessageType: "google.protobuf.Int32Value", + }, + }) + if err != nil { + t.Fatal(err) + } + got, err := json.Marshal(converted) + if err != nil { + t.Fatal(err) + } + want := `{"kind":"wrapper","params":[{"kind":"int"}]}` + if string(got) != want { + t.Errorf("got %s, want %s", got, want) + } +} diff --git a/internal/codec/compare.go b/internal/codec/compare.go new file mode 100644 index 0000000..1923bb0 --- /dev/null +++ b/internal/codec/compare.go @@ -0,0 +1,222 @@ +package codec + +import ( + "encoding/json" + "math" + "strconv" +) + +// Equal compares two tagged values structurally, with the two +// relaxations the conformance comparison requires (workspace doc 08): +// map entries are order-agnostic, and NaN matches NaN (the spec's +// rule; cel-go's own harness lacks it, ours has it). Kinds must match +// exactly -- an int result never equals a uint or double expectation, +// because result identity is what the tag exists to carry. +// +// Timestamps compare by instant (seconds and nanos): the tz field is +// display metadata carried by the value, and a corpus expectation is +// always UTC while an evaluated literal may keep its source offset. +func Equal(want, got any) bool { + wantKind, wantPayload, ok := split(want) + if !ok { + return false + } + gotKind, gotPayload, ok := split(got) + if !ok || wantKind != gotKind { + return false + } + + switch wantKind { + case "null": + return true + case "bool": + w, okW := wantPayload.(bool) + g, okG := gotPayload.(bool) + return okW && okG && w == g + case "int": + return intEqual(wantPayload, gotPayload) + case "uint": + return uintEqual(wantPayload, gotPayload) + case "double": + return doubleEqual(wantPayload, gotPayload) + case "string", "bytes", "type": + w, okW := wantPayload.(string) + g, okG := gotPayload.(string) + return okW && okG && w == g + case "list": + return listEqual(wantPayload, gotPayload) + case "map": + return mapEqual(wantPayload, gotPayload) + case "timestamp": + return timestampEqual(wantPayload, gotPayload) + case "duration": + return intEqual(wantPayload, gotPayload) + case "error": + // Existence only: expected error message text is never + // compared (the corpus carries deliberately bogus ones). + return true + case "unknown": + return unknownEqual(wantPayload, gotPayload) + } + // Opaque and anything future: strict structural equality. + return deepEqual(wantPayload, gotPayload) +} + +// split pulls the kind and payload out of a tagged value. +func split(v any) (string, any, bool) { + m, ok := v.(map[string]any) + if !ok { + return "", nil, false + } + kind, ok := m["@t"].(string) + if !ok { + return "", nil, false + } + return kind, m["v"], true +} + +func number(v any) (string, bool) { + switch n := v.(type) { + case json.Number: + return n.String(), true + case string: + return n, true + case float64: + return strconv.FormatFloat(n, 'g', -1, 64), true + } + return "", false +} + +func intEqual(want, got any) bool { + w, okW := number(want) + g, okG := number(got) + if !okW || !okG { + return false + } + wi, errW := strconv.ParseInt(w, 10, 64) + gi, errG := strconv.ParseInt(g, 10, 64) + return errW == nil && errG == nil && wi == gi +} + +func uintEqual(want, got any) bool { + w, okW := number(want) + g, okG := number(got) + if !okW || !okG { + return false + } + wu, errW := strconv.ParseUint(w, 10, 64) + gu, errG := strconv.ParseUint(g, 10, 64) + return errW == nil && errG == nil && wu == gu +} + +// float pulls a float64 out of a double payload, which is either a +// number or one of the three non-finite sentinel strings. +func float(v any) (float64, bool) { + switch n := v.(type) { + case string: + switch n { + case "NaN": + return math.NaN(), true + case "Infinity": + return math.Inf(1), true + case "-Infinity": + return math.Inf(-1), true + } + return 0, false + case json.Number: + f, err := n.Float64() + return f, err == nil + case float64: + return n, true + } + return 0, false +} + +func doubleEqual(want, got any) bool { + w, okW := float(want) + g, okG := float(got) + if !okW || !okG { + return false + } + if math.IsNaN(w) && math.IsNaN(g) { + return true + } + return w == g +} + +func listEqual(want, got any) bool { + w, okW := want.([]any) + g, okG := got.([]any) + if !okW || !okG || len(w) != len(g) { + return false + } + for i := range w { + if !Equal(w[i], g[i]) { + return false + } + } + return true +} + +func mapEqual(want, got any) bool { + w, okW := want.([]any) + g, okG := got.([]any) + if !okW || !okG || len(w) != len(g) { + return false + } + + used := make([]bool, len(g)) + for _, wantEntry := range w { + we, ok := wantEntry.(map[string]any) + if !ok { + return false + } + found := false + for i, gotEntry := range g { + if used[i] { + continue + } + ge, ok := gotEntry.(map[string]any) + if !ok { + return false + } + if Equal(we["k"], ge["k"]) && Equal(we["v"], ge["v"]) { + used[i] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func timestampEqual(want, got any) bool { + w, okW := want.(map[string]any) + g, okG := got.(map[string]any) + return okW && okG && + intEqual(w["s"], g["s"]) && intEqual(w["n"], g["n"]) +} + +func unknownEqual(want, got any) bool { + w, okW := want.([]any) + g, okG := got.([]any) + if !okW || !okG || len(w) != len(g) { + return false + } + // Both sides are sorted, deduped id sets by construction. + for i := range w { + if !intEqual(w[i], g[i]) { + return false + } + } + return true +} + +func deepEqual(want, got any) bool { + w, errW := json.Marshal(want) + g, errG := json.Marshal(got) + return errW == nil && errG == nil && string(w) == string(g) +} diff --git a/internal/codec/type.go b/internal/codec/type.go new file mode 100644 index 0000000..9af16ca --- /dev/null +++ b/internal/codec/type.go @@ -0,0 +1,172 @@ +package codec + +import ( + "fmt" + + expr "cel.dev/expr" +) + +// TypeJSON is the type encoding of workspace doc 03, used in checked +// ASTs, registry rows and declarations. +type TypeJSON = map[string]any + +func kindOnly(kind string) TypeJSON { + return TypeJSON{"kind": kind} +} + +var primitiveKinds = map[expr.Type_PrimitiveType]string{ + expr.Type_BOOL: "bool", + expr.Type_INT64: "int", + expr.Type_UINT64: "uint", + expr.Type_DOUBLE: "double", + expr.Type_STRING: "string", + expr.Type_BYTES: "bytes", +} + +// wellKnownMessages normalizes well-known message names exactly as +// cel-go's checkedWellKnowns does at declaration time (workspace doc +// 03; cel-go common/types.go:834). +var wellKnownMessages = map[string]TypeJSON{ + "google.protobuf.Timestamp": kindOnly("timestamp"), + "google.protobuf.Duration": kindOnly("duration"), + "google.protobuf.Any": kindOnly("any"), + "google.protobuf.NullValue": kindOnly("null"), + "google.protobuf.Value": kindOnly("dyn"), + "google.protobuf.Struct": { + "kind": "map", + "params": []any{kindOnly("string"), kindOnly("dyn")}, + }, + "google.protobuf.ListValue": { + "kind": "list", "params": []any{kindOnly("dyn")}, + }, + "google.protobuf.BoolValue": { + "kind": "wrapper", "params": []any{kindOnly("bool")}, + }, + "google.protobuf.Int32Value": { + "kind": "wrapper", "params": []any{kindOnly("int")}, + }, + "google.protobuf.Int64Value": { + "kind": "wrapper", "params": []any{kindOnly("int")}, + }, + "google.protobuf.UInt32Value": { + "kind": "wrapper", "params": []any{kindOnly("uint")}, + }, + "google.protobuf.UInt64Value": { + "kind": "wrapper", "params": []any{kindOnly("uint")}, + }, + "google.protobuf.FloatValue": { + "kind": "wrapper", "params": []any{kindOnly("double")}, + }, + "google.protobuf.DoubleValue": { + "kind": "wrapper", "params": []any{kindOnly("double")}, + }, + "google.protobuf.StringValue": { + "kind": "wrapper", "params": []any{kindOnly("string")}, + }, + "google.protobuf.BytesValue": { + "kind": "wrapper", "params": []any{kindOnly("bytes")}, + }, +} + +// FromType converts a checked.proto Type to the type-json encoding. +func FromType(t *expr.Type) (TypeJSON, error) { + switch kind := t.GetTypeKind().(type) { + case *expr.Type_Dyn: + return kindOnly("dyn"), nil + case *expr.Type_Null: + return kindOnly("null"), nil + case *expr.Type_Primitive: + name, ok := primitiveKinds[kind.Primitive] + if !ok { + return nil, fmt.Errorf("primitive type %v", kind.Primitive) + } + return kindOnly(name), nil + case *expr.Type_Wrapper: + name, ok := primitiveKinds[kind.Wrapper] + if !ok { + return nil, fmt.Errorf("wrapper of %v", kind.Wrapper) + } + return TypeJSON{ + "kind": "wrapper", "params": []any{kindOnly(name)}, + }, nil + case *expr.Type_WellKnown: + switch kind.WellKnown { + case expr.Type_ANY: + return kindOnly("any"), nil + case expr.Type_TIMESTAMP: + return kindOnly("timestamp"), nil + case expr.Type_DURATION: + return kindOnly("duration"), nil + } + return nil, fmt.Errorf("well-known type %v", kind.WellKnown) + case *expr.Type_ListType_: + elem, err := FromType(kind.ListType.GetElemType()) + if err != nil { + return nil, err + } + return TypeJSON{"kind": "list", "params": []any{elem}}, nil + case *expr.Type_MapType_: + key, err := FromType(kind.MapType.GetKeyType()) + if err != nil { + return nil, err + } + value, err := FromType(kind.MapType.GetValueType()) + if err != nil { + return nil, err + } + return TypeJSON{"kind": "map", "params": []any{key, value}}, nil + case *expr.Type_Type: + if kind.Type == nil || kind.Type.GetTypeKind() == nil { + return kindOnly("type"), nil + } + param, err := FromType(kind.Type) + if err != nil { + return nil, err + } + return TypeJSON{"kind": "type", "params": []any{param}}, nil + case *expr.Type_MessageType: + if wellKnown, ok := wellKnownMessages[kind.MessageType]; ok { + return wellKnown, nil + } + return TypeJSON{"kind": "struct", "name": kind.MessageType}, nil + case *expr.Type_TypeParam: + return TypeJSON{"kind": "param", "name": kind.TypeParam}, nil + case *expr.Type_Error: + return kindOnly("error"), nil + case *expr.Type_AbstractType_: + params := []any{} + for _, p := range kind.AbstractType.GetParameterTypes() { + converted, err := FromType(p) + if err != nil { + return nil, err + } + params = append(params, converted) + } + return TypeJSON{ + "kind": "opaque", + "name": kind.AbstractType.GetName(), + "params": params, + }, nil + } + return nil, fmt.Errorf("type with unsupported kind %T", t.GetTypeKind()) +} + +// FromDecl converts a type_env declaration to the {name, type} shape +// cel.check's options parameter takes. Every corpus declaration is an +// ident declaration (measured, workspace doc 01); a function decl here +// means the corpus changed and the runner needs extending. +func FromDecl(d *expr.Decl) (map[string]any, error) { + ident := d.GetIdent() + if ident == nil { + return nil, fmt.Errorf( + "declaration %q is not an ident declaration", d.GetName(), + ) + } + + declType, err := FromType(ident.GetType()) + if err != nil { + return nil, fmt.Errorf("declaration %q: %w", d.GetName(), err) + } + + return map[string]any{"name": d.GetName(), "type": declType}, nil +} diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go new file mode 100644 index 0000000..ddd52de --- /dev/null +++ b/internal/corpus/corpus.go @@ -0,0 +1,118 @@ +// Package corpus locates and parses the cel-spec conformance corpus. +// +// The corpus is the set of SimpleTestFile textprotos under +// cel-spec/tests/simple/testdata in a local cel-spec checkout. The +// checkout lives under a per-machine path named by CEL_EXPR_DIR +// (environment or .env), never hard-coded, so a fresh clone on another +// machine configures it once. +package corpus + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + test "cel.dev/expr/conformance/test" + "google.golang.org/protobuf/encoding/prototext" + + "github.com/emfga/cel4postgres/internal/dotenv" + + // The textprotos spell google.protobuf.Any fields in expanded + // form, so parsing them needs the cel.expr.conformance.proto2/3 + // descriptors registered. Test-harness-only: nothing in the + // cel4postgres runtime touches protobuf descriptors. + _ "cel.dev/expr/conformance/proto2" + _ "cel.dev/expr/conformance/proto3" +) + +// Dir returns the testdata directory of the cel-spec checkout, or an +// error naming the variable that fixes a missing configuration. +func Dir() (string, error) { + root, err := dotenv.Lookup("CEL_EXPR_DIR") + if err != nil { + return "", err + } + if root == "" { + return "", fmt.Errorf( + "CEL_EXPR_DIR is not set: point it at the directory " + + "holding the cel-spec/cel-go/cel-java checkouts " + + "(environment or .env)", + ) + } + + dir := filepath.Join(root, "cel-spec", "tests", "simple", "testdata") + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf( + "conformance corpus not found at %s: is CEL_EXPR_DIR "+ + "pointing at a directory with a cel-spec checkout? (%w)", + dir, err, + ) + } + + return dir, nil +} + +// Files returns the corpus file names (without extension), sorted. +func Files() ([]string, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read corpus directory: %w", err) + } + + names := []string{} + for _, entry := range entries { + if name, ok := strings.CutSuffix(entry.Name(), ".textproto"); ok { + names = append(names, name) + } + } + sort.Strings(names) + + return names, nil +} + +// Load parses one corpus file by name (without extension). +func Load(name string) (*test.SimpleTestFile, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(filepath.Join(dir, name+".textproto")) + if err != nil { + return nil, fmt.Errorf("read corpus file: %w", err) + } + + file := &test.SimpleTestFile{} + if err := prototext.Unmarshal(data, file); err != nil { + return nil, fmt.Errorf("parse %s.textproto: %w", name, err) + } + + return file, nil +} + +// descriptorDependent matches the message names whose cases need real +// protobuf descriptors: TestAllTypes-family constructions, proto2 +// extensions, and Any packed around serialized proto payloads. The +// package path also covers container strings, type_env declarations +// and Any type URLs, which all spell it out in full. +var descriptorDependent = regexp.MustCompile( + `cel\.expr\.conformance\.proto[23]` + + `|TestAllTypes|NestedTestAllTypes|Proto2ExtensionScopedMessage`, +) + +// DescriptorDependent reports whether a test case requires protobuf +// descriptors, by scanning its full textproto rendering for the +// message names of the conformance proto2/proto3 test schemas. Scanned +// mechanically, never listed by hand, so the skip list cannot drift +// from the corpus. +func DescriptorDependent(t *test.SimpleTest) bool { + return descriptorDependent.MatchString(prototext.Format(t)) +} diff --git a/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go new file mode 100644 index 0000000..6e16bed --- /dev/null +++ b/internal/corpus/corpus_test.go @@ -0,0 +1,60 @@ +package corpus + +import "testing" + +// The corpus is external input: these tests pin the facts the suite +// depends on -- the checkout is findable, every file parses, and the +// file census matches what the conformance target (workspace doc 01) +// was measured against. A count change here means the corpus moved +// and the target needs re-measuring, not that this test is wrong. + +func TestEveryFileParses(t *testing.T) { + files, err := Files() + if err != nil { + t.Fatal(err) + } + + // 25 included + 6 excluded. Workspace doc 01 said "26 of 32"; + // the corpus at the pinned checkout has 31 -- recorded in the + // workspace ISSUES register when this was measured. + if len(files) != 31 { + t.Errorf("corpus has %d files, the target was measured on 31", + len(files)) + } + + for _, name := range files { + file, err := Load(name) + if err != nil { + t.Errorf("load %s: %v", name, err) + continue + } + // The declared name usually matches the filename, but not + // always: type_deduction.textproto declares + // "type_deductions". The suite keys by filename. + if file.GetName() == "" { + t.Errorf("%s.textproto declares no name", name) + } + } +} + +func TestKnownCasePresent(t *testing.T) { + file, err := Load("basic") + if err != nil { + t.Fatal(err) + } + + for _, section := range file.GetSection() { + if section.GetName() != "self_eval_zeroish" { + continue + } + for _, tc := range section.GetTest() { + if tc.GetName() == "self_eval_int_zero" { + if tc.GetExpr() != "0" { + t.Fatalf("self_eval_int_zero expr = %q", tc.GetExpr()) + } + return + } + } + } + t.Fatal("basic/self_eval_zeroish/self_eval_int_zero not found") +} diff --git a/internal/oracle/options.go b/internal/oracle/options.go new file mode 100644 index 0000000..c993a43 --- /dev/null +++ b/internal/oracle/options.go @@ -0,0 +1,46 @@ +package oracle + +import ( + "fmt" + "strings" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/ext" +) + +// envOptions maps cel4postgres env names to the cel-go options that +// build the equivalent reference environment. "standard" is the base +// cel.NewEnv (standard library and macros) plus identifier-escape +// syntax, which cel-go's conformance run enables for the whole corpus +// and our standard env includes (workspace doc 01). +var envOptions = map[string][]cel.EnvOption{ + "standard": {cel.EnableIdentifierEscapeSyntax()}, + "strings": {ext.Strings()}, + "math": {ext.Math()}, + "lists": {ext.Lists()}, + "encoders": {ext.Encoders()}, + "bindings": {ext.Bindings()}, + "optionals": { + cel.OptionalTypes(), + }, + "two_var_comprehensions": {ext.TwoVarComprehensions()}, + "network": {ext.Network()}, +} + +// Options resolves a comma-separated env-name union -- the same string +// cel.parse/check/eval take -- to cel-go options, so a disputed case +// runs against the reference under the same environment composition +// our side used. An unknown name is an error, not a silent no-op: an +// oracle configured differently than the evaluator measures nothing. +func Options(env string) ([]cel.EnvOption, error) { + options := []cel.EnvOption{} + for name := range strings.SplitSeq(env, ",") { + name = strings.TrimSpace(name) + resolved, ok := envOptions[name] + if !ok { + return nil, fmt.Errorf("no cel-go options for env %q", name) + } + options = append(options, resolved...) + } + return options, nil +} diff --git a/internal/oracle/oracle.go b/internal/oracle/oracle.go index c7e4bb1..55a0e48 100644 --- a/internal/oracle/oracle.go +++ b/internal/oracle/oracle.go @@ -9,9 +9,11 @@ // // The environment here is deliberately bare: the standard library and // standard macros, nothing else. Extension libraries are opt-in per -// test file, mirroring how cel-go's own conformance runner enables -// them selectively, because a file that passes only because an -// extension leaked into the default environment is not a passing file. +// test file (Options resolves an env-name union to the matching +// cel-go options), because a file that passes only because an +// extension leaked into the default environment is not a passing +// file. This is stricter than cel-go's own conformance runner, which +// enables every extension globally. package oracle import ( From 9ea45db53a454047af28eb78bfcae66555d3eb7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 18:46:11 -0300 Subject: [PATCH 05/21] Correct the cel-go harness claim in CLAUDE.md The conformance section justified per-file envs by saying cel-go's conformance_test.go enables extensions selectively. Measured false: it builds one environment with every extension enabled globally. The per-file design stays -- it is the stricter setting and the property the registry exists to prove -- but its justification should not cite a precedent that does not exist, and the oracle side is now noted as configured per-file to match. Also updates the install-script name for the ordered sql/ layout. --- CLAUDE.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 987e332..6b83b3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ 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` +during initdb), `sql/000_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. @@ -213,10 +213,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. @@ -300,7 +303,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 From acbe0ffa1eb4d231e14fb4eb8f59d0e6806c60dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 18:52:47 -0300 Subject: [PATCH 06/21] Skip corpus cases whose strings contain NUL PostgreSQL text and jsonb strings categorically reject U+0000, so the 20 parse/string_literals cases whose expected values contain a NUL code point cannot pass on this substrate, ever. The skip generator now detects them by walking expected values and bindings for NUL runes and names them with their own reason, keeping the limitation printed rather than discovered. Raw-string cases like r'\000' hold backslash text, not NUL, and still run; bytes with NUL are unaffected (base64 payloads). --- conformance/skiplist.go | 10 ++- conformance/skipped_cases.go | 20 +++++ internal/corpus/corpus.go | 40 +++++++++ sql/010_registry.sql | 170 +++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 sql/010_registry.sql diff --git a/conformance/skiplist.go b/conformance/skiplist.go index 32fdacc..35d50dd 100644 --- a/conformance/skiplist.go +++ b/conformance/skiplist.go @@ -56,10 +56,14 @@ func GenerateSkippedCases() (map[string]string, error) { } for _, section := range parsed.GetSection() { for _, test := range section.GetTest() { - if corpus.DescriptorDependent(test) { - key := file + "/" + section.GetName() + - "/" + test.GetName() + key := file + "/" + section.GetName() + + "/" + test.GetName() + switch { + case corpus.DescriptorDependent(test): skipped[key] = "requires protobuf descriptors" + case corpus.ContainsNulString(test): + skipped[key] = + "PostgreSQL text cannot represent NUL in strings" } } } diff --git a/conformance/skipped_cases.go b/conformance/skipped_cases.go index b91bb05..ae3f8f4 100644 --- a/conformance/skipped_cases.go +++ b/conformance/skipped_cases.go @@ -219,6 +219,26 @@ var skippedCases = map[string]string{ "parse/nest/message_literal": "requires protobuf descriptors", "parse/repeat/message_literal": "requires protobuf descriptors", "parse/repeat/select": "requires protobuf descriptors", + "parse/string_literals/double_quoted_lower_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/double_quoted_lower_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/double_quoted_octal_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/double_quoted_upper_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/double_quoted_upper_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/single_quoted_lower_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/single_quoted_lower_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/single_quoted_octal_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/single_quoted_upper_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/single_quoted_upper_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_double_quoted_lower_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_double_quoted_lower_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_double_quoted_octal_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_double_quoted_upper_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_double_quoted_upper_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_single_quoted_lower_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_single_quoted_lower_x_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_single_quoted_octal_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_single_quoted_upper_u_escapes": "PostgreSQL text cannot represent NUL in strings", + "parse/string_literals/triple_single_quoted_upper_x_escapes": "PostgreSQL text cannot represent NUL in strings", "parse/struct_field_names/as": "requires protobuf descriptors", "parse/struct_field_names/break": "requires protobuf descriptors", "parse/struct_field_names/const": "requires protobuf descriptors", diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go index ddd52de..04faca2 100644 --- a/internal/corpus/corpus.go +++ b/internal/corpus/corpus.go @@ -12,9 +12,11 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strings" + expr "cel.dev/expr" test "cel.dev/expr/conformance/test" "google.golang.org/protobuf/encoding/prototext" @@ -116,3 +118,41 @@ var descriptorDependent = regexp.MustCompile( func DescriptorDependent(t *test.SimpleTest) bool { return descriptorDependent.MatchString(prototext.Format(t)) } + +// ContainsNulString reports whether the case's expected value or +// bindings carry a string (not bytes) containing NUL. PostgreSQL text +// and jsonb strings categorically cannot represent U+0000, so these +// cases are skipped by name -- a substrate limitation, not a CEL one. +// A raw-string literal like r'\000' is unaffected: its value holds +// the backslash characters, not a NUL. +func ContainsNulString(t *test.SimpleTest) bool { + for _, binding := range t.GetBindings() { + if exprValueHasNul(binding) { + return true + } + } + return valueHasNul(t.GetValue()) || + valueHasNul(t.GetTypedResult().GetResult()) +} + +func exprValueHasNul(v *expr.ExprValue) bool { + return valueHasNul(v.GetValue()) +} + +func valueHasNul(v *expr.Value) bool { + if v == nil { + return false + } + if strings.ContainsRune(v.GetStringValue(), 0) { + return true + } + if slices.ContainsFunc(v.GetListValue().GetValues(), valueHasNul) { + return true + } + for _, e := range v.GetMapValue().GetEntries() { + if valueHasNul(e.GetKey()) || valueHasNul(e.GetValue()) { + return true + } + } + return false +} diff --git a/sql/010_registry.sql b/sql/010_registry.sql new file mode 100644 index 0000000..eb1f982 --- /dev/null +++ b/sql/010_registry.sql @@ -0,0 +1,170 @@ +-- cel4postgres -- the four registries. +-- +-- An extension is rows in these tables plus PL/pgSQL functions; it is +-- never a patch to the core. The standard library itself registers +-- through them (seeded by 030_parse.sql and 060_stdlib.sql) -- if the +-- core could reach anything the registry cannot describe, the +-- registry would stop being the extension mechanism. + +BEGIN; + +-- Custom and well-known types: name resolution, construction, +-- equality and conversion hooks. Every row visible in an env also +-- implies an identifier of type type(T) under the type's name. +CREATE TABLE IF NOT EXISTS cel.type ( + name text PRIMARY KEY, + kind jsonb NOT NULL, + construct regprocedure, + equal regprocedure, + convert regprocedure, + doc text +); + +-- Overloads: the unit of dispatch. cel.check binds ids from here; +-- cel.eval dispatches on the bound id and never on a function name. +-- ordinal preserves cel-go's declaration order, because overload +-- resolution and multi-match widening are order-sensitive. +CREATE TABLE IF NOT EXISTS cel.overload ( + id text PRIMARY KEY, + function text NOT NULL, + member boolean NOT NULL, + arg_types jsonb NOT NULL, + result_type jsonb NOT NULL, + impl regprocedure, + non_strict boolean NOT NULL DEFAULT false, + ordinal int NOT NULL, + doc text +); + +CREATE INDEX IF NOT EXISTS overload_function + ON cel.overload (function, ordinal); + +-- Parse-time macros. arity -1 is variadic. The expander signature is +-- expander(target jsonb, args jsonb, next_id bigint) +-- RETURNS (expr jsonb, next_id bigint, err text) +-- returning NULL expr with NULL err to decline the expansion. +CREATE TABLE IF NOT EXISTS cel.macro ( + name text NOT NULL, + arity int NOT NULL, + member boolean NOT NULL, + expander regprocedure NOT NULL, + PRIMARY KEY (name, arity, member) +); + +-- Named environments: a bundle of visible overloads, macros and +-- types, plus parse-level feature flags (optional_syntax, ...). +-- kind='env' composes environments; the API's env argument is +-- additionally a comma-separated union of names. +CREATE TABLE IF NOT EXISTS cel.env ( + name text PRIMARY KEY, + flags jsonb NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS cel.env_item ( + env text NOT NULL REFERENCES cel.env (name), + kind text NOT NULL CHECK (kind IN ('overload', 'macro', 'type', 'env')), + ref text NOT NULL, + PRIMARY KEY (env, kind, ref) +); + +-- Resolves an env argument ('standard', 'standard,strings', nested +-- includes) to the flat set of env names, cycle-safe. An unknown name +-- raises: a misconfigured environment is a caller bug, not a CEL +-- error value. +CREATE OR REPLACE FUNCTION cel._env_names(env text) +RETURNS text[] +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + pending text[]; + seen text[] := '{}'; + current text; + extra text[]; +BEGIN + SELECT array_agg(btrim(n)) INTO pending + FROM unnest(string_to_array(env, ',')) AS n + WHERE btrim(n) <> ''; + + IF pending IS NULL THEN + RAISE 'empty env argument'; + END IF; + + WHILE cardinality(pending) > 0 LOOP + current := pending[1]; + pending := pending[2:]; + CONTINUE WHEN current = ANY (seen); + + IF NOT EXISTS (SELECT FROM cel.env WHERE name = current) THEN + RAISE 'unknown env %', quote_literal(current); + END IF; + seen := seen || current; + + SELECT array_agg(ref) INTO extra + FROM cel.env_item + WHERE env_item.env = current AND kind = 'env'; + IF extra IS NOT NULL THEN + pending := pending || extra; + END IF; + END LOOP; + + RETURN seen; +END; +$$; + +-- Merged parse-level flags of an env union. Later names win on +-- conflicting keys; flags are booleans in practice, set once by the +-- env that owns the feature, so conflicts do not arise today. +CREATE OR REPLACE FUNCTION cel._env_flags(env text) +RETURNS jsonb +LANGUAGE sql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(jsonb_object_agg(key, value), '{}'::jsonb) + FROM ( + SELECT key, value, + row_number() OVER (PARTITION BY key ORDER BY ord DESC) AS rn + FROM unnest(cel._env_names(env)) WITH ORDINALITY AS e(name, ord) + JOIN cel.env ON cel.env.name = e.name, + LATERAL jsonb_each(cel.env.flags) + ) flags + WHERE rn = 1; +$$; + +-- The macros visible to an env union, as one jsonb object the parser +-- looks up per call site without further table reads: +-- {"//": "", ...} +-- with arity -1 entries under their own key for the variadic probe. +CREATE OR REPLACE FUNCTION cel._env_macros(env text) +RETURNS jsonb +LANGUAGE sql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce( + jsonb_object_agg( + format('%s/%s/%s', m.name, m.arity, m.member::int), + split_part(m.expander::text, '(', 1) + ), + '{}'::jsonb + ) + FROM cel.macro m + WHERE EXISTS ( + SELECT FROM cel.env_item i + WHERE i.env = ANY (cel._env_names(env)) + AND i.kind = 'macro' + AND i.ref = format('%s/%s/%s', m.name, m.arity, m.member::int) + ); +$$; + +-- The spec-conformant default environment. Its items are seeded by +-- the scripts that create the functions they reference. The +-- identifier-escape syntax (`a-b`) is part of standard parsing +-- (cel-go enables it corpus-wide); optional syntax is not. +INSERT INTO cel.env (name, flags) +VALUES ('standard', '{"ident_escape": true}') +ON CONFLICT (name) DO NOTHING; + +COMMIT; From 2f11b96e9712049aa2c411527e608674c2da8310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 19:11:12 -0300 Subject: [PATCH 07/21] Add the CEL parser and macro engine A hand-written lexer and precedence-climbing parser in PL/pgSQL, the approach cel-go itself validates by carrying an equivalent Pratt parser. Errors travel as OUT parameters end to end -- a BEGIN/EXCEPTION block's subtransaction would break the PARALLEL SAFE label inside a parallel worker -- and parse failures come back as an {"errors": [...]} envelope, never an exception. Macro expansion is a registry lookup from the first commit (day-one invariant 5): the parser resolves (function, arity, receiver) against cel.macro rows visible to the env and EXECUTEs the expander, and the standard six macros register through that same path, expanding to cel-go's exact comprehension shapes with accumulator @result. The normalizations that carry semantic weight are all in: the minus sign folds into numeric literals so -9223372036854775808 parses exactly, even !/- chains collapse, && and || chains rebalance into a balanced tree, and float literals mirror Go's ParseFloat at the edges (overflow is a parse error, values at or below 2^-1075 round to signed zero -- where Postgres's own cast would raise instead). Tested by diffing tree shapes against cel-go v0.32.0 on a curated expression list covering every node kind, plus a rejection list of expressions cel-go refuses. Corpus-wide, zero non-skipped cases are rejected at the parse stage. Nine comparison cases carrying raw NUL bytes inside their expression text join the NUL skip category: they cannot be sent to Postgres as a text parameter at all. --- conformance/parse_test.go | 238 ++++ conformance/skipped_cases.go | 9 + internal/corpus/corpus.go | 6 + internal/oracle/shape.go | 194 +++ sql/010_registry.sql | 16 + sql/030_parse.sql | 2182 ++++++++++++++++++++++++++++++++++ 6 files changed, 2645 insertions(+) create mode 100644 conformance/parse_test.go create mode 100644 internal/oracle/shape.go create mode 100644 sql/030_parse.sql diff --git a/conformance/parse_test.go b/conformance/parse_test.go new file mode 100644 index 0000000..8e3b241 --- /dev/null +++ b/conformance/parse_test.go @@ -0,0 +1,238 @@ +package conformance + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/emfga/cel4postgres/internal/oracle" + "github.com/emfga/cel4postgres/internal/testdb" +) + +// The curated list the parser is diffed against cel-go on. Coverage, +// not volume: every node kind, every operator tier, every macro, the +// normalizations (sign folding, chain collapsing, && || rebalancing) +// and the syntax corners the corpus leans on. +var parseShapeExprs = []string{ + // Literals. + "0", "42", "-1", "0x1F", "-0x1F", "18446744073709551615u", "0xFFu", + "1.5", "-1.5", ".5", "1e3", "-2.5e-2", "9223372036854775807", + "-9223372036854775808", + "true", "false", "null", + `"hi"`, `'wörld'`, `"tab\tnewline\n"`, `r'raw\n'`, `b'bytes\xff'`, + `'''triple " quote'''`, + // Operators, precedence, associativity. + "1 + 2 * 3 - 4 / 5 % 6", + "a || b && c || d && e", + "a || b || c || d || e", + "a && b && c && d && e", + "1 < 2 <= 3 > 4 >= 5 == 6 != 7", + "x in [1, 2]", + "a ? b : c ? d : e", + "(a ? b : c) ? d : e", + "!a", "!!a", "!!!a", "-a", "--a", "---a", "-(5)", + // Member/postfix. + "a.b.c", "a[0]", "a.b[c.d].e", "a.b(c)", "a.b(c, d).e(f)", + "f()", "f(1)", "f(1, 2)", ".g(h)", ".a.b", + // Aggregates. + "[]", "[1]", "[1, 2u, 3.0]", "[1, 2,]", + "{}", "{'a': 1}", "{1: 'a', 2u: b, c: d,}", + "Msg{}", "Msg{a: 1}", "pkg.Msg{a: 1, b: 2,}", ".pkg.Msg{a: 1}", + // Macros. + "has(a.b)", "has(a.b.c)", + "[1, 2].all(x, x > 0)", + "[1, 2].exists(x, x > 1)", + "[1, 2].exists_one(x, x == 1)", + "[1, 2].map(x, x * 2)", + "[1, 2].map(x, x > 0, x * 2)", + "[1, 2].filter(x, x > 1)", + "{'a': 1}.all(k, k == 'a')", + "[[1], [2]].all(l, l.all(x, x > 0))", + // Non-macro calls with macro names but wrong arity/receiver. + "all(1, 2)", "x.has(y)", + // Escaped identifiers (standard enables the syntax). + "a.`b-c`", "a.`b c`.d", "Msg{`f-1`: 2}", + // Whitespace and comments. + "1 + // comment\n 2", +} + +// TestParseShape diffs cel.parse against cel-go's parser on the +// curated list, comparing tree shape (kinds, names, values, operator +// functions, macro expansions) while ignoring node ids and offsets, +// which nothing in conformance compares. +func TestParseShape(t *testing.T) { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + + options, err := oracle.Options("standard") + if err != nil { + t.Fatal(err) + } + + for _, expr := range parseShapeExprs { + t.Run(expr, func(t *testing.T) { + want, err := oracle.ParseShape(expr, options...) + if err != nil { + t.Fatalf("cel-go: %v", err) + } + + var raw []byte + err = conn.QueryRow(ctx, + "SELECT cel.parse($1, 'standard')", expr, + ).Scan(&raw) + if err != nil { + t.Fatalf("cel.parse: %v", err) + } + var envelope map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&envelope); err != nil { + t.Fatal(err) + } + if errs, ok := envelope["errors"]; ok { + t.Fatalf("cel.parse rejected: %v", errs) + } + + got := stripIDs(envelope["expr"]) + if !shapeEqual(normalize(want), got) { + wantJSON, _ := json.MarshalIndent(normalize(want), "", " ") + gotJSON, _ := json.MarshalIndent(got, "", " ") + t.Errorf("shape mismatch\ncel-go:\n%s\ncel.parse:\n%s", + wantJSON, gotJSON) + } + }) + } +} + +// TestParseErrorsRejected checks that expressions cel-go rejects are +// rejected by cel.parse too (message text is not compared). +func TestParseErrorsRejected(t *testing.T) { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + + exprs := []string{ + "1 +", "(1", "[1", "{1: ", "a.", "a[1", "f(", "1 2", + "while", "var", "1.е3", "'unterminated", `"\z"`, `"\x1"`, + "9223372036854775808", "-9223372036854775809", + "18446744073709551616u", "?:", "a ? b", "in", ".", "..a", + "x.?y", "[?x]", "{?'k': v}", "'\\'", "b\"abc", + // Escaped identifiers are not primaries. + "`a`.b", + } + + for _, expr := range exprs { + t.Run(expr, func(t *testing.T) { + var raw []byte + err := conn.QueryRow(ctx, + "SELECT cel.parse($1, 'standard')", expr, + ).Scan(&raw) + if err != nil { + t.Fatalf("cel.parse errored at the SQL level: %v", err) + } + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + if _, ok := envelope["errors"]; !ok { + t.Fatalf("cel.parse accepted %q: %s", expr, raw) + } + }) + } +} + +// stripIDs removes "id" keys recursively; ids carry no comparable +// meaning across the two parsers. +func stripIDs(v any) any { + switch value := v.(type) { + case map[string]any: + out := map[string]any{} + for k, sub := range value { + if k == "id" { + continue + } + out[k] = stripIDs(sub) + } + return out + case []any: + out := make([]any, len(value)) + for i, sub := range value { + out[i] = stripIDs(sub) + } + return out + } + return v +} + +// normalize round-trips the oracle shape through JSON so both sides +// carry json.Number for numbers. +func normalize(v any) any { + data, err := json.Marshal(v) + if err != nil { + return v + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var out any + if err := decoder.Decode(&out); err != nil { + return v + } + return out +} + +// shapeEqual compares two JSON trees, with numbers compared +// numerically (int64 exactly, doubles by value). +func shapeEqual(want, got any) bool { + switch w := want.(type) { + case map[string]any: + g, ok := got.(map[string]any) + if !ok || len(w) != len(g) { + return false + } + for k, wv := range w { + gv, ok := g[k] + if !ok || !shapeEqual(wv, gv) { + return false + } + } + return true + case []any: + g, ok := got.([]any) + if !ok || len(w) != len(g) { + return false + } + for i := range w { + if !shapeEqual(w[i], g[i]) { + return false + } + } + return true + case json.Number: + g, ok := got.(json.Number) + if !ok { + return false + } + if wi, err := w.Int64(); err == nil { + if gi, err := g.Int64(); err == nil { + return wi == gi + } + } + wf, errW := w.Float64() + gf, errG := g.Float64() + return errW == nil && errG == nil && wf == gf + case string: + g, ok := got.(string) + return ok && w == g + } + return want == got +} diff --git a/conformance/skipped_cases.go b/conformance/skipped_cases.go index ae3f8f4..31afca4 100644 --- a/conformance/skipped_cases.go +++ b/conformance/skipped_cases.go @@ -7,6 +7,7 @@ package conformance var skippedCases = map[string]string{ + "comparisons/bound/bytes_gt_left_false": "PostgreSQL text cannot represent NUL in strings", "comparisons/eq_literal/not_eq_dyn_proto2_msg_null": "requires protobuf descriptors", "comparisons/eq_literal/not_eq_dyn_proto3_msg_null": "requires protobuf descriptors", "comparisons/eq_wrapper/eq_bool_proto2_null": "requires protobuf descriptors", @@ -41,6 +42,14 @@ var skippedCases = map[string]string{ "comparisons/eq_wrapper/eq_uint32_proto3_null": "requires protobuf descriptors", "comparisons/eq_wrapper/eq_uint64_proto2_null": "requires protobuf descriptors", "comparisons/eq_wrapper/eq_uint64_proto3_null": "requires protobuf descriptors", + "comparisons/gt_literal/gt_bytes_one": "PostgreSQL text cannot represent NUL in strings", + "comparisons/gt_literal/gt_bytes_one_to_empty": "PostgreSQL text cannot represent NUL in strings", + "comparisons/gt_literal/not_gt_bytes_sorting": "PostgreSQL text cannot represent NUL in strings", + "comparisons/gte_literal/gte_bytes_samelength": "PostgreSQL text cannot represent NUL in strings", + "comparisons/gte_literal/gte_bytes_to_empty": "PostgreSQL text cannot represent NUL in strings", + "comparisons/gte_literal/not_gte_bytes_empty_to_nonempty": "PostgreSQL text cannot represent NUL in strings", + "comparisons/lte_literal/lte_bytes_empty": "PostgreSQL text cannot represent NUL in strings", + "comparisons/lte_literal/not_lte_bytes_length": "PostgreSQL text cannot represent NUL in strings", "comparisons/ne_literal/ne_proto2": "requires protobuf descriptors", "comparisons/ne_literal/ne_proto2_any_unpack": "requires protobuf descriptors", "comparisons/ne_literal/ne_proto2_any_unpack_bytewise_fallback": "requires protobuf descriptors", diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go index 04faca2..e046385 100644 --- a/internal/corpus/corpus.go +++ b/internal/corpus/corpus.go @@ -126,6 +126,12 @@ func DescriptorDependent(t *test.SimpleTest) bool { // A raw-string literal like r'\000' is unaffected: its value holds // the backslash characters, not a NUL. func ContainsNulString(t *test.SimpleTest) bool { + // Some comparison cases carry raw NUL bytes inside the expression + // text itself (bytes literals written verbatim in the textproto); + // those cannot even be sent to Postgres as a text parameter. + if strings.ContainsRune(t.GetExpr(), 0) { + return true + } for _, binding := range t.GetBindings() { if exprValueHasNul(binding) { return true diff --git a/internal/oracle/shape.go b/internal/oracle/shape.go new file mode 100644 index 0000000..d1cfbbf --- /dev/null +++ b/internal/oracle/shape.go @@ -0,0 +1,194 @@ +package oracle + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + + "cel.dev/cel-go/cel" + celast "cel.dev/cel-go/common/ast" + "cel.dev/cel-go/common/types" + "cel.dev/cel-go/common/types/ref" +) + +// ParseShape parses an expression with cel-go and returns its AST as +// the node shape cel4postgres emits (workspace doc 03), without ids +// or source offsets. The parse unit tests diff cel.parse output +// against this, so the two parsers are compared structurally rather +// than by transcription. +func ParseShape( + expression string, options ...cel.EnvOption, +) (map[string]any, error) { + env, err := Env(options...) + if err != nil { + return nil, err + } + + ast, issues := env.Parse(expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("parse %q: %w", expression, issues.Err()) + } + + return exprShape(ast.NativeRep().Expr()) +} + +func exprShape(e celast.Expr) (map[string]any, error) { + switch e.Kind() { + case celast.LiteralKind: + tagged, err := literalShape(e.AsLiteral()) + if err != nil { + return nil, err + } + return map[string]any{"k": "lit", "v": tagged}, nil + + case celast.IdentKind: + return map[string]any{"k": "ident", "name": e.AsIdent()}, nil + + case celast.SelectKind: + sel := e.AsSelect() + op, err := exprShape(sel.Operand()) + if err != nil { + return nil, err + } + shape := map[string]any{ + "k": "select", "op": op, "field": sel.FieldName(), + } + if sel.IsTestOnly() { + shape["test"] = true + } + return shape, nil + + case celast.CallKind: + call := e.AsCall() + args := []any{} + for _, a := range call.Args() { + shape, err := exprShape(a) + if err != nil { + return nil, err + } + args = append(args, shape) + } + shape := map[string]any{ + "k": "call", "fn": call.FunctionName(), "args": args, + } + if call.IsMemberFunction() { + target, err := exprShape(call.Target()) + if err != nil { + return nil, err + } + shape["target"] = target + } + return shape, nil + + case celast.ListKind: + list := e.AsList() + elems := []any{} + for _, el := range list.Elements() { + shape, err := exprShape(el) + if err != nil { + return nil, err + } + elems = append(elems, shape) + } + shape := map[string]any{"k": "list", "elems": elems} + if len(list.OptionalIndices()) > 0 { + opt := []any{} + for _, i := range list.OptionalIndices() { + opt = append(opt, int64(i)) + } + shape["opt"] = opt + } + return shape, nil + + case celast.MapKind: + m := e.AsMap() + entries := []any{} + for _, entry := range m.Entries() { + me := entry.AsMapEntry() + k, err := exprShape(me.Key()) + if err != nil { + return nil, err + } + v, err := exprShape(me.Value()) + if err != nil { + return nil, err + } + entries = append(entries, map[string]any{ + "k": k, "v": v, "opt": me.IsOptional(), + }) + } + return map[string]any{"k": "map", "entries": entries}, nil + + case celast.StructKind: + s := e.AsStruct() + fields := []any{} + for _, field := range s.Fields() { + sf := field.AsStructField() + v, err := exprShape(sf.Value()) + if err != nil { + return nil, err + } + fields = append(fields, map[string]any{ + "name": sf.Name(), "v": v, "opt": sf.IsOptional(), + }) + } + return map[string]any{ + "k": "struct", "type": s.TypeName(), "fields": fields, + }, nil + + case celast.ComprehensionKind: + comp := e.AsComprehension() + shape := map[string]any{ + "k": "comp", + "iter": comp.IterVar(), + "iter2": "", + "accu": comp.AccuVar(), + } + if comp.HasIterVar2() { + shape["iter2"] = comp.IterVar2() + } + for key, sub := range map[string]celast.Expr{ + "range": comp.IterRange(), "init": comp.AccuInit(), + "cond": comp.LoopCondition(), "step": comp.LoopStep(), + "result": comp.Result(), + } { + converted, err := exprShape(sub) + if err != nil { + return nil, err + } + shape[key] = converted + } + return shape, nil + } + return nil, fmt.Errorf("unsupported expr kind %v", e.Kind()) +} + +// literalShape renders a cel-go constant as a tagged value, with +// payloads matching internal/codec's conventions. +func literalShape(v ref.Val) (map[string]any, error) { + switch value := v.(type) { + case types.Bool: + return map[string]any{"@t": "bool", "v": bool(value)}, nil + case types.Int: + return map[string]any{ + "@t": "int", "v": json.Number(strconv.FormatInt(int64(value), 10)), + }, nil + case types.Uint: + return map[string]any{ + "@t": "uint", "v": json.Number(strconv.FormatUint(uint64(value), 10)), + }, nil + case types.Double: + return map[string]any{"@t": "double", "v": float64(value)}, nil + case types.String: + return map[string]any{"@t": "string", "v": string(value)}, nil + case types.Bytes: + return map[string]any{ + "@t": "bytes", + "v": base64.StdEncoding.EncodeToString([]byte(value)), + }, nil + case types.Null: + return map[string]any{"@t": "null", "v": nil}, nil + } + return nil, fmt.Errorf("unsupported literal %T", v) +} diff --git a/sql/010_registry.sql b/sql/010_registry.sql index eb1f982..c8232a1 100644 --- a/sql/010_registry.sql +++ b/sql/010_registry.sql @@ -167,4 +167,20 @@ INSERT INTO cel.env (name, flags) VALUES ('standard', '{"ident_escape": true}') ON CONFLICT (name) DO NOTHING; +-- Extension environments. The rows exist from day one so an env +-- union like 'standard,strings' resolves before the extension's own +-- install script has seeded any items into them; the scripts under +-- sql/ext/ fill them in later phases. optionals owns the +-- optional-syntax parse flag. +INSERT INTO cel.env (name, flags) VALUES + ('strings', '{}'), + ('math', '{}'), + ('lists', '{}'), + ('encoders', '{}'), + ('bindings', '{}'), + ('two_var_comprehensions', '{}'), + ('optionals', '{"optional_syntax": true}'), + ('network', '{}') +ON CONFLICT (name) DO NOTHING; + COMMIT; diff --git a/sql/030_parse.sql b/sql/030_parse.sql new file mode 100644 index 0000000..4721492 --- /dev/null +++ b/sql/030_parse.sql @@ -0,0 +1,2182 @@ +-- cel4postgres -- lexer, parser, macro engine. +-- +-- Hand-written lexer + precedence-climbing parser (workspace decision: +-- cel-go itself carries a Pratt parser with these semantics). Errors +-- travel as OUT parameters, never exceptions: cel.parse is labelled +-- PARALLEL SAFE, and a BEGIN/EXCEPTION block's subtransaction would +-- break that promise inside a parallel worker. +-- +-- The reference grammar is cel-go's parser/gen/CEL.g4 (v0.32.0); +-- lexical rules follow it exactly, including the newline +-- normalization applied to every literal form. + +BEGIN; + +-- Lexes one string or bytes literal. pos points at the opening quote +-- (prefixes r/R/b/B already consumed by the caller). Returns the +-- decoded value: text for strings, base64 text for bytes. ni is the +-- position after the closing quote. err set on failure. +CREATE OR REPLACE FUNCTION cel._lex_string( + source text, + pos int, + raw boolean, + is_bytes boolean, + OUT val text, + OUT ni int, + OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + n int := length(source); + q text := substr(source, pos, 1); + triple boolean := substr(source, pos, 3) = repeat(q, 3); + i int; + ch text; + sacc text := ''; + bacc bytea := ''::bytea; + code int; + hex text; + esc text; + width int; +BEGIN + i := pos + CASE WHEN triple THEN 3 ELSE 1 END; + + LOOP + IF i > n THEN + err := 'unterminated string'; + ni := i; + RETURN; + END IF; + ch := substr(source, i, 1); + + -- Closing quote. + IF triple THEN + IF substr(source, i, 3) = repeat(q, 3) THEN + i := i + 3; + EXIT; + END IF; + ELSIF ch = q THEN + i := i + 1; + EXIT; + ELSIF ch = E'\n' OR ch = E'\r' THEN + err := 'unterminated string'; + ni := i; + RETURN; + END IF; + + -- Raw literals keep backslashes verbatim. + IF ch <> E'\\' OR raw THEN + -- Newline normalization applies to literal source text in + -- every form (raw included): CRLF and CR become LF. Escape- + -- produced \r (code 13) is not source text and stays. + IF ch = E'\r' THEN + ch := E'\n'; + IF substr(source, i + 1, 1) = E'\n' THEN + i := i + 1; + END IF; + END IF; + IF is_bytes THEN + bacc := bacc || convert_to(ch, 'UTF8'); + ELSE + sacc := sacc || ch; + END IF; + i := i + 1; + CONTINUE; + END IF; + + -- Escape sequence. + i := i + 1; + IF i > n THEN + err := 'unterminated escape'; + ni := i; + RETURN; + END IF; + esc := substr(source, i, 1); + + IF esc IN ('a','b','f','n','r','t','v','\','''','"','?','`') THEN + code := CASE esc + WHEN 'a' THEN 7 WHEN 'b' THEN 8 WHEN 'f' THEN 12 + WHEN 'n' THEN 10 WHEN 'r' THEN 13 WHEN 't' THEN 9 + WHEN 'v' THEN 11 + ELSE ascii(esc) + END; + i := i + 1; + ELSIF esc IN ('x', 'X') THEN + hex := substr(source, i + 1, 2); + IF hex !~ '^[0-9a-fA-F]{2}$' THEN + err := 'invalid escape sequence \x'; + ni := i; + RETURN; + END IF; + code := ('x' || hex)::bit(8)::int; + i := i + 3; + ELSIF esc IN ('u', 'U') THEN + IF is_bytes THEN + err := format( + 'invalid escape sequence \%s in bytes literal', esc); + ni := i; + RETURN; + END IF; + width := CASE WHEN esc = 'u' THEN 4 ELSE 8 END; + hex := substr(source, i + 1, width); + IF hex !~ ('^[0-9a-fA-F]{' || width || '}$') THEN + err := format('invalid escape sequence \%s', esc); + ni := i; + RETURN; + END IF; + code := ('x' || lpad(hex, 8, '0'))::bit(32)::int; + i := i + 1 + width; + ELSIF esc ~ '^[0-3]$' THEN + hex := substr(source, i, 3); + IF hex !~ '^[0-3][0-7][0-7]$' THEN + err := 'invalid octal escape sequence'; + ni := i; + RETURN; + END IF; + code := (substr(hex, 1, 1)::int * 64) + + (substr(hex, 2, 1)::int * 8) + + substr(hex, 3, 1)::int; + i := i + 3; + ELSE + err := format('invalid escape sequence \%s', esc); + ni := i; + RETURN; + END IF; + + IF is_bytes THEN + IF code > 255 THEN + err := 'byte escape out of range'; + ni := i; + RETURN; + END IF; + bacc := bacc || decode(lpad(to_hex(code), 2, '0'), 'hex'); + ELSE + IF code = 0 THEN + -- PostgreSQL text cannot carry NUL; the conformance cases + -- that need it are skipped by name. Still a clean error. + err := 'NUL code point not representable in PostgreSQL text'; + ni := i; + RETURN; + ELSIF code < 0 OR code > 1114111 + OR (code >= 55296 AND code <= 57343) THEN + err := 'invalid unicode code point'; + ni := i; + RETURN; + END IF; + sacc := sacc || chr(code); + END IF; + END LOOP; + + ni := i; + IF is_bytes THEN + val := replace(encode(bacc, 'base64'), E'\n', ''); + ELSE + val := sacc; + END IF; +END; +$$; + +-- Lexes one numeric literal starting at pos (a digit, or '.' followed +-- by a digit). Emits t = 'int' | 'uint' | 'float' with the raw text +-- as v (uint without its u suffix, hex kept as 0x...); the parser +-- converts to a value so that a preceding '-' can fold in first. +CREATE OR REPLACE FUNCTION cel._lex_number( + source text, + pos int, + OUT t text, + OUT v text, + OUT ni int, + OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + rest text := substr(source, pos); + m text; +BEGIN + -- Hex integer. + m := (regexp_match(rest, '^0[xX][0-9a-fA-F]+'))[1]; + IF m IS NOT NULL THEN + IF substr(rest, length(m) + 1, 1) IN ('u', 'U') THEN + t := 'uint'; + v := m; + ni := pos + length(m) + 1; + ELSE + t := 'int'; + v := m; + ni := pos + length(m); + END IF; + RETURN; + END IF; + + -- Float: d+.d+[exp] | d+exp | .d+[exp] + m := (regexp_match(rest, + '^(\d+\.\d+([eE][+-]?\d+)?|\d+[eE][+-]?\d+|\.\d+([eE][+-]?\d+)?)' + ))[1]; + IF m IS NOT NULL THEN + t := 'float'; + v := m; + ni := pos + length(m); + RETURN; + END IF; + + -- Decimal integer. + m := (regexp_match(rest, '^\d+'))[1]; + IF m IS NULL THEN + err := 'invalid numeric literal'; + ni := pos; + RETURN; + END IF; + IF substr(rest, length(m) + 1, 1) IN ('u', 'U') THEN + t := 'uint'; + v := m; + ni := pos + length(m) + 1; + ELSE + t := 'int'; + v := m; + ni := pos + length(m); + END IF; +END; +$$; + +-- Lexes a whole expression into a flat token array: +-- {"t": , "v": , "s": , "e": } +-- with 0-based code-point offsets and a final {"t": "eof"} token. +-- Token types: operator/punctuation text ('&&', '(', ...), 'ident', +-- 'esc_ident', 'int', 'uint', 'float', 'string', 'bytes', 'bool', +-- 'null', 'in', 'reserved', 'eof'. +CREATE OR REPLACE FUNCTION cel._lex( + source text, + flags jsonb, + OUT toks jsonb, + OUT err text, + OUT errpos int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + n int := length(source); + i int := 1; + ch text; + two text; + start int; + m text; + raw boolean; + is_bytes boolean; + qpos int; + s record; + acc jsonb[] := '{}'; +BEGIN + WHILE i <= n LOOP + ch := substr(source, i, 1); + + -- Whitespace. + IF ch IN (' ', E'\t', E'\r', E'\n', E'\f', E'\v') THEN + i := i + 1; + CONTINUE; + END IF; + + -- Comment to end of line. + IF ch = '/' AND substr(source, i + 1, 1) = '/' THEN + WHILE i <= n AND substr(source, i, 1) <> E'\n' LOOP + i := i + 1; + END LOOP; + CONTINUE; + END IF; + + start := i; + + -- Two-character operators. + two := substr(source, i, 2); + IF two IN ('&&', '||', '<=', '>=', '==', '!=') THEN + acc := acc || jsonb_build_object( + 't', two, 's', start - 1, 'e', start + 1); + i := i + 2; + CONTINUE; + END IF; + + -- Number (before '.'-punctuation: '.5' is a float). + IF ch BETWEEN '0' AND '9' + OR (ch = '.' AND substr(source, i + 1, 1) BETWEEN '0' AND '9') + THEN + SELECT * INTO s FROM cel._lex_number(source, i); + IF s.err IS NOT NULL THEN + err := s.err; + errpos := s.ni - 1; + RETURN; + END IF; + acc := acc || jsonb_build_object( + 't', s.t, 'v', s.v, 's', start - 1, 'e', s.ni - 1); + i := s.ni; + CONTINUE; + END IF; + + -- Single-character operators and punctuation. + IF ch IN ('(', ')', '[', ']', '{', '}', ',', '.', ':', '?', + '+', '-', '*', '/', '%', '!', '<', '>', '=') + THEN + acc := acc || jsonb_build_object( + 't', ch, 's', start - 1, 'e', start); + i := i + 1; + CONTINUE; + END IF; + + -- String and bytes literals, with r/R/b/B prefixes. Per the + -- grammar, bytes is (b|B) followed by a string, whose own raw + -- prefix comes second: br'' is valid, rb'' is not. + raw := false; + is_bytes := false; + qpos := i; + IF ch IN ('b', 'B') THEN + IF substr(source, i + 1, 1) IN ('r', 'R') + AND substr(source, i + 2, 1) IN ('''', '"') THEN + is_bytes := true; + raw := true; + qpos := i + 2; + ELSIF substr(source, i + 1, 1) IN ('''', '"') THEN + is_bytes := true; + qpos := i + 1; + END IF; + ELSIF ch IN ('r', 'R') THEN + IF substr(source, i + 1, 1) IN ('''', '"') THEN + raw := true; + qpos := i + 1; + END IF; + END IF; + + IF ch IN ('''', '"') OR qpos > i THEN + SELECT * INTO s + FROM cel._lex_string(source, qpos, raw, is_bytes); + IF s.err IS NOT NULL THEN + err := s.err; + errpos := s.ni - 1; + RETURN; + END IF; + acc := acc || jsonb_build_object( + 't', CASE WHEN is_bytes THEN 'bytes' ELSE 'string' END, + 'v', s.val, 's', start - 1, 'e', s.ni - 1); + i := s.ni; + CONTINUE; + END IF; + + -- Escaped identifier `a-b.c/d ` -- letters, digits, and _.-/ + -- plus space; valid only where the parser allows it, and only + -- when the env enables the syntax. + IF ch = '`' THEN + IF NOT coalesce((flags ->> 'ident_escape')::boolean, false) THEN + err := 'unsupported syntax: ''`'''; + errpos := start - 1; + RETURN; + END IF; + m := (regexp_match(substr(source, i), '^`([A-Za-z0-9_./\- ]*)`'))[1]; + IF m IS NULL OR m = '' THEN + err := 'invalid escaped identifier'; + errpos := start - 1; + RETURN; + END IF; + acc := acc || jsonb_build_object( + 't', 'esc_ident', 'v', m, + 's', start - 1, 'e', start + length(m) + 1); + i := i + length(m) + 2; + CONTINUE; + END IF; + + -- Identifier, keyword literal, 'in', or reserved word. + IF ch = '_' OR (ch >= 'a' AND ch <= 'z') OR (ch >= 'A' AND ch <= 'Z') + THEN + m := (regexp_match(substr(source, i), '^[A-Za-z_][A-Za-z0-9_]*'))[1]; + IF m = 'true' OR m = 'false' THEN + acc := acc || jsonb_build_object( + 't', 'bool', 'v', m = 'true', + 's', start - 1, 'e', start - 1 + length(m)); + ELSIF m = 'null' THEN + acc := acc || jsonb_build_object( + 't', 'null', 's', start - 1, 'e', start - 1 + length(m)); + ELSIF m = 'in' THEN + acc := acc || jsonb_build_object( + 't', 'in', 's', start - 1, 'e', start - 1 + length(m)); + ELSIF m IN ('as', 'break', 'const', 'continue', 'else', 'for', + 'function', 'if', 'import', 'let', 'loop', 'package', + 'namespace', 'return', 'var', 'void', 'while') + THEN + acc := acc || jsonb_build_object( + 't', 'reserved', 'v', m, + 's', start - 1, 'e', start - 1 + length(m)); + ELSE + acc := acc || jsonb_build_object( + 't', 'ident', 'v', m, + 's', start - 1, 'e', start - 1 + length(m)); + END IF; + i := i + length(m); + CONTINUE; + END IF; + + err := format('unexpected character %s', quote_literal(ch)); + errpos := start - 1; + RETURN; + END LOOP; + + acc := acc || jsonb_build_object('t', 'eof', 's', n, 'e', n); + toks := to_jsonb(acc); +END; +$$; + +COMMIT; + +BEGIN; + +-- Line/column (both 0-based line, 0-based col) for an offset, for +-- parse error reporting. Conformance never string-matches parse +-- errors; this exists for humans. +CREATE OR REPLACE FUNCTION cel._line_col( + source text, off int, OUT line int, OUT col int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + before text := substr(source, 1, off); + last_nl int; +BEGIN + line := length(before) - length(replace(before, E'\n', '')); + last_nl := length(before) + - position(E'\n' IN reverse(before)) + 1; + IF position(E'\n' IN before) = 0 THEN + col := off; + ELSE + col := off - last_nl; + END IF; +END; +$$; + +-- The parse-failure envelope: {"errors": [{"msg","line","col"}]}. +CREATE OR REPLACE FUNCTION cel._parse_errors( + source text, msg text, off int +) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('errors', jsonb_build_array( + jsonb_build_object( + 'msg', msg, + 'line', lc.line + 1, + 'col', lc.col + 1 + ))) + FROM cel._line_col(source, coalesce(off, 0)) lc; +$$; + +-- Rebalances a chained && / || into a balanced binary tree, as +-- cel-go's default balancer does, keeping eval recursion logarithmic +-- in chain length. +CREATE OR REPLACE FUNCTION cel._p_balance( + elems jsonb, + fn text, + id bigint, + OUT node jsonb, + OUT nid bigint +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + cnt int := jsonb_array_length(elems); + mid int; + l record; + r record; +BEGIN + IF cnt = 1 THEN + node := elems -> 0; + nid := id; + RETURN; + END IF; + + mid := (cnt + 1) / 2; + + SELECT b.node, b.nid INTO l + FROM cel._p_balance( + (SELECT jsonb_agg(e) FROM jsonb_array_elements(elems) + WITH ORDINALITY t(e, o) WHERE o <= mid), + fn, id) b; + SELECT b.node, b.nid INTO r + FROM cel._p_balance( + (SELECT jsonb_agg(e) FROM jsonb_array_elements(elems) + WITH ORDINALITY t(e, o) WHERE o > mid), + fn, l.nid) b; + + nid := r.nid + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'call', 'fn', fn, + 'args', jsonb_build_array(l.node, r.node), + 's', l.node -> 's', 'e', r.node -> 'e'); +END; +$$; + +-- Walks a finished tree: extracts {"": [start, stop]} offsets and +-- the macro_calls recorded inline under "mc", stripping the working +-- keys from the nodes. +CREATE OR REPLACE FUNCTION cel._p_finalize( + node jsonb, + OUT clean jsonb, + OUT offsets jsonb, + OUT macro_calls jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := node ->> 'k'; + child record; + entry jsonb; + cleaned jsonb; + n_ent jsonb[] := '{}'; + key text; +BEGIN + offsets := '{}'::jsonb; + macro_calls := '{}'::jsonb; + + IF node ? 's' THEN + offsets := jsonb_build_object( + node ->> 'id', jsonb_build_array(node -> 's', node -> 'e')); + END IF; + IF node ? 'mc' THEN + SELECT f.clean INTO cleaned FROM cel._p_finalize(node -> 'mc') f; + macro_calls := jsonb_build_object(node ->> 'id', cleaned); + END IF; + + clean := node - 's' - 'e' - 'mc'; + + -- Recurse into child expression positions per kind. + FOR key IN + SELECT unnest(CASE k + WHEN 'select' THEN ARRAY['op'] + WHEN 'call' THEN ARRAY['target'] + WHEN 'comp' THEN ARRAY['range','init','cond','step','result'] + ELSE ARRAY[]::text[] + END) + LOOP + IF clean ? key THEN + SELECT * INTO child FROM cel._p_finalize(clean -> key); + clean := jsonb_set(clean, ARRAY[key], child.clean); + offsets := offsets || child.offsets; + macro_calls := macro_calls || child.macro_calls; + END IF; + END LOOP; + + IF k = 'call' AND clean ? 'args' THEN + FOR entry IN SELECT e FROM jsonb_array_elements(clean -> 'args') e + LOOP + SELECT * INTO child FROM cel._p_finalize(entry); + n_ent := n_ent || child.clean; + offsets := offsets || child.offsets; + macro_calls := macro_calls || child.macro_calls; + END LOOP; + clean := jsonb_set(clean, '{args}', to_jsonb(n_ent)); + ELSIF k = 'list' THEN + FOR entry IN SELECT e FROM jsonb_array_elements(clean -> 'elems') e + LOOP + SELECT * INTO child FROM cel._p_finalize(entry); + n_ent := n_ent || child.clean; + offsets := offsets || child.offsets; + macro_calls := macro_calls || child.macro_calls; + END LOOP; + clean := jsonb_set(clean, '{elems}', to_jsonb(n_ent)); + ELSIF k = 'map' THEN + FOR entry IN SELECT e FROM jsonb_array_elements(clean -> 'entries') e + LOOP + SELECT * INTO child FROM cel._p_finalize(entry -> 'k'); + entry := jsonb_set(entry, '{k}', child.clean); + offsets := offsets || child.offsets; + macro_calls := macro_calls || child.macro_calls; + SELECT * INTO child FROM cel._p_finalize(entry -> 'v'); + entry := jsonb_set(entry, '{v}', child.clean); + offsets := offsets || child.offsets; + macro_calls := macro_calls || child.macro_calls; + n_ent := n_ent || entry; + END LOOP; + clean := jsonb_set(clean, '{entries}', to_jsonb(n_ent)); + ELSIF k = 'struct' THEN + FOR entry IN SELECT e FROM jsonb_array_elements(clean -> 'fields') e + LOOP + SELECT * INTO child FROM cel._p_finalize(entry -> 'v'); + entry := jsonb_set(entry, '{v}', child.clean); + offsets := offsets || child.offsets; + macro_calls := macro_calls || child.macro_calls; + n_ent := n_ent || entry; + END LOOP; + clean := jsonb_set(clean, '{fields}', to_jsonb(n_ent)); + END IF; +END; +$$; + +COMMIT; + +BEGIN; + +-- Hex digits to numeric (uint64-sized values overflow bigint). +CREATE OR REPLACE FUNCTION cel._p_hex(h text) +RETURNS numeric +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + n numeric := 0; + c text; +BEGIN + FOREACH c IN ARRAY string_to_array(lower(h), NULL) LOOP + n := n * 16 + position(c IN '0123456789abcdef') - 1; + END LOOP; + RETURN n; +END; +$$; + +-- Converts a numeric literal token into a tagged value, applying an +-- already-folded sign. The sign folds at this level so that +-- -9223372036854775808 -- whose absolute value overflows int64 -- +-- parses exactly, as in cel-go, where the minus is part of the +-- literal production. +CREATE OR REPLACE FUNCTION cel._p_number_lit( + tok jsonb, + negate boolean, + OUT val jsonb, + OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + t text := tok ->> 't'; + raw text := tok ->> 'v'; + n numeric; +BEGIN + IF t = 'float' THEN + n := raw::numeric; + IF negate THEN + n := -n; + END IF; + IF abs(n) > 1.7976931348623157e308::numeric THEN + -- Overflow is a parse error in cel-go ("invalid double + -- literal", measured); underflow is not: values at or below + -- half the minimum subnormal (2^-1075, ties-to-even) round to + -- signed zero, which Postgres's cast would instead reject as + -- out of range, so they short-circuit here. + err := 'invalid double literal'; + RETURN; + END IF; + IF n <> 0 + AND abs(n) <= 2.4703282292062327e-324::numeric THEN + val := jsonb_build_object('@t', 'double', 'v', + to_jsonb((CASE WHEN n < 0 THEN '-0' ELSE '0' END)::float8)); + RETURN; + END IF; + val := jsonb_build_object('@t', 'double', 'v', to_jsonb(n::float8)); + RETURN; + END IF; + + IF raw ~ '^0[xX]' THEN + n := cel._p_hex(substr(raw, 3)); + ELSE + n := raw::numeric; + END IF; + + IF t = 'uint' THEN + IF negate OR n > 18446744073709551615::numeric THEN + err := 'invalid uint literal'; + RETURN; + END IF; + val := jsonb_build_object('@t', 'uint', 'v', to_jsonb(n)); + RETURN; + END IF; + + IF negate THEN + n := -n; + END IF; + IF n < -9223372036854775808::numeric + OR n > 9223372036854775807::numeric THEN + err := 'invalid int literal'; + RETURN; + END IF; + val := jsonb_build_object('@t', 'int', 'v', to_jsonb(n)); +END; +$$; + +-- Builds a call node, expanding it through the macro registry when a +-- (function, arity, receiver-style) row is visible in the env -- +-- day-one invariant 5: the standard macros take this exact path. An +-- expansion records the original call under "mc" for source info. +CREATE OR REPLACE FUNCTION cel._p_call( + fn text, + target jsonb, + args jsonb, + id bigint, + s int, + e int, + mac jsonb, + is_member boolean, + OUT node jsonb, + OUT nid bigint, + OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + arity int := jsonb_array_length(args); + proc text; + original jsonb; + x record; +BEGIN + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'call', 'fn', fn, 'args', args, 's', s, 'e', e); + IF target IS NOT NULL THEN + node := node || jsonb_build_object('target', target); + END IF; + + proc := coalesce( + mac ->> format('%s/%s/%s', fn, arity, is_member::int), + mac ->> format('%s/-1/%s', fn, is_member::int)); + IF proc IS NULL THEN + RETURN; + END IF; + + original := node; + EXECUTE format('SELECT * FROM %s($1, $2, $3)', proc) + INTO x + USING target, args, nid; + + IF x.err IS NOT NULL THEN + err := x.err; + RETURN; + END IF; + IF x.expr IS NULL THEN + RETURN; -- expander declined; keep the plain call + END IF; + + node := x.expr || jsonb_build_object('mc', original); + nid := x.next_id_out; +END; +$$; + +COMMIT; + +BEGIN; + +-- The recursive grammar. Every function shares one signature: +-- (tk, p, id, d, mac, fl) -> (node, np, nid, err, ep) +-- tk: token array; p: cursor; id: last assigned node id; d: depth +-- budget consumed; mac: visible macros; fl: env flags. Nodes carry +-- their source span inline as s/e until cel._p_finalize lifts the +-- spans into the envelope. err/ep report the first failure; every +-- call site checks err and bails, because errors are values here, +-- not exceptions. + +-- expr: or ('?' or ':' expr)? (ternary is right-associative) +CREATE OR REPLACE FUNCTION cel._p_expr( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + t record; + f record; + x record; +BEGIN + IF d >= 200 THEN + err := 'expression recursion limit exceeded: 200'; + ep := (tk -> p ->> 's')::int; + RETURN; + END IF; + + SELECT * INTO c FROM cel._p_or(tk, p, id, d + 1, mac, fl); + IF c.err IS NOT NULL THEN + node := NULL; np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + + IF tk -> np ->> 't' <> '?' THEN + RETURN; + END IF; + + SELECT * INTO t FROM cel._p_or(tk, np + 1, nid, d + 1, mac, fl); + IF t.err IS NOT NULL THEN + err := t.err; ep := t.ep; + RETURN; + END IF; + IF tk -> t.np ->> 't' <> ':' THEN + err := 'expected '':'' in ternary'; + ep := (tk -> t.np ->> 's')::int; + RETURN; + END IF; + SELECT * INTO f FROM cel._p_expr(tk, t.np + 1, t.nid, d + 1, mac, fl); + IF f.err IS NOT NULL THEN + err := f.err; ep := f.ep; + RETURN; + END IF; + + SELECT * INTO x FROM cel._p_call( + '_?_:_', NULL, + jsonb_build_array(node, t.node, f.node), + f.nid, (node ->> 's')::int, (f.node ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := (node ->> 's')::int; + RETURN; + END IF; + node := x.node; np := f.np; nid := x.nid; +END; +$$; + +-- Chained || gathers operands and rebalances into a balanced tree. +CREATE OR REPLACE FUNCTION cel._p_or( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + b record; + elems jsonb; +BEGIN + SELECT * INTO c FROM cel._p_and(tk, p, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + elems := jsonb_build_array(node); + + WHILE tk -> np ->> 't' = '||' LOOP + SELECT * INTO c FROM cel._p_and(tk, np + 1, nid, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + elems := elems || jsonb_build_array(c.node); + np := c.np; nid := c.nid; + END LOOP; + + IF jsonb_array_length(elems) > 1 THEN + SELECT * INTO b FROM cel._p_balance(elems, '_||_', nid); + node := b.node; nid := b.nid; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._p_and( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + b record; + elems jsonb; +BEGIN + SELECT * INTO c FROM cel._p_rel(tk, p, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + elems := jsonb_build_array(node); + + WHILE tk -> np ->> 't' = '&&' LOOP + SELECT * INTO c FROM cel._p_rel(tk, np + 1, nid, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + elems := elems || jsonb_build_array(c.node); + np := c.np; nid := c.nid; + END LOOP; + + IF jsonb_array_length(elems) > 1 THEN + SELECT * INTO b FROM cel._p_balance(elems, '_&&_', nid); + node := b.node; nid := b.nid; + END IF; +END; +$$; + +-- Left-associative binary tiers: relations, additive, +-- multiplicative. One implementation parameterized by the operator +-- map and the next-tighter parser would need dynamic SQL per call; +-- three small copies keep the hot path static. +CREATE OR REPLACE FUNCTION cel._p_rel( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + x record; + tt text; + fn text; +BEGIN + SELECT * INTO c FROM cel._p_add(tk, p, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + + LOOP + tt := tk -> np ->> 't'; + fn := CASE tt + WHEN '<' THEN '_<_' WHEN '<=' THEN '_<=_' + WHEN '>' THEN '_>_' WHEN '>=' THEN '_>=_' + WHEN '==' THEN '_==_' WHEN '!=' THEN '_!=_' + WHEN 'in' THEN '@in' + END; + EXIT WHEN fn IS NULL; + + SELECT * INTO c FROM cel._p_add(tk, np + 1, nid, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + SELECT * INTO x FROM cel._p_call( + fn, NULL, jsonb_build_array(node, c.node), c.nid, + (node ->> 's')::int, (c.node ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := (node ->> 's')::int; + RETURN; + END IF; + node := x.node; np := c.np; nid := x.nid; + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._p_add( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + x record; + tt text; + fn text; +BEGIN + SELECT * INTO c FROM cel._p_mul(tk, p, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + + LOOP + tt := tk -> np ->> 't'; + fn := CASE tt WHEN '+' THEN '_+_' WHEN '-' THEN '_-_' END; + EXIT WHEN fn IS NULL; + + SELECT * INTO c FROM cel._p_mul(tk, np + 1, nid, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + SELECT * INTO x FROM cel._p_call( + fn, NULL, jsonb_build_array(node, c.node), c.nid, + (node ->> 's')::int, (c.node ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := (node ->> 's')::int; + RETURN; + END IF; + node := x.node; np := c.np; nid := x.nid; + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._p_mul( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + x record; + tt text; + fn text; +BEGIN + SELECT * INTO c FROM cel._p_unary(tk, p, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + + LOOP + tt := tk -> np ->> 't'; + fn := CASE tt + WHEN '*' THEN '_*_' WHEN '/' THEN '_/_' WHEN '%' THEN '_%_' + END; + EXIT WHEN fn IS NULL; + + SELECT * INTO c FROM cel._p_unary(tk, np + 1, nid, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + SELECT * INTO x FROM cel._p_call( + fn, NULL, jsonb_build_array(node, c.node), c.nid, + (node ->> 's')::int, (c.node ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := (node ->> 's')::int; + RETURN; + END IF; + node := x.node; np := c.np; nid := x.nid; + END LOOP; +END; +$$; + +-- Unary ! and -. Even-length chains collapse to the operand; an odd +-- chain of - directly on a numeric literal folds into the literal +-- (which is how -9223372036854775808 parses exactly). +CREATE OR REPLACE FUNCTION cel._p_unary( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + op text := tk -> p ->> 't'; + cnt int := 0; + q int := p; + c record; + x record; + s0 int; + tok jsonb; +BEGIN + IF op NOT IN ('!', '-') THEN + SELECT * INTO c FROM cel._p_member(tk, p, id, d, mac, fl); + node := c.node; np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + + s0 := (tk -> p ->> 's')::int; + WHILE tk -> q ->> 't' = op LOOP + cnt := cnt + 1; + q := q + 1; + END LOOP; + + -- Sign fold: odd minus chain directly on a numeric literal. + tok := tk -> q; + IF op = '-' AND cnt % 2 = 1 AND tok ->> 't' IN ('int', 'float') THEN + SELECT * INTO x FROM cel._p_number_lit(tok, true); + IF x.err IS NOT NULL THEN + err := x.err; ep := (tok ->> 's')::int; + RETURN; + END IF; + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'lit', 'v', x.val, + 's', s0, 'e', tok -> 'e'); + -- Postfix still applies to the folded literal. + SELECT * INTO c FROM cel._p_postfix(node, tk, q + 1, nid, d, mac, fl); + node := c.node; np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + + SELECT * INTO c FROM cel._p_member(tk, q, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + node := c.node; np := c.np; nid := c.nid; + + IF cnt % 2 = 1 THEN + SELECT * INTO x FROM cel._p_call( + CASE op WHEN '!' THEN '!_' ELSE '-_' END, + NULL, jsonb_build_array(node), nid, + s0, (node ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := s0; + RETURN; + END IF; + node := x.node; nid := x.nid; + END IF; +END; +$$; + +-- member: primary + the postfix chain. +CREATE OR REPLACE FUNCTION cel._p_member( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; +BEGIN + SELECT * INTO c FROM cel._p_primary(tk, p, id, d, mac, fl); + IF c.err IS NOT NULL THEN + np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + SELECT * INTO c + FROM cel._p_postfix(c.node, tk, c.np, c.nid, d, mac, fl); + node := c.node; np := c.np; nid := c.nid; err := c.err; ep := c.ep; +END; +$$; + +-- The postfix chain on an already-parsed operand: .field, .f(args), +-- [index], and the optional-syntax forms .?field and [?index], which +-- are errors unless the env sets optional_syntax. +CREATE OR REPLACE FUNCTION cel._p_postfix( + operand jsonb, tk jsonb, p int, id bigint, d int, + mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + tt text; + nt jsonb; + name text; + optional boolean; + c record; + x record; + args jsonb; + s0 int := (operand ->> 's')::int; + opt_on boolean := + coalesce((fl ->> 'optional_syntax')::boolean, false); +BEGIN + node := operand; np := p; nid := id; + + LOOP + tt := tk -> np ->> 't'; + + IF tt = '.' THEN + optional := false; + nt := tk -> (np + 1); + IF nt ->> 't' = '?' THEN + IF NOT opt_on THEN + err := 'unsupported syntax ''.?'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + optional := true; + nt := tk -> (np + 2); + END IF; + + -- Reserved words are permitted as selectors (the corpus's + -- parse/selectors section) -- only true/false/null/in are + -- full keywords and stay excluded. + IF nt ->> 't' NOT IN ('ident', 'esc_ident', 'reserved') THEN + err := 'expected field or method name after ''.'''; + ep := (nt ->> 's')::int; + RETURN; + END IF; + name := nt ->> 'v'; + np := np + CASE WHEN optional THEN 3 ELSE 2 END; + + IF optional THEN + -- .?f => _?._(operand, "f") + nid := nid + 1; + SELECT * INTO x FROM cel._p_call( + '_?._', NULL, + jsonb_build_array(node, jsonb_build_object( + 'id', nid, 'k', 'lit', + 'v', jsonb_build_object('@t', 'string', 'v', name), + 's', nt -> 's', 'e', nt -> 'e')), + nid, s0, (nt ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := s0; + RETURN; + END IF; + node := x.node; nid := x.nid; + CONTINUE; + END IF; + + IF tk -> np ->> 't' = '(' THEN + -- Receiver-style call. + SELECT * INTO c FROM cel._p_args(tk, np + 1, nid, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + SELECT * INTO x FROM cel._p_call( + name, node, c.node, c.nid, + s0, (tk -> (c.np - 1) ->> 'e')::int, mac, true); + IF x.err IS NOT NULL THEN + err := x.err; ep := (nt ->> 's')::int; + RETURN; + END IF; + node := x.node; np := c.np; nid := x.nid; + CONTINUE; + END IF; + + nid := nid + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'select', 'op', node, 'field', name, + 's', s0, 'e', nt -> 'e'); + CONTINUE; + END IF; + + IF tt = '[' THEN + optional := false; + IF tk -> (np + 1) ->> 't' = '?' THEN + IF NOT opt_on THEN + err := 'unsupported syntax ''[?'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + optional := true; + END IF; + + SELECT * INTO c FROM cel._p_expr( + tk, np + CASE WHEN optional THEN 2 ELSE 1 END, + nid, d + 1, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + IF tk -> c.np ->> 't' <> ']' THEN + err := 'expected '']'''; + ep := (tk -> c.np ->> 's')::int; + RETURN; + END IF; + SELECT * INTO x FROM cel._p_call( + CASE WHEN optional THEN '_[?_]' ELSE '_[_]' END, + NULL, jsonb_build_array(node, c.node), c.nid, + s0, (tk -> c.np ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := s0; + RETURN; + END IF; + node := x.node; np := c.np + 1; nid := x.nid; + CONTINUE; + END IF; + + EXIT; + END LOOP; +END; +$$; + +-- Call argument list: '(' already consumed; returns a jsonb array of +-- argument nodes and leaves np just past ')'. +CREATE OR REPLACE FUNCTION cel._p_args( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; +BEGIN + node := '[]'::jsonb; np := p; nid := id; + + IF tk -> np ->> 't' = ')' THEN + np := np + 1; + RETURN; + END IF; + + LOOP + SELECT * INTO c FROM cel._p_expr(tk, np, nid, d + 1, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + node := node || jsonb_build_array(c.node); + np := c.np; nid := c.nid; + + IF tk -> np ->> 't' = ',' THEN + np := np + 1; + CONTINUE; + END IF; + EXIT; + END LOOP; + + IF tk -> np ->> 't' <> ')' THEN + err := 'expected '')'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + np := np + 1; +END; +$$; + +COMMIT; + +BEGIN; + +-- List literal: '[' consumed. Trailing comma allowed. '?e' elements +-- (optionals extension) record their indices under "opt". +CREATE OR REPLACE FUNCTION cel._p_list( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, s0 int, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c record; + elems jsonb := '[]'; + opts jsonb := '[]'; + idx int := 0; + opt_on boolean := + coalesce((fl ->> 'optional_syntax')::boolean, false); +BEGIN + np := p; nid := id; + + WHILE tk -> np ->> 't' <> ']' LOOP + IF tk -> np ->> 't' = '?' THEN + IF NOT opt_on THEN + err := 'unsupported syntax ''?'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + opts := opts || to_jsonb(idx); + np := np + 1; + END IF; + + SELECT * INTO c FROM cel._p_expr(tk, np, nid, d + 1, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + elems := elems || jsonb_build_array(c.node); + np := c.np; nid := c.nid; + idx := idx + 1; + + IF tk -> np ->> 't' = ',' THEN + np := np + 1; + ELSIF tk -> np ->> 't' <> ']' THEN + err := 'expected '','' or '']'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + END LOOP; + + nid := nid + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'list', 'elems', elems, + 's', s0, 'e', tk -> np -> 'e'); + IF jsonb_array_length(opts) > 0 THEN + node := node || jsonb_build_object('opt', opts); + END IF; + np := np + 1; +END; +$$; + +-- Map literal: '{' consumed. Keys are full expressions (the checker +-- restricts them); entries carry their own ids as in cel-go. +CREATE OR REPLACE FUNCTION cel._p_map( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, s0 int, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k record; + v record; + entries jsonb := '[]'; + optional boolean; + opt_on boolean := + coalesce((fl ->> 'optional_syntax')::boolean, false); +BEGIN + np := p; nid := id; + + WHILE tk -> np ->> 't' <> '}' LOOP + optional := false; + IF tk -> np ->> 't' = '?' THEN + IF NOT opt_on THEN + err := 'unsupported syntax ''?'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + optional := true; + np := np + 1; + END IF; + + SELECT * INTO k FROM cel._p_expr(tk, np, nid, d + 1, mac, fl); + IF k.err IS NOT NULL THEN + err := k.err; ep := k.ep; + RETURN; + END IF; + IF tk -> k.np ->> 't' <> ':' THEN + err := 'expected '':'''; + ep := (tk -> k.np ->> 's')::int; + RETURN; + END IF; + SELECT * INTO v FROM cel._p_expr(tk, k.np + 1, k.nid, d + 1, mac, fl); + IF v.err IS NOT NULL THEN + err := v.err; ep := v.ep; + RETURN; + END IF; + + nid := v.nid + 1; + entries := entries || jsonb_build_array(jsonb_build_object( + 'id', nid, 'k', k.node, 'v', v.node, 'opt', optional)); + np := v.np; + + IF tk -> np ->> 't' = ',' THEN + np := np + 1; + ELSIF tk -> np ->> 't' <> '}' THEN + err := 'expected '','' or ''}'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + END LOOP; + + nid := nid + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'map', 'entries', entries, + 's', s0, 'e', tk -> np -> 'e'); + np := np + 1; +END; +$$; + +-- Message literal: the type name and '{' are consumed. Field names +-- are identifiers or escaped identifiers. +CREATE OR REPLACE FUNCTION cel._p_struct( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + type_name text, s0 int, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + fields jsonb := '[]'; + fname text; + optional boolean; + nt jsonb; + opt_on boolean := + coalesce((fl ->> 'optional_syntax')::boolean, false); +BEGIN + np := p; nid := id; + + WHILE tk -> np ->> 't' <> '}' LOOP + optional := false; + IF tk -> np ->> 't' = '?' THEN + IF NOT opt_on THEN + err := 'unsupported syntax ''?'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + optional := true; + np := np + 1; + END IF; + + nt := tk -> np; + IF nt ->> 't' NOT IN ('ident', 'esc_ident') THEN + err := 'expected field name'; + ep := (nt ->> 's')::int; + RETURN; + END IF; + fname := nt ->> 'v'; + IF tk -> (np + 1) ->> 't' <> ':' THEN + err := 'expected '':'''; + ep := (tk -> (np + 1) ->> 's')::int; + RETURN; + END IF; + + SELECT * INTO v FROM cel._p_expr(tk, np + 2, nid, d + 1, mac, fl); + IF v.err IS NOT NULL THEN + err := v.err; ep := v.ep; + RETURN; + END IF; + + nid := v.nid + 1; + fields := fields || jsonb_build_array(jsonb_build_object( + 'id', nid, 'name', fname, 'v', v.node, 'opt', optional)); + np := v.np; + + IF tk -> np ->> 't' = ',' THEN + np := np + 1; + ELSIF tk -> np ->> 't' <> '}' THEN + err := 'expected '','' or ''}'''; + ep := (tk -> np ->> 's')::int; + RETURN; + END IF; + END LOOP; + + nid := nid + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'struct', 'type', type_name, 'fields', fields, + 's', s0, 'e', tk -> np -> 'e'); + np := np + 1; +END; +$$; + +-- primary: literals, identifiers, global calls, parens, list/map/ +-- message literals. A dotted identifier path followed by '{' is a +-- message literal; the lookahead scan consumes nothing on miss. +CREATE OR REPLACE FUNCTION cel._p_primary( + tk jsonb, p int, id bigint, d int, mac jsonb, fl jsonb, + OUT node jsonb, OUT np int, OUT nid bigint, + OUT err text, OUT ep int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + tok jsonb := tk -> p; + tt text := tk -> p ->> 't'; + s0 int := (tk -> p ->> 's')::int; + c record; + x record; + name text; + j int; + leading_dot boolean := false; +BEGIN + np := p; nid := id; + + -- Literals. + IF tt IN ('int', 'uint', 'float') THEN + SELECT * INTO x FROM cel._p_number_lit(tok, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := s0; + RETURN; + END IF; + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'lit', 'v', x.val, 's', s0, 'e', tok -> 'e'); + np := p + 1; + RETURN; + ELSIF tt = 'string' THEN + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'lit', + 'v', jsonb_build_object('@t', 'string', 'v', tok -> 'v'), + 's', s0, 'e', tok -> 'e'); + np := p + 1; + RETURN; + ELSIF tt = 'bytes' THEN + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bytes', 'v', tok -> 'v'), + 's', s0, 'e', tok -> 'e'); + np := p + 1; + RETURN; + ELSIF tt = 'bool' THEN + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', tok -> 'v'), + 's', s0, 'e', tok -> 'e'); + np := p + 1; + RETURN; + ELSIF tt = 'null' THEN + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'lit', + 'v', jsonb_build_object('@t', 'null', 'v', NULL), + 's', s0, 'e', tok -> 'e'); + np := p + 1; + RETURN; + END IF; + + -- Parenthesized expression. + IF tt = '(' THEN + SELECT * INTO c FROM cel._p_expr(tk, p + 1, id, d + 1, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + IF tk -> c.np ->> 't' <> ')' THEN + err := 'expected '')'''; + ep := (tk -> c.np ->> 's')::int; + RETURN; + END IF; + node := c.node; np := c.np + 1; nid := c.nid; + RETURN; + END IF; + + IF tt = '[' THEN + SELECT * INTO c + FROM cel._p_list(tk, p + 1, id, d, mac, fl, s0); + node := c.node; np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + + IF tt = '{' THEN + SELECT * INTO c + FROM cel._p_map(tk, p + 1, id, d, mac, fl, s0); + node := c.node; np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + + IF tt = 'reserved' THEN + err := format('reserved identifier: %s', tok ->> 'v'); + ep := s0; + RETURN; + END IF; + + -- Leading '.' root-qualifies the identifier that follows. + IF tt = '.' THEN + leading_dot := true; + np := p + 1; + tok := tk -> np; + tt := tok ->> 't'; + IF tt = 'reserved' THEN + err := format('reserved identifier: %s', tok ->> 'v'); + ep := (tok ->> 's')::int; + RETURN; + END IF; + IF tt <> 'ident' THEN + err := 'expected identifier after ''.'''; + ep := (tok ->> 's')::int; + RETURN; + END IF; + END IF; + + IF tt <> 'ident' THEN + IF tt = 'eof' THEN + err := 'unexpected end of expression'; + ELSE + err := format('unexpected token %s', + quote_literal(coalesce(tok ->> 'v', tt))); + END IF; + ep := s0; + RETURN; + END IF; + + name := tok ->> 'v'; + + -- Message-literal lookahead: ident ('.' ident)* '{'. The scan + -- consumes nothing unless the '{' really is there. + j := np + 1; + WHILE tk -> j ->> 't' = '.' AND tk -> (j + 1) ->> 't' = 'ident' LOOP + j := j + 2; + END LOOP; + IF tk -> j ->> 't' = '{' THEN + -- Rebuild the dotted name from the scanned tokens. + DECLARE + k2 int := np + 1; + BEGIN + WHILE k2 < j LOOP + name := name || '.' || (tk -> (k2 + 1) ->> 'v'); + k2 := k2 + 2; + END LOOP; + END; + IF leading_dot THEN + name := '.' || name; + END IF; + SELECT * INTO c + FROM cel._p_struct(tk, j + 1, id, d, mac, fl, name, s0); + node := c.node; np := c.np; nid := c.nid; err := c.err; ep := c.ep; + RETURN; + END IF; + + -- Global call. + IF tk -> (np + 1) ->> 't' = '(' THEN + SELECT * INTO c FROM cel._p_args(tk, np + 2, id, d, mac, fl); + IF c.err IS NOT NULL THEN + err := c.err; ep := c.ep; + RETURN; + END IF; + SELECT * INTO x FROM cel._p_call( + CASE WHEN leading_dot THEN '.' || name ELSE name END, + NULL, c.node, c.nid, + s0, (tk -> (c.np - 1) ->> 'e')::int, mac, false); + IF x.err IS NOT NULL THEN + err := x.err; ep := s0; + RETURN; + END IF; + node := x.node; np := c.np; nid := x.nid; + RETURN; + END IF; + + -- Plain identifier. + nid := id + 1; + node := jsonb_build_object( + 'id', nid, 'k', 'ident', + 'name', CASE WHEN leading_dot THEN '.' || name ELSE name END, + 's', s0, 'e', tok -> 'e'); + np := np + 1; +END; +$$; + +COMMIT; + +BEGIN; + +-- Macro expanders. Each has the registry signature +-- (target jsonb, args jsonb, next_id bigint) +-- -> (expr jsonb, next_id bigint, err text) +-- and builds the exact comprehension shapes cel-go's standard macros +-- produce, accumulator named @result. The standard six register +-- through cel.macro like any extension's -- no privileged path. + +-- Validates a comprehension iteration variable argument. +CREATE OR REPLACE FUNCTION cel._mx_itervar( + arg jsonb, OUT name text, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +BEGIN + IF arg ->> 'k' <> 'ident' THEN + IF arg ->> 'k' = 'select' THEN + err := 'argument must be a simple name'; + ELSE + err := 'argument is not an identifier'; + END IF; + RETURN; + END IF; + name := arg ->> 'name'; + IF name IN ('@result', '__result__') THEN + err := 'iteration variable overwrites accumulator variable'; + END IF; +END; +$$; + +-- has(e): a select becomes a presence test; anything else is an +-- error. No new ids are needed. +CREATE OR REPLACE FUNCTION cel._mx_has( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + arg jsonb := args -> 0; +BEGIN + next_id_out := next_id; + IF arg ->> 'k' <> 'select' THEN + err := 'invalid argument to has() macro'; + RETURN; + END IF; + expr := arg || jsonb_build_object('test', true); +END; +$$; + +-- Shared assembly for the five standard comprehensions. kind picks +-- the init/cond/step/result wiring; p and t are the predicate / +-- transform arguments where the macro has them. +CREATE OR REPLACE FUNCTION cel._mx_fold( + kind text, + target jsonb, iter text, p jsonb, t jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + id bigint := next_id; + cs jsonb := target -> 's'; + ce jsonb := coalesce(t, p, target) -> 'e'; + init jsonb; + cond jsonb; + step jsonb; + result jsonb; + accu jsonb; + tmp jsonb; +BEGIN + -- Helper shapes reused below; each use re-stamps a fresh id. + + IF kind = 'all' THEN + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', true), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'call', + 'fn', '@not_strictly_false', 'args', jsonb_build_array(accu), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_&&_', 'args', jsonb_build_array(accu, p), + 's', cs, 'e', ce); + id := id + 1; + result := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + + ELSIF kind = 'exists' THEN + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', false), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + tmp := jsonb_build_object('id', id, 'k', 'call', + 'fn', '!_', 'args', jsonb_build_array(accu), + 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'call', + 'fn', '@not_strictly_false', 'args', jsonb_build_array(tmp), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_||_', 'args', jsonb_build_array(accu, p), + 's', cs, 'e', ce); + id := id + 1; + result := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + + ELSIF kind = 'exists_one' THEN + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'int', 'v', 0), + 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', true), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + tmp := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'int', 'v', 1), + 's', cs, 'e', ce); + id := id + 1; + tmp := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_+_', 'args', jsonb_build_array(accu, tmp), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_?_:_', 'args', jsonb_build_array(p, tmp, accu), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + tmp := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'int', 'v', 1), + 's', cs, 'e', ce); + id := id + 1; + result := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_==_', 'args', jsonb_build_array(accu, tmp), + 's', cs, 'e', ce); + + ELSIF kind IN ('map', 'map_filter', 'filter') THEN + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'list', + 'elems', '[]'::jsonb, 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', true), + 's', cs, 'e', ce); + id := id + 1; + tmp := jsonb_build_object('id', id, 'k', 'list', + 'elems', jsonb_build_array( + CASE WHEN kind = 'filter' + THEN jsonb_build_object('k', 'ident', 'name', iter, + 's', cs, 'e', ce) + ELSE t END), + 's', cs, 'e', ce); + -- The filter element ident needs its own id. + IF kind = 'filter' THEN + id := id + 1; + tmp := jsonb_set(tmp, '{elems,0,id}', to_jsonb(id)); + END IF; + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_+_', 'args', jsonb_build_array(accu, tmp), + 's', cs, 'e', ce); + IF kind <> 'map' THEN + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_?_:_', 'args', jsonb_build_array(p, step, accu), + 's', cs, 'e', ce); + END IF; + id := id + 1; + result := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + ELSE + err := format('unknown fold kind %s', kind); + RETURN; + END IF; + + id := id + 1; + expr := jsonb_build_object( + 'id', id, 'k', 'comp', + 'range', target, 'iter', iter, 'iter2', '', + 'accu', '@result', + 'init', init, 'cond', cond, 'step', step, 'result', result, + 's', cs, 'e', ce); + next_id_out := id; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_all( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx_itervar(args -> 0); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + SELECT * INTO f + FROM cel._mx_fold('all', target, v.name, args -> 1, NULL, next_id); + expr := f.expr; next_id_out := f.next_id_out; err := f.err; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_exists( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx_itervar(args -> 0); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + SELECT * INTO f + FROM cel._mx_fold('exists', target, v.name, args -> 1, NULL, next_id); + expr := f.expr; next_id_out := f.next_id_out; err := f.err; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_exists_one( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx_itervar(args -> 0); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + SELECT * INTO f + FROM cel._mx_fold('exists_one', target, v.name, args -> 1, NULL, + next_id); + expr := f.expr; next_id_out := f.next_id_out; err := f.err; +END; +$$; + +-- map has arity 2 (transform) and arity 3 (filter + transform). +CREATE OR REPLACE FUNCTION cel._mx_map( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx_itervar(args -> 0); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + IF jsonb_array_length(args) = 3 THEN + SELECT * INTO f FROM cel._mx_fold( + 'map_filter', target, v.name, args -> 1, args -> 2, next_id); + ELSE + SELECT * INTO f FROM cel._mx_fold( + 'map', target, v.name, NULL, args -> 1, next_id); + END IF; + expr := f.expr; next_id_out := f.next_id_out; err := f.err; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_filter( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx_itervar(args -> 0); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + SELECT * INTO f + FROM cel._mx_fold('filter', target, v.name, args -> 1, NULL, next_id); + expr := f.expr; next_id_out := f.next_id_out; err := f.err; +END; +$$; + +-- The standard macro rows and their visibility in the standard env. +INSERT INTO cel.macro (name, arity, member, expander) VALUES + ('has', 1, false, 'cel._mx_has(jsonb,jsonb,bigint)'), + ('all', 2, true, 'cel._mx_all(jsonb,jsonb,bigint)'), + ('exists', 2, true, 'cel._mx_exists(jsonb,jsonb,bigint)'), + ('exists_one', 2, true, 'cel._mx_exists_one(jsonb,jsonb,bigint)'), + ('map', 2, true, 'cel._mx_map(jsonb,jsonb,bigint)'), + ('map', 3, true, 'cel._mx_map(jsonb,jsonb,bigint)'), + ('filter', 2, true, 'cel._mx_filter(jsonb,jsonb,bigint)') +ON CONFLICT (name, arity, member) DO UPDATE SET expander = excluded.expander; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'standard', 'macro', format('%s/%s/%s', name, arity, member::int) +FROM cel.macro +ON CONFLICT DO NOTHING; + +-- Parses CEL source under an environment. Returns the AST envelope, +-- or {"errors": [...]} when the expression is rejected -- callers +-- and the conformance runner key on the "errors" field. +CREATE OR REPLACE FUNCTION cel.parse(source text, env text) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + fl jsonb; + mac jsonb; + lx record; + px record; + fin record; + lines jsonb; +BEGIN + IF length(source) > 100000 THEN + RETURN cel._parse_errors(source, 'expression size limit exceeded', 0); + END IF; + + fl := cel._env_flags(env); + mac := cel._env_macros(env); + + SELECT * INTO lx FROM cel._lex(source, fl); + IF lx.err IS NOT NULL THEN + RETURN cel._parse_errors(source, lx.err, lx.errpos); + END IF; + + SELECT * INTO px FROM cel._p_expr(lx.toks, 0, 0, 0, mac, fl); + IF px.err IS NOT NULL THEN + RETURN cel._parse_errors(source, px.err, px.ep); + END IF; + IF lx.toks -> px.np ->> 't' <> 'eof' THEN + RETURN cel._parse_errors( + source, + format('unexpected token %s', quote_literal(coalesce( + lx.toks -> px.np ->> 'v', lx.toks -> px.np ->> 't'))), + (lx.toks -> px.np ->> 's')::int); + END IF; + IF px.nid > 100000 THEN + RETURN cel._parse_errors(source, 'expression node limit exceeded', 0); + END IF; + + SELECT * INTO fin FROM cel._p_finalize(px.node); + + SELECT coalesce(jsonb_agg(o - 1), '[]'::jsonb) INTO lines + FROM ( + SELECT o + FROM unnest(string_to_array(source, NULL)) WITH ORDINALITY t(ch, o) + WHERE ch = E'\n' + ) nl; + + RETURN jsonb_build_object( + 'v', 1, + 'expr', fin.clean, + 'source', jsonb_build_object( + 'desc', '', + 'lines', lines, + 'offsets', fin.offsets, + 'macro_calls', fin.macro_calls)); +END; +$$; + +COMMIT; From bb43d4f0dfd91ca069a80748abf5ab92c3d747a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 19:30:36 -0300 Subject: [PATCH 08/21] Add the evaluator core and stdlib part one The tree walk carries errors and unknowns as tagged values end to end: commutative absorption for && and || (false && error is false in either order), lazy ternary branches, LHS-error-first equality, first-error-then-merged-unknowns strictness, and the comprehension fold whose loop only a genuine bool false terminates -- the rule that lets exists recover from early errors. None of it is bolt-on; that is why it lands with the first overload rather than after. Dispatch is table-driven from the first call: candidates come from cel.overload rows visible in the env union, matched against runtime kinds in declaration order, and invoked through EXECUTE with the uniform impl(args jsonb[]) signature. The absorbing ids -- logical and/or, conditional, not_strictly_false, equals, plus the index qualifiers -- are rows with NULL impls that the core recognizes by id after finding them through the same table. Indexing sits with them because cel-go treats it as attribute machinery, which is what admits losslessly-coercible double list indices at runtime; plain signature matching could not without also corrupting arithmetic selection. Name resolution is scope-major, measured against cel-go: an iteration variable shadows an outer binding of a longer dotted name, and a container qualifies candidates longest-first. cel.eval gains an options parameter carrying the container, which unchecked evaluation needs and checked ASTs will not. Double arithmetic routes finite operands through exact numeric computation because Postgres float8 raises where IEEE saturates; re-entry to float8 uses the true rounding boundaries (2^1024 - 2^970 and 2^-1075). Postgres also considers NaN equal to NaN, so the NaN checks are direct comparisons rather than x <> x. Every disable_check case in the corpus now passes; all remaining TestSimple failures are the not-yet-written check stage. --- conformance/simple_test.go | 14 +- internal/oracle/options.go | 11 +- sql/020_values.sql | 338 ++++++++++++++++ sql/050_eval.sql | 712 ++++++++++++++++++++++++++++++++ sql/060_stdlib.sql | 805 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1878 insertions(+), 2 deletions(-) create mode 100644 sql/050_eval.sql create mode 100644 sql/060_stdlib.sql diff --git a/conformance/simple_test.go b/conformance/simple_test.go index 34984d7..fb6c390 100644 --- a/conformance/simple_test.go +++ b/conformance/simple_test.go @@ -234,9 +234,21 @@ func runCase( if err != nil { t.Fatalf("eval stage: %v", err) } + // The container reaches eval too: unchecked evaluation resolves + // names at runtime (checked ASTs bind them at check time, where + // the same option arrives via checkOptions). + evalOptions := map[string]any{} + if tc.GetContainer() != "" { + evalOptions["container"] = tc.GetContainer() + } + evalOptionsJSON, err := json.Marshal(evalOptions) + if err != nil { + t.Fatalf("eval stage: %v", err) + } var rawResult []byte err = conn.QueryRow(ctx, - "SELECT cel.eval($1, $2, $3)", raw, activation, env, + "SELECT cel.eval($1, $2, $3, $4)", + raw, activation, env, evalOptionsJSON, ).Scan(&rawResult) if err != nil { t.Fatalf("eval stage: %v", err) diff --git a/internal/oracle/options.go b/internal/oracle/options.go index c993a43..7e62493 100644 --- a/internal/oracle/options.go +++ b/internal/oracle/options.go @@ -14,7 +14,16 @@ import ( // syntax, which cel-go's conformance run enables for the whole corpus // and our standard env includes (workspace doc 01). var envOptions = map[string][]cel.EnvOption{ - "standard": {cel.EnableIdentifierEscapeSyntax()}, + // Cross-type numeric comparisons and error-on-bad-presence-test + // are cel-go options, but the conformance corpus requires both + // under its plain standard environment (cel-go's own harness + // enables them globally, conformance_test.go:82-97), so our + // standard env includes them and the oracle must match. + "standard": { + cel.EnableIdentifierEscapeSyntax(), + cel.CrossTypeNumericComparisons(true), + cel.EnableErrorOnBadPresenceTest(true), + }, "strings": {ext.Strings()}, "math": {ext.Math()}, "lists": {ext.Lists()}, diff --git a/sql/020_values.sql b/sql/020_values.sql index 77dea23..0cdd9f2 100644 --- a/sql/020_values.sql +++ b/sql/020_values.sql @@ -129,3 +129,341 @@ END; $$; COMMIT; + +BEGIN; + +-- Tagged-value primitives. The kind tag carries type identity +-- (workspace doc 02); these helpers are the single place equality, +-- ordering and payload access are defined, so every impl and the +-- evaluator agree on them. + +CREATE OR REPLACE FUNCTION cel._err(msg text, id bigint DEFAULT NULL) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'error', + 'v', jsonb_strip_nulls(jsonb_build_object('msg', msg, 'id', id))); +$$; + +CREATE OR REPLACE FUNCTION cel._is_error(v jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT v ->> '@t' = 'error'; +$$; + +CREATE OR REPLACE FUNCTION cel._is_unknown(v jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT v ->> '@t' = 'unknown'; +$$; + +-- Unknown payloads are sorted, deduped expr-id arrays; merging is set +-- union. +CREATE OR REPLACE FUNCTION cel._unknown_merge(a jsonb, b jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'unknown', 'v', + coalesce(jsonb_agg(id ORDER BY id), '[]'::jsonb)) + FROM ( + SELECT DISTINCT (e ->> 0)::bigint AS id + FROM ( + SELECT jsonb_build_array(x) AS e + FROM jsonb_array_elements(a -> 'v') x + UNION ALL + SELECT jsonb_build_array(x) + FROM jsonb_array_elements(b -> 'v') x + ) ids + ) merged; +$$; + +-- The float8 payload of a double value; the three non-finite +-- sentinels are strings in jsonb. +CREATE OR REPLACE FUNCTION cel._dbl(v jsonb) +RETURNS float8 +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE v ->> 'v' + WHEN 'Infinity' THEN 'Infinity'::float8 + WHEN '-Infinity' THEN '-Infinity'::float8 + WHEN 'NaN' THEN 'NaN'::float8 + ELSE (v ->> 'v')::float8 + END; +$$; + +-- Wraps a float8 back into a tagged double, mapping non-finite +-- results to their sentinel strings (jsonb cannot hold them). +CREATE OR REPLACE FUNCTION cel._dbl_val(f float8) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN f = 'Infinity'::float8 + THEN jsonb_build_object('@t', 'double', 'v', 'Infinity') + WHEN f = '-Infinity'::float8 + THEN jsonb_build_object('@t', 'double', 'v', '-Infinity') + -- Postgres treats NaN as equal to NaN, so f <> f cannot detect + -- it the IEEE way; the direct comparison works instead. + WHEN f = 'NaN'::float8 + THEN jsonb_build_object('@t', 'double', 'v', 'NaN') + ELSE jsonb_build_object('@t', 'double', 'v', to_jsonb(f)) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._int_val(n numeric) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'int', 'v', to_jsonb(n)); +$$; + +CREATE OR REPLACE FUNCTION cel._bool_val(b boolean) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'bool', 'v', b); +$$; + +-- Heterogeneous equality (cel-go v0.32.0 common/types, measured): +-- cross-kind == is false, never an error; int/uint compare exactly; +-- int-or-uint vs double bounds-checks then widens to double +-- (compare.go:23-66); NaN equals nothing; lists/maps size-first then +-- element-wise; timestamps by instant; null only equals null. +CREATE OR REPLACE FUNCTION cel._equal(a jsonb, b jsonb) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + ka text := a ->> '@t'; + kb text := b ->> '@t'; + da float8; + db float8; + i int; + ea jsonb; + found boolean; + used boolean[]; + j int; +BEGIN + -- Numeric cross-kind equality: NaN equals nothing; otherwise + -- exactly the ordering comparison at zero, which carries the + -- bounds-check-then-widen behaviour of compare.go (a bare widen + -- would call 9223372036854775807 equal to 2^63 as a double). + IF ka IN ('int', 'uint', 'double') AND kb IN ('int', 'uint', 'double') + THEN + IF (ka = 'double' AND a ->> 'v' = 'NaN') + OR (kb = 'double' AND b ->> 'v' = 'NaN') THEN + RETURN false; + END IF; + RETURN (cel._compare(a, b) ->> 'v')::int = 0; + END IF; + + IF ka <> kb THEN + RETURN false; + END IF; + + CASE ka + WHEN 'null' THEN + RETURN true; + WHEN 'bool', 'string', 'bytes', 'type' THEN + RETURN a -> 'v' = b -> 'v'; + WHEN 'duration' THEN + RETURN (a ->> 'v')::numeric = (b ->> 'v')::numeric; + WHEN 'timestamp' THEN + RETURN (a -> 'v' ->> 's')::numeric = (b -> 'v' ->> 's')::numeric + AND (a -> 'v' ->> 'n')::numeric = (b -> 'v' ->> 'n')::numeric; + WHEN 'list' THEN + IF jsonb_array_length(a -> 'v') <> jsonb_array_length(b -> 'v') + THEN + RETURN false; + END IF; + FOR i IN 0 .. jsonb_array_length(a -> 'v') - 1 LOOP + IF NOT cel._equal(a -> 'v' -> i, b -> 'v' -> i) THEN + RETURN false; + END IF; + END LOOP; + RETURN true; + WHEN 'map' THEN + IF jsonb_array_length(a -> 'v') <> jsonb_array_length(b -> 'v') + THEN + RETURN false; + END IF; + used := array_fill(false, ARRAY[jsonb_array_length(b -> 'v')]); + FOR i IN 0 .. jsonb_array_length(a -> 'v') - 1 LOOP + ea := a -> 'v' -> i; + found := false; + FOR j IN 0 .. jsonb_array_length(b -> 'v') - 1 LOOP + IF NOT used[j + 1] + AND cel._equal(ea -> 'k', b -> 'v' -> j -> 'k') + AND cel._equal(ea -> 'v', b -> 'v' -> j -> 'v') THEN + used[j + 1] := true; + found := true; + EXIT; + END IF; + END LOOP; + IF NOT found THEN + RETURN false; + END IF; + END LOOP; + RETURN true; + ELSE + -- Opaque and future kinds: structural payload identity unless + -- a registered equality overrides it (extension phases). + RETURN a - '@t' = b - '@t'; + END CASE; +END; +$$; + +-- Three-way ordering for the relation operators. Returns a tagged +-- int (-1/0/1) or an error value: NaN is unorderable, and kinds +-- outside the numeric cross-compare matrix order only within their +-- own kind (cel-go compare.go, measured). +CREATE OR REPLACE FUNCTION cel._compare(a jsonb, b jsonb) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + ka text := a ->> '@t'; + kb text := b ->> '@t'; + da float8; + db float8; + na numeric; + nb numeric; +BEGIN + IF ka IN ('int', 'uint', 'double') AND kb IN ('int', 'uint', 'double') + THEN + IF ka = 'double' OR kb = 'double' THEN + da := CASE WHEN ka = 'double' THEN cel._dbl(a) + ELSE NULL END; + db := CASE WHEN kb = 'double' THEN cel._dbl(b) + ELSE NULL END; + -- Postgres NaN compares equal to NaN, so the check is direct. + IF da = 'NaN'::float8 OR db = 'NaN'::float8 THEN + RETURN cel._err('NaN values cannot be ordered'); + END IF; + + -- compareDoubleInt / compareDoubleUint: bounds first, then + -- widen the integer side and compare as doubles. + IF ka = 'double' AND kb IN ('int', 'uint') THEN + nb := (b ->> 'v')::numeric; + IF kb = 'int' AND da < -9223372036854775808::float8 THEN + RETURN cel._int_val(-1); + ELSIF kb = 'int' AND da > 9223372036854775807::float8 THEN + RETURN cel._int_val(1); + ELSIF kb = 'uint' AND da < 0 THEN + RETURN cel._int_val(-1); + ELSIF kb = 'uint' AND da > 18446744073709551615::float8 THEN + RETURN cel._int_val(1); + END IF; + db := nb::float8; + ELSIF kb = 'double' AND ka IN ('int', 'uint') THEN + na := (a ->> 'v')::numeric; + IF ka = 'int' AND db < -9223372036854775808::float8 THEN + RETURN cel._int_val(1); + ELSIF ka = 'int' AND db > 9223372036854775807::float8 THEN + RETURN cel._int_val(-1); + ELSIF ka = 'uint' AND db < 0 THEN + RETURN cel._int_val(1); + ELSIF ka = 'uint' AND db > 18446744073709551615::float8 THEN + RETURN cel._int_val(-1); + END IF; + da := na::float8; + END IF; + + RETURN cel._int_val(CASE + WHEN da < db THEN -1 WHEN da > db THEN 1 ELSE 0 END); + END IF; + + -- int/uint cross: exact integer comparison. + na := (a ->> 'v')::numeric; + nb := (b ->> 'v')::numeric; + RETURN cel._int_val(CASE + WHEN na < nb THEN -1 WHEN na > nb THEN 1 ELSE 0 END); + END IF; + + IF ka <> kb THEN + RETURN cel._err('no such overload'); + END IF; + + CASE ka + WHEN 'bool' THEN + RETURN cel._int_val(CASE + WHEN a -> 'v' = b -> 'v' THEN 0 + WHEN (a ->> 'v')::boolean THEN 1 ELSE -1 END); + WHEN 'string' THEN + -- Byte-wise (C collation) order, not locale order. + RETURN cel._int_val(CASE + WHEN convert_to(a ->> 'v', 'UTF8') + < convert_to(b ->> 'v', 'UTF8') THEN -1 + WHEN convert_to(a ->> 'v', 'UTF8') + > convert_to(b ->> 'v', 'UTF8') THEN 1 + ELSE 0 END); + WHEN 'bytes' THEN + RETURN cel._int_val(CASE + WHEN decode(a ->> 'v', 'base64') < decode(b ->> 'v', 'base64') + THEN -1 + WHEN decode(a ->> 'v', 'base64') > decode(b ->> 'v', 'base64') + THEN 1 + ELSE 0 END); + WHEN 'duration' THEN + na := (a ->> 'v')::numeric; + nb := (b ->> 'v')::numeric; + RETURN cel._int_val(CASE + WHEN na < nb THEN -1 WHEN na > nb THEN 1 ELSE 0 END); + WHEN 'timestamp' THEN + na := (a -> 'v' ->> 's')::numeric * 1000000000 + + (a -> 'v' ->> 'n')::numeric; + nb := (b -> 'v' ->> 's')::numeric * 1000000000 + + (b -> 'v' ->> 'n')::numeric; + RETURN cel._int_val(CASE + WHEN na < nb THEN -1 WHEN na > nb THEN 1 ELSE 0 END); + ELSE + RETURN cel._err('no such overload'); + END CASE; +END; +$$; + +-- Map lookup by normalized key equality: exact kind first is not +-- needed separately -- cel._equal already implements the lossless +-- numeric coercions map.go's Find applies. Returns the entry's value +-- or NULL when absent. +CREATE OR REPLACE FUNCTION cel._map_find(m jsonb, key jsonb) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + entry jsonb; +BEGIN + FOR entry IN SELECT e FROM jsonb_array_elements(m -> 'v') e LOOP + IF cel._equal(entry -> 'k', key) THEN + RETURN entry -> 'v'; + END IF; + END LOOP; + RETURN NULL; +END; +$$; + +COMMIT; diff --git a/sql/050_eval.sql b/sql/050_eval.sql new file mode 100644 index 0000000..2c36616 --- /dev/null +++ b/sql/050_eval.sql @@ -0,0 +1,712 @@ +-- cel4postgres -- evaluator core. +-- +-- Recursive tree walk over the AST envelope. Errors and unknowns are +-- tagged values flowing up (never exceptions -- a Postgres exception +-- escaping cel.eval is by definition a bug in the evaluator), and +-- every call dispatches through cel.overload rows: the absorbing +-- overload ids (logical_and/or, conditional, not_strictly_false, +-- equals/not_equals, and the index qualifiers) are implemented here +-- because their semantics control argument evaluation or belong to +-- the attribute machinery, everything else EXECUTEs the row's impl. +-- No CASE on a function name anywhere -- day-one invariant 1. + +BEGIN; + +-- Signature match for runtime overload selection: does an evaluated +-- argument satisfy a declared argument type? Parameterized and +-- dynamic types erase to "match anything" at runtime; containers +-- match on their kind alone. +CREATE OR REPLACE FUNCTION cel._sig_match_one(argtype jsonb, v jsonb) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + tk text := argtype ->> 'kind'; + vk text := v ->> '@t'; +BEGIN + RETURN CASE tk + WHEN 'dyn' THEN true + WHEN 'any' THEN true + WHEN 'param' THEN true + WHEN 'wrapper' THEN + vk = 'null' OR cel._sig_match_one(argtype -> 'params' -> 0, v) + WHEN 'opaque' THEN + vk = 'opaque' AND v ->> 'type' = argtype ->> 'name' + WHEN 'struct' THEN vk = 'opaque' OR vk = 'map' + ELSE vk = tk + END; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._sig_match(arg_types jsonb, args jsonb[]) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + i int; +BEGIN + IF jsonb_array_length(arg_types) <> cardinality(args) THEN + RETURN false; + END IF; + FOR i IN 1 .. cardinality(args) LOOP + IF NOT cel._sig_match_one(arg_types -> (i - 1), args[i]) THEN + RETURN false; + END IF; + END LOOP; + RETURN true; +END; +$$; + +-- Table-driven dispatch on evaluated arguments. ref carries the +-- checker's bound overload ids when present; without it (unchecked +-- eval) the candidates are every row of the function visible in the +-- env union, tried in cel-go's declaration order. +CREATE OR REPLACE FUNCTION cel._ev_dispatch( + fn text, + is_member boolean, + args jsonb[], + ref jsonb, + envs text[], + node_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + row_r record; + result jsonb; +BEGIN + IF ref ? 'overloads' THEN + FOR row_r IN + SELECT o.* + FROM jsonb_array_elements_text(ref -> 'overloads') + WITH ORDINALITY b(id, ord) + JOIN cel.overload o ON o.id = b.id + ORDER BY b.ord + LOOP + IF cel._sig_match(row_r.arg_types, args) THEN + EXECUTE format('SELECT %s($1)', + split_part(row_r.impl::text, '(', 1)) + INTO result USING args; + RETURN result; + END IF; + END LOOP; + ELSE + FOR row_r IN + SELECT o.* + FROM cel.overload o + WHERE o.function = fn + AND o.member = is_member + AND o.impl IS NOT NULL + AND EXISTS ( + SELECT FROM cel.env_item i + WHERE i.env = ANY (envs) + AND i.kind = 'overload' AND i.ref = o.id) + ORDER BY o.ordinal + LOOP + IF cel._sig_match(row_r.arg_types, args) THEN + EXECUTE format('SELECT %s($1)', + split_part(row_r.impl::text, '(', 1)) + INTO result USING args; + RETURN result; + END IF; + END LOOP; + END IF; + + RETURN cel._err(format('found no matching overload for %s', + quote_literal(fn)), node_id); +END; +$$; + +-- The dotted parts of a pure ident/select chain ("a.b.c" -> +-- {a,b,c}), or NULL for anything else. absolute reports a leading +-- dot (root-scoped: container resolution does not apply). +CREATE OR REPLACE FUNCTION cel._attr_chain( + node jsonb, OUT parts text[], OUT absolute boolean +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + sub record; +BEGIN + IF node ->> 'k' = 'ident' THEN + absolute := (node ->> 'name') LIKE '.%'; + parts := ARRAY[ltrim(node ->> 'name', '.')]; + RETURN; + END IF; + IF node ->> 'k' = 'select' + AND NOT coalesce((node -> 'test')::boolean, false) THEN + SELECT * INTO sub FROM cel._attr_chain(node -> 'op'); + IF sub.parts IS NOT NULL THEN + parts := sub.parts || (node ->> 'field'); + absolute := sub.absolute; + END IF; + END IF; +END; +$$; + +-- Candidate variable names for a dotted name under a container: +-- container "a.b" tries a.b.name, a.name, name -- cel-go's namespace +-- resolution order (measured: container/shadowing runs in +-- measurements.md). +CREATE OR REPLACE FUNCTION cel._name_candidates( + name text, absolute boolean, ctr text +) +RETURNS text[] +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + cands text[] := '{}'; + c text := ctr; +BEGIN + IF absolute OR coalesce(ctr, '') = '' THEN + RETURN ARRAY[name]; + END IF; + WHILE c <> '' LOOP + cands := cands || (c || '.' || name); + IF position('.' IN c) = 0 THEN + EXIT; + END IF; + c := substr(c, 1, length(c) - position('.' IN reverse(c))); + END LOOP; + RETURN cands || name; +END; +$$; + +-- Resolves a dotted chain against the scope stack. Scope wins over +-- name length: an inner frame binding "y" shadows an outer binding +-- of "y.z" (measured against cel-go -- the comprehension-shadowing +-- corpus cases depend on it). Within one frame, longer names win, +-- and container-qualified candidates come before bare ones. +-- Returns NULL when nothing resolves; otherwise the value and how +-- many chain parts it consumed. +CREATE OR REPLACE FUNCTION cel._resolve_chain( + parts text[], absolute boolean, scopes jsonb, ctr text, + OUT val jsonb, OUT used int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + f int; + plen int; + cand text; + name text; +BEGIN + FOR f IN REVERSE jsonb_array_length(scopes) - 1 .. 0 LOOP + FOR plen IN REVERSE cardinality(parts) .. 1 LOOP + name := array_to_string(parts[1:plen], '.'); + FOREACH cand IN ARRAY cel._name_candidates(name, absolute, ctr) + LOOP + IF scopes -> f ? cand THEN + val := scopes -> f -> cand; + used := plen; + RETURN; + END IF; + END LOOP; + END LOOP; + END LOOP; +END; +$$; + +-- Field selection on an already-evaluated value. +CREATE OR REPLACE FUNCTION cel._sel_field(v jsonb, field text, nid bigint) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + r jsonb; +BEGIN + IF cel._is_error(v) OR cel._is_unknown(v) THEN + RETURN v; + END IF; + IF v ->> '@t' = 'map' THEN + r := cel._map_find(v, + jsonb_build_object('@t', 'string', 'v', field)); + IF r IS NULL THEN + RETURN cel._err(format('no such key: %s', field), nid); + END IF; + RETURN r; + END IF; + RETURN cel._err(format( + 'does not support field selection: %s', v ->> '@t'), nid); +END; +$$; + +-- The recursive evaluator. +CREATE OR REPLACE FUNCTION cel._ev( + node jsonb, + scopes jsonb, + envs text[], + ctr text, + d int +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := node ->> 'k'; + nid bigint := (node ->> 'id')::bigint; + fn text; + v jsonb; + l jsonb; + r jsonb; + args jsonb[]; + unk jsonb; + i int; + elems jsonb; + entries jsonb; + key jsonb; + nm text; + eq boolean; + row_r record; + chain record; + res record; +BEGIN + IF d >= 200 THEN + RETURN cel._err('expression recursion limit exceeded: 200', nid); + END IF; + + CASE k + WHEN 'lit' THEN + RETURN node -> 'v'; + + WHEN 'ident' THEN + SELECT * INTO chain FROM cel._attr_chain(node); + SELECT * INTO res + FROM cel._resolve_chain(chain.parts, chain.absolute, scopes, ctr); + IF res.val IS NOT NULL THEN + RETURN res.val; + END IF; + -- Registered type names are identifiers of type type(T). + SELECT c INTO nm + FROM unnest(cel._name_candidates( + ltrim(node ->> 'name', '.'), chain.absolute, ctr)) AS c + WHERE EXISTS ( + SELECT FROM cel.type t + WHERE t.name = c AND EXISTS ( + SELECT FROM cel.env_item i2 + WHERE i2.env = ANY (envs) AND i2.kind = 'type' + AND i2.ref = t.name)) + LIMIT 1; + IF nm IS NOT NULL THEN + RETURN jsonb_build_object('@t', 'type', 'v', nm); + END IF; + RETURN cel._err(format('no such attribute: %s', + ltrim(node ->> 'name', '.')), nid); + + WHEN 'select' THEN + key := jsonb_build_object('@t', 'string', 'v', node ->> 'field'); + + IF coalesce((node -> 'test')::boolean, false) THEN + v := cel._ev(node -> 'op', scopes, envs, ctr, d + 1); + IF cel._is_error(v) OR cel._is_unknown(v) THEN + RETURN v; + END IF; + IF v ->> '@t' = 'map' THEN + RETURN cel._bool_val(cel._map_find(v, key) IS NOT NULL); + END IF; + RETURN cel._err(format( + 'does not support field selection: %s', v ->> '@t'), nid); + END IF; + + -- Qualified-name resolution: the scope stack decides between a + -- bound "a.b" and field-selecting a bound "a". + SELECT * INTO chain FROM cel._attr_chain(node); + IF chain.parts IS NOT NULL THEN + SELECT * INTO res + FROM cel._resolve_chain(chain.parts, chain.absolute, scopes, ctr); + IF res.val IS NOT NULL THEN + v := res.val; + FOR i IN res.used + 1 .. cardinality(chain.parts) LOOP + v := cel._sel_field(v, chain.parts[i], nid); + END LOOP; + RETURN v; + END IF; + END IF; + + v := cel._ev(node -> 'op', scopes, envs, ctr, d + 1); + IF cel._is_error(v) OR cel._is_unknown(v) THEN + RETURN v; + END IF; + RETURN cel._sel_field(v, node ->> 'field', nid); + + WHEN 'call' THEN + fn := node ->> 'fn'; + + -- Core-absorbed overload ids: found by the same registry lookup + -- as everything else (impl IS NULL marks them), implemented here + -- because their semantics control argument evaluation. The + -- dispatch is on the row's *id*, not the function name. + SELECT o.id INTO nm + FROM cel.overload o + WHERE o.function = fn AND o.impl IS NULL + AND o.member = (node ? 'target') + AND EXISTS ( + SELECT FROM cel.env_item i2 + WHERE i2.env = ANY (envs) AND i2.kind = 'overload' + AND i2.ref = o.id) + ORDER BY o.ordinal + LIMIT 1; + + IF nm = 'logical_and' OR nm = 'logical_or' THEN + -- false && x = false in either order; true || x = true in + -- either order; otherwise merged unknown beats first error + -- beats the boolean identity. + l := cel._ev(node -> 'args' -> 0, scopes, envs, ctr, d + 1); + IF l ->> '@t' = 'bool' + AND (l ->> 'v')::boolean = (nm = 'logical_or') THEN + RETURN l; + END IF; + r := cel._ev(node -> 'args' -> 1, scopes, envs, ctr, d + 1); + IF r ->> '@t' = 'bool' + AND (r ->> 'v')::boolean = (nm = 'logical_or') THEN + RETURN r; + END IF; + IF l ->> '@t' = 'bool' AND r ->> '@t' = 'bool' THEN + RETURN l; -- both are the identity value + END IF; + IF cel._is_unknown(l) AND cel._is_unknown(r) THEN + RETURN cel._unknown_merge(l, r); + END IF; + IF cel._is_unknown(l) THEN RETURN l; END IF; + IF cel._is_unknown(r) THEN RETURN r; END IF; + IF cel._is_error(l) THEN RETURN l; END IF; + IF cel._is_error(r) THEN RETURN r; END IF; + RETURN cel._err('no such overload', nid); + + ELSIF nm = 'conditional' THEN + l := cel._ev(node -> 'args' -> 0, scopes, envs, ctr, d + 1); + IF cel._is_error(l) OR cel._is_unknown(l) THEN + RETURN l; + END IF; + IF l ->> '@t' <> 'bool' THEN + RETURN cel._err('no such overload', nid); + END IF; + IF (l ->> 'v')::boolean THEN + RETURN cel._ev(node -> 'args' -> 1, scopes, envs, ctr, d + 1); + END IF; + RETURN cel._ev(node -> 'args' -> 2, scopes, envs, ctr, d + 1); + + ELSIF nm = 'not_strictly_false' THEN + l := cel._ev(node -> 'args' -> 0, scopes, envs, ctr, d + 1); + IF l ->> '@t' = 'bool' THEN + RETURN l; + END IF; + RETURN cel._bool_val(true); + + ELSIF nm = 'equals' OR nm = 'not_equals' THEN + l := cel._ev(node -> 'args' -> 0, scopes, envs, ctr, d + 1); + IF cel._is_error(l) THEN + RETURN l; + END IF; + r := cel._ev(node -> 'args' -> 1, scopes, envs, ctr, d + 1); + IF cel._is_error(r) THEN + RETURN r; + END IF; + IF cel._is_unknown(l) AND cel._is_unknown(r) THEN + RETURN cel._unknown_merge(l, r); + END IF; + IF cel._is_unknown(l) THEN RETURN l; END IF; + IF cel._is_unknown(r) THEN RETURN r; END IF; + eq := cel._equal(l, r); + RETURN cel._bool_val(eq = (nm = 'equals')); + + ELSIF nm IN ('index_list', 'index_map') THEN + -- Indexing is attribute machinery in cel-go's interpreter (the + -- planner turns _[_] into a qualifier), which is what admits + -- losslessly-coercible double/uint list indices at runtime; + -- plain signature dispatch could not. Strict in both args. + l := cel._ev(node -> 'args' -> 0, scopes, envs, ctr, d + 1); + IF cel._is_error(l) THEN RETURN l; END IF; + r := cel._ev(node -> 'args' -> 1, scopes, envs, ctr, d + 1); + IF cel._is_error(r) THEN RETURN r; END IF; + IF cel._is_unknown(l) AND cel._is_unknown(r) THEN + RETURN cel._unknown_merge(l, r); + END IF; + IF cel._is_unknown(l) THEN RETURN l; END IF; + IF cel._is_unknown(r) THEN RETURN r; END IF; + IF l ->> '@t' = 'list' THEN + RETURN cel._f_index_list(ARRAY[l, r]); + ELSIF l ->> '@t' = 'map' THEN + RETURN cel._f_index_map(ARRAY[l, r]); + END IF; + RETURN cel._err('no such overload', nid); + END IF; + + -- Strict call: arguments left to right; first error wins, then + -- merged unknowns. + args := '{}'; + unk := NULL; + IF node ? 'target' THEN + v := cel._ev(node -> 'target', scopes, envs, ctr, d + 1); + IF cel._is_error(v) THEN RETURN v; END IF; + IF cel._is_unknown(v) THEN + unk := CASE WHEN unk IS NULL THEN v + ELSE cel._unknown_merge(unk, v) END; + END IF; + args := args || v; + END IF; + FOR i IN 0 .. jsonb_array_length(node -> 'args') - 1 LOOP + v := cel._ev(node -> 'args' -> i, scopes, envs, ctr, d + 1); + IF cel._is_error(v) THEN RETURN v; END IF; + IF cel._is_unknown(v) THEN + unk := CASE WHEN unk IS NULL THEN v + ELSE cel._unknown_merge(unk, v) END; + END IF; + args := args || v; + END LOOP; + IF unk IS NOT NULL THEN + RETURN unk; + END IF; + + RETURN cel._ev_dispatch( + fn, node ? 'target', args, node -> 'ref', envs, nid); + + WHEN 'list' THEN + elems := '[]'::jsonb; + unk := NULL; + FOR i IN 0 .. jsonb_array_length(node -> 'elems') - 1 LOOP + v := cel._ev(node -> 'elems' -> i, scopes, envs, ctr, d + 1); + IF cel._is_error(v) THEN RETURN v; END IF; + IF cel._is_unknown(v) THEN + unk := CASE WHEN unk IS NULL THEN v + ELSE cel._unknown_merge(unk, v) END; + END IF; + elems := elems || jsonb_build_array(v); + END LOOP; + IF unk IS NOT NULL THEN + RETURN unk; + END IF; + RETURN jsonb_build_object('@t', 'list', 'v', elems); + + WHEN 'map' THEN + entries := '[]'::jsonb; + unk := NULL; + FOR i IN 0 .. jsonb_array_length(node -> 'entries') - 1 LOOP + key := cel._ev(node -> 'entries' -> i -> 'k', + scopes, envs, ctr, d + 1); + IF cel._is_error(key) THEN RETURN key; END IF; + v := cel._ev(node -> 'entries' -> i -> 'v', + scopes, envs, ctr, d + 1); + IF cel._is_error(v) THEN RETURN v; END IF; + IF cel._is_unknown(key) THEN + unk := CASE WHEN unk IS NULL THEN key + ELSE cel._unknown_merge(unk, key) END; + END IF; + IF cel._is_unknown(v) THEN + unk := CASE WHEN unk IS NULL THEN v + ELSE cel._unknown_merge(unk, v) END; + END IF; + IF unk IS NULL THEN + -- Key type restriction and duplicate rejection are dynamic + -- (corpus-first rulings: forbidden double/null keys and + -- normalized duplicates both error at construction). + IF key ->> '@t' NOT IN ('bool', 'int', 'uint', 'string') THEN + RETURN cel._err(format( + 'unsupported map key type: %s', key ->> '@t'), nid); + END IF; + IF cel._map_find( + jsonb_build_object('@t', 'map', 'v', entries), key) + IS NOT NULL THEN + RETURN cel._err('Failed with repeated key', nid); + END IF; + entries := entries || jsonb_build_array( + jsonb_build_object('k', key, 'v', v)); + END IF; + END LOOP; + IF unk IS NOT NULL THEN + RETURN unk; + END IF; + RETURN jsonb_build_object('@t', 'map', 'v', entries); + + WHEN 'struct' THEN + nm := ltrim(node ->> 'type', '.'); + SELECT t.* INTO row_r + FROM cel.type t + WHERE t.name = ANY (cel._name_candidates( + nm, (node ->> 'type') LIKE '.%', ctr)) + AND t.construct IS NOT NULL + AND EXISTS ( + SELECT FROM cel.env_item i2 + WHERE i2.env = ANY (envs) AND i2.kind = 'type' + AND i2.ref = t.name) + LIMIT 1; + IF NOT FOUND THEN + RETURN cel._err(format('unknown message type: %s', nm), nid); + END IF; + -- Evaluate fields into an object, then hand to the type row's + -- construct impl (invariant 3: WKTs and opaque extension types + -- ride the same path). + entries := '{}'::jsonb; + unk := NULL; + FOR i IN 0 .. jsonb_array_length(node -> 'fields') - 1 LOOP + v := cel._ev(node -> 'fields' -> i -> 'v', + scopes, envs, ctr, d + 1); + IF cel._is_error(v) THEN RETURN v; END IF; + IF cel._is_unknown(v) THEN + unk := CASE WHEN unk IS NULL THEN v + ELSE cel._unknown_merge(unk, v) END; + END IF; + entries := entries || jsonb_build_object( + node -> 'fields' -> i ->> 'name', v); + END LOOP; + IF unk IS NOT NULL THEN + RETURN unk; + END IF; + EXECUTE format('SELECT %s($1)', + split_part(row_r.construct::text, '(', 1)) + INTO v USING entries; + RETURN v; + + WHEN 'comp' THEN + RETURN cel._ev_comp(node, scopes, envs, ctr, d); + + ELSE + RETURN cel._err(format('unsupported AST node kind: %s', k), nid); + END CASE; +END; +$$; + +-- The comprehension fold (workspace doc 06). The loop-termination +-- rule is load-bearing: only a genuine bool false stops iteration -- +-- an error or unknown condition keeps folding, which is how exists +-- recovers from early errors when a later element is true. +CREATE OR REPLACE FUNCTION cel._ev_comp( + node jsonb, + scopes jsonb, + envs text[], + ctr text, + d int +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + nid bigint := (node ->> 'id')::bigint; + iter text := node ->> 'iter'; + iter2 text := node ->> 'iter2'; + accu_n text := node ->> 'accu'; + range_v jsonb; + accu jsonb; + cond jsonb; + frame jsonb; + item1 jsonb; + item2 jsonb; + i int; + rk text; +BEGIN + range_v := cel._ev(node -> 'range', scopes, envs, ctr, d + 1); + IF cel._is_error(range_v) OR cel._is_unknown(range_v) THEN + RETURN range_v; + END IF; + rk := range_v ->> '@t'; + IF rk NOT IN ('list', 'map') THEN + RETURN cel._err(format( + 'cannot iterate over: %s', rk), nid); + END IF; + + accu := cel._ev(node -> 'init', scopes, envs, ctr, d + 1); + IF cel._is_error(accu) OR cel._is_unknown(accu) THEN + RETURN accu; + END IF; + + FOR i IN 0 .. jsonb_array_length(range_v -> 'v') - 1 LOOP + IF rk = 'list' THEN + item1 := CASE WHEN iter2 = '' THEN range_v -> 'v' -> i + ELSE cel._int_val(i) END; + item2 := range_v -> 'v' -> i; + ELSE + item1 := range_v -> 'v' -> i -> 'k'; + item2 := range_v -> 'v' -> i -> 'v'; + END IF; + + frame := jsonb_build_object(accu_n, accu, iter, item1); + IF iter2 <> '' THEN + frame := frame || jsonb_build_object(iter2, item2); + END IF; + + cond := cel._ev(node -> 'cond', + scopes || jsonb_build_array(frame), envs, ctr, d + 1); + IF cond ->> '@t' = 'bool' AND NOT (cond ->> 'v')::boolean THEN + EXIT; + END IF; + + accu := cel._ev(node -> 'step', + scopes || jsonb_build_array(frame), envs, ctr, d + 1); + END LOOP; + + RETURN cel._ev(node -> 'result', + scopes || jsonb_build_array(jsonb_build_object(accu_n, accu)), + envs, ctr, d + 1); +END; +$$; + +-- Evaluates a parsed (or checked) AST envelope under an activation +-- of tagged values. options carries what the corpus calls per-case +-- environment shape: today just "container", which unchecked +-- evaluation needs for name resolution (checked ASTs resolve names +-- at check time). STABLE, not IMMUTABLE: dispatch reads the +-- registry. +CREATE OR REPLACE FUNCTION cel.eval( + ast jsonb, + activation jsonb, + env text, + options jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + envs text[]; +BEGIN + IF ast ? 'errors' THEN + RAISE 'cannot evaluate a failed parse'; + END IF; + IF NOT ast ? 'expr' THEN + RAISE 'not an AST envelope'; + END IF; + + envs := cel._env_names(env); + RETURN cel._ev( + ast -> 'expr', + jsonb_build_array(coalesce(activation, '{}'::jsonb)), + envs, + coalesce(options ->> 'container', ''), + 0); +END; +$$; + +CREATE OR REPLACE FUNCTION cel.eval( + ast jsonb, + activation jsonb, + env text +) +RETURNS jsonb +LANGUAGE sql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel.eval(ast, activation, env, '{}'::jsonb); +$$; + +COMMIT; diff --git a/sql/060_stdlib.sql b/sql/060_stdlib.sql new file mode 100644 index 0000000..453dacc --- /dev/null +++ b/sql/060_stdlib.sql @@ -0,0 +1,805 @@ +-- cel4postgres -- standard library, part 1. +-- +-- Logic, arithmetic, relations, size, membership, indexing. Every +-- implementation has the uniform registry signature +-- impl(args jsonb[]) -> jsonb over already-evaluated tagged values, +-- and registers through cel.overload rows exactly as an extension +-- would -- no privileged path (CLAUDE.md, the four registries). +-- +-- Semantics are cel-go v0.32.0's, encoded from measured runs and the +-- pinned source (workspace measurements.md): checked int64/uint64 +-- arithmetic with overflow sentinels, IEEE-754 double arithmetic +-- with the three non-finite sentinels, Go-style truncated division +-- and remainder. + +BEGIN; + +-- Integer (int64) checked arithmetic. Postgres numeric is exact, so +-- overflow is a range check, not a wraparound. + +CREATE OR REPLACE FUNCTION cel._chk_int(n numeric, id bigint DEFAULT NULL) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN n < -9223372036854775808::numeric + OR n > 9223372036854775807::numeric + THEN cel._err('integer overflow', id) + ELSE cel._int_val(n) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._chk_uint(n numeric, id bigint DEFAULT NULL) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN n < 0 OR n > 18446744073709551615::numeric + THEN cel._err('unsigned integer overflow', id) + ELSE jsonb_build_object('@t', 'uint', 'v', to_jsonb(n)) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_add_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_int( + (args[1] ->> 'v')::numeric + (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_subtract_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_int( + (args[1] ->> 'v')::numeric - (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_multiply_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_int( + (args[1] ->> 'v')::numeric * (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_divide_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN (args[2] ->> 'v')::numeric = 0 + THEN cel._err('division by zero') + ELSE cel._chk_int(trunc( + (args[1] ->> 'v')::numeric / (args[2] ->> 'v')::numeric, 0)) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_modulo_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + -- Go's % truncates toward zero (remainder keeps the dividend's + -- sign), which is exactly Postgres numeric mod. MinInt64 % -1 is + -- an overflow sentinel in cel-go, not 0. + SELECT CASE + WHEN (args[2] ->> 'v')::numeric = 0 + THEN cel._err('modulus by zero') + WHEN (args[1] ->> 'v')::numeric = -9223372036854775808::numeric + AND (args[2] ->> 'v')::numeric = -1 + THEN cel._err('integer overflow') + ELSE cel._int_val(mod( + (args[1] ->> 'v')::numeric, (args[2] ->> 'v')::numeric)) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_negate_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_int(-(args[1] ->> 'v')::numeric); +$$; + +-- Unsigned (uint64) checked arithmetic. + +CREATE OR REPLACE FUNCTION cel._f_add_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_uint( + (args[1] ->> 'v')::numeric + (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_subtract_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_uint( + (args[1] ->> 'v')::numeric - (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_multiply_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_uint( + (args[1] ->> 'v')::numeric * (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_divide_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN (args[2] ->> 'v')::numeric = 0 + THEN cel._err('division by zero') + ELSE cel._chk_uint(trunc( + (args[1] ->> 'v')::numeric / (args[2] ->> 'v')::numeric, 0)) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_modulo_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN (args[2] ->> 'v')::numeric = 0 + THEN cel._err('modulus by zero') + ELSE cel._chk_uint(mod( + (args[1] ->> 'v')::numeric, (args[2] ->> 'v')::numeric)) + END; +$$; + +-- Double (IEEE-754 binary64) arithmetic. Hardware float8 ops are +-- correctly rounded, but Postgres raises on overflow/underflow where +-- IEEE wants ±Infinity/±0, so finite operands go through an exact +-- numeric computation (+ - *) or numeric pre-checks (/) with the +-- true rounding boundaries: 2^1024 - 2^970 for overflow (at or +-- beyond rounds to Infinity, ties-to-even) and 2^-1075 for +-- underflow. Non-finite operands use float8 directly -- IEEE special +-- values never raise. +-- +-- Note: float8 -> numeric goes through the shortest decimal text, +-- not the exact binary value; the discrepancy (< 1 ulp of the 17th +-- digit) only matters within one part in 1e16 of the exact overflow +-- boundary, unreachable in practice. + +CREATE OR REPLACE FUNCTION cel._dbl_of_numeric(n numeric) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + boundary numeric := 18014398509481983::numeric * (2::numeric ^ 970); +BEGIN + IF abs(n) >= boundary THEN + RETURN jsonb_build_object('@t', 'double', 'v', + CASE WHEN n < 0 THEN '-Infinity' ELSE 'Infinity' END); + END IF; + IF n <> 0 AND abs(n) <= 2.4703282292062327e-324::numeric THEN + RETURN cel._dbl_val((CASE WHEN n < 0 THEN '-0' ELSE '0' END)::float8); + END IF; + RETURN cel._dbl_val(n::float8); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_dbl_bin(op text, a jsonb, b jsonb) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + fa float8 := cel._dbl(a); + fb float8 := cel._dbl(b); + na numeric; + nb numeric; + boundary numeric := 18014398509481983::numeric * (2::numeric ^ 970); + neg boolean; +BEGIN + IF fa = 'Infinity'::float8 OR fa = '-Infinity'::float8 + OR fa = 'NaN'::float8 + OR fb = 'Infinity'::float8 OR fb = '-Infinity'::float8 + OR fb = 'NaN'::float8 + THEN + -- IEEE special values never raise in float8 arithmetic; the one + -- case Postgres would reject is division by a zero divisor. + IF op = '/' AND fb = 0 THEN + IF fa = 'NaN'::float8 THEN + RETURN jsonb_build_object('@t', 'double', 'v', 'NaN'); + END IF; + neg := (fa < 0) <> (fb::text LIKE '-%'); + RETURN jsonb_build_object('@t', 'double', 'v', + CASE WHEN neg THEN '-Infinity' ELSE 'Infinity' END); + END IF; + RETURN cel._dbl_val(CASE op + WHEN '+' THEN fa + fb + WHEN '-' THEN fa - fb + WHEN '*' THEN fa * fb + ELSE fa / fb + END); + END IF; + + IF op = '/' THEN + IF fb = 0 THEN + IF fa = 0 THEN + RETURN jsonb_build_object('@t', 'double', 'v', 'NaN'); + END IF; + -- Sign of the zero matters: 1.0 / -0.0 is -Infinity. + neg := (fa < 0) <> (fb::text LIKE '-%'); + RETURN jsonb_build_object('@t', 'double', 'v', + CASE WHEN neg THEN '-Infinity' ELSE 'Infinity' END); + END IF; + na := fa::numeric; + nb := fb::numeric; + IF abs(na) >= boundary * abs(nb) THEN + RETURN jsonb_build_object('@t', 'double', 'v', + CASE WHEN (fa < 0) <> (fb < 0) + THEN '-Infinity' ELSE 'Infinity' END); + END IF; + IF fa <> 0 + AND abs(na) <= 2.4703282292062327e-324::numeric * abs(nb) THEN + RETURN cel._dbl_val( + (CASE WHEN (fa < 0) <> (fb < 0) THEN '-0' ELSE '0' END)::float8); + END IF; + RETURN cel._dbl_val(fa / fb); + END IF; + + na := fa::numeric; + nb := fb::numeric; + RETURN cel._dbl_of_numeric(CASE op + WHEN '+' THEN na + nb + WHEN '-' THEN na - nb + ELSE na * nb + END); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_add_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._f_dbl_bin('+', args[1], args[2]); +$$; + +CREATE OR REPLACE FUNCTION cel._f_subtract_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._f_dbl_bin('-', args[1], args[2]); +$$; + +CREATE OR REPLACE FUNCTION cel._f_multiply_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._f_dbl_bin('*', args[1], args[2]); +$$; + +CREATE OR REPLACE FUNCTION cel._f_divide_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._f_dbl_bin('/', args[1], args[2]); +$$; + +CREATE OR REPLACE FUNCTION cel._f_negate_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dbl_val(-cel._dbl(args[1])); +$$; + +-- Concatenation forms of _+_. + +CREATE OR REPLACE FUNCTION cel._f_add_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', + (args[1] ->> 'v') || (args[2] ->> 'v')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_add_bytes(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'bytes', 'v', + replace(encode( + decode(args[1] ->> 'v', 'base64') + || decode(args[2] ->> 'v', 'base64'), 'base64'), E'\n', '')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_add_list(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'list', 'v', + (args[1] -> 'v') || (args[2] -> 'v')); +$$; + +-- Logic. + +CREATE OR REPLACE FUNCTION cel._f_logical_not(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val(NOT (args[1] ->> 'v')::boolean); +$$; + +-- Relations: four shared impls over cel._compare, which already +-- implements the numeric cross-type matrix and NaN unorderability. + +CREATE OR REPLACE FUNCTION cel._f_lt(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c jsonb := cel._compare(args[1], args[2]); +BEGIN + IF cel._is_error(c) THEN RETURN c; END IF; + RETURN cel._bool_val((c ->> 'v')::int < 0); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_le(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c jsonb := cel._compare(args[1], args[2]); +BEGIN + IF cel._is_error(c) THEN RETURN c; END IF; + RETURN cel._bool_val((c ->> 'v')::int <= 0); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_gt(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c jsonb := cel._compare(args[1], args[2]); +BEGIN + IF cel._is_error(c) THEN RETURN c; END IF; + RETURN cel._bool_val((c ->> 'v')::int > 0); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_ge(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c jsonb := cel._compare(args[1], args[2]); +BEGIN + IF cel._is_error(c) THEN RETURN c; END IF; + RETURN cel._bool_val((c ->> 'v')::int >= 0); +END; +$$; + +-- Size. + +CREATE OR REPLACE FUNCTION cel._f_size_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val(length(args[1] ->> 'v')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_size_bytes(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val(octet_length(decode(args[1] ->> 'v', 'base64'))); +$$; + +CREATE OR REPLACE FUNCTION cel._f_size_list(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val(jsonb_array_length(args[1] -> 'v')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_size_map(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val(jsonb_array_length(args[1] -> 'v')); +$$; + +-- Membership. + +CREATE OR REPLACE FUNCTION cel._f_in_list(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + e jsonb; +BEGIN + FOR e IN SELECT x FROM jsonb_array_elements(args[2] -> 'v') x LOOP + IF cel._equal(args[1], e) THEN + RETURN cel._bool_val(true); + END IF; + END LOOP; + RETURN cel._bool_val(false); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_in_map(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val(cel._map_find(args[2], args[1]) IS NOT NULL); +$$; + +-- Indexing. List indices accept int plus losslessly-coercible +-- double/uint (cel-go list index semantics, workspace doc 06). + +CREATE OR REPLACE FUNCTION cel._f_index_list(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + kind text := args[2] ->> '@t'; + n numeric; + size int := jsonb_array_length(args[1] -> 'v'); +BEGIN + IF kind = 'double' THEN + n := cel._dbl(args[2])::numeric; + IF n <> trunc(n) THEN + RETURN cel._err(format( + 'invalid_argument: unsupported index value %s', n::text)); + END IF; + ELSIF kind IN ('int', 'uint') THEN + n := (args[2] ->> 'v')::numeric; + ELSE + RETURN cel._err('no such overload'); + END IF; + + IF n < 0 OR n >= size THEN + RETURN cel._err(format( + 'index ''%s'' out of range in list size ''%s''', n::text, size)); + END IF; + RETURN args[1] -> 'v' -> n::int; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_index_map(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v jsonb := cel._map_find(args[1], args[2]); +BEGIN + IF v IS NULL THEN + RETURN cel._err(format('no such key: %s', + coalesce(args[2] ->> 'v', 'null'))); + END IF; + RETURN v; +END; +$$; + +-- type() and dyn(). + +CREATE OR REPLACE FUNCTION cel._f_type(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'type', 'v', CASE args[1] ->> '@t' + WHEN 'null' THEN 'null_type' + WHEN 'timestamp' THEN 'google.protobuf.Timestamp' + WHEN 'duration' THEN 'google.protobuf.Duration' + WHEN 'opaque' THEN args[1] ->> 'type' + ELSE args[1] ->> '@t' + END); +$$; + +CREATE OR REPLACE FUNCTION cel._f_to_dyn(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT args[1]; +$$; + +COMMIT; + +BEGIN; + +-- Overload rows. Ids are cel-go's exactly (common/overloads); the +-- checker binds them and conformance's type_deduction output depends +-- on them. The absorbed ids carry NULL impls -- the evaluator core +-- recognizes them by id after finding them through this same table. + +WITH t AS ( + SELECT + '{"kind":"bool"}'::jsonb AS bool, + '{"kind":"int"}'::jsonb AS int, + '{"kind":"uint"}'::jsonb AS uint, + '{"kind":"double"}'::jsonb AS dbl, + '{"kind":"string"}'::jsonb AS str, + '{"kind":"bytes"}'::jsonb AS byt, + '{"kind":"dyn"}'::jsonb AS dyn, + '{"kind":"param","name":"A"}'::jsonb AS pa, + '{"kind":"param","name":"B"}'::jsonb AS pb, + '{"kind":"list","params":[{"kind":"param","name":"A"}]}'::jsonb AS lista, + '{"kind":"map","params":[{"kind":"param","name":"A"},{"kind":"param","name":"B"}]}'::jsonb AS mapab, + '{"kind":"type","params":[{"kind":"param","name":"A"}]}'::jsonb AS typea +) +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +SELECT * FROM ( + SELECT 'logical_and', '_&&_', false, + jsonb_build_array(t.bool, t.bool), t.bool, + NULL::regprocedure, 10 FROM t + UNION ALL SELECT 'logical_or', '_||_', false, + jsonb_build_array(t.bool, t.bool), t.bool, NULL, 10 FROM t + UNION ALL SELECT 'logical_not', '!_', false, + jsonb_build_array(t.bool), t.bool, + 'cel._f_logical_not(jsonb[])'::regprocedure, 10 FROM t + UNION ALL SELECT 'conditional', '_?_:_', false, + jsonb_build_array(t.bool, t.pa, t.pa), t.pa, NULL, 10 FROM t + UNION ALL SELECT 'not_strictly_false', '@not_strictly_false', false, + jsonb_build_array(t.bool), t.bool, NULL, 10 FROM t + UNION ALL SELECT 'equals', '_==_', false, + jsonb_build_array(t.pa, t.pa), t.bool, NULL, 10 FROM t + UNION ALL SELECT 'not_equals', '_!=_', false, + jsonb_build_array(t.pa, t.pa), t.bool, NULL, 10 FROM t + + UNION ALL SELECT 'add_int64', '_+_', false, + jsonb_build_array(t.int, t.int), t.int, + 'cel._f_add_int64(jsonb[])', 10 FROM t + UNION ALL SELECT 'add_uint64', '_+_', false, + jsonb_build_array(t.uint, t.uint), t.uint, + 'cel._f_add_uint64(jsonb[])', 20 FROM t + UNION ALL SELECT 'add_double', '_+_', false, + jsonb_build_array(t.dbl, t.dbl), t.dbl, + 'cel._f_add_double(jsonb[])', 30 FROM t + UNION ALL SELECT 'add_string', '_+_', false, + jsonb_build_array(t.str, t.str), t.str, + 'cel._f_add_string(jsonb[])', 40 FROM t + UNION ALL SELECT 'add_bytes', '_+_', false, + jsonb_build_array(t.byt, t.byt), t.byt, + 'cel._f_add_bytes(jsonb[])', 50 FROM t + UNION ALL SELECT 'add_list', '_+_', false, + jsonb_build_array(t.lista, t.lista), t.lista, + 'cel._f_add_list(jsonb[])', 60 FROM t + + UNION ALL SELECT 'subtract_int64', '_-_', false, + jsonb_build_array(t.int, t.int), t.int, + 'cel._f_subtract_int64(jsonb[])', 10 FROM t + UNION ALL SELECT 'subtract_uint64', '_-_', false, + jsonb_build_array(t.uint, t.uint), t.uint, + 'cel._f_subtract_uint64(jsonb[])', 20 FROM t + UNION ALL SELECT 'subtract_double', '_-_', false, + jsonb_build_array(t.dbl, t.dbl), t.dbl, + 'cel._f_subtract_double(jsonb[])', 30 FROM t + + UNION ALL SELECT 'multiply_int64', '_*_', false, + jsonb_build_array(t.int, t.int), t.int, + 'cel._f_multiply_int64(jsonb[])', 10 FROM t + UNION ALL SELECT 'multiply_uint64', '_*_', false, + jsonb_build_array(t.uint, t.uint), t.uint, + 'cel._f_multiply_uint64(jsonb[])', 20 FROM t + UNION ALL SELECT 'multiply_double', '_*_', false, + jsonb_build_array(t.dbl, t.dbl), t.dbl, + 'cel._f_multiply_double(jsonb[])', 30 FROM t + + UNION ALL SELECT 'divide_int64', '_/_', false, + jsonb_build_array(t.int, t.int), t.int, + 'cel._f_divide_int64(jsonb[])', 10 FROM t + UNION ALL SELECT 'divide_uint64', '_/_', false, + jsonb_build_array(t.uint, t.uint), t.uint, + 'cel._f_divide_uint64(jsonb[])', 20 FROM t + UNION ALL SELECT 'divide_double', '_/_', false, + jsonb_build_array(t.dbl, t.dbl), t.dbl, + 'cel._f_divide_double(jsonb[])', 30 FROM t + + UNION ALL SELECT 'modulo_int64', '_%_', false, + jsonb_build_array(t.int, t.int), t.int, + 'cel._f_modulo_int64(jsonb[])', 10 FROM t + UNION ALL SELECT 'modulo_uint64', '_%_', false, + jsonb_build_array(t.uint, t.uint), t.uint, + 'cel._f_modulo_uint64(jsonb[])', 20 FROM t + + UNION ALL SELECT 'negate_int64', '-_', false, + jsonb_build_array(t.int), t.int, + 'cel._f_negate_int64(jsonb[])', 10 FROM t + UNION ALL SELECT 'negate_double', '-_', false, + jsonb_build_array(t.dbl), t.dbl, + 'cel._f_negate_double(jsonb[])', 20 FROM t + + UNION ALL SELECT 'size_string', 'size', false, + jsonb_build_array(t.str), t.int, + 'cel._f_size_string(jsonb[])', 10 FROM t + UNION ALL SELECT 'size_bytes', 'size', false, + jsonb_build_array(t.byt), t.int, + 'cel._f_size_bytes(jsonb[])', 20 FROM t + UNION ALL SELECT 'size_list', 'size', false, + jsonb_build_array(t.lista), t.int, + 'cel._f_size_list(jsonb[])', 30 FROM t + UNION ALL SELECT 'size_map', 'size', false, + jsonb_build_array(t.mapab), t.int, + 'cel._f_size_map(jsonb[])', 40 FROM t + UNION ALL SELECT 'string_size', 'size', true, + jsonb_build_array(t.str), t.int, + 'cel._f_size_string(jsonb[])', 10 FROM t + UNION ALL SELECT 'bytes_size', 'size', true, + jsonb_build_array(t.byt), t.int, + 'cel._f_size_bytes(jsonb[])', 20 FROM t + UNION ALL SELECT 'list_size', 'size', true, + jsonb_build_array(t.lista), t.int, + 'cel._f_size_list(jsonb[])', 30 FROM t + UNION ALL SELECT 'map_size', 'size', true, + jsonb_build_array(t.mapab), t.int, + 'cel._f_size_map(jsonb[])', 40 FROM t + + UNION ALL SELECT 'in_list', '@in', false, + jsonb_build_array(t.pa, t.lista), t.bool, + 'cel._f_in_list(jsonb[])', 10 FROM t + UNION ALL SELECT 'in_map', '@in', false, + jsonb_build_array(t.pa, t.mapab), t.bool, + 'cel._f_in_map(jsonb[])', 20 FROM t + + -- Index rows carry NULL impls: indexing is core attribute + -- machinery (runtime numeric coercion), found through this table + -- by id like the other absorbed operations. + UNION ALL SELECT 'index_list', '_[_]', false, + jsonb_build_array(t.lista, t.int), t.pa, NULL, 10 FROM t + UNION ALL SELECT 'index_map', '_[_]', false, + jsonb_build_array(t.mapab, t.pa), t.pb, NULL, 20 FROM t + + UNION ALL SELECT 'type', 'type', false, + jsonb_build_array(t.pa), t.typea, + 'cel._f_type(jsonb[])', 10 FROM t + UNION ALL SELECT 'to_dyn', 'dyn', false, + jsonb_build_array(t.pa), t.dyn, + 'cel._f_to_dyn(jsonb[])', 10 FROM t +) rows(id, fn, member, arg_types, result_type, impl, ordinal) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +-- Relation overloads: 4 operators x 12 type pairs sharing four +-- comparator impls (timestamp/duration pairs arrive with 070). +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +SELECT + op.prefix || CASE WHEN pair.suffix = '' THEN pair.t1 + ELSE pair.suffix END, + op.fn, false, + jsonb_build_array( + jsonb_build_object('kind', pair.t1), + jsonb_build_object('kind', pair.t2)), + '{"kind":"bool"}'::jsonb, + op.impl::regprocedure, + pair.ord +FROM (VALUES + ('less_', '_<_', 'cel._f_lt(jsonb[])'), + ('less_equals_', '_<=_', 'cel._f_le(jsonb[])'), + ('greater_', '_>_', 'cel._f_gt(jsonb[])'), + ('greater_equals_', '_>=_', 'cel._f_ge(jsonb[])') +) op(prefix, fn, impl) +CROSS JOIN (VALUES + ('bool', 'bool', 'bool', 10), + ('int64', 'int', 'int', 20), + ('int64_double', 'int', 'double', 30), + ('int64_uint64', 'int', 'uint', 40), + ('uint64', 'uint', 'uint', 50), + ('uint64_double', 'uint', 'double', 60), + ('uint64_int64', 'uint', 'int', 70), + ('double', 'double', 'double', 80), + ('double_int64', 'double', 'int', 90), + ('double_uint64', 'double', 'uint', 100), + ('string', 'string', 'string', 110), + ('bytes', 'bytes', 'bytes', 120) +) pair(suffix, t1, t2, ord) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + arg_types = excluded.arg_types, + impl = excluded.impl, + ordinal = excluded.ordinal; + +-- Standard type identifiers: every visible cel.type row implies an +-- ident of type type(T) under the type's name. +INSERT INTO cel.type (name, kind) VALUES + ('bool', '{"kind":"bool"}'), + ('int', '{"kind":"int"}'), + ('uint', '{"kind":"uint"}'), + ('double', '{"kind":"double"}'), + ('string', '{"kind":"string"}'), + ('bytes', '{"kind":"bytes"}'), + ('list', '{"kind":"list","params":[{"kind":"dyn"}]}'), + ('map', '{"kind":"map","params":[{"kind":"dyn"},{"kind":"dyn"}]}'), + ('null_type', '{"kind":"null"}'), + ('type', '{"kind":"type"}') +ON CONFLICT (name) DO NOTHING; + +-- Everything above is visible in the standard env. +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'standard', 'overload', id FROM cel.overload +ON CONFLICT DO NOTHING; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'standard', 'type', name FROM cel.type +ON CONFLICT DO NOTHING; + +COMMIT; From ef4ccf980badbc74c21441d3bab93089369f90d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 19:53:38 -0300 Subject: [PATCH 09/21] Add the type checker and stdlib part two The checker ports cel-go's parameter-unification algorithm (checker.go/types.go at the pinned v0.32.0): overloads resolve in declaration order with result-type widening, type parameters unify with an occurs check and most-general rebinding, and every call gets its overload ids bound into the AST so eval dispatches on ids, never on runtime types. Idents and selects that name declarations are rewritten to qualified idents, matching how cel-go feeds its interpreter. Stdlib part two adds the conversion functions, string tests and matches(). Conversion bounds follow cel-go's overflow.go, which excludes the double representations of the int64 boundaries themselves; string parsing follows Go strconv. Two Postgres exactness traps surfaced while greening fp_math and conversions, both measured in the workspace log: the float8::numeric cast yields the shortest decimal text rather than the exact binary value, so cel._f2n rebuilds the exact numeric from mantissa and exponent; and numeric ^ truncates negative integer powers to 16 significant digits, so 2^-k is built as 5^k * 1e-k from exact parts. Name shadowing adopts cel-go's disambiguation protocol: when a comprehension variable shadows a global that wins resolution, the checker keeps a leading dot on the rewritten ident and eval resolves dotted names against the input activation only, skipping comprehension frames. Of the thirteen milestone files eleven are fully green; the 33 remaining cases in comparisons and conversions all need the well-known types (wrapper idents, timestamp(), duration()) and move to the next phase's target. --- sql/020_values.sql | 42 ++ sql/040_check.sql | 1243 ++++++++++++++++++++++++++++++++++++++++++++ sql/050_eval.sql | 15 +- sql/060_stdlib.sql | 488 ++++++++++++++++- 4 files changed, 1782 insertions(+), 6 deletions(-) create mode 100644 sql/040_check.sql diff --git a/sql/020_values.sql b/sql/020_values.sql index 0cdd9f2..18ee6ce 100644 --- a/sql/020_values.sql +++ b/sql/020_values.sql @@ -467,3 +467,45 @@ END; $$; COMMIT; + +BEGIN; + +-- Exact float8 -> numeric. The built-in cast goes through the +-- shortest decimal text, which identifies the double uniquely but is +-- NOT its exact binary value (36028797018963968::float8::numeric +-- ends in ...70). Halving until the value fits 2^53 and doubling +-- until integral recovers mantissa and exponent exactly; both are +-- exact float operations. +CREATE OR REPLACE FUNCTION cel._f2n(f float8) +RETURNS numeric +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + e int := 0; +BEGIN + IF f = 0 THEN + RETURN 0; + END IF; + WHILE abs(f) >= 9007199254740992::float8 LOOP + f := f / 2; + e := e + 1; + END LOOP; + WHILE f <> trunc(f) LOOP + f := f * 2; + e := e - 1; + END LOOP; + -- 2^e must be exact. numeric ^ is exact for non-negative integer + -- exponents but rounds to ~16 significant digits for negative ones, + -- so build 2^-k as 5^k * 10^-k: an exact integer power times an + -- exact decimal literal, combined by (always exact) multiplication. + IF e >= 0 THEN + RETURN f::bigint::numeric * (2::numeric ^ e); + END IF; + RETURN f::bigint::numeric * (5::numeric ^ (-e)) + * ('1e' || e)::numeric; +END; +$$; + +COMMIT; diff --git a/sql/040_check.sql b/sql/040_check.sql new file mode 100644 index 0000000..71ed875 --- /dev/null +++ b/sql/040_check.sql @@ -0,0 +1,1243 @@ +-- cel4postgres -- type checker. +-- +-- Annotates a parse envelope with types and refs, rewriting idents, +-- qualified selects and struct type names to fully-qualified form and +-- binding overload ids into call nodes -- the ids cel.eval dispatches +-- on (day-one invariant 2). The algorithm is cel-go's +-- checker/checker.go and checker/types.go (pinned v0.32.0), ported +-- rule by rule: parameter unification with an occurs check, +-- most-general rebinding, dyn/any/error as wildcards, legacy +-- nullability, and declaration-ordered overload resolution with +-- result-type widening. +-- +-- Types are the doc-03 json encoding; the substitution mapping is a +-- jsonb object keyed by the parameter type's canonical jsonb text. +-- Errors fail fast: conformance asserts on check-failure existence, +-- never on collecting several. + +BEGIN; + +-- Type formatting for error messages (checker/format.go, loosely). +CREATE OR REPLACE FUNCTION cel._t_fmt(t jsonb) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := t ->> 'kind'; +BEGIN + RETURN CASE k + WHEN 'list' THEN + format('list(%s)', cel._t_fmt(t -> 'params' -> 0)) + WHEN 'map' THEN + format('map(%s, %s)', cel._t_fmt(t -> 'params' -> 0), + cel._t_fmt(t -> 'params' -> 1)) + WHEN 'wrapper' THEN + format('wrapper(%s)', cel._t_fmt(t -> 'params' -> 0)) + WHEN 'type' THEN + CASE WHEN t ? 'params' + THEN format('type(%s)', cel._t_fmt(t -> 'params' -> 0)) + ELSE 'type' END + WHEN 'param' THEN t ->> 'name' + WHEN 'opaque' THEN + CASE WHEN jsonb_array_length(coalesce(t -> 'params', '[]')) > 0 + THEN format('%s(%s)', t ->> 'name', ( + SELECT string_agg(cel._t_fmt(p), ', ') + FROM jsonb_array_elements(t -> 'params') p)) + ELSE t ->> 'name' END + WHEN 'struct' THEN t ->> 'name' + WHEN 'error' THEN '!error!' + WHEN 'null' THEN 'null' + WHEN 'timestamp' THEN 'google.protobuf.Timestamp' + WHEN 'duration' THEN 'google.protobuf.Duration' + ELSE k + END; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._t_is_dyn(t jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT t ->> 'kind' IN ('dyn', 'any'); +$$; + +CREATE OR REPLACE FUNCTION cel._t_dyn_or_err(t jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT t ->> 'kind' IN ('dyn', 'any', 'error'); +$$; + +-- substitute (types.go:276): follow binding chains; optionally +-- collapse unbound parameters to dyn (the checker's final pass). +CREATE OR REPLACE FUNCTION cel._ck_subst(m jsonb, t jsonb, todyn boolean) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + sub jsonb; + ps jsonb; + p jsonb; +BEGIN + IF t ->> 'kind' = 'param' THEN + sub := m -> (t::text); + IF sub IS NOT NULL THEN + RETURN cel._ck_subst(m, sub, todyn); + END IF; + IF todyn THEN + RETURN '{"kind":"dyn"}'::jsonb; + END IF; + RETURN t; + END IF; + + CASE t ->> 'kind' + WHEN 'opaque', 'list', 'map', 'type' THEN + IF NOT t ? 'params' THEN + RETURN t; + END IF; + ps := '[]'::jsonb; + FOR p IN SELECT e FROM jsonb_array_elements(t -> 'params') e LOOP + ps := ps || jsonb_build_array(cel._ck_subst(m, p, todyn)); + END LOOP; + RETURN jsonb_set(t, '{params}', ps); + ELSE + RETURN t; + END CASE; +END; +$$; + +-- isEqualOrLessSpecific (types.go:58). +CREATE OR REPLACE FUNCTION cel._ck_less_specific(t1 jsonb, t2 jsonb) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k1 text := t1 ->> 'kind'; + k2 text := t2 ->> 'kind'; + i int; +BEGIN + IF cel._t_is_dyn(t1) OR k1 = 'param' THEN + RETURN true; + END IF; + IF cel._t_is_dyn(t2) OR k2 = 'param' THEN + RETURN false; + END IF; + IF k1 <> k2 THEN + RETURN false; + END IF; + CASE k1 + WHEN 'opaque' THEN + IF t1 ->> 'name' <> t2 ->> 'name' + OR jsonb_array_length(coalesce(t1 -> 'params', '[]')) + <> jsonb_array_length(coalesce(t2 -> 'params', '[]')) THEN + RETURN false; + END IF; + FOR i IN 0 .. jsonb_array_length(coalesce(t1 -> 'params', '[]')) - 1 + LOOP + IF NOT cel._ck_less_specific( + t1 -> 'params' -> i, t2 -> 'params' -> i) THEN + RETURN false; + END IF; + END LOOP; + RETURN true; + WHEN 'list' THEN + RETURN cel._ck_less_specific( + t1 -> 'params' -> 0, t2 -> 'params' -> 0); + WHEN 'map' THEN + RETURN cel._ck_less_specific( + t1 -> 'params' -> 0, t2 -> 'params' -> 0) + AND cel._ck_less_specific( + t1 -> 'params' -> 1, t2 -> 'params' -> 1); + WHEN 'type' THEN + RETURN true; + ELSE + RETURN t1 = t2; + END CASE; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._ck_most_general(t1 jsonb, t2 jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN cel._ck_less_specific(t1, t2) THEN t1 ELSE t2 END; +$$; + +-- notReferencedIn (types.go:251): the occurs check. +CREATE OR REPLACE FUNCTION cel._ck_not_ref_in(m jsonb, t jsonb, w jsonb) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + sub jsonb; + p jsonb; +BEGIN + IF t = w THEN + RETURN false; + END IF; + CASE w ->> 'kind' + WHEN 'param' THEN + sub := m -> (w::text); + IF sub IS NULL THEN + RETURN true; + END IF; + RETURN cel._ck_not_ref_in(m, t, sub); + WHEN 'opaque', 'list', 'map', 'type' THEN + FOR p IN + SELECT e FROM jsonb_array_elements(coalesce(w -> 'params', '[]')) e + LOOP + IF NOT cel._ck_not_ref_in(m, t, p) THEN + RETURN false; + END IF; + END LOOP; + RETURN true; + ELSE + RETURN true; + END CASE; +END; +$$; + +-- isLegacyNullable + internalIsAssignableNull (types.go:208). +CREATE OR REPLACE FUNCTION cel._ck_nullable(t jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT t ->> 'kind' IN + ('opaque', 'struct', 'any', 'duration', 'timestamp', + 'wrapper', 'null'); +$$; + +-- internalIsAssignable (types.go:100), with the mapping threaded in +-- and out. jsonb is by-value, so "copy on trial" is just returning +-- the old value on failure -- the callers below rely on that. +CREATE OR REPLACE FUNCTION cel._ck_assign1( + m jsonb, t1 jsonb, t2 jsonb, + OUT ok boolean, OUT mo jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k1 text := t1 ->> 'kind'; + k2 text := t2 ->> 'kind'; + s record; +BEGIN + mo := m; + + IF k2 = 'param' THEN + SELECT * INTO s FROM cel._ck_valid_sub(mo, t1, t2); + IF s.ok THEN + ok := true; + mo := s.mo; + RETURN; + END IF; + IF s.hassub THEN + ok := false; + RETURN; + END IF; + END IF; + IF k1 = 'param' THEN + SELECT * INTO s FROM cel._ck_valid_sub(mo, t2, t1); + ok := s.ok; + IF s.ok THEN + mo := s.mo; + END IF; + RETURN; + END IF; + + IF cel._t_dyn_or_err(t1) OR cel._t_dyn_or_err(t2) THEN + ok := true; + RETURN; + END IF; + + IF k1 = 'null' THEN + ok := cel._ck_nullable(t2); + RETURN; + END IF; + IF k2 = 'null' THEN + ok := cel._ck_nullable(t1); + RETURN; + END IF; + + -- Wrappers accept their wrapped primitive (and null, above); + -- nothing else accepts a wrapper except another identical wrapper + -- or the wildcards already handled. + IF k2 = 'wrapper' THEN + ok := (k1 = 'wrapper' AND t1 -> 'params' = t2 -> 'params') + OR t1 = t2 -> 'params' -> 0; + RETURN; + END IF; + IF k1 = 'wrapper' THEN + ok := false; + RETURN; + END IF; + + CASE k1 + WHEN 'bool', 'bytes', 'double', 'int', 'string', 'uint', + 'any', 'duration', 'timestamp' THEN + ok := k1 = k2; + RETURN; + WHEN 'struct' THEN + ok := k2 = 'struct' AND t1 ->> 'name' = t2 ->> 'name'; + RETURN; + WHEN 'type' THEN + ok := k2 = 'type'; + RETURN; + WHEN 'opaque', 'list', 'map' THEN + IF k1 <> k2 + OR coalesce(t1 ->> 'name', '') <> coalesce(t2 ->> 'name', '') + THEN + ok := false; + RETURN; + END IF; + SELECT * INTO s FROM cel._ck_assign_list(mo, + coalesce(t1 -> 'params', '[]'), + coalesce(t2 -> 'params', '[]')); + ok := s.ok; + IF s.ok THEN + mo := s.mo; + END IF; + RETURN; + ELSE + ok := false; + RETURN; + END CASE; +END; +$$; + +-- isValidTypeSubstitution (types.go:160): whether t2 (a parameter) +-- can substitute for t1. +CREATE OR REPLACE FUNCTION cel._ck_valid_sub( + m jsonb, t1 jsonb, t2 jsonb, + OUT ok boolean, OUT hassub boolean, OUT mo jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + t2sub jsonb; + s record; + t2new jsonb; +BEGIN + mo := m; + IF t1 = t2 THEN + ok := true; + hassub := true; + RETURN; + END IF; + + t2sub := m -> (t2::text); + IF t2sub IS NOT NULL THEN + hassub := true; + IF t1 = t2sub THEN + ok := true; + RETURN; + END IF; + SELECT * INTO s FROM cel._ck_assign1(mo, t1, t2sub); + IF s.ok THEN + mo := s.mo; + t2new := cel._ck_most_general(t1, t2sub); + IF cel._ck_not_ref_in(mo, t2, t2new) THEN + mo := jsonb_set(mo, ARRAY[t2::text], t2new); + END IF; + ok := true; + RETURN; + END IF; + ok := false; + RETURN; + END IF; + + hassub := false; + IF cel._ck_not_ref_in(mo, t2, t1) THEN + mo := jsonb_set(mo, ARRAY[t2::text], t1); + ok := true; + RETURN; + END IF; + ok := false; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._ck_assign_list( + m jsonb, l1 jsonb, l2 jsonb, + OUT ok boolean, OUT mo jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + i int; + s record; +BEGIN + mo := m; + IF jsonb_array_length(l1) <> jsonb_array_length(l2) THEN + ok := false; + RETURN; + END IF; + FOR i IN 0 .. jsonb_array_length(l1) - 1 LOOP + SELECT * INTO s FROM cel._ck_assign1(mo, l1 -> i, l2 -> i); + IF NOT s.ok THEN + ok := false; + mo := m; + RETURN; + END IF; + mo := s.mo; + END LOOP; + ok := true; +END; +$$; + +-- joinTypes (checker.go:616) with cel-go's default dyn fallback for +-- heterogeneous aggregate literals. +CREATE OR REPLACE FUNCTION cel._ck_join( + m jsonb, prev jsonb, cur jsonb, + OUT t jsonb, OUT mo jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s record; +BEGIN + mo := m; + IF prev IS NULL THEN + t := cur; + RETURN; + END IF; + SELECT * INTO s FROM cel._ck_assign1(mo, prev, cur); + IF s.ok THEN + mo := s.mo; + t := cel._ck_most_general(prev, cur); + RETURN; + END IF; + t := '{"kind":"dyn"}'::jsonb; +END; +$$; + +COMMIT; + +BEGIN; + +-- Fresh type variables and parameter instantiation. +CREATE OR REPLACE FUNCTION cel._ck_collect_params(t jsonb) +RETURNS text[] +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + acc text[] := '{}'; + p jsonb; +BEGIN + IF t ->> 'kind' = 'param' THEN + RETURN ARRAY[t ->> 'name']; + END IF; + FOR p IN + SELECT e FROM jsonb_array_elements(coalesce(t -> 'params', '[]')) e + LOOP + acc := acc || cel._ck_collect_params(p); + END LOOP; + RETURN acc; +END; +$$; + +-- Rewrites the named parameters of one overload's signature to fresh +-- _var parameters (checker.go:388-396). +CREATE OR REPLACE FUNCTION cel._ck_instantiate( + arg_types jsonb, result_type jsonb, n int, + OUT args jsonb, OUT result jsonb, OUT nn int +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + names text[] := '{}'; + nm text; + fresh jsonb := '{}'::jsonb; + a jsonb; +BEGIN + nn := n; + names := ( + SELECT coalesce(array_agg(DISTINCT p), '{}') + FROM ( + SELECT unnest(cel._ck_collect_params(result_type) + || (SELECT coalesce(array_agg(x), '{}') + FROM (SELECT unnest(cel._ck_collect_params(e)) AS x + FROM jsonb_array_elements(arg_types) e) u)) AS p + ) q); + FOREACH nm IN ARRAY names LOOP + fresh := fresh || jsonb_build_object( + jsonb_build_object('kind', 'param', 'name', nm)::text, + jsonb_build_object('kind', 'param', 'name', '_var' || nn)); + nn := nn + 1; + END LOOP; + + args := '[]'::jsonb; + FOR a IN SELECT e FROM jsonb_array_elements(arg_types) e LOOP + args := args || jsonb_build_array(cel._ck_subst(fresh, a, false)); + END LOOP; + result := cel._ck_subst(fresh, result_type, false); +END; +$$; + +-- Overload resolution (checker.go:339): declaration order, fresh +-- instantiation, assignability trial against a copy of the mapping, +-- result-type widening across multiple matches. +CREATE OR REPLACE FUNCTION cel._ck_resolve( + fn text, + is_member boolean, + argtypes jsonb, + envs text[], + st jsonb, + OUT ref jsonb, OUT rtype jsonb, OUT sto jsonb, OUT err text +) +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + row_r record; + inst record; + a record; + ids jsonb := '[]'::jsonb; + fnres jsonb; + m jsonb := st -> 'map'; + n int := (st ->> 'n')::int; + i int; + fmt_args text; +BEGIN + sto := st; + + -- The variadic-logical special case (checker.go:365): every arg + -- must be assignable to bool; the result is bool. + IF fn IN ('_&&_', '_||_') THEN + FOR i IN 0 .. jsonb_array_length(argtypes) - 1 LOOP + SELECT * INTO a FROM cel._ck_assign1( + m, argtypes -> i, '{"kind":"bool"}'); + IF NOT a.ok THEN + err := format('expected type ''bool'' but found %s', + quote_literal(cel._t_fmt(argtypes -> i))); + RETURN; + END IF; + m := a.mo; + END LOOP; + SELECT jsonb_build_object( + 'overloads', jsonb_build_array(to_jsonb(o.id))) + INTO ref + FROM cel.overload o + WHERE o.function = fn ORDER BY o.ordinal LIMIT 1; + rtype := '{"kind":"bool"}'::jsonb; + sto := jsonb_set(jsonb_set(st, '{map}', m), '{n}', to_jsonb(n)); + RETURN; + END IF; + + FOR row_r IN + SELECT o.* + FROM cel.overload o + WHERE o.function = fn + AND EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'overload' + AND it.ref = o.id) + ORDER BY o.ordinal + LOOP + CONTINUE WHEN row_r.member <> is_member; + + SELECT * INTO inst + FROM cel._ck_instantiate(row_r.arg_types, row_r.result_type, n); + n := inst.nn; + + SELECT * INTO a + FROM cel._ck_assign_list(m, argtypes, inst.args); + IF a.ok THEN + m := a.mo; + ids := ids || to_jsonb(row_r.id); + fnres := cel._ck_subst(m, inst.result, false); + IF rtype IS NULL THEN + rtype := fnres; + ELSIF NOT cel._t_is_dyn(rtype) AND fnres <> rtype THEN + rtype := '{"kind":"dyn"}'::jsonb; + END IF; + END IF; + END LOOP; + + IF rtype IS NULL THEN + SELECT string_agg(cel._t_fmt(cel._ck_subst(m, e, true)), ', ') + INTO fmt_args + FROM jsonb_array_elements(argtypes) + WITH ORDINALITY q(e, o) + WHERE NOT is_member OR o > 1; + IF is_member THEN + err := format( + 'found no matching overload for %s applied to ''%s.(%s)''', + quote_literal(fn), + cel._t_fmt(cel._ck_subst(m, argtypes -> 0, true)), + coalesce(fmt_args, '')); + ELSE + err := format( + 'found no matching overload for %s applied to ''(%s)''', + quote_literal(fn), coalesce(fmt_args, '')); + END IF; + RETURN; + END IF; + + ref := jsonb_build_object('overloads', ids); + sto := jsonb_set(jsonb_set(st, '{map}', m), '{n}', to_jsonb(n)); +END; +$$; + +-- Local (comprehension) scope lookup, innermost first. +CREATE OR REPLACE FUNCTION cel._ck_local(scopes jsonb, name text) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + i int; +BEGIN + FOR i IN REVERSE jsonb_array_length(scopes) - 1 .. 0 LOOP + IF scopes -> i ? name THEN + RETURN scopes -> i -> name; + END IF; + END LOOP; + RETURN NULL; +END; +$$; + +-- Simple-identifier resolution (checker/env.go:152): local scope +-- first (unless absolute), then container candidates against the +-- global declarations. Returns the qualified name and its type. +CREATE OR REPLACE FUNCTION cel._ck_ident( + name text, scopes jsonb, globals jsonb, ctr text, + OUT qname text, OUT typ jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + absolute boolean := name LIKE '.%'; + bare text := ltrim(name, '.'); + cand text; + loc jsonb := cel._ck_local(scopes, bare); +BEGIN + IF loc IS NOT NULL AND NOT absolute THEN + qname := bare; + typ := loc; + RETURN; + END IF; + FOREACH cand IN ARRAY cel._name_candidates(bare, absolute, ctr) LOOP + IF globals ? cand THEN + -- A shadowing local forces runtime disambiguation: the dot + -- survives into the rewritten ident so eval skips + -- comprehension frames (checker/env.go + -- requiresDisambiguation). + qname := CASE WHEN loc IS NOT NULL THEN '.' || cand + ELSE cand END; + typ := globals -> cand; + RETURN; + END IF; + END LOOP; +END; +$$; + +-- Qualified-identifier resolution (env.go:166): a local binding of +-- the root segment forces field selection instead. +CREATE OR REPLACE FUNCTION cel._ck_qualified( + parts text[], absolute boolean, scopes jsonb, globals jsonb, + ctr text, + OUT qname text, OUT typ jsonb +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + cand text; + name text; + loc jsonb := cel._ck_local(scopes, parts[1]); +BEGIN + IF loc IS NOT NULL AND NOT absolute THEN + RETURN; + END IF; + name := array_to_string(parts, '.'); + FOREACH cand IN ARRAY cel._name_candidates(name, absolute, ctr) LOOP + IF globals ? cand THEN + -- Same disambiguation rule as _ck_ident: a local root plus a + -- won global keeps the leading dot for runtime resolution. + qname := CASE WHEN loc IS NOT NULL THEN '.' || cand + ELSE cand END; + typ := globals -> cand; + RETURN; + END IF; + END LOOP; +END; +$$; + +COMMIT; + +BEGIN; + +-- Literal kinds to checker types. +CREATE OR REPLACE FUNCTION cel._ck_lit_type(v jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('kind', + CASE v ->> '@t' WHEN 'null' THEN 'null' ELSE v ->> '@t' END); +$$; + +-- The recursive checker. Returns the (possibly rewritten) node, its +-- type, and the threaded state {"types","refs","map","n"}; err set +-- means the whole check fails with that message. +CREATE OR REPLACE FUNCTION cel._ck( + node jsonb, + scopes jsonb, + globals jsonb, + envs text[], + ctr text, + st jsonb, + OUT nodeo jsonb, OUT typ jsonb, OUT sto jsonb, OUT err text +) +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := node ->> 'k'; + nid text := node ->> 'id'; + c record; + r record; + q record; + chain record; + argtypes jsonb; + newargs jsonb; + target_t jsonb; + fname text; + cand text; + i int; + elem_t jsonb; + key_t jsonb; + val_t jsonb; + op_t jsonb; + ents jsonb; + frame jsonb; + range_t jsonb; + accu_t jsonb; + var_t jsonb; + var2_t jsonb; + s record; +BEGIN + sto := st; + nodeo := node; + + CASE k + WHEN 'lit' THEN + typ := cel._ck_lit_type(node -> 'v'); + + WHEN 'ident' THEN + SELECT * INTO q FROM cel._ck_ident( + node ->> 'name', scopes, globals, ctr); + IF q.qname IS NULL THEN + err := format( + 'undeclared reference to %s (in container %s)', + quote_literal(node ->> 'name'), quote_literal(ctr)); + RETURN; + END IF; + typ := q.typ; + nodeo := jsonb_build_object( + 'id', node -> 'id', 'k', 'ident', 'name', q.qname); + sto := jsonb_set(sto, ARRAY['refs', nid], + jsonb_build_object('name', q.qname)); + + WHEN 'select' THEN + -- Qualified-name interpretation first (checker.go:134). + IF NOT coalesce((node -> 'test')::boolean, false) THEN + SELECT * INTO chain FROM cel._attr_chain(node); + IF chain.parts IS NOT NULL THEN + SELECT * INTO q FROM cel._ck_qualified( + chain.parts, chain.absolute, scopes, globals, ctr); + IF q.qname IS NOT NULL THEN + typ := q.typ; + nodeo := jsonb_build_object( + 'id', node -> 'id', 'k', 'ident', 'name', q.qname); + sto := jsonb_set(sto, ARRAY['refs', nid], + jsonb_build_object('name', q.qname)); + sto := jsonb_set(sto, ARRAY['types', nid], typ); + RETURN; + END IF; + END IF; + END IF; + + -- Field selection. + SELECT * INTO c FROM cel._ck( + node -> 'op', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(node, '{op}', c.nodeo); + op_t := cel._ck_subst(sto -> 'map', c.typ, false); + + CASE op_t ->> 'kind' + WHEN 'map' THEN + typ := op_t -> 'params' -> 1; + WHEN 'param' THEN + SELECT * INTO s FROM cel._ck_assign1( + sto -> 'map', '{"kind":"dyn"}', op_t); + IF s.ok THEN + sto := jsonb_set(sto, '{map}', s.mo); + END IF; + typ := '{"kind":"dyn"}'::jsonb; + WHEN 'dyn', 'any', 'error' THEN + typ := '{"kind":"dyn"}'::jsonb; + WHEN 'wrapper' THEN + typ := '{"kind":"dyn"}'::jsonb; + ELSE + err := format('type %s does not support field selection', + quote_literal(cel._t_fmt(op_t))); + RETURN; + END CASE; + + IF coalesce((node -> 'test')::boolean, false) THEN + typ := '{"kind":"bool"}'::jsonb; + ELSE + typ := cel._ck_subst(sto -> 'map', typ, false); + END IF; + + WHEN 'call' THEN + -- Check the arguments first, in order. + newargs := '[]'::jsonb; + argtypes := '[]'::jsonb; + FOR i IN 0 .. jsonb_array_length(node -> 'args') - 1 LOOP + SELECT * INTO c FROM cel._ck( + node -> 'args' -> i, scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + newargs := newargs || jsonb_build_array(c.nodeo); + argtypes := argtypes || jsonb_build_array(c.typ); + END LOOP; + nodeo := jsonb_set(node, '{args}', newargs); + + fname := node ->> 'fn'; + + IF NOT node ? 'target' THEN + -- Global call: resolve the function name through the + -- container. + SELECT n.c INTO cand + FROM unnest(cel._name_candidates( + ltrim(fname, '.'), fname LIKE '.%', ctr)) n(c) + WHERE EXISTS ( + SELECT FROM cel.overload o + WHERE o.function = n.c AND EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'overload' + AND it.ref = o.id)) + LIMIT 1; + IF cand IS NULL THEN + err := format( + 'undeclared reference to %s (in container %s)', + quote_literal(fname), quote_literal(ctr)); + RETURN; + END IF; + nodeo := jsonb_set(nodeo, '{fn}', to_jsonb(cand)); + SELECT * INTO r FROM cel._ck_resolve( + cand, false, argtypes, envs, sto); + ELSE + -- Receiver call: namespaced-function flattening first + -- (a.b.fn() may be global function "a.b.fn"). + SELECT * INTO chain FROM cel._attr_chain(node -> 'target'); + cand := NULL; + IF chain.parts IS NOT NULL THEN + SELECT n.c INTO cand + FROM unnest(cel._name_candidates( + array_to_string(chain.parts, '.') || '.' || fname, + chain.absolute, ctr)) n(c) + WHERE EXISTS ( + SELECT FROM cel.overload o + WHERE o.function = n.c AND EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'overload' + AND it.ref = o.id)) + LIMIT 1; + END IF; + IF cand IS NOT NULL THEN + nodeo := (nodeo - 'target'); + nodeo := jsonb_set(nodeo, '{fn}', to_jsonb(cand)); + SELECT * INTO r FROM cel._ck_resolve( + cand, false, argtypes, envs, sto); + ELSE + SELECT * INTO c FROM cel._ck( + node -> 'target', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(nodeo, '{target}', c.nodeo); + target_t := c.typ; + + IF NOT EXISTS ( + SELECT FROM cel.overload o + WHERE o.function = fname AND EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'overload' + AND it.ref = o.id)) + THEN + err := format( + 'undeclared reference to %s (in container %s)', + quote_literal(fname), quote_literal(ctr)); + RETURN; + END IF; + SELECT * INTO r FROM cel._ck_resolve( + fname, true, + jsonb_build_array(target_t) || argtypes, envs, sto); + END IF; + END IF; + + IF r.err IS NOT NULL THEN + err := r.err; + RETURN; + END IF; + sto := r.sto; + typ := r.rtype; + nodeo := nodeo || jsonb_build_object('ref', r.ref); + sto := jsonb_set(sto, ARRAY['refs', nid], r.ref); + + WHEN 'list' THEN + elem_t := NULL; + newargs := '[]'::jsonb; + FOR i IN 0 .. jsonb_array_length(node -> 'elems') - 1 LOOP + SELECT * INTO c FROM cel._ck( + node -> 'elems' -> i, scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + newargs := newargs || jsonb_build_array(c.nodeo); + SELECT * INTO s FROM cel._ck_join(sto -> 'map', elem_t, c.typ); + elem_t := s.t; + sto := jsonb_set(sto, '{map}', s.mo); + END LOOP; + nodeo := jsonb_set(node, '{elems}', newargs); + IF elem_t IS NULL THEN + elem_t := jsonb_build_object('kind', 'param', + 'name', '_var' || (sto ->> 'n')); + sto := jsonb_set(sto, '{n}', to_jsonb((sto ->> 'n')::int + 1)); + END IF; + typ := jsonb_build_object( + 'kind', 'list', 'params', jsonb_build_array(elem_t)); + + WHEN 'map' THEN + key_t := NULL; + val_t := NULL; + ents := '[]'::jsonb; + FOR i IN 0 .. jsonb_array_length(node -> 'entries') - 1 LOOP + SELECT * INTO c FROM cel._ck( + node -> 'entries' -> i -> 'k', + scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + SELECT * INTO s FROM cel._ck_join(sto -> 'map', key_t, c.typ); + key_t := s.t; + sto := jsonb_set(sto, '{map}', s.mo); + frame := jsonb_set(node -> 'entries' -> i, '{k}', c.nodeo); + + SELECT * INTO c FROM cel._ck( + frame -> 'v', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + SELECT * INTO s FROM cel._ck_join(sto -> 'map', val_t, c.typ); + val_t := s.t; + sto := jsonb_set(sto, '{map}', s.mo); + ents := ents || jsonb_build_array( + jsonb_set(frame, '{v}', c.nodeo)); + END LOOP; + nodeo := jsonb_set(node, '{entries}', ents); + IF key_t IS NULL THEN + key_t := jsonb_build_object('kind', 'param', + 'name', '_var' || (sto ->> 'n')); + val_t := jsonb_build_object('kind', 'param', + 'name', '_var' || ((sto ->> 'n')::int + 1)); + sto := jsonb_set(sto, '{n}', to_jsonb((sto ->> 'n')::int + 2)); + END IF; + typ := jsonb_build_object( + 'kind', 'map', 'params', jsonb_build_array(key_t, val_t)); + + WHEN 'struct' THEN + -- Message construction resolves through cel.type; without a + -- descriptor pool only registered (WKT/opaque) types exist. + SELECT t.name, t.kind INTO q + FROM unnest(cel._name_candidates( + ltrim(node ->> 'type', '.'), + (node ->> 'type') LIKE '.%', ctr)) n(c) + JOIN cel.type t ON t.name = n.c + WHERE EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'type' + AND it.ref = t.name) + LIMIT 1; + IF q.name IS NULL THEN + err := format( + 'undeclared reference to %s (in container %s)', + quote_literal(node ->> 'type'), quote_literal(ctr)); + RETURN; + END IF; + -- Field checking against WKT shapes arrives with 070_wkt.sql; + -- primitive type names are not message types. + IF (q.kind ->> 'kind') NOT IN + ('struct', 'map', 'list', 'dyn', 'wrapper', + 'timestamp', 'duration', 'any') + THEN + err := format('%s is not a message type', + quote_literal(q.name)); + RETURN; + END IF; + ents := '[]'::jsonb; + FOR i IN 0 .. jsonb_array_length(node -> 'fields') - 1 LOOP + SELECT * INTO c FROM cel._ck( + node -> 'fields' -> i -> 'v', + scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + ents := ents || jsonb_build_array( + jsonb_set(node -> 'fields' -> i, '{v}', c.nodeo)); + END LOOP; + nodeo := jsonb_set(node, '{fields}', ents); + nodeo := jsonb_set(nodeo, '{type}', to_jsonb(q.name)); + sto := jsonb_set(sto, ARRAY['refs', nid], + jsonb_build_object('name', q.name)); + typ := q.kind; + + WHEN 'comp' THEN + SELECT * INTO c FROM cel._ck( + node -> 'range', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(node, '{range}', c.nodeo); + range_t := cel._ck_subst(sto -> 'map', c.typ, false); + + SELECT * INTO c FROM cel._ck( + node -> 'init', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(nodeo, '{init}', c.nodeo); + accu_t := c.typ; + + CASE range_t ->> 'kind' + WHEN 'list' THEN + var_t := range_t -> 'params' -> 0; + IF node ->> 'iter2' <> '' THEN + var2_t := var_t; + var_t := '{"kind":"int"}'::jsonb; + END IF; + WHEN 'map' THEN + var_t := range_t -> 'params' -> 0; + IF node ->> 'iter2' <> '' THEN + var2_t := range_t -> 'params' -> 1; + END IF; + WHEN 'dyn', 'any', 'error', 'param' THEN + SELECT * INTO s FROM cel._ck_assign1( + sto -> 'map', '{"kind":"dyn"}', range_t); + IF s.ok THEN + sto := jsonb_set(sto, '{map}', s.mo); + END IF; + var_t := '{"kind":"dyn"}'::jsonb; + var2_t := '{"kind":"dyn"}'::jsonb; + ELSE + err := format( + 'expression of type %s cannot be range of a comprehension ' + || '(must be list, map, or dynamic)', + quote_literal(cel._t_fmt(range_t))); + RETURN; + END CASE; + + -- Accu scope, then the loop scope with the iteration variables. + frame := jsonb_build_object(node ->> 'accu', accu_t); + scopes := scopes || jsonb_build_array(frame); + frame := jsonb_build_object(node ->> 'iter', var_t); + IF node ->> 'iter2' <> '' THEN + frame := frame || jsonb_build_object(node ->> 'iter2', var2_t); + END IF; + scopes := scopes || jsonb_build_array(frame); + + SELECT * INTO c FROM cel._ck( + node -> 'cond', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(nodeo, '{cond}', c.nodeo); + SELECT * INTO s FROM cel._ck_assign1( + sto -> 'map', '{"kind":"bool"}', c.typ); + IF NOT s.ok THEN + err := format('expected type ''bool'' but found %s', + quote_literal(cel._t_fmt(c.typ))); + RETURN; + END IF; + sto := jsonb_set(sto, '{map}', s.mo); + + SELECT * INTO c FROM cel._ck( + node -> 'step', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(nodeo, '{step}', c.nodeo); + SELECT * INTO s FROM cel._ck_assign1( + sto -> 'map', accu_t, c.typ); + IF NOT s.ok THEN + err := format('expected type %s but found %s', + quote_literal(cel._t_fmt(accu_t)), + quote_literal(cel._t_fmt(c.typ))); + RETURN; + END IF; + sto := jsonb_set(sto, '{map}', s.mo); + + -- Result checks with the iteration variables out of scope. + scopes := scopes - (jsonb_array_length(scopes) - 1); + SELECT * INTO c FROM cel._ck( + node -> 'result', scopes, globals, envs, ctr, sto); + IF c.err IS NOT NULL THEN + err := c.err; + RETURN; + END IF; + sto := c.sto; + nodeo := jsonb_set(nodeo, '{result}', c.nodeo); + typ := cel._ck_subst(sto -> 'map', c.typ, false); + + ELSE + err := format('unexpected AST node kind: %s', k); + RETURN; + END CASE; + + sto := jsonb_set(sto, ARRAY['types', nid], typ); +END; +$$; + +-- Checks a parse envelope under an environment. options carries the +-- per-case container and extra ident declarations (decision 7). +-- Returns the annotated envelope, or {"errors": [...]}. +CREATE OR REPLACE FUNCTION cel.check(ast jsonb, env text, options jsonb) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + envs text[]; + ctr text := coalesce(options ->> 'container', ''); + globals jsonb; + st jsonb; + c record; + types jsonb := '{}'::jsonb; + entry record; +BEGIN + IF ast ? 'errors' THEN + RAISE 'cannot check a failed parse'; + END IF; + IF NOT ast ? 'expr' THEN + RAISE 'not an AST envelope'; + END IF; + + envs := cel._env_names(env); + + -- Global declarations: the caller's decls plus a type(T) ident for + -- every registered type visible in the env union. + SELECT coalesce(jsonb_object_agg( + t.name, + jsonb_build_object('kind', 'type', + 'params', jsonb_build_array(t.kind))), '{}'::jsonb) + INTO globals + FROM cel.type t + WHERE EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'type' + AND it.ref = t.name); + + IF options ? 'decls' THEN + SELECT globals || coalesce(jsonb_object_agg( + d ->> 'name', d -> 'type'), '{}'::jsonb) + INTO globals + FROM jsonb_array_elements(options -> 'decls') d; + END IF; + + st := jsonb_build_object( + 'types', '{}'::jsonb, 'refs', '{}'::jsonb, + 'map', '{}'::jsonb, 'n', 0); + + SELECT * INTO c FROM cel._ck( + ast -> 'expr', '[]'::jsonb, globals, envs, ctr, st); + IF c.err IS NOT NULL THEN + RETURN jsonb_build_object('errors', + jsonb_build_array(jsonb_build_object('msg', c.err))); + END IF; + + -- Final substitution: unbound parameters collapse to dyn. + FOR entry IN SELECT key, value FROM jsonb_each(c.sto -> 'types') LOOP + types := types || jsonb_build_object( + entry.key, cel._ck_subst(c.sto -> 'map', entry.value, true)); + END LOOP; + + RETURN (ast || jsonb_build_object( + 'expr', c.nodeo, + 'types', types, + 'refs', c.sto -> 'refs')); +END; +$$; + +CREATE OR REPLACE FUNCTION cel.check(ast jsonb, env text) +RETURNS jsonb +LANGUAGE sql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel.check(ast, env, NULL); +$$; + +COMMIT; diff --git a/sql/050_eval.sql b/sql/050_eval.sql index 2c36616..d5903dd 100644 --- a/sql/050_eval.sql +++ b/sql/050_eval.sql @@ -204,7 +204,13 @@ DECLARE cand text; name text; BEGIN - FOR f IN REVERSE jsonb_array_length(scopes) - 1 .. 0 LOOP + -- A leading dot pins the name to the input activation: cel-go's + -- absoluteAttribute unwraps every comprehension frame before + -- resolving when the checker marked the name for disambiguation + -- (attributes.go, disambiguateNames). + FOR f IN REVERSE CASE WHEN absolute THEN 0 + ELSE jsonb_array_length(scopes) - 1 END .. 0 + LOOP FOR plen IN REVERSE cardinality(parts) .. 1 LOOP name := array_to_string(parts[1:plen], '.'); FOREACH cand IN ARRAY cel._name_candidates(name, absolute, ctr) @@ -691,7 +697,12 @@ BEGIN ast -> 'expr', jsonb_build_array(coalesce(activation, '{}'::jsonb)), envs, - coalesce(options ->> 'container', ''), + -- A checked AST already carries fully-qualified names; applying + -- the container again would mis-resolve deliberately-bare names + -- (the corpus disambiguation cases: a checked ident "y" must not + -- become "com.example.y" at runtime). + CASE WHEN ast ? 'types' THEN '' + ELSE coalesce(options ->> 'container', '') END, 0); END; $$; diff --git a/sql/060_stdlib.sql b/sql/060_stdlib.sql index 453dacc..1349c9d 100644 --- a/sql/060_stdlib.sql +++ b/sql/060_stdlib.sql @@ -258,8 +258,8 @@ BEGIN RETURN jsonb_build_object('@t', 'double', 'v', CASE WHEN neg THEN '-Infinity' ELSE 'Infinity' END); END IF; - na := fa::numeric; - nb := fb::numeric; + na := cel._f2n(fa); + nb := cel._f2n(fb); IF abs(na) >= boundary * abs(nb) THEN RETURN jsonb_build_object('@t', 'double', 'v', CASE WHEN (fa < 0) <> (fb < 0) @@ -273,8 +273,8 @@ BEGIN RETURN cel._dbl_val(fa / fb); END IF; - na := fa::numeric; - nb := fb::numeric; + na := cel._f2n(fa); + nb := cel._f2n(fb); RETURN cel._dbl_of_numeric(CASE op WHEN '+' THEN na + nb WHEN '-' THEN na - nb @@ -803,3 +803,483 @@ SELECT 'standard', 'type', name FROM cel.type ON CONFLICT DO NOTHING; COMMIT; + +BEGIN; + +-- Part 2: type conversions and string functions. Conversion +-- semantics are cel-go's exactly (common/types + overflow.go, +-- measured): double-to-int excludes both 2^63 boundaries, string +-- parsing follows Go strconv, string(double) is the %g formatter. + +CREATE OR REPLACE FUNCTION cel._f_conv_identity(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT args[1]; +$$; + +CREATE OR REPLACE FUNCTION cel._f_int64_to_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_uint((args[1] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_uint64_to_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._chk_int((args[1] ->> 'v')::numeric); +$$; + +-- doubleToInt64Checked (overflow.go:302): NaN, infinities, and both +-- 2^63 boundaries are overflow; conversion truncates toward zero. +CREATE OR REPLACE FUNCTION cel._f_double_to_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + f float8 := cel._dbl(args[1]); +BEGIN + IF f = 'NaN'::float8 OR f = 'Infinity'::float8 + OR f = '-Infinity'::float8 + OR f <= (-9223372036854775808)::float8 + OR f >= 9223372036854775807::float8 + THEN + RETURN cel._err('integer overflow'); + END IF; + RETURN cel._int_val(trunc(cel._f2n(f), 0)); +END; +$$; + +-- doubleToUint64Checked (overflow.go:312). +CREATE OR REPLACE FUNCTION cel._f_double_to_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + f float8 := cel._dbl(args[1]); +BEGIN + IF f = 'NaN'::float8 OR f = 'Infinity'::float8 + OR f = '-Infinity'::float8 + OR f < 0 + OR f >= 18446744073709551615::float8 + THEN + RETURN cel._err('unsigned integer overflow'); + END IF; + RETURN jsonb_build_object('@t', 'uint', 'v', + to_jsonb(trunc(cel._f2n(f), 0))); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_int64_to_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dbl_val((args[1] ->> 'v')::numeric::float8); +$$; + +CREATE OR REPLACE FUNCTION cel._f_uint64_to_double(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dbl_val((args[1] ->> 'v')::numeric::float8); +$$; + +CREATE OR REPLACE FUNCTION cel._f_string_to_int64(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; +BEGIN + IF s !~ '^[-+]?[0-9]+$' THEN + RETURN cel._err(format( + 'type conversion error from string to int: %s', + quote_literal(s))); + END IF; + RETURN cel._chk_int(s::numeric); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_string_to_uint64(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; +BEGIN + -- Go's ParseUint permits no sign. + IF s !~ '^[0-9]+$' THEN + RETURN cel._err(format( + 'type conversion error from string to uint: %s', + quote_literal(s))); + END IF; + RETURN cel._chk_uint(s::numeric); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_string_to_double(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + l text := lower(s); + n numeric; +BEGIN + -- Go ParseFloat accepts inf/infinity/nan, case-insensitive, + -- optionally signed. + IF l IN ('inf', '+inf', 'infinity', '+infinity') THEN + RETURN jsonb_build_object('@t', 'double', 'v', 'Infinity'); + ELSIF l IN ('-inf', '-infinity') THEN + RETURN jsonb_build_object('@t', 'double', 'v', '-Infinity'); + ELSIF l = 'nan' THEN + RETURN jsonb_build_object('@t', 'double', 'v', 'NaN'); + END IF; + IF s !~ '^[-+]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$' THEN + RETURN cel._err(format( + 'type conversion error from string to double: %s', + quote_literal(s))); + END IF; + n := s::numeric; + -- ParseFloat overflow is an error for conversions (unlike literal + -- underflow, which rounds to signed zero silently). + IF abs(n) > 1.7976931348623157e308::numeric THEN + RETURN cel._err(format( + 'type conversion error from string to double: %s', + quote_literal(s))); + END IF; + IF n <> 0 AND abs(n) <= 2.4703282292062327e-324::numeric THEN + RETURN cel._dbl_val( + (CASE WHEN n < 0 THEN '-0' ELSE '0' END)::float8); + END IF; + RETURN cel._dbl_val(n::float8); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_string_to_bool(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; +BEGIN + -- Go strconv.ParseBool's exact accepted set. + IF s IN ('1', 't', 'T', 'TRUE', 'true', 'True') THEN + RETURN cel._bool_val(true); + ELSIF s IN ('0', 'f', 'F', 'FALSE', 'false', 'False') THEN + RETURN cel._bool_val(false); + END IF; + RETURN cel._err(format( + 'type conversion error from string to bool: %s', + quote_literal(s))); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_int64_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', args[1] ->> 'v'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_uint64_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', args[1] ->> 'v'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_double_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', + CASE args[1] ->> 'v' + WHEN 'Infinity' THEN '+Inf' + WHEN '-Infinity' THEN '-Inf' + WHEN 'NaN' THEN 'NaN' + ELSE cel._double_text(cel._dbl(args[1])) + END); +$$; + +CREATE OR REPLACE FUNCTION cel._f_bool_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', + CASE WHEN (args[1] ->> 'v')::boolean THEN 'true' ELSE 'false' END); +$$; + +-- UTF-8 validation for bytes->string, byte-DFA style, without an +-- exception block (convert_from would raise). NUL is additionally +-- unrepresentable in Postgres text. +CREATE OR REPLACE FUNCTION cel._utf8_valid(b bytea) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + i int := 0; + n int := octet_length(b); + c int; + need int; + j int; + cp int; + mins int[] := ARRAY[0, 128, 2048, 65536]; +BEGIN + WHILE i < n LOOP + c := get_byte(b, i); + IF c = 0 THEN + RETURN false; -- representable in UTF-8, not in Postgres text + ELSIF c < 128 THEN + need := 0; + cp := c; + ELSIF c BETWEEN 194 AND 223 THEN + need := 1; + cp := c - 192; + ELSIF c BETWEEN 224 AND 239 THEN + need := 2; + cp := c - 224; + ELSIF c BETWEEN 240 AND 244 THEN + need := 3; + cp := c - 240; + ELSE + RETURN false; + END IF; + FOR j IN 1 .. need LOOP + IF i + j >= n OR get_byte(b, i + j) NOT BETWEEN 128 AND 191 THEN + RETURN false; + END IF; + cp := cp * 64 + (get_byte(b, i + j) - 128); + END LOOP; + IF need > 0 AND cp < mins[need + 1] THEN + RETURN false; -- overlong encoding + END IF; + IF cp > 1114111 OR (cp BETWEEN 55296 AND 57343) THEN + RETURN false; + END IF; + i := i + need + 1; + END LOOP; + RETURN true; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_bytes_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + b bytea := decode(args[1] ->> 'v', 'base64'); +BEGIN + IF NOT cel._utf8_valid(b) THEN + RETURN cel._err( + 'invalid UTF-8 in bytes, cannot convert to string'); + END IF; + RETURN jsonb_build_object('@t', 'string', 'v', + convert_from(b, 'UTF8')); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_string_to_bytes(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'bytes', 'v', + replace(encode(convert_to(args[1] ->> 'v', 'UTF8'), 'base64'), + E'\n', '')); +$$; + +-- String tests. matches() is Postgres ~ for this milestone +-- (decision 9: all corpus patterns measured to agree with RE2). + +CREATE OR REPLACE FUNCTION cel._f_contains(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + position((args[2] ->> 'v') IN (args[1] ->> 'v')) > 0 + OR args[2] ->> 'v' = ''); +$$; + +CREATE OR REPLACE FUNCTION cel._f_starts_with(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + left(args[1] ->> 'v', length(args[2] ->> 'v')) = args[2] ->> 'v'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_ends_with(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + right(args[1] ->> 'v', length(args[2] ->> 'v')) = args[2] ->> 'v'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_matches(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val((args[1] ->> 'v') ~ (args[2] ->> 'v')); +$$; + +-- Registration. +WITH t AS ( + SELECT + '{"kind":"bool"}'::jsonb AS bool, + '{"kind":"int"}'::jsonb AS int, + '{"kind":"uint"}'::jsonb AS uint, + '{"kind":"double"}'::jsonb AS dbl, + '{"kind":"string"}'::jsonb AS str, + '{"kind":"bytes"}'::jsonb AS byt +) +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +SELECT * FROM ( + SELECT 'int64_to_int64', 'int', false, + jsonb_build_array(t.int), t.int, + 'cel._f_conv_identity(jsonb[])'::regprocedure, 10 FROM t + UNION ALL SELECT 'uint64_to_int64', 'int', false, + jsonb_build_array(t.uint), t.int, + 'cel._f_uint64_to_int64(jsonb[])', 20 FROM t + UNION ALL SELECT 'double_to_int64', 'int', false, + jsonb_build_array(t.dbl), t.int, + 'cel._f_double_to_int64(jsonb[])', 30 FROM t + UNION ALL SELECT 'string_to_int64', 'int', false, + jsonb_build_array(t.str), t.int, + 'cel._f_string_to_int64(jsonb[])', 40 FROM t + + UNION ALL SELECT 'uint64_to_uint64', 'uint', false, + jsonb_build_array(t.uint), t.uint, + 'cel._f_conv_identity(jsonb[])', 10 FROM t + UNION ALL SELECT 'int64_to_uint64', 'uint', false, + jsonb_build_array(t.int), t.uint, + 'cel._f_int64_to_uint64(jsonb[])', 20 FROM t + UNION ALL SELECT 'double_to_uint64', 'uint', false, + jsonb_build_array(t.dbl), t.uint, + 'cel._f_double_to_uint64(jsonb[])', 30 FROM t + UNION ALL SELECT 'string_to_uint64', 'uint', false, + jsonb_build_array(t.str), t.uint, + 'cel._f_string_to_uint64(jsonb[])', 40 FROM t + + UNION ALL SELECT 'double_to_double', 'double', false, + jsonb_build_array(t.dbl), t.dbl, + 'cel._f_conv_identity(jsonb[])', 10 FROM t + UNION ALL SELECT 'int64_to_double', 'double', false, + jsonb_build_array(t.int), t.dbl, + 'cel._f_int64_to_double(jsonb[])', 20 FROM t + UNION ALL SELECT 'uint64_to_double', 'double', false, + jsonb_build_array(t.uint), t.dbl, + 'cel._f_uint64_to_double(jsonb[])', 30 FROM t + UNION ALL SELECT 'string_to_double', 'double', false, + jsonb_build_array(t.str), t.dbl, + 'cel._f_string_to_double(jsonb[])', 40 FROM t + + UNION ALL SELECT 'string_to_string', 'string', false, + jsonb_build_array(t.str), t.str, + 'cel._f_conv_identity(jsonb[])', 10 FROM t + UNION ALL SELECT 'int64_to_string', 'string', false, + jsonb_build_array(t.int), t.str, + 'cel._f_int64_to_string(jsonb[])', 20 FROM t + UNION ALL SELECT 'uint64_to_string', 'string', false, + jsonb_build_array(t.uint), t.str, + 'cel._f_uint64_to_string(jsonb[])', 30 FROM t + UNION ALL SELECT 'double_to_string', 'string', false, + jsonb_build_array(t.dbl), t.str, + 'cel._f_double_to_string(jsonb[])', 40 FROM t + UNION ALL SELECT 'bool_to_string', 'string', false, + jsonb_build_array(t.bool), t.str, + 'cel._f_bool_to_string(jsonb[])', 50 FROM t + UNION ALL SELECT 'bytes_to_string', 'string', false, + jsonb_build_array(t.byt), t.str, + 'cel._f_bytes_to_string(jsonb[])', 60 FROM t + + UNION ALL SELECT 'bool_to_bool', 'bool', false, + jsonb_build_array(t.bool), t.bool, + 'cel._f_conv_identity(jsonb[])', 10 FROM t + UNION ALL SELECT 'string_to_bool', 'bool', false, + jsonb_build_array(t.str), t.bool, + 'cel._f_string_to_bool(jsonb[])', 20 FROM t + + UNION ALL SELECT 'bytes_to_bytes', 'bytes', false, + jsonb_build_array(t.byt), t.byt, + 'cel._f_conv_identity(jsonb[])', 10 FROM t + UNION ALL SELECT 'string_to_bytes', 'bytes', false, + jsonb_build_array(t.str), t.byt, + 'cel._f_string_to_bytes(jsonb[])', 20 FROM t + + UNION ALL SELECT 'contains_string', 'contains', true, + jsonb_build_array(t.str, t.str), t.bool, + 'cel._f_contains(jsonb[])', 10 FROM t + UNION ALL SELECT 'starts_with_string', 'startsWith', true, + jsonb_build_array(t.str, t.str), t.bool, + 'cel._f_starts_with(jsonb[])', 10 FROM t + UNION ALL SELECT 'ends_with_string', 'endsWith', true, + jsonb_build_array(t.str, t.str), t.bool, + 'cel._f_ends_with(jsonb[])', 10 FROM t + UNION ALL SELECT 'matches_string', 'matches', true, + jsonb_build_array(t.str, t.str), t.bool, + 'cel._f_matches(jsonb[])', 10 FROM t + UNION ALL SELECT 'matches', 'matches', false, + jsonb_build_array(t.str, t.str), t.bool, + 'cel._f_matches(jsonb[])', 10 FROM t +) rows(id, fn, member, arg_types, result_type, impl, ordinal) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'standard', 'overload', id FROM cel.overload +ON CONFLICT DO NOTHING; + +COMMIT; From 8f43f27ebc6f90188e32109d9c003252240963c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 19:55:27 -0300 Subject: [PATCH 10/21] Bring CLAUDE.md up to date with the built pipeline The status paragraph still said no parser or checker existed, and the architecture block predated the options parameter. cel.check and cel.eval gained an optional options jsonb (container, extra decls) because unchecked evaluation resolves names at runtime and the conformance corpus's disable_check container cases cannot pass without it; the plain forms stay as wrappers, so this widens the API rather than changing it. --- CLAUDE.md | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b83b3c..b14f496 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,19 +9,21 @@ 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/000_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: core pipeline working, well-known types pending.** The +registry tables (`sql/010_registry.sql`), value representation and +comparison (`020_values.sql`), parser and macro engine +(`030_parse.sql`), type checker (`040_check.sql`), evaluator +(`050_eval.sql`) and standard library (`060_stdlib.sql`) exist and +run the conformance corpus: the core-language files (basic, +comparisons, conversions, logic, integer/fp math, lists, string, +macros, fields, namespace, plumbing, parse) pass except for cases +needing the well-known types. Timestamps/durations, the WKT +wrappers, `Struct`/`Value`/`Any`, unknowns, and every extension +library are still unwritten; sections below describing those are +design, not code. When you implement a piece, replace the +prescriptive wording with what the code actually does — and 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 @@ -97,9 +99,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 ``` @@ -109,6 +112,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. From 5a4d264fac63ea789f0da924d362020d3c50827b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 20:14:18 -0300 Subject: [PATCH 11/21] Add the well-known types Timestamps and durations, the nine wrapper types, Struct, Value, ListValue, Any and NullValue, all registered through the same four tables as the standard library. Timestamp values carry {seconds, nanos, offset} explicitly because Postgres timestamptz is microsecond-precision and CEL is nanosecond; calendar getters run on a wall-clock timestamp derived from the seconds part, with IANA names resolved by Postgres's own tzdata, so nanos never enter a timestamptz. Semantics follow cel-go v0.32.0 (strict RFC 3339 acceptance, the year 0001-9999 seconds range, Go ParseDuration for duration strings, FormatFloat 'f' rendering for string(duration)), except duration.getMilliseconds, where the corpus and cel-java agree on the sub-second component against cel-go's total; the workspace log records that adjudication. Enum constants (NullValue.NULL_VALUE) ride the type registry as an enum map on the row's kind, resolved to int-typed idents by the checker and by a shared type-or-enum lookup in eval, mirroring cel-go's Provider.FindIdent. A third numeric exactness trap surfaced here: numeric division selects a result scale that can drop fractional digits on 20-digit nanosecond totals, so flooring a quotient misplaced the overflow boundary by a nanosecond. Timestamp normalization now uses exact integer div() with a manual floor correction. With this, all fifteen core conformance files pass; the remaining red files are extension libraries and type deduction, owed to later phases. --- sql/040_check.sql | 12 + sql/050_eval.sql | 71 +++- sql/070_wkt.sql | 886 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 956 insertions(+), 13 deletions(-) create mode 100644 sql/070_wkt.sql diff --git a/sql/040_check.sql b/sql/040_check.sql index 71ed875..94a6d42 100644 --- a/sql/040_check.sql +++ b/sql/040_check.sql @@ -1200,6 +1200,18 @@ BEGIN WHERE it.env = ANY (envs) AND it.kind = 'type' AND it.ref = t.name); + -- Enum constants (kind->'enum') are int-typed idents under + -- '.', mirroring cel-go's Provider.FindIdent. + SELECT globals || coalesce(jsonb_object_agg( + t.name || '.' || e.key, '{"kind":"int"}'::jsonb), '{}'::jsonb) + INTO globals + FROM cel.type t + CROSS JOIN LATERAL jsonb_each(t.kind -> 'enum') e + WHERE t.kind ? 'enum' AND EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'type' + AND it.ref = t.name); + IF options ? 'decls' THEN SELECT globals || coalesce(jsonb_object_agg( d ->> 'name', d -> 'type'), '{}'::jsonb) diff --git a/sql/050_eval.sql b/sql/050_eval.sql index d5903dd..03ec811 100644 --- a/sql/050_eval.sql +++ b/sql/050_eval.sql @@ -226,6 +226,49 @@ BEGIN END; $$; +-- Resolves a dotted name as a registered type identifier (-> a type +-- value) or a registered enum constant (-> its tagged value, e.g. +-- google.protobuf.NullValue.NULL_VALUE -> int 0, mirroring cel-go's +-- Provider.FindIdent). NULL when the name is neither. +CREATE OR REPLACE FUNCTION cel._type_or_enum( + name text, absolute boolean, envs text[], ctr text +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + r jsonb; +BEGIN + SELECT jsonb_build_object('@t', 'type', 'v', c) INTO r + FROM unnest(cel._name_candidates(name, absolute, ctr)) AS c + WHERE EXISTS ( + SELECT FROM cel.type t + WHERE t.name = c AND EXISTS ( + SELECT FROM cel.env_item i2 + WHERE i2.env = ANY (envs) AND i2.kind = 'type' + AND i2.ref = t.name)) + LIMIT 1; + IF r IS NOT NULL THEN + RETURN r; + END IF; + SELECT jsonb_build_object('@t', 'int', 'v', e.value::bigint) + INTO r + FROM unnest(cel._name_candidates(name, absolute, ctr)) AS c + JOIN cel.type t + ON t.kind ? 'enum' AND c LIKE t.name || '.%' + JOIN LATERAL jsonb_each_text(t.kind -> 'enum') e ON true + WHERE t.name || '.' || e.key = c + AND EXISTS ( + SELECT FROM cel.env_item i2 + WHERE i2.env = ANY (envs) AND i2.kind = 'type' + AND i2.ref = t.name) + LIMIT 1; + RETURN r; +END; +$$; + -- Field selection on an already-evaluated value. CREATE OR REPLACE FUNCTION cel._sel_field(v jsonb, field text, nid bigint) RETURNS jsonb @@ -299,19 +342,12 @@ BEGIN IF res.val IS NOT NULL THEN RETURN res.val; END IF; - -- Registered type names are identifiers of type type(T). - SELECT c INTO nm - FROM unnest(cel._name_candidates( - ltrim(node ->> 'name', '.'), chain.absolute, ctr)) AS c - WHERE EXISTS ( - SELECT FROM cel.type t - WHERE t.name = c AND EXISTS ( - SELECT FROM cel.env_item i2 - WHERE i2.env = ANY (envs) AND i2.kind = 'type' - AND i2.ref = t.name)) - LIMIT 1; - IF nm IS NOT NULL THEN - RETURN jsonb_build_object('@t', 'type', 'v', nm); + -- Registered type names are identifiers of type type(T); enum + -- constants resolve to their values. + v := cel._type_or_enum( + ltrim(node ->> 'name', '.'), chain.absolute, envs, ctr); + IF v IS NOT NULL THEN + RETURN v; END IF; RETURN cel._err(format('no such attribute: %s', ltrim(node ->> 'name', '.')), nid); @@ -344,6 +380,15 @@ BEGIN END LOOP; RETURN v; END IF; + -- A fully-qualified type name or enum constant written as a + -- select chain (unchecked ASTs only; the checker rewrites + -- these to qualified idents). + v := cel._type_or_enum( + array_to_string(chain.parts, '.'), chain.absolute, envs, + ctr); + IF v IS NOT NULL THEN + RETURN v; + END IF; END IF; v := cel._ev(node -> 'op', scopes, envs, ctr, d + 1); diff --git a/sql/070_wkt.sql b/sql/070_wkt.sql new file mode 100644 index 0000000..0009c10 --- /dev/null +++ b/sql/070_wkt.sql @@ -0,0 +1,886 @@ +-- Well-known types: timestamps, durations, the wrapper types, +-- Struct/Value/ListValue, Any and NullValue (workspace doc 07, +-- decision 3). Everything registers through the same four tables the +-- standard library uses; nothing here is a core patch. +-- +-- Timestamp values are {"s": epoch seconds, "n": nanos 0..1e9-1, +-- "tz": fixed offset minutes}; durations are total nanoseconds +-- (doc 02). Semantics are cel-go v0.32.0's (common/types/ +-- timestamp.go, duration.go, overflow.go), confirmed by conformance +-- runs -- see the workspace measurements log. + +BEGIN; + +-- Range-checked constructors ------------------------------------------ + +-- Seconds range is year 0001..9999 (timestamp.go:54-56); outside it +-- construction and arithmetic yield 'timestamp overflow' +-- (overflow.go:239). +CREATE OR REPLACE FUNCTION cel._ts_val( + s numeric, n numeric, tzm int, id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN s < -62135596800 OR s > 253402300799 + THEN cel._err('timestamp overflow', id) + ELSE jsonb_build_object('@t', 'timestamp', 'v', + jsonb_build_object('s', to_jsonb(s), 'n', to_jsonb(n), + 'tz', to_jsonb(tzm))) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._dur_val(ns numeric, id bigint DEFAULT NULL) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN ns < -9223372036854775808::numeric + OR ns > 9223372036854775807::numeric + THEN cel._err('integer overflow', id) + ELSE jsonb_build_object('@t', 'duration', 'v', to_jsonb(ns)) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._ts_ns(v jsonb) +RETURNS numeric +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT (v -> 'v' ->> 's')::numeric * 1000000000 + + (v -> 'v' ->> 'n')::numeric; +$$; + +-- Builds a timestamp from total nanoseconds, flooring so nanos stay +-- in [0, 1e9). div() (truncating integer division) then a manual +-- floor correction: numeric '/' selects a result scale that can drop +-- fractional digits on 20-digit quotients, so it cannot be trusted +-- here. +CREATE OR REPLACE FUNCTION cel._ts_of_ns( + total numeric, tzm int, id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s numeric := div(total, 1000000000); + n numeric; +BEGIN + n := total - s * 1000000000; + IF n < 0 THEN + s := s - 1; + n := n + 1000000000; + END IF; + RETURN cel._ts_val(s, n, tzm, id); +END; +$$; + +-- Conversions ---------------------------------------------------------- + +-- Strict RFC 3339 (timestamp.go isStrictRFC3339 + Go time.Parse): +-- fixed-width date-time, 'T'/'t' separator, optional fraction, +-- 'Z'/'z' or +-HH:MM offset. Go's parser rejects leap seconds and +-- impossible dates. +CREATE OR REPLACE FUNCTION cel._f_string_to_timestamp(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + str text := args[1] ->> 'v'; + m text[]; + y int; mo int; dd int; hh int; mi int; ss int; + n numeric := 0; + tzm int := 0; + days numeric; +BEGIN + m := regexp_match(str, + '^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})' || + '(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$'); + IF m IS NULL THEN + RETURN cel._err( + format('invalid RFC 3339 timestamp %s', quote_literal(str))); + END IF; + y := m[1]::int; mo := m[2]::int; dd := m[3]::int; + hh := m[4]::int; mi := m[5]::int; ss := m[6]::int; + IF y < 1 OR mo < 1 OR mo > 12 OR hh > 23 OR mi > 59 OR ss > 59 + OR dd < 1 + OR dd > extract(day FROM + (make_date(y, mo, 1) + interval '1 month - 1 day')) + THEN + RETURN cel._err( + format('invalid RFC 3339 timestamp %s', quote_literal(str))); + END IF; + IF m[7] IS NOT NULL THEN + n := rpad(substr(substr(m[7], 2), 1, 9), 9, '0')::numeric; + END IF; + IF lower(m[8]) <> 'z' THEN + hh := NULL; -- reuse below is confusing; parse offset afresh + tzm := substr(m[8], 2, 2)::int * 60 + substr(m[8], 5, 2)::int; + IF left(m[8], 1) = '-' THEN + tzm := -tzm; + END IF; + IF abs(tzm) > 23 * 60 + 59 THEN + RETURN cel._err( + format('invalid RFC 3339 timestamp %s', quote_literal(str))); + END IF; + END IF; + days := (make_date(y, mo, dd) - date '1970-01-01')::numeric; + RETURN cel._ts_val( + days * 86400 + m[4]::int * 3600 + mi * 60 + ss - tzm * 60, + n, tzm); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_int_to_timestamp(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._ts_val((args[1] ->> 'v')::numeric, 0, 0); +$$; + +CREATE OR REPLACE FUNCTION cel._f_timestamp_to_int(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val((args[1] -> 'v' ->> 's')::numeric); +$$; + +-- RFC3339Nano in the value's own offset: fraction with trailing +-- zeros trimmed, 'Z' for a zero offset (timestamp.go:190). +CREATE OR REPLACE FUNCTION cel._f_timestamp_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s numeric := (args[1] -> 'v' ->> 's')::numeric; + n int := (args[1] -> 'v' ->> 'n')::numeric; + tzm int := coalesce((args[1] -> 'v' ->> 'tz')::int, 0); + wall numeric := s + tzm * 60; + days int := floor(wall / 86400); + rem int; + frac text := ''; + off text; +BEGIN + rem := wall - days::numeric * 86400; + IF n > 0 THEN + frac := '.' || rtrim(lpad(n::text, 9, '0'), '0'); + END IF; + IF tzm = 0 THEN + off := 'Z'; + ELSE + off := CASE WHEN tzm < 0 THEN '-' ELSE '+' END + || lpad((abs(tzm) / 60)::text, 2, '0') || ':' + || lpad((abs(tzm) % 60)::text, 2, '0'); + END IF; + RETURN jsonb_build_object('@t', 'string', 'v', + to_char(date '1970-01-01' + days, 'YYYY-MM-DD') || 'T' + || lpad((rem / 3600)::text, 2, '0') || ':' + || lpad(((rem / 60) % 60)::text, 2, '0') || ':' + || lpad((rem % 60)::text, 2, '0') || frac || off); +END; +$$; + +-- Go time.ParseDuration: signed sequence of decimal numbers with +-- units ns/us/µs/μs/ms/s/m/h; bare "0" allowed. +CREATE OR REPLACE FUNCTION cel._f_string_to_duration(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + str text := args[1] ->> 'v'; + s text := str; + neg boolean := false; + total numeric := 0; + m text[]; +BEGIN + IF s ~ '^[+-]' THEN + neg := left(s, 1) = '-'; + s := substr(s, 2); + END IF; + IF s = '0' THEN + RETURN cel._dur_val(0); + END IF; + IF s = '' THEN + RETURN cel._err( + format('invalid duration %s', quote_literal(str))); + END IF; + WHILE s <> '' LOOP + m := regexp_match(s, + '^(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h)(.*)$'); + IF m IS NULL THEN + RETURN cel._err( + format('invalid duration %s', quote_literal(str))); + END IF; + total := total + trunc(m[1]::numeric * CASE m[2] + WHEN 'ns' THEN 1 + WHEN 'ms' THEN 1000000 + WHEN 's' THEN 1000000000 + WHEN 'm' THEN 60000000000 + WHEN 'h' THEN 3600000000000 + ELSE 1000 -- us / µs / μs + END::numeric); + s := m[3]; + END LOOP; + RETURN cel._dur_val(CASE WHEN neg THEN -total ELSE total END); +END; +$$; + +-- Renders scientific notation as plain decimal, for Go's +-- FormatFloat(f, 'f', -1, 64): same shortest digits as %g, fixed +-- rendering. +CREATE OR REPLACE FUNCTION cel._sci_to_plain(t text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + m text[]; + digits text; + p int; +BEGIN + m := regexp_match(t, '^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$'); + IF m IS NULL THEN + RETURN t; + END IF; + digits := m[2] || coalesce(m[3], ''); + p := length(m[2]) + m[4]::int; + IF p <= 0 THEN + RETURN m[1] || '0.' || repeat('0', -p) || digits; + ELSIF p >= length(digits) THEN + RETURN m[1] || digits || repeat('0', p - length(digits)); + END IF; + RETURN m[1] || substr(digits, 1, p) || '.' || substr(digits, p + 1); +END; +$$; + +-- duration.go:125: FormatFloat(d.Seconds(), 'f', -1, 64) + "s", +-- where Seconds() = float64(sec) + float64(nsec)/1e9. +CREATE OR REPLACE FUNCTION cel._f_duration_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + ns numeric := (args[1] ->> 'v')::numeric; + sec float8; +BEGIN + sec := trunc(ns / 1000000000)::float8 + + (ns - trunc(ns / 1000000000) * 1000000000)::float8 / 1e9; + RETURN jsonb_build_object('@t', 'string', 'v', + cel._sci_to_plain(cel._double_text(sec)) || 's'); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_duration_to_int(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val((args[1] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_identity(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT args[1]; +$$; + +-- Arithmetic (overflow.go:236-260) ------------------------------------ + +CREATE OR REPLACE FUNCTION cel._f_add_ts_dur(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._ts_of_ns( + cel._ts_ns(args[1]) + (args[2] ->> 'v')::numeric, + coalesce((args[1] -> 'v' ->> 'tz')::int, 0), NULL); +$$; + +CREATE OR REPLACE FUNCTION cel._f_add_dur_ts(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._ts_of_ns( + cel._ts_ns(args[2]) + (args[1] ->> 'v')::numeric, + coalesce((args[2] -> 'v' ->> 'tz')::int, 0), NULL); +$$; + +CREATE OR REPLACE FUNCTION cel._f_add_dur_dur(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dur_val( + (args[1] ->> 'v')::numeric + (args[2] ->> 'v')::numeric); +$$; + +CREATE OR REPLACE FUNCTION cel._f_sub_ts_ts(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dur_val(cel._ts_ns(args[1]) - cel._ts_ns(args[2])); +$$; + +CREATE OR REPLACE FUNCTION cel._f_sub_ts_dur(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._ts_of_ns( + cel._ts_ns(args[1]) - (args[2] ->> 'v')::numeric, + coalesce((args[1] -> 'v' ->> 'tz')::int, 0), NULL); +$$; + +CREATE OR REPLACE FUNCTION cel._f_sub_dur_dur(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dur_val( + (args[1] ->> 'v')::numeric - (args[2] ->> 'v')::numeric); +$$; + +-- Getters -------------------------------------------------------------- + +-- Wall-clock timestamp for a value under an optional tz override +-- (timestamp.go timeZone): NULL -> the value's own fixed offset; a +-- string with ':' -> +-H:MM fixed offset; otherwise an IANA name +-- resolved by Postgres's tzdata. +CREATE OR REPLACE FUNCTION cel._ts_wall( + v jsonb, tz text, OUT wall timestamp, OUT err jsonb +) +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s numeric := (v -> 'v' ->> 's')::numeric; + offm int; + hr int; + mi int; +BEGIN + IF tz IS NULL THEN + offm := coalesce((v -> 'v' ->> 'tz')::int, 0); + ELSIF position(':' IN tz) = 0 THEN + BEGIN + wall := to_timestamp(s::float8) AT TIME ZONE tz; + RETURN; + EXCEPTION WHEN OTHERS THEN + err := cel._err(format('unknown time zone %s', + quote_literal(tz))); + RETURN; + END; + ELSE + BEGIN + hr := split_part(tz, ':', 1)::int; + mi := split_part(tz, ':', 2)::int; + EXCEPTION WHEN OTHERS THEN + err := cel._err(format('invalid timezone %s', + quote_literal(tz))); + RETURN; + END; + IF hr < -23 OR hr > 23 OR mi < 0 OR mi > 59 THEN + err := cel._err(format( + 'timezone offset out of range: %s', tz)); + RETURN; + END IF; + offm := CASE WHEN left(tz, 1) = '-' THEN hr * 60 - mi + ELSE hr * 60 + mi END; + END IF; + wall := timestamp '1970-01-01' + + make_interval(secs => (s + offm * 60)::float8); +END; +$$; + +-- One impl per getter; a two-element args array carries the tz +-- override, so each impl serves both the 0- and 1-arg overloads. +CREATE OR REPLACE FUNCTION cel._ts_get(args jsonb[], part text) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + w record; +BEGIN + IF part = 'milliseconds' THEN + RETURN cel._int_val( + trunc((args[1] -> 'v' ->> 'n')::numeric / 1000000)); + END IF; + SELECT * INTO w FROM cel._ts_wall(args[1], + CASE WHEN cardinality(args) > 1 THEN args[2] ->> 'v' END); + IF w.err IS NOT NULL THEN + RETURN w.err; + END IF; + RETURN cel._int_val(CASE part + WHEN 'year' THEN extract(year FROM w.wall) + WHEN 'month' THEN extract(month FROM w.wall) - 1 + WHEN 'day_of_year' THEN extract(doy FROM w.wall) - 1 + WHEN 'day_of_month' THEN extract(day FROM w.wall) - 1 + WHEN 'date' THEN extract(day FROM w.wall) + WHEN 'day_of_week' THEN extract(dow FROM w.wall) + WHEN 'hours' THEN extract(hour FROM w.wall) + WHEN 'minutes' THEN extract(minute FROM w.wall) + WHEN 'seconds' THEN floor(extract(second FROM w.wall)) + END); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_ts_year(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'year') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_month(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'month') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_doy(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'day_of_year') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_dom0(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'day_of_month') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_dom1(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'date') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_dow(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'day_of_week') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_hours(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'hours') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_minutes(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'minutes') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_seconds(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'seconds') $$; +CREATE OR REPLACE FUNCTION cel._f_ts_ms(args jsonb[]) +RETURNS jsonb LANGUAGE sql STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._ts_get(args, 'milliseconds') $$; + +-- Duration getters are truncated totals, except getMilliseconds, +-- which is the sub-second component: the corpus and cel-java agree +-- against cel-go v0.32.0 here (workspace ISSUES R2 adjudication). +CREATE OR REPLACE FUNCTION cel._f_dur_hours(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val( + trunc((args[1] ->> 'v')::numeric / 3600000000000)); +$$; +CREATE OR REPLACE FUNCTION cel._f_dur_minutes(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val( + trunc((args[1] ->> 'v')::numeric / 60000000000)); +$$; +CREATE OR REPLACE FUNCTION cel._f_dur_seconds(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val( + trunc((args[1] ->> 'v')::numeric / 1000000000)); +$$; +CREATE OR REPLACE FUNCTION cel._f_dur_ms(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val( + trunc((args[1] ->> 'v')::numeric / 1000000) % 1000); +$$; + +COMMIT; + +BEGIN; + +-- Construction impls --------------------------------------------------- +-- Each receives the evaluated fields as a jsonb object of tagged +-- values (050_eval.sql struct branch). Wrappers unwrap to their +-- primitive; an unset field takes the proto3 default. + +CREATE OR REPLACE FUNCTION cel._wkt_bool(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'value', + '{"@t": "bool", "v": false}'::jsonb); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_int(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'value', '{"@t": "int", "v": 0}'::jsonb); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_uint(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'value', '{"@t": "uint", "v": 0}'::jsonb); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_double(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'value', + '{"@t": "double", "v": 0}'::jsonb); +$$; + +-- FloatValue narrows to float32 (dynamic/float/literal_not_double). +CREATE OR REPLACE FUNCTION cel._wkt_float(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN fields ? 'value' + THEN cel._dbl_val( + ((fields -> 'value' ->> 'v')::float8::float4)::float8) + ELSE '{"@t": "double", "v": 0}'::jsonb + END; +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_string(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'value', + '{"@t": "string", "v": ""}'::jsonb); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_bytes(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'value', + '{"@t": "bytes", "v": ""}'::jsonb); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_struct(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'fields', + '{"@t": "map", "v": []}'::jsonb); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_listvalue(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT coalesce(fields -> 'values', + '{"@t": "list", "v": []}'::jsonb); +$$; + +-- google.protobuf.Value: whichever field is set decides the JSON +-- kind; unset means null. +CREATE OR REPLACE FUNCTION cel._wkt_value(fields jsonb) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +BEGIN + IF fields ? 'number_value' THEN + RETURN jsonb_build_object('@t', 'double', 'v', + fields -> 'number_value' -> 'v'); + ELSIF fields ? 'string_value' THEN + RETURN fields -> 'string_value'; + ELSIF fields ? 'bool_value' THEN + RETURN fields -> 'bool_value'; + ELSIF fields ? 'struct_value' THEN + RETURN fields -> 'struct_value'; + ELSIF fields ? 'list_value' THEN + RETURN fields -> 'list_value'; + END IF; + RETURN '{"@t": "null", "v": null}'::jsonb; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_timestamp(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._ts_val( + coalesce((fields -> 'seconds' ->> 'v')::numeric, 0), + coalesce((fields -> 'nanos' ->> 'v')::numeric, 0), 0); +$$; + +CREATE OR REPLACE FUNCTION cel._wkt_duration(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dur_val( + coalesce((fields -> 'seconds' ->> 'v')::numeric, 0) * 1000000000 + + coalesce((fields -> 'nanos' ->> 'v')::numeric, 0)); +$$; + +-- Any needs a descriptor pool to pack; its row exists so the name +-- resolves (workspace doc 07). +CREATE OR REPLACE FUNCTION cel._wkt_any(fields jsonb) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._err( + 'cannot construct google.protobuf.Any without descriptors'); +$$; + +-- Registry rows -------------------------------------------------------- + +INSERT INTO cel.type (name, kind, construct) VALUES + ('google.protobuf.Timestamp', '{"kind": "timestamp"}', + 'cel._wkt_timestamp(jsonb)'), + ('google.protobuf.Duration', '{"kind": "duration"}', + 'cel._wkt_duration(jsonb)'), + ('google.protobuf.BoolValue', + '{"kind": "wrapper", "params": [{"kind": "bool"}]}', + 'cel._wkt_bool(jsonb)'), + ('google.protobuf.Int32Value', + '{"kind": "wrapper", "params": [{"kind": "int"}]}', + 'cel._wkt_int(jsonb)'), + ('google.protobuf.Int64Value', + '{"kind": "wrapper", "params": [{"kind": "int"}]}', + 'cel._wkt_int(jsonb)'), + ('google.protobuf.UInt32Value', + '{"kind": "wrapper", "params": [{"kind": "uint"}]}', + 'cel._wkt_uint(jsonb)'), + ('google.protobuf.UInt64Value', + '{"kind": "wrapper", "params": [{"kind": "uint"}]}', + 'cel._wkt_uint(jsonb)'), + ('google.protobuf.FloatValue', + '{"kind": "wrapper", "params": [{"kind": "double"}]}', + 'cel._wkt_float(jsonb)'), + ('google.protobuf.DoubleValue', + '{"kind": "wrapper", "params": [{"kind": "double"}]}', + 'cel._wkt_double(jsonb)'), + ('google.protobuf.StringValue', + '{"kind": "wrapper", "params": [{"kind": "string"}]}', + 'cel._wkt_string(jsonb)'), + ('google.protobuf.BytesValue', + '{"kind": "wrapper", "params": [{"kind": "bytes"}]}', + 'cel._wkt_bytes(jsonb)'), + ('google.protobuf.Struct', + '{"kind": "map", "params": [{"kind": "string"}, {"kind": "dyn"}]}', + 'cel._wkt_struct(jsonb)'), + ('google.protobuf.Value', '{"kind": "dyn"}', + 'cel._wkt_value(jsonb)'), + ('google.protobuf.ListValue', + '{"kind": "list", "params": [{"kind": "dyn"}]}', + 'cel._wkt_listvalue(jsonb)'), + ('google.protobuf.Any', '{"kind": "any"}', 'cel._wkt_any(jsonb)'), + ('google.protobuf.NullValue', + '{"kind": "int", "enum": {"NULL_VALUE": 0}}', NULL) +ON CONFLICT (name) DO UPDATE SET + kind = excluded.kind, + construct = excluded.construct; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + -- arithmetic (cel-go standard.go declaration order) + ('add_duration_duration', '_+_', false, + '[{"kind": "duration"}, {"kind": "duration"}]', + '{"kind": "duration"}', 'cel._f_add_dur_dur(jsonb[])', 70), + ('add_duration_timestamp', '_+_', false, + '[{"kind": "duration"}, {"kind": "timestamp"}]', + '{"kind": "timestamp"}', 'cel._f_add_dur_ts(jsonb[])', 80), + ('add_timestamp_duration', '_+_', false, + '[{"kind": "timestamp"}, {"kind": "duration"}]', + '{"kind": "timestamp"}', 'cel._f_add_ts_dur(jsonb[])', 90), + ('subtract_duration_duration', '_-_', false, + '[{"kind": "duration"}, {"kind": "duration"}]', + '{"kind": "duration"}', 'cel._f_sub_dur_dur(jsonb[])', 40), + ('subtract_timestamp_duration', '_-_', false, + '[{"kind": "timestamp"}, {"kind": "duration"}]', + '{"kind": "timestamp"}', 'cel._f_sub_ts_dur(jsonb[])', 50), + ('subtract_timestamp_timestamp', '_-_', false, + '[{"kind": "timestamp"}, {"kind": "timestamp"}]', + '{"kind": "duration"}', 'cel._f_sub_ts_ts(jsonb[])', 60), + -- relations + ('less_timestamp', '_<_', false, + '[{"kind": "timestamp"}, {"kind": "timestamp"}]', + '{"kind": "bool"}', 'cel._f_lt(jsonb[])', 130), + ('less_duration', '_<_', false, + '[{"kind": "duration"}, {"kind": "duration"}]', + '{"kind": "bool"}', 'cel._f_lt(jsonb[])', 140), + ('less_equals_timestamp', '_<=_', false, + '[{"kind": "timestamp"}, {"kind": "timestamp"}]', + '{"kind": "bool"}', 'cel._f_le(jsonb[])', 130), + ('less_equals_duration', '_<=_', false, + '[{"kind": "duration"}, {"kind": "duration"}]', + '{"kind": "bool"}', 'cel._f_le(jsonb[])', 140), + ('greater_timestamp', '_>_', false, + '[{"kind": "timestamp"}, {"kind": "timestamp"}]', + '{"kind": "bool"}', 'cel._f_gt(jsonb[])', 130), + ('greater_duration', '_>_', false, + '[{"kind": "duration"}, {"kind": "duration"}]', + '{"kind": "bool"}', 'cel._f_gt(jsonb[])', 140), + ('greater_equals_timestamp', '_>=_', false, + '[{"kind": "timestamp"}, {"kind": "timestamp"}]', + '{"kind": "bool"}', 'cel._f_ge(jsonb[])', 130), + ('greater_equals_duration', '_>=_', false, + '[{"kind": "duration"}, {"kind": "duration"}]', + '{"kind": "bool"}', 'cel._f_ge(jsonb[])', 140), + -- conversions + ('duration_to_int64', 'int', false, + '[{"kind": "duration"}]', '{"kind": "int"}', + 'cel._f_duration_to_int(jsonb[])', 50), + ('timestamp_to_int64', 'int', false, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_timestamp_to_int(jsonb[])', 60), + ('duration_to_string', 'string', false, + '[{"kind": "duration"}]', '{"kind": "string"}', + 'cel._f_duration_to_string(jsonb[])', 70), + ('timestamp_to_string', 'string', false, + '[{"kind": "timestamp"}]', '{"kind": "string"}', + 'cel._f_timestamp_to_string(jsonb[])', 80), + ('timestamp_to_timestamp', 'timestamp', false, + '[{"kind": "timestamp"}]', '{"kind": "timestamp"}', + 'cel._f_identity(jsonb[])', 10), + ('int64_to_timestamp', 'timestamp', false, + '[{"kind": "int"}]', '{"kind": "timestamp"}', + 'cel._f_int_to_timestamp(jsonb[])', 20), + ('string_to_timestamp', 'timestamp', false, + '[{"kind": "string"}]', '{"kind": "timestamp"}', + 'cel._f_string_to_timestamp(jsonb[])', 30), + ('duration_to_duration', 'duration', false, + '[{"kind": "duration"}]', '{"kind": "duration"}', + 'cel._f_identity(jsonb[])', 10), + ('string_to_duration', 'duration', false, + '[{"kind": "string"}]', '{"kind": "duration"}', + 'cel._f_string_to_duration(jsonb[])', 20), + -- timestamp getters + ('timestamp_to_year', 'getFullYear', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_year(jsonb[])', 10), + ('timestamp_to_year_with_tz', 'getFullYear', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_year(jsonb[])', 20), + ('timestamp_to_month', 'getMonth', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_month(jsonb[])', 10), + ('timestamp_to_month_with_tz', 'getMonth', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_month(jsonb[])', 20), + ('timestamp_to_day_of_year', 'getDayOfYear', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_doy(jsonb[])', 10), + ('timestamp_to_day_of_year_with_tz', 'getDayOfYear', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_doy(jsonb[])', 20), + ('timestamp_to_day_of_month', 'getDayOfMonth', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_dom0(jsonb[])', 10), + ('timestamp_to_day_of_month_with_tz', 'getDayOfMonth', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_dom0(jsonb[])', 20), + ('timestamp_to_day_of_month_1_based', 'getDate', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_dom1(jsonb[])', 10), + ('timestamp_to_day_of_month_1_based_with_tz', 'getDate', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_dom1(jsonb[])', 20), + ('timestamp_to_day_of_week', 'getDayOfWeek', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_dow(jsonb[])', 10), + ('timestamp_to_day_of_week_with_tz', 'getDayOfWeek', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_dow(jsonb[])', 20), + ('timestamp_to_hours', 'getHours', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_hours(jsonb[])', 10), + ('timestamp_to_hours_with_tz', 'getHours', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_hours(jsonb[])', 20), + ('timestamp_to_minutes', 'getMinutes', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_minutes(jsonb[])', 10), + ('timestamp_to_minutes_with_tz', 'getMinutes', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_minutes(jsonb[])', 20), + ('timestamp_to_seconds', 'getSeconds', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_seconds(jsonb[])', 10), + ('timestamp_to_seconds_tz', 'getSeconds', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_seconds(jsonb[])', 20), + ('timestamp_to_milliseconds', 'getMilliseconds', true, + '[{"kind": "timestamp"}]', '{"kind": "int"}', + 'cel._f_ts_ms(jsonb[])', 10), + ('timestamp_to_milliseconds_with_tz', 'getMilliseconds', true, + '[{"kind": "timestamp"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_ts_ms(jsonb[])', 20), + -- duration getters + ('duration_to_hours', 'getHours', true, + '[{"kind": "duration"}]', '{"kind": "int"}', + 'cel._f_dur_hours(jsonb[])', 30), + ('duration_to_minutes', 'getMinutes', true, + '[{"kind": "duration"}]', '{"kind": "int"}', + 'cel._f_dur_minutes(jsonb[])', 30), + ('duration_to_seconds', 'getSeconds', true, + '[{"kind": "duration"}]', '{"kind": "int"}', + 'cel._f_dur_seconds(jsonb[])', 30), + ('duration_to_milliseconds', 'getMilliseconds', true, + '[{"kind": "duration"}]', '{"kind": "int"}', + 'cel._f_dur_ms(jsonb[])', 30) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'standard', 'type', name FROM cel.type +WHERE name LIKE 'google.protobuf.%' +ON CONFLICT DO NOTHING; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'standard', 'overload', id FROM cel.overload +ON CONFLICT DO NOTHING; + +COMMIT; From 0d1457b6362f68a39aba14f1caecef2c48617be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 20:14:36 -0300 Subject: [PATCH 12/21] Move the wrapper types into scope The v1 scope excluded Int32Value and family on the assumption they need a descriptor pool. Implementing the well-known types showed they do not: wrappers are JSON-shaped and construct through registered type rows like Struct or Value, and the comparisons and dynamic conformance files exercise them directly. The owner approved the change when the evaluator plan was drawn up (workspace decision 3); it lands with sql/070_wkt.sql, which implements them. --- CLAUDE.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b14f496..f44134a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,11 +42,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`, From 0c574bb3088cd178f8e7be968e072ba5aa9489a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 20:28:36 -0300 Subject: [PATCH 13/21] Add two-var comprehensions and close out the core Three pieces finish the core milestone. The two-variable comprehension macros (all/exists/existsOne/transformList over index-and-value or key-and-value pairs) register through the macro table and reuse the one-variable fold shapes where cel-go's do; the map transforms fold through cel.@mapInsert, whose insert-on-existing -key error comes with it. The evaluator's comprehension loop already carried the second variable, so the whole of macros2 went green on registration alone. Function declarations in a conformance case's type_env become caller-scoped overloads: the runner passes them through check options and the checker consults them after the registry rows, which is what the type_deduction functions and type_parameters sections exercise. The optionals extension lands as its declaration surface (of/ofNonZeroValue/none/value/hasValue and the optional_type row); the syntax sugar and remaining functions belong to the extension phase. One checker behaviour was adjudicated corpus-first against both reference implementations: joining null with a nullable type keeps the nullable type. cel-go answers null and skips those corpus cases in its own build as known-wrong; cel-java's checker is the same algorithm. The corpus describes the intended fix, so it wins; the workspace log records the reasoning. Unknown propagation, which the corpus cannot cover (its unknowns file is an empty stub), gets its own suite: 24 cases diffed against cel-go partial evaluation, agreeing on absorption in the logic operators (including unknown beating error), conditionals, strict propagation, containers and comprehension folds. Extension scripts live at the top of sql/ with a 1xx prefix rather than the planned sql/ext/ subdirectory: initdb executes only the mounted directory's top level, so a subdirectory would silently not install. --- conformance/envs.go | 4 + conformance/unknowns_test.go | 182 +++++++++++++++++ internal/codec/type.go | 42 +++- internal/oracle/oracle.go | 42 ++++ sql/040_check.sql | 62 ++++-- sql/100_ext_comprehensions.sql | 359 +++++++++++++++++++++++++++++++++ sql/110_ext_optionals.sql | 157 ++++++++++++++ 7 files changed, 829 insertions(+), 19 deletions(-) create mode 100644 conformance/unknowns_test.go create mode 100644 sql/100_ext_comprehensions.sql create mode 100644 sql/110_ext_optionals.sql diff --git a/conformance/envs.go b/conformance/envs.go index 7cde75d..bf5aeef 100644 --- a/conformance/envs.go +++ b/conformance/envs.go @@ -19,6 +19,10 @@ var fileEnvs = map[string]string{ "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. diff --git a/conformance/unknowns_test.go b/conformance/unknowns_test.go new file mode 100644 index 0000000..4992057 --- /dev/null +++ b/conformance/unknowns_test.go @@ -0,0 +1,182 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/common/types" + + "github.com/emfga/cel4postgres/internal/codec" + "github.com/emfga/cel4postgres/internal/oracle" + "github.com/emfga/cel4postgres/internal/testdb" +) + +// TestUnknownPropagation is the coverage for day-one invariant 4 that +// the conformance corpus cannot provide: unknowns.textproto is an +// empty stub, so unknown propagation is measured directly against +// cel-go partial evaluation (oracle.EvalPartial). Each case runs both +// evaluators; outcomes must agree in class (unknown / error / value), +// and concrete scalar values must agree exactly. +// +// Our side represents an unknown input as the tagged unknown value +// {"@t": "unknown", "v": []}; cel-go derives unknowns from +// declared-but-unbound variables under a partial activation. Unknown +// ids are not comparable across the two (cel-go tracks attribute +// trails, we track expression ids), so agreement is asserted on the +// outcome class, which is what the semantics prescribe. +func TestUnknownPropagation(t *testing.T) { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + + cases := []struct { + name string + expr string + known map[string]any + vars []string // every declared variable, known or not + }{ + // Commutative absorption across && and ||. + {"and_absorbs_unknown_left", "x && false", nil, []string{"x"}}, + {"and_absorbs_unknown_right", "false && x", nil, []string{"x"}}, + {"and_keeps_unknown", "x && true", nil, []string{"x"}}, + {"or_absorbs_unknown_left", "x || true", nil, []string{"x"}}, + {"or_absorbs_unknown_right", "true || x", nil, []string{"x"}}, + {"or_keeps_unknown", "x || false", nil, []string{"x"}}, + // Error and unknown interaction: unknown wins over error in + // logic operators. + {"and_error_and_unknown", "(1 / 0 == 1) && x", nil, + []string{"x"}}, + {"or_error_and_unknown", "(1 / 0 == 1) || x", nil, + []string{"x"}}, + // Conditionals: only the taken branch matters. + {"cond_unknown_condition", "x ? 1 : 2", nil, []string{"x"}}, + {"cond_unknown_taken", "true ? x : 2", nil, []string{"x"}}, + {"cond_unknown_not_taken", "true ? 1 : x", nil, []string{"x"}}, + // Strict functions and operators propagate. + {"add_propagates", "x + 1", nil, []string{"x"}}, + {"eq_propagates", "x == 1", nil, []string{"x"}}, + {"conversion_propagates", "string(x)", nil, []string{"x"}}, + // Containers carry unknowns per element. + {"list_index_hits_unknown", "[x, 1][0]", nil, []string{"x"}}, + {"list_index_misses_unknown", "[x, 1][1]", nil, []string{"x"}}, + {"map_value_unknown", "{'a': x}['a']", nil, []string{"x"}}, + // Merging: two distinct unknowns meet. + {"merge_two_unknowns", "x == y", nil, []string{"x", "y"}}, + // Mixed known/unknown activation. + {"known_beside_unknown", "x + y", + map[string]any{"y": int64(2)}, []string{"x", "y"}}, + // Comprehensions: absorption inside the fold. + {"exists_absorbs_unknown", "[1, x].exists(i, i == 1)", nil, + []string{"x"}}, + {"exists_keeps_unknown", "[1, x].exists(i, i == 2)", nil, + []string{"x"}}, + {"all_keeps_unknown", "[1, x].all(i, i == 1)", nil, + []string{"x"}}, + {"all_absorbs_unknown", "[2, x].all(i, i == 1)", nil, + []string{"x"}}, + {"range_unknown", "x.all(i, i > 0)", nil, []string{"x"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + options := make([]cel.EnvOption, 0, len(tc.vars)) + for _, v := range tc.vars { + options = append(options, cel.Variable(v, cel.DynType)) + } + refVal, refErr := oracle.EvalPartial( + tc.expr, tc.known, options...) + + activation := map[string]any{} + unknownID := 9001 + for _, v := range tc.vars { + if known, ok := tc.known[v]; ok { + activation[v] = tagScalar(t, known) + continue + } + activation[v] = map[string]any{ + "@t": "unknown", "v": []any{unknownID}, + } + unknownID++ + } + activationJSON, err := json.Marshal(activation) + if err != nil { + t.Fatal(err) + } + + var raw []byte + err = conn.QueryRow(ctx, + "SELECT cel.eval(cel.parse($1, 'standard'), $2,"+ + " 'standard')", + tc.expr, activationJSON, + ).Scan(&raw) + if err != nil { + t.Fatalf("eval: %v", err) + } + got, err := codec.Decode(raw) + if err != nil { + t.Fatal(err) + } + kind, _ := taggedKind(got) + + switch { + case refErr != nil: + if kind != "error" { + t.Fatalf( + "cel-go errored (%v), got %s", refErr, raw) + } + case types.IsUnknown(refVal): + if kind != "unknown" { + t.Fatalf("cel-go returned unknown, got %s", raw) + } + default: + want, err := tagRefScalar(refVal) + if err != nil { + t.Fatalf("reference result: %v", err) + } + if !codec.Equal(want, got) { + t.Fatalf("cel-go returned %v, got %s", refVal, raw) + } + } + }) + } +} + +// tagScalar converts a Go scalar used in a known activation binding to +// the tagged-value encoding. +func tagScalar(t *testing.T, v any) map[string]any { + t.Helper() + switch x := v.(type) { + case bool: + return map[string]any{"@t": "bool", "v": x} + case int64: + return map[string]any{"@t": "int", "v": x} + case string: + return map[string]any{"@t": "string", "v": x} + } + t.Fatalf("unsupported known binding %T", v) + return nil +} + +// tagRefScalar converts a concrete cel-go scalar result to the tagged +// encoding for comparison. The suite's cases are designed so every +// concrete outcome is a scalar. +func tagRefScalar(v any) (map[string]any, error) { + switch x := v.(type) { + case types.Bool: + return map[string]any{"@t": "bool", "v": bool(x)}, nil + case types.Int: + return map[string]any{ + "@t": "int", "v": json.Number(fmt.Sprint(int64(x))), + }, nil + case types.String: + return map[string]any{"@t": "string", "v": string(x)}, nil + } + return nil, fmt.Errorf("non-scalar reference result %T", v) +} diff --git a/internal/codec/type.go b/internal/codec/type.go index 9af16ca..bed36b3 100644 --- a/internal/codec/type.go +++ b/internal/codec/type.go @@ -151,15 +151,47 @@ func FromType(t *expr.Type) (TypeJSON, error) { return nil, fmt.Errorf("type with unsupported kind %T", t.GetTypeKind()) } -// FromDecl converts a type_env declaration to the {name, type} shape -// cel.check's options parameter takes. Every corpus declaration is an -// ident declaration (measured, workspace doc 01); a function decl here -// means the corpus changed and the runner needs extending. +// FromDecl converts a type_env declaration to the shape cel.check's +// options parameter takes: {name, type} for ident declarations, +// {name, function: {overloads: [...]}} for function declarations +// (type_deduction's functions section uses the latter). func FromDecl(d *expr.Decl) (map[string]any, error) { + if fn := d.GetFunction(); fn != nil { + overloads := []any{} + for _, o := range fn.GetOverloads() { + argTypes := []any{} + for _, p := range o.GetParams() { + pt, err := FromType(p) + if err != nil { + return nil, fmt.Errorf( + "declaration %q: %w", d.GetName(), err, + ) + } + argTypes = append(argTypes, pt) + } + resultType, err := FromType(o.GetResultType()) + if err != nil { + return nil, fmt.Errorf( + "declaration %q: %w", d.GetName(), err, + ) + } + overloads = append(overloads, map[string]any{ + "id": o.GetOverloadId(), + "member": o.GetIsInstanceFunction(), + "arg_types": argTypes, + "result_type": resultType, + }) + } + return map[string]any{ + "name": d.GetName(), + "function": map[string]any{"overloads": overloads}, + }, nil + } + ident := d.GetIdent() if ident == nil { return nil, fmt.Errorf( - "declaration %q is not an ident declaration", d.GetName(), + "declaration %q is neither ident nor function", d.GetName(), ) } diff --git a/internal/oracle/oracle.go b/internal/oracle/oracle.go index 55a0e48..70460d0 100644 --- a/internal/oracle/oracle.go +++ b/internal/oracle/oracle.go @@ -80,3 +80,45 @@ func Eval( return value, nil } + +// EvalPartial compiles and evaluates an expression under a partial +// activation: every declared variable absent from known is treated as +// unknown, and the program runs with partial evaluation enabled so +// unknowns propagate instead of erroring. This is the reference for +// the unknown-propagation suite (day-one invariant 4) -- the +// conformance corpus's unknowns file is an empty stub, so agreement +// with cel-go partial evaluation is the measure. +func EvalPartial( + expression string, + known map[string]any, + options ...cel.EnvOption, +) (ref.Val, error) { + env, err := Env(options...) + if err != nil { + return nil, err + } + + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("compile %q: %w", expression, issues.Err()) + } + + program, err := env.Program(ast, cel.EvalOptions(cel.OptPartialEval)) + if err != nil { + return nil, fmt.Errorf("plan %q: %w", expression, err) + } + + if known == nil { + known = map[string]any{} + } + vars, err := env.PartialVars(known) + if err != nil { + return nil, fmt.Errorf("partial activation %q: %w", expression, err) + } + + value, _, err := program.Eval(vars) + if err != nil { + return nil, fmt.Errorf("evaluate %q: %w", expression, err) + } + return value, nil +} diff --git a/sql/040_check.sql b/sql/040_check.sql index 94a6d42..955c492 100644 --- a/sql/040_check.sql +++ b/sql/040_check.sql @@ -426,7 +426,20 @@ BEGIN SELECT * INTO s FROM cel._ck_assign1(mo, prev, cur); IF s.ok THEN mo := s.mo; - t := cel._ck_most_general(prev, cur); + -- Joining null with a nullable type keeps the nullable type: + -- the corpus's legacy_nullable_types section fixes this and + -- cel-go v0.32.0 skips those cases as known-wrong (its + -- mostGeneral would answer null) -- see the workspace + -- measurements log for the adjudication. + IF prev ->> 'kind' = 'null' AND cel._ck_nullable(cur) + AND cur ->> 'kind' <> 'null' THEN + t := cur; + ELSIF cur ->> 'kind' = 'null' AND cel._ck_nullable(prev) + AND prev ->> 'kind' <> 'null' THEN + t := prev; + ELSE + t := cel._ck_most_general(prev, cur); + END IF; RETURN; END IF; t := '{"kind":"dyn"}'::jsonb; @@ -551,15 +564,27 @@ BEGIN RETURN; END IF; + -- Registry rows first, then caller-declared overloads (options + -- 'decls' function entries, threaded in via st -> 'fns'). FOR row_r IN - SELECT o.* - FROM cel.overload o - WHERE o.function = fn - AND EXISTS ( - SELECT FROM cel.env_item it - WHERE it.env = ANY (envs) AND it.kind = 'overload' - AND it.ref = o.id) - ORDER BY o.ordinal + SELECT q.id, q.member, q.arg_types, q.result_type + FROM ( + SELECT o.id, o.member, o.arg_types, o.result_type, o.ordinal + FROM cel.overload o + WHERE o.function = fn + AND EXISTS ( + SELECT FROM cel.env_item it + WHERE it.env = ANY (envs) AND it.kind = 'overload' + AND it.ref = o.id) + UNION ALL + SELECT e ->> 'id', + coalesce((e ->> 'member')::boolean, false), + e -> 'arg_types', e -> 'result_type', + 1000000 + (row_number() OVER ())::int + FROM jsonb_array_elements( + coalesce(st -> 'fns' -> fn, '[]'::jsonb)) e + ) q + ORDER BY q.ordinal LOOP CONTINUE WHEN row_r.member <> is_member; @@ -854,7 +879,7 @@ BEGIN SELECT n.c INTO cand FROM unnest(cel._name_candidates( ltrim(fname, '.'), fname LIKE '.%', ctr)) n(c) - WHERE EXISTS ( + WHERE sto -> 'fns' ? n.c OR EXISTS ( SELECT FROM cel.overload o WHERE o.function = n.c AND EXISTS ( SELECT FROM cel.env_item it @@ -880,7 +905,7 @@ BEGIN FROM unnest(cel._name_candidates( array_to_string(chain.parts, '.') || '.' || fname, chain.absolute, ctr)) n(c) - WHERE EXISTS ( + WHERE sto -> 'fns' ? n.c OR EXISTS ( SELECT FROM cel.overload o WHERE o.function = n.c AND EXISTS ( SELECT FROM cel.env_item it @@ -904,7 +929,7 @@ BEGIN nodeo := jsonb_set(nodeo, '{target}', c.nodeo); target_t := c.typ; - IF NOT EXISTS ( + IF NOT (sto -> 'fns' ? fname) AND NOT EXISTS ( SELECT FROM cel.overload o WHERE o.function = fname AND EXISTS ( SELECT FROM cel.env_item it @@ -1174,6 +1199,7 @@ DECLARE ctr text := coalesce(options ->> 'container', ''); globals jsonb; st jsonb; + fns jsonb := '{}'::jsonb; c record; types jsonb := '{}'::jsonb; entry record; @@ -1216,12 +1242,20 @@ BEGIN SELECT globals || coalesce(jsonb_object_agg( d ->> 'name', d -> 'type'), '{}'::jsonb) INTO globals - FROM jsonb_array_elements(options -> 'decls') d; + FROM jsonb_array_elements(options -> 'decls') d + WHERE d ? 'type'; + -- Function declarations become caller-scoped overloads, + -- threaded to _ck_resolve via the checker state. + SELECT coalesce(jsonb_object_agg( + d ->> 'name', d -> 'function' -> 'overloads'), '{}'::jsonb) + INTO fns + FROM jsonb_array_elements(options -> 'decls') d + WHERE d ? 'function'; END IF; st := jsonb_build_object( 'types', '{}'::jsonb, 'refs', '{}'::jsonb, - 'map', '{}'::jsonb, 'n', 0); + 'map', '{}'::jsonb, 'n', 0, 'fns', fns); SELECT * INTO c FROM cel._ck( ast -> 'expr', '[]'::jsonb, globals, envs, ctr, st); diff --git a/sql/100_ext_comprehensions.sql b/sql/100_ext_comprehensions.sql new file mode 100644 index 0000000..443095b --- /dev/null +++ b/sql/100_ext_comprehensions.sql @@ -0,0 +1,359 @@ +-- The two-variable comprehensions extension (cel-go +-- ext.TwoVarComprehensions, ext/comprehensions.go at the pinned +-- v0.32.0): all/exists/existsOne/exists_one, transformList, +-- transformMap and transformMapEntry over (index, value) or +-- (key, value) pairs, plus the cel.@mapInsert helper the map +-- transforms expand to. Registered under the +-- 'two_var_comprehensions' env; nothing ships in 'standard'. +-- +-- Extension scripts live at the top of sql/ with a 1xx prefix +-- because initdb runs only the directory's top level; the doc-07 +-- sql/ext/ subdirectory would silently not install. + +BEGIN; + +-- Extracts and validates the two iteration variables +-- (ext/comprehensions.go extractIterVars). +CREATE OR REPLACE FUNCTION cel._mx2_vars( + a0 jsonb, a1 jsonb, OUT v1 text, OUT v2 text, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + r record; +BEGIN + SELECT * INTO r FROM cel._mx_itervar(a0); + IF r.err IS NOT NULL THEN + err := r.err; + RETURN; + END IF; + v1 := r.name; + SELECT * INTO r FROM cel._mx_itervar(a1); + IF r.err IS NOT NULL THEN + err := r.err; + RETURN; + END IF; + v2 := r.name; + IF v1 = v2 THEN + err := format('duplicate variable name: %s', v1); + END IF; +END; +$$; + +-- The quantifiers and transformList share their fold wiring with +-- the one-variable macros; only the second iteration variable is +-- new. +CREATE OR REPLACE FUNCTION cel._mx2_quant( + kind text, target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx2_vars(args -> 0, args -> 1); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + SELECT * INTO f + FROM cel._mx_fold(kind, target, v.v1, args -> 2, NULL, next_id); + IF f.err IS NOT NULL THEN + err := f.err; + RETURN; + END IF; + expr := jsonb_set(f.expr, '{iter2}', to_jsonb(v.v2)); + next_id_out := f.next_id_out; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx2_all( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx2_quant('all', target, args, next_id); +$$; + +CREATE OR REPLACE FUNCTION cel._mx2_exists( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx2_quant('exists', target, args, next_id); +$$; + +CREATE OR REPLACE FUNCTION cel._mx2_exists_one( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx2_quant('exists_one', target, args, next_id); +$$; + +CREATE OR REPLACE FUNCTION cel._mx2_transform_list( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + f record; +BEGIN + SELECT * INTO v FROM cel._mx2_vars(args -> 0, args -> 1); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + IF jsonb_array_length(args) = 4 THEN + SELECT * INTO f FROM cel._mx_fold( + 'map_filter', target, v.v1, args -> 2, args -> 3, next_id); + ELSE + SELECT * INTO f FROM cel._mx_fold( + 'map', target, v.v1, NULL, args -> 2, next_id); + END IF; + IF f.err IS NOT NULL THEN + err := f.err; + RETURN; + END IF; + expr := jsonb_set(f.expr, '{iter2}', to_jsonb(v.v2)); + next_id_out := f.next_id_out; +END; +$$; + +-- transformMap / transformMapEntry: fold into a map through +-- cel.@mapInsert (ext/comprehensions.go:333-397). entry_mode picks +-- the two-argument @mapInsert(accu, transform) form. +CREATE OR REPLACE FUNCTION cel._mx2_transform_map( + entry_mode boolean, target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v record; + id bigint := next_id; + cs jsonb := target -> 's'; + ce jsonb := target -> 'e'; + filter jsonb; + transform jsonb; + init jsonb; + cond jsonb; + step jsonb; + accu jsonb; + result jsonb; + cargs jsonb; +BEGIN + SELECT * INTO v FROM cel._mx2_vars(args -> 0, args -> 1); + IF v.err IS NOT NULL THEN + err := v.err; + RETURN; + END IF; + IF jsonb_array_length(args) = 4 THEN + filter := args -> 2; + transform := args -> 3; + ELSE + transform := args -> 2; + END IF; + + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'map', + 'entries', '[]'::jsonb, 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', true), + 's', cs, 'e', ce); + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + IF entry_mode THEN + cargs := jsonb_build_array(accu, transform); + ELSE + id := id + 1; + cargs := jsonb_build_array(accu, + jsonb_build_object('id', id, 'k', 'ident', 'name', v.v1, + 's', cs, 'e', ce), + transform); + END IF; + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', 'cel.@mapInsert', 'args', cargs, 's', cs, 'e', ce); + IF filter IS NOT NULL THEN + id := id + 1; + accu := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_?_:_', 'args', jsonb_build_array(filter, step, accu), + 's', cs, 'e', ce); + END IF; + id := id + 1; + result := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@result', 's', cs, 'e', ce); + + id := id + 1; + expr := jsonb_build_object( + 'id', id, 'k', 'comp', + 'range', target, 'iter', v.v1, 'iter2', v.v2, + 'accu', '@result', + 'init', init, 'cond', cond, 'step', step, 'result', result, + 's', cs, 'e', ce); + next_id_out := id; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx2_transform_map_3( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * + FROM cel._mx2_transform_map(false, target, args, next_id); +$$; + +CREATE OR REPLACE FUNCTION cel._mx2_transform_map_entry( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * + FROM cel._mx2_transform_map(true, target, args, next_id); +$$; + +-- cel.@mapInsert impls: inserting an existing key is an error +-- (cel-go types.InsertMapKeyValue). + +CREATE OR REPLACE FUNCTION cel._f_map_insert_kv(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +BEGIN + IF cel._map_find(args[1], args[2]) IS NOT NULL THEN + RETURN cel._err(format('insert failed: key %s already exists', + args[2] ->> 'v')); + END IF; + RETURN jsonb_set(args[1], '{v}', + (args[1] -> 'v') || jsonb_build_array( + jsonb_build_object('k', args[2], 'v', args[3]))); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_map_insert_map(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + acc jsonb := args[1]; + i int; +BEGIN + FOR i IN 0 .. jsonb_array_length(args[2] -> 'v') - 1 LOOP + acc := cel._f_map_insert_kv(ARRAY[ + acc, + args[2] -> 'v' -> i -> 'k', + args[2] -> 'v' -> i -> 'v']); + IF cel._is_error(acc) THEN + RETURN acc; + END IF; + END LOOP; + RETURN acc; +END; +$$; + +-- Registry rows -------------------------------------------------------- + +INSERT INTO cel.macro (name, arity, member, expander) VALUES + ('all', 3, true, + 'cel._mx2_all(jsonb,jsonb,bigint)'), + ('exists', 3, true, + 'cel._mx2_exists(jsonb,jsonb,bigint)'), + ('existsOne', 3, true, + 'cel._mx2_exists_one(jsonb,jsonb,bigint)'), + ('exists_one', 3, true, + 'cel._mx2_exists_one(jsonb,jsonb,bigint)'), + ('transformList', 3, true, + 'cel._mx2_transform_list(jsonb,jsonb,bigint)'), + ('transformList', 4, true, + 'cel._mx2_transform_list(jsonb,jsonb,bigint)'), + ('transformMap', 3, true, + 'cel._mx2_transform_map_3(jsonb,jsonb,bigint)'), + ('transformMap', 4, true, + 'cel._mx2_transform_map_3(jsonb,jsonb,bigint)'), + ('transformMapEntry', 3, true, + 'cel._mx2_transform_map_entry(jsonb,jsonb,bigint)'), + ('transformMapEntry', 4, true, + 'cel._mx2_transform_map_entry(jsonb,jsonb,bigint)') +ON CONFLICT (name, arity, member) DO UPDATE + SET expander = excluded.expander; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('@mapInsert_map_key_value', 'cel.@mapInsert', false, + '[{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}, + {"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]', + '{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}', + 'cel._f_map_insert_kv(jsonb[])', 10), + ('@mapInsert_map_map', 'cel.@mapInsert', false, + '[{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}, + {"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}]', + '{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}', + 'cel._f_map_insert_map(jsonb[])', 20) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'two_var_comprehensions', 'macro', + format('%s/%s/%s', name, arity, member::int) +FROM cel.macro +WHERE arity IN (3, 4) AND member + AND name IN ('all', 'exists', 'existsOne', 'exists_one', + 'transformList', 'transformMap', 'transformMapEntry') +ON CONFLICT DO NOTHING; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('two_var_comprehensions', 'overload', '@mapInsert_map_key_value'), + ('two_var_comprehensions', 'overload', '@mapInsert_map_map') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/110_ext_optionals.sql b/sql/110_ext_optionals.sql new file mode 100644 index 0000000..f3ff9b5 --- /dev/null +++ b/sql/110_ext_optionals.sql @@ -0,0 +1,157 @@ +-- The optionals extension, part one: the optional_type opaque type +-- and the optional.of / optional.ofNonZeroValue / optional.none / +-- value / hasValue functions (cel-go cel/library.go optionals, pinned +-- v0.32.0). The optional-syntax sugar (x.?f, [?x], {?k: v}), or / +-- orValue, and the optMap/optFlatMap macros arrive with the Phase 6 +-- extension work; type_deduction's optional sections need only the +-- declaration surface here. +-- +-- An optional value is the opaque +-- {"@t": "opaque", "type": "optional_type", "v": +-- {"p": , "v": }} +-- (day-one invariant 3: extension types are registry rows over the +-- opaque kind, never new core kinds). + +BEGIN; + +CREATE OR REPLACE FUNCTION cel._opt_of(v jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'opaque', 'type', 'optional_type', + 'v', jsonb_build_object('p', true, 'v', v)); +$$; + +CREATE OR REPLACE FUNCTION cel._opt_none() +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'opaque', 'type', 'optional_type', + 'v', jsonb_build_object('p', false)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_opt_of(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._opt_of(args[1]); +$$; + +-- ofNonZeroValue: none when the argument is its type's zero value +-- (cel-go types/optional.go / library.go). +CREATE OR REPLACE FUNCTION cel._f_opt_of_nonzero(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v jsonb := args[1]; + zero boolean; +BEGIN + zero := CASE v ->> '@t' + WHEN 'int' THEN (v ->> 'v')::numeric = 0 + WHEN 'uint' THEN (v ->> 'v')::numeric = 0 + WHEN 'double' THEN (v ->> 'v') = '0' + OR (v ->> 'v')::float8 = 0 + WHEN 'bool' THEN NOT (v ->> 'v')::boolean + WHEN 'string' THEN v ->> 'v' = '' + WHEN 'bytes' THEN v ->> 'v' = '' + WHEN 'list' THEN jsonb_array_length(v -> 'v') = 0 + WHEN 'map' THEN jsonb_array_length(v -> 'v') = 0 + WHEN 'null' THEN true + ELSE false + END; + RETURN CASE WHEN zero THEN cel._opt_none() + ELSE cel._opt_of(v) END; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_opt_none(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._opt_none(); +$$; + +CREATE OR REPLACE FUNCTION cel._f_opt_value(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN (args[1] -> 'v' ->> 'p')::boolean THEN args[1] -> 'v' -> 'v' + ELSE cel._err('optional.none() dereference') + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_opt_has_value(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val((args[1] -> 'v' ->> 'p')::boolean); +$$; + +INSERT INTO cel.type (name, kind) VALUES + ('optional_type', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}') +ON CONFLICT (name) DO UPDATE SET kind = excluded.kind; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('optional_of', 'optional.of', false, + '[{"kind": "param", "name": "V"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_of(jsonb[])', 10), + ('optional_ofNonZeroValue', 'optional.ofNonZeroValue', false, + '[{"kind": "param", "name": "V"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_of_nonzero(jsonb[])', 10), + ('optional_none', 'optional.none', false, + '[]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_none(jsonb[])', 10), + ('optional_value', 'value', true, + '[{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}]', + '{"kind": "param", "name": "V"}', + 'cel._f_opt_value(jsonb[])', 10), + ('optional_hasValue', 'hasValue', true, + '[{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}]', + '{"kind": "bool"}', + 'cel._f_opt_has_value(jsonb[])', 10) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('optionals', 'type', 'optional_type'), + ('optionals', 'overload', 'optional_of'), + ('optionals', 'overload', 'optional_ofNonZeroValue'), + ('optionals', 'overload', 'optional_none'), + ('optionals', 'overload', 'optional_value'), + ('optionals', 'overload', 'optional_hasValue') +ON CONFLICT DO NOTHING; + +COMMIT; From 37ffbca10516d6b01a2a08644df9042e78fefc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 21:02:10 -0300 Subject: [PATCH 14/21] Add the extension libraries; corpus goes green The seven extension libraries land as registry rows plus PL/pgSQL impls under their own env names, which is the point of the design: none of them touches the evaluator core, and none is visible in the standard env. strings carries the string.format mini-language, whose fixed and scientific clauses round half-even over the exact decimal expansion of the double the way Go's correctly-rounded formatter does. math runs its bit operations in numeric two's complement because bigint shifts take the count mod 64 and uint64 does not fit bigint. optionals completes with the syntax operators; the checker types _?._ by field-selection logic and unwraps optional operands, and the evaluator adopts cel-go's if-present qualification (a missing key under an optional is none, not an error) and splices [?x] / {?k: v} literal elements. network validates with Go netip's strictness before handing classification and containment to Postgres inet machinery. Two behaviours were adjudicated corpus-first where cel-go could not referee: out-of-range indexOf/lastIndexOf offsets error (cel-java agrees), and hex-form IPv4-mapped IPv6 parses and unmaps while the dotted form is rejected (cel-go rejects both but does not run network_ext in its own conformance). or/orValue are strict rather than short-circuiting; every corpus case passes strict. With this, every in-scope conformance file passes on a fresh install: 1841 case passes, 288 case skips from the 287 named skip entries plus 6 named file skips, zero failures. --- sql/040_check.sql | 100 ++++- sql/050_eval.sql | 64 +++ sql/110_ext_optionals.sql | 341 ++++++++++++++++ sql/120_ext_strings.sql | 799 ++++++++++++++++++++++++++++++++++++++ sql/130_ext_math.sql | 651 +++++++++++++++++++++++++++++++ sql/140_ext_lists.sql | 413 ++++++++++++++++++++ sql/150_ext_encoders.sql | 64 +++ sql/160_ext_bindings.sql | 66 ++++ sql/170_ext_network.sql | 523 +++++++++++++++++++++++++ 9 files changed, 3002 insertions(+), 19 deletions(-) create mode 100644 sql/120_ext_strings.sql create mode 100644 sql/130_ext_math.sql create mode 100644 sql/140_ext_lists.sql create mode 100644 sql/150_ext_encoders.sql create mode 100644 sql/160_ext_bindings.sql create mode 100644 sql/170_ext_network.sql diff --git a/sql/040_check.sql b/sql/040_check.sql index 955c492..5e0d927 100644 --- a/sql/040_check.sql +++ b/sql/040_check.sql @@ -631,6 +631,49 @@ BEGIN END; $$; + +-- Field-selection result typing (checker.go:215 checkSelectField): +-- shared by the select branch and the _?._ optional-select call. +-- Unwraps an optional_type operand and reports it via was_opt. +CREATE OR REPLACE FUNCTION cel._ck_sel_type( + op_t jsonb, st jsonb, + OUT typ jsonb, OUT sto jsonb, OUT err text, OUT was_opt boolean +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s record; +BEGIN + sto := st; + was_opt := op_t ->> 'kind' = 'opaque' + AND op_t ->> 'name' = 'optional_type'; + IF was_opt THEN + op_t := cel._ck_subst(sto -> 'map', op_t -> 'params' -> 0, + false); + END IF; + CASE op_t ->> 'kind' + WHEN 'map' THEN + typ := op_t -> 'params' -> 1; + WHEN 'param' THEN + SELECT * INTO s FROM cel._ck_assign1( + sto -> 'map', '{"kind":"dyn"}', op_t); + IF s.ok THEN + sto := jsonb_set(sto, '{map}', s.mo); + END IF; + typ := '{"kind":"dyn"}'::jsonb; + WHEN 'dyn', 'any', 'error' THEN + typ := '{"kind":"dyn"}'::jsonb; + WHEN 'wrapper' THEN + typ := '{"kind":"dyn"}'::jsonb; + ELSE + err := format('type %s does not support field selection', + quote_literal(cel._t_fmt(op_t))); + END CASE; +END; +$$; + -- Local (comprehension) scope lookup, innermost first. CREATE OR REPLACE FUNCTION cel._ck_local(scopes jsonb, name text) RETURNS jsonb @@ -752,6 +795,7 @@ STABLE PARALLEL SAFE SET search_path = cel, pg_temp AS $$ DECLARE + selr record; k text := node ->> 'k'; nid text := node ->> 'id'; c record; @@ -828,30 +872,25 @@ BEGIN nodeo := jsonb_set(node, '{op}', c.nodeo); op_t := cel._ck_subst(sto -> 'map', c.typ, false); - CASE op_t ->> 'kind' - WHEN 'map' THEN - typ := op_t -> 'params' -> 1; - WHEN 'param' THEN - SELECT * INTO s FROM cel._ck_assign1( - sto -> 'map', '{"kind":"dyn"}', op_t); - IF s.ok THEN - sto := jsonb_set(sto, '{map}', s.mo); - END IF; - typ := '{"kind":"dyn"}'::jsonb; - WHEN 'dyn', 'any', 'error' THEN - typ := '{"kind":"dyn"}'::jsonb; - WHEN 'wrapper' THEN - typ := '{"kind":"dyn"}'::jsonb; - ELSE - err := format('type %s does not support field selection', - quote_literal(cel._t_fmt(op_t))); - RETURN; - END CASE; + SELECT * INTO selr FROM cel._ck_sel_type(op_t, sto); + IF selr.err IS NOT NULL THEN + err := selr.err; + RETURN; + END IF; + sto := selr.sto; + typ := selr.typ; IF coalesce((node -> 'test')::boolean, false) THEN typ := '{"kind":"bool"}'::jsonb; ELSE typ := cel._ck_subst(sto -> 'map', typ, false); + -- An optional operand makes the selection optional too + -- (checker.go:253). + IF selr.was_opt THEN + typ := jsonb_build_object('kind', 'opaque', + 'name', 'optional_type', + 'params', jsonb_build_array(typ)); + END IF; END IF; WHEN 'call' THEN @@ -873,6 +912,29 @@ BEGIN fname := node ->> 'fn'; + -- The optional-select operator is typed by field-selection + -- logic, not overload resolution (checker.go:187 + -- checkOptSelect); its reference is the fixed function id. + IF NOT node ? 'target' AND fname = '_?._' THEN + SELECT * INTO selr FROM cel._ck_sel_type( + cel._ck_subst(sto -> 'map', argtypes -> 0, false), sto); + IF selr.err IS NOT NULL THEN + err := selr.err; + RETURN; + END IF; + sto := selr.sto; + typ := jsonb_build_object('kind', 'opaque', + 'name', 'optional_type', + 'params', jsonb_build_array( + cel._ck_subst(sto -> 'map', selr.typ, false))); + nodeo := nodeo || jsonb_build_object('ref', + jsonb_build_object('overloads', + jsonb_build_array(to_jsonb('select_optional_field'::text)))); + sto := jsonb_set(sto, ARRAY['refs', nid], + nodeo -> 'ref'); + RETURN; + END IF; + IF NOT node ? 'target' THEN -- Global call: resolve the function name through the -- container. diff --git a/sql/050_eval.sql b/sql/050_eval.sql index 03ec811..734239f 100644 --- a/sql/050_eval.sql +++ b/sql/050_eval.sql @@ -290,6 +290,28 @@ BEGIN END IF; RETURN r; END IF; + -- Selection distributes over optional values (cel-go optional + -- qualifiers): none stays none, a present value is selected + -- strictly and re-wrapped. + IF v ->> '@t' = 'opaque' AND v ->> 'type' = 'optional_type' THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + RETURN v; + END IF; + -- If-present semantics: a missing field yields none. + IF v -> 'v' -> 'v' ->> '@t' = 'map' THEN + r := cel._map_find(v -> 'v' -> 'v', + jsonb_build_object('@t', 'string', 'v', field)); + IF r IS NULL THEN + RETURN cel._opt_none(); + END IF; + RETURN cel._opt_of(r); + END IF; + r := cel._sel_field(v -> 'v' -> 'v', field, nid); + IF cel._is_error(r) OR cel._is_unknown(r) THEN + RETURN r; + END IF; + RETURN cel._opt_of(r); + END IF; RETURN cel._err(format( 'does not support field selection: %s', v ->> '@t'), nid); END; @@ -360,6 +382,13 @@ BEGIN IF cel._is_error(v) OR cel._is_unknown(v) THEN RETURN v; END IF; + IF v ->> '@t' = 'opaque' AND v ->> 'type' = 'optional_type' + THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + RETURN cel._bool_val(false); + END IF; + v := v -> 'v' -> 'v'; + END IF; IF v ->> '@t' = 'map' THEN RETURN cel._bool_val(cel._map_find(v, key) IS NOT NULL); END IF; @@ -496,6 +525,11 @@ BEGIN RETURN cel._f_index_list(ARRAY[l, r]); ELSIF l ->> '@t' = 'map' THEN RETURN cel._f_index_map(ARRAY[l, r]); + ELSIF l ->> '@t' = 'opaque' THEN + -- An extension index overload (e.g. the optionals rows) may + -- accept the opaque; fall through to normal dispatch. + RETURN cel._ev_dispatch(fn, node ? 'target', + ARRAY[l, r], node -> 'ref', envs, nid); END IF; RETURN cel._err('no such overload', nid); END IF; @@ -539,6 +573,17 @@ BEGIN unk := CASE WHEN unk IS NULL THEN v ELSE cel._unknown_merge(unk, v) END; END IF; + -- [?x] elements splice: none disappears, of(v) inlines + -- (optionals extension list-literal support). + IF node -> 'opt' @> to_jsonb(i) THEN + IF v ->> '@t' = 'opaque' AND v ->> 'type' = 'optional_type' + THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + CONTINUE; + END IF; + v := v -> 'v' -> 'v'; + END IF; + END IF; elems := elems || jsonb_build_array(v); END LOOP; IF unk IS NOT NULL THEN @@ -565,6 +610,17 @@ BEGIN ELSE cel._unknown_merge(unk, v) END; END IF; IF unk IS NULL THEN + -- {?k: v} entries splice: a none value drops the entry, a + -- present one inlines (optionals extension). + IF coalesce((node -> 'entries' -> i -> 'opt')::boolean, + false) + AND v ->> '@t' = 'opaque' + AND v ->> 'type' = 'optional_type' THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + CONTINUE; + END IF; + v := v -> 'v' -> 'v'; + END IF; -- Key type restriction and duplicate rejection are dynamic -- (corpus-first rulings: forbidden double/null keys and -- normalized duplicates both error at construction). @@ -614,6 +670,14 @@ BEGIN unk := CASE WHEN unk IS NULL THEN v ELSE cel._unknown_merge(unk, v) END; END IF; + IF coalesce((node -> 'fields' -> i -> 'opt')::boolean, false) + AND v ->> '@t' = 'opaque' + AND v ->> 'type' = 'optional_type' THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + CONTINUE; + END IF; + v := v -> 'v' -> 'v'; + END IF; entries := entries || jsonb_build_object( node -> 'fields' -> i ->> 'name', v); END LOOP; diff --git a/sql/110_ext_optionals.sql b/sql/110_ext_optionals.sql index f3ff9b5..ef4eafc 100644 --- a/sql/110_ext_optionals.sql +++ b/sql/110_ext_optionals.sql @@ -155,3 +155,344 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ON CONFLICT DO NOTHING; COMMIT; + +BEGIN; + +-- Part two: the optional-syntax operators, or/orValue, and the +-- optMap/optFlatMap macros (cel/library.go optionals block, +-- cel/library.go:430-560 at the pinned v0.32.0). + +-- select_optional_field (_?._): field presence lifted into an +-- optional; distributes over an optional operand. +CREATE OR REPLACE FUNCTION cel._f_opt_select(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v jsonb := args[1]; + r jsonb; +BEGIN + IF v ->> '@t' = 'opaque' AND v ->> 'type' = 'optional_type' THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + RETURN v; + END IF; + RETURN cel._f_opt_select(ARRAY[v -> 'v' -> 'v', args[2]]); + END IF; + IF v ->> '@t' = 'map' THEN + r := cel._map_find(v, args[2]); + IF r IS NULL THEN + RETURN cel._opt_none(); + END IF; + RETURN cel._opt_of(r); + END IF; + RETURN cel._err(format( + 'does not support field selection: %s', v ->> '@t')); +END; +$$; + +-- _[?_]: index presence lifted into an optional; distributes over +-- an optional operand. +CREATE OR REPLACE FUNCTION cel._f_opt_index(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v jsonb := args[1]; + k jsonb := args[2]; + r jsonb; + n numeric; +BEGIN + IF v ->> '@t' = 'opaque' AND v ->> 'type' = 'optional_type' THEN + IF NOT (v -> 'v' ->> 'p')::boolean THEN + RETURN v; + END IF; + RETURN cel._f_opt_index(ARRAY[v -> 'v' -> 'v', k]); + END IF; + IF v ->> '@t' = 'list' THEN + IF k ->> '@t' NOT IN ('int', 'uint', 'double') THEN + RETURN cel._err(format('no such overload: %s[?%s]', + v ->> '@t', k ->> '@t')); + END IF; + n := (k ->> 'v')::numeric; + IF n <> trunc(n) OR n < 0 + OR n >= jsonb_array_length(v -> 'v') THEN + RETURN cel._opt_none(); + END IF; + RETURN cel._opt_of(v -> 'v' -> n::int); + END IF; + IF v ->> '@t' = 'map' THEN + r := cel._map_find(v, k); + IF r IS NULL THEN + RETURN cel._opt_none(); + END IF; + RETURN cel._opt_of(r); + END IF; + RETURN cel._err(format('no such overload: %s[?_]', v ->> '@t')); +END; +$$; + +-- Plain _[_] with an optional operand: none stays none, a present +-- container indexes strictly and re-wraps. +CREATE OR REPLACE FUNCTION cel._f_opt_index_strict(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +BEGIN + -- Qualification of an optional is always if-present in cel-go's + -- attribute machinery: a missing key or index yields none, not an + -- error (measured: optionals/optional_chaining_5..11). + RETURN cel._f_opt_index(args); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_opt_or(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN (args[1] -> 'v' ->> 'p')::boolean + THEN args[1] ELSE args[2] END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_opt_or_value(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN (args[1] -> 'v' ->> 'p')::boolean + THEN args[1] -> 'v' -> 'v' ELSE args[2] END; +$$; + +-- optMap / optFlatMap (cel/library.go optMap/optFlatMap): expand to +-- target.hasValue() +-- ? (bind(v, target.value(), expr)) +-- : optional.none() +-- with the target itself bound to @target first when it is not a +-- simple identifier. +CREATE OR REPLACE FUNCTION cel._mx_opt_map( + flat boolean, target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + cs jsonb := target -> 's'; + ce jsonb := target -> 'e'; + id bigint := next_id; + nm text; + tgt jsonb; + hasv jsonb; + valv jsonb; + innerc jsonb; + cond jsonb; + step jsonb; + nonec jsonb; + branch jsonb; +BEGIN + IF args -> 0 ->> 'k' <> 'ident' THEN + err := format('opt%s() variable name must be a simple ' + || 'identifier', CASE WHEN flat THEN 'FlatMap' ELSE 'Map' END); + RETURN; + END IF; + nm := args -> 0 ->> 'name'; + + IF target ->> 'k' = 'ident' THEN + tgt := target; + ELSE + id := id + 1; + tgt := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@target', 's', cs, 'e', ce); + END IF; + + id := id + 1; + hasv := jsonb_build_object('id', id, 'k', 'call', + 'fn', 'hasValue', 'target', tgt, 'args', '[]'::jsonb, + 's', cs, 'e', ce); + id := id + 1; + valv := jsonb_build_object('id', id, 'k', 'call', + 'fn', 'value', 'target', tgt, 'args', '[]'::jsonb, + 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', false), + 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'ident', + 'name', nm, 's', cs, 'e', ce); + id := id + 1; + innerc := jsonb_build_object( + 'id', id, 'k', 'comp', + 'range', jsonb_build_object('id', id, 'k', 'list', + 'elems', '[]'::jsonb, 's', cs, 'e', ce), + 'iter', '#unused', 'iter2', '', 'accu', nm, + 'init', valv, 'cond', cond, 'step', step, + 'result', args -> 1, 's', cs, 'e', ce); + IF NOT flat THEN + id := id + 1; + innerc := jsonb_build_object('id', id, 'k', 'call', + 'fn', 'optional.of', 'args', jsonb_build_array(innerc), + 's', cs, 'e', ce); + END IF; + id := id + 1; + nonec := jsonb_build_object('id', id, 'k', 'call', + 'fn', 'optional.none', 'args', '[]'::jsonb, 's', cs, 'e', ce); + id := id + 1; + branch := jsonb_build_object('id', id, 'k', 'call', + 'fn', '_?_:_', + 'args', jsonb_build_array(hasv, innerc, nonec), + 's', cs, 'e', ce); + + IF target ->> 'k' = 'ident' THEN + expr := branch; + next_id_out := id; + RETURN; + END IF; + + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@target', 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', false), + 's', cs, 'e', ce); + id := id + 1; + expr := jsonb_build_object( + 'id', id, 'k', 'comp', + 'range', jsonb_build_object('id', id, 'k', 'list', + 'elems', '[]'::jsonb, 's', cs, 'e', ce), + 'iter', '#unused', 'iter2', '', 'accu', '@target', + 'init', target, 'cond', cond, 'step', step, + 'result', branch, 's', cs, 'e', ce); + next_id_out := id; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_opt_map_2( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx_opt_map(false, target, args, next_id); +$$; + +CREATE OR REPLACE FUNCTION cel._mx_opt_flat_map( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx_opt_map(true, target, args, next_id); +$$; + +INSERT INTO cel.macro (name, arity, member, expander) VALUES + ('optMap', 2, true, 'cel._mx_opt_map_2(jsonb,jsonb,bigint)'), + ('optFlatMap', 2, true, + 'cel._mx_opt_flat_map(jsonb,jsonb,bigint)') +ON CONFLICT (name, arity, member) DO UPDATE + SET expander = excluded.expander; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('optional_or_optional', 'or', true, + '[{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}, + {"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_or(jsonb[])', 10), + ('optional_orValue_value', 'orValue', true, + '[{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}, + {"kind": "param", "name": "V"}]', + '{"kind": "param", "name": "V"}', + 'cel._f_opt_or_value(jsonb[])', 10), + ('select_optional_field', '_?._', false, + '[{"kind": "dyn"}, {"kind": "string"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_select(jsonb[])', 10), + ('list_optindex_optional_int', '_[?_]', false, + '[{"kind": "list", "params": [{"kind": "param", "name": "V"}]}, + {"kind": "int"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_index(jsonb[])', 10), + ('optional_list_optindex_optional_int', '_[?_]', false, + '[{"kind": "opaque", "name": "optional_type", "params": + [{"kind": "list", "params": [{"kind": "param", + "name": "V"}]}]}, + {"kind": "int"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_index(jsonb[])', 20), + ('map_optindex_optional_value', '_[?_]', false, + '[{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}, + {"kind": "param", "name": "K"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_index(jsonb[])', 30), + ('optional_map_optindex_optional_value', '_[?_]', false, + '[{"kind": "opaque", "name": "optional_type", "params": + [{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}]}, + {"kind": "param", "name": "K"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_index(jsonb[])', 40), + ('optional_list_index_int', '_[_]', false, + '[{"kind": "opaque", "name": "optional_type", "params": + [{"kind": "list", "params": [{"kind": "param", + "name": "V"}]}]}, + {"kind": "int"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_index_strict(jsonb[])', 30), + ('optional_map_index_value', '_[_]', false, + '[{"kind": "opaque", "name": "optional_type", "params": + [{"kind": "map", "params": [{"kind": "param", "name": "K"}, + {"kind": "param", "name": "V"}]}]}, + {"kind": "param", "name": "K"}]', + '{"kind": "opaque", "name": "optional_type", + "params": [{"kind": "param", "name": "V"}]}', + 'cel._f_opt_index_strict(jsonb[])', 40) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('optionals', 'overload', 'optional_or_optional'), + ('optionals', 'overload', 'optional_orValue_value'), + ('optionals', 'overload', 'select_optional_field'), + ('optionals', 'overload', 'list_optindex_optional_int'), + ('optionals', 'overload', 'optional_list_optindex_optional_int'), + ('optionals', 'overload', 'map_optindex_optional_value'), + ('optionals', 'overload', 'optional_map_optindex_optional_value'), + ('optionals', 'overload', 'optional_list_index_int'), + ('optionals', 'overload', 'optional_map_index_value'), + ('optionals', 'macro', 'optMap/2/1'), + ('optionals', 'macro', 'optFlatMap/2/1') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/120_ext_strings.sql b/sql/120_ext_strings.sql new file mode 100644 index 0000000..0ffc07f --- /dev/null +++ b/sql/120_ext_strings.sql @@ -0,0 +1,799 @@ +-- The strings extension (cel-go ext/strings.go at the pinned +-- v0.32.0, latest library version): charAt, indexOf, lastIndexOf, +-- lowerAscii, upperAscii, replace, split, substring, trim, join, +-- reverse, strings.quote and string.format. Registered under the +-- 'strings' env. +-- +-- All index arithmetic is in code points; Postgres text functions +-- are character-based under UTF8, which lines up. One deliberate +-- divergence from cel-go: indexOf/lastIndexOf with an out-of-range +-- offset error instead of returning -1 -- the corpus and cel-java +-- agree against cel-go v0.32.0 there (workspace ISSUES R2). + +BEGIN; + +CREATE OR REPLACE FUNCTION cel._str_val(s text) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', s); +$$; + +CREATE OR REPLACE FUNCTION cel._f_char_at(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + i numeric := (args[2] ->> 'v')::numeric; +BEGIN + IF i < 0 OR i > length(s) THEN + RETURN cel._err(format('index out of range: %s', i)); + END IF; + RETURN cel._str_val(substr(s, i::int + 1, 1)); +END; +$$; + +-- Shared scan for indexOf / lastIndexOf. The empty-substring case +-- returns the clamped offset before the bounds check (matching both +-- cel-go and cel-java); a non-empty search with an out-of-range +-- offset errors (corpus + cel-java adjudication, ISSUES R2). +CREATE OR REPLACE FUNCTION cel._str_index( + s text, sub text, off numeric, backwards boolean +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + l int := length(s); + ls int := length(sub); + i int; +BEGIN + IF off < 0 THEN + RETURN cel._err(format('index out of range: %s', off)); + END IF; + IF sub = '' THEN + RETURN cel._int_val(least(off, l::numeric)); + END IF; + IF off >= l THEN + RETURN cel._err(format('index out of range: %s', off)); + END IF; + IF backwards THEN + i := least(off::int, l - ls); + WHILE i >= 0 LOOP + IF substr(s, i + 1, ls) = sub THEN + RETURN cel._int_val(i); + END IF; + i := i - 1; + END LOOP; + ELSE + i := off::int; + WHILE i <= l - ls LOOP + IF substr(s, i + 1, ls) = sub THEN + RETURN cel._int_val(i); + END IF; + i := i + 1; + END LOOP; + END IF; + RETURN cel._int_val(-1); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_index_of(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._str_index(args[1] ->> 'v', args[2] ->> 'v', + CASE WHEN cardinality(args) > 2 + THEN (args[3] ->> 'v')::numeric ELSE 0 END, + false); +$$; + +CREATE OR REPLACE FUNCTION cel._f_last_index_of(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + sub text := args[2] ->> 'v'; +BEGIN + IF cardinality(args) > 2 THEN + RETURN cel._str_index(s, sub, (args[3] ->> 'v')::numeric, true); + END IF; + -- The 2-argument form never errors: it searches from the end. + IF sub = '' THEN + RETURN cel._int_val(length(s)); + END IF; + IF length(s) < length(sub) THEN + RETURN cel._int_val(-1); + END IF; + RETURN cel._str_index(s, sub, (length(s) - 1)::numeric, true); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_lower_ascii(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._str_val(translate(args[1] ->> 'v', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_upper_ascii(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._str_val(translate(args[1] ->> 'v', + 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')); +$$; + +-- Go strings.Replace semantics: n < 0 replaces all, n = 0 none. +CREATE OR REPLACE FUNCTION cel._f_replace(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + old text := args[2] ->> 'v'; + new text := args[3] ->> 'v'; + n numeric := CASE WHEN cardinality(args) > 3 + THEN (args[4] ->> 'v')::numeric ELSE -1 END; + res text := ''; + p int; +BEGIN + IF n < 0 THEN + IF old = '' THEN + -- Go inserts new between every rune and at both ends. + res := new; + FOR p IN 1 .. length(s) LOOP + res := res || substr(s, p, 1) || new; + END LOOP; + RETURN cel._str_val(res); + END IF; + RETURN cel._str_val(replace(s, old, new)); + END IF; + WHILE n > 0 LOOP + IF old = '' THEN + res := res || new; + IF s = '' THEN + EXIT; + END IF; + res := res || substr(s, 1, 1); + s := substr(s, 2); + ELSE + p := strpos(s, old); + EXIT WHEN p = 0; + res := res || substr(s, 1, p - 1) || new; + s := substr(s, p + length(old)); + END IF; + n := n - 1; + END LOOP; + RETURN cel._str_val(res || s); +END; +$$; + +-- Go strings.SplitN semantics, in code points; sep = '' splits into +-- characters. +CREATE OR REPLACE FUNCTION cel._f_split(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + sep text := args[2] ->> 'v'; + n numeric := CASE WHEN cardinality(args) > 2 + THEN (args[3] ->> 'v')::numeric ELSE -1 END; + parts jsonb := '[]'::jsonb; + p int; + cnt int := 0; +BEGIN + IF n = 0 THEN + RETURN jsonb_build_object('@t', 'list', 'v', '[]'::jsonb); + END IF; + IF sep = '' THEN + FOR p IN 1 .. length(s) LOOP + EXIT WHEN n > 0 AND cnt = n - 1; + parts := parts || jsonb_build_array( + cel._str_val(substr(s, p, 1))); + cnt := cnt + 1; + END LOOP; + IF n > 0 AND length(s) > cnt THEN + parts := parts || jsonb_build_array( + cel._str_val(substr(s, cnt + 1))); + END IF; + RETURN jsonb_build_object('@t', 'list', 'v', parts); + END IF; + LOOP + EXIT WHEN n > 0 AND cnt = n - 1; + p := strpos(s, sep); + EXIT WHEN p = 0; + parts := parts || jsonb_build_array( + cel._str_val(substr(s, 1, p - 1))); + s := substr(s, p + length(sep)); + cnt := cnt + 1; + END LOOP; + parts := parts || jsonb_build_array(cel._str_val(s)); + RETURN jsonb_build_object('@t', 'list', 'v', parts); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_substring(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + a numeric := (args[2] ->> 'v')::numeric; + b numeric; + l int := length(s); +BEGIN + IF cardinality(args) = 2 THEN + IF a < 0 OR a > l THEN + RETURN cel._err(format('index out of range: %s', a)); + END IF; + RETURN cel._str_val(substr(s, a::int + 1)); + END IF; + b := (args[3] ->> 'v')::numeric; + IF a > b THEN + RETURN cel._err(format( + 'invalid substring range. start: %s, end: %s', a, b)); + END IF; + IF a < 0 OR a > l THEN + RETURN cel._err(format('index out of range: %s', a)); + END IF; + IF b < 0 OR b > l THEN + RETURN cel._err(format('index out of range: %s', b)); + END IF; + RETURN cel._str_val(substr(s, a::int + 1, (b - a)::int)); +END; +$$; + +-- Go strings.TrimSpace: the Unicode white-space set. +CREATE OR REPLACE FUNCTION cel._f_trim(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._str_val(btrim(args[1] ->> 'v', + E' \t\n\f\r' || chr(11) || chr(133) || chr(160) || chr(5760) + || chr(8192) || chr(8193) || chr(8194) || chr(8195) + || chr(8196) || chr(8197) || chr(8198) || chr(8199) + || chr(8200) || chr(8201) || chr(8202) || chr(8232) + || chr(8233) || chr(8239) || chr(8287) || chr(12288))); +$$; + +CREATE OR REPLACE FUNCTION cel._f_str_reverse(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._str_val(reverse(args[1] ->> 'v')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_join(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + sep text := CASE WHEN cardinality(args) > 1 + THEN args[2] ->> 'v' ELSE '' END; + res text := ''; + i int; + e jsonb; +BEGIN + FOR i IN 0 .. jsonb_array_length(args[1] -> 'v') - 1 LOOP + e := args[1] -> 'v' -> i; + IF e ->> '@t' <> 'string' THEN + RETURN cel._err(format('join: invalid input: %s', e ->> 'v')); + END IF; + IF i > 0 THEN + res := res || sep; + END IF; + res := res || (e ->> 'v'); + END LOOP; + RETURN cel._str_val(res); +END; +$$; + +-- strings.quote: CEL escape sequences, double-quoted. +CREATE OR REPLACE FUNCTION cel._f_quote(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + res text := ''; + c text; + i int; +BEGIN + FOR i IN 1 .. length(s) LOOP + c := substr(s, i, 1); + res := res || CASE c + WHEN chr(7) THEN '\a' + WHEN chr(8) THEN '\b' + WHEN chr(12) THEN '\f' + WHEN chr(10) THEN '\n' + WHEN chr(13) THEN '\r' + WHEN chr(9) THEN '\t' + WHEN chr(11) THEN '\v' + WHEN '\' THEN '\\' + WHEN '"' THEN '\"' + ELSE c + END; + END LOOP; + RETURN cel._str_val('"' || res || '"'); +END; +$$; + +COMMIT; + +BEGIN; + +-- string.format ------------------------------------------------------ + +-- Round-half-even of an exact numeric at scale p, returned as a +-- numeric of exactly scale p (multiplication by the exact decimal +-- 1e-p preserves exactness; numeric division would not). +CREATE OR REPLACE FUNCTION cel._fmt_round_even(x numeric, p int) +RETURNS numeric +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a numeric := abs(x) * (10::numeric ^ p); + i numeric := trunc(a); + f numeric := a - i; +BEGIN + IF f > 0.5 OR (f = 0.5 AND mod(i, 2) = 1) THEN + i := i + 1; + END IF; + RETURN sign(x) * i * ('1e-' || p)::numeric; +END; +$$; + +-- The %s formatter (formatting_v2.go formatStringV2): recursive over +-- lists and maps, map entries sorted by their formatted key. +CREATE OR REPLACE FUNCTION cel._fmt_s(v jsonb, OUT o text, OUT err text) +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := v ->> '@t'; + i int; + r record; + parts text[]; + ents text[]; + kv record; +BEGIN + CASE k + WHEN 'string' THEN o := v ->> 'v'; + WHEN 'bool' THEN o := CASE WHEN (v ->> 'v')::boolean + THEN 'true' ELSE 'false' END; + WHEN 'int', 'uint' THEN o := v ->> 'v'; + WHEN 'double' THEN + o := CASE v ->> 'v' + WHEN 'NaN' THEN 'NaN' + WHEN 'Infinity' THEN 'Infinity' + WHEN '-Infinity' THEN '-Infinity' + ELSE cel._sci_to_plain( + cel._double_text((v ->> 'v')::float8)) + END; + WHEN 'bytes' THEN + o := convert_from(decode(v ->> 'v', 'base64'), 'UTF8'); + WHEN 'null' THEN o := 'null'; + WHEN 'type' THEN o := v ->> 'v'; + WHEN 'duration' THEN + o := cel._f_duration_to_string(ARRAY[v]) ->> 'v'; + WHEN 'timestamp' THEN + -- Formatting renders in UTC regardless of the value's offset. + o := cel._f_timestamp_to_string(ARRAY[ + jsonb_set(v, '{v,tz}', '0'::jsonb)]) ->> 'v'; + WHEN 'list' THEN + parts := '{}'; + FOR i IN 0 .. jsonb_array_length(v -> 'v') - 1 LOOP + SELECT * INTO r FROM cel._fmt_s(v -> 'v' -> i); + IF r.err IS NOT NULL THEN + err := r.err; + RETURN; + END IF; + parts := parts || r.o; + END LOOP; + o := '[' || array_to_string(parts, ', ') || ']'; + WHEN 'map' THEN + ents := '{}'; + FOR i IN 0 .. jsonb_array_length(v -> 'v') - 1 LOOP + SELECT * INTO r FROM cel._fmt_s(v -> 'v' -> i -> 'k'); + IF r.err IS NOT NULL THEN + err := r.err; + RETURN; + END IF; + ents := ents || (r.o || chr(1)); + SELECT * INTO r FROM cel._fmt_s(v -> 'v' -> i -> 'v'); + IF r.err IS NOT NULL THEN + err := r.err; + RETURN; + END IF; + ents[cardinality(ents)] := ents[cardinality(ents)] || r.o; + END LOOP; + SELECT array_agg(e ORDER BY split_part(e, chr(1), 1) + COLLATE "C") + INTO ents FROM unnest(ents) e; + o := '{' || coalesce(( + SELECT string_agg(replace(e, chr(1), ': '), ', ') + FROM unnest(ents) e), '') || '}'; + ELSE + err := format('string clause can only be used on strings, ' + || 'bools, bytes, ints, doubles, maps, lists, types, ' + || 'durations, and timestamps, was given %s', + CASE WHEN k = 'opaque' THEN v ->> 'type' ELSE k END); + END CASE; +END; +$$; + +-- Integer rendering in bases 2, 8 and 16 (sign + digits of the +-- absolute value, matching Go strconv.FormatInt). +CREATE OR REPLACE FUNCTION cel._fmt_base(n numeric, b int, up boolean) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a numeric := abs(n); + digits text := '0123456789abcdef'; + o text := ''; + d int; +BEGIN + IF a = 0 THEN + o := '0'; + END IF; + WHILE a > 0 LOOP + d := mod(a, b)::int; + o := substr(digits, d + 1, 1) || o; + a := div(a, b); + END LOOP; + IF up THEN + o := upper(o); + END IF; + RETURN CASE WHEN n < 0 THEN '-' || o ELSE o END; +END; +$$; + +-- Fixed-point (%f) and scientific (%e) rendering over the exact +-- decimal expansion of the double (cel._f2n), rounded half-even the +-- way Go's correctly-rounded formatter behaves. +CREATE OR REPLACE FUNCTION cel._fmt_fixed(f float8, p int) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + r numeric := cel._fmt_round_even(cel._f2n(f), p); + neg boolean := f < 0 OR (f = 0 AND f::text = '-0'); +BEGIN + RETURN CASE WHEN neg AND r >= 0 THEN '-' ELSE '' END || r::text; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._fmt_sci(f float8, p int) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + m numeric := cel._f2n(f); + e int := 0; + am numeric; + es text; +BEGIN + am := abs(m); + IF am <> 0 THEN + WHILE am >= 10 LOOP + am := am * 0.1; + e := e + 1; + END LOOP; + WHILE am < 1 LOOP + am := am * 10; + e := e - 1; + END LOOP; + END IF; + am := cel._fmt_round_even(am, p); + IF am >= 10 THEN + am := am * 0.1; + e := e + 1; + am := cel._fmt_round_even(am, p); + END IF; + es := lpad(abs(e)::text, 2, '0'); + RETURN CASE WHEN m < 0 OR (f = 0 AND f::text = '-0') + THEN '-' ELSE '' END + || am::text || 'e' + || CASE WHEN e < 0 THEN '-' ELSE '+' END || es; +END; +$$; + +-- One formatting clause applied to one argument. kinds follow +-- formatting_v2.go's per-clause type admission exactly. +CREATE OR REPLACE FUNCTION cel._fmt_clause( + c text, p int, v jsonb, OUT o text, OUT err text +) +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := v ->> '@t'; + r record; + tn text := CASE k + WHEN 'opaque' THEN v ->> 'type' + WHEN 'timestamp' THEN 'google.protobuf.Timestamp' + WHEN 'duration' THEN 'google.protobuf.Duration' + ELSE k END; +BEGIN + CASE c + WHEN 's' THEN + SELECT * INTO r FROM cel._fmt_s(v); + o := r.o; + err := r.err; + WHEN 'd' THEN + IF k IN ('int', 'uint') THEN + o := v ->> 'v'; + ELSIF k = 'double' + AND v ->> 'v' IN ('NaN', 'Infinity', '-Infinity') THEN + o := v ->> 'v'; + ELSE + err := format('decimal clause can only be used on ' + || 'integers, was given %s', tn); + END IF; + WHEN 'f' THEN + IF k IN ('int', 'uint', 'double') THEN + IF k = 'double' + AND v ->> 'v' IN ('NaN', 'Infinity', '-Infinity') THEN + o := v ->> 'v'; + ELSE + o := cel._fmt_fixed((v ->> 'v')::float8, p); + END IF; + ELSE + err := format('fixed-point clause can only be used on ' + || 'numeric types, was given %s', tn); + END IF; + WHEN 'e' THEN + IF k IN ('int', 'uint', 'double') THEN + IF k = 'double' + AND v ->> 'v' IN ('NaN', 'Infinity', '-Infinity') THEN + o := v ->> 'v'; + ELSE + o := cel._fmt_sci((v ->> 'v')::float8, p); + END IF; + ELSE + err := format('scientific clause can only be used on ' + || 'numeric types, was given %s', tn); + END IF; + WHEN 'b' THEN + IF k IN ('int', 'uint') THEN + o := cel._fmt_base((v ->> 'v')::numeric, 2, false); + ELSIF k = 'bool' THEN + o := CASE WHEN (v ->> 'v')::boolean THEN '1' ELSE '0' END; + ELSE + err := format('only integers and bools can be formatted ' + || 'as binary, was given %s', tn); + END IF; + WHEN 'x', 'X' THEN + IF k IN ('int', 'uint') THEN + o := cel._fmt_base((v ->> 'v')::numeric, 16, c = 'X'); + ELSIF k = 'string' THEN + o := encode(convert_to(v ->> 'v', 'UTF8'), 'hex'); + IF c = 'X' THEN o := upper(o); END IF; + ELSIF k = 'bytes' THEN + o := encode(decode(v ->> 'v', 'base64'), 'hex'); + IF c = 'X' THEN o := upper(o); END IF; + ELSE + err := format('only integers, byte buffers, and strings ' + || 'can be formatted as hex, was given %s', tn); + END IF; + WHEN 'o' THEN + IF k IN ('int', 'uint') THEN + o := cel._fmt_base((v ->> 'v')::numeric, 8, false); + ELSE + err := format('octal clause can only be used on integers, ' + || 'was given %s', tn); + END IF; + ELSE + err := NULL; -- unreachable; clauses validated by the caller + END CASE; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_format(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; + lst jsonb := args[2] -> 'v'; + n int := jsonb_array_length(lst); + res text := ''; + i int := 1; + ai int := 0; + l int := length(s); + c text; + p int; + ptxt text; + r record; +BEGIN + WHILE i <= l LOOP + c := substr(s, i, 1); + IF c <> '%' THEN + res := res || c; + i := i + 1; + CONTINUE; + END IF; + IF substr(s, i + 1, 1) = '%' THEN + res := res || '%'; + i := i + 2; + CONTINUE; + END IF; + IF i = l THEN + RETURN cel._err('unexpected end of string'); + END IF; + -- precision + i := i + 1; + p := 6; + IF substr(s, i, 1) = '.' THEN + i := i + 1; + ptxt := ''; + WHILE i <= l AND substr(s, i, 1) BETWEEN '0' AND '9' LOOP + ptxt := ptxt || substr(s, i, 1); + i := i + 1; + END LOOP; + IF i > l THEN + RETURN cel._err('could not parse formatting clause: ' + || 'could not find end of precision specifier'); + END IF; + IF ptxt = '' THEN + RETURN cel._err('could not parse formatting clause: error ' + || 'while converting precision to integer'); + END IF; + p := ptxt::int; + END IF; + c := substr(s, i, 1); + i := i + 1; + IF c NOT IN ('s', 'd', 'f', 'e', 'b', 'x', 'X', 'o') THEN + RETURN cel._err(format('could not parse formatting clause: ' + || 'unrecognized formatting clause "%s"', c)); + END IF; + IF ai >= n THEN + RETURN cel._err(format('index %s out of range', ai)); + END IF; + SELECT * INTO r FROM cel._fmt_clause(c, p, lst -> ai); + IF r.err IS NOT NULL THEN + RETURN cel._err('error during formatting: ' || r.err); + END IF; + res := res || r.o; + ai := ai + 1; + END LOOP; + RETURN cel._str_val(res); +END; +$$; + +-- Registry rows -------------------------------------------------------- + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('string_char_at_int', 'charAt', true, + '[{"kind": "string"}, {"kind": "int"}]', '{"kind": "string"}', + 'cel._f_char_at(jsonb[])', 10), + ('string_index_of_string', 'indexOf', true, + '[{"kind": "string"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_index_of(jsonb[])', 10), + ('string_index_of_string_int', 'indexOf', true, + '[{"kind": "string"}, {"kind": "string"}, {"kind": "int"}]', + '{"kind": "int"}', 'cel._f_index_of(jsonb[])', 20), + ('string_last_index_of_string', 'lastIndexOf', true, + '[{"kind": "string"}, {"kind": "string"}]', '{"kind": "int"}', + 'cel._f_last_index_of(jsonb[])', 10), + ('string_last_index_of_string_int', 'lastIndexOf', true, + '[{"kind": "string"}, {"kind": "string"}, {"kind": "int"}]', + '{"kind": "int"}', 'cel._f_last_index_of(jsonb[])', 20), + ('string_lower_ascii', 'lowerAscii', true, + '[{"kind": "string"}]', '{"kind": "string"}', + 'cel._f_lower_ascii(jsonb[])', 10), + ('string_upper_ascii', 'upperAscii', true, + '[{"kind": "string"}]', '{"kind": "string"}', + 'cel._f_upper_ascii(jsonb[])', 10), + ('string_replace_string_string', 'replace', true, + '[{"kind": "string"}, {"kind": "string"}, {"kind": "string"}]', + '{"kind": "string"}', 'cel._f_replace(jsonb[])', 10), + ('string_replace_string_string_int', 'replace', true, + '[{"kind": "string"}, {"kind": "string"}, {"kind": "string"}, + {"kind": "int"}]', + '{"kind": "string"}', 'cel._f_replace(jsonb[])', 20), + ('string_split_string', 'split', true, + '[{"kind": "string"}, {"kind": "string"}]', + '{"kind": "list", "params": [{"kind": "string"}]}', + 'cel._f_split(jsonb[])', 10), + ('string_split_string_int', 'split', true, + '[{"kind": "string"}, {"kind": "string"}, {"kind": "int"}]', + '{"kind": "list", "params": [{"kind": "string"}]}', + 'cel._f_split(jsonb[])', 20), + ('string_substring_int', 'substring', true, + '[{"kind": "string"}, {"kind": "int"}]', '{"kind": "string"}', + 'cel._f_substring(jsonb[])', 10), + ('string_substring_int_int', 'substring', true, + '[{"kind": "string"}, {"kind": "int"}, {"kind": "int"}]', + '{"kind": "string"}', 'cel._f_substring(jsonb[])', 20), + ('string_trim', 'trim', true, + '[{"kind": "string"}]', '{"kind": "string"}', + 'cel._f_trim(jsonb[])', 10), + ('string_reverse', 'reverse', true, + '[{"kind": "string"}]', '{"kind": "string"}', + 'cel._f_str_reverse(jsonb[])', 10), + ('list_join', 'join', true, + '[{"kind": "list", "params": [{"kind": "string"}]}]', + '{"kind": "string"}', 'cel._f_join(jsonb[])', 10), + ('list_join_string', 'join', true, + '[{"kind": "list", "params": [{"kind": "string"}]}, + {"kind": "string"}]', + '{"kind": "string"}', 'cel._f_join(jsonb[])', 20), + ('strings_quote', 'strings.quote', false, + '[{"kind": "string"}]', '{"kind": "string"}', + 'cel._f_quote(jsonb[])', 10), + ('string_format', 'format', true, + '[{"kind": "string"}, + {"kind": "list", "params": [{"kind": "dyn"}]}]', + '{"kind": "string"}', 'cel._f_format(jsonb[])', 10) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'strings', 'overload', id FROM cel.overload +WHERE id IN ( + 'string_char_at_int', 'string_index_of_string', + 'string_index_of_string_int', 'string_last_index_of_string', + 'string_last_index_of_string_int', 'string_lower_ascii', + 'string_upper_ascii', 'string_replace_string_string', + 'string_replace_string_string_int', 'string_split_string', + 'string_split_string_int', 'string_substring_int', + 'string_substring_int_int', 'string_trim', 'string_reverse', + 'list_join', 'list_join_string', 'strings_quote', + 'string_format') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/130_ext_math.sql b/sql/130_ext_math.sql new file mode 100644 index 0000000..9fb3ac4 --- /dev/null +++ b/sql/130_ext_math.sql @@ -0,0 +1,651 @@ +-- The math extension (cel-go ext/math.go at the pinned v0.32.0, +-- latest library version): the math.greatest / math.least macros over +-- math.@max / math.@min, ceil/floor/round/trunc, isInf/isNaN/ +-- isFinite, abs/sign/sqrt, and the 64-bit bit operations. Registered +-- under the 'math' env. +-- +-- Bit operations run in numeric two's-complement arithmetic (div / +-- mod by exact powers of two) because Postgres bigint shifts take +-- the count mod 64, and uint64 values do not fit bigint. + +BEGIN; + +CREATE OR REPLACE FUNCTION cel._math_ident(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT args[1]; +$$; + +-- minPair/maxPair (math.go:684): compare, propagate NaN's +-- unorderable error. +CREATE OR REPLACE FUNCTION cel._math_pair(a jsonb, b jsonb, mx boolean) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + c jsonb := cel._compare(a, b); + take int := CASE WHEN mx THEN -1 ELSE 1 END; +BEGIN + IF cel._is_error(c) THEN + RETURN c; + END IF; + IF (c ->> 'v')::int = take THEN + RETURN b; + END IF; + RETURN a; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._math_minmax(args jsonb[], mx boolean) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + acc jsonb; + i int; +BEGIN + IF cardinality(args) = 2 THEN + RETURN cel._math_pair(args[1], args[2], mx); + END IF; + -- Single list argument. + IF jsonb_array_length(args[1] -> 'v') = 0 THEN + RETURN cel._err(format('math.@%s(list) argument must not be ' + || 'empty', CASE WHEN mx THEN 'max' ELSE 'min' END)); + END IF; + acc := args[1] -> 'v' -> 0; + FOR i IN 1 .. jsonb_array_length(args[1] -> 'v') - 1 LOOP + acc := cel._math_pair(acc, args[1] -> 'v' -> i, mx); + IF cel._is_error(acc) THEN + RETURN acc; + END IF; + END LOOP; + IF acc ->> '@t' NOT IN ('int', 'uint', 'double', 'unknown') THEN + RETURN cel._err(format('no such overload: math.@%s', + CASE WHEN mx THEN 'max' ELSE 'min' END)); + END IF; + RETURN acc; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_min(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._math_minmax(args, false) $$; + +CREATE OR REPLACE FUNCTION cel._f_math_max(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._math_minmax(args, true) $$; + +CREATE OR REPLACE FUNCTION cel._f_math_ceil(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dbl_val(ceil((args[1] ->> 'v')::float8)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_floor(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._dbl_val(floor((args[1] ->> 'v')::float8)); +$$; + +-- math.Round: half away from zero; NaN and infinities pass through. +CREATE OR REPLACE FUNCTION cel._f_math_round(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + f float8 := (args[1] ->> 'v')::float8; +BEGIN + IF args[1] ->> 'v' IN ('NaN', 'Infinity', '-Infinity') THEN + RETURN args[1]; + END IF; + RETURN cel._dbl_val(CASE WHEN f < 0 THEN -floor(-f + 0.5) + ELSE floor(f + 0.5) END); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_trunc(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE + WHEN args[1] ->> 'v' IN ('NaN', 'Infinity', '-Infinity') + THEN args[1] + ELSE cel._dbl_val(trunc((args[1] ->> 'v')::float8::numeric) + ::float8) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_isinf(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + args[1] ->> 'v' IN ('Infinity', '-Infinity')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_isnan(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val(args[1] ->> 'v' = 'NaN'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_isfinite(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + args[1] ->> 'v' NOT IN ('NaN', 'Infinity', '-Infinity')); +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_abs(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := args[1] ->> '@t'; +BEGIN + CASE k + WHEN 'double' THEN + IF args[1] ->> 'v' = 'NaN' THEN + RETURN args[1]; + END IF; + RETURN cel._dbl_val(abs((args[1] ->> 'v')::float8)); + WHEN 'int' THEN + IF (args[1] ->> 'v')::numeric = -9223372036854775808 THEN + RETURN cel._err('integer overflow'); + END IF; + RETURN cel._int_val(abs((args[1] ->> 'v')::numeric)); + ELSE + RETURN args[1]; -- uint + END CASE; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_sign(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + k text := args[1] ->> '@t'; + n numeric; +BEGIN + IF k = 'double' THEN + IF args[1] ->> 'v' = 'NaN' THEN + RETURN args[1]; + END IF; + RETURN cel._dbl_val(sign((args[1] ->> 'v')::float8)::float8); + END IF; + n := (args[1] ->> 'v')::numeric; + IF k = 'uint' THEN + RETURN jsonb_build_object('@t', 'uint', 'v', + CASE WHEN n = 0 THEN 0 ELSE 1 END); + END IF; + RETURN cel._int_val(sign(n)); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_sqrt(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + f float8; +BEGIN + IF args[1] ->> '@t' = 'double' + AND args[1] ->> 'v' IN ('NaN', '-Infinity') THEN + RETURN cel._dbl_val('NaN'::float8); + END IF; + IF args[1] ->> 'v' = 'Infinity' THEN + RETURN args[1]; + END IF; + f := (args[1] ->> 'v')::float8; + IF f < 0 THEN + RETURN cel._dbl_val('NaN'::float8); + END IF; + RETURN cel._dbl_val(sqrt(f)); +END; +$$; + +-- Two's-complement helpers over numeric: to64/from64 map an +-- int64-or-uint64 payload onto [0, 2^64) and back. +CREATE OR REPLACE FUNCTION cel._bits_of(v jsonb) +RETURNS numeric +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN (v ->> 'v')::numeric < 0 + THEN (v ->> 'v')::numeric + 18446744073709551616::numeric + ELSE (v ->> 'v')::numeric END; +$$; + +CREATE OR REPLACE FUNCTION cel._bits_val(u numeric, uns boolean) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN uns + THEN jsonb_build_object('@t', 'uint', 'v', to_jsonb(u)) + ELSE cel._int_val(CASE + WHEN u >= 9223372036854775808::numeric + THEN u - 18446744073709551616::numeric + ELSE u END) + END; +$$; + +-- Bitwise and/or/xor run on bigint after an offset-preserving remap +-- (two's complement is offset-invariant under these operators). +CREATE OR REPLACE FUNCTION cel._f_math_bitop(args jsonb[], op text) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + uns boolean := args[1] ->> '@t' = 'uint'; + a bigint := cel._bits_val(cel._bits_of(args[1]), false) ->> 'v'; + b bigint := cel._bits_val(cel._bits_of(args[2]), false) ->> 'v'; + r bigint; +BEGIN + r := CASE op + WHEN 'and' THEN a & b + WHEN 'or' THEN a | b + ELSE a # b + END; + RETURN cel._bits_val( + cel._bits_of(cel._int_val(r::numeric)), uns); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_bitand(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._f_math_bitop(args, 'and') $$; +CREATE OR REPLACE FUNCTION cel._f_math_bitor(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._f_math_bitop(args, 'or') $$; +CREATE OR REPLACE FUNCTION cel._f_math_bitxor(args jsonb[]) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ SELECT cel._f_math_bitop(args, 'xor') $$; + +CREATE OR REPLACE FUNCTION cel._f_math_bitnot(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE WHEN args[1] ->> '@t' = 'uint' + THEN jsonb_build_object('@t', 'uint', 'v', to_jsonb( + 18446744073709551615::numeric - (args[1] ->> 'v')::numeric)) + ELSE cel._int_val(-(args[1] ->> 'v')::numeric - 1) + END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_math_shl(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + uns boolean := args[1] ->> '@t' = 'uint'; + bs numeric := (args[2] ->> 'v')::numeric; + u numeric; +BEGIN + IF bs < 0 THEN + RETURN cel._err(format( + 'math.bitShiftLeft() negative offset: %s', bs)); + END IF; + IF bs >= 64 THEN + RETURN cel._bits_val(0, uns); + END IF; + -- numeric ^ returns a scaled result even for integer powers; + -- trunc() restores the integer. + u := trunc(mod( + cel._bits_of(args[1]) * (2::numeric ^ bs::int), + 18446744073709551616::numeric)); + RETURN cel._bits_val(u, uns); +END; +$$; + +-- Right shift is logical for both int and uint (math.go +-- bitShiftRightIntInt reinterprets through uint64). +CREATE OR REPLACE FUNCTION cel._f_math_shr(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + uns boolean := args[1] ->> '@t' = 'uint'; + bs numeric := (args[2] ->> 'v')::numeric; +BEGIN + IF bs < 0 THEN + RETURN cel._err(format( + 'math.bitShiftRight() negative offset: %s', bs)); + END IF; + IF bs >= 64 THEN + RETURN cel._bits_val(0, uns); + END IF; + RETURN cel._bits_val( + div(cel._bits_of(args[1]), 2::numeric ^ bs::int), uns); +END; +$$; + +-- The greatest/least macros (math.go:617): receiver macros on the +-- 'math' namespace, variadic; literal arguments must be numeric. + +CREATE OR REPLACE FUNCTION cel._mx_math_arg_ok(arg jsonb) +RETURNS boolean +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT CASE arg ->> 'k' + WHEN 'lit' THEN + (arg -> 'v' ->> '@t') IN ('int', 'uint', 'double') + WHEN 'list' THEN false + WHEN 'map' THEN false + WHEN 'struct' THEN false + ELSE true + END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_math_minmax( + fn text, disp text, target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + n int := jsonb_array_length(args); + cs jsonb := target -> 's'; + ce jsonb := target -> 'e'; + ok boolean; + i int; + lst jsonb; +BEGIN + next_id_out := next_id; + -- Decline unless the receiver is the math namespace. + IF target ->> 'k' <> 'ident' + OR ltrim(target ->> 'name', '.') <> 'math' THEN + RETURN; + END IF; + IF n = 0 THEN + err := format('%s() requires at least one argument', disp); + RETURN; + END IF; + IF n = 1 THEN + ok := CASE args -> 0 ->> 'k' + WHEN 'list' THEN + jsonb_array_length(args -> 0 -> 'elems') > 0 AND NOT EXISTS ( + SELECT FROM jsonb_array_elements(args -> 0 -> 'elems') e + WHERE NOT cel._mx_math_arg_ok(e)) + ELSE cel._mx_math_arg_ok(args -> 0) + END; + IF NOT ok THEN + err := format('%s() invalid single argument value', disp); + RETURN; + END IF; + next_id_out := next_id + 1; + expr := jsonb_build_object('id', next_id_out, 'k', 'call', + 'fn', fn, 'args', args, 's', cs, 'e', ce); + RETURN; + END IF; + FOR i IN 0 .. n - 1 LOOP + IF NOT cel._mx_math_arg_ok(args -> i) THEN + err := format('%s() simple literal arguments must be numeric', + disp); + RETURN; + END IF; + END LOOP; + IF n = 2 THEN + next_id_out := next_id + 1; + expr := jsonb_build_object('id', next_id_out, 'k', 'call', + 'fn', fn, 'args', args, 's', cs, 'e', ce); + RETURN; + END IF; + next_id_out := next_id + 1; + lst := jsonb_build_object('id', next_id_out, 'k', 'list', + 'elems', args, 's', cs, 'e', ce); + next_id_out := next_id_out + 1; + expr := jsonb_build_object('id', next_id_out, 'k', 'call', + 'fn', fn, 'args', jsonb_build_array(lst), 's', cs, 'e', ce); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._mx_math_least( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx_math_minmax( + 'math.@min', 'math.least', target, args, next_id); +$$; + +CREATE OR REPLACE FUNCTION cel._mx_math_greatest( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT * FROM cel._mx_math_minmax( + 'math.@max', 'math.greatest', target, args, next_id); +$$; + +-- Registry rows -------------------------------------------------------- + +INSERT INTO cel.macro (name, arity, member, expander) VALUES + ('least', -1, true, + 'cel._mx_math_least(jsonb,jsonb,bigint)'), + ('greatest', -1, true, + 'cel._mx_math_greatest(jsonb,jsonb,bigint)') +ON CONFLICT (name, arity, member) DO UPDATE + SET expander = excluded.expander; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +SELECT ('math_@' || mm || suffix), 'math.@' || mm, false, + arg_types, result_type, + CASE WHEN mm = 'min' THEN 'cel._f_math_min(jsonb[])' + ELSE 'cel._f_math_max(jsonb[])' END::regprocedure, + ordinal +FROM (VALUES + ('_double', '[{"kind":"double"}]'::jsonb, '{"kind":"double"}'::jsonb, 10), + ('_int', '[{"kind":"int"}]'::jsonb, '{"kind":"int"}'::jsonb, 20), + ('_uint', '[{"kind":"uint"}]'::jsonb, '{"kind":"uint"}'::jsonb, 30), + ('_double_double', + '[{"kind":"double"},{"kind":"double"}]'::jsonb, + '{"kind":"double"}'::jsonb, 40), + ('_int_int', '[{"kind":"int"},{"kind":"int"}]'::jsonb, + '{"kind":"int"}'::jsonb, 50), + ('_uint_uint', '[{"kind":"uint"},{"kind":"uint"}]'::jsonb, + '{"kind":"uint"}'::jsonb, 60), + ('_int_uint', '[{"kind":"int"},{"kind":"uint"}]'::jsonb, + '{"kind":"dyn"}'::jsonb, 70), + ('_int_double', '[{"kind":"int"},{"kind":"double"}]'::jsonb, + '{"kind":"dyn"}'::jsonb, 80), + ('_double_int', '[{"kind":"double"},{"kind":"int"}]'::jsonb, + '{"kind":"dyn"}'::jsonb, 90), + ('_double_uint', '[{"kind":"double"},{"kind":"uint"}]'::jsonb, + '{"kind":"dyn"}'::jsonb, 100), + ('_uint_int', '[{"kind":"uint"},{"kind":"int"}]'::jsonb, + '{"kind":"dyn"}'::jsonb, 110), + ('_uint_double', '[{"kind":"uint"},{"kind":"double"}]'::jsonb, + '{"kind":"dyn"}'::jsonb, 120), + ('_list_double', + '[{"kind":"list","params":[{"kind":"double"}]}]'::jsonb, + '{"kind":"double"}'::jsonb, 130), + ('_list_int', + '[{"kind":"list","params":[{"kind":"int"}]}]'::jsonb, + '{"kind":"int"}'::jsonb, 140), + ('_list_uint', + '[{"kind":"list","params":[{"kind":"uint"}]}]'::jsonb, + '{"kind":"uint"}'::jsonb, 150) +) v(suffix, arg_types, result_type, ordinal) +CROSS JOIN (VALUES ('min'), ('max')) m(mm) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +-- Single-argument @min/@max of a scalar are identity. +UPDATE cel.overload +SET impl = 'cel._math_ident(jsonb[])' +WHERE id IN ('math_@min_double', 'math_@min_int', 'math_@min_uint', + 'math_@max_double', 'math_@max_int', 'math_@max_uint'); + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('math_ceil_double', 'math.ceil', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_ceil(jsonb[])', 10), + ('math_floor_double', 'math.floor', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_floor(jsonb[])', 10), + ('math_round_double', 'math.round', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_round(jsonb[])', 10), + ('math_trunc_double', 'math.trunc', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_trunc(jsonb[])', 10), + ('math_isInf_double', 'math.isInf', false, + '[{"kind": "double"}]', '{"kind": "bool"}', + 'cel._f_math_isinf(jsonb[])', 10), + ('math_isNaN_double', 'math.isNaN', false, + '[{"kind": "double"}]', '{"kind": "bool"}', + 'cel._f_math_isnan(jsonb[])', 10), + ('math_isFinite_double', 'math.isFinite', false, + '[{"kind": "double"}]', '{"kind": "bool"}', + 'cel._f_math_isfinite(jsonb[])', 10), + ('math_abs_double', 'math.abs', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_abs(jsonb[])', 10), + ('math_abs_int', 'math.abs', false, + '[{"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_abs(jsonb[])', 20), + ('math_abs_uint', 'math.abs', false, + '[{"kind": "uint"}]', '{"kind": "uint"}', + 'cel._f_math_abs(jsonb[])', 30), + ('math_sign_double', 'math.sign', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_sign(jsonb[])', 10), + ('math_sign_int', 'math.sign', false, + '[{"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_sign(jsonb[])', 20), + ('math_sign_uint', 'math.sign', false, + '[{"kind": "uint"}]', '{"kind": "uint"}', + 'cel._f_math_sign(jsonb[])', 30), + ('math_sqrt_double', 'math.sqrt', false, + '[{"kind": "double"}]', '{"kind": "double"}', + 'cel._f_math_sqrt(jsonb[])', 10), + ('math_sqrt_int', 'math.sqrt', false, + '[{"kind": "int"}]', '{"kind": "double"}', + 'cel._f_math_sqrt(jsonb[])', 20), + ('math_sqrt_uint', 'math.sqrt', false, + '[{"kind": "uint"}]', '{"kind": "double"}', + 'cel._f_math_sqrt(jsonb[])', 30), + ('math_bitAnd_int_int', 'math.bitAnd', false, + '[{"kind": "int"}, {"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_bitand(jsonb[])', 10), + ('math_bitAnd_uint_uint', 'math.bitAnd', false, + '[{"kind": "uint"}, {"kind": "uint"}]', '{"kind": "uint"}', + 'cel._f_math_bitand(jsonb[])', 20), + ('math_bitOr_int_int', 'math.bitOr', false, + '[{"kind": "int"}, {"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_bitor(jsonb[])', 10), + ('math_bitOr_uint_uint', 'math.bitOr', false, + '[{"kind": "uint"}, {"kind": "uint"}]', '{"kind": "uint"}', + 'cel._f_math_bitor(jsonb[])', 20), + ('math_bitXor_int_int', 'math.bitXor', false, + '[{"kind": "int"}, {"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_bitxor(jsonb[])', 10), + ('math_bitXor_uint_uint', 'math.bitXor', false, + '[{"kind": "uint"}, {"kind": "uint"}]', '{"kind": "uint"}', + 'cel._f_math_bitxor(jsonb[])', 20), + ('math_bitNot_int_int', 'math.bitNot', false, + '[{"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_bitnot(jsonb[])', 10), + ('math_bitNot_uint_uint', 'math.bitNot', false, + '[{"kind": "uint"}]', '{"kind": "uint"}', + 'cel._f_math_bitnot(jsonb[])', 20), + ('math_bitShiftLeft_int_int', 'math.bitShiftLeft', false, + '[{"kind": "int"}, {"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_shl(jsonb[])', 10), + ('math_bitShiftLeft_uint_int', 'math.bitShiftLeft', false, + '[{"kind": "uint"}, {"kind": "int"}]', '{"kind": "uint"}', + 'cel._f_math_shl(jsonb[])', 20), + ('math_bitShiftRight_int_int', 'math.bitShiftRight', false, + '[{"kind": "int"}, {"kind": "int"}]', '{"kind": "int"}', + 'cel._f_math_shr(jsonb[])', 10), + ('math_bitShiftRight_uint_int', 'math.bitShiftRight', false, + '[{"kind": "uint"}, {"kind": "int"}]', '{"kind": "uint"}', + 'cel._f_math_shr(jsonb[])', 20) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'math', 'overload', id FROM cel.overload +WHERE function LIKE 'math.%' +ON CONFLICT DO NOTHING; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('math', 'macro', 'least/-1/1'), + ('math', 'macro', 'greatest/-1/1') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/140_ext_lists.sql b/sql/140_ext_lists.sql new file mode 100644 index 0000000..3036091 --- /dev/null +++ b/sql/140_ext_lists.sql @@ -0,0 +1,413 @@ +-- The lists extension (cel-go ext/lists.go at the pinned v0.32.0): +-- slice, flatten, sort, sortBy (macro over @sortByAssociatedKeys), +-- lists.range, reverse, distinct. Registered under the 'lists' env. + +BEGIN; + +CREATE OR REPLACE FUNCTION cel._list_val(elems jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'list', 'v', elems); +$$; + +CREATE OR REPLACE FUNCTION cel._f_list_slice(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + l jsonb := args[1] -> 'v'; + a numeric := (args[2] ->> 'v')::numeric; + b numeric := (args[3] ->> 'v')::numeric; + n int := jsonb_array_length(l); + o jsonb := '[]'::jsonb; + i int; +BEGIN + IF a < 0 OR b < 0 THEN + RETURN cel._err(format('cannot slice(%s, %s), negative indexes ' + || 'not supported', a, b)); + END IF; + IF a > b THEN + RETURN cel._err(format('cannot slice(%s, %s), start index must ' + || 'be less than or equal to end index', a, b)); + END IF; + IF n < b THEN + RETURN cel._err(format('cannot slice(%s, %s), list is length %s', + a, b, n)); + END IF; + FOR i IN a::int .. b::int - 1 LOOP + o := o || jsonb_build_array(l -> i); + END LOOP; + RETURN cel._list_val(o); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._list_flatten(l jsonb, depth numeric) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + o jsonb := '[]'::jsonb; + e jsonb; + i int; +BEGIN + FOR i IN 0 .. jsonb_array_length(l) - 1 LOOP + e := l -> i; + IF e ->> '@t' = 'list' AND depth > 0 THEN + o := o || cel._list_flatten(e -> 'v', depth - 1); + ELSE + o := o || jsonb_build_array(e); + END IF; + END LOOP; + RETURN o; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_list_flatten(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + depth numeric := CASE WHEN cardinality(args) > 1 + THEN (args[2] ->> 'v')::numeric ELSE 1 END; +BEGIN + IF depth < 0 THEN + RETURN cel._err('level must be non-negative'); + END IF; + RETURN cel._list_val(cel._list_flatten(args[1] -> 'v', depth)); +END; +$$; + +-- sort()/sortBy() core: reorder list by the sort order of keys, +-- which must share one comparable runtime type (lists.go:539). +CREATE OR REPLACE FUNCTION cel._f_list_sort_by_keys(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + l jsonb := args[1] -> 'v'; + ks jsonb := args[2] -> 'v'; + n int := jsonb_array_length(l); + kt text; + o jsonb := '[]'::jsonb; + idx int[]; + i int; + j int; + tmp int; + c jsonb; +BEGIN + IF n <> jsonb_array_length(ks) THEN + RETURN cel._err(format('@sortByAssociatedKeys() expected a list ' + || 'of the same size as the associated keys list, but got %s ' + || 'and %s elements respectively', + n, jsonb_array_length(ks))); + END IF; + IF n = 0 THEN + RETURN args[1]; + END IF; + kt := ks -> 0 ->> '@t'; + IF kt NOT IN ('int', 'uint', 'double', 'bool', 'duration', + 'timestamp', 'string', 'bytes') THEN + RETURN cel._err('list elements must be comparable'); + END IF; + idx := '{}'; + FOR i IN 0 .. n - 1 LOOP + IF (ks -> i ->> '@t') <> kt THEN + RETURN cel._err('list elements must have the same type'); + END IF; + idx := idx || i; + END LOOP; + -- Insertion sort on indices (stable, adequate for test-sized + -- lists; the no-performance-work rule was struck but conformance + -- sizes do not warrant more). + FOR i IN 2 .. n LOOP + j := i; + WHILE j > 1 LOOP + c := cel._compare(ks -> idx[j], ks -> idx[j - 1]); + IF cel._is_error(c) THEN + RETURN c; + END IF; + EXIT WHEN (c ->> 'v')::int <> -1; + tmp := idx[j]; + idx[j] := idx[j - 1]; + idx[j - 1] := tmp; + j := j - 1; + END LOOP; + END LOOP; + FOR i IN 1 .. n LOOP + o := o || jsonb_build_array(l -> idx[i]); + END LOOP; + RETURN cel._list_val(o); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_list_sort(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._f_list_sort_by_keys(ARRAY[args[1], args[1]]); +$$; + +CREATE OR REPLACE FUNCTION cel._f_lists_range(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + n numeric := (args[1] ->> 'v')::numeric; +BEGIN + IF n < 0 THEN + RETURN cel._err(format( + 'lists.range: size must be non-negative, got %s', n)); + END IF; + -- cel-go's conformance default limit. + IF n > 1000000 THEN + RETURN cel._err(format( + 'lists.range: size %s exceeds maximum allowed (1000000)', n)); + END IF; + RETURN cel._list_val(coalesce(( + SELECT jsonb_agg(cel._int_val(i)) + FROM generate_series(0, n::int - 1) i), '[]'::jsonb)); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_list_reverse(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._list_val(coalesce(( + SELECT jsonb_agg(e ORDER BY o DESC) + FROM jsonb_array_elements(args[1] -> 'v') + WITH ORDINALITY q(e, o)), '[]'::jsonb)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_list_distinct(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + l jsonb := args[1] -> 'v'; + o jsonb := '[]'::jsonb; + i int; + j int; + seen boolean; +BEGIN + FOR i IN 0 .. jsonb_array_length(l) - 1 LOOP + seen := false; + FOR j IN 0 .. jsonb_array_length(o) - 1 LOOP + IF cel._equal(l -> i, o -> j) THEN + seen := true; + EXIT; + END IF; + END LOOP; + IF NOT seen THEN + o := o || jsonb_build_array(l -> i); + END IF; + END LOOP; + RETURN cel._list_val(o); +END; +$$; + +-- sortBy(e, keyExpr) expands to a bind-style comprehension +-- (lists.go:594): fold the target into @__sortBy_input__, then call +-- @sortByAssociatedKeys with the mapped keys. +CREATE OR REPLACE FUNCTION cel._mx_sort_by( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + cs jsonb := target -> 's'; + ce jsonb := target -> 'e'; + id bigint := next_id; + vref jsonb; + mapc record; + callx jsonb; + init jsonb; + cond jsonb; + itv record; +BEGIN + IF target ->> 'k' NOT IN + ('list', 'select', 'ident', 'comp', 'call') THEN + err := 'sortBy can only be applied to a list, identifier, ' + || 'comprehension, call or select expression'; + RETURN; + END IF; + SELECT * INTO itv FROM cel._mx_itervar(args -> 0); + IF itv.err IS NOT NULL THEN + err := itv.err; + RETURN; + END IF; + id := id + 1; + vref := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@__sortBy_input__', 's', cs, 'e', ce); + SELECT * INTO mapc FROM cel._mx_fold( + 'map', vref, args -> 0 ->> 'name', NULL, args -> 1, id); + IF mapc.err IS NOT NULL THEN + err := mapc.err; + RETURN; + END IF; + id := mapc.next_id_out + 1; + vref := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@__sortBy_input__', 's', cs, 'e', ce); + id := id + 1; + callx := jsonb_build_object('id', id, 'k', 'call', + 'fn', '@sortByAssociatedKeys', 'target', vref, + 'args', jsonb_build_array(mapc.expr), 's', cs, 'e', ce); + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'list', + 'elems', '[]'::jsonb, 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', false), + 's', cs, 'e', ce); + id := id + 1; + vref := jsonb_build_object('id', id, 'k', 'ident', + 'name', '@__sortBy_input__', 's', cs, 'e', ce); + id := id + 1; + expr := jsonb_build_object( + 'id', id, 'k', 'comp', + 'range', init, 'iter', '#unused', 'iter2', '', + 'accu', '@__sortBy_input__', + 'init', target, 'cond', cond, 'step', vref, 'result', callx, + 's', cs, 'e', ce); + next_id_out := id; +END; +$$; + +-- Registry rows -------------------------------------------------------- + +INSERT INTO cel.macro (name, arity, member, expander) VALUES + ('sortBy', 2, true, 'cel._mx_sort_by(jsonb,jsonb,bigint)') +ON CONFLICT (name, arity, member) DO UPDATE + SET expander = excluded.expander; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('list_slice', 'slice', true, + '[{"kind": "list", "params": [{"kind": "param", "name": "T"}]}, + {"kind": "int"}, {"kind": "int"}]', + '{"kind": "list", "params": [{"kind": "param", "name": "T"}]}', + 'cel._f_list_slice(jsonb[])', 10), + ('list_flatten', 'flatten', true, + '[{"kind": "list", "params": [{"kind": "list", + "params": [{"kind": "param", "name": "T"}]}]}]', + '{"kind": "list", "params": [{"kind": "param", "name": "T"}]}', + 'cel._f_list_flatten(jsonb[])', 10), + ('list_flatten_int', 'flatten', true, + '[{"kind": "list", "params": [{"kind": "dyn"}]}, + {"kind": "int"}]', + '{"kind": "list", "params": [{"kind": "dyn"}]}', + 'cel._f_list_flatten(jsonb[])', 20), + ('lists_range', 'lists.range', false, + '[{"kind": "int"}]', + '{"kind": "list", "params": [{"kind": "int"}]}', + 'cel._f_lists_range(jsonb[])', 10), + ('list_reverse', 'reverse', true, + '[{"kind": "list", "params": [{"kind": "param", "name": "T"}]}]', + '{"kind": "list", "params": [{"kind": "param", "name": "T"}]}', + 'cel._f_list_reverse(jsonb[])', 20), + ('list_distinct', 'distinct', true, + '[{"kind": "list", "params": [{"kind": "param", "name": "T"}]}]', + '{"kind": "list", "params": [{"kind": "param", "name": "T"}]}', + 'cel._f_list_distinct(jsonb[])', 10) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +-- sort() and @sortByAssociatedKeys(): one row per comparable element +-- type, sharing an impl (lists.go templatedOverloads). +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +SELECT 'list_' || tn || '_sort', 'sort', true, + jsonb_build_array(jsonb_build_object( + 'kind', 'list', 'params', jsonb_build_array(t))), + jsonb_build_object( + 'kind', 'list', 'params', jsonb_build_array(t)), + 'cel._f_list_sort(jsonb[])', ord +FROM (VALUES + ('int', '{"kind":"int"}'::jsonb, 10), + ('uint', '{"kind":"uint"}'::jsonb, 20), + ('double', '{"kind":"double"}'::jsonb, 30), + ('bool', '{"kind":"bool"}'::jsonb, 40), + ('google.protobuf.Duration', '{"kind":"duration"}'::jsonb, 50), + ('google.protobuf.Timestamp', '{"kind":"timestamp"}'::jsonb, 60), + ('string', '{"kind":"string"}'::jsonb, 70), + ('bytes', '{"kind":"bytes"}'::jsonb, 80) +) v(tn, t, ord) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +SELECT 'list_' || tn || '_sortByAssociatedKeys', + '@sortByAssociatedKeys', true, + jsonb_build_array( + '{"kind":"list","params":[{"kind":"param","name":"T"}]}' + ::jsonb, + jsonb_build_object( + 'kind', 'list', 'params', jsonb_build_array(t))), + '{"kind":"list","params":[{"kind":"param","name":"T"}]}' + ::jsonb, + 'cel._f_list_sort_by_keys(jsonb[])', ord +FROM (VALUES + ('int', '{"kind":"int"}'::jsonb, 10), + ('uint', '{"kind":"uint"}'::jsonb, 20), + ('double', '{"kind":"double"}'::jsonb, 30), + ('bool', '{"kind":"bool"}'::jsonb, 40), + ('google.protobuf.Duration', '{"kind":"duration"}'::jsonb, 50), + ('google.protobuf.Timestamp', '{"kind":"timestamp"}'::jsonb, 60), + ('string', '{"kind":"string"}'::jsonb, 70), + ('bytes', '{"kind":"bytes"}'::jsonb, 80) +) v(tn, t, ord) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'lists', 'overload', id FROM cel.overload +WHERE id IN ('list_slice', 'list_flatten', 'list_flatten_int', + 'lists_range', 'list_reverse', 'list_distinct') + OR id LIKE 'list\_%\_sort' + OR id LIKE 'list\_%\_sortByAssociatedKeys' +ON CONFLICT DO NOTHING; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('lists', 'macro', 'sortBy/2/1') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/150_ext_encoders.sql b/sql/150_ext_encoders.sql new file mode 100644 index 0000000..be76866 --- /dev/null +++ b/sql/150_ext_encoders.sql @@ -0,0 +1,64 @@ +-- The encoders extension (cel-go ext/encoders.go at the pinned +-- v0.32.0): base64.encode / base64.decode. Registered under the +-- 'encoders' env. + +BEGIN; + +-- Go accepts both padded and raw (unpadded) standard base64 +-- (encoders.go:143-150); Postgres decode requires padding, so pad +-- first. +CREATE OR REPLACE FUNCTION cel._f_base64_decode(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + s text := args[1] ->> 'v'; +BEGIN + IF length(s) % 4 <> 0 THEN + s := rpad(s, length(s) + 4 - length(s) % 4, '='); + END IF; + RETURN jsonb_build_object('@t', 'bytes', 'v', + translate(encode(decode(s, 'base64'), 'base64'), + E'\n', '')); +EXCEPTION WHEN OTHERS THEN + RETURN cel._err(format('illegal base64 data in %s', + quote_literal(args[1] ->> 'v'))); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_base64_encode(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', + translate(encode(decode(args[1] ->> 'v', 'base64'), 'base64'), + E'\n', '')); +$$; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('base64_decode_string', 'base64.decode', false, + '[{"kind": "string"}]', '{"kind": "bytes"}', + 'cel._f_base64_decode(jsonb[])', 10), + ('base64_encode_bytes', 'base64.encode', false, + '[{"kind": "bytes"}]', '{"kind": "string"}', + 'cel._f_base64_encode(jsonb[])', 10) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('encoders', 'overload', 'base64_decode_string'), + ('encoders', 'overload', 'base64_encode_bytes') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/160_ext_bindings.sql b/sql/160_ext_bindings.sql new file mode 100644 index 0000000..74511db --- /dev/null +++ b/sql/160_ext_bindings.sql @@ -0,0 +1,66 @@ +-- The bindings extension (cel-go ext/bindings.go at the pinned +-- v0.32.0): the cel.bind(var, init, expr) macro, expanding to the +-- bind-style comprehension (empty range, accumulator = the bound +-- variable). Registered under the 'bindings' env. + +BEGIN; + +CREATE OR REPLACE FUNCTION cel._mx_cel_bind( + target jsonb, args jsonb, next_id bigint, + OUT expr jsonb, OUT next_id_out bigint, OUT err text +) +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + cs jsonb := target -> 's'; + ce jsonb := target -> 'e'; + id bigint := next_id; + nm text; + init jsonb; + cond jsonb; + step jsonb; +BEGIN + next_id_out := next_id; + -- Decline unless the receiver is the cel namespace. + IF target ->> 'k' <> 'ident' + OR ltrim(target ->> 'name', '.') <> 'cel' THEN + RETURN; + END IF; + IF args -> 0 ->> 'k' <> 'ident' THEN + err := 'cel.bind() variable names must be simple identifiers'; + RETURN; + END IF; + nm := args -> 0 ->> 'name'; + id := id + 1; + init := jsonb_build_object('id', id, 'k', 'list', + 'elems', '[]'::jsonb, 's', cs, 'e', ce); + id := id + 1; + cond := jsonb_build_object('id', id, 'k', 'lit', + 'v', jsonb_build_object('@t', 'bool', 'v', false), + 's', cs, 'e', ce); + id := id + 1; + step := jsonb_build_object('id', id, 'k', 'ident', + 'name', nm, 's', cs, 'e', ce); + id := id + 1; + expr := jsonb_build_object( + 'id', id, 'k', 'comp', + 'range', init, 'iter', '#unused', 'iter2', '', + 'accu', nm, + 'init', args -> 1, 'cond', cond, 'step', step, + 'result', args -> 2, 's', cs, 'e', ce); + next_id_out := id; +END; +$$; + +INSERT INTO cel.macro (name, arity, member, expander) VALUES + ('bind', 3, true, 'cel._mx_cel_bind(jsonb,jsonb,bigint)') +ON CONFLICT (name, arity, member) DO UPDATE + SET expander = excluded.expander; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('bindings', 'macro', 'bind/3/1') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/sql/170_ext_network.sql b/sql/170_ext_network.sql new file mode 100644 index 0000000..6774c36 --- /dev/null +++ b/sql/170_ext_network.sql @@ -0,0 +1,523 @@ +-- The network extension (cel-go ext/network.go at the pinned +-- v0.32.0): net.IP / net.CIDR opaque types over Postgres inet +-- machinery, 21 overloads. Registered under the 'network' env. +-- +-- Values store canonical text: net.IP as the canonical address +-- string, net.CIDR as '/' with host bits +-- preserved (netip.Prefix keeps them; masked() is explicit). +-- Structural payload identity in cel._equal then matches cel-go's +-- equality. +-- +-- Parsing is Go netip's strictness, which Postgres inet does not +-- share: no leading zeros in IPv4 octets, no partial addresses, no +-- zone suffixes, no IPv4-mapped IPv6, and a CIDR requires an +-- explicit /bits. + +BEGIN; + +-- Strict address parse. Returns the canonical text or NULL when the +-- input is not a valid address under netip.ParseAddr rules. +CREATE OR REPLACE FUNCTION cel._net_parse_ip(s text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v inet; +BEGIN + IF s ~ '%' THEN + RETURN NULL; -- zones are not allowed + END IF; + IF position(':' IN s) = 0 THEN + -- IPv4: exactly four octets, 0-255, no leading zeros. + IF s !~ '^(0|[1-9]\d{0,2})(\.(0|[1-9]\d{0,2})){3}$' THEN + RETURN NULL; + END IF; + IF EXISTS ( + SELECT FROM unnest(string_to_array(s, '.')) o + WHERE o::int > 255) THEN + RETURN NULL; + END IF; + RETURN s; + END IF; + BEGIN + v := s::inet; + EXCEPTION WHEN OTHERS THEN + RETURN NULL; + END; + IF family(v) <> 6 OR masklen(v) <> 128 THEN + RETURN NULL; + END IF; + -- IPv4-mapped IPv6: the dotted text form is rejected, the hex + -- form parses and unmaps to the IPv4 address (corpus + -- network_ext/parse_invalid_ipv4_in_ipv6 vs ipv4_equals_ipv6 -- + -- cel-go v0.32.0 rejects both and does not run this file in its + -- own conformance; the corpus is the authority). + IF v <<= inet '::ffff:0.0.0.0/96' THEN + IF position('.' IN s) > 0 THEN + RETURN NULL; + END IF; + RETURN (regexp_match(host(v), '([^:]*)$'))[1]; + END IF; + RETURN host(v); +END; +$$; + +-- Strict prefix parse. Returns canonical '/' or NULL. +CREATE OR REPLACE FUNCTION cel._net_parse_cidr(s text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + addr text; + bits text; + a text; +BEGIN + IF s !~ '^[^/]+/[^/]+$' THEN + RETURN NULL; + END IF; + addr := split_part(s, '/', 1); + bits := split_part(s, '/', 2); + a := cel._net_parse_ip(addr); + IF a IS NULL THEN + RETURN NULL; + END IF; + IF bits !~ '^(0|[1-9]\d{0,2})$' THEN + RETURN NULL; + END IF; + IF position(':' IN a) > 0 THEN + IF bits::int > 128 THEN + RETURN NULL; + END IF; + ELSIF bits::int > 32 THEN + RETURN NULL; + END IF; + RETURN a || '/' || bits::int; +END; +$$; + +CREATE OR REPLACE FUNCTION cel._net_ip_val(t text) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'opaque', 'type', 'net.IP', + 'v', t); +$$; + +CREATE OR REPLACE FUNCTION cel._net_cidr_val(t text) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'opaque', 'type', 'net.CIDR', + 'v', t); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_string_to_ip(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a text := cel._net_parse_ip(args[1] ->> 'v'); +BEGIN + IF a IS NULL THEN + RETURN cel._err(format( + 'IP Address %s parse error during conversion from string', + quote_literal(args[1] ->> 'v'))); + END IF; + RETURN cel._net_ip_val(a); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_string_to_cidr(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a text := cel._net_parse_cidr(args[1] ->> 'v'); +BEGIN + IF a IS NULL THEN + RETURN cel._err(format( + 'CIDR %s parse error during conversion from string', + quote_literal(args[1] ->> 'v'))); + END IF; + RETURN cel._net_cidr_val(a); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_is_ip(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + cel._net_parse_ip(args[1] ->> 'v') IS NOT NULL); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_is_cidr(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + cel._net_parse_cidr(args[1] ->> 'v') IS NOT NULL); +$$; + +-- isCanonical: parses and compares against the canonical rendering +-- (RFC 5952 for IPv6 -- Postgres inet output follows it). +CREATE OR REPLACE FUNCTION cel._f_net_ip_is_canonical(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a text := cel._net_parse_ip(args[1] ->> 'v'); +BEGIN + IF a IS NULL THEN + RETURN cel._err(format( + 'IP Address %s parse error during conversion from string', + quote_literal(args[1] ->> 'v'))); + END IF; + RETURN cel._bool_val(a = (args[1] ->> 'v')); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_ip_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', args[1] ->> 'v'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_cidr_to_string(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT jsonb_build_object('@t', 'string', 'v', args[1] ->> 'v'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_ip_family(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val(family((args[1] ->> 'v')::inet)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_cidr_ip(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._net_ip_val(host((args[1] ->> 'v')::inet)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_cidr_masked(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._net_cidr_val( + host(network((args[1] ->> 'v')::inet)) || '/' + || masklen((args[1] ->> 'v')::inet)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_cidr_prefix_length(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._int_val(masklen((args[1] ->> 'v')::inet)); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_cidr_is_mask(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + host((args[1] ->> 'v')::inet) + = host(network((args[1] ->> 'v')::inet))); +$$; + +-- Containment: families must match (netip returns false, never an +-- error, on family mismatch), then Postgres's network containment +-- compares the masked prefixes. +CREATE OR REPLACE FUNCTION cel._net_contains( + parent inet, child inet, cidr_child boolean +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +BEGIN + IF family(parent) <> family(child) THEN + RETURN false; + END IF; + IF cidr_child AND masklen(child) < masklen(parent) THEN + RETURN false; + END IF; + RETURN network(child) <<= network(parent) + OR network(child) = network(parent); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_contains_ip(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a text; +BEGIN + IF args[2] ->> '@t' = 'string' THEN + a := cel._net_parse_ip(args[2] ->> 'v'); + IF a IS NULL THEN + RETURN cel._err(format( + 'IP Address %s parse error during conversion from string', + quote_literal(args[2] ->> 'v'))); + END IF; + ELSE + a := args[2] ->> 'v'; + END IF; + RETURN cel._bool_val(cel._net_contains( + (args[1] ->> 'v')::inet, a::inet, false)); +END; +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_contains_cidr(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + a text; +BEGIN + IF args[2] ->> '@t' = 'string' THEN + a := cel._net_parse_cidr(args[2] ->> 'v'); + IF a IS NULL THEN + RETURN cel._err(format( + 'CIDR %s parse error during conversion from string', + quote_literal(args[2] ->> 'v'))); + END IF; + ELSE + a := args[2] ->> 'v'; + END IF; + RETURN cel._bool_val(cel._net_contains( + (args[1] ->> 'v')::inet, a::inet, true)); +END; +$$; + +-- Address classification (Go net/netip semantics). +CREATE OR REPLACE FUNCTION cel._f_net_ip_is_loopback(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + CASE WHEN family((args[1] ->> 'v')::inet) = 4 + THEN (args[1] ->> 'v')::inet <<= inet '127.0.0.0/8' + ELSE (args[1] ->> 'v')::inet = inet '::1' + END); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_ip_is_unspecified(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + (args[1] ->> 'v')::inet = inet '0.0.0.0' + OR (args[1] ->> 'v')::inet = inet '::'); +$$; + +CREATE OR REPLACE FUNCTION cel._f_net_ip_is_ll_unicast(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + CASE WHEN family((args[1] ->> 'v')::inet) = 4 + THEN (args[1] ->> 'v')::inet <<= inet '169.254.0.0/16' + ELSE (args[1] ->> 'v')::inet <<= inet 'fe80::/10' + END); +$$; + +-- Link-local multicast: 224.0.0.0/24, or IPv6 ffX2::/16 (first byte +-- 0xff, low nibble of the second byte 0x2 -- the flags nibble is +-- arbitrary, so mask with ff0f::). +CREATE OR REPLACE FUNCTION cel._f_net_ip_is_ll_mcast(args jsonb[]) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ + SELECT cel._bool_val( + CASE WHEN family((args[1] ->> 'v')::inet) = 4 + THEN (args[1] ->> 'v')::inet <<= inet '224.0.0.0/24' + ELSE ((args[1] ->> 'v')::inet & inet 'ff0f::') + = inet 'ff02::' + END); +$$; + +-- Global unicast: everything except unspecified, loopback, +-- multicast, link-local unicast, and the IPv4 broadcast address. +CREATE OR REPLACE FUNCTION cel._f_net_ip_is_global_ucast(args jsonb[]) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + v inet := (args[1] ->> 'v')::inet; +BEGIN + IF family(v) = 4 THEN + RETURN cel._bool_val(NOT ( + v = inet '0.0.0.0' + OR v = inet '255.255.255.255' + OR v <<= inet '127.0.0.0/8' + OR v <<= inet '169.254.0.0/16' + OR v <<= inet '224.0.0.0/4')); + END IF; + RETURN cel._bool_val(NOT ( + v = inet '::' + OR v = inet '::1' + OR v <<= inet 'fe80::/10' + OR v <<= inet 'ff00::/8')); +END; +$$; + +-- Registry rows -------------------------------------------------------- + +INSERT INTO cel.type (name, kind) VALUES + ('net.IP', '{"kind": "opaque", "name": "net.IP"}'), + ('net.CIDR', '{"kind": "opaque", "name": "net.CIDR"}') +ON CONFLICT (name) DO UPDATE SET kind = excluded.kind; + +INSERT INTO cel.overload + (id, function, member, arg_types, result_type, impl, ordinal) +VALUES + ('string_to_ip', 'ip', false, + '[{"kind": "string"}]', + '{"kind": "opaque", "name": "net.IP"}', + 'cel._f_net_string_to_ip(jsonb[])', 10), + ('cidr_ip', 'ip', true, + '[{"kind": "opaque", "name": "net.CIDR"}]', + '{"kind": "opaque", "name": "net.IP"}', + 'cel._f_net_cidr_ip(jsonb[])', 20), + ('string_to_cidr', 'cidr', false, + '[{"kind": "string"}]', + '{"kind": "opaque", "name": "net.CIDR"}', + 'cel._f_net_string_to_cidr(jsonb[])', 10), + ('ip_to_string', 'string', false, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "string"}', + 'cel._f_net_ip_to_string(jsonb[])', 90), + ('cidr_to_string', 'string', false, + '[{"kind": "opaque", "name": "net.CIDR"}]', '{"kind": "string"}', + 'cel._f_net_cidr_to_string(jsonb[])', 100), + ('ip_family', 'family', true, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "int"}', + 'cel._f_net_ip_family(jsonb[])', 10), + ('ip_is_canonical', 'ip.isCanonical', false, + '[{"kind": "string"}]', '{"kind": "bool"}', + 'cel._f_net_ip_is_canonical(jsonb[])', 10), + ('is_ip', 'isIP', false, + '[{"kind": "string"}]', '{"kind": "bool"}', + 'cel._f_net_is_ip(jsonb[])', 10), + ('is_cidr', 'isCIDR', false, + '[{"kind": "string"}]', '{"kind": "bool"}', + 'cel._f_net_is_cidr(jsonb[])', 10), + ('cidr_contains_ip_ip', 'containsIP', true, + '[{"kind": "opaque", "name": "net.CIDR"}, + {"kind": "opaque", "name": "net.IP"}]', + '{"kind": "bool"}', 'cel._f_net_contains_ip(jsonb[])', 10), + ('cidr_contains_ip_string', 'containsIP', true, + '[{"kind": "opaque", "name": "net.CIDR"}, {"kind": "string"}]', + '{"kind": "bool"}', 'cel._f_net_contains_ip(jsonb[])', 20), + ('cidr_contains_cidr', 'containsCIDR', true, + '[{"kind": "opaque", "name": "net.CIDR"}, + {"kind": "opaque", "name": "net.CIDR"}]', + '{"kind": "bool"}', 'cel._f_net_contains_cidr(jsonb[])', 10), + ('cidr_contains_cidr_string', 'containsCIDR', true, + '[{"kind": "opaque", "name": "net.CIDR"}, {"kind": "string"}]', + '{"kind": "bool"}', 'cel._f_net_contains_cidr(jsonb[])', 20), + ('ip_is_loopback', 'isLoopback', true, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "bool"}', + 'cel._f_net_ip_is_loopback(jsonb[])', 10), + ('ip_is_unspecified', 'isUnspecified', true, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "bool"}', + 'cel._f_net_ip_is_unspecified(jsonb[])', 10), + ('ip_is_link_local_unicast', 'isLinkLocalUnicast', true, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "bool"}', + 'cel._f_net_ip_is_ll_unicast(jsonb[])', 10), + ('ip_is_link_local_multicast', 'isLinkLocalMulticast', true, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "bool"}', + 'cel._f_net_ip_is_ll_mcast(jsonb[])', 10), + ('ip_is_global_unicast', 'isGlobalUnicast', true, + '[{"kind": "opaque", "name": "net.IP"}]', '{"kind": "bool"}', + 'cel._f_net_ip_is_global_ucast(jsonb[])', 10), + ('cidr_masked', 'masked', true, + '[{"kind": "opaque", "name": "net.CIDR"}]', + '{"kind": "opaque", "name": "net.CIDR"}', + 'cel._f_net_cidr_masked(jsonb[])', 10), + ('cidr_prefix_length', 'prefixLength', true, + '[{"kind": "opaque", "name": "net.CIDR"}]', '{"kind": "int"}', + 'cel._f_net_cidr_prefix_length(jsonb[])', 10), + ('cidr_is_mask', 'isMask', true, + '[{"kind": "opaque", "name": "net.CIDR"}]', '{"kind": "bool"}', + 'cel._f_net_cidr_is_mask(jsonb[])', 10) +ON CONFLICT (id) DO UPDATE SET + function = excluded.function, + member = excluded.member, + arg_types = excluded.arg_types, + result_type = excluded.result_type, + impl = excluded.impl, + ordinal = excluded.ordinal; + +INSERT INTO cel.env_item (env, kind, ref) +SELECT 'network', 'overload', id FROM cel.overload +WHERE id IN ( + 'string_to_ip', 'cidr_ip', 'string_to_cidr', 'ip_to_string', + 'cidr_to_string', 'ip_family', 'ip_is_canonical', 'is_ip', + 'is_cidr', 'cidr_contains_ip_ip', 'cidr_contains_ip_string', + 'cidr_contains_cidr', 'cidr_contains_cidr_string', + 'ip_is_loopback', 'ip_is_unspecified', + 'ip_is_link_local_unicast', 'ip_is_link_local_multicast', + 'ip_is_global_unicast', 'cidr_masked', 'cidr_prefix_length', + 'cidr_is_mask') +ON CONFLICT DO NOTHING; + +INSERT INTO cel.env_item (env, kind, ref) VALUES + ('network', 'type', 'net.IP'), + ('network', 'type', 'net.CIDR') +ON CONFLICT DO NOTHING; + +COMMIT; From 69192a6f669cefae45837d64f46c698e0248f020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 21:02:57 -0300 Subject: [PATCH 15/21] Add the cel.evaluate one-shot entry point The architecture promises a single evaluate(source, activation, env) for the application role that gets EXECUTE on nothing else. It composes the three stages and folds parse or check rejections into a CEL error value, so callers handle exactly one result type; anyone needing the distinct stages or caller-managed memoization uses parse/check/eval directly. --- sql/050_eval.sql | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/sql/050_eval.sql b/sql/050_eval.sql index 734239f..407ded8 100644 --- a/sql/050_eval.sql +++ b/sql/050_eval.sql @@ -829,4 +829,32 @@ AS $$ SELECT cel.eval(ast, activation, env, '{}'::jsonb); $$; +-- The one-shot composition: parse, check, eval. A parse or check +-- rejection comes back as a CEL error value carrying the first +-- message, so callers see one result type; callers that need the +-- distinct stages (or memoization) use them directly. +CREATE OR REPLACE FUNCTION cel.evaluate( + source text, + activation jsonb, + env text +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE PARALLEL SAFE +SET search_path = cel, pg_temp +AS $$ +DECLARE + ast jsonb := cel.parse(source, env); +BEGIN + IF ast ? 'errors' THEN + RETURN cel._err(ast -> 'errors' -> 0 ->> 'msg'); + END IF; + ast := cel.check(ast, env); + IF ast ? 'errors' THEN + RETURN cel._err(ast -> 'errors' -> 0 ->> 'msg'); + END IF; + RETURN cel.eval(ast, activation, env); +END; +$$; + COMMIT; From 309c738fcc00ae0256efe3c51bb8c6b5ebd55df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sat, 22 Aug 2026 21:03:22 -0300 Subject: [PATCH 16/21] Record the completed evaluator in CLAUDE.md The status paragraph still described the well-known types and every extension library as unwritten. All of it now exists and the in-scope conformance corpus passes on a fresh install, so the paragraph describes the shipped pipeline and where the skip list lives instead of a plan. --- CLAUDE.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f44134a..4096ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,21 +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: core pipeline working, well-known types pending.** The -registry tables (`sql/010_registry.sql`), value representation and -comparison (`020_values.sql`), parser and macro engine -(`030_parse.sql`), type checker (`040_check.sql`), evaluator -(`050_eval.sql`) and standard library (`060_stdlib.sql`) exist and -run the conformance corpus: the core-language files (basic, -comparisons, conversions, logic, integer/fp math, lists, string, -macros, fields, namespace, plumbing, parse) pass except for cases -needing the well-known types. Timestamps/durations, the WKT -wrappers, `Struct`/`Value`/`Any`, unknowns, and every extension -library are still unwritten; sections below describing those are -design, not code. When you implement a piece, replace the -prescriptive wording 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 From e1f1a5703f0015cb3fccda76d82dbb91600a7fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sun, 30 Aug 2026 17:13:22 -0300 Subject: [PATCH 17/21] Give the cel-spec corpus pin a single home The corpus commit was written down only in the CI workflow. The conformance report about to be added prints that commit as provenance, and a number is not reproducible if the corpus it was measured on is recorded in a place nothing checks: a workflow edited to a different ref and a report claiming the old one would each be internally consistent and jointly wrong. Pin now lives beside the corpus loader, the workflow is checked against it, and the local checkout is checked too -- a developer whose cel-spec has wandered learns it from a named failure rather than from a report that disagrees with everyone else's. HeadSHA is deliberately soft: a checkout unpacked from an archive has no commit to report, which is a working configuration rather than a broken one. --- .github/workflows/ci.yml | 4 ++- internal/corpus/corpus.go | 48 +++++++++++++++++++++++-- internal/corpus/corpus_test.go | 66 +++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c3bb3..7fe7222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,9 @@ jobs: # 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. + # 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 diff --git a/internal/corpus/corpus.go b/internal/corpus/corpus.go index e046385..1058da3 100644 --- a/internal/corpus/corpus.go +++ b/internal/corpus/corpus.go @@ -10,6 +10,7 @@ package corpus import ( "fmt" "os" + "os/exec" "path/filepath" "regexp" "slices" @@ -30,9 +31,18 @@ import ( _ "cel.dev/expr/conformance/proto3" ) -// Dir returns the testdata directory of the cel-spec checkout, or an +// Pin is the cel-spec commit the conformance corpus is measured at. +// +// It is pinned for the same reason cel-go is: the corpus defines what +// a conformance number means, so it moves only deliberately, with a +// re-measurement behind it. CI checks the corpus out at this commit +// and the conformance report prints it, so this constant is the one +// place the pin is written down -- a test keeps the workflow honest. +const Pin = "ba58ae5007845f3a1279b488cdeb79645ce958bb" + +// Checkout returns the root of the local cel-spec checkout, or an // error naming the variable that fixes a missing configuration. -func Dir() (string, error) { +func Checkout() (string, error) { root, err := dotenv.Lookup("CEL_EXPR_DIR") if err != nil { return "", err @@ -45,7 +55,18 @@ func Dir() (string, error) { ) } - dir := filepath.Join(root, "cel-spec", "tests", "simple", "testdata") + return filepath.Join(root, "cel-spec"), nil +} + +// Dir returns the testdata directory of the cel-spec checkout, or an +// error naming the variable that fixes a missing configuration. +func Dir() (string, error) { + checkout, err := Checkout() + if err != nil { + return "", err + } + + dir := filepath.Join(checkout, "tests", "simple", "testdata") if _, err := os.Stat(dir); err != nil { return "", fmt.Errorf( "conformance corpus not found at %s: is CEL_EXPR_DIR "+ @@ -57,6 +78,27 @@ func Dir() (string, error) { return dir, nil } +// HeadSHA returns the commit the local cel-spec checkout sits at, or +// "" when that cannot be determined -- a checkout unpacked from an +// archive rather than cloned has no commit to report, and that is a +// working configuration, not an error. Callers report the difference +// rather than failing on it. +func HeadSHA() (string, error) { + checkout, err := Checkout() + if err != nil { + return "", err + } + + out, err := exec.Command( + "git", "-C", checkout, "rev-parse", "HEAD", + ).Output() + if err != nil { + return "", nil + } + + return strings.TrimSpace(string(out)), nil +} + // Files returns the corpus file names (without extension), sorted. func Files() ([]string, error) { dir, err := Dir() diff --git a/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go index 6e16bed..7c7dce6 100644 --- a/internal/corpus/corpus_test.go +++ b/internal/corpus/corpus_test.go @@ -1,6 +1,13 @@ package corpus -import "testing" +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/emfga/cel4postgres/internal/repo" +) // The corpus is external input: these tests pin the facts the suite // depends on -- the checkout is findable, every file parses, and the @@ -58,3 +65,60 @@ func TestKnownCasePresent(t *testing.T) { } t.Fatal("basic/self_eval_zeroish/self_eval_int_zero not found") } + +// The pin lives in one place. CI checks the corpus out by commit, and +// a workflow that drifts from the constant would measure a different +// corpus than the report claims -- silently, since both would be +// internally consistent. +func TestPinMatchesWorkflow(t *testing.T) { + root, err := repo.Root() + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(root, ".github", "workflows", "ci.yml") + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read ci.yml: %v", err) + } + + pattern := regexp.MustCompile( + `repository:\s*cel-expr/cel-spec\s*\n\s*ref:\s*([0-9a-f]{40})`, + ) + + match := pattern.FindSubmatch(source) + if match == nil { + t.Fatal("ci.yml does not check cel-expr/cel-spec out by commit") + } + + if ref := string(match[1]); ref != Pin { + t.Fatalf( + "ci.yml checks out cel-spec %s but corpus.Pin says %s\n"+ + "The corpus defines what the conformance number means: "+ + "move both together, with a re-measurement behind it.", + ref, Pin, + ) + } +} + +// The local checkout is measured against the same pin, so a developer +// whose cel-spec has wandered learns it here rather than from a +// conformance report that disagrees with everyone else's. +func TestCheckoutMatchesPin(t *testing.T) { + head, err := HeadSHA() + if err != nil { + t.Fatal(err) + } + if head == "" { + t.Skip("cel-spec checkout reports no commit") + } + + if head != Pin { + t.Fatalf( + "the cel-spec checkout is at %s but corpus.Pin says %s\n"+ + "Check the corpus out at the pinned commit, or move "+ + "the pin deliberately.", + head, Pin, + ) + } +} From 53385fe230d0094156aa8cc2595241eed209278e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sun, 30 Aug 2026 17:14:56 -0300 Subject: [PATCH 18/21] Lift the conformance case runner out of the test The suite was the only thing that could run a corpus case: runCase took a *testing.T and reported through it. The conformance report being added next needs the same execution, and a generator that ran the corpus its own way would eventually claim a pass the suite fails -- two runners disagreeing is worse than no report. RunCase now returns a CaseResult and the subtest is a switch over it, so there is exactly one place a case is parsed, checked, evaluated and compared. No behaviour changes: the comparisons are the same, moved from t.Fatalf to errors carrying the same text. --- conformance/run.go | 362 +++++++++++++++++++++++++++++++++++++ conformance/simple_test.go | 305 +------------------------------ 2 files changed, 371 insertions(+), 296 deletions(-) create mode 100644 conformance/run.go diff --git a/conformance/run.go b/conformance/run.go new file mode 100644 index 0000000..2871c9a --- /dev/null +++ b/conformance/run.go @@ -0,0 +1,362 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + + test "cel.dev/expr/conformance/test" + "github.com/jackc/pgx/v5" + + "github.com/emfga/cel4postgres/internal/codec" +) + +// Running a conformance case lives here rather than in the test file +// because two callers need it: the suite, and the report generator +// (internal/cmd/confreport). A report that ran the corpus its own way +// could claim a pass the suite fails, so there is one execution path +// and the suite is a thin wrapper over it. + +// Status is the outcome of one conformance case. +type Status int + +const ( + // Passed means the case ran and matched the corpus expectation. + Passed Status = iota + // Skipped means the case was not attempted; Detail says why. + Skipped + // Failed means the case ran and disagreed with the corpus, or + // could not be run at all; Detail says which. + Failed +) + +// CaseResult is what running one corpus case produced. +type CaseResult struct { + File string + Section string + Name string + Status Status + // Detail is the skip reason for Skipped and the failure message + // for Failed, and is empty for Passed. + Detail string +} + +// StageSet records which cel.* entry points exist in the database, so +// failures name the missing stage rather than surfacing SQL errors. +type StageSet struct { + Parse, Check, Eval bool +} + +// InstalledStages probes the database once per run, so a +// not-yet-implemented stage fails each case with one clear line +// instead of thousands of undefined-function SQL errors, and so a +// missing function can never masquerade as a CEL error an eval_error +// case would spuriously pass on. +func InstalledStages( + ctx context.Context, conn *pgx.Conn, +) (StageSet, error) { + var stages StageSet + err := conn.QueryRow(ctx, + `SELECT to_regprocedure('cel.parse(text, text)') IS NOT NULL, + to_regprocedure('cel.check(jsonb, text, jsonb)') + IS NOT NULL, + to_regprocedure('cel.eval(jsonb, jsonb, text)') + IS NOT NULL`, + ).Scan(&stages.Parse, &stages.Check, &stages.Eval) + if err != nil { + return stages, fmt.Errorf("probe installed stages: %w", err) + } + return stages, nil +} + +// RunCase parses, checks and evaluates one corpus case against the +// database and compares the outcome to the case's result matcher. +func RunCase( + ctx context.Context, + conn *pgx.Conn, + stages StageSet, + file, section string, + tc *test.SimpleTest, +) CaseResult { + result := CaseResult{File: file, Section: section, Name: tc.GetName()} + + if reason := skipReason(file, section, tc.GetName()); reason != "" { + result.Status = Skipped + result.Detail = reason + return result + } + + status, detail := runCase(ctx, conn, stages, file, tc) + result.Status = status + result.Detail = detail + return result +} + +// runCase returns Passed with an empty detail, or Failed with the +// message the suite reports. +func runCase( + ctx context.Context, + conn *pgx.Conn, + stages StageSet, + file string, + tc *test.SimpleTest, +) (Status, string) { + fail := func(format string, args ...any) (Status, string) { + return Failed, fmt.Sprintf(format, args...) + } + + env := EnvFor(file) + + // Parse. + if !stages.Parse { + return fail("parse stage: cel.parse(text, text) is not installed") + } + var raw []byte + err := conn.QueryRow(ctx, + "SELECT cel.parse($1, $2)", tc.GetExpr(), env, + ).Scan(&raw) + if err != nil { + return fail("parse stage: %v", err) + } + ast, err := codec.Decode(raw) + if err != nil { + return fail("parse stage: %v", err) + } + if errs, failed := stageErrors(ast); failed { + if expectsError(tc) { + return Passed, "" + } + return fail("parse stage: expression rejected: %v", errs) + } + + // Check. + if !tc.GetDisableCheck() { + if !stages.Check { + return fail( + "check stage: cel.check(jsonb, text, jsonb) " + + "is not installed", + ) + } + options, err := checkOptions(tc) + if err != nil { + return fail("check stage: %v", err) + } + err = conn.QueryRow(ctx, + "SELECT cel.check($1, $2, $3)", raw, env, options, + ).Scan(&raw) + if err != nil { + return fail("check stage: %v", err) + } + ast, err = codec.Decode(raw) + if err != nil { + return fail("check stage: %v", err) + } + if errs, failed := stageErrors(ast); failed { + if expectsError(tc) { + return Passed, "" + } + return fail("check stage: expression rejected: %v", errs) + } + } + + if tc.GetCheckOnly() { + if err := compareDeducedType(ast, tc); err != nil { + return fail("%v", err) + } + return Passed, "" + } + + // Eval. + if !stages.Eval { + return fail( + "eval stage: cel.eval(jsonb, jsonb, text) is not installed", + ) + } + activation, err := activationJSON(tc) + if err != nil { + return fail("eval stage: %v", err) + } + // The container reaches eval too: unchecked evaluation resolves + // names at runtime (checked ASTs bind them at check time, where + // the same option arrives via checkOptions). + evalOptions := map[string]any{} + if tc.GetContainer() != "" { + evalOptions["container"] = tc.GetContainer() + } + evalOptionsJSON, err := json.Marshal(evalOptions) + if err != nil { + return fail("eval stage: %v", err) + } + var rawResult []byte + err = conn.QueryRow(ctx, + "SELECT cel.eval($1, $2, $3, $4)", + raw, activation, env, evalOptionsJSON, + ).Scan(&rawResult) + if err != nil { + return fail("eval stage: %v", err) + } + got, err := codec.Decode(rawResult) + if err != nil { + return fail("eval stage: %v", err) + } + + if err := compareResult(got, rawResult, tc); err != nil { + return fail("%v", err) + } + + // typed_result compares the deduced type in addition to the value. + if _, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult); ok { + if err := compareDeducedType(ast, tc); err != nil { + return fail("%v", err) + } + } + + return Passed, "" +} + +// expectsError reports whether the case's result matcher is +// eval_error. Parse and check failures pass exactly these cases; the +// corpus never asserts on error message text (measured -- plumbing +// carries a deliberately bogus one), so existence is the whole test. +func expectsError(tc *test.SimpleTest) bool { + _, ok := tc.GetResultMatcher().(*test.SimpleTest_EvalError) + return ok +} + +// stageErrors extracts the {"errors": [...]} failure shape that +// cel.parse and cel.check return instead of an envelope. +func stageErrors(envelope any) ([]any, bool) { + m, ok := envelope.(map[string]any) + if !ok { + return nil, false + } + errs, ok := m["errors"].([]any) + return errs, ok +} + +// checkOptions builds the options argument of cel.check (decision 7): +// the case's container and its type_env ident declarations. +func checkOptions(tc *test.SimpleTest) ([]byte, error) { + options := map[string]any{} + if tc.GetContainer() != "" { + options["container"] = tc.GetContainer() + } + if typeEnv := tc.GetTypeEnv(); len(typeEnv) > 0 { + decls := []any{} + for _, decl := range typeEnv { + converted, err := codec.FromDecl(decl) + if err != nil { + return nil, err + } + decls = append(decls, converted) + } + options["decls"] = decls + } + return json.Marshal(options) +} + +// activationJSON builds cel.eval's activation: binding name to tagged +// value, always tagged -- the runner never sends plain JSON the +// evaluator would have to guess about. +func activationJSON(tc *test.SimpleTest) ([]byte, error) { + activation := map[string]any{} + for name, value := range tc.GetBindings() { + tagged, err := codec.FromExprValue(value) + if err != nil { + return nil, fmt.Errorf("binding %q: %w", name, err) + } + activation[name] = tagged + } + return json.Marshal(activation) +} + +// compareResult applies the case's result matcher. A missing matcher +// defaults to value: bool true (measured, workspace doc 01). +func compareResult(got any, rawResult []byte, tc *test.SimpleTest) error { + compare := func(want any) error { + if codec.Equal(want, got) { + return nil + } + wantJSON, _ := json.Marshal(want) + return fmt.Errorf("result mismatch:\n want %s\n got %s", + wantJSON, rawResult) + } + + switch matcher := tc.GetResultMatcher().(type) { + case *test.SimpleTest_Value: + want, err := codec.FromValue(matcher.Value) + if err != nil { + return fmt.Errorf("convert expected value: %w", err) + } + return compare(want) + case *test.SimpleTest_EvalError: + if kind, _ := taggedKind(got); kind != "error" { + return fmt.Errorf("expected an error value, got %s", + rawResult) + } + return nil + case *test.SimpleTest_TypedResult: + want, err := codec.FromValue(matcher.TypedResult.GetResult()) + if err != nil { + return fmt.Errorf("convert expected value: %w", err) + } + return compare(want) + case nil: + return compare(map[string]any{"@t": "bool", "v": true}) + default: + // unknown / any_unknowns / any_eval_errors: zero corpus + // cases use them today (measured); a corpus update that + // introduces one must extend the runner, not pass silently. + return fmt.Errorf("unsupported result matcher %T", matcher) + } +} + +// compareDeducedType compares the checked AST's root type against the +// typed_result matcher's deduced type (check_only cases, +// type_deduction file). +func compareDeducedType(ast any, tc *test.SimpleTest) error { + typed, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult) + if !ok { + return fmt.Errorf("check_only case without typed_result matcher") + } + + want, err := codec.FromType(typed.TypedResult.GetDeducedType()) + if err != nil { + return fmt.Errorf("convert expected type: %w", err) + } + + envelope, ok := ast.(map[string]any) + if !ok { + return fmt.Errorf("checked AST is not an object") + } + root, ok := envelope["expr"].(map[string]any) + if !ok { + return fmt.Errorf("checked AST has no root expression") + } + rootID, ok := root["id"].(json.Number) + if !ok { + return fmt.Errorf("root expression has no id") + } + types, ok := envelope["types"].(map[string]any) + if !ok { + return fmt.Errorf("AST is not checked: no types map") + } + + got := types[rootID.String()] + wantJSON, _ := json.Marshal(want) + gotJSON, _ := json.Marshal(got) + if string(wantJSON) != string(gotJSON) { + return fmt.Errorf("deduced type mismatch:\n want %s\n got %s", + wantJSON, gotJSON) + } + return nil +} + +func taggedKind(v any) (string, bool) { + m, ok := v.(map[string]any) + if !ok { + return "", false + } + kind, ok := m["@t"].(string) + return kind, ok +} diff --git a/conformance/simple_test.go b/conformance/simple_test.go index fb6c390..ceca0cd 100644 --- a/conformance/simple_test.go +++ b/conformance/simple_test.go @@ -2,16 +2,11 @@ package conformance import ( "context" - "encoding/json" "fmt" "os" "sort" "testing" - test "cel.dev/expr/conformance/test" - "github.com/jackc/pgx/v5" - - "github.com/emfga/cel4postgres/internal/codec" "github.com/emfga/cel4postgres/internal/corpus" "github.com/emfga/cel4postgres/internal/testdb" ) @@ -71,11 +66,7 @@ func TestSimple(t *testing.T) { } defer conn.Close(ctx) - // Probed once so a not-yet-implemented stage fails each case with - // one clear line instead of thousands of undefined-function SQL - // errors, and so a missing function can never masquerade as a CEL - // error an eval_error case would spuriously pass on. - stages, err := installedStages(ctx, conn) + stages, err := InstalledStages(ctx, conn) if err != nil { t.Fatal(err) } @@ -100,8 +91,14 @@ func TestSimple(t *testing.T) { t.Run(section.GetName(), func(t *testing.T) { for _, tc := range section.GetTest() { t.Run(tc.GetName(), func(t *testing.T) { - runCase(t, ctx, conn, stages, file, - section.GetName(), tc) + result := RunCase(ctx, conn, stages, + file, section.GetName(), tc) + switch result.Status { + case Skipped: + t.Skip(result.Detail) + case Failed: + t.Fatal(result.Detail) + } }) } }) @@ -109,287 +106,3 @@ func TestSimple(t *testing.T) { }) } } - -// stageSet records which cel.* entry points exist in the database, so -// failures name the missing stage rather than surfacing SQL errors. -type stageSet struct { - parse, check, eval bool -} - -func installedStages( - ctx context.Context, conn *pgx.Conn, -) (stageSet, error) { - var stages stageSet - err := conn.QueryRow(ctx, - `SELECT to_regprocedure('cel.parse(text, text)') IS NOT NULL, - to_regprocedure('cel.check(jsonb, text, jsonb)') - IS NOT NULL, - to_regprocedure('cel.eval(jsonb, jsonb, text)') - IS NOT NULL`, - ).Scan(&stages.parse, &stages.check, &stages.eval) - if err != nil { - return stages, fmt.Errorf("probe installed stages: %w", err) - } - return stages, nil -} - -// expectsError reports whether the case's result matcher is -// eval_error. Parse and check failures pass exactly these cases; the -// corpus never asserts on error message text (measured -- plumbing -// carries a deliberately bogus one), so existence is the whole test. -func expectsError(tc *test.SimpleTest) bool { - _, ok := tc.GetResultMatcher().(*test.SimpleTest_EvalError) - return ok -} - -// stageErrors extracts the {"errors": [...]} failure shape that -// cel.parse and cel.check return instead of an envelope. -func stageErrors(envelope any) ([]any, bool) { - m, ok := envelope.(map[string]any) - if !ok { - return nil, false - } - errs, ok := m["errors"].([]any) - return errs, ok -} - -func runCase( - t *testing.T, - ctx context.Context, - conn *pgx.Conn, - stages stageSet, - file, section string, - tc *test.SimpleTest, -) { - if reason := skipReason(file, section, tc.GetName()); reason != "" { - t.Skip(reason) - } - - env := EnvFor(file) - - // Parse. - if !stages.parse { - t.Fatal("parse stage: cel.parse(text, text) is not installed") - } - var raw []byte - err := conn.QueryRow(ctx, - "SELECT cel.parse($1, $2)", tc.GetExpr(), env, - ).Scan(&raw) - if err != nil { - t.Fatalf("parse stage: %v", err) - } - ast, err := codec.Decode(raw) - if err != nil { - t.Fatalf("parse stage: %v", err) - } - if errs, failed := stageErrors(ast); failed { - if expectsError(tc) { - return - } - t.Fatalf("parse stage: expression rejected: %v", errs) - } - - // Check. - if !tc.GetDisableCheck() { - if !stages.check { - t.Fatal( - "check stage: cel.check(jsonb, text, jsonb) " + - "is not installed", - ) - } - options, err := checkOptions(tc) - if err != nil { - t.Fatalf("check stage: %v", err) - } - err = conn.QueryRow(ctx, - "SELECT cel.check($1, $2, $3)", raw, env, options, - ).Scan(&raw) - if err != nil { - t.Fatalf("check stage: %v", err) - } - ast, err = codec.Decode(raw) - if err != nil { - t.Fatalf("check stage: %v", err) - } - if errs, failed := stageErrors(ast); failed { - if expectsError(tc) { - return - } - t.Fatalf("check stage: expression rejected: %v", errs) - } - } - - if tc.GetCheckOnly() { - compareDeducedType(t, ast, tc) - return - } - - // Eval. - if !stages.eval { - t.Fatal( - "eval stage: cel.eval(jsonb, jsonb, text) is not installed", - ) - } - activation, err := activationJSON(tc) - if err != nil { - t.Fatalf("eval stage: %v", err) - } - // The container reaches eval too: unchecked evaluation resolves - // names at runtime (checked ASTs bind them at check time, where - // the same option arrives via checkOptions). - evalOptions := map[string]any{} - if tc.GetContainer() != "" { - evalOptions["container"] = tc.GetContainer() - } - evalOptionsJSON, err := json.Marshal(evalOptions) - if err != nil { - t.Fatalf("eval stage: %v", err) - } - var rawResult []byte - err = conn.QueryRow(ctx, - "SELECT cel.eval($1, $2, $3, $4)", - raw, activation, env, evalOptionsJSON, - ).Scan(&rawResult) - if err != nil { - t.Fatalf("eval stage: %v", err) - } - got, err := codec.Decode(rawResult) - if err != nil { - t.Fatalf("eval stage: %v", err) - } - - compareResult(t, got, rawResult, tc) - - // typed_result compares the deduced type in addition to the value. - if _, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult); ok { - compareDeducedType(t, ast, tc) - } -} - -// checkOptions builds the options argument of cel.check (decision 7): -// the case's container and its type_env ident declarations. -func checkOptions(tc *test.SimpleTest) ([]byte, error) { - options := map[string]any{} - if tc.GetContainer() != "" { - options["container"] = tc.GetContainer() - } - if typeEnv := tc.GetTypeEnv(); len(typeEnv) > 0 { - decls := []any{} - for _, decl := range typeEnv { - converted, err := codec.FromDecl(decl) - if err != nil { - return nil, err - } - decls = append(decls, converted) - } - options["decls"] = decls - } - return json.Marshal(options) -} - -// activationJSON builds cel.eval's activation: binding name to tagged -// value, always tagged -- the runner never sends plain JSON the -// evaluator would have to guess about. -func activationJSON(tc *test.SimpleTest) ([]byte, error) { - activation := map[string]any{} - for name, value := range tc.GetBindings() { - tagged, err := codec.FromExprValue(value) - if err != nil { - return nil, fmt.Errorf("binding %q: %w", name, err) - } - activation[name] = tagged - } - return json.Marshal(activation) -} - -// compareResult applies the case's result matcher. A missing matcher -// defaults to value: bool true (measured, workspace doc 01). -func compareResult( - t *testing.T, got any, rawResult []byte, tc *test.SimpleTest, -) { - switch matcher := tc.GetResultMatcher().(type) { - case *test.SimpleTest_Value: - want, err := codec.FromValue(matcher.Value) - if err != nil { - t.Fatalf("convert expected value: %v", err) - } - if !codec.Equal(want, got) { - wantJSON, _ := json.Marshal(want) - t.Fatalf("result mismatch:\n want %s\n got %s", - wantJSON, rawResult) - } - case *test.SimpleTest_EvalError: - if kind, _ := taggedKind(got); kind != "error" { - t.Fatalf("expected an error value, got %s", rawResult) - } - case *test.SimpleTest_TypedResult: - want, err := codec.FromValue(matcher.TypedResult.GetResult()) - if err != nil { - t.Fatalf("convert expected value: %v", err) - } - if !codec.Equal(want, got) { - wantJSON, _ := json.Marshal(want) - t.Fatalf("result mismatch:\n want %s\n got %s", - wantJSON, rawResult) - } - case nil: - want := map[string]any{"@t": "bool", "v": true} - if !codec.Equal(want, got) { - t.Fatalf("expected bool true, got %s", rawResult) - } - default: - // unknown / any_unknowns / any_eval_errors: zero corpus - // cases use them today (measured); a corpus update that - // introduces one must extend the runner, not pass silently. - t.Fatalf("unsupported result matcher %T", matcher) - } -} - -// compareDeducedType compares the checked AST's root type against the -// typed_result matcher's deduced type (check_only cases, -// type_deduction file). -func compareDeducedType(t *testing.T, ast any, tc *test.SimpleTest) { - typed, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult) - if !ok { - t.Fatalf("check_only case without typed_result matcher") - } - - want, err := codec.FromType(typed.TypedResult.GetDeducedType()) - if err != nil { - t.Fatalf("convert expected type: %v", err) - } - - envelope, ok := ast.(map[string]any) - if !ok { - t.Fatalf("checked AST is not an object") - } - root, ok := envelope["expr"].(map[string]any) - if !ok { - t.Fatalf("checked AST has no root expression") - } - rootID, ok := root["id"].(json.Number) - if !ok { - t.Fatalf("root expression has no id") - } - types, ok := envelope["types"].(map[string]any) - if !ok { - t.Fatalf("AST is not checked: no types map") - } - - got := types[rootID.String()] - wantJSON, _ := json.Marshal(want) - gotJSON, _ := json.Marshal(got) - if string(wantJSON) != string(gotJSON) { - t.Fatalf("deduced type mismatch:\n want %s\n got %s", - wantJSON, gotJSON) - } -} - -func taggedKind(v any) (string, bool) { - m, ok := v.(map[string]any) - if !ok { - return "", false - } - kind, ok := m["@t"].(string) - return kind, ok -} From 940e4b3664006e98ca615ce19f09b593f6972cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sun, 30 Aug 2026 17:21:11 -0300 Subject: [PATCH 19/21] Generate a conformance report from an actual run The conformance claim existed only as terminal output: the suite printed its skips and its pass count and then they were gone. That is unciteable -- a reader cannot date it, reproduce it, or see what was left out -- and it is the failure mode the skip list was made visible to avoid, one level up. The report is generated, never written, and covers both sides. cel-go now runs the same cases through the same comparator, so "where do the two differ" is measured per case rather than remembered: eleven cases today, each named with the expression and both verdicts. Those were adjudicated during development and only survived in a scratch register; now they are in the tree. Provenance is corpus commit, cel-go pin and PostgreSQL major version, and deliberately carries no date -- those three decide the numbers, and a timestamp would only make the staleness test go red once a day. That test compares the whole file, so a report that stops describing this tree fails the build. The JSON sidecar carries the same data for anything that wants to consume rather than read it. --- conformance/oracle.go | 100 ++ conformance/report.go | 241 ++++ conformance/report_markdown.go | 160 +++ conformance/report_test.go | 57 + conformance/run.go | 8 +- docs/conformance-report.json | 2060 +++++++++++++++++++++++++++++++ docs/conformance-report.md | 489 ++++++++ internal/cmd/confreport/main.go | 69 ++ internal/oracle/case.go | 131 ++ 9 files changed, 3314 insertions(+), 1 deletion(-) create mode 100644 conformance/oracle.go create mode 100644 conformance/report.go create mode 100644 conformance/report_markdown.go create mode 100644 conformance/report_test.go create mode 100644 docs/conformance-report.json create mode 100644 docs/conformance-report.md create mode 100644 internal/cmd/confreport/main.go create mode 100644 internal/oracle/case.go diff --git a/conformance/oracle.go b/conformance/oracle.go new file mode 100644 index 0000000..b2b4b37 --- /dev/null +++ b/conformance/oracle.go @@ -0,0 +1,100 @@ +package conformance + +import ( + "encoding/json" + "fmt" + + test "cel.dev/expr/conformance/test" + + "github.com/emfga/cel4postgres/internal/codec" + "github.com/emfga/cel4postgres/internal/oracle" +) + +// The conformance report names every case where cel4postgres and +// cel-go disagree. Both verdicts have to be reached the same way for +// that to mean anything, so the oracle's outcome is judged by the +// comparators the database side is judged by -- the only difference is +// where the value came from. + +// RunOracleCase runs one corpus case against cel-go and compares the +// outcome to the corpus expectation, under the same env the database +// side uses. +func RunOracleCase( + file, section string, tc *test.SimpleTest, +) CaseResult { + result := CaseResult{File: file, Section: section, Name: tc.GetName()} + + if reason := skipReason(file, section, tc.GetName()); reason != "" { + result.Status = Skipped + result.Detail = reason + return result + } + + status, detail := runOracleCase(file, tc) + result.Status = status + result.Detail = detail + return result +} + +func runOracleCase(file string, tc *test.SimpleTest) (Status, string) { + fail := func(format string, args ...any) (Status, string) { + return Failed, fmt.Sprintf(format, args...) + } + + outcome, err := oracle.RunCase(tc, EnvFor(file)) + if err != nil { + return fail("oracle: %v", err) + } + + if outcome.Err != nil { + if expectsError(tc) { + return Passed, "" + } + return fail("cel-go rejected the case: %v", outcome.Err) + } + + if tc.GetCheckOnly() { + if err := compareOracleType(outcome, tc); err != nil { + return fail("%v", err) + } + return Passed, "" + } + + got, err := codec.FromValue(outcome.Value) + if err != nil { + return fail("oracle: convert result: %v", err) + } + rendered, _ := json.Marshal(got) + + if err := compareResult(got, rendered, tc); err != nil { + return fail("%v", err) + } + + if _, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult); ok { + if err := compareOracleType(outcome, tc); err != nil { + return fail("%v", err) + } + } + + return Passed, "" +} + +func compareOracleType( + outcome oracle.Outcome, tc *test.SimpleTest, +) error { + typed, ok := tc.GetResultMatcher().(*test.SimpleTest_TypedResult) + if !ok { + return fmt.Errorf("check_only case without typed_result matcher") + } + + want, err := codec.FromType(typed.TypedResult.GetDeducedType()) + if err != nil { + return fmt.Errorf("convert expected type: %w", err) + } + got, err := codec.FromType(outcome.Type) + if err != nil { + return fmt.Errorf("convert deduced type: %w", err) + } + + return compareTypeJSON(want, got) +} diff --git a/conformance/report.go b/conformance/report.go new file mode 100644 index 0000000..1763f85 --- /dev/null +++ b/conformance/report.go @@ -0,0 +1,241 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/emfga/cel4postgres/internal/corpus" + "github.com/emfga/cel4postgres/internal/oracle" +) + +// The conformance report is the durable form of what the suite +// prints. A number that lives only in terminal scrollback cannot be +// cited, dated or reproduced, and a skip nobody sees is a silent +// scope reduction -- so the corpus run is rendered to a committed +// document, and a test keeps it current. +// +// Provenance carries no timestamp. Everything that decides the +// numbers -- the corpus commit, the cel-go pin, the PostgreSQL major +// version -- is an input, and dating the file instead would make the +// staleness test go red once a day for no reason. + +// The committed report, relative to the repository root. +const ( + ReportMarkdownPath = "docs/conformance-report.md" + ReportJSONPath = "docs/conformance-report.json" +) + +// Report is one full run of the in-scope corpus, from both sides. +type Report struct { + CelSpec string `json:"cel_spec"` + CelGo string `json:"cel_go"` + Postgres string `json:"postgres"` + Totals Totals `json:"totals"` + Files []FileReport `json:"files"` + SkippedFiles []FileSkip `json:"skipped_files"` + SkippedCases []CaseSkip `json:"skipped_cases"` + Divergences []Divergence `json:"divergences"` + Failures []CaseFailure `json:"failures"` +} + +// Totals counts the whole corpus, attempted and not. +type Totals struct { + Files int `json:"files"` + SkippedFiles int `json:"skipped_files"` + Cases int `json:"cases"` + Passed int `json:"passed"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` +} + +// FileReport is one attempted corpus file. +type FileReport struct { + Name string `json:"name"` + Env string `json:"env"` + Cases int `json:"cases"` + Passed int `json:"passed"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` +} + +// FileSkip is a corpus file not attempted at all. +type FileSkip struct { + Name string `json:"name"` + Cases int `json:"cases"` + Reason string `json:"reason"` +} + +// CaseSkip is a case not attempted inside an attempted file. +type CaseSkip struct { + File string `json:"file"` + Section string `json:"section"` + Name string `json:"name"` + Reason string `json:"reason"` +} + +// CaseFailure is an attempted case that disagreed with the corpus. +type CaseFailure struct { + File string `json:"file"` + Section string `json:"section"` + Name string `json:"name"` + Detail string `json:"detail"` +} + +// Divergence is a case the two implementations judge differently. +// Since cel4postgres follows the corpus wherever the two disagree +// (the corpus-first ruling), these are almost always cases cel-go +// itself does not satisfy -- which is exactly what a reader comparing +// the two needs told. +type Divergence struct { + File string `json:"file"` + Section string `json:"section"` + Name string `json:"name"` + Expr string `json:"expr"` + Cel4Postgres string `json:"cel4postgres"` + CelGo string `json:"cel_go"` +} + +// Build runs every in-scope corpus case against the database and +// against cel-go, and assembles the report. +func Build(ctx context.Context, conn *pgx.Conn) (Report, error) { + version, err := postgresVersion(ctx, conn) + if err != nil { + return Report{}, err + } + + stages, err := InstalledStages(ctx, conn) + if err != nil { + return Report{}, err + } + + files, err := corpus.Files() + if err != nil { + return Report{}, err + } + + report := Report{ + CelSpec: corpus.Pin, + CelGo: oracle.Version, + Postgres: version, + } + report.Totals.Files = len(files) + + for _, file := range files { + parsed, err := corpus.Load(file) + if err != nil { + return Report{}, err + } + + cases := 0 + for _, section := range parsed.GetSection() { + cases += len(section.GetTest()) + } + report.Totals.Cases += cases + + if reason, ok := SkippedFiles[file]; ok { + report.SkippedFiles = append(report.SkippedFiles, FileSkip{ + Name: file, Cases: cases, Reason: reason, + }) + report.Totals.SkippedFiles++ + report.Totals.Skipped += cases + continue + } + + summary := FileReport{ + Name: file, Env: EnvFor(file), Cases: cases, + } + + for _, section := range parsed.GetSection() { + for _, tc := range section.GetTest() { + name := section.GetName() + got := RunCase(ctx, conn, stages, file, name, tc) + + switch got.Status { + case Skipped: + summary.Skipped++ + report.SkippedCases = append( + report.SkippedCases, + CaseSkip{ + File: file, Section: name, + Name: tc.GetName(), Reason: got.Detail, + }, + ) + continue + case Failed: + summary.Failed++ + report.Failures = append(report.Failures, CaseFailure{ + File: file, Section: name, + Name: tc.GetName(), Detail: got.Detail, + }) + default: + summary.Passed++ + } + + reference := RunOracleCase(file, name, tc) + if reference.Status == got.Status { + continue + } + report.Divergences = append(report.Divergences, Divergence{ + File: file, Section: name, Name: tc.GetName(), + Expr: tc.GetExpr(), + Cel4Postgres: verdict(got), + CelGo: verdict(reference), + }) + } + } + + report.Totals.Passed += summary.Passed + report.Totals.Skipped += summary.Skipped + report.Totals.Failed += summary.Failed + report.Files = append(report.Files, summary) + } + + sort.Slice(report.SkippedFiles, func(i, j int) bool { + return report.SkippedFiles[i].Name < report.SkippedFiles[j].Name + }) + + return report, nil +} + +// verdict renders one side's outcome for the divergence list. +func verdict(result CaseResult) string { + if result.Status == Passed { + return "matches the corpus" + } + return "disagrees: " + oneLine(result.Detail) +} + +// oneLine flattens a multi-line failure detail so it fits a table +// cell and a JSON field without carrying layout with it. +func oneLine(s string) string { + fields := strings.Fields(strings.ReplaceAll(s, "\n", " ")) + return strings.Join(fields, " ") +} + +// postgresVersion returns the server's major version. The major is +// what can plausibly change a result; a patch release cannot, and +// recording it would churn the report for nothing. +func postgresVersion(ctx context.Context, conn *pgx.Conn) (string, error) { + var num int + err := conn.QueryRow(ctx, + "SELECT current_setting('server_version_num')::int", + ).Scan(&num) + if err != nil { + return "", fmt.Errorf("read server version: %w", err) + } + return fmt.Sprintf("%d", num/10000), nil +} + +// MarshalJSON renders the machine-readable sidecar. +func (r Report) MarshalJSONReport() ([]byte, error) { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} diff --git a/conformance/report_markdown.go b/conformance/report_markdown.go new file mode 100644 index 0000000..39a753b --- /dev/null +++ b/conformance/report_markdown.go @@ -0,0 +1,160 @@ +package conformance + +import ( + "fmt" + "sort" + "strings" +) + +// Markdown renders the report as the committed document. It is +// written for a reader deciding whether to trust the number, so the +// order is: what was measured against, the totals, then everything +// that was not attempted, then everywhere the two implementations +// disagree. The prose that explains what any of it means lives in +// docs/CONFORMANCE.md; this file states facts and nothing else. +func (r Report) Markdown() string { + var b strings.Builder + + b.WriteString( + "\n" + + "\n\n" + + "# Conformance report\n\n" + + "Measured by running every in-scope case of the cel-spec\n" + + "simple conformance corpus against a fresh cel4postgres\n" + + "install, and the same cases against cel-go under the same\n" + + "environment. See [CONFORMANCE.md](CONFORMANCE.md) for what\n" + + "is in scope and why, and for the reasoning behind the\n" + + "divergences listed at the end.\n\n", + ) + + b.WriteString("## Measured against\n\n") + b.WriteString("| | |\n|---|---|\n") + fmt.Fprintf(&b, "| cel-spec corpus | `%s` |\n", r.CelSpec) + fmt.Fprintf(&b, "| cel-go | `%s` |\n", r.CelGo) + fmt.Fprintf(&b, "| PostgreSQL | %s |\n\n", r.Postgres) + + b.WriteString("## Summary\n\n") + attempted := r.Totals.Passed + r.Totals.Failed + inSkippedFiles := 0 + for _, file := range r.SkippedFiles { + inSkippedFiles += file.Cases + } + fmt.Fprintf(&b, + "**%d of %d attempted cases passed, %d failed.**\n\n"+ + "The corpus holds %d cases in %d files. %d cases in %d\n"+ + "files were attempted. The other %d were not: %d in the\n"+ + "files listed under *Files not attempted*, and %d named\n"+ + "cases inside files that were.\n\n", + r.Totals.Passed, attempted, r.Totals.Failed, + r.Totals.Cases, r.Totals.Files, + attempted, r.Totals.Files-r.Totals.SkippedFiles, + r.Totals.Skipped, inSkippedFiles, len(r.SkippedCases), + ) + + b.WriteString("## Per file\n\n") + b.WriteString( + "`env` is the environment parameter the file runs under: each\n" + + "extension file enables exactly the extension it exercises,\n" + + "and nothing is enabled by default.\n\n", + ) + b.WriteString( + "| file | env | cases | passed | skipped | failed |\n" + + "|---|---|--:|--:|--:|--:|\n", + ) + for _, file := range r.Files { + fmt.Fprintf(&b, "| %s | `%s` | %d | %d | %d | %d |\n", + file.Name, file.Env, file.Cases, + file.Passed, file.Skipped, file.Failed) + } + b.WriteString("\n") + + b.WriteString("## Files not attempted\n\n") + b.WriteString("| file | cases | reason |\n|---|--:|---|\n") + for _, file := range r.SkippedFiles { + fmt.Fprintf(&b, "| %s | %d | %s |\n", + file.Name, file.Cases, file.Reason) + } + b.WriteString("\n") + + r.writeSkippedCases(&b) + r.writeFailures(&b) + r.writeDivergences(&b) + + return b.String() +} + +// writeSkippedCases lists every unattempted case by name, grouped by +// file and reason. The count alone would be half a fact: a skip is +// only visible if the thing skipped is named. +func (r Report) writeSkippedCases(b *strings.Builder) { + fmt.Fprintf(b, + "## Cases not attempted, inside attempted files\n\n"+ + "%d cases, each named here.\n\n", len(r.SkippedCases)) + + grouped := map[string][]string{} + for _, skip := range r.SkippedCases { + key := skip.File + "\x00" + skip.Reason + grouped[key] = append(grouped[key], + skip.Section+"/"+skip.Name) + } + + keys := make([]string, 0, len(grouped)) + for key := range grouped { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + file, reason, _ := strings.Cut(key, "\x00") + cases := grouped[key] + sort.Strings(cases) + fmt.Fprintf(b, "### %s — %s (%d)\n\n", file, reason, len(cases)) + for _, name := range cases { + fmt.Fprintf(b, "- `%s`\n", name) + } + b.WriteString("\n") + } +} + +func (r Report) writeFailures(b *strings.Builder) { + if len(r.Failures) == 0 { + return + } + + fmt.Fprintf(b, "## Failures\n\n%d cases disagreed with the corpus.\n\n", + len(r.Failures)) + for _, failure := range r.Failures { + fmt.Fprintf(b, "### `%s/%s/%s`\n\n```\n%s\n```\n\n", + failure.File, failure.Section, failure.Name, + failure.Detail) + } +} + +// writeDivergences names every case the two implementations judge +// differently. cel4postgres follows the corpus where the two +// disagree, so an entry here is nearly always a case cel-go itself +// does not satisfy -- stated case by case rather than asserted. +func (r Report) writeDivergences(b *strings.Builder) { + b.WriteString( + "## Divergences from cel-go\n\n" + + "Cases where the two implementations reach different\n" + + "verdicts against the corpus expectation. Both sides ran\n" + + "under the same environment and were judged by the same\n" + + "comparator.\n\n", + ) + + if len(r.Divergences) == 0 { + b.WriteString("None: the two agree on every attempted case.\n") + return + } + + fmt.Fprintf(b, "%d cases.\n\n", len(r.Divergences)) + for _, d := range r.Divergences { + fmt.Fprintf(b, "### `%s/%s/%s`\n\n```\n%s\n```\n\n", + d.File, d.Section, d.Name, d.Expr) + fmt.Fprintf(b, "- cel4postgres — %s\n", d.Cel4Postgres) + fmt.Fprintf(b, "- cel-go — %s\n\n", d.CelGo) + } +} diff --git a/conformance/report_test.go b/conformance/report_test.go new file mode 100644 index 0000000..dbf96c6 --- /dev/null +++ b/conformance/report_test.go @@ -0,0 +1,57 @@ +package conformance + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/emfga/cel4postgres/internal/repo" + "github.com/emfga/cel4postgres/internal/testdb" +) + +// TestReportCurrent keeps the committed conformance report identical +// to what a run produces now, the way TestSkipListCurrent keeps the +// skip list current. A report is a claim about this tree; a stale one +// is a false claim, and nothing else would notice it. +func TestReportCurrent(t *testing.T) { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close(ctx) + + report, err := Build(ctx, conn) + if err != nil { + t.Fatal(err) + } + + sidecar, err := report.MarshalJSONReport() + if err != nil { + t.Fatal(err) + } + + root, err := repo.Root() + if err != nil { + t.Fatal(err) + } + + for path, want := range map[string]string{ + ReportMarkdownPath: report.Markdown(), + ReportJSONPath: string(sidecar), + } { + committed, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Errorf("read %s: %v", path, err) + continue + } + if string(committed) != want { + t.Errorf( + "%s is stale: regenerate with: "+ + "go run ./internal/cmd/confreport", path, + ) + } + } +} diff --git a/conformance/run.go b/conformance/run.go index 2871c9a..b2381ff 100644 --- a/conformance/run.go +++ b/conformance/run.go @@ -342,7 +342,13 @@ func compareDeducedType(ast any, tc *test.SimpleTest) error { return fmt.Errorf("AST is not checked: no types map") } - got := types[rootID.String()] + return compareTypeJSON(want, types[rootID.String()]) +} + +// compareTypeJSON compares a deduced type against the expected one in +// the rendered shape both sides share, so the AST's types map and a +// type converted from cel-go are judged identically. +func compareTypeJSON(want codec.TypeJSON, got any) error { wantJSON, _ := json.Marshal(want) gotJSON, _ := json.Marshal(got) if string(wantJSON) != string(gotJSON) { diff --git a/docs/conformance-report.json b/docs/conformance-report.json new file mode 100644 index 0000000..e240add --- /dev/null +++ b/docs/conformance-report.json @@ -0,0 +1,2060 @@ +{ + "cel_spec": "ba58ae5007845f3a1279b488cdeb79645ce958bb", + "cel_go": "v0.32.0", + "postgres": "18", + "totals": { + "files": 31, + "skipped_files": 6, + "cases": 2508, + "passed": 1841, + "skipped": 667, + "failed": 0 + }, + "files": [ + { + "name": "basic", + "env": "standard", + "cases": 43, + "passed": 43, + "skipped": 0, + "failed": 0 + }, + { + "name": "bindings_ext", + "env": "standard,bindings", + "cases": 8, + "passed": 8, + "skipped": 0, + "failed": 0 + }, + { + "name": "comparisons", + "env": "standard", + "cases": 406, + "passed": 353, + "skipped": 53, + "failed": 0 + }, + { + "name": "conversions", + "env": "standard", + "cases": 109, + "passed": 109, + "skipped": 0, + "failed": 0 + }, + { + "name": "dynamic", + "env": "standard", + "cases": 226, + "passed": 72, + "skipped": 154, + "failed": 0 + }, + { + "name": "encoders_ext", + "env": "standard,encoders", + "cases": 4, + "passed": 4, + "skipped": 0, + "failed": 0 + }, + { + "name": "fields", + "env": "standard", + "cases": 60, + "passed": 60, + "skipped": 0, + "failed": 0 + }, + { + "name": "fp_math", + "env": "standard", + "cases": 30, + "passed": 30, + "skipped": 0, + "failed": 0 + }, + { + "name": "integer_math", + "env": "standard", + "cases": 64, + "passed": 64, + "skipped": 0, + "failed": 0 + }, + { + "name": "lists", + "env": "standard", + "cases": 39, + "passed": 39, + "skipped": 0, + "failed": 0 + }, + { + "name": "lists_ext", + "env": "standard,lists", + "cases": 52, + "passed": 52, + "skipped": 0, + "failed": 0 + }, + { + "name": "logic", + "env": "standard", + "cases": 30, + "passed": 30, + "skipped": 0, + "failed": 0 + }, + { + "name": "macros", + "env": "standard", + "cases": 44, + "passed": 44, + "skipped": 0, + "failed": 0 + }, + { + "name": "macros2", + "env": "standard,two_var_comprehensions", + "cases": 46, + "passed": 46, + "skipped": 0, + "failed": 0 + }, + { + "name": "math_ext", + "env": "standard,math", + "cases": 199, + "passed": 199, + "skipped": 0, + "failed": 0 + }, + { + "name": "namespace", + "env": "standard", + "cases": 14, + "passed": 14, + "skipped": 0, + "failed": 0 + }, + { + "name": "network_ext", + "env": "standard,network", + "cases": 69, + "passed": 69, + "skipped": 0, + "failed": 0 + }, + { + "name": "optionals", + "env": "standard,optionals", + "cases": 70, + "passed": 59, + "skipped": 11, + "failed": 0 + }, + { + "name": "parse", + "env": "standard", + "cases": 219, + "passed": 173, + "skipped": 46, + "failed": 0 + }, + { + "name": "plumbing", + "env": "standard", + "cases": 5, + "passed": 5, + "skipped": 0, + "failed": 0 + }, + { + "name": "string", + "env": "standard", + "cases": 51, + "passed": 51, + "skipped": 0, + "failed": 0 + }, + { + "name": "string_ext", + "env": "standard,strings", + "cases": 216, + "passed": 213, + "skipped": 3, + "failed": 0 + }, + { + "name": "timestamps", + "env": "standard", + "cases": 78, + "passed": 78, + "skipped": 0, + "failed": 0 + }, + { + "name": "type_deduction", + "env": "standard,optionals", + "cases": 47, + "passed": 26, + "skipped": 21, + "failed": 0 + }, + { + "name": "unknowns", + "env": "standard", + "cases": 0, + "passed": 0, + "skipped": 0, + "failed": 0 + } + ], + "skipped_files": [ + { + "name": "block_ext", + "cases": 37, + "reason": "cel-go-internal cel.block form; no consumer" + }, + { + "name": "enums", + "cases": 85, + "reason": "requires protobuf descriptors" + }, + { + "name": "proto2", + "cases": 118, + "reason": "requires protobuf descriptors" + }, + { + "name": "proto2_ext", + "cases": 18, + "reason": "requires proto2 extensions" + }, + { + "name": "proto3", + "cases": 85, + "reason": "requires protobuf descriptors" + }, + { + "name": "wrappers", + "cases": 36, + "reason": "requires proto3 TestAllTypes + Any unpacking" + } + ], + "skipped_cases": [ + { + "file": "comparisons", + "section": "eq_literal", + "name": "not_eq_dyn_proto2_msg_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_literal", + "name": "not_eq_dyn_proto3_msg_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_bool_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_bool_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_bytes_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_bytes_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_double_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_double_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_float_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_float_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_int32_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_int32_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_int64_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_int64_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_string_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_string_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_uint32_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_uint32_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_uint64_proto2_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_uint64_proto3_null", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto2_missing_fields_neq", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto3_missing_fields_neq", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto_nan_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto_different_types", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto2_any_unpack_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto2_any_unpack_not_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto2_any_unpack_bytewise_fallback_not_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto2_any_unpack_bytewise_fallback_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto3_any_unpack_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto3_any_unpack_not_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto3_any_unpack_bytewise_fallback_not_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "eq_wrapper", + "name": "eq_proto3_any_unpack_bytewise_fallback_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto2_missing_fields_neq", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto3_missing_fields_neq", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto_nan_not_equal", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto_different_types", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto2_any_unpack", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto2_any_unpack_bytewise_fallback", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto3_any_unpack", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "ne_literal", + "name": "ne_proto3_any_unpack_bytewise_fallback", + "reason": "requires protobuf descriptors" + }, + { + "file": "comparisons", + "section": "gt_literal", + "name": "gt_bytes_one", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "gt_literal", + "name": "gt_bytes_one_to_empty", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "gt_literal", + "name": "not_gt_bytes_sorting", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "lte_literal", + "name": "lte_bytes_empty", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "lte_literal", + "name": "not_lte_bytes_length", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "gte_literal", + "name": "gte_bytes_to_empty", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "gte_literal", + "name": "not_gte_bytes_empty_to_nonempty", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "gte_literal", + "name": "gte_bytes_samelength", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "comparisons", + "section": "bound", + "name": "bytes_gt_left_false", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto2_max", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto2_min", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto2_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto3_max", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto3_min", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_assign_proto3_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_read_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int32", + "name": "field_read_proto3_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int64", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int64", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int64", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "int64", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto2_max", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto2_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto3_max", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_assign_proto3_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint32", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "uint64", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto2_subnorm", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto2_round_to_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto2_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto2_subnorm", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto3_round_to_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_assign_proto3_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_read_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "float", + "name": "field_read_proto3_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_assign_proto2_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_assign_proto3_range", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_read_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "double", + "name": "field_read_proto3_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bool", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bool", + "name": "field_assign_proto2_false", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bool", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bool", + "name": "field_assign_proto3_false", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "string", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "string", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "string", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "string", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bytes", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bytes", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bytes", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "bytes", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_read_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_read_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "list", + "name": "field_read_proto3_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_assign_proto2_bad", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_read_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_assign_proto3_bad", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_read_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "struct", + "name": "field_read_proto3_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_null", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_null", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_null", + "name": "field_read_proto2_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_null", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_null", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_null", + "name": "field_read_proto3_unset", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_assign_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_assign_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_number", + "name": "field_read_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_read_proto2_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_string", + "name": "field_read_proto3_zero", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_assign_proto2_false", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_read_proto2_false", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_assign_proto3_false", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_bool", + "name": "field_read_proto3_false", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_read_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_struct", + "name": "field_read_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_assign_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_read_proto2_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_assign_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "value_list", + "name": "field_read_proto3_empty", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "literal", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "literal_no_field_access", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "var", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "field_assign_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "field_read_proto2", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "field_assign_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "any", + "name": "field_read_proto3", + "reason": "requires protobuf descriptors" + }, + { + "file": "dynamic", + "section": "complex", + "name": "any_list_map", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "has_optional_ofNonZeroValue_struct_optional_ofNonZeroValue_map_optindex_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "optional_ofNonZeroValue_struct_optional_ofNonZeroValue_map_optindex_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "struct_map_optindex_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "struct_optional_ofNonZeroValue_map_optindex_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "struct_map_optindex_field_nested", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "struct_list_optindex_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "empty_struct_optindex_hasValue", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "optional_empty_struct_optindex_hasValue", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "struct_optindex_value", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "optional_struct_optindex_value", + "reason": "requires protobuf descriptors" + }, + { + "file": "optionals", + "section": "optionals", + "name": "optional_struct_optindex_index_value", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "nest", + "name": "message_literal", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "repeat", + "name": "select", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "repeat", + "name": "message_literal", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "string_literals", + "name": "single_quoted_octal_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "double_quoted_octal_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_single_quoted_octal_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_double_quoted_octal_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "single_quoted_lower_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "double_quoted_lower_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_single_quoted_lower_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_double_quoted_lower_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "single_quoted_upper_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "double_quoted_upper_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_single_quoted_upper_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_double_quoted_upper_x_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "single_quoted_lower_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "double_quoted_lower_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_single_quoted_lower_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_double_quoted_lower_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "single_quoted_upper_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "double_quoted_upper_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_single_quoted_upper_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "string_literals", + "name": "triple_double_quoted_upper_u_escapes", + "reason": "PostgreSQL text cannot represent NUL in strings" + }, + { + "file": "parse", + "section": "whitespace", + "name": "spaces", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "whitespace", + "name": "tabs", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "whitespace", + "name": "new_lines", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "whitespace", + "name": "new_pages", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "whitespace", + "name": "carriage_returns", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "comments", + "name": "new_line_terminated", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "as", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "break", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "const", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "continue", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "else", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "for", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "function", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "if", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "import", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "let", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "loop", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "package", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "namespace", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "return", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "var", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "void", + "reason": "requires protobuf descriptors" + }, + { + "file": "parse", + "section": "struct_field_names", + "name": "while", + "reason": "requires protobuf descriptors" + }, + { + "file": "string_ext", + "section": "format_errors", + "name": "object not allowed", + "reason": "requires protobuf descriptors" + }, + { + "file": "string_ext", + "section": "format_errors", + "name": "object inside list", + "reason": "requires protobuf descriptors" + }, + { + "file": "string_ext", + "section": "format_errors", + "name": "object inside map", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "complex_initializers", + "name": "struct", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "field_access", + "name": "int_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "field_access", + "name": "repeated_int_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "field_access", + "name": "map_bool_int", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "field_access", + "name": "enum_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "field_access", + "name": "repeated_enum_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "field_access", + "name": "enum_map_field", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "flexible_type_parameter_assignment", + "name": "comprehension_type_var_aliasing", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "flexible_type_parameter_assignment", + "name": "overload_type_var_aliasing", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "flexible_type_parameter_assignment", + "name": "list_parameters_do_not_unify", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_promotion", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_promotion_2", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_dyn_promotion", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_dyn_promotion_2", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_primitive_assignable", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_null_assignable", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_ternary_parameter_assignment", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "wrappers", + "name": "wrapper_ternary_parameter_assignment_2", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "legacy_nullable_types", + "name": "null_assignable_to_message_parameter_candidate", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "legacy_nullable_types", + "name": "null_assignable_to_duration_parameter_candidate", + "reason": "requires protobuf descriptors" + }, + { + "file": "type_deduction", + "section": "legacy_nullable_types", + "name": "null_assignable_to_timestamp_parameter_candidate", + "reason": "requires protobuf descriptors" + } + ], + "divergences": [ + { + "file": "fields", + "section": "qualified_identifier_resolution", + "name": "map_key_float", + "expr": "{3.3:15.15, 1.0: 5}[1.0]", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: expected an error value, got {\"@t\":\"int\",\"v\":5}" + }, + { + "file": "fields", + "section": "qualified_identifier_resolution", + "name": "map_value_repeat_key", + "expr": "{true:1,false:2,true:3}[true]", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: expected an error value, got {\"@t\":\"int\",\"v\":3}" + }, + { + "file": "fields", + "section": "qualified_identifier_resolution", + "name": "map_value_repeat_key_heterogeneous", + "expr": "{0: 1, 0u: 2}[0.0]", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: expected an error value, got {\"@t\":\"int\",\"v\":1}" + }, + { + "file": "network_ext", + "section": "ipv4", + "name": "ipv4_equals_ipv6", + "expr": "ip('::ffff:c0a8:1') == ip('192.168.0.1')", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: cel-go rejected the case: ERROR: \u003cinput\u003e:1:4: invalid ip argument: IPv4-mapped IPv6 address \"::ffff:c0a8:1\" is not allowed | ip('::ffff:c0a8:1') == ip('192.168.0.1') | ...^" + }, + { + "file": "network_ext", + "section": "ipv4", + "name": "ipv4_not_equals_ipv6", + "expr": "ip('::ffff:c0a8:1') == ip('192.168.10.1')", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: cel-go rejected the case: ERROR: \u003cinput\u003e:1:4: invalid ip argument: IPv4-mapped IPv6 address \"::ffff:c0a8:1\" is not allowed | ip('::ffff:c0a8:1') == ip('192.168.10.1') | ...^" + }, + { + "file": "optionals", + "section": "optionals", + "name": "map_optional_select_has", + "expr": "has({'foo': optional.none()}.foo.bar)", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: cel-go rejected the case: no such key: bar" + }, + { + "file": "string_ext", + "section": "value_errors", + "name": "indexof_out_of_range", + "expr": "'tacocat'.indexOf('a', 30) == -1", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: expected an error value, got {\"@t\":\"bool\",\"v\":true}" + }, + { + "file": "string_ext", + "section": "value_errors", + "name": "lastindexof_out_of_range", + "expr": "'tacocat'.lastIndexOf('a', 30) == -1", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: expected an error value, got {\"@t\":\"bool\",\"v\":true}" + }, + { + "file": "timestamps", + "section": "duration_converters", + "name": "get_milliseconds", + "expr": "x.getMilliseconds()", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: result mismatch: want {\"@t\":\"int\",\"v\":321} got {\"@t\":\"int\",\"v\":123321}" + }, + { + "file": "type_deduction", + "section": "legacy_nullable_types", + "name": "null_assignable_to_abstract_parameter_candidate", + "expr": "[optional.of(1), null][0]", + "cel4postgres": "matches the corpus", + "cel_go": "disagrees: deduced type mismatch: want {\"kind\":\"opaque\",\"name\":\"optional_type\",\"params\":[{\"kind\":\"int\"}]} got {\"kind\":\"null\"}" + } + ], + "failures": null +} diff --git a/docs/conformance-report.md b/docs/conformance-report.md new file mode 100644 index 0000000..7c16ffa --- /dev/null +++ b/docs/conformance-report.md @@ -0,0 +1,489 @@ + + + +# Conformance report + +Measured by running every in-scope case of the cel-spec +simple conformance corpus against a fresh cel4postgres +install, and the same cases against cel-go under the same +environment. See [CONFORMANCE.md](CONFORMANCE.md) for what +is in scope and why, and for the reasoning behind the +divergences listed at the end. + +## Measured against + +| | | +|---|---| +| cel-spec corpus | `ba58ae5007845f3a1279b488cdeb79645ce958bb` | +| cel-go | `v0.32.0` | +| PostgreSQL | 18 | + +## Summary + +**1841 of 1841 attempted cases passed, 0 failed.** + +The corpus holds 2508 cases in 31 files. 1841 cases in 25 +files were attempted. The other 667 were not: 379 in the +files listed under *Files not attempted*, and 288 named +cases inside files that were. + +## Per file + +`env` is the environment parameter the file runs under: each +extension file enables exactly the extension it exercises, +and nothing is enabled by default. + +| file | env | cases | passed | skipped | failed | +|---|---|--:|--:|--:|--:| +| basic | `standard` | 43 | 43 | 0 | 0 | +| bindings_ext | `standard,bindings` | 8 | 8 | 0 | 0 | +| comparisons | `standard` | 406 | 353 | 53 | 0 | +| conversions | `standard` | 109 | 109 | 0 | 0 | +| dynamic | `standard` | 226 | 72 | 154 | 0 | +| encoders_ext | `standard,encoders` | 4 | 4 | 0 | 0 | +| fields | `standard` | 60 | 60 | 0 | 0 | +| fp_math | `standard` | 30 | 30 | 0 | 0 | +| integer_math | `standard` | 64 | 64 | 0 | 0 | +| lists | `standard` | 39 | 39 | 0 | 0 | +| lists_ext | `standard,lists` | 52 | 52 | 0 | 0 | +| logic | `standard` | 30 | 30 | 0 | 0 | +| macros | `standard` | 44 | 44 | 0 | 0 | +| macros2 | `standard,two_var_comprehensions` | 46 | 46 | 0 | 0 | +| math_ext | `standard,math` | 199 | 199 | 0 | 0 | +| namespace | `standard` | 14 | 14 | 0 | 0 | +| network_ext | `standard,network` | 69 | 69 | 0 | 0 | +| optionals | `standard,optionals` | 70 | 59 | 11 | 0 | +| parse | `standard` | 219 | 173 | 46 | 0 | +| plumbing | `standard` | 5 | 5 | 0 | 0 | +| string | `standard` | 51 | 51 | 0 | 0 | +| string_ext | `standard,strings` | 216 | 213 | 3 | 0 | +| timestamps | `standard` | 78 | 78 | 0 | 0 | +| type_deduction | `standard,optionals` | 47 | 26 | 21 | 0 | +| unknowns | `standard` | 0 | 0 | 0 | 0 | + +## Files not attempted + +| file | cases | reason | +|---|--:|---| +| block_ext | 37 | cel-go-internal cel.block form; no consumer | +| enums | 85 | requires protobuf descriptors | +| proto2 | 118 | requires protobuf descriptors | +| proto2_ext | 18 | requires proto2 extensions | +| proto3 | 85 | requires protobuf descriptors | +| wrappers | 36 | requires proto3 TestAllTypes + Any unpacking | + +## Cases not attempted, inside attempted files + +288 cases, each named here. + +### comparisons — PostgreSQL text cannot represent NUL in strings (9) + +- `bound/bytes_gt_left_false` +- `gt_literal/gt_bytes_one` +- `gt_literal/gt_bytes_one_to_empty` +- `gt_literal/not_gt_bytes_sorting` +- `gte_literal/gte_bytes_samelength` +- `gte_literal/gte_bytes_to_empty` +- `gte_literal/not_gte_bytes_empty_to_nonempty` +- `lte_literal/lte_bytes_empty` +- `lte_literal/not_lte_bytes_length` + +### comparisons — requires protobuf descriptors (44) + +- `eq_literal/not_eq_dyn_proto2_msg_null` +- `eq_literal/not_eq_dyn_proto3_msg_null` +- `eq_wrapper/eq_bool_proto2_null` +- `eq_wrapper/eq_bool_proto3_null` +- `eq_wrapper/eq_bytes_proto2_null` +- `eq_wrapper/eq_bytes_proto3_null` +- `eq_wrapper/eq_double_proto2_null` +- `eq_wrapper/eq_double_proto3_null` +- `eq_wrapper/eq_float_proto2_null` +- `eq_wrapper/eq_float_proto3_null` +- `eq_wrapper/eq_int32_proto2_null` +- `eq_wrapper/eq_int32_proto3_null` +- `eq_wrapper/eq_int64_proto2_null` +- `eq_wrapper/eq_int64_proto3_null` +- `eq_wrapper/eq_proto2` +- `eq_wrapper/eq_proto2_any_unpack_bytewise_fallback_equal` +- `eq_wrapper/eq_proto2_any_unpack_bytewise_fallback_not_equal` +- `eq_wrapper/eq_proto2_any_unpack_equal` +- `eq_wrapper/eq_proto2_any_unpack_not_equal` +- `eq_wrapper/eq_proto2_missing_fields_neq` +- `eq_wrapper/eq_proto3` +- `eq_wrapper/eq_proto3_any_unpack_bytewise_fallback_equal` +- `eq_wrapper/eq_proto3_any_unpack_bytewise_fallback_not_equal` +- `eq_wrapper/eq_proto3_any_unpack_equal` +- `eq_wrapper/eq_proto3_any_unpack_not_equal` +- `eq_wrapper/eq_proto3_missing_fields_neq` +- `eq_wrapper/eq_proto_different_types` +- `eq_wrapper/eq_proto_nan_equal` +- `eq_wrapper/eq_string_proto2_null` +- `eq_wrapper/eq_string_proto3_null` +- `eq_wrapper/eq_uint32_proto2_null` +- `eq_wrapper/eq_uint32_proto3_null` +- `eq_wrapper/eq_uint64_proto2_null` +- `eq_wrapper/eq_uint64_proto3_null` +- `ne_literal/ne_proto2` +- `ne_literal/ne_proto2_any_unpack` +- `ne_literal/ne_proto2_any_unpack_bytewise_fallback` +- `ne_literal/ne_proto2_missing_fields_neq` +- `ne_literal/ne_proto3` +- `ne_literal/ne_proto3_any_unpack` +- `ne_literal/ne_proto3_any_unpack_bytewise_fallback` +- `ne_literal/ne_proto3_missing_fields_neq` +- `ne_literal/ne_proto_different_types` +- `ne_literal/ne_proto_nan_not_equal` + +### dynamic — requires protobuf descriptors (154) + +- `any/field_assign_proto2` +- `any/field_assign_proto3` +- `any/field_read_proto2` +- `any/field_read_proto3` +- `any/literal` +- `any/literal_no_field_access` +- `any/var` +- `bool/field_assign_proto2` +- `bool/field_assign_proto2_false` +- `bool/field_assign_proto3` +- `bool/field_assign_proto3_false` +- `bytes/field_assign_proto2` +- `bytes/field_assign_proto2_empty` +- `bytes/field_assign_proto3` +- `bytes/field_assign_proto3_empty` +- `complex/any_list_map` +- `double/field_assign_proto2` +- `double/field_assign_proto2_range` +- `double/field_assign_proto2_zero` +- `double/field_assign_proto3` +- `double/field_assign_proto3_range` +- `double/field_assign_proto3_zero` +- `double/field_read_proto2` +- `double/field_read_proto2_unset` +- `double/field_read_proto2_zero` +- `double/field_read_proto3` +- `double/field_read_proto3_unset` +- `double/field_read_proto3_zero` +- `float/field_assign_proto2` +- `float/field_assign_proto2_range` +- `float/field_assign_proto2_round_to_zero` +- `float/field_assign_proto2_subnorm` +- `float/field_assign_proto2_subnorm` +- `float/field_assign_proto2_zero` +- `float/field_assign_proto3` +- `float/field_assign_proto3_range` +- `float/field_assign_proto3_round_to_zero` +- `float/field_assign_proto3_zero` +- `float/field_read_proto2` +- `float/field_read_proto2_unset` +- `float/field_read_proto2_zero` +- `float/field_read_proto3` +- `float/field_read_proto3_unset` +- `float/field_read_proto3_zero` +- `int32/field_assign_proto2` +- `int32/field_assign_proto2_max` +- `int32/field_assign_proto2_min` +- `int32/field_assign_proto2_range` +- `int32/field_assign_proto2_zero` +- `int32/field_assign_proto3` +- `int32/field_assign_proto3_max` +- `int32/field_assign_proto3_min` +- `int32/field_assign_proto3_range` +- `int32/field_assign_proto3_zero` +- `int32/field_read_proto2` +- `int32/field_read_proto2_unset` +- `int32/field_read_proto2_zero` +- `int32/field_read_proto3` +- `int32/field_read_proto3_unset` +- `int32/field_read_proto3_zero` +- `int64/field_assign_proto2` +- `int64/field_assign_proto2_zero` +- `int64/field_assign_proto3` +- `int64/field_assign_proto3_zero` +- `list/field_assign_proto2` +- `list/field_assign_proto2_empty` +- `list/field_assign_proto3` +- `list/field_assign_proto3_empty` +- `list/field_read_proto2` +- `list/field_read_proto2_empty` +- `list/field_read_proto2_unset` +- `list/field_read_proto3` +- `list/field_read_proto3_empty` +- `list/field_read_proto3_unset` +- `string/field_assign_proto2` +- `string/field_assign_proto2_empty` +- `string/field_assign_proto3` +- `string/field_assign_proto3_empty` +- `struct/field_assign_proto2` +- `struct/field_assign_proto2_bad` +- `struct/field_assign_proto2_empty` +- `struct/field_assign_proto3` +- `struct/field_assign_proto3_bad` +- `struct/field_assign_proto3_empty` +- `struct/field_read_proto2` +- `struct/field_read_proto2_empty` +- `struct/field_read_proto2_unset` +- `struct/field_read_proto3` +- `struct/field_read_proto3_empty` +- `struct/field_read_proto3_unset` +- `uint32/field_assign_proto2` +- `uint32/field_assign_proto2_max` +- `uint32/field_assign_proto2_range` +- `uint32/field_assign_proto2_zero` +- `uint32/field_assign_proto3` +- `uint32/field_assign_proto3_max` +- `uint32/field_assign_proto3_range` +- `uint32/field_assign_proto3_zero` +- `uint32/field_read_proto2` +- `uint32/field_read_proto2_unset` +- `uint32/field_read_proto2_zero` +- `uint64/field_assign_proto2` +- `uint64/field_assign_proto2_zero` +- `uint64/field_assign_proto3` +- `uint64/field_assign_proto3_zero` +- `uint64/field_read_proto2` +- `uint64/field_read_proto2_unset` +- `uint64/field_read_proto2_zero` +- `value_bool/field_assign_proto2` +- `value_bool/field_assign_proto2_false` +- `value_bool/field_assign_proto3` +- `value_bool/field_assign_proto3_false` +- `value_bool/field_read_proto2` +- `value_bool/field_read_proto2_false` +- `value_bool/field_read_proto3` +- `value_bool/field_read_proto3_false` +- `value_list/field_assign_proto2` +- `value_list/field_assign_proto2_empty` +- `value_list/field_assign_proto3` +- `value_list/field_assign_proto3_empty` +- `value_list/field_read_proto2` +- `value_list/field_read_proto2_empty` +- `value_list/field_read_proto3` +- `value_list/field_read_proto3_empty` +- `value_null/field_assign_proto2` +- `value_null/field_assign_proto3` +- `value_null/field_read_proto2` +- `value_null/field_read_proto2_unset` +- `value_null/field_read_proto3` +- `value_null/field_read_proto3_unset` +- `value_number/field_assign_proto2` +- `value_number/field_assign_proto2_zero` +- `value_number/field_assign_proto3` +- `value_number/field_assign_proto3_zero` +- `value_number/field_read_proto2` +- `value_number/field_read_proto2_zero` +- `value_number/field_read_proto3` +- `value_number/field_read_proto3_zero` +- `value_string/field_assign_proto2` +- `value_string/field_assign_proto2_empty` +- `value_string/field_assign_proto3` +- `value_string/field_assign_proto3_empty` +- `value_string/field_read_proto2` +- `value_string/field_read_proto2_zero` +- `value_string/field_read_proto3` +- `value_string/field_read_proto3_zero` +- `value_struct/field_assign_proto2` +- `value_struct/field_assign_proto2_empty` +- `value_struct/field_assign_proto3` +- `value_struct/field_assign_proto3_empty` +- `value_struct/field_read_proto2` +- `value_struct/field_read_proto2_empty` +- `value_struct/field_read_proto3` +- `value_struct/field_read_proto3_empty` + +### optionals — requires protobuf descriptors (11) + +- `optionals/empty_struct_optindex_hasValue` +- `optionals/has_optional_ofNonZeroValue_struct_optional_ofNonZeroValue_map_optindex_field` +- `optionals/optional_empty_struct_optindex_hasValue` +- `optionals/optional_ofNonZeroValue_struct_optional_ofNonZeroValue_map_optindex_field` +- `optionals/optional_struct_optindex_index_value` +- `optionals/optional_struct_optindex_value` +- `optionals/struct_list_optindex_field` +- `optionals/struct_map_optindex_field` +- `optionals/struct_map_optindex_field_nested` +- `optionals/struct_optindex_value` +- `optionals/struct_optional_ofNonZeroValue_map_optindex_field` + +### parse — PostgreSQL text cannot represent NUL in strings (20) + +- `string_literals/double_quoted_lower_u_escapes` +- `string_literals/double_quoted_lower_x_escapes` +- `string_literals/double_quoted_octal_escapes` +- `string_literals/double_quoted_upper_u_escapes` +- `string_literals/double_quoted_upper_x_escapes` +- `string_literals/single_quoted_lower_u_escapes` +- `string_literals/single_quoted_lower_x_escapes` +- `string_literals/single_quoted_octal_escapes` +- `string_literals/single_quoted_upper_u_escapes` +- `string_literals/single_quoted_upper_x_escapes` +- `string_literals/triple_double_quoted_lower_u_escapes` +- `string_literals/triple_double_quoted_lower_x_escapes` +- `string_literals/triple_double_quoted_octal_escapes` +- `string_literals/triple_double_quoted_upper_u_escapes` +- `string_literals/triple_double_quoted_upper_x_escapes` +- `string_literals/triple_single_quoted_lower_u_escapes` +- `string_literals/triple_single_quoted_lower_x_escapes` +- `string_literals/triple_single_quoted_octal_escapes` +- `string_literals/triple_single_quoted_upper_u_escapes` +- `string_literals/triple_single_quoted_upper_x_escapes` + +### parse — requires protobuf descriptors (26) + +- `comments/new_line_terminated` +- `nest/message_literal` +- `repeat/message_literal` +- `repeat/select` +- `struct_field_names/as` +- `struct_field_names/break` +- `struct_field_names/const` +- `struct_field_names/continue` +- `struct_field_names/else` +- `struct_field_names/for` +- `struct_field_names/function` +- `struct_field_names/if` +- `struct_field_names/import` +- `struct_field_names/let` +- `struct_field_names/loop` +- `struct_field_names/namespace` +- `struct_field_names/package` +- `struct_field_names/return` +- `struct_field_names/var` +- `struct_field_names/void` +- `struct_field_names/while` +- `whitespace/carriage_returns` +- `whitespace/new_lines` +- `whitespace/new_pages` +- `whitespace/spaces` +- `whitespace/tabs` + +### string_ext — requires protobuf descriptors (3) + +- `format_errors/object inside list` +- `format_errors/object inside map` +- `format_errors/object not allowed` + +### type_deduction — requires protobuf descriptors (21) + +- `complex_initializers/struct` +- `field_access/enum_field` +- `field_access/enum_map_field` +- `field_access/int_field` +- `field_access/map_bool_int` +- `field_access/repeated_enum_field` +- `field_access/repeated_int_field` +- `flexible_type_parameter_assignment/comprehension_type_var_aliasing` +- `flexible_type_parameter_assignment/list_parameters_do_not_unify` +- `flexible_type_parameter_assignment/overload_type_var_aliasing` +- `legacy_nullable_types/null_assignable_to_duration_parameter_candidate` +- `legacy_nullable_types/null_assignable_to_message_parameter_candidate` +- `legacy_nullable_types/null_assignable_to_timestamp_parameter_candidate` +- `wrappers/wrapper_dyn_promotion` +- `wrappers/wrapper_dyn_promotion_2` +- `wrappers/wrapper_null_assignable` +- `wrappers/wrapper_primitive_assignable` +- `wrappers/wrapper_promotion` +- `wrappers/wrapper_promotion_2` +- `wrappers/wrapper_ternary_parameter_assignment` +- `wrappers/wrapper_ternary_parameter_assignment_2` + +## Divergences from cel-go + +Cases where the two implementations reach different +verdicts against the corpus expectation. Both sides ran +under the same environment and were judged by the same +comparator. + +10 cases. + +### `fields/qualified_identifier_resolution/map_key_float` + +``` +{3.3:15.15, 1.0: 5}[1.0] +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: expected an error value, got {"@t":"int","v":5} + +### `fields/qualified_identifier_resolution/map_value_repeat_key` + +``` +{true:1,false:2,true:3}[true] +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: expected an error value, got {"@t":"int","v":3} + +### `fields/qualified_identifier_resolution/map_value_repeat_key_heterogeneous` + +``` +{0: 1, 0u: 2}[0.0] +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: expected an error value, got {"@t":"int","v":1} + +### `network_ext/ipv4/ipv4_equals_ipv6` + +``` +ip('::ffff:c0a8:1') == ip('192.168.0.1') +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: cel-go rejected the case: ERROR: :1:4: invalid ip argument: IPv4-mapped IPv6 address "::ffff:c0a8:1" is not allowed | ip('::ffff:c0a8:1') == ip('192.168.0.1') | ...^ + +### `network_ext/ipv4/ipv4_not_equals_ipv6` + +``` +ip('::ffff:c0a8:1') == ip('192.168.10.1') +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: cel-go rejected the case: ERROR: :1:4: invalid ip argument: IPv4-mapped IPv6 address "::ffff:c0a8:1" is not allowed | ip('::ffff:c0a8:1') == ip('192.168.10.1') | ...^ + +### `optionals/optionals/map_optional_select_has` + +``` +has({'foo': optional.none()}.foo.bar) +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: cel-go rejected the case: no such key: bar + +### `string_ext/value_errors/indexof_out_of_range` + +``` +'tacocat'.indexOf('a', 30) == -1 +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: expected an error value, got {"@t":"bool","v":true} + +### `string_ext/value_errors/lastindexof_out_of_range` + +``` +'tacocat'.lastIndexOf('a', 30) == -1 +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: expected an error value, got {"@t":"bool","v":true} + +### `timestamps/duration_converters/get_milliseconds` + +``` +x.getMilliseconds() +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: result mismatch: want {"@t":"int","v":321} got {"@t":"int","v":123321} + +### `type_deduction/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate` + +``` +[optional.of(1), null][0] +``` + +- cel4postgres — matches the corpus +- cel-go — disagrees: deduced type mismatch: want {"kind":"opaque","name":"optional_type","params":[{"kind":"int"}]} got {"kind":"null"} + diff --git a/internal/cmd/confreport/main.go b/internal/cmd/confreport/main.go new file mode 100644 index 0000000..861c4f0 --- /dev/null +++ b/internal/cmd/confreport/main.go @@ -0,0 +1,69 @@ +// Command confreport regenerates the committed conformance report by +// running the in-scope corpus against the database and against +// cel-go. It needs the test database up (docker compose up -d --wait); +// the report states what actually happened, so there is no way to +// produce it without running it. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/emfga/cel4postgres/conformance" + "github.com/emfga/cel4postgres/internal/repo" + "github.com/emfga/cel4postgres/internal/testdb" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "confreport:", err) + os.Exit(1) + } +} + +func run() error { + ctx := context.Background() + + conn, err := testdb.Connect(ctx) + if err != nil { + return err + } + defer conn.Close(ctx) + + report, err := conformance.Build(ctx, conn) + if err != nil { + return err + } + + root, err := repo.Root() + if err != nil { + return err + } + + markdown := filepath.Join(root, conformance.ReportMarkdownPath) + if err := os.WriteFile( + markdown, []byte(report.Markdown()), 0o644, + ); err != nil { + return err + } + + sidecar, err := report.MarshalJSONReport() + if err != nil { + return err + } + jsonPath := filepath.Join(root, conformance.ReportJSONPath) + if err := os.WriteFile(jsonPath, sidecar, 0o644); err != nil { + return err + } + + fmt.Printf( + "wrote %s and %s: %d passed, %d failed, %d skipped, "+ + "%d divergences\n", + conformance.ReportMarkdownPath, conformance.ReportJSONPath, + report.Totals.Passed, report.Totals.Failed, + report.Totals.Skipped, len(report.Divergences), + ) + return nil +} diff --git a/internal/oracle/case.go b/internal/oracle/case.go new file mode 100644 index 0000000..c27cdee --- /dev/null +++ b/internal/oracle/case.go @@ -0,0 +1,131 @@ +package oracle + +import ( + "fmt" + + expr "cel.dev/expr" + test "cel.dev/expr/conformance/test" + + "cel.dev/cel-go/cel" + "cel.dev/cel-go/common/types" +) + +// Running a whole conformance case against cel-go -- rather than a +// bare expression -- is what lets the two implementations be compared +// case by case for the conformance report. The recipe mirrors cel-go's +// own harness (conformance/conformance_test.go): declarations and +// container come from the case, macros are cleared when the case says +// so, and check runs unless the case disables it. + +// Outcome is what cel-go produced for one corpus case. +// +// Err carries a CEL-level outcome -- an expression cel-go rejected, or +// one that evaluated to an error -- which the corpus asserts on with +// eval_error. It is not a harness failure; those are returned as the +// second result of RunCase. +type Outcome struct { + Value *expr.Value + Type *expr.Type + Err error +} + +// RunCase compiles and evaluates one corpus case against cel-go under +// the given cel4postgres env name, so both sides of the comparison see +// the same environment composition. +func RunCase(tc *test.SimpleTest, envName string) (Outcome, error) { + options, err := Options(envName) + if err != nil { + return Outcome{}, err + } + if tc.GetDisableMacros() { + options = append(options, cel.ClearMacros()) + } + if container := tc.GetContainer(); container != "" { + options = append(options, cel.Container(container)) + } + for _, decl := range tc.GetTypeEnv() { + option, err := cel.ProtoAsDeclaration(decl) + if err != nil { + return Outcome{}, fmt.Errorf( + "declaration %q: %w", decl.GetName(), err) + } + options = append(options, option) + } + + env, err := Env(options...) + if err != nil { + return Outcome{}, err + } + + ast, issues := env.Parse(tc.GetExpr()) + if issues != nil && issues.Err() != nil { + return Outcome{Err: issues.Err()}, nil + } + + if !tc.GetDisableCheck() { + ast, issues = env.Check(ast) + if issues != nil && issues.Err() != nil { + return Outcome{Err: issues.Err()}, nil + } + } + + outcome := Outcome{} + if !tc.GetDisableCheck() { + deduced, err := types.TypeToProto(ast.OutputType()) + if err != nil { + return Outcome{}, fmt.Errorf("deduced type: %w", err) + } + outcome.Type = deduced + } + if tc.GetCheckOnly() { + return outcome, nil + } + + // A planning failure is cel-go refusing the expression -- an + // unsupported map key type is reported here rather than at eval + // -- so it is a CEL-level outcome the corpus asserts on with + // eval_error, not a harness failure. + program, err := env.Program(ast) + if err != nil { + return Outcome{Type: outcome.Type, Err: err}, nil + } + + activation := map[string]any{} + for name, binding := range tc.GetBindings() { + value, err := bindingValue(env, binding) + if err != nil { + return Outcome{}, fmt.Errorf("binding %q: %w", name, err) + } + activation[name] = value + } + + result, _, err := program.Eval(activation) + if err != nil { + return Outcome{Type: outcome.Type, Err: err}, nil + } + if types.IsError(result) { + return Outcome{ + Type: outcome.Type, + Err: result.(*types.Err).Unwrap(), + }, nil + } + + value, err := cel.ValueAsProto(result) + if err != nil { + return Outcome{}, fmt.Errorf("convert result: %w", err) + } + outcome.Value = value + return outcome, nil +} + +// bindingValue converts a case binding to a cel-go value. Only the +// value kind occurs in the corpus (measured); an error or unknown +// binding is reported rather than guessed at, so a corpus update that +// introduces one extends this deliberately. +func bindingValue(env *cel.Env, v *expr.ExprValue) (any, error) { + value, ok := v.GetKind().(*expr.ExprValue_Value) + if !ok { + return nil, fmt.Errorf("unsupported binding kind %T", v.GetKind()) + } + return cel.ProtoAsValue(env.CELTypeAdapter(), value.Value) +} From d5e8d961d849dd79f4e198745a75cf0983b83f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sun, 30 Aug 2026 17:23:49 -0300 Subject: [PATCH 20/21] Write down what the conformance claim excludes The number was defensible and the reasoning behind it was not written anywhere a reader could reach: which cases we deliberately answer differently from cel-go, and on what evidence, existed only in a scratch register. A conformance figure whose exceptions are undocumented asks to be taken on trust, which is the opposite of what measuring it was for. This is written for someone deciding whether to put their policy expressions through it, so it leads with the claim and its pins and spends most of its length on the boundaries: what protobuf support costs and why it is refused, the one permanent substrate limit (NUL in strings), why the corpus outranks cel-go where they disagree, and each divergence with the evidence that settled it -- including the one case where no implementation matches the corpus and we followed it anyway. The three divergences with no corpus case are called out separately. A difference nothing tests is the kind that surprises someone later. --- docs/CONFORMANCE.md | 239 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/CONFORMANCE.md diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md new file mode 100644 index 0000000..6f31fdb --- /dev/null +++ b/docs/CONFORMANCE.md @@ -0,0 +1,239 @@ +# Conformance + +What "cel4postgres is CEL-conformant" claims, what it excludes, and +every place it knowingly answers differently from cel-go. + +The numbers live in [conformance-report.md](conformance-report.md), +which is generated from an actual run and kept current by a test. This +document is the part that does not come out of a machine: what was +measured, against what, and why the exceptions are what they are. + +## The claim + +Every in-scope case of the [cel-spec][cel-spec] simple conformance +corpus passes against a fresh cel4postgres install, with no failures +and no unnamed omissions. + +Three things pin what that sentence means: + +| | | +|---|---| +| corpus | cel-spec at `ba58ae5007845f3a1279b488cdeb79645ce958bb` | +| reference | cel-go `v0.32.0` | +| substrate | PostgreSQL 18 | + +All three are pinned deliberately and move only with a +re-measurement behind them. Which expressions two implementations +agree on changes with the version of either, so a bumped dependency +is a changed claim, not a routine upgrade. The corpus commit lives in +`internal/corpus.Pin`, the cel-go version in `internal/oracle.Version`, +and tests fail when either drifts from what the build actually uses. + +## What is in scope + +The cel-spec core language over the JSON-representable types — `bool`, +`int`, `uint`, `double`, `string`, `bytes`, `null`, `list`, `map`, +`type` — the standard library, the standard macros, overflow and +conversion semantics, and error and unknown propagation. + +The well-known types are in scope and implemented: `Timestamp`, +`Duration`, `Struct`, `Value`, `ListValue`, `Any` (name resolution), +and the nine `Int32Value`-family wrappers. These are JSON-shaped and +need no descriptor pool, so they are ordinary registered type rows. + +The extension libraries — `strings`, `math`, `lists`, `encoders`, +`bindings`, `optionals`, two-variable comprehensions, and `network` — +are implemented and conformance-tested, each behind its own +environment name. **None of them is enabled in the default +environment.** That is load-bearing: a spec-conformance number +measured in an environment that quietly gained an extension is +measuring something else. + +## What is not attempted, and why + +Nothing is skipped silently. Every omission is a named entry the test +binary prints on each run and the generated report lists in full. + +**Protobuf messages are out of scope**, and with them field selection +over messages, proto2/proto3 presence semantics, and enums. They +require a descriptor pool inside PostgreSQL and buy nothing for the +JSON-shaped data cel4postgres exists to serve. Six corpus files are +not attempted for this reason (`proto2`, `proto3`, `enums`, +`wrappers`, `proto2_ext`) or because they exercise a cel-go-internal +form with no consumer (`block_ext`). + +Inside attempted files, cases are skipped in exactly two categories, +both derived mechanically from the corpus rather than listed by hand, +so the list cannot drift from what the corpus actually contains: + +- **Requires protobuf descriptors** — the case constructs or + references `TestAllTypes`, `NestedTestAllTypes`, + `Proto2ExtensionScopedMessage`, or an `Any` wrapping serialized + proto bytes. Same exclusion as above, at case granularity. +- **PostgreSQL text cannot represent NUL in strings** — PostgreSQL + `text` and `jsonb` categorically reject U+0000. Twenty-nine corpus + cases carry one: twenty expect a string value containing NUL, and + nine spell a NUL byte directly in the expression text. This is a + substrate limit, not a CEL one, and it is the one place a + conformance gap is permanent rather than out of scope. Bytes + containing NUL are unaffected, and so are raw-string literals like + `r'\000'`, whose value is backslash characters rather than a NUL. + +## How it is measured + +Each corpus file runs under the environment its features require, as a +union of registered environment names: `basic` and `comparisons` under +`standard`; `string_ext` under `standard,strings`; `math_ext` under +`standard,math`, and so on. The per-file environment is printed in the +report. + +This is **stricter than cel-go's own conformance harness**, which +builds one environment with every extension enabled globally and +selects only macros on or off per test. The stricter form is the +point: cel4postgres's extension model is registry rows, and per-file +environments are what demonstrates that an extension is genuinely +absent until registered. A file that passes only because the default +environment gained an extension is not a passing file. + +Both sides of every comparison are configured the same way. The +reference environment is assembled in exactly one place +(`internal/oracle`), from the same environment string the database +side receives, and the same comparator judges both outcomes. A +comparison against a reference configured ad hoc per call site +measures two different references and reports it as one. + +## The reference, and what happens when it disagrees + +cel-go is the behavioural authority: a claim about CEL semantics that +has not been run against it is a hypothesis. cel-java is the second +opinion where cel-go's behaviour looks like an implementation detail +rather than a specified one — two independent implementations +agreeing is evidence about the spec, one is evidence about that +implementation. + +Where the corpus and cel-go disagree, **cel4postgres follows the +corpus.** The corpus is the specification's own executable statement +of intent; cel-go is an implementation of it, and its maintainers +mark several of these cases as known-failing in their own build. Each +such case was adjudicated individually before being encoded, with the +spec text read and cel-java consulted. + +The consequence is that the divergence list below is, with one +exception, a list of cases where cel-go does not satisfy the corpus. +It is generated by running both implementations over every attempted +case, so it is measured on each regeneration rather than remembered. + +## Divergence register + +Ten cases, in six groups. The report names each one with its +expression and both verdicts. + +### Map keys: forbidden types and duplicates + +`fields/qualified_identifier_resolution/map_key_float`, +`map_value_repeat_key`, `map_value_repeat_key_heterogeneous` + +The spec text is settled: `double` and `null` are not valid map key +types, and duplicate keys are an error at construction — including +keys that collide only after `int`/`uint` normalization, as in +`{0: 1, 0u: 2}`. The corpus expects an error; cel-go v0.32.0 returns a +value and annotates the cases with its current behaviour as a +comment. cel4postgres errors. + +(`map_key_null` is not a divergence: cel-go rejects it too, when +planning rather than when evaluating.) + +### `duration.getMilliseconds()` + +`timestamps/duration_converters/get_milliseconds` + +The corpus expects the sub-second component — `321` for a duration of +123s 321456789ns. cel-go v0.32.0 returns total milliseconds +(`123321`). cel-java's `GetMillisecondsFunction` computes +`toMillis(arg) % 1000`, agreeing with the corpus, and the corpus's own +description marks the total-milliseconds reading as the one being +deprecated. Corpus and cel-java against cel-go alone is not a +three-way split. cel4postgres returns the component. + +### `indexOf` / `lastIndexOf` with an out-of-range offset + +`string_ext/value_errors/indexof_out_of_range`, +`lastindexof_out_of_range` + +`'tacocat'.indexOf('a', 30)` errors per the corpus; cel-go returns +`-1`. cel-java's `CelStringExtensions` throws "Offset out of range" +for `offset < 0 || offset >= length`, agreeing with the corpus — and +cel-go itself errors on the negative-offset and `substring` variants, +so its own behaviour is internally inconsistent here. cel4postgres +errors. The empty-substring special case returns the offset before +the bounds check, matching both implementations. + +### `has()` through an optional chain + +`optionals/optionals/map_optional_select_has` + +`has({'foo': optional.none()}.foo.bar)` is `false` per the corpus; +cel-go raises "no such key: bar". Optional qualification is +if-present throughout cel4postgres: qualifying a present optional +with a missing key or index yields `optional.none()`, never an error. + +### Joining `null` with a legacy nullable type + +`type_deduction/legacy_nullable_types/null_assignable_to_abstract_parameter_candidate` + +The one entry where no implementation agrees with the corpus. +`[optional.of(1), null][0]` should deduce `optional_type(int)`; +cel-go's `joinTypes`/`mostGeneral` answers `null`, and cel-java's +checker is the same algorithm. cel-go's conformance build skips all +four `legacy_nullable_types` cases as known-failing, which marks its +own behaviour as pending a fix rather than as intended. cel4postgres +encodes the corpus: joining `null` with a legacy-nullable type keeps +the nullable type. + +### IPv4-mapped IPv6 addresses + +`network_ext/ipv4/ipv4_equals_ipv6`, `ipv4_not_equals_ipv6` + +The corpus accepts the hex form `::ffff:c0a8:1` and treats it as +equal to the IPv4 address it maps to, while rejecting the dotted form +`::ffff:192.168.0.1`. cel-go v0.32.0's `parseIPAddr` rejects both, and +its conformance harness does not run `network_ext` at all — so there +is no reference position to weigh here, only the corpus, and the +corpus is what cel4postgres implements. + +## Divergences the corpus does not cover + +Three behaviours differ from cel-go without a corpus case to record +it. They are stated here because a divergence nothing tests is the +kind that surprises someone in production. + +- **`matches()` uses PostgreSQL's regex engine (ARE), not RE2.** All + nine patterns the corpus uses were measured to agree. ARE and RE2 + differ in corners — ARE has backreferences, escape classes differ — + so a pattern outside the measured set is not covered by the + conformance claim. +- **`optional.or` and `orValue` are strict**, evaluating both sides, + where cel-go's interpreter special-cases them to short-circuit. + Every corpus case passes either way; the difference is observable + only when the right-hand side errors and the left is present. +- **Unknown propagation has no corpus coverage at all** — + `unknowns.textproto` is an empty stub upstream. It is covered + instead by a 24-case suite diffed against cel-go's partial + evaluation, which passes: unknowns win over errors in `&&` and `||` + in either order, conditionals propagate only the taken branch, and + comprehensions absorb unknowns exactly as `&&` and `||` do. + +## Reproducing it + +```bash +docker compose up -d --wait # installs the schema during initdb +go test ./conformance/... # the suite; prints every skip +go run ./internal/cmd/confreport # regenerates the report +``` + +The corpus is read from a local cel-spec checkout named by +`CEL_EXPR_DIR`, never a hard-coded path. The report regenerates +deterministically from a run, and `TestReportCurrent` fails when the +committed copy stops describing this tree. + +[cel-spec]: https://github.com/cel-expr/cel-spec From da783d776a880ceee359e1a8eb180d056c496958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Sun, 30 Aug 2026 17:25:35 -0300 Subject: [PATCH 21/21] Correct the README's status and scope It still opened with "Status: scaffolding -- there is no parser, checker or evaluator yet", which stopped being true several milestones ago. That is the first thing a reader sees, and a project that misdescribes itself in its first paragraph earns doubt about everything after it. The scope section is brought in line too: the well-known types moved into scope once it was clear they need no descriptor pool, the extension libraries are implemented rather than planned, and both conformance documents are linked from where a reader would look for the claim. The quick-start example now runs an actual expression instead of selecting a version string, and shows the staged form as well -- cel.evaluate checks with no declarations, so an expression with a free variable fails there, which is worth learning from the README rather than from a puzzling error. Volatility labels are stated as the catalog reports them. --- README.md | 87 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1587ab0..6b304d4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -95,10 +123,18 @@ 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 @@ -116,25 +152,28 @@ 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