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..7fe7222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,23 @@ jobs: - run: go vet ./... + # The conformance suite reads the cel-spec corpus from a local + # checkout named by CEL_EXPR_DIR. Pinned by commit for the same + # reason cel-go is pinned in go.mod: the corpus defines what the + # conformance number means, so it moves only deliberately. The + # ref below is checked against internal/corpus.Pin by a test -- + # move both together. + # Checked out after the gofmt step so its files are never + # formatted-checked as ours. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: cel-expr/cel-spec + ref: ba58ae5007845f3a1279b488cdeb79645ce958bb + path: .cel-expr/cel-spec + - run: go test ./... -v + env: + CEL_EXPR_DIR: ${{ github.workspace }}/.cel-expr - name: Dump database logs if: failure() 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/CLAUDE.md b/CLAUDE.md index 987e332..4096ffc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,19 +9,22 @@ Common Expression Language (CEL). It parses, type-checks and evaluates CEL inside PostgreSQL, with no server-side extension, no shared library, and no procedural language beyond `plpgsql`. -**Status: scaffolding only.** What exists is the harness, not the -evaluator: `compose.yaml` (a disposable Postgres that installs `sql/` -during initdb), `sql/install.sql` (the `cel` schema, a `schema_version` -table and `cel.version()` — nothing that evaluates anything), a Go suite -whose only tests assert that the database is reachable and the schema -installed, and a CI workflow that runs them. - -No parser, checker, evaluator, registry table or conformance runner has -been written. Everything about those in the sections below describes the -design the next commits are meant to realise, not code you can read. -When you implement a piece of it, replace the prescriptive wording here -with what the code actually does — and when the code and this file -disagree, the code is right and this file is a bug. +**Status: the evaluator is complete and the in-scope conformance +corpus is green.** The pipeline is `sql/010_registry.sql` (the four +registry tables), `020_values.sql` (tagged values, equality, +comparison), `030_parse.sql` (lexer, Pratt parser, macro engine), +`040_check.sql` (checker with overload-id binding), `050_eval.sql` +(evaluator core and the public `eval`/`evaluate` entry points), +`060_stdlib.sql` (standard library rows and impls), `070_wkt.sql` +(timestamps, durations, wrappers, Struct/Value/ListValue), and the +extension libraries `100`–`170` (two-var comprehensions, optionals, +strings, math, lists, encoders, bindings, network), each visible +only under its own env name. Every in-scope conformance file passes +on a fresh install; skips are a named list the suite prints (proto +descriptor material and NUL-in-string cases). Unknown propagation +is covered by its own suite against cel-go partial evaluation. +When the code and this file disagree, the code is right and this +file is a bug. "Zero-dependency" is the product claim and the design constraint: a consumer installs cel4postgres by running SQL scripts against a database @@ -40,11 +43,18 @@ comprehensions), overflow and conversion semantics, and error/unknown propagation. **Out of scope (v1):** protobuf messages, field selection over messages, -proto2/proto3 presence semantics, enums, and the wrapper types -(`Int32Value` and family). These require a descriptor pool inside -Postgres and buy nothing for the JSON-shaped data cel4postgres targets. -The corresponding conformance files (`proto2`, `proto3`, `enums`, -`wrappers`, `proto2_ext`) are out of scope with them. +proto2/proto3 presence semantics, and enums. These require a +descriptor pool inside Postgres and buy nothing for the JSON-shaped +data cel4postgres targets. The corresponding conformance files +(`proto2`, `proto3`, `enums`, `wrappers`, `proto2_ext`) are out of +scope with them. + +The wrapper types (`Int32Value` and family) turned out not to need a +descriptor pool — they are JSON-shaped, constructed by registered +type rows like every other WKT — so they are in scope and +implemented in `sql/070_wkt.sql`. The `wrappers` conformance file +stays skipped only because its cases also need proto3 `TestAllTypes` +and `Any` unpacking. **Out of core, in by registration:** every cel-go extension library (`strings`, `math`, `lists`, `sets`, `encoders`, `bindings`, @@ -97,9 +107,10 @@ anything. ## Architecture ``` -cel.parse(source text, env text) → ast jsonb -cel.check(ast jsonb, env text) → ast jsonb -cel.eval(ast jsonb, activation jsonb, env text) → value jsonb +cel.parse(source text, env text) → ast jsonb +cel.check(ast jsonb, env text[, options jsonb]) → ast jsonb +cel.eval(ast jsonb, activation jsonb, env text[, options jsonb]) + → value jsonb cel.evaluate(source text, activation jsonb, env text) → value jsonb ``` @@ -109,6 +120,14 @@ installation has to serve the spec-conformant environment, the OpenFGA dialect and a client's own dialect simultaneously, and a global would collapse them. +`options` carries per-call context that is not part of the +environment: the namespace `container` and, for `check`, extra ident +declarations (`decls`). `eval` accepts a container because unchecked +evaluation resolves names at runtime — the conformance corpus's +disable_check container cases cannot pass without it. Checked ASTs +ignore it: the checker has already rewritten every name to its +qualified form. The plain three-argument forms remain as wrappers. + Macros expand during `parse`. Overloads resolve during `check`, which binds an **overload id** into the AST. `eval` dispatches on that bound id and never on runtime types. @@ -213,10 +232,13 @@ is ambiguous, cel-go evaluating the same expression is the tiebreak — a claim about CEL semantics that has not been run against cel-go is a hypothesis. -**Each file runs under the `env` its features require**, mirroring how -cel-go's own `conformance/conformance_test.go` enables extensions -selectively: `basic`/`comparisons`/`logic` under `standard`, -`string_ext` under `standard + strings`, and so on. A file that passes +**Each file runs under the `env` its features require**: +`basic`/`comparisons`/`logic` under `standard`, `string_ext` under +`standard + strings`, and so on. This is deliberately stricter than +cel-go's own `conformance_test.go`, which (measured) builds one +environment with every extension enabled globally — per-file envs are +the property the registry design exists to prove, and the oracle is +configured per-file the same way when used as tiebreak. A file that passes only because the default environment quietly gained an extension is not a passing file — that is the failure mode the env parameter exists to prevent. @@ -300,7 +322,7 @@ burns an hour re-reading 3 000 assertions. Infrastructure failures must never reach the suite as test failures. `--wait` cannot return before the schema is in place, a failing -`install.sql` aborts container startup rather than yielding a running +`sql/` script aborts container startup rather than yielding a running database, and `internal/testdb` names the command that fixes an unreachable database instead of reporting a bare connection error — a red conformance run should never be ambiguous about which of the two diff --git a/README.md b/README.md index 61e383f..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,18 +123,28 @@ is faster to iterate on. Go 1.26 or newer: ```bash docker compose up -d go test ./... -go test ./... -v -run TestSchemaInstalled +go test ./conformance/... -run TestSimple/basic +go test ./conformance/... -run TestSimple/basic/self_eval_zeroish/self_eval_int_zero ``` -Both paths run the same tests against the same database. +Both paths run the same tests against the same database. The +conformance suite reads the corpus from a local cel-spec checkout +named by `CEL_EXPR_DIR`, and regenerating the report after a change +is one command: + +```bash +go run ./internal/cmd/confreport +``` ## Installing into your own database -`sql/install.sql` is an ordinary, idempotent SQL script. Nothing about -it is specific to the compose setup: +The `sql/` scripts are ordinary, idempotent SQL, ordered by their +numeric prefix. Nothing about them is specific to the compose setup: ```bash -psql -v ON_ERROR_STOP=1 -f sql/install.sql "$YOUR_DATABASE_URL" +for f in sql/*.sql; do + psql -v ON_ERROR_STOP=1 -f "$f" "$YOUR_DATABASE_URL" +done ``` It needs a role that may create the `cel` schema. It does not need @@ -114,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 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..bf5aeef --- /dev/null +++ b/conformance/envs.go @@ -0,0 +1,34 @@ +package conformance + +// fileEnvs names the environment each corpus file runs under, as a +// comma-separated union of registered env names. Files absent here run +// under plain "standard". Extension files enable exactly the extension +// they exercise and nothing more: a file that passes only because the +// default environment quietly gained an extension is not a passing +// file. +// +// macros2 was measured (Phase 0): every one of its 46 cases uses the +// two-var comprehension macros and none uses optional syntax, so the +// whole file takes two_var_comprehensions and nothing else. +var fileEnvs = map[string]string{ + "string_ext": "standard,strings", + "math_ext": "standard,math", + "lists_ext": "standard,lists", + "encoders_ext": "standard,encoders", + "bindings_ext": "standard,bindings", + "optionals": "standard,optionals", + "macros2": "standard,two_var_comprehensions", + "network_ext": "standard,network", + // type_deduction's flexible_type_parameter_assignment and + // legacy_nullable_types sections deduce optional_type values; + // everything else in the file is standard. + "type_deduction": "standard,optionals", +} + +// EnvFor returns the env parameter for a corpus file. +func EnvFor(file string) string { + if env, ok := fileEnvs[file]; ok { + return env + } + return "standard" +} 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/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/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/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 new file mode 100644 index 0000000..b2381ff --- /dev/null +++ b/conformance/run.go @@ -0,0 +1,368 @@ +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") + } + + 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) { + 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 new file mode 100644 index 0000000..ceca0cd --- /dev/null +++ b/conformance/simple_test.go @@ -0,0 +1,108 @@ +package conformance + +import ( + "context" + "fmt" + "os" + "sort" + "testing" + + "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) + + 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) { + result := RunCase(ctx, conn, stages, + file, section.GetName(), tc) + switch result.Status { + case Skipped: + t.Skip(result.Detail) + case Failed: + t.Fatal(result.Detail) + } + }) + } + }) + } + }) + } +} diff --git a/conformance/skiplist.go b/conformance/skiplist.go new file mode 100644 index 0000000..35d50dd --- /dev/null +++ b/conformance/skiplist.go @@ -0,0 +1,109 @@ +// 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() { + 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" + } + } + } + } + + 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..31afca4 --- /dev/null +++ b/conformance/skipped_cases.go @@ -0,0 +1,297 @@ +// 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/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", + "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/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", + "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/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", + "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/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/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 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/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/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/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..bed36b3 --- /dev/null +++ b/internal/codec/type.go @@ -0,0 +1,204 @@ +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 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 neither ident nor function", 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..1058da3 --- /dev/null +++ b/internal/corpus/corpus.go @@ -0,0 +1,206 @@ +// 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" + "os/exec" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + expr "cel.dev/expr" + 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" +) + +// 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 Checkout() (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)", + ) + } + + 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 "+ + "pointing at a directory with a cel-spec checkout? (%w)", + dir, err, + ) + } + + 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() + 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)) +} + +// 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 { + // 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 + } + } + 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/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go new file mode 100644 index 0000000..7c7dce6 --- /dev/null +++ b/internal/corpus/corpus_test.go @@ -0,0 +1,124 @@ +package corpus + +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 +// 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") +} + +// 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, + ) + } +} 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/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) +} diff --git a/internal/oracle/options.go b/internal/oracle/options.go new file mode 100644 index 0000000..7e62493 --- /dev/null +++ b/internal/oracle/options.go @@ -0,0 +1,55 @@ +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{ + // 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()}, + "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..70460d0 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 ( @@ -78,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/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/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 diff --git a/sql/010_registry.sql b/sql/010_registry.sql new file mode 100644 index 0000000..c8232a1 --- /dev/null +++ b/sql/010_registry.sql @@ -0,0 +1,186 @@ +-- 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; + +-- 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/020_values.sql b/sql/020_values.sql new file mode 100644 index 0000000..18ee6ce --- /dev/null +++ b/sql/020_values.sql @@ -0,0 +1,511 @@ +-- 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; + +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; + +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/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; diff --git a/sql/040_check.sql b/sql/040_check.sql new file mode 100644 index 0000000..5e0d927 --- /dev/null +++ b/sql/040_check.sql @@ -0,0 +1,1351 @@ +-- 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; + -- 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; +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; + + -- Registry rows first, then caller-declared overloads (options + -- 'decls' function entries, threaded in via st -> 'fns'). + FOR row_r IN + 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; + + 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; +$$; + + +-- 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 +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 + selr record; + 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); + + 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 + -- 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'; + + -- 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. + SELECT n.c INTO cand + FROM unnest(cel._name_candidates( + ltrim(fname, '.'), fname LIKE '.%', ctr)) n(c) + 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 + 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 sto -> 'fns' ? n.c OR 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 (sto -> 'fns' ? fname) AND 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; + fns jsonb := '{}'::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); + + -- 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) + INTO globals + 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, 'fns', fns); + + 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 new file mode 100644 index 0000000..407ded8 --- /dev/null +++ b/sql/050_eval.sql @@ -0,0 +1,860 @@ +-- 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 + -- 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) + LOOP + IF scopes -> f ? cand THEN + val := scopes -> f -> cand; + used := plen; + RETURN; + END IF; + END LOOP; + END LOOP; + END LOOP; +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 +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; + -- 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; +$$; + +-- 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); 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); + + 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' = '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; + 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; + -- 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); + 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]); + 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; + + -- 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; + -- [?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 + 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 + -- {?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). + 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; + 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; + 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, + -- 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; +$$; + +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); +$$; + +-- 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; diff --git a/sql/060_stdlib.sql b/sql/060_stdlib.sql new file mode 100644 index 0000000..1349c9d --- /dev/null +++ b/sql/060_stdlib.sql @@ -0,0 +1,1285 @@ +-- 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 := 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) + 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 := cel._f2n(fa); + nb := cel._f2n(fb); + 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; + +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; 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; 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..ef4eafc --- /dev/null +++ b/sql/110_ext_optionals.sql @@ -0,0 +1,498 @@ +-- 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; + +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;