From 2aba09ea2cf2e0aa0932416bd087a005a7cff10e Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:37 +1000 Subject: [PATCH 01/10] feat(codegen): scalar encrypted-domain SQL generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render eql_v2_ jsonb-backed domain families (types, functions, operators, aggregates) from a minimal TOML manifest. Term capabilities are fixed in terms.py (hm -> equality, ore -> equality+ordering); output is byte-identical and headed AUTO-GENERATED — DO NOT EDIT. Hardening: _sql_str() quote-doubler at every SQL interpolation boundary, distinct-fixture-value validation in spec.py, SQL-identifier validation of manifest domain names, and codegen:domain / codegen:domain:all mise tasks. mise run build regenerates all domains every build. Part of PR #239 (eql_v2_int4 variant family). --- .gitignore | 10 +- mise.toml | 38 ++ tasks/build.sh | 22 +- tasks/codegen/__init__.py | 17 + tasks/codegen/conftest.py | 6 + tasks/codegen/domain.sh | 15 + tasks/codegen/generate.py | 332 ++++++++++++++++ tasks/codegen/operator_surface.py | 72 ++++ tasks/codegen/scalars.py | 93 +++++ tasks/codegen/spec.py | 141 +++++++ tasks/codegen/templates.py | 490 +++++++++++++++++++++++ tasks/codegen/terms.py | 107 +++++ tasks/codegen/test_against_reference.py | 117 ++++++ tasks/codegen/test_generate.py | 351 +++++++++++++++++ tasks/codegen/test_operator_surface.py | 126 ++++++ tasks/codegen/test_scalars.py | 64 +++ tasks/codegen/test_spec.py | 208 ++++++++++ tasks/codegen/test_templates.py | 499 ++++++++++++++++++++++++ tasks/codegen/test_terms.py | 96 +++++ tasks/codegen/test_writer.py | 157 ++++++++ tasks/codegen/types/.gitkeep | 0 tasks/codegen/types/int4.toml | 19 + tasks/codegen/writer.py | 89 +++++ 23 files changed, 3067 insertions(+), 2 deletions(-) create mode 100644 tasks/codegen/__init__.py create mode 100644 tasks/codegen/conftest.py create mode 100755 tasks/codegen/domain.sh create mode 100644 tasks/codegen/generate.py create mode 100644 tasks/codegen/operator_surface.py create mode 100644 tasks/codegen/scalars.py create mode 100644 tasks/codegen/spec.py create mode 100644 tasks/codegen/templates.py create mode 100644 tasks/codegen/terms.py create mode 100644 tasks/codegen/test_against_reference.py create mode 100644 tasks/codegen/test_generate.py create mode 100644 tasks/codegen/test_operator_surface.py create mode 100644 tasks/codegen/test_scalars.py create mode 100644 tasks/codegen/test_spec.py create mode 100644 tasks/codegen/test_templates.py create mode 100644 tasks/codegen/test_terms.py create mode 100644 tasks/codegen/test_writer.py create mode 100644 tasks/codegen/types/.gitkeep create mode 100644 tasks/codegen/types/int4.toml create mode 100644 tasks/codegen/writer.py diff --git a/.gitignore b/.gitignore index 68bca65b..05b0ae00 100644 --- a/.gitignore +++ b/.gitignore @@ -221,7 +221,15 @@ tests/sqlx/migrations/001_install_eql.sql # Generated SQLx fixtures (regenerated via `mise run fixture:generate`, # never commit — stale fixtures hide bugs) -tests/sqlx/fixtures/eql_v2_int4.sql +tests/sqlx/fixtures/eql_v2* + +# Generated encrypted-domain SQL — regenerated by `tasks/build.sh` from +# tasks/codegen/types/.toml on every build (or `mise run codegen:domain +# ` to refresh manually). Hand-written *_extensions.sql stays committed. +src/encrypted_domain/*/*_types.sql +src/encrypted_domain/*/*_functions.sql +src/encrypted_domain/*/*_operators.sql +src/encrypted_domain/*/*_aggregates.sql # Large generated test data files tests/ste_vec_vast.sql diff --git a/mise.toml b/mise.toml index a5ead126..33c96519 100644 --- a/mise.toml +++ b/mise.toml @@ -76,3 +76,41 @@ dir = "{{config_root}}/tests/sqlx" run = """ cargo test --test payload_schema_tests """ + +[tasks."codegen:domain:all"] +description = "Regenerate every encrypted-domain type from its TOML manifest" +dir = "{{config_root}}" +run = """ +mise exec python -- python -m tasks.codegen.generate --all +""" + +[tasks."test:codegen"] +description = "Run the encrypted-domain codegen generator tests (no database required)" +dir = "{{config_root}}" +run = """ +# pytest is the only non-stdlib dependency; the install is a fast no-op once satisfied. +mise exec python -- python -m pip install --quiet --disable-pip-version-check pytest +mise exec python -- python -m pytest tasks/codegen -q +""" + +[tasks."test:matrix:inventory"] +description = "Regenerate the int4 matrix test-name inventory snapshot (no database required)" +dir = "{{config_root}}/tests/sqlx" +run = """ +# Pin an explicit feature set so the inventory is deterministic regardless of +# the caller's local flags. `--no-default-features` keeps the `scale` arm +# (`#[cfg(feature = "scale")]`) excluded — its add/delete is a known blind spot +# of this default-feature inventory, covered instead by the scale gate + the +# family::mutations negative controls. `--list` enumerates the whole +# encrypted_domain binary (family::support, family::inlinability, +# family::mutations, scalars::int4); `grep '^scalars::int4'` scopes the +# snapshot to the matrix only, so landing other family tests never dirties it. +# `LC_ALL=C sort` makes ordering byte-stable across locales (a bare `sort` is +# locale-dependent and yields spurious CI diffs). +set -euo pipefail +mkdir -p snapshots +cargo test --no-default-features --test encrypted_domain -- --list | + sed -n 's/: test$//p' | + grep '^scalars::int4' | + LC_ALL=C sort > snapshots/int4_matrix_tests.txt +""" diff --git a/tasks/build.sh b/tasks/build.sh index 0768dd34..cef25521 100755 --- a/tasks/build.sh +++ b/tasks/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash #MISE description="Build SQL into single release file" #MISE alias="b" -#MISE sources=["src/**/*.sql", "tasks/pin_search_path.sql", "tasks/uninstall.sql", "tasks/uninstall-protect.sql"] +#MISE sources=["src/**/*.sql", "tasks/pin_search_path.sql", "tasks/uninstall.sql", "tasks/uninstall-protect.sql", "tasks/codegen/types/*.toml", "tasks/codegen/*.py"] #MISE outputs=["release/cipherstash-encrypt.sql","release/cipherstash-encrypt-uninstall.sql","release/cipherstash-encrypt-protect.sql","release/cipherstash-encrypt-protect-uninstall.sql"] #USAGE flag "--version " help="Specify release version of EQL" default="DEV" @@ -9,6 +9,26 @@ set -euo pipefail +# Regenerate encrypted-domain SQL from TOML specs before building. +# Generated files (src/encrypted_domain//_*.sql) are gitignored; the +# manifest at tasks/codegen/types/.toml is the source of truth. +# +# Nuke every generated file first so a deleted or renamed manifest can't +# leave orphans in src/ that the `src/**/*.sql` build glob would silently +# pick up. writer.py cleans within a directory it's regenerating, but it +# never runs for a type whose manifest no longer exists. Hand-written +# *_extensions.sql is preserved by the name patterns; -mindepth 2 keeps +# the type-agnostic src/encrypted_domain/functions.sql safe. +find src/encrypted_domain -mindepth 2 -type f \ + \( -name '*_types.sql' -o -name '*_functions.sql' -o -name '*_operators.sql' \ + -o -name '*_aggregates.sql' \) \ + -delete 2>/dev/null || true + +# Regenerate every type — single source of truth for the enumeration lives in +# tasks/codegen/generate.py (sorted, deterministic, aggregate exit code). The +# orphan sweep above still handles the manifest-deleted case --all cannot. +mise exec python -- python -m tasks.codegen.generate --all + # Fail loudly if any file referenced in a tsorted dep list doesn't exist. # Without this, `xargs cat` would print `cat: foo.sql: No such file or directory` # and continue — silently producing an incomplete release artefact. diff --git a/tasks/codegen/__init__.py b/tasks/codegen/__init__.py new file mode 100644 index 00000000..4443af87 --- /dev/null +++ b/tasks/codegen/__init__.py @@ -0,0 +1,17 @@ +"""Encrypted-domain SQL code generator for EQL scalar domain families.""" + +from .generate import generate_type, main +from .spec import DomainSpec, SpecError, TypeSpec, load_spec +from .terms import TERM_CATALOG, Term, TermError + +__all__ = [ + "DomainSpec", + "SpecError", + "TERM_CATALOG", + "Term", + "TermError", + "TypeSpec", + "generate_type", + "load_spec", + "main", +] diff --git a/tasks/codegen/conftest.py b/tasks/codegen/conftest.py new file mode 100644 index 00000000..f03b0e8b --- /dev/null +++ b/tasks/codegen/conftest.py @@ -0,0 +1,6 @@ +"""pytest discovery anchor for the codegen package. + +Tests import via `from tasks.codegen. import ...`; pytest runs +from the repo root (where `tasks/__init__.py` exists), so no `sys.path` +manipulation is needed. +""" diff --git a/tasks/codegen/domain.sh b/tasks/codegen/domain.sh new file mode 100755 index 00000000..ae279a12 --- /dev/null +++ b/tasks/codegen/domain.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +#MISE description="Regenerate an encrypted-domain type from its TOML spec" +#USAGE arg "type" help="Type token, e.g. int4 (matches tasks/codegen/types/.toml)" + +set -euo pipefail + +TYPE=${usage_type:?type argument required} + +echo "Regenerating encrypted-domain type: ${TYPE}" +mise exec python -- python -m tasks.codegen.generate "${TYPE}" +echo "" +echo "✓ Regenerated src/encrypted_domain/${TYPE}/ (gitignored)" +echo " Note: 'mise run build' regenerates every type automatically;" +echo " this task is for refreshing one type while iterating on its manifest." +echo " When ready, run 'mise run clean && mise run build' then 'mise run test'." diff --git a/tasks/codegen/generate.py b/tasks/codegen/generate.py new file mode 100644 index 00000000..cf30c598 --- /dev/null +++ b/tasks/codegen/generate.py @@ -0,0 +1,332 @@ +"""Top-level scalar encrypted-domain materializer.""" + +import sys +from collections.abc import Iterator +from pathlib import Path + +from .operator_surface import ( + BLOCKER_ONLY_OPERATORS, + PATH_OPERATORS, + SYMMETRIC_OPERATORS, + backing_function, +) +from .spec import DomainSpec, SpecError, TypeSpec, load_spec +from .templates import ( + AGGREGATE_OPS, + domain_name, + extractor_for_operator, + is_ord_capable, + render_aggregate, + render_blocker_bool, + render_blocker_native, + render_blocker_path, + render_domain_block, + render_extractor, + render_fixture_values_rs, + render_operator, + render_wrapper, + role_phrase, + supported_operators, +) +from .terms import TERM_CATALOG, Term, term_requires +from .writer import ( + clean_generated_files, + ensure_generated_paths_writable, + write_generated_file, + write_generated_rs, +) + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _symmetric_shapes(dom: str) -> list[tuple[str, str]]: + return [(dom, dom), (dom, "jsonb"), ("jsonb", dom)] + + +def _path_shapes(dom: str) -> list[tuple[str, str]]: + return [(dom, "text"), (dom, "integer"), ("jsonb", dom)] + + +def _blocker_only_shapes(dom: str, op: str) -> list[tuple[str, str, str]]: + if op in {"?", "?|", "?&"}: + rhs = "text[]" if op in {"?|", "?&"} else "text" + return [(dom, rhs, "boolean")] + if op in {"@?", "@@"}: + return [(dom, "jsonpath", "boolean")] + if op == "#>": + return [(dom, "text[]", "jsonb")] + if op == "#>>": + return [(dom, "text[]", "text")] + if op == "-": + return [(dom, "text", "jsonb"), (dom, "integer", "jsonb"), (dom, "text[]", "jsonb")] + if op == "#-": + return [(dom, "text[]", "jsonb")] + if op == "||": + return [(dom, dom, "jsonb"), (dom, "jsonb", "jsonb"), ("jsonb", dom, "jsonb")] + raise ValueError(f"unhandled blocker-only operator: {op}") + + +def _types_path(token: str) -> str: + return f"src/encrypted_domain/{token}/{token}_types.sql" + + +def fixture_values_rs_path(out_root: Path, token: str) -> Path: + """Committed Rust fixture-value const for a type. Outside the gitignored + src/encrypted_domain/ SQL tree because it is consumed (and committed) by + the Rust test crate.""" + return ( + out_root / "tests" / "sqlx" / "src" / "fixtures" / f"{token}_values.rs" + ) + + +def render_types_file(spec: TypeSpec) -> str: + """Body for _types.sql: every domain in one idempotent DO block. + + Iteration order follows the manifest's declared order — the TOML file is + the source of truth for emit order. + """ + blocks = [render_domain_block(domain, spec.token) for domain in spec.domains] + return ( + "-- REQUIRE: src/schema.sql\n\n" + f"--! @file encrypted_domain/{spec.token}/{spec.token}_types.sql\n" + f"--! @brief Encrypted-domain type family for {spec.token}.\n\n" + "DO $$\nBEGIN\n" + + "\n".join(blocks) + + "END\n$$;\n" + ) + + +def _functions_requires(spec: TypeSpec, domain: DomainSpec) -> list[str]: + reqs = [ + "src/schema.sql", + _types_path(spec.token), + "src/encrypted_domain/functions.sql", + ] + for extra in term_requires(domain.terms): + if extra not in reqs: + reqs.append(extra) + return reqs + + +def _extractor_terms(domain: DomainSpec) -> Iterator[Term]: + seen: set[str] = set() + for term_name in domain.terms: + term = TERM_CATALOG[term_name] + if term.extractor not in seen: + seen.add(term.extractor) + yield term + + +def render_functions_file(spec: TypeSpec, domain: DomainSpec) -> str: + """Body for a domain's _functions.sql.""" + dom = domain_name(domain.name) + supported = set(supported_operators(domain)) + parts: list[str] = [] + + for term in _extractor_terms(domain): + parts.append(render_extractor(domain, term)) + + for op in SYMMETRIC_OPERATORS: + extractor = extractor_for_operator(domain, op) + for arg_a, arg_b in _symmetric_shapes(dom): + if op in supported and extractor is not None: + parts.append(render_wrapper(domain, op, arg_a, arg_b, extractor)) + else: + parts.append(render_blocker_bool(domain, op, arg_a, arg_b)) + + for op in PATH_OPERATORS: + for arg_a, arg_b in _path_shapes(dom): + parts.append(render_blocker_path(domain, op, arg_a, arg_b)) + + for op in BLOCKER_ONLY_OPERATORS: + for arg_a, arg_b, returns in _blocker_only_shapes(dom, op): + parts.append(render_blocker_native(domain, op, arg_a, arg_b, returns)) + + requires = "\n".join(f"-- REQUIRE: {r}" for r in _functions_requires(spec, domain)) + header = ( + requires + "\n\n" + f"--! @file encrypted_domain/{spec.token}/{domain.name}_functions.sql\n" + f"--! @brief {role_phrase(domain.terms)} domain of the {spec.token} " + f"encrypted-domain family — comparison/path functions.\n\n" + ) + return header + "\n".join(parts) + + +def render_operators_file(spec: TypeSpec, domain: DomainSpec) -> str: + """Body for a domain's _operators.sql: 44 CREATE OPERATOR statements.""" + dom = domain_name(domain.name) + supported = set(supported_operators(domain)) + parts: list[str] = [] + + for op in SYMMETRIC_OPERATORS: + backing = backing_function(op) + for leftarg, rightarg in _symmetric_shapes(dom): + parts.append( + render_operator( + op, backing, leftarg, rightarg, + supported=op in supported, + ) + ) + for op in PATH_OPERATORS: + backing = backing_function(op) + for leftarg, rightarg in _path_shapes(dom): + parts.append( + render_operator(op, backing, leftarg, rightarg, supported=False) + ) + for op in BLOCKER_ONLY_OPERATORS: + backing = backing_function(op) + for leftarg, rightarg, _returns in _blocker_only_shapes(dom, op): + parts.append( + render_operator(op, backing, leftarg, rightarg, supported=False) + ) + + requires = ( + "-- REQUIRE: src/schema.sql\n" + f"-- REQUIRE: {_types_path(spec.token)}\n" + f"-- REQUIRE: src/encrypted_domain/{spec.token}/" + f"{domain.name}_functions.sql\n" + ) + header = ( + requires + "\n" + f"--! @file encrypted_domain/{spec.token}/{domain.name}_operators.sql\n" + f"--! @brief {role_phrase(domain.terms)} domain of the {spec.token} " + f"encrypted-domain family — operator declarations.\n\n" + ) + return header + "\n".join(parts) + + +def render_aggregates_file(spec: TypeSpec, domain: DomainSpec) -> str | None: + """Body for a domain's _aggregates.sql, or None if the domain has no + ordering comparator (storage/eq variants have no MIN/MAX semantics).""" + if not is_ord_capable(domain): + return None + parts = [render_aggregate(domain, AGGREGATE_OPS[name]) for name in ("min", "max")] + requires = ( + "-- REQUIRE: src/schema.sql\n" + f"-- REQUIRE: {_types_path(spec.token)}\n" + f"-- REQUIRE: src/encrypted_domain/{spec.token}/" + f"{domain.name}_functions.sql\n" + f"-- REQUIRE: src/encrypted_domain/{spec.token}/" + f"{domain.name}_operators.sql\n" + ) + header = ( + requires + "\n" + f"--! @file encrypted_domain/{spec.token}/{domain.name}_aggregates.sql\n" + f"--! @brief {role_phrase(domain.terms)} domain of the {spec.token} " + f"encrypted-domain family — MIN/MAX aggregates.\n\n" + ) + return header + "\n".join(parts) + + +def generate_type(spec: TypeSpec, out_dir: Path) -> list[Path]: + """Regenerate every generated file for a type.""" + out_dir = Path(out_dir) + target_paths = [out_dir / f"{spec.token}_types.sql"] + for domain in spec.domains: + target_paths.append(out_dir / f"{domain.name}_functions.sql") + target_paths.append(out_dir / f"{domain.name}_operators.sql") + if is_ord_capable(domain): + target_paths.append(out_dir / f"{domain.name}_aggregates.sql") + ensure_generated_paths_writable(target_paths) + clean_generated_files(out_dir) + + written: list[Path] = [] + + types_path = out_dir / f"{spec.token}_types.sql" + write_generated_file(types_path, render_types_file(spec)) + written.append(types_path) + + for domain in spec.domains: + fn_path = out_dir / f"{domain.name}_functions.sql" + write_generated_file(fn_path, render_functions_file(spec, domain)) + written.append(fn_path) + + op_path = out_dir / f"{domain.name}_operators.sql" + write_generated_file(op_path, render_operators_file(spec, domain)) + written.append(op_path) + + agg_body = render_aggregates_file(spec, domain) + if agg_body is not None: + agg_path = out_dir / f"{domain.name}_aggregates.sql" + write_generated_file(agg_path, agg_body) + written.append(agg_path) + + return written + + +DEFAULT_TYPES_DIR = Path(__file__).parent / "types" + + +def generate_one(token: str, *, types_dir: Path, out_root: Path) -> int: + """Regenerate one type from types_dir/.toml. + + Returns 0 on success, 1 when the manifest is missing or its inferred token + does not match. A malformed manifest raises SpecError — the caller decides + whether to surface it (single-type CLI) or aggregate it (--all).""" + toml_path = types_dir / f"{token}.toml" + if not toml_path.is_file(): + print(f"error: no manifest at {toml_path}", file=sys.stderr) + return 1 + spec = load_spec(toml_path) + if spec.token != token: + print( + f"error: manifest token '{spec.token}' does not match '{token}'", + file=sys.stderr, + ) + return 1 + out_dir = out_root / "src" / "encrypted_domain" / token + written = generate_type(spec, out_dir) + + if spec.fixture_values is not None: + rs_path = fixture_values_rs_path(out_root, token) + write_generated_rs(rs_path, render_fixture_values_rs(spec)) + written.append(rs_path) + + for path in written: + print(f"generated {path.relative_to(out_root)}") + print(f"generated {len(written)} files for {token}") + return 0 + + +def generate_all(*, types_dir: Path, out_root: Path) -> int: + """Regenerate every type whose manifest lives in types_dir. + + Iterates sorted(types_dir.glob('*.toml')) for deterministic order and + aggregates return codes: a missing/mismatched/malformed manifest is + reported and counted as a failure without aborting the remaining types.""" + tokens = [p.stem for p in sorted(types_dir.glob("*.toml"))] + if not tokens: + print(f"error: no manifests found in {types_dir}", file=sys.stderr) + return 1 + rc = 0 + for token in tokens: + try: + if generate_one(token, types_dir=types_dir, out_root=out_root) != 0: + rc = 1 + except SpecError as exc: + print(f"error: {token}: {exc}", file=sys.stderr) + rc = 1 + status = "ok" if rc == 0 else "FAILED" + print(f"codegen --all: {status} ({len(tokens)} types: {', '.join(tokens)})") + return rc + + +def main( + argv: list[str], + *, + types_dir: Path | None = None, + out_root: Path | None = None, +) -> int: + """CLI entrypoint: generate , or --all for every manifest.""" + types_dir = types_dir or DEFAULT_TYPES_DIR + out_root = out_root or REPO_ROOT + if len(argv) == 2 and argv[1] == "--all": + return generate_all(types_dir=types_dir, out_root=out_root) + if len(argv) != 2: + print("Usage: generate.py | generate.py --all", file=sys.stderr) + return 2 + return generate_one(argv[1], types_dir=types_dir, out_root=out_root) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tasks/codegen/operator_surface.py b/tasks/codegen/operator_surface.py new file mode 100644 index 00000000..355e5751 --- /dev/null +++ b/tasks/codegen/operator_surface.py @@ -0,0 +1,72 @@ +"""The generated operator surface for a scalar encrypted-domain type. + +Supported comparison operators route to inlinable wrappers when the domain +has the required term. Unsupported comparisons, path operators, and native +jsonb fallback operators route to blockers. +""" + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class Operator: + """One operator in the generated surface.""" + + symbol: str + backing: str # eql_v2 backing function name (bare or quoted) + kind: Literal["symmetric", "path", "blocker_only"] + restrict: str | None # selectivity estimator, symmetric ops only + join: str | None # join selectivity estimator, symmetric ops only + commutator: str | None + negator: str | None + + +SYMMETRIC_OPERATORS = ["=", "<>", "<", "<=", ">", ">=", "@>", "<@"] +PATH_OPERATORS = ["->", "->>"] +BLOCKER_ONLY_OPERATORS = ["?", "?|", "?&", "@?", "@@", "#>", "#>>", "-", "#-", "||"] + + +OPERATORS: dict[str, Operator] = { + "=": Operator("=", "eq", "symmetric", "eqsel", "eqjoinsel", "=", "<>"), + "<>": Operator("<>", "neq", "symmetric", "neqsel", "neqjoinsel", "<>", "="), + "<": Operator("<", "lt", "symmetric", "scalarltsel", "scalarltjoinsel", ">", ">="), + "<=": Operator("<=", "lte", "symmetric", "scalarlesel", "scalarlejoinsel", ">=", ">"), + ">": Operator(">", "gt", "symmetric", "scalargtsel", "scalargtjoinsel", "<", "<="), + ">=": Operator(">=", "gte", "symmetric", "scalargesel", "scalargejoinsel", "<=", "<"), + "@>": Operator("@>", "contains", "symmetric", None, None, None, None), + "<@": Operator("<@", "contained_by", "symmetric", None, None, None, None), + "->": Operator("->", '"->"', "path", None, None, None, None), + "->>": Operator("->>", '"->>"', "path", None, None, None, None), + "?": Operator("?", '"?"', "blocker_only", None, None, None, None), + "?|": Operator("?|", '"?|"', "blocker_only", None, None, None, None), + "?&": Operator("?&", '"?&"', "blocker_only", None, None, None, None), + "@?": Operator("@?", '"@?"', "blocker_only", None, None, None, None), + "@@": Operator("@@", '"@@"', "blocker_only", None, None, None, None), + "#>": Operator("#>", '"#>"', "blocker_only", None, None, None, None), + "#>>": Operator("#>>", '"#>>"', "blocker_only", None, None, None, None), + "-": Operator("-", '"-"', "blocker_only", None, None, None, None), + "#-": Operator("#-", '"#-"', "blocker_only", None, None, None, None), + "||": Operator("||", '"||"', "blocker_only", None, None, None, None), +} + + +def backing_function(symbol: str) -> str: + """Return the eql_v2 backing function name for an operator symbol.""" + return OPERATORS[symbol].backing + + +# The full union of operator symbols the generator knows about: supported +# wrappers, path operators, and explicit blockers. Together these are exactly +# the native jsonb operator surface for PG 14-17, so this set is the basis of +# the storage-only "every native jsonb operator is blocked" guarantee. +# +# A live-DB structural guard (tests/sqlx/.../family/jsonb_operator_surface.rs) +# queries pg_operator for every operator with a jsonb argument and asserts the +# set is a subset of this union — if a future PG version adds a jsonb operator +# not enumerated here, that test fails rather than silently letting native +# plaintext-jsonb semantics through on an encrypted column. Keep that test's +# hardcoded expectation in sync with this set. +KNOWN_JSONB_OPERATORS: frozenset[str] = frozenset( + SYMMETRIC_OPERATORS + PATH_OPERATORS + BLOCKER_ONLY_OPERATORS +) diff --git a/tasks/codegen/scalars.py b/tasks/codegen/scalars.py new file mode 100644 index 00000000..a93df905 --- /dev/null +++ b/tasks/codegen/scalars.py @@ -0,0 +1,93 @@ +"""Fixed scalar-kind catalog for fixture-value emission. + +A `ScalarKind` knows how to turn a manifest fixture-value token into a Rust +literal of the type's native Rust scalar, and how to resolve it to a numeric +value for the MIN/MAX/zero invariant check. The manifest carries only the +list of value tokens; the per-type behaviour lives here (mirroring terms.py), +not in free-form TOML fields. + +Recognised sentinels are ``MIN`` / ``MAX`` / ``ZERO``; every other token is a +numeric literal validated against the type's representable range. +""" + +from dataclasses import dataclass + + +class ScalarError(Exception): + """Raised for an unknown scalar token or an invalid fixture value.""" + + +_SENTINELS = ("MIN", "MAX", "ZERO") + + +@dataclass(frozen=True) +class ScalarKind: + """One scalar type's Rust rendering rules for fixture values.""" + + token: str + rust_type: str + min_symbol: str + max_symbol: str + zero_symbol: str + min_value: int + max_value: int + + def _parse(self, value: str) -> int: + if value == "MIN": + return self.min_value + if value == "MAX": + return self.max_value + if value == "ZERO": + return 0 + try: + n = int(value) + except ValueError as exc: + raise ScalarError( + f"{self.token}: {value!r} is not a valid {self.rust_type} " + f"literal or sentinel ({'/'.join(_SENTINELS)})" + ) from exc + if not (self.min_value <= n <= self.max_value): + raise ScalarError( + f"{self.token}: {value!r} out of range for {self.rust_type} " + f"[{self.min_value}, {self.max_value}]" + ) + return n + + def numeric_value(self, value: str) -> int: + """Resolve a fixture token to its numeric value (validates range).""" + return self._parse(value) + + def render_literal(self, value: str) -> str: + """Render a fixture token as a Rust literal of this scalar type.""" + symbols = { + "MIN": self.min_symbol, + "MAX": self.max_symbol, + "ZERO": self.zero_symbol, + } + if value in symbols: + return symbols[value] + return str(self._parse(value)) + + +SCALAR_KINDS: dict[str, ScalarKind] = { + "int4": ScalarKind( + token="int4", + rust_type="i32", + min_symbol="i32::MIN", + max_symbol="i32::MAX", + zero_symbol="0", + min_value=-2147483648, + max_value=2147483647, + ), +} + + +def require_scalar(token: str) -> ScalarKind: + """Return the catalog kind for `token`, or raise ScalarError.""" + try: + return SCALAR_KINDS[token] + except KeyError as exc: + raise ScalarError( + f"unknown scalar token '{token}' " + f"(expected one of {sorted(SCALAR_KINDS)})" + ) from exc diff --git a/tasks/codegen/spec.py b/tasks/codegen/spec.py new file mode 100644 index 00000000..40e28cac --- /dev/null +++ b/tasks/codegen/spec.py @@ -0,0 +1,141 @@ +"""Minimal TOML manifest loader for scalar encrypted-domain codegen.""" + +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from .scalars import ScalarError, require_scalar +from .terms import TermError, require_terms + + +_SQL_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$") + + +class SpecError(Exception): + """Raised when a TOML manifest is missing or invalid.""" + + +@dataclass(frozen=True) +class DomainSpec: + """One generated public domain and the fixed terms it carries.""" + + name: str + terms: list[str] + + +@dataclass(frozen=True) +class TypeSpec: + """A scalar encrypted-domain manifest loaded from one TOML file.""" + + token: str + domains: list[DomainSpec] + fixture_values: list[str] | None = None + + +def _load_fixture_values(raw: dict, token: str) -> list[str] | None: + """Parse and validate the optional [fixture] table. + + Returns the ordered list of value tokens, or None when no [fixture] table + is present. The tokens are the manifest source of truth for the generated + Rust fixture-value const; the scalar kind validates each one and the set + must include MIN, MAX, and zero (the matrix comparison pivots).""" + if "fixture" not in raw: + return None + + fixture_table = raw["fixture"] + if not isinstance(fixture_table, dict) or "values" not in fixture_table: + raise SpecError("[fixture]: missing required key 'values'") + + values = fixture_table["values"] + if not isinstance(values, list): + raise SpecError("[fixture] values: must be a list of value tokens") + if not values: + raise SpecError("[fixture] values: must not be empty") + if any(not isinstance(v, str) for v in values): + raise SpecError("[fixture] values: must be strings") + + try: + kind = require_scalar(token) + resolved = [(v, kind.numeric_value(v)) for v in values] + for v in values: + kind.render_literal(v) + except ScalarError as exc: + raise SpecError(f"[fixture] values: {exc}") from exc + + # Distinct-plaintext contract: the matrix oracle treats each fixture value + # as a distinct plaintext, and the generated Rust const must not repeat a + # literal. Detect duplicates against the *resolved numeric* value so that + # both copy-paste token dups ("1", "1") and sentinel/literal aliases + # (e.g. "MIN" alongside the same number as a literal) are rejected. + seen: dict[int, str] = {} + duplicates: list[str] = [] + for token_value, number in resolved: + if number in seen: + duplicates.append( + f"{token_value!r} duplicates {seen[number]!r} (both resolve to {number})" + if token_value != seen[number] + else f"{token_value!r}" + ) + else: + seen[number] = token_value + if duplicates: + raise SpecError( + "[fixture] values: must be distinct, but found duplicate values: " + + ", ".join(duplicates) + ) + + numbers = set(seen) + if not ({kind.min_value, kind.max_value, 0} <= numbers): + raise SpecError( + "[fixture] values: must include MIN, MAX, and zero " + "(the matrix comparison pivots)" + ) + + return list(values) + + +def load_spec(path: Path | str) -> TypeSpec: + """Load and validate a per-type scalar-domain manifest.""" + path = Path(path) + with path.open("rb") as fh: + raw = tomllib.load(fh) + + if "domain" not in raw: + raise SpecError("spec: missing required table '[domain]'") + + domain_table = raw["domain"] + if not isinstance(domain_table, dict) or not domain_table: + raise SpecError("[domain]: at least one domain is required") + + token = path.stem + if not _SQL_IDENTIFIER.match(token): + raise SpecError( + f"spec: token {token!r} must match {_SQL_IDENTIFIER.pattern}" + ) + domains: list[DomainSpec] = [] + for name, terms in domain_table.items(): + if not isinstance(name, str) or not _SQL_IDENTIFIER.match(name): + raise SpecError( + f"[domain] {name}: domain name {name!r} must match " + f"{_SQL_IDENTIFIER.pattern}" + ) + if name != token and not name.startswith(f"{token}_"): + raise SpecError( + f"[domain] {name}: domain name must start with '{token}'" + ) + if not isinstance(terms, list): + raise SpecError( + f"[domain] {name}: value must be a list of term names" + ) + if any(not isinstance(term, str) for term in terms): + raise SpecError(f"[domain] {name}: term names must be strings") + try: + require_terms(list(terms)) + except TermError as exc: + raise SpecError(f"[domain] {name}: {exc}") from exc + domains.append(DomainSpec(name=name, terms=list(terms))) + + fixture_values = _load_fixture_values(raw, token) + + return TypeSpec(token=token, domains=domains, fixture_values=fixture_values) diff --git a/tasks/codegen/templates.py b/tasks/codegen/templates.py new file mode 100644 index 00000000..60833495 --- /dev/null +++ b/tasks/codegen/templates.py @@ -0,0 +1,490 @@ +"""Per-construct SQL template functions for scalar encrypted-domain codegen.""" + +from dataclasses import dataclass + +from .operator_surface import OPERATORS +from .scalars import require_scalar +from .spec import DomainSpec, TypeSpec +from .terms import ( + Term, + extractor_for_operator as _catalog_extractor_for_operator, + operators_for_terms, + role_for_terms, + term_json_keys, +) + +AUTO_GENERATED_HEADER = ( + "-- AUTO-GENERATED — DO NOT EDIT.\n" + "-- Regenerated automatically by `mise run build`; " + "also `mise run codegen:domain ` to refresh one type.\n" + "-- Source of truth: tasks/codegen/types/.toml\n" + "-- This file is gitignored; never commit it.\n" +) + +# Rust counterpart of AUTO_GENERATED_HEADER. Unlike the gitignored SQL surface, +# the fixture-value const IS committed and verified by the CI staleness guard, +# so the wording differs deliberately. +AUTO_GENERATED_HEADER_RS = ( + "// AUTO-GENERATED — DO NOT EDIT.\n" + "// Regenerated by `mise run build` " + "(or `mise run codegen:domain `).\n" + "// Source of truth: tasks/codegen/types/.toml `[fixture] values`.\n" + "// This file IS committed and verified in CI (git diff --exit-code).\n" +) + +ENVELOPE_KEYS = ["v", "i"] +CIPHERTEXT_KEY = "c" +# EQL payload-format version. The domain CHECK pins the 'v' envelope key to +# this value, matching EQL's repo-wide rule (eql_v2._encrypted_check_v, +# src/encrypted/constraints.sql). Presence of 'v' is enforced via +# ENVELOPE_KEYS; this pins its value so a stale/foreign-version payload is +# rejected on insert or cast rather than surfacing later at query time. +VERSION_KEY = "v" +ENVELOPE_VERSION = 2 + + +def _sql_str(s: str) -> str: + """Escape a Python string for use *inside* a single-quoted SQL string + literal by doubling embedded single quotes. + + Use this at every `'{...}'` interpolation boundary in the render_* + helpers — payload keys, operator symbols, domain names rendered into + RAISE messages, etc. + + Today every catalog string (term keys, operator symbols) is quote-free, + so this is a no-op on real input and output stays byte-identical. It + exists so a future quote-bearing catalog string can never break out of + its SQL literal — nothing else enforces the quote-free invariant.""" + return s.replace("'", "''") + + +def render_fixture_values_rs(spec: TypeSpec) -> str: + """Body for tests/sqlx/src/fixtures/_values.rs. + + Emits one `pub const VALUES: &[]` from the manifest's + `[fixture] values`, preserving declaration order. The writer prepends the + AUTO-GENERATED Rust header, so the body carries none.""" + kind = require_scalar(spec.token) + values = spec.fixture_values or [] + literals = "".join(f" {kind.render_literal(v)},\n" for v in values) + return ( + f"//! Fixture plaintext values for the {spec.token} " + "encrypted-domain family.\n" + "//!\n" + f"//! Generated from tasks/codegen/types/{spec.token}.toml " + "`[fixture] values` —\n" + "//! the single source of truth shared by the fixture generator\n" + f"//! (`fixtures::eql_v2_{spec.token}`) and the matrix oracle\n" + "//! (`ScalarType::FIXTURE_VALUES`).\n\n" + f"/// Distinct plaintext values present in the `eql_v2_{spec.token}` " + "fixture.\n" + f"pub const VALUES: &[{kind.rust_type}] = &[\n" + f"{literals}" + "];\n" + ) + +OPERATOR_PHRASES: dict[str, str] = { + "=": "Equality", + "<>": "Inequality", + "<": "Less-than", + "<=": "Less-than-or-equal", + ">": "Greater-than", + ">=": "Greater-than-or-equal", + "@>": "Contains", + "<@": "Contained-by", +} + +DOMAIN_ROLE_PHRASES: dict[str, str] = { + "storage": "Storage-only", + "eq": "Equality-only", + "ord": "Ordered", +} + + +def role_phrase(terms: list[str]) -> str: + """Proper-cased prose label for a domain with these terms — the single + source of truth for role → human prose. Every renderer that wants to + describe a domain's role in @brief lines reaches for this, so a rename + in DOMAIN_ROLE_PHRASES propagates to every generated file.""" + return DOMAIN_ROLE_PHRASES[role_for_terms(terms)] + + +def _scheme_suffix(name: str, token: str, role: str) -> str | None: + """The scheme tag of a domain name, or None for the converged name. + + The naming convention is ``_`` for the recommended converged + domain and ``__`` for a scheme-explicit twin that + pins the same role to one concrete index scheme. ``storage`` has no role + segment, so its converged name is the bare ````. + + Generic by construction: it reads ``token`` and ``role`` rather than any + hard-coded type or scheme string, so it works for int8/date/etc. and for + schemes other than ``ore``. Returns the scheme segment (e.g. ``"ore"``) + for a twin, or None when ``name`` is the converged name (or doesn't match + the convention at all).""" + converged = token if role == "storage" else f"{token}_{role}" + if name == converged: + return None + prefix = converged + "_" + if name.startswith(prefix): + scheme = name[len(prefix):] + if scheme: + return scheme + return None + + +# Roles that come in converged + scheme-explicit-twin pairs and therefore need +# a disambiguating @brief clause. Ordered domains are the case the reviewer +# flagged: int4_ord and int4_ord_ore carry identical terms (["ore"]) and would +# otherwise render an identical brief. Driven by role (generic across int8, +# date, etc.), never by a literal type/scheme name. eq and storage have a +# single name each, so no disambiguation is needed (or wanted — it'd be noise). +_TWINNABLE_ROLES = frozenset({"ord"}) + + +def brief_role_clause(domain: DomainSpec, token: str) -> str: + """The trailing clause distinguishing the recommended converged domain + from a scheme-explicit twin, for use in a per-domain @brief. + + Two domains that carry identical terms (e.g. ``int4_ord`` and + ``int4_ord_ore``, both ``["ore"]``) would otherwise render an identical + brief. The converged name is the recommended one to reach for; the twin + names the concrete scheme explicitly. Returns "" for roles that don't come + in converged/twin pairs (eq, storage) and for names that match no pattern. + + Generic by construction: keyed on the term-derived role and the + ``_[_]`` name shape, never on a literal type or scheme + string, so int8/date/etc. and non-ore schemes work unchanged.""" + role = role_for_terms(domain.terms) + if role not in _TWINNABLE_ROLES: + return "" + scheme = _scheme_suffix(domain.name, token, role) + if scheme is not None: + return ( + f" Scheme-explicit twin pinning the {scheme} scheme; " + f"prefer the converged {token}_{role} name." + ) + if domain.name == f"{token}_{role}": + return " Recommended converged name for this role." + return "" + + +def domain_name(domain: str) -> str: + """The public SQL domain type name.""" + return f"eql_v2_{domain}" + + +def _arg_label(dom: str, arg_type: str) -> str: + """Doxygen brief shape qualifier for one operand: 'domain' if it's + the encrypted-domain type, otherwise the literal SQL type.""" + return "domain" if arg_type == dom else arg_type + + +def _shape_qualifier(dom: str, arg_a: str, arg_b: str) -> str: + """Doxygen brief parenthetical. Empty for the canonical (dom, dom) shape.""" + if arg_a == dom and arg_b == dom: + return "" + return f" ({_arg_label(dom, arg_a)}, {_arg_label(dom, arg_b)})" + + +def render_domain_block(domain: DomainSpec, token: str) -> str: + """One idempotent IF NOT EXISTS CREATE DOMAIN block, prefixed by a + per-domain --! @brief derived from role + token.""" + dom = domain_name(domain.name) + keys = ENVELOPE_KEYS + [CIPHERTEXT_KEY] + term_json_keys(domain.terms) + presence = "\n AND ".join(f"VALUE ? '{_sql_str(key)}'" for key in keys) + checks = ( + presence + + f"\n AND VALUE->>'{_sql_str(VERSION_KEY)}' = '{ENVELOPE_VERSION}'" + ) + phrase = role_phrase(domain.terms) + clause = brief_role_clause(domain, token) + return ( + f" --! @brief {phrase} encrypted {token} domain.{clause}\n" + f" IF NOT EXISTS (\n" + f" SELECT 1 FROM pg_type\n" + f" WHERE typname = '{_sql_str(dom)}' " + f"AND typnamespace = 'public'::regnamespace\n" + f" ) THEN\n" + f" CREATE DOMAIN public.{dom} AS jsonb\n" + f" CHECK (\n" + f" jsonb_typeof(VALUE) = 'object'\n" + f" AND {checks}\n" + f" );\n" + f" END IF;\n" + ) + + +def render_extractor(domain: DomainSpec, term: Term) -> str: + """The inlinable index-term extractor for a domain term.""" + dom = domain_name(domain.name) + doxy = ( + f"--! @brief Index extractor for the {dom} variant.\n" + f"--! @param a {dom}\n" + f"--! @return {term.returns}\n" + ) + return doxy + ( + f"CREATE FUNCTION eql_v2.{term.extractor}(a {dom})\n" + f"RETURNS {term.returns}\n" + f"LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\n" + f"AS $$ SELECT eql_v2.{term.ctor}(a::jsonb) $$;\n" + ) + + +def _extract_arg(arg_type: str, extractor: str, domain: str, arg: str) -> str: + """The extractor-call SQL for one operand, casting jsonb to the domain first.""" + if arg_type == "jsonb": + return f"eql_v2.{extractor}({arg}::{domain})" + return f"eql_v2.{extractor}({arg})" + + +def render_wrapper( + domain: DomainSpec, op: str, arg_a: str, arg_b: str, extractor: str +) -> str: + """An inlinable comparison wrapper for a supported operator.""" + dom = domain_name(domain.name) + backing = OPERATORS[op].backing + call_a = _extract_arg(arg_a, extractor, dom, "a") + call_b = _extract_arg(arg_b, extractor, dom, "b") + doxy = ( + f"--! @brief {OPERATOR_PHRASES[op]} wrapper for {dom}" + f"{_shape_qualifier(dom, arg_a, arg_b)}.\n" + f"--! @param a {arg_a}\n" + f"--! @param b {arg_b}\n" + f"--! @return boolean\n" + ) + return doxy + ( + f"CREATE FUNCTION eql_v2.{backing}(a {arg_a}, b {arg_b})\n" + f"RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\n" + f"AS $$ SELECT {call_a} {op} {call_b} $$;\n" + ) + + +def render_blocker_bool( + domain: DomainSpec, op: str, arg_a: str, arg_b: str +) -> str: + """A boolean-returning blocker. NEVER STRICT, ALWAYS LANGUAGE plpgsql + so the RAISE survives inlining and planner-time elision; see CLAUDE.md + footguns and the encrypted-domain spec §4.""" + dom = domain_name(domain.name) + backing = OPERATORS[op].backing + doxy = ( + f"--! @brief Blocker for {op} on {dom}" + f"{_shape_qualifier(dom, arg_a, arg_b)}.\n" + f"--! @param a {arg_a}\n" + f"--! @param b {arg_b}\n" + f"--! @return boolean (never returns; always raises)\n" + ) + return doxy + ( + f"CREATE FUNCTION eql_v2.{backing}(a {arg_a}, b {arg_b})\n" + f"RETURNS boolean IMMUTABLE PARALLEL SAFE\n" + f"AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool(" + f"'{_sql_str(dom)}', '{_sql_str(op)}'); END; $$\n" + f"LANGUAGE plpgsql;\n" + ) + + +def render_blocker_path( + domain: DomainSpec, op: str, arg_a: str, arg_b: str +) -> str: + """A path-operator blocker. NEVER STRICT, ALWAYS LANGUAGE plpgsql + so the RAISE survives inlining and planner-time elision; see CLAUDE.md + footguns and the encrypted-domain spec §4.""" + dom = domain_name(domain.name) + backing = OPERATORS[op].backing + returns = "text" if op == "->>" else dom + doxy = ( + f"--! @brief Blocker for {op} on {dom} " + f"({_arg_label(dom, arg_a)}, {_arg_label(dom, arg_b)}).\n" + f"--! @param a {arg_a}\n" + f"--! @param selector {arg_b}\n" + f"--! @return {returns} (never returns; always raises)\n" + ) + return doxy + ( + f"CREATE FUNCTION eql_v2.{backing}(a {arg_a}, selector {arg_b})\n" + f"RETURNS {returns} IMMUTABLE PARALLEL SAFE\n" + f"AS $$ BEGIN RAISE EXCEPTION " + f"'operator % is not supported for %', '{_sql_str(op)}', " + f"'{_sql_str(dom)}'; END; $$\n" + f"LANGUAGE plpgsql;\n" + ) + + +def render_blocker_native( + domain: DomainSpec, op: str, arg_a: str, arg_b: str, returns: str +) -> str: + """A blocker for a native jsonb fallback operator. NEVER STRICT, ALWAYS + LANGUAGE plpgsql. Boolean blockers delegate to the shared helper so lint + recognition and messages stay uniform; other return types raise directly. + """ + dom = domain_name(domain.name) + backing = OPERATORS[op].backing + doxy = ( + f"--! @brief Blocker for {op} on {dom}" + f"{_shape_qualifier(dom, arg_a, arg_b)}.\n" + f"--! @param a {arg_a}\n" + f"--! @param b {arg_b}\n" + f"--! @return {returns} (never returns; always raises)\n" + ) + if returns == "boolean": + body = ( + "BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool(" + f"'{_sql_str(dom)}', '{_sql_str(op)}'); END;" + ) + else: + body = ( + "BEGIN RAISE EXCEPTION " + f"'operator % is not supported for %', '{_sql_str(op)}', " + f"'{_sql_str(dom)}'; END;" + ) + return doxy + ( + f"CREATE FUNCTION eql_v2.{backing}(a {arg_a}, b {arg_b})\n" + f"RETURNS {returns} IMMUTABLE PARALLEL SAFE\n" + f"AS $$ {body} $$\n" + f"LANGUAGE plpgsql;\n" + ) + + +def extractor_for_operator(domain: DomainSpec, op: str) -> str | None: + """Return the catalog extractor that supports op for this domain.""" + return _catalog_extractor_for_operator(domain.terms, op) + + +def supported_operators(domain: DomainSpec) -> list[str]: + """Supported operators for this domain.""" + return operators_for_terms(domain.terms) + + +@dataclass(frozen=True) +class AggregateOp: + """One aggregate operator definition (min or max).""" + + name: str # public function name, e.g. "min" + sfunc_name: str # state function name, e.g. "min_sfunc" + comparator: str # SQL comparator used to choose the new state: "<" or ">" + phrase: str # short prose label used in --! @brief lines + + +AGGREGATE_OPS: dict[str, AggregateOp] = { + "min": AggregateOp("min", "min_sfunc", "<", "minimum"), + "max": AggregateOp("max", "max_sfunc", ">", "maximum"), +} + + +def is_ord_capable(domain: DomainSpec) -> bool: + """True if the domain carries a comparator term (i.e. supports `<`).""" + return role_for_terms(domain.terms) == "ord" + + +def render_aggregate(domain: DomainSpec, op: AggregateOp) -> str: + """Render state function + CREATE AGGREGATE for one aggregate op on one + domain. The ord-capability gate lives at the file-level renderer + (`render_aggregates_file`); callers may legitimately render a single + aggregate without re-asserting that precondition. MIN/MAX on a non-ord + domain is structurally well-formed text but semantically meaningless — + the file-level gate is what stops it ever reaching disk.""" + dom = domain_name(domain.name) + sfunc_doxy = ( + f"--! @brief State function for {op.name} aggregate on {dom}.\n" + f"--! @internal\n" + f"--!\n" + f"--! @param state {dom} running extremum\n" + f"--! @param value {dom} next non-NULL value\n" + f"--! @return {dom} the {op.phrase} of state and value\n" + ) + # plpgsql + STRICT: PG seeds the state with the first non-NULL value and + # skips NULL inputs. plpgsql (not sql) because aggregate state functions + # aren't index expressions — opacity to the planner is fine — and a + # multi-statement BEGIN/IF/END body is the natural shape. + # + # The same rationale is mirrored into the emitted SQL below so a reader of + # the generated file (who never sees this Python) understands why it isn't + # an inlinable LANGUAGE sql CASE. + sfunc_rationale = ( + "-- LANGUAGE plpgsql, not sql: aggregate state functions are not index\n" + "-- expressions, so opacity to the planner is fine, and a multi-statement\n" + "-- BEGIN/IF/END body is the natural shape. (A LANGUAGE sql CASE would\n" + "-- also work, but the procedural form mirrors the blocker convention.)\n" + ) + sfunc = sfunc_rationale + ( + f"CREATE FUNCTION eql_v2.{op.sfunc_name}(state {dom}, value {dom})\n" + f"RETURNS {dom}\n" + f"LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE\n" + f"SET search_path = pg_catalog, extensions, public\n" + f"AS $$\n" + f"BEGIN\n" + f" IF value {op.comparator} state THEN\n" + f" RETURN value;\n" + f" END IF;\n" + f" RETURN state;\n" + f"END;\n" + f"$$;\n" + ) + agg_doxy = ( + f"--! @brief Find the {op.phrase} encrypted value in a group of " + f"{dom} values.\n" + f"--!\n" + f"--! Comparison routes through the domain's `{op.comparator}` " + f"operator, which uses the ORE block term — no decryption.\n" + f"--!\n" + f"--! @param input {dom} encrypted values to aggregate\n" + f"--! @return {dom} {op.phrase} of the group, or NULL if all " + f"inputs are NULL\n" + ) + # min/max are associative, so the state function doubles as the combine + # function: merging two partial extrema is the same comparison. With a + # PARALLEL SAFE sfunc/combinefunc and `parallel = safe`, PG can use partial + # and parallel aggregation on the large GROUP BY workloads these ORE + # aggregates exist to serve — still with no decryption. The combinefunc is + # STRICT (it is the sfunc), so PG carries a null partial state through as + # "no value yet", matching the serial seed-and-skip semantics. + aggregate = ( + "-- combinefunc = sfunc: min/max are associative, so merging two partial\n" + "-- extrema is the same comparison. PARALLEL SAFE enables partial and\n" + "-- parallel aggregation on large GROUP BY workloads, with no decryption.\n" + f"CREATE AGGREGATE eql_v2.{op.name}({dom}) (\n" + f" sfunc = eql_v2.{op.sfunc_name},\n" + f" stype = {dom},\n" + f" combinefunc = eql_v2.{op.sfunc_name},\n" + f" parallel = safe\n" + f");\n" + ) + return sfunc_doxy + sfunc + "\n" + agg_doxy + aggregate + + +def render_operator( + op: str, backing: str, leftarg: str, rightarg: str, supported: bool +) -> str: + """A CREATE OPERATOR declaration. + + Unsupported operators are still declared, but their backing function is a + blocker that always raises. We emit them so the operator resolves on the + domain (rather than silently falling through to a native jsonb operator), + and a leading SQL comment explains the placeholder to future readers.""" + meta = OPERATORS[op] + lines = [] + if not supported: + lines.append( + f"-- Placeholder: this domain's term set does not support {op}; " + f"the backing function always raises." + ) + lines += [ + f"CREATE OPERATOR {op} (", + f" FUNCTION = eql_v2.{backing},", + f" LEFTARG = {leftarg}, RIGHTARG = {rightarg}", + ] + if supported and meta.kind == "symmetric": + extras = [] + if meta.commutator: + extras.append(f"COMMUTATOR = {meta.commutator}") + if meta.negator: + extras.append(f"NEGATOR = {meta.negator}") + if meta.restrict: + extras.append(f"RESTRICT = {meta.restrict}") + if meta.join: + extras.append(f"JOIN = {meta.join}") + if extras: + lines[-1] += "," + lines.append(" " + ", ".join(extras)) + lines.append(");") + return "\n".join(lines) + "\n" diff --git a/tasks/codegen/terms.py b/tasks/codegen/terms.py new file mode 100644 index 00000000..32a7c788 --- /dev/null +++ b/tasks/codegen/terms.py @@ -0,0 +1,107 @@ +"""Fixed index-term catalog for scalar encrypted-domain codegen.""" + +from collections.abc import Iterable +from dataclasses import dataclass + + +class TermError(Exception): + """Raised when a manifest references an unknown term.""" + + +@dataclass(frozen=True) +class Term: + """One fixed index term known to the scalar materializer.""" + + name: str + json_key: str + extractor: str + returns: str + ctor: str + role: str + operators: tuple[str, ...] + requires: tuple[str, ...] + + +TERM_CATALOG: dict[str, Term] = { + "hm": Term( + name="hm", + json_key="hm", + extractor="eq_term", + returns="eql_v2.hmac_256", + ctor="hmac_256", + role="eq", + operators=("=", "<>"), + requires=("src/hmac_256/functions.sql",), + ), + "ore": Term( + name="ore", + json_key="ob", + extractor="ord_term", + returns="eql_v2.ore_block_u64_8_256", + ctor="ore_block_u64_8_256", + role="ord", + operators=("=", "<>", "<", "<=", ">", ">="), + requires=( + "src/ore_block_u64_8_256/functions.sql", + "src/ore_block_u64_8_256/operators.sql", + ), + ), +} + + +def _dedupe_preserving_order(values: Iterable[str]) -> list[str]: + """Stable dedupe — first occurrence wins. `dict.fromkeys` preserves insert order.""" + return list(dict.fromkeys(values)) + + +def require_terms(names: list[str]) -> list[Term]: + """Return catalog terms for manifest names, preserving input order.""" + terms: list[Term] = [] + for name in names: + try: + terms.append(TERM_CATALOG[name]) + except KeyError as exc: + raise TermError( + f"unknown term '{name}' (expected one of {sorted(TERM_CATALOG)})" + ) from exc + return terms + + +def operators_for_terms(names: list[str]) -> list[str]: + """Supported operators for the union of a domain's terms.""" + return _dedupe_preserving_order( + op for term in require_terms(names) for op in term.operators + ) + + +def term_json_keys(names: list[str]) -> list[str]: + """JSON payload keys required by these terms.""" + return _dedupe_preserving_order( + term.json_key for term in require_terms(names) + ) + + +def term_requires(names: list[str]) -> list[str]: + """SQL REQUIRE edges needed by these terms.""" + return _dedupe_preserving_order( + req for term in require_terms(names) for req in term.requires + ) + + +def extractor_for_operator(names: list[str], op: str) -> str | None: + """The catalog extractor that supports `op` for a domain carrying `names`.""" + for term in require_terms(names): + if op in term.operators: + return term.extractor + return None + + +def role_for_terms(names: list[str]) -> str: + """Generated-file role label for a domain with these terms. + + A domain with no terms is `storage`; otherwise the role comes from + the first term's catalog role (e.g. `hm` -> `eq`, `ore` -> `ord`). + """ + if not names: + return "storage" + return require_terms(names)[0].role diff --git a/tasks/codegen/test_against_reference.py b/tasks/codegen/test_against_reference.py new file mode 100644 index 00000000..e7ea62e9 --- /dev/null +++ b/tasks/codegen/test_against_reference.py @@ -0,0 +1,117 @@ +"""Identity guard: the generator must reproduce the frozen manual +reference under tests/codegen/reference// byte-for-byte. + +The reference is the reviewed manual implementation. If the generator's +output diverges from the reference, either the generator regressed (fix +it) or the reference is being deliberately updated (commit the new +reference in this PR). + +Compares in-memory `render_*_file` output directly against the reference, +so it runs anywhere regardless of whether the build has materialised +src/encrypted_domain// (those files are gitignored — `tasks/build.sh` +regenerates them on each build). +""" +from pathlib import Path + +import pytest + +from tasks.codegen.generate import ( + REPO_ROOT, + render_aggregates_file, + render_functions_file, + render_operators_file, + render_types_file, +) +from tasks.codegen.spec import load_spec +from tasks.codegen.templates import render_fixture_values_rs + +_REFERENCE_ROOT = REPO_ROOT / "tests" / "codegen" / "reference" +_TYPES_DIR = REPO_ROOT / "tasks" / "codegen" / "types" + + +def _strip_reference_marker(text: str) -> str: + """Drop any leading `-- REFERENCE:` / `// REFERENCE:` lines. They label the + file as the parity baseline (see tests/codegen/reference/README.md) and are + not part of the generator's output. Both comment styles are recognised so + the same helper serves SQL and Rust reference files.""" + lines = text.splitlines(keepends=True) + while lines and lines[0].startswith(("-- REFERENCE:", "// REFERENCE:")): + lines.pop(0) + return "".join(lines) + + +def _reference_files() -> list[Path]: + """Every SQL file under tests/codegen/reference//.""" + if not _REFERENCE_ROOT.is_dir(): + return [] + return sorted(_REFERENCE_ROOT.glob("*/*.sql")) + + +def _render(reference_path: Path) -> str: + """Render the corresponding generator output for a reference file.""" + token = reference_path.parent.name + name = reference_path.name + spec = load_spec(_TYPES_DIR / f"{token}.toml") + + if name == f"{token}_types.sql": + return render_types_file(spec) + + for domain in spec.domains: + if name == f"{domain.name}_functions.sql": + return render_functions_file(spec, domain) + if name == f"{domain.name}_operators.sql": + return render_operators_file(spec, domain) + if name == f"{domain.name}_aggregates.sql": + body = render_aggregates_file(spec, domain) + if body is None: + pytest.fail( + f"reference {reference_path.relative_to(REPO_ROOT)} exists " + f"but the generator skipped this variant (not ord-capable). " + f"Remove the reference file or update the manifest." + ) + return body + + pytest.fail(f"unrecognised reference filename: {name}") + + +@pytest.mark.parametrize( + "reference_path", + _reference_files(), + ids=lambda p: f"{p.parent.name}/{p.name}", +) +def test_generator_matches_manual_reference(reference_path: Path): + """Generator render output must equal the reviewed reference.""" + token = reference_path.parent.name + fix = ( + f"either the generator regressed (fix tasks/codegen/) or the " + f"manual reference is being updated deliberately — commit the " + f"new reference at {reference_path.relative_to(REPO_ROOT)} in " + f"this PR. Regenerate via: mise run codegen:domain {token}" + ) + + expected = _strip_reference_marker(reference_path.read_text(encoding="utf-8")) + actual = _render(reference_path) + + assert actual == expected, f"{reference_path.name}: {fix}" + + +def test_generator_matches_rust_fixture_values_reference(): + """The generated Rust fixture-value const must match the reviewed reference. + + Guards the committed tests/sqlx/src/fixtures/int4_values.rs against drift + from the manifest (the same property the CI staleness guard enforces, but + runnable without a checkout diff).""" + reference_path = _REFERENCE_ROOT / "int4" / "int4_values.rs" + spec = load_spec(_TYPES_DIR / "int4.toml") + + expected = _strip_reference_marker( + reference_path.read_text(encoding="utf-8") + ) + actual = render_fixture_values_rs(spec) + + assert actual == expected, ( + "int4_values.rs: either the generator regressed (fix tasks/codegen/) " + "or the reference is being updated deliberately — commit the new " + f"reference at {reference_path.relative_to(REPO_ROOT)} in this PR. " + "Regenerate via: mise run codegen:domain int4" + ) diff --git a/tasks/codegen/test_generate.py b/tasks/codegen/test_generate.py new file mode 100644 index 00000000..e92e2f2f --- /dev/null +++ b/tasks/codegen/test_generate.py @@ -0,0 +1,351 @@ +"""Tests for composing scalar encrypted-domain files from a manifest.""" + +import textwrap + +import pytest + +from tasks.codegen.generate import ( + generate_type, + main, + render_aggregates_file, + render_functions_file, + render_operators_file, + render_types_file, +) +from tasks.codegen.spec import load_spec +from tasks.codegen.templates import AUTO_GENERATED_HEADER, AUTO_GENERATED_HEADER_RS +from tasks.codegen.writer import OwnershipError + + +INT4_TOML = textwrap.dedent(""" + [domain] + int4 = [] + int4_eq = ["hm"] + int4_ord_ore = ["ore"] + int4_ord = ["ore"] +""") + +INT4_FIXTURE_TOML = INT4_TOML + textwrap.dedent(""" + [fixture] + values = ["MIN", "-1", "ZERO", "1", "MAX"] +""") + +# A second, synthetic type for multi-type (--all) coverage. No [fixture] table, +# so it never touches scalars.py (which only registers int4) — it exercises the +# enumeration, not fixture rendering. +INT4X_TOML = textwrap.dedent(""" + [domain] + int4x = [] + int4x_eq = ["hm"] + int4x_ord = ["ore"] +""") + + +def _fixture_values_rs(out_root): + return out_root / "tests" / "sqlx" / "src" / "fixtures" / "int4_values.rs" + + +def load(tmp_path): + p = tmp_path / "int4.toml" + p.write_text(INT4_TOML) + return load_spec(p) + + +def test_types_file_has_all_four_domains(tmp_path): + spec = load(tmp_path) + sql = render_types_file(spec) + assert "-- REQUIRE: src/schema.sql" in sql + for dom in ("eql_v2_int4", "eql_v2_int4_eq", + "eql_v2_int4_ord", "eql_v2_int4_ord_ore"): + assert f"CREATE DOMAIN public.{dom} AS jsonb" in sql + + +def test_storage_functions_file_is_all_blockers(tmp_path): + spec = load(tmp_path) + storage = next(d for d in spec.domains if d.name == "int4") + sql = render_functions_file(spec, storage) + assert sql.count("CREATE FUNCTION") == 44 + assert "SET search_path" not in sql + assert sql.count("LANGUAGE plpgsql") == 44 + assert sql.count("LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE") == 0 + + +def test_eq_functions_file_counts_and_extractor(tmp_path): + spec = load(tmp_path) + eq = next(d for d in spec.domains if d.name == "int4_eq") + sql = render_functions_file(spec, eq) + assert sql.count("CREATE FUNCTION") == 45 + assert "CREATE FUNCTION eql_v2.eq_term(a eql_v2_int4_eq)" in sql + assert "RETURNS eql_v2.hmac_256" in sql + # 1 extractor + 6 wrappers (=, <> across 3 arg-shapes) inlined as SQL; + # 38 blockers across the remaining native jsonb surface as plpgsql. + assert sql.count("LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE") == 7 + assert sql.count("LANGUAGE plpgsql") == 38 + assert "SET search_path" not in sql + + +def test_ore_functions_file_counts_and_extractor(tmp_path): + spec = load(tmp_path) + ordered = next(d for d in spec.domains if d.name == "int4_ord") + sql = render_functions_file(spec, ordered) + assert sql.count("CREATE FUNCTION") == 45 + assert "CREATE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord)" in sql + assert "RETURNS eql_v2.ore_block_u64_8_256" in sql + # 1 extractor + 18 wrappers (=, <>, <, <=, >, >= across 3 shapes); + # 26 blockers across containment/path/native-jsonb fallback ops. + assert sql.count("LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE") == 19 + assert sql.count("LANGUAGE plpgsql") == 26 + assert "SET search_path" not in sql + + +def test_operators_file_has_forty_four(tmp_path): + spec = load(tmp_path) + eq = next(d for d in spec.domains if d.name == "int4_eq") + sql = render_operators_file(spec, eq) + assert sql.count("CREATE OPERATOR") == 44 + + +def test_generate_type_writes_expected_files(tmp_path): + spec = load(tmp_path) + out_dir = tmp_path / "int4" + written = generate_type(spec, out_dir) + names = {p.name for p in written} + assert "int4_types.sql" in names + for domain in ("int4", "int4_eq", "int4_ord", "int4_ord_ore"): + assert f"{domain}_functions.sql" in names + assert f"{domain}_operators.sql" in names + # Aggregates only emitted for ord-capable variants — storage and eq skip. + assert "int4_aggregates.sql" not in names + assert "int4_eq_aggregates.sql" not in names + assert "int4_ord_aggregates.sql" in names + assert "int4_ord_ore_aggregates.sql" in names + # 1 types + 4 functions + 4 operators + 2 aggregates = 11 + assert len(written) == 11 + for p in written: + assert p.read_text().startswith(AUTO_GENERATED_HEADER) + + +def test_generate_type_cleans_stale_files(tmp_path): + spec = load(tmp_path) + out_dir = tmp_path / "int4" + out_dir.mkdir() + stale = out_dir / "int4_removed_functions.sql" + stale.write_text(AUTO_GENERATED_HEADER + "-- orphan\n") + generate_type(spec, out_dir) + assert not stale.exists() + + +def test_generate_type_preserves_hand_written_extension_file(tmp_path): + spec = load(tmp_path) + out_dir = tmp_path / "int4" + out_dir.mkdir() + extension = out_dir / "int4_extensions.sql" + body = ( + "-- REQUIRE: src/encrypted_domain/int4/int4_types.sql\n" + "-- hand-written extension SQL\n" + ) + extension.write_text(body) + generate_type(spec, out_dir) + assert extension.read_text() == body + + +def test_generate_type_preflights_hand_written_target_before_cleanup(tmp_path): + spec = load(tmp_path) + out_dir = tmp_path / "int4" + out_dir.mkdir() + generated = out_dir / "int4_types.sql" + protected = out_dir / "int4_eq_functions.sql" + original_generated = AUTO_GENERATED_HEADER + "-- old generated\n" + original_protected = "-- REQUIRE: src/schema.sql\n-- hand-written\n" + generated.write_text(original_generated) + protected.write_text(original_protected) + + with pytest.raises(OwnershipError, match="hand-written"): + generate_type(spec, out_dir) + + assert generated.read_text() == original_generated + assert protected.read_text() == original_protected + assert not (out_dir / "int4_eq_operators.sql").exists() + + +def _seed_types_dir(tmp_path, name: str = "int4.toml", body: str = INT4_TOML): + types_dir = tmp_path / "types" + types_dir.mkdir() + (types_dir / name).write_text(body) + return types_dir + + +def test_main_rejects_wrong_argv_length(capsys): + rc = main(["generate.py"]) + assert rc == 2 + err = capsys.readouterr().err + assert "Usage: generate.py " in err + + +def test_main_errors_on_missing_manifest(tmp_path, capsys): + types_dir = tmp_path / "types" + types_dir.mkdir() + rc = main( + ["generate.py", "int4"], + types_dir=types_dir, + out_root=tmp_path, + ) + assert rc == 1 + err = capsys.readouterr().err + assert "no manifest at" in err + assert "int4.toml" in err + + +def test_main_errors_on_token_mismatch(tmp_path, capsys): + """Manifest stem must equal argv token — guards against a copy/rename.""" + types_dir = _seed_types_dir(tmp_path, name="int4.toml") + rc = main( + ["generate.py", "int8"], + types_dir=types_dir, + out_root=tmp_path, + ) + # int8.toml doesn't exist — first failure is missing manifest, not mismatch. + # To exercise the mismatch branch we need a manifest at int8.toml that + # declares int4 domains (impossible — the loader infers token from stem). + # The branch is therefore unreachable via the normal types/.toml + # convention; the assertion below just confirms the missing-manifest + # error path fires when the names diverge. + assert rc == 1 + err = capsys.readouterr().err + assert "no manifest at" in err + assert "int8.toml" in err + + +def test_main_happy_path_writes_files(tmp_path, capsys): + types_dir = _seed_types_dir(tmp_path) + rc = main( + ["generate.py", "int4"], + types_dir=types_dir, + out_root=tmp_path, + ) + assert rc == 0 + out_dir = tmp_path / "src" / "encrypted_domain" / "int4" + assert (out_dir / "int4_types.sql").is_file() + assert (out_dir / "int4_eq_functions.sql").is_file() + assert (out_dir / "int4_ord_operators.sql").is_file() + assert (out_dir / "int4_ord_aggregates.sql").is_file() + assert (out_dir / "int4_ord_ore_aggregates.sql").is_file() + assert not (out_dir / "int4_aggregates.sql").exists() + assert not (out_dir / "int4_eq_aggregates.sql").exists() + stdout = capsys.readouterr().out + assert "generated 11 files for int4" in stdout + + +def test_main_emits_fixture_values_rs_when_manifest_has_fixture(tmp_path, capsys): + types_dir = _seed_types_dir(tmp_path, body=INT4_FIXTURE_TOML) + rc = main(["generate.py", "int4"], types_dir=types_dir, out_root=tmp_path) + assert rc == 0 + rs = _fixture_values_rs(tmp_path) + assert rs.is_file() + text = rs.read_text() + assert text.startswith(AUTO_GENERATED_HEADER_RS) + assert "pub const VALUES: &[i32] = &[" in text + assert "i32::MIN," in text and "i32::MAX," in text + stdout = capsys.readouterr().out + assert "int4_values.rs" in stdout + + +def test_main_omits_fixture_values_rs_when_no_fixture_table(tmp_path, capsys): + types_dir = _seed_types_dir(tmp_path, body=INT4_TOML) + rc = main(["generate.py", "int4"], types_dir=types_dir, out_root=tmp_path) + assert rc == 0 + assert not _fixture_values_rs(tmp_path).exists() + + +def _seed_two_types(tmp_path): + types_dir = _seed_types_dir(tmp_path, name="int4.toml", body=INT4_TOML) + (types_dir / "int4x.toml").write_text(INT4X_TOML) + return types_dir + + +def test_main_all_generates_every_type(tmp_path, capsys): + types_dir = _seed_two_types(tmp_path) + rc = main(["generate.py", "--all"], types_dir=types_dir, out_root=tmp_path) + assert rc == 0 + assert (tmp_path / "src/encrypted_domain/int4/int4_types.sql").is_file() + assert (tmp_path / "src/encrypted_domain/int4x/int4x_types.sql").is_file() + out = capsys.readouterr().out + assert "generated 11 files for int4" in out + assert "codegen --all: ok (2 types: int4, int4x)" in out + + +def test_main_all_generates_in_sorted_order(tmp_path, capsys): + types_dir = _seed_two_types(tmp_path) + main(["generate.py", "--all"], types_dir=types_dir, out_root=tmp_path) + out = capsys.readouterr().out + assert out.index("for int4\n") < out.index("for int4x\n") + + +def test_main_all_errors_when_no_manifests(tmp_path, capsys): + types_dir = tmp_path / "types" + types_dir.mkdir() + rc = main(["generate.py", "--all"], types_dir=types_dir, out_root=tmp_path) + assert rc == 1 + assert "no manifests found" in capsys.readouterr().err + + +def test_main_all_aggregates_nonzero_on_bad_manifest(tmp_path, capsys): + types_dir = _seed_types_dir(tmp_path, name="int4.toml", body=INT4_TOML) + # 'broken' sorts before 'int4', so it is processed first; its domain name + # does not start with the token, so load_spec raises SpecError. + (types_dir / "broken.toml").write_text("[domain]\nwrongprefix = []\n") + rc = main(["generate.py", "--all"], types_dir=types_dir, out_root=tmp_path) + assert rc == 1 + captured = capsys.readouterr() + assert "broken" in captured.err + assert "codegen --all: FAILED" in captured.out + # The good type still generated despite the broken sibling. + assert (tmp_path / "src/encrypted_domain/int4/int4_types.sql").is_file() + + +def test_ordered_files_are_byte_identical_modulo_typename(tmp_path): + spec = load(tmp_path) + ord_domain = next(d for d in spec.domains if d.name == "int4_ord") + ore_domain = next(d for d in spec.domains if d.name == "int4_ord_ore") + + for renderer in (render_functions_file, render_operators_file, render_aggregates_file): + ord_sql = renderer(spec, ord_domain) + ore_sql = renderer(spec, ore_domain) + normalised_ord = ord_sql.replace("int4_ord_ore", "T").replace( + "int4_ord", "T" + ) + normalised_ore = ore_sql.replace("int4_ord_ore", "T").replace( + "int4_ord", "T" + ) + assert normalised_ord == normalised_ore, ( + f"{renderer.__name__}: int4_ord and int4_ord_ore must produce " + f"byte-identical SQL modulo their typenames" + ) + + +def test_render_aggregates_file_only_for_ord_variants(tmp_path): + spec = load(tmp_path) + storage = next(d for d in spec.domains if d.name == "int4") + eq = next(d for d in spec.domains if d.name == "int4_eq") + ordered = next(d for d in spec.domains if d.name == "int4_ord") + ore = next(d for d in spec.domains if d.name == "int4_ord_ore") + + assert render_aggregates_file(spec, storage) is None + assert render_aggregates_file(spec, eq) is None + assert render_aggregates_file(spec, ordered) is not None + assert render_aggregates_file(spec, ore) is not None + + +def test_render_aggregates_file_carries_both_min_and_max(tmp_path): + spec = load(tmp_path) + ordered = next(d for d in spec.domains if d.name == "int4_ord") + sql = render_aggregates_file(spec, ordered) + assert sql is not None + assert sql.count("CREATE FUNCTION") == 2 + assert sql.count("CREATE AGGREGATE") == 2 + assert "eql_v2.min_sfunc" in sql + assert "eql_v2.max_sfunc" in sql + # REQUIRE edges: types + functions + operators must all be declared. + assert "-- REQUIRE: src/encrypted_domain/int4/int4_ord_operators.sql" in sql + assert "-- REQUIRE: src/encrypted_domain/int4/int4_ord_functions.sql" in sql + assert "-- REQUIRE: src/encrypted_domain/int4/int4_types.sql" in sql diff --git a/tasks/codegen/test_operator_surface.py b/tasks/codegen/test_operator_surface.py new file mode 100644 index 00000000..a513ce82 --- /dev/null +++ b/tasks/codegen/test_operator_surface.py @@ -0,0 +1,126 @@ +"""Tests for the scalar operator surface definition.""" +from tasks.codegen.operator_surface import ( + BLOCKER_ONLY_OPERATORS, + KNOWN_JSONB_OPERATORS, + OPERATORS, + PATH_OPERATORS, + SYMMETRIC_OPERATORS, + backing_function, +) + + +def test_twenty_operators_total(): + """The surface covers supported wrappers plus native jsonb fallbacks.""" + assert len(OPERATORS) == 20 + + +def test_eight_symmetric_operators(): + """8 symmetric boolean operators.""" + assert SYMMETRIC_OPERATORS == ["=", "<>", "<", "<=", ">", ">=", "@>", "<@"] + + +def test_two_path_operators(): + """2 path operators.""" + assert PATH_OPERATORS == ["->", "->>"] + + +def test_ten_blocker_only_jsonb_fallback_operators(): + """Native jsonb operators not otherwise supported are blocker-only.""" + assert BLOCKER_ONLY_OPERATORS == [ + "?", + "?|", + "?&", + "@?", + "@@", + "#>", + "#>>", + "-", + "#-", + "||", + ] + + +def test_no_like_operators(): + """The surface excludes ~~ and ~~* (int4 has no LIKE support).""" + assert "~~" not in OPERATORS + assert "~~*" not in OPERATORS + + +def test_backing_function_names(): + """Each operator maps to its eql_v2 backing function name.""" + assert backing_function("=") == "eq" + assert backing_function("<>") == "neq" + assert backing_function("<") == "lt" + assert backing_function("<=") == "lte" + assert backing_function(">") == "gt" + assert backing_function(">=") == "gte" + assert backing_function("@>") == "contains" + assert backing_function("<@") == "contained_by" + assert backing_function("->") == '"->"' + assert backing_function("->>") == '"->>"' + assert backing_function("?") == '"?"' + assert backing_function("?|") == '"?|"' + assert backing_function("?&") == '"?&"' + assert backing_function("@?") == '"@?"' + assert backing_function("@@") == '"@@"' + assert backing_function("#>") == '"#>"' + assert backing_function("#>>") == '"#>>"' + assert backing_function("-") == '"-"' + assert backing_function("#-") == '"#-"' + assert backing_function("||") == '"||"' + + +def test_selectivity_estimators(): + """Symmetric ops carry RESTRICT/JOIN selectivity estimators.""" + assert OPERATORS["="].restrict == "eqsel" + assert OPERATORS["="].join == "eqjoinsel" + assert OPERATORS["<>"].restrict == "neqsel" + assert OPERATORS["<"].restrict == "scalarltsel" + assert OPERATORS["<="].restrict == "scalarlesel" + assert OPERATORS[">"].restrict == "scalargtsel" + assert OPERATORS[">="].restrict == "scalargesel" + + +def test_negators_and_commutators(): + """= / <> are negators; range ops commute as documented.""" + assert OPERATORS["="].negator == "<>" + assert OPERATORS["<>"].negator == "=" + assert OPERATORS["<"].commutator == ">" + assert OPERATORS["<"].negator == ">=" + assert OPERATORS[">="].commutator == "<=" + + +def test_known_jsonb_operators_is_union_of_the_three_lists(): + """The exported union is exactly the three enumerated lists, deduped.""" + assert KNOWN_JSONB_OPERATORS == frozenset( + SYMMETRIC_OPERATORS + PATH_OPERATORS + BLOCKER_ONLY_OPERATORS + ) + + +def test_known_jsonb_operators_matches_operators_keys(): + """The union must stay in lockstep with the OPERATORS table itself, so a + new operator added to one but not the other is caught here rather than + leaving a hole in the storage-only blocker guarantee.""" + assert KNOWN_JSONB_OPERATORS == frozenset(OPERATORS) + + +def test_known_jsonb_operators_full_native_surface(): + """Pin the full native jsonb operator surface for PG 14-17. This is the + source-of-truth the live-DB structural guard + (tests/sqlx/.../family/jsonb_operator_surface.rs) asserts pg_operator is a + subset of. If PG adds a jsonb operator, that DB test fails; if this list is + edited, both must move together. The three lists are disjoint, so the union + size equals their combined length.""" + assert KNOWN_JSONB_OPERATORS == frozenset( + { + # symmetric (supported wrappers) + "=", "<>", "<", "<=", ">", ">=", "@>", "<@", + # path + "->", "->>", + # blocker-only native jsonb fallbacks + "?", "?|", "?&", "@?", "@@", "#>", "#>>", "-", "#-", "||", + } + ) + assert len(KNOWN_JSONB_OPERATORS) == ( + len(SYMMETRIC_OPERATORS) + len(PATH_OPERATORS) + len(BLOCKER_ONLY_OPERATORS) + ) diff --git a/tasks/codegen/test_scalars.py b/tasks/codegen/test_scalars.py new file mode 100644 index 00000000..3ef7d0f0 --- /dev/null +++ b/tasks/codegen/test_scalars.py @@ -0,0 +1,64 @@ +"""Tests for the scalar-kind catalog driving fixture-value emission.""" + +import pytest + +from tasks.codegen.scalars import ( + ScalarError, + require_scalar, + SCALAR_KINDS, +) + + +def test_int4_kind_fields(): + kind = require_scalar("int4") + assert kind.token == "int4" + assert kind.rust_type == "i32" + assert kind.min_symbol == "i32::MIN" + assert kind.max_symbol == "i32::MAX" + assert kind.zero_symbol == "0" + assert kind.min_value == -2147483648 + assert kind.max_value == 2147483647 + + +def test_render_literal_maps_sentinels(): + kind = require_scalar("int4") + assert kind.render_literal("MIN") == "i32::MIN" + assert kind.render_literal("MAX") == "i32::MAX" + assert kind.render_literal("ZERO") == "0" + + +def test_render_literal_passes_through_numeric(): + kind = require_scalar("int4") + assert kind.render_literal("-100") == "-100" + assert kind.render_literal("0") == "0" + assert kind.render_literal("9999") == "9999" + + +def test_render_literal_rejects_non_numeric(): + kind = require_scalar("int4") + with pytest.raises(ScalarError, match="not a valid i32 literal or sentinel"): + kind.render_literal("oops") + + +def test_render_literal_rejects_out_of_range(): + kind = require_scalar("int4") + with pytest.raises(ScalarError, match="out of range"): + kind.render_literal("2147483648") # i32::MAX + 1 + + +def test_numeric_value_resolves_sentinels_and_literals(): + kind = require_scalar("int4") + assert kind.numeric_value("MIN") == -2147483648 + assert kind.numeric_value("MAX") == 2147483647 + assert kind.numeric_value("ZERO") == 0 + assert kind.numeric_value("42") == 42 + assert kind.numeric_value("-1") == -1 + + +def test_require_scalar_unknown_raises(): + with pytest.raises(ScalarError, match="unknown scalar token 'bogus'"): + require_scalar("bogus") + + +def test_int4_registered_in_catalog(): + assert "int4" in SCALAR_KINDS diff --git a/tasks/codegen/test_spec.py b/tasks/codegen/test_spec.py new file mode 100644 index 00000000..151a03cb --- /dev/null +++ b/tasks/codegen/test_spec.py @@ -0,0 +1,208 @@ +"""Tests for the scalar-domain manifest loader.""" + +import textwrap + +import pytest + +from tasks.codegen.spec import DomainSpec, SpecError, TypeSpec, load_spec + + +VALID_TOML = textwrap.dedent(""" + [domain] + int4 = [] + int4_eq = ["hm"] + int4_ord_ore = ["ore"] + int4_ord = ["ore"] +""") + + +def write(tmp_path, name, text): + p = tmp_path / name + p.write_text(text) + return p + + +def test_loads_valid_manifest_and_infers_token_from_filename(tmp_path): + spec = load_spec(write(tmp_path, "int4.toml", VALID_TOML)) + assert isinstance(spec, TypeSpec) + assert spec.token == "int4" + assert spec.domains == [ + DomainSpec(name="int4", terms=[]), + DomainSpec(name="int4_eq", terms=["hm"]), + DomainSpec(name="int4_ord_ore", terms=["ore"]), + DomainSpec(name="int4_ord", terms=["ore"]), + ] + + +def test_missing_domain_table_raises(tmp_path): + with pytest.raises(SpecError, match="missing required table '\\[domain\\]'"): + load_spec(write(tmp_path, "int4.toml", "")) + + +def test_empty_domain_table_raises(tmp_path): + with pytest.raises(SpecError, match="at least one domain"): + load_spec(write(tmp_path, "int4.toml", "[domain]\n")) + + +def test_domain_value_must_be_list(tmp_path): + bad = textwrap.dedent(""" + [domain] + int4_eq = "hm" + """) + with pytest.raises(SpecError, match="must be a list of term names"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_domain_term_must_be_string(tmp_path): + bad = textwrap.dedent(""" + [domain] + int4_eq = [1] + """) + with pytest.raises(SpecError, match="term names must be strings"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_unknown_term_raises_with_domain_context(tmp_path): + bad = textwrap.dedent(""" + [domain] + int4_eq = ["bogus"] + """) + with pytest.raises(SpecError, match="\\[domain\\] int4_eq: unknown term 'bogus'"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_domain_name_must_start_with_type_token(tmp_path): + bad = textwrap.dedent(""" + [domain] + text = [] + """) + with pytest.raises(SpecError, match="domain name must start with 'int4'"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_domain_name_must_be_token_or_token_underscore(tmp_path): + bad = textwrap.dedent(""" + [domain] + int4xfoo = [] + """) + with pytest.raises(SpecError, match="domain name must start with 'int4'"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +@pytest.mark.parametrize("filename", [ + "Int4.toml", + "int-4.toml", + "int 4.toml", + "4int.toml", + "int4;drop.toml", +]) +def test_token_must_be_sql_identifier(tmp_path, filename): + with pytest.raises(SpecError, match=r"token .* must match"): + load_spec(write(tmp_path, filename, VALID_TOML)) + + +@pytest.mark.parametrize("bad_name", [ + "int4-eq", + "int4 eq", + "INT4_eq", + "int4;drop", +]) +def test_domain_name_must_be_sql_identifier(tmp_path, bad_name): + bad = textwrap.dedent(f""" + [domain] + "{bad_name}" = [] + """) + with pytest.raises(SpecError, match=r"domain name .* must match"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +FIXTURE_TOML = VALID_TOML + textwrap.dedent(""" + [fixture] + values = ["MIN", "-100", "-1", "ZERO", "1", "9999", "MAX"] +""") + + +def test_fixture_values_default_to_none_when_absent(tmp_path): + spec = load_spec(write(tmp_path, "int4.toml", VALID_TOML)) + assert spec.fixture_values is None + + +def test_loads_fixture_values_when_present(tmp_path): + spec = load_spec(write(tmp_path, "int4.toml", FIXTURE_TOML)) + assert spec.fixture_values == [ + "MIN", "-100", "-1", "ZERO", "1", "9999", "MAX", + ] + + +def test_fixture_values_must_be_a_list(tmp_path): + bad = VALID_TOML + '\n[fixture]\nvalues = "MIN"\n' + with pytest.raises(SpecError, match=r"\[fixture\] values: must be a list"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_table_requires_values_key(tmp_path): + bad = VALID_TOML + "\n[fixture]\nother = 1\n" + with pytest.raises(SpecError, match=r"\[fixture\]: missing required key 'values'"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_must_be_non_empty(tmp_path): + bad = VALID_TOML + "\n[fixture]\nvalues = []\n" + with pytest.raises(SpecError, match=r"\[fixture\] values: must not be empty"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_must_be_strings(tmp_path): + bad = VALID_TOML + "\n[fixture]\nvalues = [1, 2]\n" + with pytest.raises(SpecError, match=r"\[fixture\] values: must be strings"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_reject_invalid_literal(tmp_path): + bad = VALID_TOML + '\n[fixture]\nvalues = ["MIN", "oops", "ZERO", "MAX"]\n' + with pytest.raises(SpecError, match="not a valid i32 literal"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_require_min_max_zero(tmp_path): + bad = VALID_TOML + '\n[fixture]\nvalues = ["1", "2", "3"]\n' + with pytest.raises(SpecError, match="must include MIN, MAX, and zero"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_require_max_even_if_min_and_zero_present(tmp_path): + bad = VALID_TOML + '\n[fixture]\nvalues = ["MIN", "ZERO", "1"]\n' + with pytest.raises(SpecError, match="must include MIN, MAX, and zero"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_reject_duplicate_literal(tmp_path): + bad = VALID_TOML + '\n[fixture]\nvalues = ["MIN", "1", "ZERO", "1", "MAX"]\n' + with pytest.raises(SpecError, match=r"must be distinct.*duplicate values.*'1'"): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_values_reject_sentinel_literal_alias(tmp_path): + # "MIN" and the i32::MIN literal resolve to the same plaintext value; + # the distinct-plaintext contract must reject the pair. + bad = ( + VALID_TOML + + '\n[fixture]\nvalues = ["MIN", "-2147483648", "ZERO", "MAX"]\n' + ) + with pytest.raises( + SpecError, + match=r"must be distinct.*'-2147483648' duplicates 'MIN' \(both resolve to -2147483648\)", + ): + load_spec(write(tmp_path, "int4.toml", bad)) + + +def test_fixture_for_unknown_scalar_token_raises(tmp_path): + bad = textwrap.dedent(""" + [domain] + int8 = [] + + [fixture] + values = ["1"] + """) + with pytest.raises(SpecError, match="unknown scalar token 'int8'"): + load_spec(write(tmp_path, "int8.toml", bad)) diff --git a/tasks/codegen/test_templates.py b/tasks/codegen/test_templates.py new file mode 100644 index 00000000..ba1e3b7d --- /dev/null +++ b/tasks/codegen/test_templates.py @@ -0,0 +1,499 @@ +"""Tests for per-construct SQL template functions.""" + +from tasks.codegen.spec import DomainSpec, TypeSpec +from tasks.codegen.templates import ( + AGGREGATE_OPS, + AUTO_GENERATED_HEADER, + AUTO_GENERATED_HEADER_RS, + _sql_str, + brief_role_clause, + domain_name, + extractor_for_operator, + is_ord_capable, + render_aggregate, + render_blocker_bool, + render_blocker_native, + render_blocker_path, + render_domain_block, + render_extractor, + render_fixture_values_rs, + render_operator, + render_wrapper, +) +from tasks.codegen.terms import TERM_CATALOG + + +def test_auto_generated_header_present(): + assert "AUTO-GENERATED" in AUTO_GENERATED_HEADER + assert "DO NOT EDIT" in AUTO_GENERATED_HEADER + + +def test_rust_header_is_comment_and_marks_committed(): + # Rust uses // comments, not SQL's --, and unlike the gitignored SQL + # surface this file is committed and CI-verified. + assert AUTO_GENERATED_HEADER_RS.startswith("// AUTO-GENERATED") + assert "DO NOT EDIT" in AUTO_GENERATED_HEADER_RS + assert "committed" in AUTO_GENERATED_HEADER_RS + # No line is an SQL-style (`--`) comment — this is Rust, not SQL. + assert not any( + line.startswith("--") for line in AUTO_GENERATED_HEADER_RS.splitlines() + ) + + +def test_render_fixture_values_rs_emits_typed_const(): + spec = TypeSpec( + token="int4", + domains=[], + fixture_values=["MIN", "-1", "ZERO", "1", "MAX"], + ) + body = render_fixture_values_rs(spec) + assert "pub const VALUES: &[i32] = &[" in body + assert "tasks/codegen/types/int4.toml" in body + # Sentinels map to named consts; numeric tokens pass through. + assert "i32::MIN," in body + assert "i32::MAX," in body + assert " -1,\n" in body + assert " 0,\n" in body # ZERO and "1" both literal + assert " 1,\n" in body + # No AUTO-GENERATED header in the body — the writer prepends it. + assert "AUTO-GENERATED" not in body + + +def test_render_fixture_values_rs_preserves_manifest_order(): + spec = TypeSpec( + token="int4", + domains=[], + fixture_values=["MIN", "ZERO", "MAX"], + ) + body = render_fixture_values_rs(spec) + assert body.index("i32::MIN") < body.index("0,") < body.index("i32::MAX") + + +def test_domain_block_storage_uses_fixed_envelope_only(): + domain = DomainSpec(name="int4", terms=[]) + sql = render_domain_block(domain, "int4") + assert "CREATE DOMAIN public.eql_v2_int4 AS jsonb" in sql + assert "VALUE ? 'v'" in sql + assert "VALUE ? 'i'" in sql + assert "VALUE ? 'c'" in sql + assert "VALUE ? 'hm'" not in sql + assert "VALUE ? 'ob'" not in sql + + +def test_domain_block_uses_catalog_json_keys(): + domain = DomainSpec(name="int4_ord", terms=["ore"]) + sql = render_domain_block(domain, "int4") + assert "CREATE DOMAIN public.eql_v2_int4_ord AS jsonb" in sql + assert "VALUE ? 'ob'" in sql + assert "VALUE ? 'ore'" not in sql + + +def test_domain_block_check_pins_envelope_version(): + """Thread D: the CHECK both verifies the envelope `v` key is PRESENT and + pins its value to the EQL payload-format version (2), matching the + repo-wide eql_v2._encrypted_check_v rule. The v=1 payloads in + tests/sqlx/fixtures/aggregate_minmax_data.sql belong to the separate + composite-type (eql_v2_encrypted) aggregate stream, not these domains, so + pinning the value here rejects stale/foreign-version payloads without + affecting that fixture.""" + for domain in ( + DomainSpec(name="int4", terms=[]), + DomainSpec(name="int4_eq", terms=["hm"]), + DomainSpec(name="int4_ord", terms=["ore"]), + ): + sql = render_domain_block(domain, "int4") + assert "VALUE ? 'v'" in sql # presence checked + assert "VALUE->>'v' = '2'" in sql # value pinned to version 2 + + +def test_extractor_is_catalog_derived_and_inlinable(): + domain = DomainSpec(name="int4_eq", terms=["hm"]) + sql = render_extractor(domain, TERM_CATALOG["hm"]) + assert "CREATE FUNCTION eql_v2.eq_term(a eql_v2_int4_eq)" in sql + assert "RETURNS eql_v2.hmac_256" in sql + assert "LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE" in sql + assert "SELECT eql_v2.hmac_256(a::jsonb)" in sql + assert "SET search_path" not in sql + + +def test_wrapper_uses_term_extractor_for_supported_operator(): + domain = DomainSpec(name="int4_ord", terms=["ore"]) + sql = render_wrapper( + domain, + op="<", + arg_a="eql_v2_int4_ord", + arg_b="jsonb", + extractor="ord_term", + ) + assert "CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord, b jsonb)" in sql + assert "SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b::eql_v2_int4_ord)" in sql + + +def test_wrapper_is_inlinable_sql(): + """Wrappers must be single-statement LANGUAGE sql with no search_path pin.""" + domain = DomainSpec(name="int4_eq", terms=["hm"]) + sql = render_wrapper( + domain, + op="=", + arg_a="eql_v2_int4_eq", + arg_b="eql_v2_int4_eq", + extractor="eq_term", + ) + assert "LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE" in sql + assert "SET search_path" not in sql + assert "LANGUAGE plpgsql" not in sql + + +def test_extractor_for_operator_selects_catalog_term(): + domain = DomainSpec(name="int4_ord", terms=["ore"]) + assert extractor_for_operator(domain, "=") == "ord_term" + assert extractor_for_operator(domain, "<") == "ord_term" + + +def test_extractor_for_operator_returns_none_for_unsupported_operator(): + domain = DomainSpec(name="int4_eq", terms=["hm"]) + assert extractor_for_operator(domain, "<") is None + + +def test_blocker_bool_is_not_strict(): + """Footgun: a STRICT blocker lets Postgres skip the body on NULL input, + silently bypassing the 'operator not supported' raise. Assert the exact + attribute line so any future refactor that re-adds STRICT fails loudly.""" + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_bool( + domain, op="<", arg_a="eql_v2_int4", arg_b="eql_v2_int4", + ) + assert "CREATE FUNCTION eql_v2.lt(a eql_v2_int4, b eql_v2_int4)" in sql + assert "encrypted_domain_unsupported_bool('eql_v2_int4', '<')" in sql + assert "RETURNS boolean IMMUTABLE PARALLEL SAFE\n" in sql + assert "LANGUAGE plpgsql" in sql + assert "STRICT" not in sql + + +def test_blocker_path_is_not_strict(): + """Mirror of test_blocker_bool_is_not_strict for path blockers.""" + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_path( + domain, op="->", arg_a="eql_v2_int4", arg_b="text", + ) + assert "RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE\n" in sql + assert "LANGUAGE plpgsql" in sql + assert "STRICT" not in sql + + +def test_blocker_path_returns_domain_or_text(): + domain = DomainSpec(name="int4", terms=[]) + arrow = render_blocker_path( + domain, op="->", arg_a="eql_v2_int4", arg_b="text", + ) + assert 'CREATE FUNCTION eql_v2."->"(a eql_v2_int4, selector text)' in arrow + assert "RETURNS eql_v2_int4" in arrow + arrow2 = render_blocker_path( + domain, op="->>", arg_a="eql_v2_int4", arg_b="text", + ) + assert "RETURNS text" in arrow2 + + +def test_blocker_path_for_jsonb_left_arg_returns_domain(): + """The (jsonb, dom) shape from _path_shapes still routes to the domain + return type for `->` (only `->>` returns text).""" + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_path( + domain, op="->", arg_a="jsonb", arg_b="eql_v2_int4", + ) + assert 'CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4)' in sql + assert "RETURNS eql_v2_int4" in sql + + +def test_blocker_native_bool_uses_helper_and_is_not_strict(): + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_native( + domain, op="?", arg_a="eql_v2_int4", arg_b="text", returns="boolean", + ) + assert 'CREATE FUNCTION eql_v2."?"(a eql_v2_int4, b text)' in sql + assert "encrypted_domain_unsupported_bool('eql_v2_int4', '?')" in sql + assert "RETURNS boolean IMMUTABLE PARALLEL SAFE\n" in sql + assert "LANGUAGE plpgsql" in sql + assert "STRICT" not in sql + + +def test_blocker_native_jsonb_result_raises_and_is_not_strict(): + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_native( + domain, op="#>", arg_a="eql_v2_int4", arg_b="text[]", returns="jsonb", + ) + assert 'CREATE FUNCTION eql_v2."#>"(a eql_v2_int4, b text[])' in sql + assert "RETURNS jsonb IMMUTABLE PARALLEL SAFE\n" in sql + assert "RAISE EXCEPTION 'operator % is not supported for %', '#>', 'eql_v2_int4'" in sql + assert "LANGUAGE plpgsql" in sql + assert "STRICT" not in sql + + +def test_blocker_native_text_result_raises_and_is_not_strict(): + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_native( + domain, op="#>>", arg_a="eql_v2_int4", arg_b="text[]", returns="text", + ) + assert 'CREATE FUNCTION eql_v2."#>>"(a eql_v2_int4, b text[])' in sql + assert "RETURNS text IMMUTABLE PARALLEL SAFE\n" in sql + assert "LANGUAGE plpgsql" in sql + assert "STRICT" not in sql + + +def test_blocker_native_concat_cross_shape(): + domain = DomainSpec(name="int4", terms=[]) + sql = render_blocker_native( + domain, op="||", arg_a="jsonb", arg_b="eql_v2_int4", returns="jsonb", + ) + assert 'CREATE FUNCTION eql_v2."||"(a jsonb, b eql_v2_int4)' in sql + assert "RETURNS jsonb" in sql + + +def test_operator_symmetric_metadata(): + sql = render_operator( + op="=", backing="eq", + leftarg="eql_v2_int4_eq", rightarg="eql_v2_int4_eq", + supported=True, + ) + assert "CREATE OPERATOR = (" in sql + assert "FUNCTION = eql_v2.eq" in sql + assert "LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq" in sql + assert "NEGATOR = <>" in sql + assert "RESTRICT = eqsel" in sql + + +def test_render_operator_unsupported_emits_only_function_and_args(): + """Unsupported routing must not emit NEGATOR / RESTRICT / JOIN / COMMUTATOR + (those would lie about selectivity for a function that always raises).""" + sql = render_operator( + op="=", backing="eq", + leftarg="eql_v2_int4", rightarg="eql_v2_int4", + supported=False, + ) + assert "CREATE OPERATOR = (" in sql + assert "FUNCTION = eql_v2.eq" in sql + assert "LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4" in sql + assert "NEGATOR" not in sql + assert "RESTRICT" not in sql + assert "JOIN" not in sql + assert "COMMUTATOR" not in sql + + +def test_render_aggregate_min_int4_ord_emits_state_function_and_aggregate(): + """Pin the rendered shape for the canonical (int4_ord, min) case.""" + domain = DomainSpec(name="int4_ord", terms=["ore"]) + sql = render_aggregate(domain, AGGREGATE_OPS["min"]) + assert "CREATE FUNCTION eql_v2.min_sfunc(state eql_v2_int4_ord, value eql_v2_int4_ord)" in sql + assert "RETURNS eql_v2_int4_ord" in sql + assert "LANGUAGE plpgsql IMMUTABLE STRICT" in sql + assert "SET search_path = pg_catalog, extensions, public" in sql + assert "IF value < state THEN" in sql + assert "CREATE AGGREGATE eql_v2.min(eql_v2_int4_ord) (" in sql + assert "sfunc = eql_v2.min_sfunc" in sql + assert "stype = eql_v2_int4_ord" in sql + + +def test_render_aggregate_max_uses_greater_than_comparator(): + """Symmetric pin: max uses `>` not `<`.""" + domain = DomainSpec(name="int4_ord_ore", terms=["ore"]) + sql = render_aggregate(domain, AGGREGATE_OPS["max"]) + assert "CREATE FUNCTION eql_v2.max_sfunc(state eql_v2_int4_ord_ore, value eql_v2_int4_ord_ore)" in sql + assert "IF value > state THEN" in sql + assert "CREATE AGGREGATE eql_v2.max(eql_v2_int4_ord_ore) (" in sql + + +def test_render_aggregate_state_function_is_not_inlinable(): + """Footgun mirror: blockers must be LANGUAGE plpgsql; the state function + deliberately is too, so the planner can't elide an IMMUTABLE STRICT + aggregate state call away. STRICT + plpgsql + SET search_path together.""" + domain = DomainSpec(name="int4_ord", terms=["ore"]) + sql = render_aggregate(domain, AGGREGATE_OPS["min"]) + assert "LANGUAGE plpgsql" in sql + assert "STRICT" in sql + # Inlinable-SQL shape — explicitly absent. + assert "LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE" not in sql + + +def test_is_ord_capable_matches_role(): + assert is_ord_capable(DomainSpec(name="int4_ord", terms=["ore"])) is True + assert is_ord_capable(DomainSpec(name="int4_ord_ore", terms=["ore"])) is True + assert is_ord_capable(DomainSpec(name="int4_eq", terms=["hm"])) is False + assert is_ord_capable(DomainSpec(name="int4", terms=[])) is False + + +def test_render_operator_for_containment_omits_commutator(): + """@> has no commutator / negator / selectivity in OPERATORS; supported=True + must still omit those clauses.""" + sql = render_operator( + op="@>", backing="contains", + leftarg="eql_v2_int4_ord", rightarg="eql_v2_int4_ord", + supported=True, + ) + assert "CREATE OPERATOR @> (" in sql + assert "FUNCTION = eql_v2.contains" in sql + assert "COMMUTATOR" not in sql + assert "NEGATOR" not in sql + assert "RESTRICT" not in sql + assert "JOIN" not in sql + + +# --- ITEM A: placeholder/blocker operator comment ------------------------- + + +def test_render_operator_unsupported_emits_placeholder_comment(): + """Thread A: a blocker-backed (unsupported) operator must carry a leading + SQL comment explaining it is a placeholder that raises, so a future + reviewer doesn't wonder why an ordering op is declared on an eq-only + domain.""" + sql = render_operator( + op="<", backing="lt", + leftarg="eql_v2_int4_eq", rightarg="eql_v2_int4_eq", + supported=False, + ) + assert sql.startswith("-- Placeholder:") + assert "does not support <" in sql + assert "always raises" in sql + # The comment precedes the CREATE OPERATOR. + assert sql.index("-- Placeholder:") < sql.index("CREATE OPERATOR") + + +def test_render_operator_supported_has_no_placeholder_comment(): + """Supported operators route to real wrappers — no placeholder comment.""" + sql = render_operator( + op="=", backing="eq", + leftarg="eql_v2_int4_eq", rightarg="eql_v2_int4_eq", + supported=True, + ) + assert "Placeholder" not in sql + + +# --- ITEM B & J: aggregate SQL rationale comments ------------------------- + + +def test_render_aggregate_state_function_emits_plpgsql_rationale_comment(): + """Thread B: the plpgsql rationale must appear in the emitted SQL (not just + as a Python comment) so a SQL reader sees why it isn't an inlinable + LANGUAGE sql CASE.""" + domain = DomainSpec(name="int4_ord", terms=["ore"]) + sql = render_aggregate(domain, AGGREGATE_OPS["min"]) + assert "-- LANGUAGE plpgsql, not sql:" in sql + assert "not index" in sql + # The rationale precedes the state-function definition. + assert sql.index("-- LANGUAGE plpgsql, not sql:") < sql.index( + "CREATE FUNCTION eql_v2.min_sfunc" + ) + + +def test_render_aggregate_enables_parallel_and_combinefunc(): + """Thread #22: MIN/MAX aggregates declare a combine function (the state + function itself — min/max are associative) and PARALLEL = SAFE, so PG can + use partial/parallel aggregation on the large GROUP BY workloads these ORE + aggregates exist to serve. The sfunc is likewise PARALLEL SAFE.""" + for op_name, sfunc in (("min", "min_sfunc"), ("max", "max_sfunc")): + domain = DomainSpec(name="int4_ord", terms=["ore"]) + sql = render_aggregate(domain, AGGREGATE_OPS[op_name]) + # The state function must be parallel-safe... + assert "LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE" in sql + # ...and the aggregate must declare the combinefunc + parallel safety + # inside the CREATE AGGREGATE option list (not merely in prose). + aggregate_body = sql[sql.index(f"CREATE AGGREGATE eql_v2.{op_name}"):] + assert f"combinefunc = eql_v2.{sfunc}" in aggregate_body + assert "parallel = safe" in aggregate_body + # The stale "intentionally disabled" omission note must be gone. + assert "intentionally disabled" not in sql + assert "-- No COMBINEFUNC" not in sql + + +# --- ITEM K: differentiated @brief for converged vs scheme-explicit ------- + + +def test_domain_brief_distinguishes_converged_from_scheme_twin(): + """Thread K: int4_ord (converged) and int4_ord_ore (scheme twin) carry the + same terms but must render distinct, sensible briefs.""" + ord_dom = DomainSpec(name="int4_ord", terms=["ore"]) + ore_dom = DomainSpec(name="int4_ord_ore", terms=["ore"]) + ord_sql = render_domain_block(ord_dom, "int4") + ore_sql = render_domain_block(ore_dom, "int4") + + ord_brief = next( + line for line in ord_sql.splitlines() if "@brief" in line + ) + ore_brief = next( + line for line in ore_sql.splitlines() if "@brief" in line + ) + # Both still lead with the role phrase... + assert "Ordered encrypted int4 domain." in ord_brief + assert "Ordered encrypted int4 domain." in ore_brief + # ...but the trailing clause differs and reads sensibly. + assert ord_brief != ore_brief + assert "Recommended converged name" in ord_brief + assert "Scheme-explicit twin" in ore_brief + assert "ore scheme" in ore_brief + assert "int4_ord" in ore_brief # points back at the converged name + + +def test_brief_role_clause_is_generic_over_token_and_scheme(): + """The disambiguation reads token/role/scheme from the name, not a + hard-coded literal — so it works for other types (int8) and schemes.""" + # Converged ordered name for a different token. + assert "Recommended converged name" in brief_role_clause( + DomainSpec(name="int8_ord", terms=["ore"]), "int8" + ) + # Scheme-explicit twin with a hypothetical non-ore scheme label. + clause = brief_role_clause( + DomainSpec(name="date_ord_lex", terms=["ore"]), "date" + ) + assert "Scheme-explicit twin" in clause + assert "lex scheme" in clause + assert "date_ord" in clause + + +def test_brief_role_clause_empty_for_storage_and_eq(): + """Storage and eq domains have no converged/twin ambiguity (only one name + each), so they get no disambiguating clause — brief stays unchanged.""" + assert brief_role_clause(DomainSpec(name="int4", terms=[]), "int4") == "" + assert brief_role_clause( + DomainSpec(name="int4_eq", terms=["hm"]), "int4" + ) == "" + + +# --- THREAD 1: SQL-string interpolation hardening ------------------------- + + +def test_sql_str_doubles_single_quotes(): + """_sql_str doubles embedded single quotes so a value can't break out of + its SQL string literal.""" + assert _sql_str("o'brien") == "o''brien" + assert _sql_str("a'b'c") == "a''b''c" + # Quote-free input is unchanged — current catalog strings stay byte-stable. + assert _sql_str("int4_eq") == "int4_eq" + assert _sql_str("<=") == "<=" + + +def test_blocker_escapes_quote_bearing_domain_in_rendered_sql(): + """A hypothetical quote-bearing domain name must be doubled inside the + helper-call string literal in the rendered blocker, not interpolated raw. + + (op can't carry a quote in practice — it's looked up in the operator + catalog — so the domain name is the live escaping path through the blocker + string literals.)""" + domain = DomainSpec(name="o'dom", terms=[]) + sql = render_blocker_bool( + domain, op="<", arg_a="eql_v2_o'dom", arg_b="eql_v2_o'dom", + ) + # The dom flows into encrypted_domain_unsupported_bool('', '') + # as a single-quoted literal — the quote must be doubled. + assert "encrypted_domain_unsupported_bool('eql_v2_o''dom', '<')" in sql + # The raw, unescaped single-quoted form must not appear. + assert "'eql_v2_o'dom'" not in sql + + +def test_domain_block_escapes_quote_bearing_key_in_check(): + """A hypothetical quote-bearing payload key must be doubled inside the + VALUE ? '' check rather than interpolated raw.""" + # A term-free domain whose name carries a quote exercises the typname + # literal escaping in the IF NOT EXISTS guard. + quoted = DomainSpec(name="we'ird", terms=[]) + sql = render_domain_block(quoted, "int4") + assert "typname = 'eql_v2_we''ird'" in sql + assert "typname = 'eql_v2_we'ird'" not in sql diff --git a/tasks/codegen/test_terms.py b/tasks/codegen/test_terms.py new file mode 100644 index 00000000..8ac7aa4b --- /dev/null +++ b/tasks/codegen/test_terms.py @@ -0,0 +1,96 @@ +"""Tests for the fixed scalar-domain term catalog.""" + +import pytest + +from tasks.codegen.terms import ( + TermError, + extractor_for_operator, + operators_for_terms, + require_terms, + role_for_terms, + term_json_keys, + term_requires, +) + + +def test_hm_term_provides_equality(): + terms = require_terms(["hm"]) + hm = terms[0] + assert hm.name == "hm" + assert hm.json_key == "hm" + assert hm.extractor == "eq_term" + assert hm.returns == "eql_v2.hmac_256" + assert hm.ctor == "hmac_256" + assert hm.role == "eq" + assert hm.operators == ("=", "<>") + assert hm.requires == ("src/hmac_256/functions.sql",) + + +def test_ore_term_preserves_existing_int4_sql_contract(): + terms = require_terms(["ore"]) + ore = terms[0] + assert ore.name == "ore" + assert ore.json_key == "ob" + assert ore.extractor == "ord_term" + assert ore.returns == "eql_v2.ore_block_u64_8_256" + assert ore.ctor == "ore_block_u64_8_256" + assert ore.role == "ord" + assert ore.operators == ("=", "<>", "<", "<=", ">", ">=") + assert ore.requires == ( + "src/ore_block_u64_8_256/functions.sql", + "src/ore_block_u64_8_256/operators.sql", + ) + + +def test_unknown_term_raises(): + with pytest.raises(TermError, match="unknown term 'bogus'"): + require_terms(["bogus"]) + + +def test_operators_are_union_in_catalog_order(): + assert operators_for_terms(["ore", "hm"]) == [ + "=", "<>", "<", "<=", ">", ">=", + ] + + +def test_json_keys_come_from_catalog_not_manifest_names(): + assert term_json_keys(["hm", "ore"]) == ["hm", "ob"] + + +def test_term_requires_are_deduplicated(): + assert term_requires(["ore", "ore", "hm"]) == [ + "src/ore_block_u64_8_256/functions.sql", + "src/ore_block_u64_8_256/operators.sql", + "src/hmac_256/functions.sql", + ] + + +def test_role_for_terms_handles_storage_eq_ord(): + assert role_for_terms([]) == "storage" + assert role_for_terms(["hm"]) == "eq" + assert role_for_terms(["ore"]) == "ord" + + +def test_operators_for_terms_handles_empty_list(): + assert operators_for_terms([]) == [] + + +def test_term_json_keys_handles_empty_list(): + assert term_json_keys([]) == [] + + +def test_term_requires_handles_empty_list(): + assert term_requires([]) == [] + + +def test_extractor_for_operator_picks_first_term_supporting_op(): + assert extractor_for_operator(["hm"], "=") == "eq_term" + assert extractor_for_operator(["ore"], "<") == "ord_term" + # Multi-term domains: first supporting term wins. + assert extractor_for_operator(["hm", "ore"], "=") == "eq_term" + assert extractor_for_operator(["hm", "ore"], "<") == "ord_term" + + +def test_extractor_for_operator_returns_none_when_no_term_supports_op(): + assert extractor_for_operator(["hm"], "<") is None + assert extractor_for_operator([], "=") is None diff --git a/tasks/codegen/test_writer.py b/tasks/codegen/test_writer.py new file mode 100644 index 00000000..81805cb1 --- /dev/null +++ b/tasks/codegen/test_writer.py @@ -0,0 +1,157 @@ +"""Tests for the ownership / overwrite-refusal / stale-cleanup rules.""" +import pytest +from tasks.codegen.generate import REPO_ROOT +from tasks.codegen.templates import AUTO_GENERATED_HEADER, AUTO_GENERATED_HEADER_RS +from tasks.codegen.writer import ( + _MARKER, + OwnershipError, + is_generated, + is_generated_rs, + clean_generated_files, + ensure_generated_paths_writable, + write_generated_file, + write_generated_rs, +) + + +_EXPECTED_SUFFIXES = ( + "_types.sql", + "_functions.sql", + "_operators.sql", + "_aggregates.sql", + "_extensions.sql", +) + + +def test_is_generated_true_for_header(tmp_path): + p = tmp_path / "x.sql" + p.write_text(AUTO_GENERATED_HEADER + "SELECT 1;\n") + assert is_generated(p) is True + + +def test_is_generated_false_for_handwritten(tmp_path): + p = tmp_path / "x.sql" + p.write_text("-- REQUIRE: src/schema.sql\nSELECT 1;\n") + assert is_generated(p) is False + + +def test_is_generated_true_for_crlf_header(tmp_path): + p = tmp_path / "x.sql" + p.write_bytes((_MARKER + "\r\n" + "SELECT 1;\n").encode("utf-8")) + assert is_generated(p) is True + + +def test_write_generated_file_creates_with_header(tmp_path): + p = tmp_path / "int4_types.sql" + write_generated_file(p, "DO $$ BEGIN END $$;\n") + text = p.read_text() + assert text.startswith(AUTO_GENERATED_HEADER) + assert "DO $$ BEGIN END $$;" in text + + +def test_write_refuses_to_overwrite_handwritten(tmp_path): + """Refuse to clobber a hand-written file at a generated path.""" + p = tmp_path / "int4_types.sql" + p.write_text("-- REQUIRE: src/schema.sql\n-- hand-written\n") + with pytest.raises(OwnershipError, match="hand-written"): + write_generated_file(p, "DO $$ BEGIN END $$;\n") + + +def test_preflight_refuses_handwritten_target_before_cleanup(tmp_path): + generated = tmp_path / "int4_types.sql" + hand = tmp_path / "int4_eq_functions.sql" + generated.write_text(AUTO_GENERATED_HEADER + "-- old generated\n") + hand.write_text("-- REQUIRE: src/schema.sql\n-- hand-written\n") + + with pytest.raises(OwnershipError, match=r"int4_eq_functions\.sql"): + ensure_generated_paths_writable([generated, hand]) + + assert generated.exists() + assert hand.exists() + + +def test_write_overwrites_existing_generated_file(tmp_path): + """A file that already carries the header may be overwritten.""" + p = tmp_path / "int4_types.sql" + p.write_text(AUTO_GENERATED_HEADER + "-- old content\n") + write_generated_file(p, "-- new content\n") + text = p.read_text() + assert "-- new content" in text + assert "-- old content" not in text + + +def test_clean_removes_only_generated_files(tmp_path): + """Clean deletes every generated file, keeps the rest.""" + gen1 = tmp_path / "int4_eq_functions.sql" + gen2 = tmp_path / "int4_old_domain_functions.sql" # stale orphan + hand = tmp_path / "int4_jsonb_extra.sql" + gen1.write_text(AUTO_GENERATED_HEADER + "SELECT 1;\n") + gen2.write_text(AUTO_GENERATED_HEADER + "SELECT 2;\n") + hand.write_text("-- REQUIRE: src/schema.sql\n-- hand-written\n") + + removed = clean_generated_files(tmp_path) + + assert not gen1.exists() + assert not gen2.exists() # stale orphan cleaned up + assert hand.exists() # hand-written file untouched + assert set(removed) == {gen1, gen2} + + +def test_clean_on_empty_directory(tmp_path): + """Clean on a greenfield directory removes nothing and does not error.""" + removed = clean_generated_files(tmp_path) + assert removed == [] + + +def test_write_generated_rs_creates_with_rust_header(tmp_path): + p = tmp_path / "int4_values.rs" + write_generated_rs(p, "pub const VALUES: &[i32] = &[];\n") + text = p.read_text() + assert text.startswith(AUTO_GENERATED_HEADER_RS) + assert "pub const VALUES" in text + + +def test_is_generated_rs_true_for_rust_header(tmp_path): + p = tmp_path / "int4_values.rs" + p.write_text(AUTO_GENERATED_HEADER_RS + "pub const VALUES: &[i32] = &[];\n") + assert is_generated_rs(p) is True + + +def test_is_generated_rs_false_for_handwritten(tmp_path): + p = tmp_path / "int4_values.rs" + p.write_text("//! hand-written\npub const VALUES: &[i32] = &[];\n") + assert is_generated_rs(p) is False + + +def test_write_generated_rs_refuses_to_overwrite_handwritten(tmp_path): + p = tmp_path / "int4_values.rs" + p.write_text("//! hand-written\n") + with pytest.raises(OwnershipError, match="hand-written"): + write_generated_rs(p, "pub const VALUES: &[i32] = &[];\n") + + +def test_write_generated_rs_overwrites_existing_generated(tmp_path): + p = tmp_path / "int4_values.rs" + p.write_text(AUTO_GENERATED_HEADER_RS + "// old\n") + write_generated_rs(p, "// new\n") + text = p.read_text() + assert "// new" in text + assert "// old" not in text + + +def test_no_misnamed_sql_files_in_generated_dirs(): + """Files under src/encrypted_domain// must end in one of the four + documented suffixes — catches mistakes like `int4_extension.sql` + (singular), which the build would silently include despite violating + the documented convention.""" + root = REPO_ROOT / "src" / "encrypted_domain" + misnamed = [ + path.relative_to(REPO_ROOT) + for type_dir in root.iterdir() if type_dir.is_dir() + for path in sorted(type_dir.glob("*.sql")) + if not path.name.endswith(_EXPECTED_SUFFIXES) + ] if root.is_dir() else [] + assert not misnamed, ( + f"misnamed SQL files in src/encrypted_domain/ — expected suffix in " + f"{_EXPECTED_SUFFIXES}: {misnamed}" + ) diff --git a/tasks/codegen/types/.gitkeep b/tasks/codegen/types/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tasks/codegen/types/int4.toml b/tasks/codegen/types/int4.toml new file mode 100644 index 00000000..606d80ee --- /dev/null +++ b/tasks/codegen/types/int4.toml @@ -0,0 +1,19 @@ +# Encrypted-domain scalar manifest for int4. +# The filename supplies the type token. Each domain lists the index terms +# it carries; term capabilities are fixed in tasks/codegen/terms.py. + +[domain] +int4 = [] +int4_eq = ["hm"] +int4_ord_ore = ["ore"] +int4_ord = ["ore"] + +# Single source of truth for the int4 fixture plaintext list. Drives the +# generated tests/sqlx/src/fixtures/int4_values.rs const, shared by the fixture +# generator and the matrix oracle. Sentinels MIN/MAX/ZERO map to i32 named +# consts; the set MUST include MIN, MAX, and zero (matrix comparison pivots). +[fixture] +values = [ + "MIN", "-100", "-1", "ZERO", "1", "2", "5", "10", "17", "25", + "42", "50", "100", "250", "1000", "9999", "MAX", +] diff --git a/tasks/codegen/writer.py b/tasks/codegen/writer.py new file mode 100644 index 00000000..aa0cdd99 --- /dev/null +++ b/tasks/codegen/writer.py @@ -0,0 +1,89 @@ +"""File writer enforcing the AUTO-GENERATED-header ownership rule. + +The generator owns only files carrying the AUTO-GENERATED header. It +preflights expected output paths, deletes generated files to clear stale +orphans, and refuses to overwrite a hand-written file at a generated path. +""" + +from pathlib import Path + +from .templates import AUTO_GENERATED_HEADER, AUTO_GENERATED_HEADER_RS + +# The first line of each header is the ownership marker. +_MARKER = AUTO_GENERATED_HEADER.splitlines()[0] +_RS_MARKER = AUTO_GENERATED_HEADER_RS.splitlines()[0] + + +class OwnershipError(Exception): + """Raised when the generator would clobber a hand-written file.""" + + +def _first_line(path: Path) -> str: + with path.open("r", encoding="utf-8") as fh: + return fh.readline().rstrip("\r\n") + + +def is_generated(path: Path) -> bool: + """True if the file at `path` carries the SQL AUTO-GENERATED marker.""" + if not path.is_file(): + return False + return _first_line(path) == _MARKER + + +def is_generated_rs(path: Path) -> bool: + """True if the file at `path` carries the Rust AUTO-GENERATED marker.""" + if not path.is_file(): + return False + return _first_line(path) == _RS_MARKER + + +def clean_generated_files(directory: Path) -> list[Path]: + """Delete every generated .sql file in `directory`. Returns the list + of removed paths. Hand-written files are left untouched. A no-op on a + directory that does not exist or holds no generated files.""" + directory = Path(directory) + if not directory.is_dir(): + return [] + removed: list[Path] = [] + for path in sorted(directory.glob("*.sql")): + if is_generated(path): + path.unlink() + removed.append(path) + return removed + + +def ensure_generated_paths_writable(paths: list[Path]) -> None: + """Refuse a generation run before cleanup if any target is hand-written.""" + for path in paths: + path = Path(path) + if path.exists() and not is_generated(path): + raise OwnershipError( + f"refusing to overwrite hand-written file: {path} " + f"(no AUTO-GENERATED header). Remove it by hand if it is a " + f"one-time generator-adoption target." + ) + + +def write_generated_file(path: Path, body: str) -> None: + """Write `body` to `path`, prefixed with the SQL AUTO-GENERATED header. + + Refuses (OwnershipError) if `path` exists and is hand-written — a file + at a generated path that lacks the header is never clobbered.""" + path = Path(path) + ensure_generated_paths_writable([path]) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(AUTO_GENERATED_HEADER + body, encoding="utf-8") + + +def write_generated_rs(path: Path, body: str) -> None: + """Write `body` to a Rust file, prefixed with the Rust AUTO-GENERATED + header. Unlike the SQL surface this file is committed; the header still + guards against clobbering a hand-written file at the same path.""" + path = Path(path) + if path.exists() and not is_generated_rs(path): + raise OwnershipError( + f"refusing to overwrite hand-written file: {path} " + f"(no AUTO-GENERATED header)." + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(AUTO_GENERATED_HEADER_RS + body, encoding="utf-8") From b0d716dd1c137336b17e0eeb1e11cce6ca4bcb28 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:37 +1000 Subject: [PATCH 02/10] feat(encrypted-domain): eql_v2_int4 variant family Four jsonb-backed domains for encrypted int4: storage-only eql_v2_int4 (every operator blocked), eql_v2_int4_eq (hm; =, <>), and the ordered pair eql_v2_int4_ord / eql_v2_int4_ord_ore (ore; = <> < <= > >=). Domain CHECK pins the envelope version (VALUE->>'v' = '2'). Unsupported operators are error-throwing placeholders carrying an explanatory comment. Uniform extractors eql_v2.eq_term/ord_term keep functional indexes engaging without ::jsonb casts. Committed reference baselines guard byte parity. Part of PR #239. --- src/encrypted_domain/functions.sql | 26 ++ src/ore_block_u64_8_256/operators.sql | 11 + tests/codegen/reference/README.md | 5 + .../reference/int4/int4_eq_functions.sql | 406 ++++++++++++++++++ .../reference/int4/int4_eq_operators.sql | 271 ++++++++++++ .../codegen/reference/int4/int4_functions.sql | 403 +++++++++++++++++ .../codegen/reference/int4/int4_operators.sql | 271 ++++++++++++ .../reference/int4/int4_ord_functions.sql | 395 +++++++++++++++++ .../reference/int4/int4_ord_operators.sql | 271 ++++++++++++ .../reference/int4/int4_ord_ore_functions.sql | 395 +++++++++++++++++ .../reference/int4/int4_ord_ore_operators.sql | 271 ++++++++++++ tests/codegen/reference/int4/int4_types.sql | 72 ++++ 12 files changed, 2797 insertions(+) create mode 100644 src/encrypted_domain/functions.sql create mode 100644 tests/codegen/reference/README.md create mode 100644 tests/codegen/reference/int4/int4_eq_functions.sql create mode 100644 tests/codegen/reference/int4/int4_eq_operators.sql create mode 100644 tests/codegen/reference/int4/int4_functions.sql create mode 100644 tests/codegen/reference/int4/int4_operators.sql create mode 100644 tests/codegen/reference/int4/int4_ord_functions.sql create mode 100644 tests/codegen/reference/int4/int4_ord_operators.sql create mode 100644 tests/codegen/reference/int4/int4_ord_ore_functions.sql create mode 100644 tests/codegen/reference/int4/int4_ord_ore_operators.sql create mode 100644 tests/codegen/reference/int4/int4_types.sql diff --git a/src/encrypted_domain/functions.sql b/src/encrypted_domain/functions.sql new file mode 100644 index 00000000..24b75145 --- /dev/null +++ b/src/encrypted_domain/functions.sql @@ -0,0 +1,26 @@ +-- REQUIRE: src/schema.sql + +--! @file encrypted_domain/functions.sql +--! @brief Shared blocker helper for the eql_v2_int4 domain family. +--! +--! Per-domain wrapper functions live in src/encrypted_domain/int4/. +--! Blockers in those files delegate to encrypted_domain_unsupported_bool +--! so every domain raises a uniform domain-specific error rather than +--! letting an unsupported operator fall through to native jsonb +--! behaviour. + +--! @brief Shared blocker helper. Raises 'operator X is not supported +--! for TYPE' so unsupported domain operators surface a clear +--! error rather than fall through to native jsonb behaviour. +--! @param type_name Domain type name (eql_v2_int4*) +--! @param operator_name Operator symbol (=, <, @>, ->, etc.) +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.encrypted_domain_unsupported_bool(type_name text, operator_name text) +RETURNS boolean +IMMUTABLE PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + RAISE EXCEPTION 'operator % is not supported for %', operator_name, type_name; +END; +$$ LANGUAGE plpgsql; diff --git a/src/ore_block_u64_8_256/operators.sql b/src/ore_block_u64_8_256/operators.sql index e9e34561..06a4fa65 100644 --- a/src/ore_block_u64_8_256/operators.sql +++ b/src/ore_block_u64_8_256/operators.sql @@ -123,10 +123,17 @@ $$; --! @brief = operator for ORE block types +--! +--! COMMUTATOR is the operator itself: equality is symmetric. The clause +--! is required for a MERGES (mergejoinable) operator — without it the +--! planner raises "could not find commutator" the first time an +--! ore_block equality is used as a join qual (e.g. via the inlined +--! eql_v2_int4_ord_ore equality wrappers). CREATE OPERATOR = ( FUNCTION=eql_v2.ore_block_u64_8_256_eq, LEFTARG=eql_v2.ore_block_u64_8_256, RIGHTARG=eql_v2.ore_block_u64_8_256, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel, @@ -137,10 +144,14 @@ CREATE OPERATOR = ( --! @brief <> operator for ORE block types +--! +--! COMMUTATOR is the operator itself: inequality is symmetric. Required +--! alongside the MERGES flag — see the = operator above. CREATE OPERATOR <> ( FUNCTION=eql_v2.ore_block_u64_8_256_neq, LEFTARG=eql_v2.ore_block_u64_8_256, RIGHTARG=eql_v2.ore_block_u64_8_256, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = eqsel, JOIN = eqjoinsel, diff --git a/tests/codegen/reference/README.md b/tests/codegen/reference/README.md new file mode 100644 index 00000000..c1fa5118 --- /dev/null +++ b/tests/codegen/reference/README.md @@ -0,0 +1,5 @@ +# Codegen reference + +The SQL files under `/` are the original, hand-written reference implementation for each encrypted-domain scalar type. + +They are the parity baseline for the generator in `tasks/codegen/`. `tasks/codegen/test_against_reference.py` renders the generator's output and asserts it matches these files byte-for-byte. If the generator diverges, either it regressed (fix `tasks/codegen/`) or the reference is being updated deliberately (commit the new reference in the same PR). diff --git a/tests/codegen/reference/int4/int4_eq_functions.sql b/tests/codegen/reference/int4/int4_eq_functions.sql new file mode 100644 index 00000000..f1fe0d70 --- /dev/null +++ b/tests/codegen/reference/int4/int4_eq_functions.sql @@ -0,0 +1,406 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/functions.sql +-- REQUIRE: src/hmac_256/functions.sql + +--! @file encrypted_domain/int4/int4_eq_functions.sql +--! @brief Equality-only domain of the int4 encrypted-domain family — comparison/path functions. + +--! @brief Index extractor for the eql_v2_int4_eq variant. +--! @param a eql_v2_int4_eq +--! @return eql_v2.hmac_256 +CREATE FUNCTION eql_v2.eq_term(a eql_v2_int4_eq) +RETURNS eql_v2.hmac_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.hmac_256(a::jsonb) $$; + +--! @brief Equality wrapper for eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) = eql_v2.eq_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_eq, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) = eql_v2.eq_term(b::eql_v2_int4_eq) $$; + +--! @brief Equality wrapper for eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a::eql_v2_int4_eq) = eql_v2.eq_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) <> eql_v2.eq_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_eq, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a) <> eql_v2.eq_term(b::eql_v2_int4_eq) $$; + +--! @brief Inequality wrapper for eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4_eq) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.eq_term(a::eql_v2_int4_eq) <> eql_v2.eq_term(b) $$; + +--! @brief Blocker for < on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_eq, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4_eq) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_eq (domain, text). +--! @param a eql_v2_int4_eq +--! @param selector text +--! @return eql_v2_int4_eq (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_eq, selector text) +RETURNS eql_v2_int4_eq IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_eq (domain, integer). +--! @param a eql_v2_int4_eq +--! @param selector integer +--! @return eql_v2_int4_eq (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_eq, selector integer) +RETURNS eql_v2_int4_eq IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_eq +--! @return eql_v2_int4_eq (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4_eq) +RETURNS eql_v2_int4_eq IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_eq (domain, text). +--! @param a eql_v2_int4_eq +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_eq, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_eq (domain, integer). +--! @param a eql_v2_int4_eq +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_eq, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_eq +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4_eq) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ? on eql_v2_int4_eq (domain, text). +--! @param a eql_v2_int4_eq +--! @param b text +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?"(a eql_v2_int4_eq, b text) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?| on eql_v2_int4_eq (domain, text[]). +--! @param a eql_v2_int4_eq +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?|"(a eql_v2_int4_eq, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '?|'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?& on eql_v2_int4_eq (domain, text[]). +--! @param a eql_v2_int4_eq +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?&"(a eql_v2_int4_eq, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '?&'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @? on eql_v2_int4_eq (domain, jsonpath). +--! @param a eql_v2_int4_eq +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@?"(a eql_v2_int4_eq, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @@ on eql_v2_int4_eq (domain, jsonpath). +--! @param a eql_v2_int4_eq +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@@"(a eql_v2_int4_eq, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_eq', '@@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #> on eql_v2_int4_eq (domain, text[]). +--! @param a eql_v2_int4_eq +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#>"(a eql_v2_int4_eq, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #>> on eql_v2_int4_eq (domain, text[]). +--! @param a eql_v2_int4_eq +--! @param b text[] +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."#>>"(a eql_v2_int4_eq, b text[]) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>>', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_eq (domain, text). +--! @param a eql_v2_int4_eq +--! @param b text +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_eq, b text) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_eq (domain, integer). +--! @param a eql_v2_int4_eq +--! @param b integer +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_eq, b integer) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_eq (domain, text[]). +--! @param a eql_v2_int4_eq +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_eq, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #- on eql_v2_int4_eq (domain, text[]). +--! @param a eql_v2_int4_eq +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#-"(a eql_v2_int4_eq, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#-', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_eq. +--! @param a eql_v2_int4_eq +--! @param b eql_v2_int4_eq +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4_eq, b eql_v2_int4_eq) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_eq (domain, jsonb). +--! @param a eql_v2_int4_eq +--! @param b jsonb +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4_eq, b jsonb) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_eq (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_eq +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a jsonb, b eql_v2_int4_eq) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_eq'; END; $$ +LANGUAGE plpgsql; diff --git a/tests/codegen/reference/int4/int4_eq_operators.sql b/tests/codegen/reference/int4/int4_eq_operators.sql new file mode 100644 index 00000000..85d8353c --- /dev/null +++ b/tests/codegen/reference/int4/int4_eq_operators.sql @@ -0,0 +1,271 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_eq_functions.sql + +--! @file encrypted_domain/int4/int4_eq_operators.sql +--! @brief Equality-only domain of the int4 encrypted-domain family — operator declarations. + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +-- Placeholder: this domain's term set does not support <; the backing function always raises. +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support <; the backing function always raises. +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <; the backing function always raises. +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support <=; the backing function always raises. +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support <=; the backing function always raises. +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <=; the backing function always raises. +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support >; the backing function always raises. +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support >; the backing function always raises. +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support >; the backing function always raises. +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support >=; the backing function always raises. +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support >=; the backing function always raises. +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support >=; the backing function always raises. +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_eq, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_eq, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support ?; the backing function always raises. +CREATE OPERATOR ? ( + FUNCTION = eql_v2."?", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ?|; the backing function always raises. +CREATE OPERATOR ?| ( + FUNCTION = eql_v2."?|", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ?&; the backing function always raises. +CREATE OPERATOR ?& ( + FUNCTION = eql_v2."?&", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support @?; the backing function always raises. +CREATE OPERATOR @? ( + FUNCTION = eql_v2."@?", + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support @@; the backing function always raises. +CREATE OPERATOR @@ ( + FUNCTION = eql_v2."@@", + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support #>; the backing function always raises. +CREATE OPERATOR #> ( + FUNCTION = eql_v2."#>", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #>>; the backing function always raises. +CREATE OPERATOR #>> ( + FUNCTION = eql_v2."#>>", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_eq, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #-; the backing function always raises. +CREATE OPERATOR #- ( + FUNCTION = eql_v2."#-", + LEFTARG = eql_v2_int4_eq, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4_eq, RIGHTARG = eql_v2_int4_eq +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4_eq, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_eq +); diff --git a/tests/codegen/reference/int4/int4_functions.sql b/tests/codegen/reference/int4/int4_functions.sql new file mode 100644 index 00000000..27936e1d --- /dev/null +++ b/tests/codegen/reference/int4/int4_functions.sql @@ -0,0 +1,403 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/functions.sql + +--! @file encrypted_domain/int4/int4_functions.sql +--! @brief Storage-only domain of the int4 encrypted-domain family — comparison/path functions. + +--! @brief Blocker for = on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.eq(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for = on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.eq(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for = on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <> on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.neq(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <> on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.neq(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for < on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <= on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for > on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for >= on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '>='); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4 (domain, text). +--! @param a eql_v2_int4 +--! @param selector text +--! @return eql_v2_int4 (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4, selector text) +RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4 (domain, integer). +--! @param a eql_v2_int4 +--! @param selector integer +--! @return eql_v2_int4 (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4, selector integer) +RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4 +--! @return eql_v2_int4 (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4) +RETURNS eql_v2_int4 IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4 (domain, text). +--! @param a eql_v2_int4 +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4 (domain, integer). +--! @param a eql_v2_int4 +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4 +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ? on eql_v2_int4 (domain, text). +--! @param a eql_v2_int4 +--! @param b text +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?"(a eql_v2_int4, b text) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?| on eql_v2_int4 (domain, text[]). +--! @param a eql_v2_int4 +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?|"(a eql_v2_int4, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '?|'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?& on eql_v2_int4 (domain, text[]). +--! @param a eql_v2_int4 +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?&"(a eql_v2_int4, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '?&'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @? on eql_v2_int4 (domain, jsonpath). +--! @param a eql_v2_int4 +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@?"(a eql_v2_int4, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @@ on eql_v2_int4 (domain, jsonpath). +--! @param a eql_v2_int4 +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@@"(a eql_v2_int4, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '@@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #> on eql_v2_int4 (domain, text[]). +--! @param a eql_v2_int4 +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#>"(a eql_v2_int4, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #>> on eql_v2_int4 (domain, text[]). +--! @param a eql_v2_int4 +--! @param b text[] +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."#>>"(a eql_v2_int4, b text[]) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>>', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4 (domain, text). +--! @param a eql_v2_int4 +--! @param b text +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4, b text) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4 (domain, integer). +--! @param a eql_v2_int4 +--! @param b integer +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4, b integer) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4 (domain, text[]). +--! @param a eql_v2_int4 +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #- on eql_v2_int4 (domain, text[]). +--! @param a eql_v2_int4 +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#-"(a eql_v2_int4, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#-', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4. +--! @param a eql_v2_int4 +--! @param b eql_v2_int4 +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4, b eql_v2_int4) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4 (domain, jsonb). +--! @param a eql_v2_int4 +--! @param b jsonb +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4, b jsonb) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4 (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4 +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a jsonb, b eql_v2_int4) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4'; END; $$ +LANGUAGE plpgsql; diff --git a/tests/codegen/reference/int4/int4_operators.sql b/tests/codegen/reference/int4/int4_operators.sql new file mode 100644 index 00000000..fc3dd7cf --- /dev/null +++ b/tests/codegen/reference/int4/int4_operators.sql @@ -0,0 +1,271 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_functions.sql + +--! @file encrypted_domain/int4/int4_operators.sql +--! @brief Storage-only domain of the int4 encrypted-domain family — operator declarations. + +-- Placeholder: this domain's term set does not support =; the backing function always raises. +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support =; the backing function always raises. +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support =; the backing function always raises. +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <>; the backing function always raises. +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <>; the backing function always raises. +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <>; the backing function always raises. +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <; the backing function always raises. +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <; the backing function always raises. +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <; the backing function always raises. +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <=; the backing function always raises. +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <=; the backing function always raises. +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <=; the backing function always raises. +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support >; the backing function always raises. +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support >; the backing function always raises. +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support >; the backing function always raises. +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support >=; the backing function always raises. +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support >=; the backing function always raises. +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support >=; the backing function always raises. +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support ?; the backing function always raises. +CREATE OPERATOR ? ( + FUNCTION = eql_v2."?", + LEFTARG = eql_v2_int4, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ?|; the backing function always raises. +CREATE OPERATOR ?| ( + FUNCTION = eql_v2."?|", + LEFTARG = eql_v2_int4, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ?&; the backing function always raises. +CREATE OPERATOR ?& ( + FUNCTION = eql_v2."?&", + LEFTARG = eql_v2_int4, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support @?; the backing function always raises. +CREATE OPERATOR @? ( + FUNCTION = eql_v2."@?", + LEFTARG = eql_v2_int4, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support @@; the backing function always raises. +CREATE OPERATOR @@ ( + FUNCTION = eql_v2."@@", + LEFTARG = eql_v2_int4, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support #>; the backing function always raises. +CREATE OPERATOR #> ( + FUNCTION = eql_v2."#>", + LEFTARG = eql_v2_int4, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #>>; the backing function always raises. +CREATE OPERATOR #>> ( + FUNCTION = eql_v2."#>>", + LEFTARG = eql_v2_int4, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #-; the backing function always raises. +CREATE OPERATOR #- ( + FUNCTION = eql_v2."#-", + LEFTARG = eql_v2_int4, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4, RIGHTARG = eql_v2_int4 +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4 +); diff --git a/tests/codegen/reference/int4/int4_ord_functions.sql b/tests/codegen/reference/int4/int4_ord_functions.sql new file mode 100644 index 00000000..9d3ba2a2 --- /dev/null +++ b/tests/codegen/reference/int4/int4_ord_functions.sql @@ -0,0 +1,395 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/operators.sql + +--! @file encrypted_domain/int4/int4_ord_functions.sql +--! @brief Ordered domain of the int4 encrypted-domain family — comparison/path functions. + +--! @brief Index extractor for the eql_v2_int4_ord variant. +--! @param a eql_v2_int4_ord +--! @return eql_v2.ore_block_u64_8_256 +CREATE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord) +RETURNS eql_v2.ore_block_u64_8_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ore_block_u64_8_256(a::jsonb) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) = eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) <> eql_v2.ord_term(b) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) < eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) <= eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b::eql_v2_int4_ord) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4_ord) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord) >= eql_v2.ord_term(b) $$; + +--! @brief Blocker for @> on eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4_ord) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord (domain, text). +--! @param a eql_v2_int4_ord +--! @param selector text +--! @return eql_v2_int4_ord (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord, selector text) +RETURNS eql_v2_int4_ord IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord (domain, integer). +--! @param a eql_v2_int4_ord +--! @param selector integer +--! @return eql_v2_int4_ord (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord, selector integer) +RETURNS eql_v2_int4_ord IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord +--! @return eql_v2_int4_ord (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4_ord) +RETURNS eql_v2_int4_ord IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord (domain, text). +--! @param a eql_v2_int4_ord +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord (domain, integer). +--! @param a eql_v2_int4_ord +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4_ord) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ? on eql_v2_int4_ord (domain, text). +--! @param a eql_v2_int4_ord +--! @param b text +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?"(a eql_v2_int4_ord, b text) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?| on eql_v2_int4_ord (domain, text[]). +--! @param a eql_v2_int4_ord +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?|"(a eql_v2_int4_ord, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '?|'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?& on eql_v2_int4_ord (domain, text[]). +--! @param a eql_v2_int4_ord +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?&"(a eql_v2_int4_ord, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '?&'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @? on eql_v2_int4_ord (domain, jsonpath). +--! @param a eql_v2_int4_ord +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@?"(a eql_v2_int4_ord, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @@ on eql_v2_int4_ord (domain, jsonpath). +--! @param a eql_v2_int4_ord +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@@"(a eql_v2_int4_ord, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '@@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #> on eql_v2_int4_ord (domain, text[]). +--! @param a eql_v2_int4_ord +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#>"(a eql_v2_int4_ord, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #>> on eql_v2_int4_ord (domain, text[]). +--! @param a eql_v2_int4_ord +--! @param b text[] +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."#>>"(a eql_v2_int4_ord, b text[]) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>>', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_ord (domain, text). +--! @param a eql_v2_int4_ord +--! @param b text +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_ord, b text) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_ord (domain, integer). +--! @param a eql_v2_int4_ord +--! @param b integer +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_ord, b integer) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_ord (domain, text[]). +--! @param a eql_v2_int4_ord +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_ord, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #- on eql_v2_int4_ord (domain, text[]). +--! @param a eql_v2_int4_ord +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#-"(a eql_v2_int4_ord, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#-', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_ord. +--! @param a eql_v2_int4_ord +--! @param b eql_v2_int4_ord +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4_ord, b eql_v2_int4_ord) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_ord (domain, jsonb). +--! @param a eql_v2_int4_ord +--! @param b jsonb +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4_ord, b jsonb) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_ord (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a jsonb, b eql_v2_int4_ord) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_ord'; END; $$ +LANGUAGE plpgsql; diff --git a/tests/codegen/reference/int4/int4_ord_operators.sql b/tests/codegen/reference/int4/int4_ord_operators.sql new file mode 100644 index 00000000..3e3657f9 --- /dev/null +++ b/tests/codegen/reference/int4/int4_ord_operators.sql @@ -0,0 +1,271 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_functions.sql + +--! @file encrypted_domain/int4/int4_ord_operators.sql +--! @brief Ordered domain of the int4 encrypted-domain family — operator declarations. + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >, NEGATOR = >=, RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = >, NEGATOR = >=, RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >, NEGATOR = >=, RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >=, NEGATOR = >, RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = >=, NEGATOR = >, RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = >=, NEGATOR = >, RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <, NEGATOR = <=, RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = <, NEGATOR = <=, RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <, NEGATOR = <=, RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <=, NEGATOR = <, RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb, + COMMUTATOR = <=, NEGATOR = <, RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord, + COMMUTATOR = <=, NEGATOR = <, RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support ?; the backing function always raises. +CREATE OPERATOR ? ( + FUNCTION = eql_v2."?", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ?|; the backing function always raises. +CREATE OPERATOR ?| ( + FUNCTION = eql_v2."?|", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ?&; the backing function always raises. +CREATE OPERATOR ?& ( + FUNCTION = eql_v2."?&", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support @?; the backing function always raises. +CREATE OPERATOR @? ( + FUNCTION = eql_v2."@?", + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support @@; the backing function always raises. +CREATE OPERATOR @@ ( + FUNCTION = eql_v2."@@", + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support #>; the backing function always raises. +CREATE OPERATOR #> ( + FUNCTION = eql_v2."#>", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #>>; the backing function always raises. +CREATE OPERATOR #>> ( + FUNCTION = eql_v2."#>>", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_ord, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #-; the backing function always raises. +CREATE OPERATOR #- ( + FUNCTION = eql_v2."#-", + LEFTARG = eql_v2_int4_ord, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4_ord, RIGHTARG = eql_v2_int4_ord +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4_ord, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord +); diff --git a/tests/codegen/reference/int4/int4_ord_ore_functions.sql b/tests/codegen/reference/int4/int4_ord_ore_functions.sql new file mode 100644 index 00000000..bd6fe8b4 --- /dev/null +++ b/tests/codegen/reference/int4/int4_ord_ore_functions.sql @@ -0,0 +1,395 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/functions.sql +-- REQUIRE: src/ore_block_u64_8_256/operators.sql + +--! @file encrypted_domain/int4/int4_ord_ore_functions.sql +--! @brief Ordered domain of the int4 encrypted-domain family — comparison/path functions. + +--! @brief Index extractor for the eql_v2_int4_ord_ore variant. +--! @param a eql_v2_int4_ord_ore +--! @return eql_v2.ore_block_u64_8_256 +CREATE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord_ore) +RETURNS eql_v2.ore_block_u64_8_256 +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ore_block_u64_8_256(a::jsonb) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.eq(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) = eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Equality wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.eq(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) = eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.neq(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <> eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Inequality wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.neq(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) <> eql_v2.ord_term(b) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lt(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) < eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Less-than wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lt(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) < eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.lte(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) <= eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Less-than-or-equal wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.lte(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) <= eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gt(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) > eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Greater-than wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gt(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) > eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean +CREATE FUNCTION eql_v2.gte(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a) >= eql_v2.ord_term(b::eql_v2_int4_ord_ore) $$; + +--! @brief Greater-than-or-equal wrapper for eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean +CREATE FUNCTION eql_v2.gte(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT eql_v2.ord_term(a::eql_v2_int4_ord_ore) >= eql_v2.ord_term(b) $$; + +--! @brief Blocker for @> on eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @> on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contains(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@>'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a eql_v2_int4_ord_ore, b jsonb) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for <@ on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2.contained_by(a jsonb, b eql_v2_int4_ord_ore) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '<@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord_ore (domain, text). +--! @param a eql_v2_int4_ord_ore +--! @param selector text +--! @return eql_v2_int4_ord_ore (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord_ore, selector text) +RETURNS eql_v2_int4_ord_ore IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord_ore (domain, integer). +--! @param a eql_v2_int4_ord_ore +--! @param selector integer +--! @return eql_v2_int4_ord_ore (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a eql_v2_int4_ord_ore, selector integer) +RETURNS eql_v2_int4_ord_ore IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for -> on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord_ore +--! @return eql_v2_int4_ord_ore (never returns; always raises) +CREATE FUNCTION eql_v2."->"(a jsonb, selector eql_v2_int4_ord_ore) +RETURNS eql_v2_int4_ord_ore IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord_ore (domain, text). +--! @param a eql_v2_int4_ord_ore +--! @param selector text +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord_ore, selector text) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord_ore (domain, integer). +--! @param a eql_v2_int4_ord_ore +--! @param selector integer +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a eql_v2_int4_ord_ore, selector integer) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ->> on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param selector eql_v2_int4_ord_ore +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."->>"(a jsonb, selector eql_v2_int4_ord_ore) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '->>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ? on eql_v2_int4_ord_ore (domain, text). +--! @param a eql_v2_int4_ord_ore +--! @param b text +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?"(a eql_v2_int4_ord_ore, b text) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?| on eql_v2_int4_ord_ore (domain, text[]). +--! @param a eql_v2_int4_ord_ore +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?|"(a eql_v2_int4_ord_ore, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '?|'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for ?& on eql_v2_int4_ord_ore (domain, text[]). +--! @param a eql_v2_int4_ord_ore +--! @param b text[] +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."?&"(a eql_v2_int4_ord_ore, b text[]) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '?&'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @? on eql_v2_int4_ord_ore (domain, jsonpath). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@?"(a eql_v2_int4_ord_ore, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@?'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for @@ on eql_v2_int4_ord_ore (domain, jsonpath). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonpath +--! @return boolean (never returns; always raises) +CREATE FUNCTION eql_v2."@@"(a eql_v2_int4_ord_ore, b jsonpath) +RETURNS boolean IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord_ore', '@@'); END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #> on eql_v2_int4_ord_ore (domain, text[]). +--! @param a eql_v2_int4_ord_ore +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#>"(a eql_v2_int4_ord_ore, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #>> on eql_v2_int4_ord_ore (domain, text[]). +--! @param a eql_v2_int4_ord_ore +--! @param b text[] +--! @return text (never returns; always raises) +CREATE FUNCTION eql_v2."#>>"(a eql_v2_int4_ord_ore, b text[]) +RETURNS text IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#>>', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_ord_ore (domain, text). +--! @param a eql_v2_int4_ord_ore +--! @param b text +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_ord_ore, b text) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_ord_ore (domain, integer). +--! @param a eql_v2_int4_ord_ore +--! @param b integer +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_ord_ore, b integer) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for - on eql_v2_int4_ord_ore (domain, text[]). +--! @param a eql_v2_int4_ord_ore +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."-"(a eql_v2_int4_ord_ore, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '-', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for #- on eql_v2_int4_ord_ore (domain, text[]). +--! @param a eql_v2_int4_ord_ore +--! @param b text[] +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."#-"(a eql_v2_int4_ord_ore, b text[]) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '#-', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_ord_ore. +--! @param a eql_v2_int4_ord_ore +--! @param b eql_v2_int4_ord_ore +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4_ord_ore, b eql_v2_int4_ord_ore) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_ord_ore (domain, jsonb). +--! @param a eql_v2_int4_ord_ore +--! @param b jsonb +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a eql_v2_int4_ord_ore, b jsonb) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; + +--! @brief Blocker for || on eql_v2_int4_ord_ore (jsonb, domain). +--! @param a jsonb +--! @param b eql_v2_int4_ord_ore +--! @return jsonb (never returns; always raises) +CREATE FUNCTION eql_v2."||"(a jsonb, b eql_v2_int4_ord_ore) +RETURNS jsonb IMMUTABLE PARALLEL SAFE +AS $$ BEGIN RAISE EXCEPTION 'operator % is not supported for %', '||', 'eql_v2_int4_ord_ore'; END; $$ +LANGUAGE plpgsql; diff --git a/tests/codegen/reference/int4/int4_ord_ore_operators.sql b/tests/codegen/reference/int4/int4_ord_ore_operators.sql new file mode 100644 index 00000000..ee1f84cf --- /dev/null +++ b/tests/codegen/reference/int4/int4_ord_ore_operators.sql @@ -0,0 +1,271 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_ore_functions.sql + +--! @file encrypted_domain/int4/int4_ord_ore_operators.sql +--! @brief Ordered domain of the int4 encrypted-domain family — operator declarations. + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR = ( + FUNCTION = eql_v2.eq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = =, NEGATOR = <>, RESTRICT = eqsel, JOIN = eqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR <> ( + FUNCTION = eql_v2.neq, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <>, NEGATOR = =, RESTRICT = neqsel, JOIN = neqjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >, NEGATOR = >=, RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = >, NEGATOR = >=, RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR < ( + FUNCTION = eql_v2.lt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >, NEGATOR = >=, RESTRICT = scalarltsel, JOIN = scalarltjoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >=, NEGATOR = >, RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = >=, NEGATOR = >, RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR <= ( + FUNCTION = eql_v2.lte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = >=, NEGATOR = >, RESTRICT = scalarlesel, JOIN = scalarlejoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <, NEGATOR = <=, RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = <, NEGATOR = <=, RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR > ( + FUNCTION = eql_v2.gt, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <, NEGATOR = <=, RESTRICT = scalargtsel, JOIN = scalargtjoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <=, NEGATOR = <, RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb, + COMMUTATOR = <=, NEGATOR = <, RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +CREATE OPERATOR >= ( + FUNCTION = eql_v2.gte, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore, + COMMUTATOR = <=, NEGATOR = <, RESTRICT = scalargesel, JOIN = scalargejoinsel +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support @>; the backing function always raises. +CREATE OPERATOR @> ( + FUNCTION = eql_v2.contains, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support <@; the backing function always raises. +CREATE OPERATOR <@ ( + FUNCTION = eql_v2.contained_by, + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->; the backing function always raises. +CREATE OPERATOR -> ( + FUNCTION = eql_v2."->", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support ->>; the backing function always raises. +CREATE OPERATOR ->> ( + FUNCTION = eql_v2."->>", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support ?; the backing function always raises. +CREATE OPERATOR ? ( + FUNCTION = eql_v2."?", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support ?|; the backing function always raises. +CREATE OPERATOR ?| ( + FUNCTION = eql_v2."?|", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ?&; the backing function always raises. +CREATE OPERATOR ?& ( + FUNCTION = eql_v2."?&", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support @?; the backing function always raises. +CREATE OPERATOR @? ( + FUNCTION = eql_v2."@?", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support @@; the backing function always raises. +CREATE OPERATOR @@ ( + FUNCTION = eql_v2."@@", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonpath +); + +-- Placeholder: this domain's term set does not support #>; the backing function always raises. +CREATE OPERATOR #> ( + FUNCTION = eql_v2."#>", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #>>; the backing function always raises. +CREATE OPERATOR #>> ( + FUNCTION = eql_v2."#>>", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = integer +); + +-- Placeholder: this domain's term set does not support -; the backing function always raises. +CREATE OPERATOR - ( + FUNCTION = eql_v2."-", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support #-; the backing function always raises. +CREATE OPERATOR #- ( + FUNCTION = eql_v2."#-", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = text[] +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = eql_v2_int4_ord_ore +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = eql_v2_int4_ord_ore, RIGHTARG = jsonb +); + +-- Placeholder: this domain's term set does not support ||; the backing function always raises. +CREATE OPERATOR || ( + FUNCTION = eql_v2."||", + LEFTARG = jsonb, RIGHTARG = eql_v2_int4_ord_ore +); diff --git a/tests/codegen/reference/int4/int4_types.sql b/tests/codegen/reference/int4/int4_types.sql new file mode 100644 index 00000000..f7616539 --- /dev/null +++ b/tests/codegen/reference/int4/int4_types.sql @@ -0,0 +1,72 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql + +--! @file encrypted_domain/int4/int4_types.sql +--! @brief Encrypted-domain type family for int4. + +DO $$ +BEGIN + --! @brief Storage-only encrypted int4 domain. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4 AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'c' + AND VALUE->>'v' = '2' + ); + END IF; + + --! @brief Equality-only encrypted int4 domain. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4_eq' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4_eq AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'c' + AND VALUE ? 'hm' + AND VALUE->>'v' = '2' + ); + END IF; + + --! @brief Ordered encrypted int4 domain. Scheme-explicit twin pinning the ore scheme; prefer the converged int4_ord name. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4_ord_ore' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4_ord_ore AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'c' + AND VALUE ? 'ob' + AND VALUE->>'v' = '2' + ); + END IF; + + --! @brief Ordered encrypted int4 domain. Recommended converged name for this role. + IF NOT EXISTS ( + SELECT 1 FROM pg_type + WHERE typname = 'eql_v2_int4_ord' AND typnamespace = 'public'::regnamespace + ) THEN + CREATE DOMAIN public.eql_v2_int4_ord AS jsonb + CHECK ( + jsonb_typeof(VALUE) = 'object' + AND VALUE ? 'v' + AND VALUE ? 'i' + AND VALUE ? 'c' + AND VALUE ? 'ob' + AND VALUE->>'v' = '2' + ); + END IF; +END +$$; From fa528a40392ba5df625484f633dec5ec2f041e3a Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:37 +1000 Subject: [PATCH 03/10] feat(aggregates): per-domain MIN/MAX with parallel aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MIN/MAX aggregates on ord-capable int4 domains route comparison through the domain's ORE operators — no decryption. min/max are associative, so the state function doubles as combinefunc; with PARALLEL SAFE sfuncs and parallel = safe, PostgreSQL can use partial/parallel aggregation on the large GROUP BY workloads these aggregates exist to serve. Part of PR #239. --- .../reference/int4/int4_ord_aggregates.sql | 86 +++++++++++++++++++ .../int4/int4_ord_ore_aggregates.sql | 86 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/codegen/reference/int4/int4_ord_aggregates.sql create mode 100644 tests/codegen/reference/int4/int4_ord_ore_aggregates.sql diff --git a/tests/codegen/reference/int4/int4_ord_aggregates.sql b/tests/codegen/reference/int4/int4_ord_aggregates.sql new file mode 100644 index 00000000..52a64ec1 --- /dev/null +++ b/tests/codegen/reference/int4/int4_ord_aggregates.sql @@ -0,0 +1,86 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_functions.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_operators.sql + +--! @file encrypted_domain/int4/int4_ord_aggregates.sql +--! @brief Ordered domain of the int4 encrypted-domain family — MIN/MAX aggregates. + +--! @brief State function for min aggregate on eql_v2_int4_ord. +--! @internal +--! +--! @param state eql_v2_int4_ord running extremum +--! @param value eql_v2_int4_ord next non-NULL value +--! @return eql_v2_int4_ord the minimum of state and value +-- LANGUAGE plpgsql, not sql: aggregate state functions are not index +-- expressions, so opacity to the planner is fine, and a multi-statement +-- BEGIN/IF/END body is the natural shape. (A LANGUAGE sql CASE would +-- also work, but the procedural form mirrors the blocker convention.) +CREATE FUNCTION eql_v2.min_sfunc(state eql_v2_int4_ord, value eql_v2_int4_ord) +RETURNS eql_v2_int4_ord +LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + IF value < state THEN + RETURN value; + END IF; + RETURN state; +END; +$$; + +--! @brief Find the minimum encrypted value in a group of eql_v2_int4_ord values. +--! +--! Comparison routes through the domain's `<` operator, which uses the ORE block term — no decryption. +--! +--! @param input eql_v2_int4_ord encrypted values to aggregate +--! @return eql_v2_int4_ord minimum of the group, or NULL if all inputs are NULL +-- combinefunc = sfunc: min/max are associative, so merging two partial +-- extrema is the same comparison. PARALLEL SAFE enables partial and +-- parallel aggregation on large GROUP BY workloads, with no decryption. +CREATE AGGREGATE eql_v2.min(eql_v2_int4_ord) ( + sfunc = eql_v2.min_sfunc, + stype = eql_v2_int4_ord, + combinefunc = eql_v2.min_sfunc, + parallel = safe +); + +--! @brief State function for max aggregate on eql_v2_int4_ord. +--! @internal +--! +--! @param state eql_v2_int4_ord running extremum +--! @param value eql_v2_int4_ord next non-NULL value +--! @return eql_v2_int4_ord the maximum of state and value +-- LANGUAGE plpgsql, not sql: aggregate state functions are not index +-- expressions, so opacity to the planner is fine, and a multi-statement +-- BEGIN/IF/END body is the natural shape. (A LANGUAGE sql CASE would +-- also work, but the procedural form mirrors the blocker convention.) +CREATE FUNCTION eql_v2.max_sfunc(state eql_v2_int4_ord, value eql_v2_int4_ord) +RETURNS eql_v2_int4_ord +LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + IF value > state THEN + RETURN value; + END IF; + RETURN state; +END; +$$; + +--! @brief Find the maximum encrypted value in a group of eql_v2_int4_ord values. +--! +--! Comparison routes through the domain's `>` operator, which uses the ORE block term — no decryption. +--! +--! @param input eql_v2_int4_ord encrypted values to aggregate +--! @return eql_v2_int4_ord maximum of the group, or NULL if all inputs are NULL +-- combinefunc = sfunc: min/max are associative, so merging two partial +-- extrema is the same comparison. PARALLEL SAFE enables partial and +-- parallel aggregation on large GROUP BY workloads, with no decryption. +CREATE AGGREGATE eql_v2.max(eql_v2_int4_ord) ( + sfunc = eql_v2.max_sfunc, + stype = eql_v2_int4_ord, + combinefunc = eql_v2.max_sfunc, + parallel = safe +); diff --git a/tests/codegen/reference/int4/int4_ord_ore_aggregates.sql b/tests/codegen/reference/int4/int4_ord_ore_aggregates.sql new file mode 100644 index 00000000..f2f1e81e --- /dev/null +++ b/tests/codegen/reference/int4/int4_ord_ore_aggregates.sql @@ -0,0 +1,86 @@ +-- REFERENCE: hand-written parity baseline for tasks/codegen/ — see ../README.md +-- REQUIRE: src/schema.sql +-- REQUIRE: src/encrypted_domain/int4/int4_types.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_ore_functions.sql +-- REQUIRE: src/encrypted_domain/int4/int4_ord_ore_operators.sql + +--! @file encrypted_domain/int4/int4_ord_ore_aggregates.sql +--! @brief Ordered domain of the int4 encrypted-domain family — MIN/MAX aggregates. + +--! @brief State function for min aggregate on eql_v2_int4_ord_ore. +--! @internal +--! +--! @param state eql_v2_int4_ord_ore running extremum +--! @param value eql_v2_int4_ord_ore next non-NULL value +--! @return eql_v2_int4_ord_ore the minimum of state and value +-- LANGUAGE plpgsql, not sql: aggregate state functions are not index +-- expressions, so opacity to the planner is fine, and a multi-statement +-- BEGIN/IF/END body is the natural shape. (A LANGUAGE sql CASE would +-- also work, but the procedural form mirrors the blocker convention.) +CREATE FUNCTION eql_v2.min_sfunc(state eql_v2_int4_ord_ore, value eql_v2_int4_ord_ore) +RETURNS eql_v2_int4_ord_ore +LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + IF value < state THEN + RETURN value; + END IF; + RETURN state; +END; +$$; + +--! @brief Find the minimum encrypted value in a group of eql_v2_int4_ord_ore values. +--! +--! Comparison routes through the domain's `<` operator, which uses the ORE block term — no decryption. +--! +--! @param input eql_v2_int4_ord_ore encrypted values to aggregate +--! @return eql_v2_int4_ord_ore minimum of the group, or NULL if all inputs are NULL +-- combinefunc = sfunc: min/max are associative, so merging two partial +-- extrema is the same comparison. PARALLEL SAFE enables partial and +-- parallel aggregation on large GROUP BY workloads, with no decryption. +CREATE AGGREGATE eql_v2.min(eql_v2_int4_ord_ore) ( + sfunc = eql_v2.min_sfunc, + stype = eql_v2_int4_ord_ore, + combinefunc = eql_v2.min_sfunc, + parallel = safe +); + +--! @brief State function for max aggregate on eql_v2_int4_ord_ore. +--! @internal +--! +--! @param state eql_v2_int4_ord_ore running extremum +--! @param value eql_v2_int4_ord_ore next non-NULL value +--! @return eql_v2_int4_ord_ore the maximum of state and value +-- LANGUAGE plpgsql, not sql: aggregate state functions are not index +-- expressions, so opacity to the planner is fine, and a multi-statement +-- BEGIN/IF/END body is the natural shape. (A LANGUAGE sql CASE would +-- also work, but the procedural form mirrors the blocker convention.) +CREATE FUNCTION eql_v2.max_sfunc(state eql_v2_int4_ord_ore, value eql_v2_int4_ord_ore) +RETURNS eql_v2_int4_ord_ore +LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +SET search_path = pg_catalog, extensions, public +AS $$ +BEGIN + IF value > state THEN + RETURN value; + END IF; + RETURN state; +END; +$$; + +--! @brief Find the maximum encrypted value in a group of eql_v2_int4_ord_ore values. +--! +--! Comparison routes through the domain's `>` operator, which uses the ORE block term — no decryption. +--! +--! @param input eql_v2_int4_ord_ore encrypted values to aggregate +--! @return eql_v2_int4_ord_ore maximum of the group, or NULL if all inputs are NULL +-- combinefunc = sfunc: min/max are associative, so merging two partial +-- extrema is the same comparison. PARALLEL SAFE enables partial and +-- parallel aggregation on large GROUP BY workloads, with no decryption. +CREATE AGGREGATE eql_v2.max(eql_v2_int4_ord_ore) ( + sfunc = eql_v2.max_sfunc, + stype = eql_v2_int4_ord_ore, + combinefunc = eql_v2.max_sfunc, + parallel = safe +); From 4f143f58160a27790d553d7220b09b0a464a6971 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:37 +1000 Subject: [PATCH 04/10] feat(lint): encrypted-domain lint rules Add blocker_language, blocker_strict, domain_over_domain, and domain_opclass structural lints enforcing the encrypted-domain footguns (blockers must be plpgsql and non-STRICT; no domain-over-domain; no opclass on a domain). pin_search_path recognises the converged extractor/wrapper names intrinsically. Part of PR #239. --- src/lint/lints.sql | 147 ++++++++++++++++++++++++++++++++++++++ tasks/pin_search_path.sql | 55 ++++++++++++-- 2 files changed, 195 insertions(+), 7 deletions(-) diff --git a/src/lint/lints.sql b/src/lint/lints.sql index 12ffea00..b378f1bb 100644 --- a/src/lint/lints.sql +++ b/src/lint/lints.sql @@ -38,6 +38,24 @@ --! but its body invokes a non-inlinable function --! (depth 1; the planner can't peek through --! that boundary). +--! `blocker_language` — encrypted-domain blocker is not LANGUAGE +--! plpgsql. The planner can inline / elide a +--! LANGUAGE sql body when the result is +--! provably unused, silently bypassing the +--! RAISE that the blocker exists to perform. +--! `blocker_strict` — encrypted-domain blocker is STRICT. +--! PostgreSQL skips the body and returns NULL +--! on NULL arguments, silently bypassing the +--! RAISE. +--! `domain_over_domain` — an `eql_v2_*` domain is derived from another +--! `eql_v2_*` domain rather than jsonb. +--! Operators resolve against the ultimate base +--! type, so the derived domain does not +--! inherit the base domain's blocker surface. +--! `domain_opclass` — an operator class is declared FOR TYPE on an +--! `eql_v2_*` domain. Opclasses on domains +--! bypass operator resolution; use a +--! functional index on the extractor instead. --! --! @example --! ``` @@ -85,6 +103,7 @@ AS $$ eo.opname, eo.lhs, eo.rhs, + eo.implfunc AS impl_oid, eo.impl_signature::text AS impl_signature, lang_l.lanname AS lang, p.provolatile AS volatility, @@ -94,6 +113,39 @@ AS $$ FROM eql_operators eo JOIN pg_proc p ON p.oid = eo.implfunc JOIN pg_language lang_l ON lang_l.oid = p.prolang + ), + + -- Encrypted-domain blockers: functions in `eql_v2` whose body contains + -- one of the two blocker markers emitted by the codegen + -- (`encrypted_domain_unsupported_bool` for boolean blockers; the literal + -- `is not supported for` for path-operator blockers) AND that take at + -- least one `public.eql_v2_*` domain over jsonb argument. The argument + -- filter excludes the shared `encrypted_domain_unsupported_bool(text, + -- text)` helper itself, which contains the marker in its body but is + -- not a blocker. + encrypted_domain_blockers AS ( + SELECT + p.oid AS oid, + p.oid::regprocedure::text AS signature, + lang_l.lanname AS lang, + p.proisstrict AS isstrict + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language lang_l ON lang_l.oid = p.prolang + WHERE n.nspname = 'eql_v2' + AND (p.prosrc LIKE '%encrypted_domain_unsupported_bool%' + OR p.prosrc LIKE '%is not supported for%') + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(p.proargtypes::oid[]) AS arg(typ) + JOIN pg_catalog.pg_type dt ON dt.oid = arg.typ + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND dt.typname LIKE 'eql_v2\_%' + AND bt.typname = 'jsonb' + ) ) -- ┌─────────────────────────────────────────────────────────────────┐ @@ -113,6 +165,10 @@ AS $$ lang, opname) AS message FROM op_impl WHERE lang <> 'sql' + AND NOT EXISTS ( + SELECT 1 FROM encrypted_domain_blockers b + WHERE b.oid = op_impl.impl_oid + ) UNION ALL @@ -125,6 +181,10 @@ AS $$ opname) FROM op_impl WHERE volatility = 'v' + AND NOT EXISTS ( + SELECT 1 FROM encrypted_domain_blockers b + WHERE b.oid = op_impl.impl_oid + ) UNION ALL @@ -136,6 +196,10 @@ AS $$ 'Operator implementation function has a `SET` clause (e.g. `SET search_path = ...`). Per Postgres function-inlining rules, any `SET` clause blocks inlining. Use schema-qualified identifiers in the body and remove the `SET` clause to allow the planner to inline.') FROM op_impl WHERE config IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM encrypted_domain_blockers b + WHERE b.oid = op_impl.impl_oid + ) UNION ALL @@ -146,6 +210,10 @@ AS $$ 'Operator implementation function is `SECURITY DEFINER`. Such functions cannot be inlined; remove `SECURITY DEFINER` or use a non-inlinable wrapper layer.' FROM op_impl WHERE secdef + AND NOT EXISTS ( + SELECT 1 FROM encrypted_domain_blockers b + WHERE b.oid = op_impl.impl_oid + ) -- ┌─────────────────────────────────────────────────────────────────┐ -- │ Transitive inlinability: an operator implementation function │ @@ -201,6 +269,85 @@ AS $$ OR called.prosecdef ) + -- ┌─────────────────────────────────────────────────────────────────┐ + -- │ Encrypted-domain footguns: blockers exist to RAISE, so they │ + -- │ have inverted inlinability requirements vs operator impls. │ + -- │ A LANGUAGE sql blocker can be elided by the planner; a STRICT │ + -- │ blocker returns NULL on NULL args. Both silently re-enable │ + -- │ operators the storage variant is supposed to block. │ + -- └─────────────────────────────────────────────────────────────────┘ + + UNION ALL + + SELECT + 'error', + 'blocker_language', + format('function %s', signature), + format( + 'Encrypted-domain blocker is `LANGUAGE %s`; must be `LANGUAGE plpgsql` so the RAISE is opaque to the planner. A `LANGUAGE sql` body is inlinable and may be elided when the result is provably unused, silently re-enabling the operator.', + lang) + FROM encrypted_domain_blockers + WHERE lang <> 'plpgsql' + + UNION ALL + + SELECT + 'error', + 'blocker_strict', + format('function %s', signature), + 'Encrypted-domain blocker is `STRICT`. PostgreSQL skips the body and returns NULL on a NULL argument, silently bypassing the RAISE. Remove `STRICT`.' + FROM encrypted_domain_blockers + WHERE isstrict + + -- ┌─────────────────────────────────────────────────────────────────┐ + -- │ Domain identity: an eql_v2_* domain must be defined directly │ + -- │ over jsonb. Operators resolve against the ultimate base type, │ + -- │ so domain-over-domain inherits jsonb's operator surface and not │ + -- │ the base domain's blockers. │ + -- └─────────────────────────────────────────────────────────────────┘ + + UNION ALL + + SELECT + 'error', + 'domain_over_domain', + format('domain %I.%I', dn.nspname, dt.typname), + format( + 'Domain `%s.%s` is derived from another eql_v2_* domain `%s.%s` rather than jsonb. Operators resolve against the ultimate base type, so the derived domain does not inherit the base domain''s operator surface and storage blockers do not engage. Define this domain directly over jsonb.', + dn.nspname, dt.typname, bn.nspname, bt.typname) + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype + JOIN pg_catalog.pg_namespace bn ON bn.oid = bt.typnamespace + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND dt.typname LIKE 'eql_v2\_%' + AND bt.typtype = 'd' + AND bt.typname LIKE 'eql_v2\_%' + + -- ┌─────────────────────────────────────────────────────────────────┐ + -- │ Domain opclass: an operator class declared FOR TYPE on an │ + -- │ eql_v2_* domain bypasses operator resolution at index time. │ + -- │ Use a functional index on the extractor instead. │ + -- └─────────────────────────────────────────────────────────────────┘ + + UNION ALL + + SELECT + 'error', + 'domain_opclass', + format('opclass %I.%I FOR TYPE %s.%s', cn.nspname, oc.opcname, tn.nspname, t.typname), + format( + 'Operator class `%s.%s` is declared FOR TYPE `%s.%s`, which is an eql_v2_* domain. Opclasses on domains bypass operator resolution. Use a functional index on the extractor (e.g. `eql_v2.eq_term(col)`, `eql_v2.ord_term(col)`) instead.', + cn.nspname, oc.opcname, tn.nspname, t.typname) + FROM pg_catalog.pg_opclass oc + JOIN pg_catalog.pg_type t ON t.oid = oc.opcintype + JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace + JOIN pg_catalog.pg_namespace cn ON cn.oid = oc.opcnamespace + WHERE t.typtype = 'd' + AND tn.nspname = 'public' + AND t.typname LIKE 'eql_v2\_%' + ORDER BY 1, 2, 3; $$; diff --git a/tasks/pin_search_path.sql b/tasks/pin_search_path.sql index 8369589e..168a9478 100644 --- a/tasks/pin_search_path.sql +++ b/tasks/pin_search_path.sql @@ -215,13 +215,16 @@ BEGIN OR p.proargtypes[1] = (SELECT t.oid FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'pg_catalog' AND t.typname = 'int4'))) - -- XOR-aware equality term extractor on a ste_vec entry. Must - -- inline so `eql_v2.eq_term(col -> 'sel')` folds into the - -- calling query and matches a functional hash index built on - -- the same expression. - OR (p.pronargs = 1 - AND p.proname = 'eq_term' - AND p.proargtypes[0] = entry_oid) + -- Equality-term and order-term extractors — `eq_term` / `ord_term` + -- on a ste_vec entry and on the encrypted-domain family. Must + -- inline so `eql_v2.eq_term(col)` / `eql_v2.ord_term(col)` fold + -- into the calling query and match a functional index built on the + -- same expression. Name-only match (any arity-1 overload). The + -- encrypted-domain overloads are also covered by the identity + -- predicate's structural skip in the pin loop; these name-only + -- clauses are kept as belt-and-suspenders. + OR (p.pronargs = 1 AND p.proname = 'eq_term') + OR (p.pronargs = 1 AND p.proname = 'ord_term') -- Type-safe `@>` / `<@` overloads with typed needles -- (`stevec_query`, `ste_vec_entry`). Inline to the existing -- `ste_vec_contains` machinery — must stay unpinned to engage @@ -259,6 +262,44 @@ BEGIN WHERE c LIKE 'search_path=%' ) AND NOT (p.oid = ANY (coalesce(inline_critical_oids, '{}'::oid[]))) + -- Encrypted-domain family — structural skip (hybrid primary mechanism). + -- A new encrypted-domain type needs NO edit here: its inline-critical + -- extractors and comparison wrappers are recognised by the identity + -- predicate — LANGUAGE sql, IMMUTABLE, and taking at least one argument + -- typed as a jsonb-backed DOMAIN in `public` named `eql_v2_*`. The + -- predicate is proconfig-independent: the outer loop has already + -- excluded any function with a pinned `search_path`, so the only + -- functions reaching here are unpinned. This catches no core function: + -- `eql_v2_encrypted` is a composite type (not a domain), `ste_vec_entry` + -- is a domain in `eql_v2` (not `public`), and `hmac_256` is a domain + -- over `text` (not `jsonb`). + AND NOT ( + p.prolang = (SELECT l.oid FROM pg_catalog.pg_language l + WHERE l.lanname = 'sql') + AND p.provolatile = 'i' + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(p.proargtypes::oid[]) AS arg(typ) + JOIN pg_catalog.pg_type dt ON dt.oid = arg.typ + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND dt.typname LIKE 'eql_v2\_%' + AND dt.typbasetype = jsonb_oid + ) + ) + -- Encrypted-domain family — comment-marker fallback. Covers a + -- hand-written extension function that is inline-critical but takes no + -- domain argument (invisible to the identity predicate). The generator + -- does NOT emit this marker — every function it produces takes a domain + -- argument and is covered by the structural skip above. The marker is a + -- manual opt-in for hand-written extension functions only. + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_description d + WHERE d.objoid = p.oid + AND d.classoid = 'pg_catalog.pg_proc'::regclass + AND d.description LIKE 'eql-inline-critical%' + ) LOOP -- oid::regprocedure renders as `schema.name(argtype, argtype)` and is a -- valid target for ALTER FUNCTION regardless of caller search_path. From 0715c80863e2c8d3f2706f48a8ae48aa1f44cf13 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:52 +1000 Subject: [PATCH 05/10] test(encrypted-domain): SQLx scalar matrix, fixtures & jsonb-surface guard One ScalarType impl plus an ordered_numeric_matrix! invocation generates the full SQLx suite for a scalar. Fixture values are single-sourced from the manifest. Coverage includes: - always-on cost-preference proof (~5000 rows, enable_seqscan ON) that asserts on the EXPLAIN node type (Index/Index Only/Bitmap Index Scan), not an index-name substring; eq_count pinned to == 1 so the derived <> count is load-bearing - a live-DB structural guard querying pg_operator that fails if any native jsonb operator is absent from the generator's blocked surface - ORDER BY NULLS FIRST/LAST coverage for ordered domains Part of PR #239. --- tasks/fixtures.toml | 6 +- tasks/test.sh | 12 +- tasks/test/splinter.sh | 32 +- tests/codegen/reference/int4/int4_values.rs | 28 + tests/sqlx/Cargo.lock | 7 + tests/sqlx/Cargo.toml | 8 + tests/sqlx/fixtures/FIXTURE_SCHEMA.md | 3 +- tests/sqlx/snapshots/int4_matrix_tests.txt | 211 ++ tests/sqlx/src/assertions.rs | 48 + tests/sqlx/src/fixtures/cipherstash.rs | 214 +- tests/sqlx/src/fixtures/driver.rs | 99 +- tests/sqlx/src/fixtures/eql_plaintext.rs | 11 +- tests/sqlx/src/fixtures/eql_v2_int4.rs | 35 +- tests/sqlx/src/fixtures/index_kind.rs | 59 + tests/sqlx/src/fixtures/int4_values.rs | 31 + tests/sqlx/src/fixtures/mod.rs | 13 +- tests/sqlx/src/fixtures/spec.rs | 43 +- tests/sqlx/src/helpers.rs | 13 + tests/sqlx/src/lib.rs | 15 +- tests/sqlx/src/matrix.rs | 2745 +++++++++++++++++ tests/sqlx/src/scalar_domains.rs | 308 ++ tests/sqlx/tests/aggregate_tests.rs | 18 +- tests/sqlx/tests/constraint_tests.rs | 127 +- tests/sqlx/tests/encrypted_domain.rs | 12 + .../encrypted_domain/family/inlinability.rs | 252 ++ .../family/jsonb_operator_surface.rs | 75 + .../sqlx/tests/encrypted_domain/family/mod.rs | 7 + .../encrypted_domain/family/mutations.rs | 428 +++ .../tests/encrypted_domain/family/support.rs | 329 ++ .../tests/encrypted_domain/scalars/int4.rs | 14 + .../tests/encrypted_domain/scalars/mod.rs | 4 + tests/sqlx/tests/eql_v2_int4_fixture_tests.rs | 40 +- tests/sqlx/tests/lint_tests.rs | 262 +- 33 files changed, 5191 insertions(+), 318 deletions(-) create mode 100644 tests/codegen/reference/int4/int4_values.rs create mode 100644 tests/sqlx/snapshots/int4_matrix_tests.txt create mode 100644 tests/sqlx/src/fixtures/index_kind.rs create mode 100644 tests/sqlx/src/fixtures/int4_values.rs create mode 100644 tests/sqlx/src/matrix.rs create mode 100644 tests/sqlx/src/scalar_domains.rs create mode 100644 tests/sqlx/tests/encrypted_domain.rs create mode 100644 tests/sqlx/tests/encrypted_domain/family/inlinability.rs create mode 100644 tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs create mode 100644 tests/sqlx/tests/encrypted_domain/family/mod.rs create mode 100644 tests/sqlx/tests/encrypted_domain/family/mutations.rs create mode 100644 tests/sqlx/tests/encrypted_domain/family/support.rs create mode 100644 tests/sqlx/tests/encrypted_domain/scalars/int4.rs create mode 100644 tests/sqlx/tests/encrypted_domain/scalars/mod.rs diff --git a/tasks/fixtures.toml b/tasks/fixtures.toml index 808200cd..a9804f8b 100644 --- a/tasks/fixtures.toml +++ b/tasks/fixtures.toml @@ -15,8 +15,12 @@ description = "Generate a SQLx fixture script via cipherstash-client" dir = "{{config_root}}/tests/sqlx" run = """ fixture="{{arg(name="fixture")}}" +# Match the Rust `FixtureIdentifier` rule: `^[a-z][a-z0-9_]*$`. Reject +# empty, leading-digit, and any non-lowercase-alphanumeric-underscore +# input here so the failure mode is a clear shell error rather than a +# Rust panic during the cargo test invocation. case "$fixture" in - (*[!a-z0-9_]*|'') echo "Invalid fixture name: $fixture (expected [a-z0-9_]+)" >&2; exit 1 ;; + (''|[0-9]*|*[!a-z0-9_]*) echo "Invalid fixture name: $fixture (expected ^[a-z][a-z0-9_]*$)" >&2; exit 1 ;; esac cargo test --features fixture-gen --lib \ diff --git a/tasks/test.sh b/tasks/test.sh index 2e7988e9..806d6e99 100755 --- a/tasks/test.sh +++ b/tasks/test.sh @@ -22,17 +22,24 @@ echo "" echo "Building EQL..." mise run --output prefix --force build +# Run encrypted-domain codegen generator tests +echo "" +echo "==============================================" +echo "1/3: Running encrypted-domain codegen tests" +echo "==============================================" +mise run --output prefix test:codegen + # Run lints on sqlx tests echo "" echo "==============================================" -echo "1/2: Running linting checks on SQLx Rust tests" +echo "2/3: Running linting checks on SQLx Rust tests" echo "==============================================" mise run --output prefix test:lint # Run SQLx Rust tests echo "" echo "==============================================" -echo "2/2: Running SQLx Rust Tests" +echo "3/3: Running SQLx Rust Tests" echo "==============================================" mise run --output prefix test:sqlx @@ -42,6 +49,7 @@ echo "✅ ALL TESTS PASSED" echo "==============================================" echo "" echo "Summary:" +echo " ✓ Encrypted-domain codegen tests" echo " ✓ SQLx Rust lint checks" echo " ✓ SQLx Rust tests" echo "" diff --git a/tasks/test/splinter.sh b/tasks/test/splinter.sh index dae147d6..6c203233 100755 --- a/tasks/test/splinter.sh +++ b/tasks/test/splinter.sh @@ -9,6 +9,9 @@ set -euo pipefail +# Scope: only findings in EQL-owned schemas are gated. +EQL_OWNED_SCHEMAS="('eql_v2')" + # Pinned to splinter main as of 2026-04-27. Bump intentionally. SPLINTER_SHA="55db5b1f28e58d816f7d9136eed87eabcd95868d" SPLINTER_URL="https://raw.githubusercontent.com/supabase/splinter/${SPLINTER_SHA}/splinter.sql" @@ -81,12 +84,12 @@ function_search_path_mutable eql_v2 jsonb_contained_by function GIN-inlining: sa function_search_path_mutable eql_v2 ore_cllw function Consolidated ORE-CLLW extractor (U-006): inlinable SQL so the planner can fold `eql_v2.ore_cllw(col -> 'sel')` calls into the calling query. SET search_path would silently undo the inlining and prevent functional-index match through the extractor form. Two overloads: (jsonb), (eql_v2.ste_vec_entry). function_search_path_mutable eql_v2 has_ore_cllw function Consolidated ORE-CLLW presence check (U-006): inlinable SQL counterpart to `eql_v2.ore_cllw`. Same rationale as `ore_cllw` — must stay unpinned to inline into the calling query. Two overloads: (jsonb), (eql_v2.ste_vec_entry). function_search_path_mutable eql_v2 selector function STE-vec entry selector extractor (#219): typed (eql_v2.ste_vec_entry) overload, inlinable so the planner can fold `eql_v2.selector(col -> 'sel')` into the calling query. -function_search_path_mutable eql_v2 eq function Equality backing function for `eql_v2.ste_vec_entry × eql_v2.ste_vec_entry` (#219). Inlines to `hmac_256(a) = hmac_256(b)`; the `=` operator must reach the functional hash index on `eql_v2.hmac_256(col -> 'sel')` for bare-form field equality to engage Index Scan. -function_search_path_mutable eql_v2 neq function Inequality backing function for `eql_v2.ste_vec_entry`. Same rationale as `eq`. -function_search_path_mutable eql_v2 lt function Less-than backing function for `eql_v2.ste_vec_entry`. Inlines to `ore_cllw(a) < ore_cllw(b)`; must reach the functional btree opclass on `eql_v2.ore_cllw` for ordered field queries to engage Index Scan. -function_search_path_mutable eql_v2 lte function Less-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. -function_search_path_mutable eql_v2 gt function Greater-than backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. -function_search_path_mutable eql_v2 gte function Greater-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. +function_search_path_mutable eql_v2 eq function Equality backing function for `eql_v2.ste_vec_entry × eql_v2.ste_vec_entry` (#219). Inlines to `hmac_256(a) = hmac_256(b)`; the `=` operator must reach the functional hash index on `eql_v2.hmac_256(col -> 'sel')` for bare-form field equality to engage Index Scan. Splinter matches by name only, so this row also covers the converged eql_v2.eq wrappers on eql_v2_int4_eq / _ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 neq function Inequality backing function for `eql_v2.ste_vec_entry`. Same rationale as `eq`. Also covers the converged eql_v2.neq wrappers on eql_v2_int4_eq / _ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 lt function Less-than backing function for `eql_v2.ste_vec_entry`. Inlines to `ore_cllw(a) < ore_cllw(b)`; must reach the functional btree opclass on `eql_v2.ore_cllw` for ordered field queries to engage Index Scan. Splinter matches by name only, so this row also covers the converged eql_v2.lt wrappers on eql_v2_int4_ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 lte function Less-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. Also covers the converged eql_v2.lte wrappers on eql_v2_int4_ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 gt function Greater-than backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. Also covers the converged eql_v2.gt wrappers on eql_v2_int4_ord / _ord_ore (PR #225). +function_search_path_mutable eql_v2 gte function Greater-than-or-equal backing function for `eql_v2.ste_vec_entry`. Same rationale as `lt`. Also covers the converged eql_v2.gte wrappers on eql_v2_int4_ord / _ord_ore (PR #225). function_search_path_mutable eql_v2 ore_cllw_eq function Inner comparator for the `eql_v2.ore_cllw` type's `=` operator (#221). The outer same-type operators back the btree opclass on `eql_v2.ore_cllw`; the planner only carries the inlined form through to functional-index match if this inner function is also inlinable (no SET, IMMUTABLE). Mirrors ore_block_u64_8_256_eq. function_search_path_mutable eql_v2 ore_cllw_neq function Inner comparator for the `eql_v2.ore_cllw` type's `<>` operator (#221). Same rationale as `ore_cllw_eq`. function_search_path_mutable eql_v2 ore_cllw_lt function Inner comparator for the `eql_v2.ore_cllw` type's `<` operator (#221). Same rationale as `ore_cllw_eq`. @@ -94,10 +97,11 @@ function_search_path_mutable eql_v2 ore_cllw_lte function Inner comparator for t function_search_path_mutable eql_v2 ore_cllw_gt function Inner comparator for the `eql_v2.ore_cllw` type's `>` operator (#221). Same rationale as `ore_cllw_eq`. function_search_path_mutable eql_v2 ore_cllw_gte function Inner comparator for the `eql_v2.ore_cllw` type's `>=` operator (#221). Same rationale as `ore_cllw_eq`. function_search_path_mutable eql_v2 -> function Typed sv-element selector lookup (U-007): inlinable SQL so the planner can fold `col -> ''` into the calling query, preserving functional-index match for the chained recipes `WHERE col -> 'sel' = $1::ste_vec_entry` (via eq_term) and `ORDER BY eql_v2.ore_cllw(col -> 'sel')`. Three overloads: (enc, text), (enc, enc), (enc, int). -function_search_path_mutable eql_v2 eq_term function XOR-aware equality term extractor on a ste_vec entry (U-007): coalesces hm and oc as bytea. Must inline so `eql_v2.eq_term(col -> 'sel')` folds into the calling query and matches a functional hash index built on the same expression — same precedent as ore_cllw / hmac_256 extractors on ste_vec_entry. +function_search_path_mutable eql_v2 eq_term function XOR-aware equality term extractor on a ste_vec entry (U-007): coalesces hm and oc as bytea. Must inline so `eql_v2.eq_term(col -> 'sel')` folds into the calling query and matches a functional hash index built on the same expression — same precedent as ore_cllw / hmac_256 extractors on ste_vec_entry. Also covers the eql_v2_int4_eq eq_term overload (PR #225). function_search_path_mutable eql_v2 min function Aggregate (splinter labels these type=function): ALTER AGGREGATE has no SET configuration_parameter syntax, and ALTER ROUTINE/FUNCTION reject aggregates. The aggregate's SFUNC has a pinned search_path. function_search_path_mutable eql_v2 max function Aggregate: same as min. function_search_path_mutable eql_v2 grouped_value function Aggregate: same as min. +function_search_path_mutable eql_v2 ord_term function eql_v2_int4 ordered-variant index extractor: returns eql_v2.ore_block_u64_8_256 (carrying main DEFAULT btree opclass). Used inside the inlinable comparison wrappers and as the functional-index expression USING btree (eql_v2.ord_term(col)); must inline. SET search_path would disable SQL function inlining (see PostgreSQL inline_function). Covers both ord_term overloads (eql_v2_int4_ord_ore, eql_v2_int4_ord). ALLOW # Wrap splinter (a single bare SELECT expression) into a subquery we can @@ -106,6 +110,7 @@ ALLOW splinter_body="$(tail -n +2 "$splinter_sql" | sed 's/;[[:space:]]*$//')" # Pull all findings with their metadata, then split into allowlisted vs not. +# Scoped to EQL-owned schemas — see EQL_OWNED_SCHEMAS at the top of this file. "${PSQL[@]}" -At -F $'\t' --quiet < "$all_findings_tsv" BEGIN; SET LOCAL search_path = ''; @@ -117,6 +122,7 @@ SELECT coalesce(metadata->>'name', ''), coalesce(metadata->>'type', '') FROM (${splinter_body}) splinter +WHERE coalesce(metadata->>'schema', '') IN ${EQL_OWNED_SCHEMAS} ORDER BY level, name, detail; COMMIT; SQL @@ -155,11 +161,14 @@ awk -F'\t' \ # Touch in case awk didn't write either file (no findings at all). touch "$findings_tsv" "$allowlisted_tsv" +# Summary scoped to the same schemas the gate considers, so the count line +# matches what was actually checked. "${PSQL[@]}" -At -F $'\t' --quiet < "$summary_by_rule" BEGIN; SET LOCAL search_path = ''; SELECT level, name, count(*) FROM (${splinter_body}) splinter +WHERE coalesce(metadata->>'schema', '') IN ${EQL_OWNED_SCHEMAS} GROUP BY level, name ORDER BY CASE level WHEN 'ERROR' THEN 0 WHEN 'WARN' THEN 1 WHEN 'INFO' THEN 2 ELSE 3 END, @@ -175,7 +184,7 @@ warns="$(awk -F'\t' '$2 == "WARN"' "$findings_tsv" | wc -l | tr -d ' ')" infos="$(awk -F'\t' '$2 == "INFO"' "$findings_tsv" | wc -l | tr -d ' ')" echo -echo "Splinter findings: raw=${raw_total} (allowlisted=${allowlisted_total}, unallowlisted=${total} — ERROR=${errors} WARN=${warns} INFO=${infos})" +echo "Splinter findings: raw=${raw_total} (allowlisted=${allowlisted_total}, unmatched=${total} — ERROR=${errors} WARN=${warns} INFO=${infos})" echo printf 'LEVEL\tRULE\tCOUNT (raw)\n' cat "$summary_by_rule" @@ -188,7 +197,7 @@ fi if [[ "$total" -gt 0 ]]; then echo - echo "Unallowlisted findings:" + echo "Findings not covered by the allowlist:" awk -F'\t' '{ printf " - [%s] %s — %s\n", $2, $1, $3 }' "$findings_tsv" fi @@ -198,11 +207,12 @@ if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then echo "## Supabase splinter (database linter)" echo echo "Pinned to [\`splinter@${SPLINTER_SHA:0:12}\`](https://github.com/supabase/splinter/tree/${SPLINTER_SHA})." + echo "Scope: schemas owned by EQL (${EQL_OWNED_SCHEMAS//[\'()]/}). Findings outside these schemas are not reported." echo - echo "**${raw_total} raw findings** (allowlisted: ${allowlisted_total}, unallowlisted: ${total} — ERROR: ${errors}, WARN: ${warns}, INFO: ${infos})" + echo "**${raw_total} raw findings** (allowlisted: ${allowlisted_total}, unmatched: ${total} — ERROR: ${errors}, WARN: ${warns}, INFO: ${infos})" echo if [[ "$total" -gt 0 ]]; then - echo "### Unallowlisted findings (action required)" + echo "### Unmatched findings (action required)" echo echo "| Level | Rule | Detail |" echo "| --- | --- | --- |" diff --git a/tests/codegen/reference/int4/int4_values.rs b/tests/codegen/reference/int4/int4_values.rs new file mode 100644 index 00000000..3e6b1ec6 --- /dev/null +++ b/tests/codegen/reference/int4/int4_values.rs @@ -0,0 +1,28 @@ +// REFERENCE: hand-reviewed parity baseline for tasks/codegen/ — see ../README.md +//! Fixture plaintext values for the int4 encrypted-domain family. +//! +//! Generated from tasks/codegen/types/int4.toml `[fixture] values` — +//! the single source of truth shared by the fixture generator +//! (`fixtures::eql_v2_int4`) and the matrix oracle +//! (`ScalarType::FIXTURE_VALUES`). + +/// Distinct plaintext values present in the `eql_v2_int4` fixture. +pub const VALUES: &[i32] = &[ + i32::MIN, + -100, + -1, + 0, + 1, + 2, + 5, + 10, + 17, + 25, + 42, + 50, + 100, + 250, + 1000, + 9999, + i32::MAX, +]; diff --git a/tests/sqlx/Cargo.lock b/tests/sqlx/Cargo.lock index e39e030b..18dd84e0 100644 --- a/tests/sqlx/Cargo.lock +++ b/tests/sqlx/Cargo.lock @@ -1163,6 +1163,7 @@ dependencies = [ "cipherstash-client", "hex", "jsonschema", + "paste", "serde", "serde_json", "sqlx", @@ -2525,6 +2526,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/tests/sqlx/Cargo.toml b/tests/sqlx/Cargo.toml index 153aebf3..a1851398 100644 --- a/tests/sqlx/Cargo.toml +++ b/tests/sqlx/Cargo.toml @@ -12,6 +12,7 @@ anyhow = "1" hex = "0.4" jsonschema = { version = "0.46.4", default-features = false } cipherstash-client = { version = "0.35", features = ["tokio"] } +paste = "1" [dev-dependencies] # None needed - tests live in this crate @@ -23,6 +24,13 @@ default = [] # it on push to main and on a nightly schedule. Run locally with: # mise run test:bench bench = [] +# Opt-in to the matrix's per-(variant, index) scale tests. Each builds +# ~5000 rows of filler plus a single selective pivot and asserts the +# planner *prefers* the functional index with `enable_seqscan` left on. +# The default index tests force seqscan off and only prove the index is +# *usable*. Off by default to keep `mise run test` fast; CI runs with +# `--features scale`. +scale = [] # Opt-in to compiling the fixture generators. Without this feature the # `#[cfg(feature = "fixture-gen")]` generator tests do not exist, so # `cargo test` and CI never see them. Generators need a live Postgres and diff --git a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md index 9ac804b7..8eab33ee 100644 --- a/tests/sqlx/fixtures/FIXTURE_SCHEMA.md +++ b/tests/sqlx/fixtures/FIXTURE_SCHEMA.md @@ -9,7 +9,6 @@ EQL Extension (via migrations) ├── encrypted_json.sql │ └── array_data.sql (extends `encrypted` table from encrypted_json) ├── match_data.sql - ├── aggregate_minmax_data.sql ├── config_tables.sql ├── constraint_tables.sql ├── encryptindex_tables.sql @@ -231,7 +230,7 @@ CREATE TABLE fixtures.eql_v2_int4 ( (`k = "ct"`, `v = 2`). **Used By:** -- eql_v2_int4_fixture_tests.rs (structural verification) +- `__scalar_matrix_fixture_shape!` arm in `tests/sqlx/src/matrix.rs` (structural verification, generated per type) - (#225) the `eql_v2_int4` domain operator tests, via per-query `payload` casts **Opt-in:** Not a migration — a SQLx fixture script. Each consuming test opts diff --git a/tests/sqlx/snapshots/int4_matrix_tests.txt b/tests/sqlx/snapshots/int4_matrix_tests.txt new file mode 100644 index 00000000..1fab59bd --- /dev/null +++ b/tests/sqlx/snapshots/int4_matrix_tests.txt @@ -0,0 +1,211 @@ +scalars::int4::matrix_int4_eq_aggregate_typecheck_max +scalars::int4::matrix_int4_eq_aggregate_typecheck_min +scalars::int4::matrix_int4_eq_contained_by_blocker +scalars::int4::matrix_int4_eq_contains_blocker +scalars::int4::matrix_int4_eq_count_distinct_extractor +scalars::int4::matrix_int4_eq_count_path_cast +scalars::int4::matrix_int4_eq_count_typed_column +scalars::int4::matrix_int4_eq_eq_pivot_max_correctness +scalars::int4::matrix_int4_eq_eq_pivot_max_cross_shape +scalars::int4::matrix_int4_eq_eq_pivot_min_correctness +scalars::int4::matrix_int4_eq_eq_pivot_min_cross_shape +scalars::int4::matrix_int4_eq_eq_pivot_zero_correctness +scalars::int4::matrix_int4_eq_eq_pivot_zero_cross_shape +scalars::int4::matrix_int4_eq_eq_supported_null +scalars::int4::matrix_int4_eq_gt_blocker +scalars::int4::matrix_int4_eq_gte_blocker +scalars::int4::matrix_int4_eq_index_engages_btree +scalars::int4::matrix_int4_eq_index_engages_hash +scalars::int4::matrix_int4_eq_lt_blocker +scalars::int4::matrix_int4_eq_lte_blocker +scalars::int4::matrix_int4_eq_native_absent_ops +scalars::int4::matrix_int4_eq_neq_pivot_max_correctness +scalars::int4::matrix_int4_eq_neq_pivot_max_cross_shape +scalars::int4::matrix_int4_eq_neq_pivot_min_correctness +scalars::int4::matrix_int4_eq_neq_pivot_min_cross_shape +scalars::int4::matrix_int4_eq_neq_pivot_zero_correctness +scalars::int4::matrix_int4_eq_neq_pivot_zero_cross_shape +scalars::int4::matrix_int4_eq_neq_supported_null +scalars::int4::matrix_int4_eq_path_op_blockers +scalars::int4::matrix_int4_eq_payload_check +scalars::int4::matrix_int4_eq_planner_metadata_eq +scalars::int4::matrix_int4_eq_sanity +scalars::int4::matrix_int4_eq_typed_column_blocker +scalars::int4::matrix_int4_fixture_shape +scalars::int4::matrix_int4_ord_aggregate_group_by_max +scalars::int4::matrix_int4_ord_aggregate_group_by_min +scalars::int4::matrix_int4_ord_aggregate_max +scalars::int4::matrix_int4_ord_aggregate_max_all_null +scalars::int4::matrix_int4_ord_aggregate_max_empty +scalars::int4::matrix_int4_ord_aggregate_max_mixed_null +scalars::int4::matrix_int4_ord_aggregate_min +scalars::int4::matrix_int4_ord_aggregate_min_all_null +scalars::int4::matrix_int4_ord_aggregate_min_empty +scalars::int4::matrix_int4_ord_aggregate_min_mixed_null +scalars::int4::matrix_int4_ord_aggregate_parallel_safe +scalars::int4::matrix_int4_ord_contained_by_blocker +scalars::int4::matrix_int4_ord_contains_blocker +scalars::int4::matrix_int4_ord_count_distinct_extractor +scalars::int4::matrix_int4_ord_count_path_cast +scalars::int4::matrix_int4_ord_count_typed_column +scalars::int4::matrix_int4_ord_eq_pivot_max_correctness +scalars::int4::matrix_int4_ord_eq_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_eq_pivot_min_correctness +scalars::int4::matrix_int4_ord_eq_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_eq_pivot_zero_correctness +scalars::int4::matrix_int4_ord_eq_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_eq_supported_null +scalars::int4::matrix_int4_ord_gt_pivot_max_correctness +scalars::int4::matrix_int4_ord_gt_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_gt_pivot_min_correctness +scalars::int4::matrix_int4_ord_gt_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_gt_pivot_zero_correctness +scalars::int4::matrix_int4_ord_gt_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_gt_supported_null +scalars::int4::matrix_int4_ord_gte_pivot_max_correctness +scalars::int4::matrix_int4_ord_gte_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_gte_pivot_min_correctness +scalars::int4::matrix_int4_ord_gte_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_gte_pivot_zero_correctness +scalars::int4::matrix_int4_ord_gte_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_gte_supported_null +scalars::int4::matrix_int4_ord_index_engages_btree +scalars::int4::matrix_int4_ord_lt_pivot_max_correctness +scalars::int4::matrix_int4_ord_lt_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_lt_pivot_min_correctness +scalars::int4::matrix_int4_ord_lt_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_lt_pivot_zero_correctness +scalars::int4::matrix_int4_ord_lt_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_lt_supported_null +scalars::int4::matrix_int4_ord_lte_pivot_max_correctness +scalars::int4::matrix_int4_ord_lte_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_lte_pivot_min_correctness +scalars::int4::matrix_int4_ord_lte_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_lte_pivot_zero_correctness +scalars::int4::matrix_int4_ord_lte_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_lte_supported_null +scalars::int4::matrix_int4_ord_native_absent_ops +scalars::int4::matrix_int4_ord_neq_pivot_max_correctness +scalars::int4::matrix_int4_ord_neq_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_neq_pivot_min_correctness +scalars::int4::matrix_int4_ord_neq_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_neq_pivot_zero_correctness +scalars::int4::matrix_int4_ord_neq_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_neq_supported_null +scalars::int4::matrix_int4_ord_ord_routes_through_ob +scalars::int4::matrix_int4_ord_order_by_asc_no_where +scalars::int4::matrix_int4_ord_order_by_asc_nulls_first +scalars::int4::matrix_int4_ord_order_by_asc_nulls_last +scalars::int4::matrix_int4_ord_order_by_asc_with_where +scalars::int4::matrix_int4_ord_order_by_desc_no_where +scalars::int4::matrix_int4_ord_order_by_desc_nulls_first +scalars::int4::matrix_int4_ord_order_by_desc_nulls_last +scalars::int4::matrix_int4_ord_order_by_desc_with_where +scalars::int4::matrix_int4_ord_order_by_using_gt_rejects +scalars::int4::matrix_int4_ord_order_by_using_gte_rejects +scalars::int4::matrix_int4_ord_order_by_using_lt_rejects +scalars::int4::matrix_int4_ord_order_by_using_lte_rejects +scalars::int4::matrix_int4_ord_ore_aggregate_group_by_max +scalars::int4::matrix_int4_ord_ore_aggregate_group_by_min +scalars::int4::matrix_int4_ord_ore_aggregate_max +scalars::int4::matrix_int4_ord_ore_aggregate_max_all_null +scalars::int4::matrix_int4_ord_ore_aggregate_max_empty +scalars::int4::matrix_int4_ord_ore_aggregate_max_mixed_null +scalars::int4::matrix_int4_ord_ore_aggregate_min +scalars::int4::matrix_int4_ord_ore_aggregate_min_all_null +scalars::int4::matrix_int4_ord_ore_aggregate_min_empty +scalars::int4::matrix_int4_ord_ore_aggregate_min_mixed_null +scalars::int4::matrix_int4_ord_ore_aggregate_parallel_safe +scalars::int4::matrix_int4_ord_ore_contained_by_blocker +scalars::int4::matrix_int4_ord_ore_contains_blocker +scalars::int4::matrix_int4_ord_ore_count_distinct_extractor +scalars::int4::matrix_int4_ord_ore_count_path_cast +scalars::int4::matrix_int4_ord_ore_count_typed_column +scalars::int4::matrix_int4_ord_ore_eq_pivot_max_correctness +scalars::int4::matrix_int4_ord_ore_eq_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_ore_eq_pivot_min_correctness +scalars::int4::matrix_int4_ord_ore_eq_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_ore_eq_pivot_zero_correctness +scalars::int4::matrix_int4_ord_ore_eq_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_ore_eq_supported_null +scalars::int4::matrix_int4_ord_ore_gt_pivot_max_correctness +scalars::int4::matrix_int4_ord_ore_gt_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_ore_gt_pivot_min_correctness +scalars::int4::matrix_int4_ord_ore_gt_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_ore_gt_pivot_zero_correctness +scalars::int4::matrix_int4_ord_ore_gt_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_ore_gt_supported_null +scalars::int4::matrix_int4_ord_ore_gte_pivot_max_correctness +scalars::int4::matrix_int4_ord_ore_gte_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_ore_gte_pivot_min_correctness +scalars::int4::matrix_int4_ord_ore_gte_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_ore_gte_pivot_zero_correctness +scalars::int4::matrix_int4_ord_ore_gte_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_ore_gte_supported_null +scalars::int4::matrix_int4_ord_ore_index_engages_btree +scalars::int4::matrix_int4_ord_ore_lt_pivot_max_correctness +scalars::int4::matrix_int4_ord_ore_lt_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_ore_lt_pivot_min_correctness +scalars::int4::matrix_int4_ord_ore_lt_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_ore_lt_pivot_zero_correctness +scalars::int4::matrix_int4_ord_ore_lt_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_ore_lt_supported_null +scalars::int4::matrix_int4_ord_ore_lte_pivot_max_correctness +scalars::int4::matrix_int4_ord_ore_lte_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_ore_lte_pivot_min_correctness +scalars::int4::matrix_int4_ord_ore_lte_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_ore_lte_pivot_zero_correctness +scalars::int4::matrix_int4_ord_ore_lte_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_ore_lte_supported_null +scalars::int4::matrix_int4_ord_ore_native_absent_ops +scalars::int4::matrix_int4_ord_ore_neq_pivot_max_correctness +scalars::int4::matrix_int4_ord_ore_neq_pivot_max_cross_shape +scalars::int4::matrix_int4_ord_ore_neq_pivot_min_correctness +scalars::int4::matrix_int4_ord_ore_neq_pivot_min_cross_shape +scalars::int4::matrix_int4_ord_ore_neq_pivot_zero_correctness +scalars::int4::matrix_int4_ord_ore_neq_pivot_zero_cross_shape +scalars::int4::matrix_int4_ord_ore_neq_supported_null +scalars::int4::matrix_int4_ord_ore_ord_routes_through_ob +scalars::int4::matrix_int4_ord_ore_order_by_asc_no_where +scalars::int4::matrix_int4_ord_ore_order_by_asc_nulls_first +scalars::int4::matrix_int4_ord_ore_order_by_asc_nulls_last +scalars::int4::matrix_int4_ord_ore_order_by_asc_with_where +scalars::int4::matrix_int4_ord_ore_order_by_desc_no_where +scalars::int4::matrix_int4_ord_ore_order_by_desc_nulls_first +scalars::int4::matrix_int4_ord_ore_order_by_desc_nulls_last +scalars::int4::matrix_int4_ord_ore_order_by_desc_with_where +scalars::int4::matrix_int4_ord_ore_order_by_using_gt_rejects +scalars::int4::matrix_int4_ord_ore_order_by_using_gte_rejects +scalars::int4::matrix_int4_ord_ore_order_by_using_lt_rejects +scalars::int4::matrix_int4_ord_ore_order_by_using_lte_rejects +scalars::int4::matrix_int4_ord_ore_ore_injectivity +scalars::int4::matrix_int4_ord_ore_path_op_blockers +scalars::int4::matrix_int4_ord_ore_payload_check +scalars::int4::matrix_int4_ord_ore_planner_metadata_eq +scalars::int4::matrix_int4_ord_ore_planner_metadata_ord +scalars::int4::matrix_int4_ord_ore_sanity +scalars::int4::matrix_int4_ord_ore_typed_column_blocker +scalars::int4::matrix_int4_ord_path_op_blockers +scalars::int4::matrix_int4_ord_payload_check +scalars::int4::matrix_int4_ord_planner_metadata_eq +scalars::int4::matrix_int4_ord_planner_metadata_ord +scalars::int4::matrix_int4_ord_sanity +scalars::int4::matrix_int4_ord_scale_preference_default_btree +scalars::int4::matrix_int4_ord_typed_column_blocker +scalars::int4::matrix_int4_storage_aggregate_typecheck_max +scalars::int4::matrix_int4_storage_aggregate_typecheck_min +scalars::int4::matrix_int4_storage_contained_by_blocker +scalars::int4::matrix_int4_storage_contains_blocker +scalars::int4::matrix_int4_storage_count_path_cast +scalars::int4::matrix_int4_storage_count_typed_column +scalars::int4::matrix_int4_storage_eq_blocker +scalars::int4::matrix_int4_storage_gt_blocker +scalars::int4::matrix_int4_storage_gte_blocker +scalars::int4::matrix_int4_storage_lt_blocker +scalars::int4::matrix_int4_storage_lte_blocker +scalars::int4::matrix_int4_storage_native_absent_ops +scalars::int4::matrix_int4_storage_neq_blocker +scalars::int4::matrix_int4_storage_path_op_blockers +scalars::int4::matrix_int4_storage_payload_check +scalars::int4::matrix_int4_storage_sanity +scalars::int4::matrix_int4_storage_typed_column_blocker diff --git a/tests/sqlx/src/assertions.rs b/tests/sqlx/src/assertions.rs index 2fa7d4b6..538db6a1 100644 --- a/tests/sqlx/src/assertions.rs +++ b/tests/sqlx/src/assertions.rs @@ -148,3 +148,51 @@ impl<'a> QueryAssertion<'a> { ); } } + +/// Assert a `sqlx::Error` is a database error with the given SQLSTATE, +/// optionally with the given constraint name. Includes the actual error +/// in the panic message so a failing test prints *why* it failed, not +/// just *that* it failed — `assert!(result.is_err(), "…")` swallows the +/// underlying error so a constraint engagement against the wrong +/// constraint or SQLSTATE passes silently. +/// +/// # SQLSTATEs commonly seen on encrypted columns +/// - `23505` — unique_violation +/// - `23502` — not_null_violation +/// - `23514` — check_violation +/// - `23503` — foreign_key_violation +/// - `P0001` — raise_exception (PL/pgSQL `RAISE EXCEPTION`) +/// - `42704` — undefined_object (no operator class found, etc.) +/// +/// # Example +/// ```ignore +/// let result = sqlx::query(...).execute(&pool).await.unwrap_err(); +/// assert_db_error(&result, "23514", Some("encrypted_check_c_constrained")); +/// ``` +pub fn assert_db_error( + err: &sqlx::Error, + expected_sqlstate: &str, + expected_constraint: Option<&str>, +) { + let db_err = err + .as_database_error() + .unwrap_or_else(|| panic!("expected database error, got: {err:?}")); + + let code = db_err.code(); + assert_eq!( + code.as_deref(), + Some(expected_sqlstate), + "expected SQLSTATE {expected_sqlstate}, got {code:?} (message: {})", + db_err.message(), + ); + + if let Some(expected) = expected_constraint { + let constraint = db_err.constraint(); + assert_eq!( + constraint, + Some(expected), + "expected constraint name {expected:?}, got {constraint:?} (message: {})", + db_err.message(), + ); + } +} diff --git a/tests/sqlx/src/fixtures/cipherstash.rs b/tests/sqlx/src/fixtures/cipherstash.rs index bb3a69bc..d7a8e8ca 100644 --- a/tests/sqlx/src/fixtures/cipherstash.rs +++ b/tests/sqlx/src/fixtures/cipherstash.rs @@ -8,11 +8,12 @@ //! existed only because the Proxy was the encryption oracle. //! //! `cipherstash-client` 0.35 exposes the same surface natively. This module -//! owns the bootstrap — `cipher()` lazily builds a process-wide -//! `ScopedCipher` — and the per-value helper -//! `encrypt_store()` that wraps `eql::encrypt_eql` and returns the resulting -//! EQL ciphertext as a `serde_json::Value` ready to bind into a `jsonb` -//! column. +//! owns the bootstrap — `build_cipher()` builds a `ScopedCipher` — +//! and the batched helper `encrypt_store()` that wraps `eql::encrypt_eql` and +//! returns the resulting EQL ciphertexts as `serde_json::Value`s ready to bind +//! into a `jsonb` column. A fixture-generator process makes exactly one +//! `encrypt_store` call, so the cipher is built once per process by +//! construction — no static cache, no cross-runtime hazard. //! //! `column_config_for` is the bridge between the fixture spec's string-typed //! index names (`"unique"`, `"ore"`, …) and the typed `IndexType` enum @@ -32,70 +33,38 @@ use cipherstash_client::schema::column::{Index, IndexType}; use cipherstash_client::schema::{ColumnConfig, ColumnType}; use cipherstash_client::zerokms::{EnvKeyProvider, ZeroKMSBuilder}; use cipherstash_client::AutoStrategy; -use tokio::sync::OnceCell; use super::eql_plaintext::{Cast, EqlPlaintext}; -use super::validation::FixtureIdentifier; - -/// Process-wide `ScopedCipher`. Built on first use and held for the lifetime -/// of the test binary — `ScopedCipher` is documented as -/// "initialise once per process, hold an `Arc` for the process lifetime" -/// (see the upstream doc comment in `scoped_cipher.rs`). Re-initialising it -/// per call discards the warm reqwest pool and the cached auth token, and -/// makes the generator slower for no benefit. -static CIPHER: OnceCell>> = OnceCell::const_new(); - -/// Lazily initialise the process-wide cipher. On the first call this performs -/// the AutoStrategy detection, the ZeroKMS handshake, and the keyset load — -/// each subsequent call is an `Arc` clone. -/// -/// Errors surface as `anyhow::Error` with `.context(...)` naming the step -/// that failed (credential detection vs ZeroKMS connect vs keyset load). -pub async fn cipher() -> Result>> { - CIPHER - .get_or_try_init(|| async { - let zerokms = ZeroKMSBuilder::auto() - .context( - "building ZeroKMSBuilder via AutoStrategy::detect() — check \ - CS_CLIENT_ACCESS_KEY or CS_WORKSPACE_CRN env vars", - )? - .with_key_provider(EnvKeyProvider) - .build() - .await - .context( - "building ZeroKMS client — check CS_CLIENT_ID + CS_CLIENT_KEY \ - env vars (loaded by EnvKeyProvider)", - )?; - - let cipher = ScopedCipher::init_default(Arc::new(zerokms)) - .await - .context("initialising ScopedCipher for the default keyset")?; - - Ok::<_, anyhow::Error>(Arc::new(cipher)) - }) - .await - .cloned() +use super::index_kind::IndexKind; + +/// Build a fresh `ScopedCipher`. Performs `AutoStrategy::detect()`, the +/// ZeroKMS handshake, and the keyset load on every call — fine because +/// every fixture-generator process calls this exactly once via the +/// single batched `encrypt_store`. +async fn build_cipher() -> Result>> { + let zerokms = ZeroKMSBuilder::auto()? + .with_key_provider(EnvKeyProvider) + .build() + .await?; + + let cipher = ScopedCipher::init_default(Arc::new(zerokms)).await?; + + Ok(Arc::new(cipher)) } /// Build a `ColumnConfig` from the fixture spec's index list + cast. /// -/// The fixture spec uses EQL's string-typed index identifiers (`"unique"`, -/// `"ore"`, `"match"`, `"ste_vec"`); cipherstash-config uses the typed -/// `IndexType` enum. The mapping here is the single point of contact -/// between the two — extending fixture coverage to a new index means one -/// new arm here plus the corresponding `EqlPlaintext::CAST` constant. -/// -/// Unknown identifiers raise immediately with the offending name in the -/// error so a typo at spec-construction surfaces at run time (the -/// `FixtureIdentifier` newtype only proves the string is a valid SQL -/// identifier, not that it names a real index type). -pub fn column_config_for(spec_indexes: &[FixtureIdentifier], cast: Cast) -> Result { +/// `IndexKind` is a typed enum — every value is a real EQL index by +/// construction, so the mapping is total and `column_config_for` cannot +/// fail on an unknown index name. Extending fixture coverage to a new +/// index is one variant on `IndexKind` plus one arm here, both compile- +/// time checked. +pub fn column_config_for(spec_indexes: &[IndexKind], cast: Cast) -> Result { let column_type = cast_to_column_type(cast)?; let mut config = ColumnConfig::build("payload").casts_as(column_type); for ix in spec_indexes { - let index_type = index_type_for(ix.as_str())?; - config = config.add_index(Index::new(index_type)); + config = config.add_index(Index::new(index_type_for(*ix))); } Ok(config) @@ -126,19 +95,17 @@ fn cast_to_column_type(cast: Cast) -> Result { } } -/// Map the fixture spec's string-typed index identifier onto a typed -/// `IndexType`. Reuses the canonical constructors on `Index` -/// (`Index::new_unique`, etc.) so the defaults stay in sync with whatever -/// cipherstash-config considers the canonical shape for each index. -fn index_type_for(name: &str) -> Result { - match name { - "unique" => Ok(Index::new_unique().index_type), - "ore" => Ok(IndexType::Ore), - "match" => Ok(Index::new_match().index_type), - other => Err(anyhow!( - "unknown EQL index identifier {other:?} — supported: \ - unique, ore, match" - )), +/// Map an `IndexKind` variant onto cipherstash-config's `IndexType`. +/// Reuses the canonical constructors on `Index` (`Index::new_unique`, +/// etc.) so the defaults stay in sync with whatever cipherstash-config +/// considers the canonical shape for each index. Total — every variant +/// has an arm; adding a new variant is a compile error here, which is +/// the point. +fn index_type_for(kind: IndexKind) -> IndexType { + match kind { + IndexKind::Unique => Index::new_unique().index_type, + IndexKind::Ore => IndexType::Ore, + IndexKind::Match => Index::new_match().index_type, } } @@ -158,9 +125,10 @@ fn index_type_for(name: &str) -> Result { /// index filter — the same defaults Proxy uses for column-config-driven /// inserts. /// -/// An empty `values` slice short-circuits before `cipher()` so a caller -/// with nothing to encrypt does not pay the ZeroKMS bootstrap cost. -pub async fn encrypt_store( +/// An empty `values` slice short-circuits before `build_cipher()` so a +/// caller with nothing to encrypt does not pay the ZeroKMS bootstrap +/// cost. +pub async fn encrypt_store( table: &str, column: &str, values: &[T], @@ -170,7 +138,7 @@ pub async fn encrypt_store( return Ok(Vec::new()); } - let cipher = cipher().await?; + let cipher = build_cipher().await?; // `Identifier::new` does two `String` allocations per call — cheap // enough that constructing per-iteration is preferred over assuming @@ -228,13 +196,9 @@ pub async fn encrypt_store( mod tests { use super::*; - fn ident(s: &str) -> FixtureIdentifier { - FixtureIdentifier::try_from(s).unwrap() - } - #[test] fn column_config_for_int_with_unique_and_ore_builds_a_two_index_config() { - let indexes = [ident("unique"), ident("ore")]; + let indexes = [IndexKind::Unique, IndexKind::Ore]; let config = column_config_for(&indexes, Cast::INT).unwrap(); assert_eq!(config.name, "payload"); @@ -244,51 +208,34 @@ mod tests { assert!(config.indexes.iter().any(|i| i.is_ore())); } - #[test] - fn column_config_for_rejects_an_unknown_index_name() { - let indexes = [ident("bogus")]; - let err = column_config_for(&indexes, Cast::INT).unwrap_err(); - assert!( - format!("{err:#}").contains("unknown EQL index identifier"), - "error should name the unknown identifier: {err:#}" - ); - } - - #[test] - fn index_type_for_maps_known_names_to_their_canonical_index_type() { - // The named EQL index identifiers each round-trip into the - // `IndexType` cipherstash-config considers canonical for that - // name. Compared via the public `Index` surface (`is_unique`, - // `is_ore`, `is_match`) so the assertion does not depend on the - // shape of the non-exhaustive `IndexType` enum. - let unique = Index::new(index_type_for("unique").unwrap()); - assert!(unique.is_unique(), "'unique' must map to the unique index"); - - let ore = Index::new(index_type_for("ore").unwrap()); - assert!(ore.is_ore(), "'ore' must map to the ORE index"); - - let m = Index::new(index_type_for("match").unwrap()); - assert!(m.is_match(), "'match' must map to the match (bloom) index"); - } + // Note: the "unknown index name rejected at runtime" test is gone — + // `IndexKind` is a closed enum, so a typo is a compile error. #[test] - fn index_type_for_rejects_an_unknown_index_name() { - let err = index_type_for("bogus").unwrap_err(); - let msg = format!("{err:#}"); - assert!( - msg.contains("unknown EQL index identifier") && msg.contains("bogus"), - "error should name the offending identifier: {msg}" - ); + fn index_type_for_maps_every_variant_to_its_canonical_index_type() { + // Each `IndexKind` variant round-trips into the `IndexType` + // cipherstash-config considers canonical for that name. Compared + // via the public `Index` surface (`is_unique`, `is_ore`, + // `is_match`) so the assertion does not depend on the shape of + // the non-exhaustive `IndexType` enum. + let unique = Index::new(index_type_for(IndexKind::Unique)); + assert!(unique.is_unique(), "Unique must map to the unique index"); + + let ore = Index::new(index_type_for(IndexKind::Ore)); + assert!(ore.is_ore(), "Ore must map to the ORE index"); + + let m = Index::new(index_type_for(IndexKind::Match)); + assert!(m.is_match(), "Match must map to the match (bloom) index"); } #[tokio::test] - async fn encrypt_store_with_empty_values_returns_an_empty_vec_without_calling_cipher() { - // Empty input short-circuits before `cipher()` so a caller with - // nothing to encrypt does not pay the ZeroKMS bootstrap cost. + async fn encrypt_store_with_empty_values_returns_an_empty_vec_without_building_cipher() { + // Empty input short-circuits before `build_cipher()` so a caller + // with nothing to encrypt does not pay the ZeroKMS bootstrap cost. // Running this test under `cargo test` (no `fixture-gen` feature, - // no CS_* env vars) proves the short-circuit: if `cipher()` were - // reached, the missing credentials would surface as an error. - let config = column_config_for(&[ident("unique")], Cast::INT).unwrap(); + // no CS_* env vars) proves the short-circuit: if `build_cipher()` + // were reached, the missing credentials would surface as an error. + let config = column_config_for(&[IndexKind::Unique], Cast::INT).unwrap(); let out = encrypt_store::("t", "c", &[], &config).await.unwrap(); assert!(out.is_empty(), "empty input must yield empty output"); } @@ -326,20 +273,11 @@ mod tests { /// by `fixture-gen` so default `cargo test` runs do not require /// `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN`. Each test is /// `#[ignore]` so it only runs under -/// `cargo test --features fixture-gen -- --ignored --test-threads=1`, -/// mirroring the `generate` test in `eql_v2_int4.rs`. -/// -/// **Must run serially (`--test-threads=1`).** The process-wide -/// `CIPHER` `OnceCell` caches a `ScopedCipher` whose reqwest connection -/// pool is bound to the tokio runtime that initialised it. Each -/// `#[tokio::test]` builds its own runtime, so under parallel -/// execution the second test's calls go through a pool whose -/// dispatcher has been dropped — failing with -/// "SendRequest: dispatch task is gone". Production fixture runs (one -/// `#[tokio::main]` runtime) are unaffected. +/// `cargo test --features fixture-gen -- --ignored`, mirroring the +/// `generate` test in `eql_v2_int4.rs`. /// /// These complement the structural fixture-tests in -/// `tests/sqlx/tests/eql_v2_int4_fixture_tests.rs`: those assert over the +/// the `__scalar_matrix_fixture_shape!` arm in `tests/sqlx/src/matrix.rs`: those assert over the /// regenerated SQL file end-to-end; these isolate the /// `encrypt_store` call so an SDK API drift surfaces here before the /// whole fixture pipeline fails. @@ -348,19 +286,15 @@ mod live_tests { use super::*; use serde_json::Value; - fn ident(s: &str) -> FixtureIdentifier { - FixtureIdentifier::try_from(s).unwrap() - } - - /// Config used by every live test — `unique` drives the `hm` term, - /// `ore` drives the `ob` term, so the returned payloads carry both. + /// Config used by every live test — `Unique` drives the `hm` term, + /// `Ore` drives the `ob` term, so the returned payloads carry both. fn int_config_with_hm_and_ob() -> ColumnConfig { - column_config_for(&[ident("unique"), ident("ore")], Cast::INT).unwrap() + column_config_for(&[IndexKind::Unique, IndexKind::Ore], Cast::INT).unwrap() } /// Assert the well-formed Store shape: the payload is a JSON object /// with non-null `v`, `c`, `hm`, `ob`, and `i` fields. Mirrors the - /// per-key assertions in `eql_v2_int4_fixture_tests.rs`. + /// per-key assertions in `tests/encrypted_domain/scalars/int4/fixture.rs`. fn assert_store_shape(payload: &Value) { let obj = payload.as_object().expect("payload must be a JSON object"); for key in ["v", "c", "hm", "ob", "i"] { diff --git a/tests/sqlx/src/fixtures/driver.rs b/tests/sqlx/src/fixtures/driver.rs index 90060a5f..1d6c3f45 100644 --- a/tests/sqlx/src/fixtures/driver.rs +++ b/tests/sqlx/src/fixtures/driver.rs @@ -110,11 +110,15 @@ where /// Generate and write `tests/sqlx/fixtures/.sql`. /// /// The production entry point. Parses the env-driven `DriverConfig` - /// once, opens a direct Postgres connection, then delegates the - /// schema + teardown orchestration to `run_with`, supplying - /// `insert_direct` as the closure. After `run_with` returns the - /// rendered INSERT lines, this method composes them with + /// once, opens a single direct Postgres connection, runs the + /// schema/insert/render/drop pipeline inline against that connection + /// (no second connection needed — encryption happens in Rust via + /// cipherstash-client), then composes the rendered INSERT lines with /// `fixture_script_preamble` and writes the committed script to disk. + /// + /// The pipeline mirrors the teardown contract in `run_with`: drop the + /// working table unconditionally once it has been created, and + /// propagate failures in causal order (insert error first). pub async fn run(&self) -> Result<()> { let config = DriverConfig::from_env()?; @@ -125,21 +129,42 @@ where .await .context("connecting to Postgres (direct)")?; - // Second direct connection for the inserter closure. `run_with` - // borrows the first connection mutably for the duration of the - // pipeline, so the inserter must hold its own. - let mut inserter_conn = config - .direct - .clone() - .connect() + self.check_complete().context("invalid FixtureSpec")?; + + sqlx::raw_sql(&self.working_schema_sql()) + .execute(&mut direct) .await - .context("connecting to Postgres (direct inserter)")?; + .context("applying working-table schema")?; - let lines = self - .run_with(&mut direct, || self.insert_direct(&mut inserter_conn)) - .await?; + // Insert directly on the same connection used for schema/render/drop. + // The earlier two-connection design existed because `run_with` borrows + // `direct` mutably across the closure call; production has no such + // need — `insert_direct` is the only caller of cipherstash-client and + // can hold the same `&mut direct` for its duration. + let insert_result = self.insert_direct(&mut direct).await; + let render_result = if insert_result.is_ok() { + sqlx::query(&self.render_rows_sql()) + .fetch_all(&mut direct) + .await + .context("rendering fixture rows") + } else { + Ok(Vec::new()) + }; + + let working = self.working_table(); + let drop_result = sqlx::raw_sql(&format!("DROP TABLE IF EXISTS public.{working};")) + .execute(&mut direct) + .await; + + insert_result?; + let rows = render_result?; + drop_result.context("dropping the working table")?; + + let lines: Vec = rows + .iter() + .map(|r| r.try_get::(0).context("reading rendered INSERT")) + .collect::>()?; - let _ = inserter_conn.close().await; let _ = direct.close().await; let mut script = self.fixture_script_preamble(); @@ -190,29 +215,34 @@ where Ok(()) } - /// Orchestrates the schema-apply / insert / render / teardown pipeline - /// against a caller-supplied `direct` connection, with the insert step - /// pluggable via `insert_rows`. The pipeline is: + /// **Test seam** for the schema-apply / insert / render / teardown + /// pipeline. Production code uses `run()`, which inlines the same + /// pipeline on a single connection. This entry point exists so tests + /// can plug in arbitrary insert behavior (hand-crafted JSONB, + /// deliberate failures) without going through cipherstash-client. + /// Gated behind `#[cfg(test)]` so it is never linked into a + /// production build. /// + /// Pipeline: /// 1. Check the spec is complete. /// 2. Apply `working_schema_sql` on `direct`. After this succeeds the /// `public._fixture_` table exists and MUST be dropped before /// return, whatever happens next. - /// 3. Run `insert_rows()`. Its result is captured (not `?`-propagated) - /// so the drop in step 5 always runs. + /// 3. Run `insert_rows()`. Its result is captured (not + /// `?`-propagated) so the drop in step 5 always runs. /// 4. If the inserter succeeded, render the committed rows via /// `render_rows_sql` on `direct`. Skipped on inserter error. /// 5. Drop the working table on `direct` unconditionally. /// 6. Propagate failures in causal order: inserter error first /// (root cause), then render, then drop. /// - /// `run()` calls this with `insert_direct`. Tests call it with - /// closures that insert hand-crafted JSONB payloads directly (no - /// cipherstash-client required), or with closures that return `Err` - /// to exercise the teardown contract. + /// The closure has no `&mut PgConnection` parameter because the + /// caller (a test) closes over its own pool / connection — the + /// production path's single-connection invariant is enforced inside + /// `run`, not here. /// - /// Private by design: this is a test seam, not a public API. Other - /// fixtures must go through `run`. + /// Private by design: this is a test seam, not a public API. + #[cfg(test)] async fn run_with( &self, direct: &mut PgConnection, @@ -263,10 +293,11 @@ mod tests { /// A small int4 spec for driver tests. Three values keeps the test fast; /// the driver's orchestration is independent of value count. fn small_spec(name: &'static str) -> FixtureSpec<'static, i32> { + use super::super::index_kind::IndexKind; const VALUES: &[i32] = &[-1, 1, 42]; FixtureSpec::new(name) - .with_index("unique") - .with_index("ore") + .with_index(IndexKind::Unique) + .with_index(IndexKind::Ore) .with_column_type("jsonb") .with_values(VALUES) } @@ -280,9 +311,13 @@ mod tests { let mut conn = pool.acquire().await?; + // `run_with` is the test seam; it borrows `&mut conn` for the + // schema/render/drop steps, so a test that wants to insert via + // sqlx must close over its own connection — exactly the + // two-connection shape production (`run`) was rewritten to + // avoid. Tests pay this cost so production doesn't have to. let lines = spec - .run_with(&mut *conn, move || async move { - // Working table should exist while the closure runs. + .run_with(&mut conn, move || async move { let mut c = pool_for_closure.acquire().await?; let exists: Option = sqlx::query_scalar(&format!( "SELECT to_regclass('public.{working_for_closure}')::text" @@ -344,7 +379,7 @@ mod tests { let mut conn = pool.acquire().await?; let result = spec - .run_with(&mut *conn, || async { + .run_with(&mut conn, || async { anyhow::bail!("forced failure for test") }) .await; diff --git a/tests/sqlx/src/fixtures/eql_plaintext.rs b/tests/sqlx/src/fixtures/eql_plaintext.rs index 4cdc807d..0db9482a 100644 --- a/tests/sqlx/src/fixtures/eql_plaintext.rs +++ b/tests/sqlx/src/fixtures/eql_plaintext.rs @@ -81,15 +81,18 @@ pub trait EqlPlaintext: sealed::Sealed { /// EQL encryption pipeline consumes. The mapping is total — every /// `EqlPlaintext` impl maps cleanly onto a `Plaintext::*(Some(_))` /// variant. - fn to_plaintext(self) -> Plaintext; + /// + /// Takes `&self` so future non-`Copy` plaintexts (`String`, + /// `BigDecimal`, `Vec`) implement without unnecessary clones. + fn to_plaintext(&self) -> Plaintext; } impl EqlPlaintext for i32 { const CAST: Cast = Cast::INT; const PLAINTEXT_SQL_TYPE: PlaintextSqlType = PlaintextSqlType::INTEGER; - fn to_plaintext(self) -> Plaintext { - Plaintext::Int(Some(self)) + fn to_plaintext(&self) -> Plaintext { + Plaintext::Int(Some(*self)) } } @@ -114,7 +117,7 @@ mod tests { fn i32_to_plaintext_wraps_in_int_variant() { // The trait must lift the raw i32 into the EQL pipeline's Plaintext // enum so the fixture driver can hand it to `eql::encrypt_eql`. - match (42_i32).to_plaintext() { + match 42_i32.to_plaintext() { Plaintext::Int(Some(value)) => assert_eq!(value, 42), other => panic!("expected Plaintext::Int(Some(42)), got {other:?}"), } diff --git a/tests/sqlx/src/fixtures/eql_v2_int4.rs b/tests/sqlx/src/fixtures/eql_v2_int4.rs index f32e93a5..316eac48 100644 --- a/tests/sqlx/src/fixtures/eql_v2_int4.rs +++ b/tests/sqlx/src/fixtures/eql_v2_int4.rs @@ -1,22 +1,21 @@ //! The `eql_v2_int4` fixture — the framework's reference example and proof. //! -//! 14 integers spanning a negative boundary and small/medium/large/extreme -//! magnitudes. The generated `tests/sqlx/fixtures/eql_v2_int4.sql` is a plain -//! `jsonb`-payload table with no EQL dependency; #225 layers the `eql_v2_int4` -//! domain on top by casting `payload` per query. - +//! 17 integers spanning a negative boundary, the i32 signed extremes +//! (`MIN`/`MAX`), zero, and small/medium/large magnitudes. The generated +//! `tests/sqlx/fixtures/eql_v2_int4.sql` is a plain `jsonb`-payload table with +//! no EQL dependency; #225 layers the `eql_v2_int4` domain on top by casting +//! `payload` per query. + +use super::index_kind::IndexKind; +use super::int4_values::VALUES; use super::spec::FixtureSpec; -/// 14 values: a negative boundary plus small/medium/large/extreme magnitudes, -/// chosen so range pivots produce distinct cardinalities. -const VALUES: &[i32] = &[-100, -1, 1, 2, 5, 10, 17, 25, 42, 50, 100, 250, 1000, 9999]; - -/// The complete fixture definition. `.with_index("unique")` drives `=` / `<>` -/// (HMAC); `.with_index("ore")` drives `<` `<=` `>` `>=` (ORE block terms). +/// The complete fixture definition. `IndexKind::Unique` drives `=` / `<>` +/// (HMAC); `IndexKind::Ore` drives `<` `<=` `>` `>=` (ORE block terms). pub fn spec() -> FixtureSpec<'static, i32> { FixtureSpec::new("eql_v2_int4") - .with_index("unique") - .with_index("ore") + .with_index(IndexKind::Unique) + .with_index(IndexKind::Ore) .with_column_type("jsonb") .with_values(VALUES) } @@ -40,8 +39,14 @@ mod tests { } #[test] - fn spec_has_14_values() { - assert_eq!(spec().values().len(), 14); + fn spec_includes_signed_extremes() { + // i32::MIN / MAX exercise ORE block-encoding sign-bit edges + // that the smaller earlier list did not cover. + let spec = spec(); + let values = spec.values(); + assert!(values.contains(&i32::MIN), "spec must include i32::MIN"); + assert!(values.contains(&i32::MAX), "spec must include i32::MAX"); + assert!(values.contains(&0), "spec must include 0"); } #[test] diff --git a/tests/sqlx/src/fixtures/index_kind.rs b/tests/sqlx/src/fixtures/index_kind.rs new file mode 100644 index 00000000..f1633a03 --- /dev/null +++ b/tests/sqlx/src/fixtures/index_kind.rs @@ -0,0 +1,59 @@ +//! `IndexKind` — the typed EQL search-index identifier. +//! +//! Replaces the `&str` / `FixtureIdentifier`-validated string at the +//! spec/driver boundary. `FixtureIdentifier` proves the value matches +//! `^[a-z][a-z0-9_]*$`; it does NOT prove the name is a real index type. +//! `IndexKind` proves both, at compile time. A typo at spec construction +//! (`.with_index(IndexKind::Uniqu)`) is a compile error rather than a +//! runtime "unknown EQL index identifier" panic deep in the driver. + +use std::fmt; + +/// One of the EQL search-index identifiers cipherstash-config recognises. +/// Construction is through the variants — by construction every value is +/// in the allowlist. The wire-form `&str` (used in cipherstash-config and +/// the SQL renderers) is available via `as_str` / `Display`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IndexKind { + /// `unique` — drives `=` / `<>` via HMAC. + Unique, + /// `ore` — drives `<` / `<=` / `>` / `>=` via ORE block terms. + Ore, + /// `match` — drives `LIKE` / `ILIKE` via the bloom filter. + Match, +} + +impl IndexKind { + pub fn as_str(self) -> &'static str { + match self { + IndexKind::Unique => "unique", + IndexKind::Ore => "ore", + IndexKind::Match => "match", + } + } +} + +impl fmt::Display for IndexKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_as_the_eql_wire_form_string() { + assert_eq!(IndexKind::Unique.as_str(), "unique"); + assert_eq!(IndexKind::Ore.as_str(), "ore"); + assert_eq!(IndexKind::Match.as_str(), "match"); + } + + #[test] + fn display_matches_as_str() { + assert_eq!(format!("{}", IndexKind::Unique), "unique"); + assert_eq!(format!("{}", IndexKind::Ore), "ore"); + assert_eq!(format!("{}", IndexKind::Match), "match"); + } +} diff --git a/tests/sqlx/src/fixtures/int4_values.rs b/tests/sqlx/src/fixtures/int4_values.rs new file mode 100644 index 00000000..92f6491d --- /dev/null +++ b/tests/sqlx/src/fixtures/int4_values.rs @@ -0,0 +1,31 @@ +// AUTO-GENERATED — DO NOT EDIT. +// Regenerated by `mise run build` (or `mise run codegen:domain `). +// Source of truth: tasks/codegen/types/.toml `[fixture] values`. +// This file IS committed and verified in CI (git diff --exit-code). +//! Fixture plaintext values for the int4 encrypted-domain family. +//! +//! Generated from tasks/codegen/types/int4.toml `[fixture] values` — +//! the single source of truth shared by the fixture generator +//! (`fixtures::eql_v2_int4`) and the matrix oracle +//! (`ScalarType::FIXTURE_VALUES`). + +/// Distinct plaintext values present in the `eql_v2_int4` fixture. +pub const VALUES: &[i32] = &[ + i32::MIN, + -100, + -1, + 0, + 1, + 2, + 5, + 10, + 17, + 25, + 42, + 50, + 100, + 250, + 1000, + 9999, + i32::MAX, +]; diff --git a/tests/sqlx/src/fixtures/mod.rs b/tests/sqlx/src/fixtures/mod.rs index d2c90c3f..416a3b02 100644 --- a/tests/sqlx/src/fixtures/mod.rs +++ b/tests/sqlx/src/fixtures/mod.rs @@ -1,8 +1,9 @@ //! Type-checked fixture generation framework. //! //! A fixture is one Rust file under `src/fixtures/` declaring a `FixtureSpec`. -//! `FixtureSpec::run()` generates the committed SQLx fixture script -//! `tests/sqlx/fixtures/.sql`. +//! `FixtureSpec::run()` generates the SQLx fixture script +//! `tests/sqlx/fixtures/.sql` (gitignored — regenerated on every +//! `mise run test:sqlx`). pub mod validation; @@ -10,6 +11,10 @@ pub mod eql_plaintext; pub use eql_plaintext::EqlPlaintext; +pub mod index_kind; + +pub use index_kind::IndexKind; + pub mod spec; pub use spec::FixtureSpec; @@ -18,4 +23,8 @@ pub mod cipherstash; pub mod driver; +/// Generated from tasks/codegen/types/int4.toml `[fixture] values`. +/// Committed and verified by CI; never hand-edit (`mise run codegen:domain int4`). +pub mod int4_values; + pub mod eql_v2_int4; diff --git a/tests/sqlx/src/fixtures/spec.rs b/tests/sqlx/src/fixtures/spec.rs index 35e82615..5f9b952d 100644 --- a/tests/sqlx/src/fixtures/spec.rs +++ b/tests/sqlx/src/fixtures/spec.rs @@ -21,12 +21,13 @@ //! is finished. use super::eql_plaintext::EqlPlaintext; +use super::index_kind::IndexKind; use super::validation::{ColumnType, FixtureIdentifier}; /// A fully specified fixture, ready to `.run()`. pub struct FixtureSpec<'a, T> { name: FixtureIdentifier, - indexes: Vec, + indexes: Vec, column_type: ColumnType, values: &'a [T], } @@ -51,14 +52,10 @@ impl<'a, T> FixtureSpec<'a, T> { } } - /// Add a search index (`"unique"`, `"ore"`, ...). Chainable. - /// - /// # Panics - /// Panics if `index_name` is not a valid identifier. - pub fn with_index(mut self, index_name: &str) -> Self { - let id = - FixtureIdentifier::try_from(index_name).unwrap_or_else(|e| panic!("index name: {e}")); - self.indexes.push(id); + /// Add a search index. `IndexKind` is a closed enum — a typo at the + /// call site is a compile error rather than a runtime panic. + pub fn with_index(mut self, kind: IndexKind) -> Self { + self.indexes.push(kind); self } @@ -90,7 +87,7 @@ impl<'a, T> FixtureSpec<'a, T> { self.name.as_str() } - pub fn indexes(&self) -> &[FixtureIdentifier] { + pub fn indexes(&self) -> &[IndexKind] { &self.indexes } @@ -140,7 +137,7 @@ impl<'a, T> FixtureSpec<'a, T> { CREATE TABLE public.{working} (\n \ id BIGINT PRIMARY KEY,\n \ plaintext {plaintext_type} NOT NULL,\n \ - payload jsonb\n);\n", + payload jsonb NOT NULL\n);\n", plaintext_type = T::PLAINTEXT_SQL_TYPE, ) } @@ -216,8 +213,8 @@ mod tests { fn int4_spec() -> FixtureSpec<'static, i32> { const VALUES: &[i32] = &[-1, 1, 42]; FixtureSpec::new("eql_v2_int4") - .with_index("unique") - .with_index("ore") + .with_index(IndexKind::Unique) + .with_index(IndexKind::Ore) .with_column_type("jsonb") .with_values(VALUES) } @@ -233,14 +230,15 @@ mod tests { #[test] fn records_indexes_in_order() { let s = int4_spec(); - let names: Vec<&str> = s.indexes().iter().map(FixtureIdentifier::as_str).collect(); - assert_eq!(names, vec!["unique", "ore"]); + assert_eq!(s.indexes(), &[IndexKind::Unique, IndexKind::Ore]); } #[test] fn column_type_defaults_to_jsonb() { const V: &[i32] = &[1]; - let s = FixtureSpec::new("x").with_index("unique").with_values(V); + let s = FixtureSpec::new("x") + .with_index(IndexKind::Unique) + .with_values(V); assert_eq!(s.column_type().as_str(), "jsonb"); } @@ -263,12 +261,9 @@ mod tests { let _ = FixtureSpec::<'static, i32>::new("x").with_column_type("text"); } - #[test] - #[should_panic(expected = "is not a valid identifier")] - fn validation_rejects_a_bad_index_name() { - // A bad index name panics in `.with_index()`. - let _ = FixtureSpec::<'static, i32>::new("x").with_index("BAD IX"); - } + // Note: `with_index` formerly panicked on a malformed identifier (a + // `FixtureIdentifier::try_from` failure). The typed `IndexKind` enum + // makes that case unrepresentable — a typo is now a compile error. #[test] fn completeness_rejects_a_spec_with_no_indexes() { @@ -280,7 +275,9 @@ mod tests { #[test] fn completeness_rejects_a_spec_with_no_values() { const V: &[i32] = &[]; - let s = FixtureSpec::new("x").with_index("unique").with_values(V); + let s = FixtureSpec::new("x") + .with_index(IndexKind::Unique) + .with_values(V); assert!(s.check_complete().is_err()); } diff --git a/tests/sqlx/src/helpers.rs b/tests/sqlx/src/helpers.rs index 6bf5fc4c..2e111e55 100644 --- a/tests/sqlx/src/helpers.rs +++ b/tests/sqlx/src/helpers.rs @@ -6,6 +6,19 @@ use anyhow::{Context, Result}; use serde_json; use sqlx::{PgPool, Row}; +/// Sentinel payload that satisfies every encrypted-domain CHECK in the +/// `eql_v2_{,_eq,_ord,_ord_ore}` family. Carries the EQL envelope +/// (`v`, `i`, `c`) plus *both* term keys (`hm`, `ob`) so one bind value +/// works for any variant's cast. +/// +/// Used by blocker / null-result tests where the payload is bound but +/// never decrypted — the blocker raises (or the STRICT wrapper +/// short-circuits) before the term values matter. **Not a representative +/// payload.** Real encrypted payloads come from the fixture +/// (Proxy-encrypted). +pub const PLACEHOLDER_PAYLOAD: &str = + r#"{"v":2,"i":{"t":"t","c":"c"},"c":"sample","hm":"sample","ob":["00"]}"#; + /// Fetch ORE encrypted value from pre-seeded ore table /// /// The ore table is created by migration `002_install_ore_data.sql` diff --git a/tests/sqlx/src/lib.rs b/tests/sqlx/src/lib.rs index 2f915e37..c37c176e 100644 --- a/tests/sqlx/src/lib.rs +++ b/tests/sqlx/src/lib.rs @@ -8,9 +8,17 @@ pub mod assertions; pub mod fixtures; pub mod helpers; pub mod index_types; +pub mod matrix; +pub mod scalar_domains; pub mod selectors; -pub use assertions::QueryAssertion; +// Re-export `paste` under a stable path so the `scalar_domain_matrix!` macro +// can refer to `$crate::paste::paste!` without requiring callers to depend on +// the `paste` crate directly. +#[doc(hidden)] +pub use paste; + +pub use assertions::{assert_db_error, QueryAssertion}; pub use helpers::{ analyze_table, assert_no_seq_scan, assert_sequential_ids, assert_uses_index, assert_uses_seq_scan, create_jsonb_gin_index, ensure_pg_stat_statements, explain_analyze_avg, @@ -19,8 +27,13 @@ pub use helpers::{ get_ore_text_encrypted_as_jsonb, get_ste_vec_encrypted, get_ste_vec_encrypted_pair, get_ste_vec_selector_term, get_ste_vec_sv_element, get_ste_vec_term_by_id, read_pg_stat_statements, reset_pg_stat_statements, ExplainStats, PgStatEntry, + PLACEHOLDER_PAYLOAD, }; pub use index_types as IndexTypes; +pub use scalar_domains::{ + assert_null, assert_raises, assert_scalar_plaintexts, blocker_msg, commute_op, + fetch_fixture_payload, sql_string_literal, ScalarDomainSpec, ScalarType, Variant, +}; pub use selectors::Selectors; /// Reset pg_stat_user_functions tracking before tests diff --git a/tests/sqlx/src/matrix.rs b/tests/sqlx/src/matrix.rs new file mode 100644 index 00000000..0d277af9 --- /dev/null +++ b/tests/sqlx/src/matrix.rs @@ -0,0 +1,2745 @@ +//! Type-generic test matrix for encrypted scalar domains. +//! +//! Two entry points: +//! +//! - **`ordered_numeric_matrix!`** — the recommended wrapper. For an +//! ordered numeric scalar (i32, i64, f64, date, numeric, timestamp, +//! ...) all four variants are present, the operator surface is +//! identical, and the only inputs that change per type are the scalar +//! itself, the suite token (used to derive domain + test names), the +//! EQL type name (the fixture `scripts(...)` ref), and the pivot +//! values. Invocation is ~5 lines. +//! +//! - **`scalar_domain_matrix!`** — the lower-level macro the wrapper +//! expands to. Use directly only for types with a non-standard surface +//! (e.g. equality-only scalars like bool). +//! +//! Each invocation emits one `#[sqlx::test]` per (category, domain, +//! operator, pivot) tuple. Categories: sanity, correctness, cross-shape, +//! supported-NULL, blocker raises, index engagement, ORDER BY, ORDER BY +//! USING. +//! +//! Per-domain capability and payload metadata live in `Variant` (see +//! `scalar_domains.rs`); the macro derives the runtime `ScalarDomainSpec` +//! from `<$scalar as ScalarType>::PG_TYPE` + `Variant::` so no +//! per-type constants are needed. + +// ============================================================================ +// EXPLAIN plan inspection — node-type-aware index-engagement assertion. +// +// The index-engagement arms (`*_index_engages_*`, `*_ord_routes_through_ob`) +// previously asserted `plan_text.contains(index_name)` on a *text* EXPLAIN. +// That substring match is too weak in two independent ways: +// +// 1. It cannot distinguish an actual index-scan node from an incidental +// textual mention of the index name (e.g. inside an `Index Cond`, a +// filter expression, or a "Recheck Cond" line) — any line carrying the +// string passes, even if the relation is still read in full. +// 2. It says nothing about *which kind* of node read the relation. A +// Bitmap-recheck that still touches every heap row, or a node that +// merely references the index, looks identical to a clean Index Scan. +// +// `assert_index_scan_uses` parses `EXPLAIN (FORMAT JSON)` and requires a +// genuine index-scan node (`Index Scan` / `Index Only Scan` / +// `Bitmap Index Scan`) whose `Index Name` is the expected index. This is a +// structurally meaningful assertion even with `enable_seqscan = off`. +// +// LOUD CAVEAT — VALIDITY, NOT PREFERENCE. Even after this upgrade, the +// index-engagement arms run against a ~17-row fixture with +// `SET LOCAL enable_seqscan = off`. With the only cheaper alternative +// (seqscan) forcibly disabled, the planner will pick essentially any usable +// index. So these arms prove the index is USABLE / VALID (the operator +// resolves through the functional index and produces a real index-scan node) +// — they do NOT prove the planner would PREFER the index under realistic +// costs. Cost-preference is proven exclusively by `__scalar_matrix_scale_case` +// (the `*_scale_preference_*` tests), which build ~5000 rows and leave +// `enable_seqscan` ON. Those are `#[cfg(feature = "scale")]` and are OFF in +// default PR CI. Do not read a green index-engagement arm as "the planner +// chooses this index" — it only means "the planner *can* use this index". +// ============================================================================ + +/// Assert that a JSON EXPLAIN plan contains a real index-scan node whose +/// `Index Name` matches `index_name`. +/// +/// Recursively walks the plan tree. A node qualifies only if its `Node Type` +/// is one of `Index Scan`, `Index Only Scan`, or `Bitmap Index Scan` AND its +/// `Index Name` equals `index_name`. This is strictly stronger than a +/// substring match on the text plan, which would also accept an index name +/// appearing in an `Index Cond` / `Recheck Cond` / filter expression without +/// any index-scan node actually reading the relation. +/// +/// `query` is the bare SQL (no `EXPLAIN` prefix); it is interpolated directly, +/// so it must be a trusted/hardcoded string. `tx` is any sqlx executor. +/// +/// Returns `Err` (with the full pretty-printed plan) if no qualifying node is +/// found, so it composes with the `?` operator inside the generated arms. +pub async fn assert_index_scan_uses<'e, E>( + executor: E, + query: &str, + index_name: &str, + context: &str, +) -> anyhow::Result<()> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + let sql = format!("EXPLAIN (FORMAT JSON) {query}"); + let plan: serde_json::Value = sqlx::query_scalar(&sql) + .fetch_one(executor) + .await + .map_err(|e| anyhow::anyhow!("running `{sql}`: {e}"))?; + + let mut index_scan_nodes: Vec<(String, String)> = Vec::new(); + collect_index_scan_nodes(&plan, &mut index_scan_nodes); + + let matched = index_scan_nodes + .iter() + .any(|(_node_type, name)| name == index_name); + + anyhow::ensure!( + matched, + "{context}: expected an index-scan node (Index Scan / Index Only Scan / \ + Bitmap Index Scan) referencing index `{index_name}`, but found none. \ + Index-scan nodes present: {index_scan_nodes:?}. Full plan:\n{}", + serde_json::to_string_pretty(&plan).unwrap_or_else(|_| plan.to_string()), + ); + Ok(()) +} + +/// Recursively collect `(Node Type, Index Name)` pairs for every index-scan +/// node in a JSON EXPLAIN plan tree. Only the three index-scan node types are +/// collected; other nodes (Seq Scan, Aggregate, Sort, ...) are skipped but +/// their children are still walked. +fn collect_index_scan_nodes(value: &serde_json::Value, found: &mut Vec<(String, String)>) { + match value { + serde_json::Value::Object(map) => { + if let Some(node_type) = map.get("Node Type").and_then(|v| v.as_str()) { + if matches!( + node_type, + "Index Scan" | "Index Only Scan" | "Bitmap Index Scan" + ) { + let index_name = map + .get("Index Name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + found.push((node_type.to_string(), index_name.to_string())); + } + } + for v in map.values() { + collect_index_scan_nodes(v, found); + } + } + serde_json::Value::Array(arr) => { + for item in arr { + collect_index_scan_nodes(item, found); + } + } + _ => {} + } +} + +/// Convention wrapper for ordered numeric scalars. Expands to a +/// `scalar_domain_matrix!` invocation with the standard 4 variants, 6 +/// supported comparison operators, 2 path operators, and the standard +/// blocker / index partitions. +/// +/// `eql_type` is the EQL domain type name (e.g. `"eql_v2_int4"`). It is +/// used as the SQLx fixture `scripts(...)` ref, which sqlx parses as a +/// token-level string literal — so it must be a literal, not derived. +/// +/// Pivots — the comparison anchors swept by the correctness / cross-shape +/// arms — are derived from the scalar type: `MIN`, `MAX`, and zero +/// (`Default::default()`). The fixture must contain those three plaintext +/// rows, since each pivot's ciphertext is fetched at test time via +/// `fetch_fixture_payload`. +#[macro_export] +macro_rules! ordered_numeric_matrix { + ( + suite = $suite:ident, + scalar = $scalar:ty, + eql_type = $eql_type:literal $(,)? + ) => { + $crate::scalar_domain_matrix! { + suite = $suite, + scalar = $scalar, + eql_type = $eql_type, + // Relative to the suite source file at + // tests/sqlx/tests/encrypted_domain/scalars/.rs; sqlx's + // include_str! resolves it against that file. Every scalar + // suite lives at this depth, so the path is fixed here rather + // than repeated per invocation. + fixture_path = "../../../fixtures", + all_domains = [(storage, Storage), (eq, Eq), (ord, Ord), (ord_ore, OrdOre)], + eq_domains = [(eq, Eq), (ord, Ord), (ord_ore, OrdOre)], + ord_domains = [(ord, Ord), (ord_ore, OrdOre)], + ord_ore_domains = [(ord_ore, OrdOre)], + pivots = [ + (min, <$scalar>::MIN), + (max, <$scalar>::MAX), + (zero, <$scalar as ::core::default::Default>::default()), + ], + eq_ops = [(eq, "="), (neq, "<>")], + ord_ops = [(lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")], + index_combos = [ + (eq, Eq, "eql_v2.eq_term", "btree", [(eq, "=")]), + (eq, Eq, "eql_v2.eq_term", "hash", [(eq, "=")]), + (ord, Ord, "eql_v2.ord_term", "btree", + [(eq, "="), (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")]), + (ord_ore, OrdOre, "eql_v2.ord_term", "btree", + [(eq, "="), (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")]), + ], + blocker_combos = [ + (storage, Storage, [ + (eq, "="), (neq, "<>"), + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + (eq, Eq, [ + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + (ord, Ord, [(contains, "@>"), (contained_by, "<@")]), + (ord_ore, OrdOre, [(contains, "@>"), (contained_by, "<@")]), + ], + // Always-on cost-preference proof (#239 thread 17): the recommended + // converged ordered domain, ord_term btree. One curated combo keeps + // PR CI cost bounded. + scale_default_combos = [ + (ord, Ord, "eql_v2.ord_term", "btree"), + ], + } + }; +} + +/// Convention wrapper for equality-only scalars (no ord variants). Bool +/// is the canonical consumer: `=` / `<>` are meaningful; the four ord +/// operators are deliberate blockers. +/// +/// Expands to `scalar_domain_matrix!` with `ord_domains = []`, +/// `ord_ore_domains = []`, no btree-ord index combo, and blocker_combos +/// covering the ord operators on every materialised variant. Order-by / +/// order-by-using arms emit zero tests because they iterate empty +/// ord_domains. +/// +/// **Status:** this umbrella has no in-tree consumer yet. It exists so +/// that adding `bool` (or any other equality-only scalar) is one +/// `impl ScalarType` + fixture + one-line macro invocation, with no +/// macro authoring required. Runtime validation lands with bool. +#[macro_export] +macro_rules! eq_only_scalar_matrix { + ( + suite = $suite:ident, + scalar = $scalar:ty, + eql_type = $eql_type:literal, + pivots = [$($pivot:tt),+ $(,)?] $(,)? + ) => { + $crate::scalar_domain_matrix! { + suite = $suite, + scalar = $scalar, + eql_type = $eql_type, + // Fixed path; see `ordered_numeric_matrix!` for the rationale. + fixture_path = "../../../fixtures", + all_domains = [(storage, Storage), (eq, Eq)], + eq_domains = [(eq, Eq)], + ord_domains = [], + ord_ore_domains = [], + pivots = [$($pivot),+], + eq_ops = [(eq, "="), (neq, "<>")], + ord_ops = [(lt, "<"), (lte, "<="), (gt, ">"), (gte, ">=")], + index_combos = [ + (eq, Eq, "eql_v2.eq_term", "btree", [(eq, "=")]), + (eq, Eq, "eql_v2.eq_term", "hash", [(eq, "=")]), + ], + blocker_combos = [ + (storage, Storage, [ + (eq, "="), (neq, "<>"), + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + (eq, Eq, [ + (lt, "<"), (lte, "<="), (gt, ">"), (gte, ">="), + (contains, "@>"), (contained_by, "<@"), + ]), + ], + // Equality-only scalars have no ordered functional index to prefer. + scale_default_combos = [], + } + }; +} + +/// Low-level entry point. Use `ordered_numeric_matrix!` instead unless +/// your type's surface deviates from the standard ordered-numeric shape. +#[macro_export] +macro_rules! scalar_domain_matrix { + ( + suite = $suite:ident, + scalar = $scalar:ty, + eql_type = $eql_type:literal, + fixture_path = $fixture_path:literal, + all_domains = [$(($all_name:ident, $all_variant:ident)),+ $(,)?], + eq_domains = [$($eq_dom:tt),+ $(,)?], + ord_domains = [$($ord_dom:tt),* $(,)?], + ord_ore_domains = [$($ord_ore_dom:tt),* $(,)?], + pivots = [$($pivot:tt),+ $(,)?], + eq_ops = [$($eq_op:tt),+ $(,)?], + ord_ops = [$($ord_op:tt),+ $(,)?], + index_combos = [$($index_combo:tt),+ $(,)?], + blocker_combos = [$($blocker_combo:tt),+ $(,)?], + // Curated combo(s) that get an ALWAYS-ON cost-preference test (#239 + // thread 17). May be empty (e.g. equality-only scalars have no ordered + // index to prefer). + scale_default_combos = [$($scale_default_combo:tt),* $(,)?] $(,)? + ) => { + $crate::__scalar_matrix_sanity! { + suite = $suite, scalar = $scalar, + domains = [$(($all_name, $all_variant)),+], + } + $crate::__scalar_matrix_dxop_outer! { + case = __scalar_matrix_correctness_case, + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($eq_dom),+], ops_list = [$($eq_op),+], + pivots_list = [$($pivot),+], + } + $crate::__scalar_matrix_dxop_outer! { + case = __scalar_matrix_correctness_case, + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], ops_list = [$($ord_op),+], + pivots_list = [$($pivot),+], + } + $crate::__scalar_matrix_dxop_outer! { + case = __scalar_matrix_cross_shape_case, + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($eq_dom),+], ops_list = [$($eq_op),+], + pivots_list = [$($pivot),+], + } + $crate::__scalar_matrix_dxop_outer! { + case = __scalar_matrix_cross_shape_case, + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], ops_list = [$($ord_op),+], + pivots_list = [$($pivot),+], + } + $crate::__scalar_matrix_dxo_outer! { + case = __scalar_matrix_supported_null_case, + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($eq_dom),+], ops_list = [$($eq_op),+], + } + $crate::__scalar_matrix_dxo_outer! { + case = __scalar_matrix_supported_null_case, + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], ops_list = [$($ord_op),+], + } + $crate::__scalar_matrix_blocker_outer! { + suite = $suite, scalar = $scalar, + combos = [$($blocker_combo),+], + } + $crate::__scalar_matrix_payload_check_outer! { + suite = $suite, scalar = $scalar, + domains = [$(($all_name, $all_variant)),+], + } + $crate::__scalar_matrix_path_op_outer! { + suite = $suite, scalar = $scalar, + domains = [$(($all_name, $all_variant)),+], + } + $crate::__scalar_matrix_native_absent_outer! { + suite = $suite, scalar = $scalar, + domains = [$(($all_name, $all_variant)),+], + } + $crate::__scalar_matrix_typed_column_outer! { + suite = $suite, scalar = $scalar, + combos = [$($blocker_combo),+], + } + $crate::__scalar_matrix_planner_metadata_outer! { + suite = $suite, scalar = $scalar, group = eq, + domains = [$($eq_dom),+], + ops_list = [$($eq_op),+], + } + $crate::__scalar_matrix_planner_metadata_outer! { + suite = $suite, scalar = $scalar, group = ord, + domains = [$($ord_dom),*], + ops_list = [$($ord_op),+], + } + $crate::__scalar_matrix_index_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + combos = [$($index_combo),+], + } + $crate::__scalar_matrix_scale_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + combos = [$($index_combo),+], + } + $crate::__scalar_matrix_scale_default_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + combos = [$($scale_default_combo),*], + } + $crate::__scalar_matrix_fixture_shape! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + } + $crate::__scalar_matrix_ord_routes_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], + } + $crate::__scalar_matrix_ore_injectivity_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_ore_dom),*], + } + $crate::__scalar_matrix_aggregate_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], + } + $crate::__scalar_matrix_aggregate_group_by_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], + } + $crate::__scalar_matrix_aggregate_parallel_outer! { + suite = $suite, scalar = $scalar, + domains = [$($ord_dom),*], + } + $crate::__scalar_matrix_aggregate_typecheck_outer! { + suite = $suite, scalar = $scalar, + domains = [$(($all_name, $all_variant)),+], + } + $crate::__scalar_matrix_count_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$(($all_name, $all_variant)),+], + } + $crate::__scalar_matrix_order_by_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], + } + $crate::__scalar_matrix_order_by_nulls_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], + } + $crate::__scalar_matrix_order_by_using_outer! { + suite = $suite, scalar = $scalar, script = $eql_type, script_path = $fixture_path, + domains = [$($ord_dom),*], ops_list = [$($ord_op),+], + } + }; +} + +// ============================================================================ +// Helpers: spec construction inside generated test bodies. +// ============================================================================ + +/// Inside a generated test body, build the runtime `ScalarDomainSpec` +/// from `<$scalar>::PG_TYPE` + `Variant::$variant`. All categories use +/// this — keeps the per-case body short. +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_spec { + ($scalar:ty, $variant:ident) => { + $crate::scalar_domains::ScalarDomainSpec::new::<$scalar>( + $crate::scalar_domains::Variant::$variant, + ) + }; +} + +// ============================================================================ +// Sanity category — one test per domain. Cheap thread-through check that +// the macro expanded and the trait wires up. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_sanity { + ( + suite = $suite:ident, + scalar = $scalar:ty, + domains = [$(($name:ident, $variant:ident)),+ $(,)?] $(,)? + ) => { + $( + $crate::paste::paste! { + #[sqlx::test] + async fn [](_pool: sqlx::PgPool) + -> anyhow::Result<()> + { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + assert!(!spec.sql_domain.is_empty()); + assert!(<$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name() + .starts_with("fixtures.")); + Ok(()) + } + } + )+ + }; +} + +// ============================================================================ +// Shared cartesian-product drivers. `macro_rules!` cannot cross-product +// independent lists in one repetition (`$($($(…)*)*)*` over flat depth-1 +// lists does not compile — every metavariable is bound at depth 1), so one +// recursion level fixes one dimension. These generic drivers do that fan-out +// once and dispatch to a per-category leaf macro named by `case`. The +// dimension lists are independent: this is a product, not a zip. +// ============================================================================ + +// domain × op × pivot. +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_dxop_outer { + ( + case = $case:ident, + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?], + ops_list = $ops_list:tt, pivots_list = $pivots_list:tt $(,)? + ) => { + $( + $crate::__scalar_matrix_dxop_mid! { + case = $case, + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + domain = $domain, ops_list = $ops_list, pivots_list = $pivots_list, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_dxop_mid { + ( + case = $case:ident, + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident), + ops_list = [$($op:tt),+ $(,)?], pivots_list = $pivots_list:tt $(,)? + ) => { + $( + $crate::__scalar_matrix_dxop_inner! { + case = $case, + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op = $op, pivots_list = $pivots_list, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_dxop_inner { + ( + case = $case:ident, + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op = ($op_name:ident, $op:literal), + pivots_list = [$($pivot:tt),+ $(,)?] $(,)? + ) => { + $( + $crate::$case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op_name = $op_name, op = $op, pivot = $pivot, + } + )+ + }; +} + +// domain × op. +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_dxo_outer { + ( + case = $case:ident, + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?], ops_list = $ops_list:tt $(,)? + ) => { + $( + $crate::__scalar_matrix_dxo_inner! { + case = $case, + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + domain = $domain, ops_list = $ops_list, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_dxo_inner { + ( + case = $case:ident, + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident), + ops_list = [$($op:tt),+ $(,)?] $(,)? + ) => { + $( + $crate::$case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, op = $op, + } + )+ + }; +} + +// ============================================================================ +// Correctness category — leaf for the domain × op × pivot driver: assert the +// row set from `WHERE col op pivot` matches `T::expected_forward(op, pivot)`. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_correctness_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op_name = $op_name:ident, op = $op:literal, + pivot = ($pivot_name:ident, $pivot_val:expr) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let pivot: $scalar = $pivot_val; + let payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, pivot).await?; + let lit = $crate::scalar_domains::sql_string_literal(&payload); + let predicate = format!( + "payload::{d} {op} {lit}::jsonb::{d}", + d = &spec.sql_domain, op = $op, + ); + let expected = + <$scalar as $crate::scalar_domains::ScalarType>::expected_forward($op, pivot); + $crate::scalar_domains::assert_scalar_plaintexts::<$scalar>( + &pool, &spec.sql_domain, $op, &predicate, &expected, + ) + .await + } + } + }; +} + +// ============================================================================ +// Cross-shape category — leaf for the domain × op × pivot driver: per +// (domain, op, pivot) sweep the three operator argument shapes (d,d), (d,j), +// (j,d) and assert each returns the right row count. The `j_d` shape uses the +// commuted operator's expected set. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_cross_shape_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op_name = $op_name:ident, op = $op:literal, + pivot = ($pivot_name:ident, $pivot_val:expr) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let pivot: $scalar = $pivot_val; + let payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, pivot).await?; + let lit = $crate::scalar_domains::sql_string_literal(&payload); + let forward_count = + <$scalar as $crate::scalar_domains::ScalarType>::expected_forward($op, pivot) + .len() as i64; + let commuted_count = <$scalar as $crate::scalar_domains::ScalarType>::expected_forward( + $crate::scalar_domains::commute_op($op), pivot, + ).len() as i64; + let d = &spec.sql_domain; + let shapes = [ + ("d_d", format!("payload::{d} {op} {lit}::jsonb::{d}", op = $op), forward_count), + ("d_j", format!("payload::{d} {op} {lit}::jsonb", op = $op), forward_count), + ("j_d", format!("{lit}::jsonb {op} payload::{d}", op = $op), commuted_count), + ]; + let table = <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + for (shape_label, predicate, expected_count) in shapes { + let count_sql = format!("SELECT count(*) FROM {table} WHERE {predicate}"); + let count: i64 = sqlx::query_scalar(&count_sql).fetch_one(&pool).await?; + assert_eq!( + count, expected_count, + "domain={} op={} pivot={:?} shape={shape_label} SQL={count_sql} \ + expected {expected_count} rows, got {count}", + d, $op, pivot + ); + } + Ok(()) + } + } + }; +} + +// ============================================================================ +// Supported-NULL category — leaf for the domain × op driver: STRICT wrappers +// must propagate NULL on all three NULL positions (left, right, both). +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_supported_null_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op = ($op_name:ident, $op:literal) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let payload = $crate::helpers::PLACEHOLDER_PAYLOAD; + let sql = format!( + "SELECT $1::jsonb::{d} {op} $2::jsonb::{d}", + d = &spec.sql_domain, op = $op, + ); + $crate::scalar_domains::assert_null(&pool, &sql, &[Some(payload), None]).await?; + $crate::scalar_domains::assert_null(&pool, &sql, &[None, Some(payload)]).await?; + $crate::scalar_domains::assert_null(&pool, &sql, &[None, None]).await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Blocker category — per blocked (domain, op), sweep 3 arg shapes (all +// must raise) and 3 NULL positions on the (d, d) shape (non-STRICT proof). +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_blocker_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + combos = [$($combo:tt),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_blocker_combo! { + suite = $suite, scalar = $scalar, combo = $combo, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_blocker_combo { + ( + suite = $suite:ident, scalar = $scalar:ty, + combo = ($dom_name:ident, $variant:ident, [$($op:tt),+ $(,)?]) $(,)? + ) => { + $( + $crate::__scalar_matrix_blocker_case! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, op = $op, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_blocker_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = $variant:ident, + op = ($op_name:ident, $op:literal) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let payload = $crate::helpers::PLACEHOLDER_PAYLOAD; + let msg = $crate::scalar_domains::blocker_msg(&spec.sql_domain, $op); + let d = &spec.sql_domain; + + // Sweep 3 arg shapes — every overload must engage. + let shapes: [(String, String); 3] = [ + (format!("$1::jsonb::{d}"), format!("$2::jsonb::{d}")), + (format!("$1::jsonb::{d}"), "$2::jsonb".into()), + ("$1::jsonb".into(), format!("$2::jsonb::{d}")), + ]; + for (lhs, rhs) in shapes { + let sql = format!("SELECT {lhs} {op} {rhs}", op = $op); + $crate::scalar_domains::assert_raises( + &pool, &sql, &[Some(payload), Some(payload)], &msg, + ).await?; + } + + // Sweep 3 NULL positions on the (d, d) shape — blockers + // are non-STRICT so they must engage on every NULL config. + let null_sql = format!( + "SELECT $1::jsonb::{d} {op} $2::jsonb::{d}", op = $op, + ); + $crate::scalar_domains::assert_raises(&pool, &null_sql, &[None, Some(payload)], &msg).await?; + $crate::scalar_domains::assert_raises(&pool, &null_sql, &[Some(payload), None], &msg).await?; + $crate::scalar_domains::assert_raises(&pool, &null_sql, &[None, None], &msg).await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Payload-check category — per variant, the domain CHECK rejects payloads +// missing required keys (envelope `v`/`i`/`c` plus `Variant::required_term()`) +// and rejects non-object payloads. Required keys are derived from +// `Variant::payload_required_keys()` so future variants pick up coverage. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_payload_check_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + domains = [$(($dom_name:ident, $variant:ident)),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_payload_check_case! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_payload_check_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let baseline = $crate::helpers::PLACEHOLDER_PAYLOAD; + + // Each required key must trigger CHECK rejection when stripped. + for key in spec.variant.payload_required_keys() { + let sql = format!( + "SELECT ('{baseline}'::jsonb - '{key}')::{d}", + ); + let err = sqlx::query(&sql) + .fetch_one(&pool) + .await + .expect_err(&format!( + "{d} must reject payload missing `{key}`: {sql}" + )) + .to_string(); + anyhow::ensure!( + err.contains("violates check constraint"), + "expected check-constraint violation for missing `{key}` on {d}, got: {err}", + ); + } + + // Non-object payloads are rejected for every variant. + let sql = format!(r#"SELECT '["v","i","c"]'::jsonb::{d}"#); + let err = sqlx::query(&sql) + .fetch_one(&pool) + .await + .expect_err(&format!("{d} must reject non-object payload")) + .to_string(); + anyhow::ensure!( + err.contains("violates check constraint"), + "expected check-constraint violation for non-object on {d}, got: {err}", + ); + Ok(()) + } + } + }; +} + +// ============================================================================ +// Path-operator category — `->` and `->>` must raise the blocker on every +// variant (encrypted domains don't expose JSON path access). Three arg +// shapes per op, matching the parameter blocker arm's coverage. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_path_op_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + domains = [$(($dom_name:ident, $variant:ident)),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_path_op_case! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_path_op_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let payload = $crate::helpers::PLACEHOLDER_PAYLOAD; + + for op in ["->", "->>"] { + let msg = $crate::scalar_domains::blocker_msg(d, op); + for sql in [ + format!("SELECT $1::jsonb::{d} {op} 'field'::text"), + format!("SELECT $1::jsonb::{d} {op} 0::integer"), + format!("SELECT $1::jsonb {op} $1::jsonb::{d}"), + ] { + $crate::scalar_domains::assert_raises( + &pool, &sql, &[Some(payload)], &msg, + ).await?; + } + } + Ok(()) + } + } + }; +} + +// ============================================================================ +// Native-absent category — `~~` / `~~*` (LIKE / ILIKE) are deliberately +// not declared on encrypted-domain types (no pattern-match capability), +// so resolution falls back to PostgreSQL's "operator does not exist" +// rather than an EQL blocker. Pin that they stay absent on every variant. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_native_absent_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + domains = [$(($dom_name:ident, $variant:ident)),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_native_absent_case! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_native_absent_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let payload = $crate::helpers::PLACEHOLDER_PAYLOAD; + + for op in ["~~", "~~*"] { + let sql = format!("SELECT $1::jsonb::{d} {op} $2::jsonb::{d}"); + $crate::scalar_domains::assert_raises( + &pool, &sql, + &[Some(payload), Some(payload)], + "operator does not exist", + ).await?; + } + Ok(()) + } + } + }; +} + +// ============================================================================ +// Typed-column blocker category — pins the bare `WHERE col op col` form a +// real caller writes. The parameter blocker arm uses $1/$2 binds; this +// form resolves the same overloads through a different planner path +// (column-typed operand vs. cast-expression operand). One test per +// (variant, blocker-ops list), savepoint-isolated to avoid abort. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_typed_column_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + combos = [$($combo:tt),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_typed_column_case! { + suite = $suite, scalar = $scalar, combo = $combo, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_typed_column_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + combo = ($dom_name:ident, $variant:ident, [$(($op_name:ident, $op:literal)),+ $(,)?]) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let payload = $crate::helpers::PLACEHOLDER_PAYLOAD; + + let mut tx = pool.begin().await?; + let create_sql = format!( + "CREATE TEMP TABLE typed_col (\ + id integer GENERATED ALWAYS AS IDENTITY,\ + value {d}\ + ) ON COMMIT DROP" + ); + sqlx::query(&create_sql).execute(&mut *tx).await?; + let insert_sql = format!( + "INSERT INTO typed_col(value) VALUES ($1::jsonb::{d})" + ); + sqlx::query(&insert_sql).bind(payload).execute(&mut *tx).await?; + + $( + sqlx::query("SAVEPOINT op_probe").execute(&mut *tx).await?; + let sql = format!("SELECT * FROM typed_col WHERE value {op} value", op = $op); + let err = sqlx::query(&sql) + .fetch_all(&mut *tx) + .await + .expect_err(&format!("{d} column {op} must raise", op = $op)) + .to_string(); + let expected = $crate::scalar_domains::blocker_msg(d, $op); + anyhow::ensure!( + err.contains(&expected), + "unexpected error for {sql}: got {err}, want {expected}", + ); + sqlx::query("ROLLBACK TO SAVEPOINT op_probe").execute(&mut *tx).await?; + )+ + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Planner-metadata category — for every (variant, supported-op) the +// declared operator must carry COMMUTATOR, NEGATOR, and the RESTRICT / +// JOIN selectivity estimators on all 3 arg-shapes. Without these the +// planner cannot normalise commuted/negated predicates or cost them. +// Called twice from `scalar_domain_matrix!`: once for (eq_domains, +// eq_ops), once for (ord_domains, ord_ops). Storage variants have no +// supported ops and so don't emit a test. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_planner_metadata_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, group = $group:ident, + domains = [$(($dom_name:ident, $variant:ident)),* $(,)?], + ops_list = $ops_list:tt $(,)? + ) => { + $( + $crate::__scalar_matrix_planner_metadata_case! { + suite = $suite, scalar = $scalar, group = $group, + dom_name = $dom_name, variant = $variant, + ops_list = $ops_list, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_planner_metadata_case { + ( + suite = $suite:ident, scalar = $scalar:ty, group = $group:ident, + dom_name = $dom_name:ident, variant = $variant:ident, + ops_list = [$(($op_name:ident, $op:literal)),+ $(,)?] $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let ops: &[&str] = &[$($op),+]; + let op_list = ops.iter() + .map(|o| format!("'{o}'")) + .collect::>() + .join(", "); + let sql = format!( + r#" + SELECT o.oprname, + lt.typname AS lhs, + rt.typname AS rhs, + o.oprcom <> 0 AS has_commutator, + o.oprnegate <> 0 AS has_negator, + o.oprrest::oid <> 0 AS has_restrict, + o.oprjoin::oid <> 0 AS has_join + FROM pg_catalog.pg_operator o + JOIN pg_catalog.pg_type lt ON lt.oid = o.oprleft + JOIN pg_catalog.pg_type rt ON rt.oid = o.oprright + WHERE o.oprname IN ({op_list}) + AND (lt.typname = '{d}' OR rt.typname = '{d}') + "# + ); + let rows: Vec<(String, String, String, bool, bool, bool, bool)> = + sqlx::query_as(&sql).fetch_all(&pool).await?; + + let expected = ops.len() * 3; + anyhow::ensure!( + rows.len() == expected, + "expected {expected} rows ({n_ops} ops x 3 arg shapes) on {d}, got {got}", + n_ops = ops.len(), + got = rows.len(), + ); + for (op, lhs, rhs, has_com, has_neg, has_rest, has_join) in &rows { + anyhow::ensure!(*has_com, + "operator {op}({lhs},{rhs}) must declare COMMUTATOR"); + anyhow::ensure!(*has_neg, + "operator {op}({lhs},{rhs}) must declare NEGATOR"); + anyhow::ensure!(*has_rest, + "operator {op}({lhs},{rhs}) must declare RESTRICT"); + anyhow::ensure!(*has_join, + "operator {op}({lhs},{rhs}) must declare JOIN"); + } + Ok(()) + } + } + }; +} + +// ============================================================================ +// Scale-preference category — feature-gated. Builds a temp table with +// ~5000 filler rows plus one selective pivot, creates the functional +// index, and asserts the planner *prefers* the index with +// `enable_seqscan` left on. The index_engages arm forces seqscan off and +// only proves the index is *usable*; this proves the planner picks it. +// Off by default (`#[cfg(feature = "scale")]`) so PR CI stays fast. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_scale_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + combos = [$($combo:tt),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_scale_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, combo = $combo, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_scale_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + combo = ( + $dom_name:ident, $variant:ident, + $extractor:literal, $using:literal, + [$(($op_name:ident, $op:literal)),+ $(,)?] $(,)? + ) $(,)? + ) => { + $crate::paste::paste! { + #[cfg(feature = "scale")] + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let table = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_scale_", $using, + ); + let index = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_scale_", $using, "_idx", + ); + + let values: &[$scalar] = <$scalar as ScalarType>::FIXTURE_VALUES; + anyhow::ensure!(values.len() >= 2, + "scale test requires >= 2 fixture rows for distinct filler/pivot"); + let filler = values[0]; + let pivot = values[values.len() / 2]; + let filler_payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, filler).await?; + let pivot_payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, pivot).await?; + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO {table}(value) \ +SELECT $1::jsonb::{d} FROM generate_series(1, 5000)", + )).bind(&filler_payload).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO {table}(value) VALUES ($1::jsonb::{d})", + )).bind(&pivot_payload).execute(&mut *tx).await?; + sqlx::query(&format!( + "CREATE INDEX {index} ON {table} USING {using} ({extractor}(value))", using = $using, extractor = $extractor, + )).execute(&mut *tx).await?; + sqlx::query(&format!("ANALYZE {table}")) + .execute(&mut *tx).await?; + + let lit = pivot_payload.replace('\'', "''"); + let plan: Vec = sqlx::query_scalar(&format!( + "EXPLAIN SELECT * FROM {table} WHERE value = '{lit}'::jsonb::{d}", + )).fetch_all(&mut *tx).await?; + let plan_text = plan.join("\n"); + anyhow::ensure!(plan_text.contains(index), + "with seqscan enabled the planner must prefer the {extractor} \ +{using} index for a selective = ; plan:\n{plan_text}", + extractor = $extractor, using = $using, + ); + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Scale-preference DEFAULT category — the always-on counterpart of the +// feature-gated scale sweep above (#239 thread 17). For one curated combo +// (the recommended ordered domain, ord_term btree) it builds ~5000 filler +// rows + one selective pivot, ANALYZEs, and — leaving `enable_seqscan` ON — +// asserts the planner PREFERS the functional index under realistic costs. +// Unlike the index-engagement arms (validity only, seqscan forced off), this +// proves cost-preference; unlike the `*_scale_preference_*` sweep it runs in +// default PR CI. The assertion is node-type-aware via `assert_index_scan_uses` +// (a genuine Index/Index-Only/Bitmap-Index-Scan node referencing the index), +// so it cannot be satisfied by an incidental textual mention of the index. +// Curated to a single combo so PR CI cost stays bounded. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_scale_default_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + combos = [$($combo:tt),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_scale_default_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, combo = $combo, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_scale_default_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + combo = ($dom_name:ident, $variant:ident, $extractor:literal, $using:literal) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let table = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_scaledef_", $using, + ); + let index = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_scaledef_", $using, "_idx", + ); + + let values: &[$scalar] = <$scalar as ScalarType>::FIXTURE_VALUES; + anyhow::ensure!(values.len() >= 2, + "scale test requires >= 2 fixture rows for distinct filler/pivot"); + let filler = values[0]; + let pivot = values[values.len() / 2]; + let filler_payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, filler).await?; + let pivot_payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, pivot).await?; + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO {table}(value) \ +SELECT $1::jsonb::{d} FROM generate_series(1, 5000)", + )).bind(&filler_payload).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO {table}(value) VALUES ($1::jsonb::{d})", + )).bind(&pivot_payload).execute(&mut *tx).await?; + sqlx::query(&format!( + "CREATE INDEX {index} ON {table} USING {using} ({extractor}(value))", using = $using, extractor = $extractor, + )).execute(&mut *tx).await?; + sqlx::query(&format!("ANALYZE {table}")) + .execute(&mut *tx).await?; + // enable_seqscan left ON: this is a cost-preference proof, not a + // validity check. With ~5000 filler rows and a single selective + // pivot, a correctly-costed plan must choose the functional index. + + let lit = pivot_payload.replace('\'', "''"); + $crate::matrix::assert_index_scan_uses( + &mut *tx, + &format!("SELECT * FROM {table} WHERE value = '{lit}'::jsonb::{d}"), + index, + "with seqscan ON the planner must PREFER the ord_term functional index for a selective =", + ).await?; + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Fixture-shape category — one test per type that pins the fixture's +// structural invariants: row count matches `T::FIXTURE_VALUES.len()`, +// ids are sequential from 1, plaintext column matches FIXTURE_VALUES in +// order, every payload carries the variant terms (`hm`, `ob`, `c`), +// distinct plaintexts produce distinct hm terms, every payload declares +// `v=2`. A single test runs all assertions to keep pool-setup cost +// bounded. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_fixture_shape { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let table = <$scalar as ScalarType>::fixture_table_name(); + let expected: &[$scalar] = <$scalar as ScalarType>::FIXTURE_VALUES; + let n = expected.len() as i64; + + let count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table}", + )).fetch_one(&pool).await?; + anyhow::ensure!(count == n, + "row count must match FIXTURE_VALUES.len(): want {n}, got {count}"); + + let ids: Vec = sqlx::query_scalar(&format!( + "SELECT id FROM {table} ORDER BY id", + )).fetch_all(&pool).await?; + anyhow::ensure!(ids == (1..=n).collect::>(), + "ids must be sequential from 1: got {ids:?}"); + + let plaintexts: Vec<$scalar> = sqlx::query_scalar(&format!( + "SELECT plaintext FROM {table} ORDER BY id", + )).fetch_all(&pool).await?; + anyhow::ensure!(plaintexts == expected, + "plaintext column must match FIXTURE_VALUES in order"); + + for (label, predicate) in [ + ("hm string", "payload->'hm' IS NULL OR jsonb_typeof(payload->'hm') <> 'string'"), + ("ob array", "payload->'ob' IS NULL OR jsonb_typeof(payload->'ob') <> 'array'"), + ("c string", "payload->'c' IS NULL OR jsonb_typeof(payload->'c') <> 'string'"), + ] { + let missing: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table} WHERE {predicate}", + )).fetch_one(&pool).await?; + anyhow::ensure!(missing == 0, + "every payload must carry a `{label}` term; missing = {missing}"); + } + + let distinct_hm: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(DISTINCT payload->>'hm') FROM {table}", + )).fetch_one(&pool).await?; + anyhow::ensure!(distinct_hm == n, + "{n} distinct values -> {n} distinct hm terms; got {distinct_hm}"); + + let mismatched_version: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table} \ + WHERE payload->'v' IS NULL OR payload->>'v' <> '2'", + )).fetch_one(&pool).await?; + anyhow::ensure!(mismatched_version == 0, + "every payload must declare v = '2'"); + + // Value-filtering oracle: take the midpoint of FIXTURE_VALUES, + // derive its expected id from position, assert exactly one row. + if !expected.is_empty() { + let probe = expected[expected.len() / 2]; + let probe_lit = <$scalar as ScalarType>::to_sql_literal(probe); + let expected_id = (expected.len() / 2 + 1) as i64; + let ids: Vec = sqlx::query_scalar(&format!( + "SELECT id FROM {table} WHERE plaintext = {lit} ORDER BY id", lit = probe_lit, + )).fetch_all(&pool).await?; + anyhow::ensure!(ids == vec![expected_id], + "expected exactly one row with plaintext = {probe:?} at id {expected_id}, got {ids:?}"); + } + + Ok(()) + } + } + }; +} + +// ============================================================================ +// Ord-routes-through-ob category — ordered variants carry `c + ob` and +// drop `hm`. Equality on an ord variant must therefore route through +// `eql_v2.ord_term` (the `ob` term), never HMAC. Strip `hm` from every +// fixture payload so an accidental regression to HMAC equality fails +// rather than passing on the hm-carrying fixture. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_ord_routes_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$(($dom_name:ident, $variant:ident)),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_ord_routes_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_ord_routes_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let table = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), "_no_hm", + ); + let index = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), "_no_hm_idx", + ); + let fixture_table = + <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + let pivot: $scalar = + <$scalar as $crate::scalar_domains::ScalarType>::FIXTURE_VALUES[0]; + let pivot_lit = + <$scalar as $crate::scalar_domains::ScalarType>::to_sql_literal(pivot); + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (plaintext {pg}, value {d}) ON COMMIT DROP", + pg = <$scalar as $crate::scalar_domains::ScalarType>::PG_TYPE, + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO {table}(plaintext, value) \ + SELECT plaintext, (payload - 'hm')::{d} FROM {fixture}", fixture = fixture_table, + )).execute(&mut *tx).await?; + let with_hm: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM {table} WHERE jsonb_exists(value::jsonb, 'hm')", + )).fetch_one(&mut *tx).await?; + anyhow::ensure!(with_hm == 0, "test rows must not carry hm"); + + sqlx::query(&format!( + "CREATE INDEX {index} ON {table} USING btree (eql_v2.ord_term(value))", + )).execute(&mut *tx).await?; + sqlx::query(&format!("ANALYZE {table}")) + .execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx).await?; + + let pivot_payload: String = sqlx::query_scalar(&format!( + "SELECT (payload - 'hm')::text FROM {fixture} WHERE plaintext = {lit}", + fixture = fixture_table, lit = pivot_lit, + )).fetch_one(&mut *tx).await?; + + // The fixture plaintexts are distinct, so the pivot row is + // unique: `=` via ob must match EXACTLY one row, not "at + // least one". A weaker `>= 1` here is not independent of the + // `<>` check below — `expected_neq` is `len - eq_count`, so an + // `=` that over-matches inflates `eq_count` and deflates + // `expected_neq` in lockstep and both assertions still pass. + // Pinning `== 1` makes both this and the derived `<>` count + // load-bearing. + let eq_count: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM {table} WHERE value = $1::jsonb::{d}", + )).bind(&pivot_payload).fetch_one(&mut *tx).await?; + anyhow::ensure!(eq_count == 1, + "= must match exactly the pivot row via ob with no hm present (want 1, got {eq_count})"); + + // Derive from the pinned `eq_count == 1`: every other fixture + // row must be `<>`. Kept as `len - eq_count` (not a bare + // `len - 1`) so that if the `== 1` invariant above is ever + // relaxed the two assertions cannot silently compensate for + // each other — the derivation stays honest regardless. + let expected_neq = + <$scalar as $crate::scalar_domains::ScalarType>::FIXTURE_VALUES.len() as i64 + - eq_count; + let neq_count: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) FROM {table} WHERE value <> $1::jsonb::{d}", + )).bind(&pivot_payload).fetch_one(&mut *tx).await?; + anyhow::ensure!(neq_count == expected_neq, + "<> must match every non-pivot fixture row (want {expected_neq}, got {neq_count})", + ); + + // VALIDITY, NOT PREFERENCE: this runs with + // `enable_seqscan = off` (set above) on the ~17-row fixture, + // so the planner picks the only usable alternative. A green + // assertion proves the `eql_v2.ord_term` functional btree is + // *usable* for `=` with no hm present, NOT that the planner + // would *prefer* it at realistic scale. Cost-preference lives + // in the `*_scale_preference_*` tests + // (`#[cfg(feature = "scale")]`, OFF in PR CI). See the module + // header on `assert_index_scan_uses` for the full caveat. + // + // Node-type-aware (not a name substring): we require a genuine + // Index/Index-Only/Bitmap-Index-Scan node referencing `index`, + // so an incidental textual mention of the index name in an + // Index Cond / filter can no longer satisfy the assertion. + let lit = pivot_payload.replace('\'', "''"); + $crate::matrix::assert_index_scan_uses( + &mut *tx, + &format!("SELECT * FROM {table} WHERE value = '{lit}'::jsonb::{d}"), + index, + "= must engage the eql_v2.ord_term functional btree with no hm", + ).await?; + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// ORE-injectivity category — for OrdOre variants, distinct plaintexts in +// the fixture must produce distinct ORE blocks. Pairwise self-join over +// the fixture: zero collisions. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_ore_injectivity_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$(($dom_name:ident, $variant:ident)),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_ore_injectivity_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_ore_injectivity_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let fixture_table = + <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + let collisions: i64 = sqlx::query_scalar(&format!( + "SELECT count(*) \ +FROM {fixture} a \ +JOIN {fixture} b ON a.id < b.id \ +WHERE a.payload::{d} = b.payload::{d}", + fixture = fixture_table, + )).fetch_one(&pool).await?; + anyhow::ensure!(collisions == 0, + "no two distinct plaintexts may share an ORE term on {d}"); + Ok(()) + } + } + }; +} + +// ============================================================================ +// Index-engagement category — per (domain, extractor, using, ops) build a +// typed temp table from the fixture, create the functional index, sweep +// ops × rhs-casts asserting EXPLAIN contains a genuine index-scan node +// referencing the index (via `assert_index_scan_uses`, not a name substring). +// +// VALIDITY ONLY: forces `enable_seqscan = off` on the ~17-row fixture, so a +// green arm proves the index is *usable*, NOT that the planner would *prefer* +// it. Cost-preference is the `*_scale_preference_*` tests +// (`#[cfg(feature = "scale")]`, OFF in PR CI). See the module-level comment on +// `assert_index_scan_uses` for the full caveat. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_index_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + combos = [$($combo:tt),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_index_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, combo = $combo, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_index_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + combo = ( + $dom_name:ident, $variant:ident, + $extractor:literal, $using:literal, + [$(($op_name:ident, $op:literal)),+ $(,)?] $(,)? + ) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let table = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_idx_", $using, + ); + let index = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_idx_", $using, "_idx", + ); + let fixture_table = + <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + let mut tx = pool.begin().await?; + + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (plaintext {pg}, value {d}) ON COMMIT DROP", + pg = <$scalar as $crate::scalar_domains::ScalarType>::PG_TYPE, + d = &spec.sql_domain, + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO {table}(plaintext, value) \ + SELECT plaintext, payload::{d} FROM {fixture}", d = &spec.sql_domain, fixture = fixture_table, + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "CREATE INDEX {index} ON {table} USING {using} ({extractor}(value))", using = $using, extractor = $extractor, + )).execute(&mut *tx).await?; + sqlx::query(&format!("ANALYZE {table}")) + .execute(&mut *tx).await?; + sqlx::query("SET LOCAL enable_seqscan = off").execute(&mut *tx).await?; + + let pivot: $scalar = <$scalar as $crate::scalar_domains::ScalarType>::FIXTURE_VALUES[0]; + let payload = + $crate::scalar_domains::fetch_fixture_payload::<$scalar>(&pool, pivot).await?; + let lit = $crate::scalar_domains::sql_string_literal(&payload); + + // VALIDITY, NOT PREFERENCE: `enable_seqscan = off` is set + // above and the table holds only the ~17 fixture rows, so the + // planner has no cheaper option than the functional index. + // These arms therefore prove the index is *usable* for each + // (op, rhs-cast) shape — that the operator resolves through + // `{extractor}` and produces a real index-scan node — NOT that + // the planner would *prefer* the index under realistic costs. + // Cost-preference is proven ONLY by the `*_scale_preference_*` + // tests (`#[cfg(feature = "scale")]`), which are OFF in default + // PR CI. See the module header on `assert_index_scan_uses`. + // + // The assertion is node-type-aware (Index / Index Only / + // Bitmap Index Scan referencing `index`), not a bare substring + // match on the text plan, so an index name that merely appears + // in an Index Cond / Recheck Cond / filter cannot pass it. + let rhs_casts = [format!("::{d}", d = &spec.sql_domain), String::new()]; + $( + for rhs_cast in &rhs_casts { + let query = format!( + "SELECT * FROM {table} WHERE value {op} {lit}::jsonb{cast}", op = $op, cast = rhs_cast, + ); + $crate::matrix::assert_index_scan_uses( + &mut *tx, + &query, + index, + &format!( + "domain={} op={} rhs_cast={:?} must use index={}", + &spec.sql_domain, $op, rhs_cast, index, + ), + ).await?; + } + )+ + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// ORDER BY category — per ord domain × {ASC,DESC} × {no-WHERE, WHERE>0}. +// Fixture has no NULL plaintexts so NULLS FIRST/LAST is moot. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_order_by_domain! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, domain = $domain, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_domain { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident) $(,)? + ) => { + $crate::__scalar_matrix_order_by_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = asc_no_where, direction = "ASC", where_clause = "", + } + $crate::__scalar_matrix_order_by_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = desc_no_where, direction = "DESC", where_clause = "", + } + $crate::__scalar_matrix_order_by_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = asc_with_where, direction = "ASC", + where_clause = " WHERE plaintext > 0", + } + $crate::__scalar_matrix_order_by_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = desc_with_where, direction = "DESC", + where_clause = " WHERE plaintext > 0", + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + mode_name = $mode_name:ident, direction = $direction:literal, + where_clause = $where_clause:literal $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let fixture_table = + <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + let sql = format!( + "SELECT plaintext FROM {fixture}{where_clause} \ +ORDER BY eql_v2.ord_term(payload::{d}) {dir}", + fixture = fixture_table, where_clause = $where_clause, + d = &spec.sql_domain, dir = $direction, + ); + let actual: Vec<$scalar> = sqlx::query_scalar(&sql).fetch_all(&pool).await?; + + let zero: $scalar = Default::default(); + let mut expected: Vec<$scalar> = + <$scalar as $crate::scalar_domains::ScalarType>::FIXTURE_VALUES.to_vec(); + expected.sort(); + if $where_clause.contains("plaintext > 0") { + expected.retain(|v| *v > zero); + } + if $direction == "DESC" { expected.reverse(); } + + assert_eq!(actual, expected, + "domain={} mode={} SQL={} expected {:?}, got {:?}", + &spec.sql_domain, stringify!($mode_name), sql, expected, actual); + Ok(()) + } + } + }; +} + +// ============================================================================ +// ORDER BY NULLS FIRST/LAST category — per ord domain × {ASC,DESC} × +// {NULLS FIRST, NULLS LAST}. The plain ORDER BY arm above sorts the fixture, +// which has no NULL rows, so NULLS placement goes untested there. This arm +// builds an isolated temp table mixing NULL-valued rows with the fixture rows +// and pins that the NULL sort keys land at the requested end while the +// non-NULL rows stay in plaintext order. `eql_v2.ord_term` is STRICT, so a +// NULL domain value yields a NULL sort key; a regression making it non-STRICT +// would let NULL rows interleave — see the `family::mutations` negative +// control for that dimension. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_nulls_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_order_by_nulls_domain! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, domain = $domain, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_nulls_domain { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident) $(,)? + ) => { + $crate::__scalar_matrix_order_by_nulls_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = asc_nulls_first, direction = "ASC", nulls = "FIRST", + } + $crate::__scalar_matrix_order_by_nulls_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = asc_nulls_last, direction = "ASC", nulls = "LAST", + } + $crate::__scalar_matrix_order_by_nulls_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = desc_nulls_first, direction = "DESC", nulls = "FIRST", + } + $crate::__scalar_matrix_order_by_nulls_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + mode_name = desc_nulls_last, direction = "DESC", nulls = "LAST", + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_nulls_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + mode_name = $mode_name:ident, direction = $direction:literal, nulls = $nulls:literal $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + // Number of NULL-valued rows mixed in; >1 proves they cluster. + const NULL_ROWS: usize = 3; + + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let table = concat!( + "matrix_", stringify!($suite), "_", stringify!($dom_name), + "_order_by_", stringify!($mode_name), + ); + let fixture_table = + <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + let pg = <$scalar as $crate::scalar_domains::ScalarType>::PG_TYPE; + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (plaintext {pg}, value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + // Non-NULL rows: every fixture row, carrying its plaintext. + sqlx::query(&format!( + "INSERT INTO {table}(plaintext, value) \ +SELECT plaintext, payload::{d} FROM {fixture}", fixture = fixture_table, + )).execute(&mut *tx).await?; + // NULL-valued rows: NULL plaintext too, so they surface as None + // and their position is what the assertion pins. + sqlx::query(&format!( + "INSERT INTO {table}(plaintext, value) \ +SELECT NULL::{pg}, NULL::{d} FROM generate_series(1, {n})", n = NULL_ROWS, + )).execute(&mut *tx).await?; + + let sql = format!( + "SELECT plaintext FROM {table} \ +ORDER BY eql_v2.ord_term(value) {dir} NULLS {nulls}", + dir = $direction, nulls = $nulls, + ); + let actual: Vec> = + sqlx::query_scalar(&sql).fetch_all(&mut *tx).await?; + + // Ground truth: non-NULL plaintexts sorted (reversed for DESC), + // with NULL_ROWS Nones at the requested end. + let mut non_null: Vec<$scalar> = + <$scalar as $crate::scalar_domains::ScalarType>::FIXTURE_VALUES.to_vec(); + non_null.sort(); + if $direction == "DESC" { non_null.reverse(); } + let sorted = non_null.into_iter().map(Some); + let mut expected: Vec> = Vec::new(); + if $nulls == "FIRST" { + expected.extend(std::iter::repeat(None).take(NULL_ROWS)); + expected.extend(sorted); + } else { + expected.extend(sorted); + expected.extend(std::iter::repeat(None).take(NULL_ROWS)); + } + + assert_eq!(actual, expected, + "domain={} mode={} SQL={} expected {:?}, got {:?}", + d, stringify!($mode_name), sql, expected, actual); + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// ORDER BY USING category — every op × ord domain must reject +// `ORDER BY col USING ` because the design forbids opclasses on +// these domains. If a refactor accidentally adds one, this fails. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_using_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?], ops_list = $ops_list:tt $(,)? + ) => { + $( + $crate::__scalar_matrix_order_by_using_inner! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + domain = $domain, ops_list = $ops_list, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_using_inner { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident), + ops_list = [$(($op_name:ident, $op:literal)),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_order_by_using_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op_name = $op_name, op = $op, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_order_by_using_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op_name = $op_name:ident, op = $op:literal $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let fixture_table = + <$scalar as $crate::scalar_domains::ScalarType>::fixture_table_name(); + let sql = format!( + "SELECT plaintext FROM {fixture} ORDER BY payload::{d} USING {op}", + fixture = fixture_table, d = &spec.sql_domain, op = $op, + ); + let err = sqlx::query_scalar::<_, $scalar>(&sql) + .fetch_all(&pool) + .await + .expect_err(&format!( + "domain={} op={} SQL={} must reject ORDER BY USING (no opclass on \ +domain by design) but succeeded", + &spec.sql_domain, $op, sql, + )); + // SQLSTATE 42809 (wrong_object_type) — "operator X is not a + // valid ordering operator". The boolean operator exists on the + // domain but lacks a btree opclass entry, so ORDER BY USING + // refuses to use it. Pinning this catches the regression where + // a stray opclass would make ORDER BY USING start succeeding + // for the wrong reason — `is_err()` alone could not. + $crate::assert_db_error(&err, "42809", None); + Ok(()) + } + } + }; +} + +// ============================================================================ +// Aggregate category — per (ord domain, op ∈ {min, max}), three tests: +// extremum identity (payload of the min/max FIXTURE_VALUES row), all-NULL +// returns NULL, and mixed NULL/non-NULL returns the correct extremum from +// the non-NULL subset. Pins that `eql_v2.min` / `eql_v2.max` aggregates +// route through the domain's `<` / `>` and that the STRICT state function +// correctly seeds + skips NULLs. Emits zero tests when ord_domains is +// empty — eq-only umbrellas pick that up naturally. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_aggregate_mid! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + domain = $domain, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_mid { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident) $(,)? + ) => { + $crate::__scalar_matrix_aggregate_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op_name = min, agg_fn = "min", picker = min, + } + $crate::__scalar_matrix_aggregate_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op_name = max, agg_fn = "max", picker = max, + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op_name = $op_name:ident, agg_fn = $agg_fn:literal, picker = $picker:ident $(,)? + ) => { + $crate::paste::paste! { + // Extremum identity: aggregate returns the exact payload of the + // smallest (or largest) fixture row. Domain-cast on both sides + // so the comparator routes through the variant's `<` / `>`. + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let fixture = <$scalar as ScalarType>::fixture_table_name(); + let extremum: $scalar = <$scalar as ScalarType>::FIXTURE_VALUES + .iter() + .copied() + .$picker() + .expect("FIXTURE_VALUES must be non-empty"); + let extremum_lit = <$scalar as ScalarType>::to_sql_literal(extremum); + + let expected: String = sqlx::query_scalar(&format!( + "SELECT payload::text FROM {fixture} WHERE plaintext = {lit}", lit = extremum_lit, + )).fetch_one(&pool).await?; + + let actual: String = sqlx::query_scalar(&format!( + "SELECT eql_v2.{agg}(payload::{d})::text FROM {fixture}", + agg = $agg_fn, + )).fetch_one(&pool).await?; + + assert_eq!( + actual, expected, + "eql_v2.{}({}) must return the payload of plaintext={:?} (the fixture {})", + $agg_fn, d, extremum, $agg_fn, + ); + + // Secondary diagnostic: when the primary identity holds, + // the ORE comparator must agree. The check is reached only + // on success of `assert_eq!`, so it's a self-consistency + // assertion on the comparator — catches the regression + // where payload text matches but `ord_term` resolves to a + // different value (e.g. due to payload-key reordering). + let ord_terms_match: bool = sqlx::query_scalar(&format!( + "SELECT eql_v2.ord_term(eql_v2.{agg}(payload::{d})) \ + = eql_v2.ord_term($1::jsonb::{d}) \ + FROM {fixture}", + agg = $agg_fn, + )) + .bind(&expected) + .fetch_one(&pool) + .await?; + anyhow::ensure!( + ord_terms_match, + "eql_v2.ord_term(eql_v2.{}({})) must equal eql_v2.ord_term() \ + for plaintext={:?}", + $agg_fn, d, extremum, + ); + Ok(()) + } + + // Empty rowset: aggregate over zero rows returns NULL, + // structurally distinct from the all-NULL case (no rows fed + // at all vs. rows fed but every value NULL). Both must + // return NULL but they exercise different sfunc paths. + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE empty_agg (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + let result: Option = sqlx::query_scalar(&format!( + "SELECT eql_v2.{agg}(value)::text FROM empty_agg", + agg = $agg_fn, + )).fetch_one(&mut *tx).await?; + anyhow::ensure!( + result.is_none(), + "empty rowset to eql_v2.{} on {} must return NULL, got {:?}", + $agg_fn, d, result, + ); + tx.commit().await?; + Ok(()) + } + + // All-NULL input: STRICT sfunc never seeds the state, final + // result is NULL. No fixture needed. + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let sql = format!( + "SELECT eql_v2.{agg}(NULL::{d})::text FROM generate_series(1, 3)", + agg = $agg_fn, + ); + let result: Option = sqlx::query_scalar(&sql) + .fetch_one(&pool) + .await?; + anyhow::ensure!( + result.is_none(), + "all-NULL input to eql_v2.{} on {} must return NULL, got {:?}; SQL={}", + $agg_fn, d, result, sql, + ); + Ok(()) + } + + // Mixed NULL / non-NULL: feeds [NULL, mid, NULL, high, NULL] and + // asserts the aggregate returns the correct extremum of {mid, + // high}. A non-STRICT sfunc would crash on (state=NULL, value=mid) + // because `value < state` would be NULL; the STRICT contract + // skips NULL inputs and seeds with the first non-NULL value. + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let fixture = <$scalar as ScalarType>::fixture_table_name(); + let values: &[$scalar] = <$scalar as ScalarType>::FIXTURE_VALUES; + anyhow::ensure!( + values.len() >= 2, + "mixed-NULL test needs >= 2 fixture values; got {}", + values.len(), + ); + let mut sorted: Vec<$scalar> = values.to_vec(); + sorted.sort(); + // Span the fixture's extremes — for signed numeric scalars this + // exercises the ORE sign-bit edges in addition to pinning STRICT + // sfunc behaviour. + let low: $scalar = *sorted.first().expect("non-empty after len check"); + let high: $scalar = *sorted.last().expect("non-empty after len check"); + // .min() / .max() on two values resolves to the correct picker. + let expected_plaintext: $scalar = low.$picker(high); + let low_lit = <$scalar as ScalarType>::to_sql_literal(low); + let high_lit = <$scalar as ScalarType>::to_sql_literal(high); + let expected_lit = <$scalar as ScalarType>::to_sql_literal(expected_plaintext); + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE mixed_null (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO mixed_null(value) \ + SELECT NULL::{d} \ + UNION ALL SELECT payload::{d} FROM {fixture} WHERE plaintext = {low} \ + UNION ALL SELECT NULL::{d} \ + UNION ALL SELECT payload::{d} FROM {fixture} WHERE plaintext = {high} \ + UNION ALL SELECT NULL::{d}", low = low_lit, high = high_lit, + )).execute(&mut *tx).await?; + + let expected: String = sqlx::query_scalar(&format!( + "SELECT payload::text FROM {fixture} WHERE plaintext = {lit}", lit = expected_lit, + )).fetch_one(&mut *tx).await?; + + let actual: Option = sqlx::query_scalar(&format!( + "SELECT eql_v2.{agg}(value)::text FROM mixed_null", + agg = $agg_fn, + )).fetch_one(&mut *tx).await?; + + anyhow::ensure!( + actual.as_deref() == Some(expected.as_str()), + "eql_v2.{} on mixed NULL/non-NULL must return the {} non-NULL value (plaintext={:?}); want {expected:?}, got {actual:?}", + $agg_fn, $agg_fn, expected_plaintext, + ); + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Aggregate parallelism category — per ord domain, assert that the catalog +// declares MIN/MAX as PARALLEL SAFE with a combine function. Without those, +// PostgreSQL silently forecloses partial/parallel aggregation on exactly the +// large GROUP BY workloads these ORE aggregates exist to serve (#239 thread +// 22). A catalog-level structural guard (cheap, deterministic, no plan +// dependence) rather than a flaky "force a parallel plan" behavioural test. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_parallel_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + domains = [$($domain:tt),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_aggregate_parallel_case! { + suite = $suite, scalar = $scalar, domain = $domain, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_parallel_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + domain = ($dom_name:ident, $variant:ident) $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + for agg in ["min", "max"] { + let (proparallel, has_combine): (String, bool) = sqlx::query_as( + "SELECT p.proparallel::text, a.aggcombinefn <> 0 \ + FROM pg_proc p \ + JOIN pg_aggregate a ON a.aggfnoid = p.oid \ + WHERE p.proname = $1 \ + AND p.pronamespace = 'eql_v2'::regnamespace \ + AND p.proargtypes[0]::regtype = $2::regtype", + ) + .bind(agg) + .bind(d) + .fetch_one(&pool) + .await?; + anyhow::ensure!(proparallel == "s", + "eql_v2.{agg}({d}) must be PARALLEL SAFE (proparallel='s'), got {proparallel:?}"); + anyhow::ensure!(has_combine, + "eql_v2.{agg}({d}) must declare a combinefunc for partial aggregation"); + } + Ok(()) + } + } + }; +} + +// ============================================================================ +// Aggregate GROUP BY category — per (ord domain, op ∈ {min, max}), build a +// temp table partitioned into two groups, populate each with a known +// subset of fixture rows, GROUP BY the group key, and assert that +// `eql_v2.(value)` returns the correct extremum payload per group. +// Pins that the aggregate composes correctly under GROUP BY (state is +// reset between groups, the sfunc routes through the variant's +// comparator inside each partition). +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_group_by_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$($domain:tt),* $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_aggregate_group_by_mid! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + domain = $domain, + } + )* + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_group_by_mid { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domain = ($dom_name:ident, $variant:ident) $(,)? + ) => { + $crate::__scalar_matrix_aggregate_group_by_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op_name = min, agg_fn = "min", picker = min, + } + $crate::__scalar_matrix_aggregate_group_by_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + op_name = max, agg_fn = "max", picker = max, + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_group_by_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident, + op_name = $op_name:ident, agg_fn = $agg_fn:literal, picker = $picker:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let fixture = <$scalar as ScalarType>::fixture_table_name(); + let values: &[$scalar] = <$scalar as ScalarType>::FIXTURE_VALUES; + anyhow::ensure!( + values.len() >= 5, + "GROUP BY test needs >= 5 fixture values; got {}", + values.len(), + ); + + // Partition FIXTURE_VALUES[..3] into group 1 and [3..5] + // into group 2. Per-group extremum is computed in Rust as + // the ground truth. + let group1: &[$scalar] = &values[..3]; + let group2: &[$scalar] = &values[3..5]; + let group1_extremum: $scalar = group1.iter().copied().$picker() + .expect("group 1 is non-empty"); + let group2_extremum: $scalar = group2.iter().copied().$picker() + .expect("group 2 is non-empty"); + let g1_lit = <$scalar as ScalarType>::to_sql_literal(group1_extremum); + let g2_lit = <$scalar as ScalarType>::to_sql_literal(group2_extremum); + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE group_test (group_key int, value {d}) \ +ON COMMIT DROP", + )).execute(&mut *tx).await?; + + // Insert group 1 rows. + for v in group1 { + let lit = <$scalar as ScalarType>::to_sql_literal(*v); + sqlx::query(&format!( + "INSERT INTO group_test(group_key, value) \ +SELECT 1, payload::{d} FROM {fixture} WHERE plaintext = {lit}", + )).execute(&mut *tx).await?; + } + // Insert group 2 rows. + for v in group2 { + let lit = <$scalar as ScalarType>::to_sql_literal(*v); + sqlx::query(&format!( + "INSERT INTO group_test(group_key, value) \ +SELECT 2, payload::{d} FROM {fixture} WHERE plaintext = {lit}", + )).execute(&mut *tx).await?; + } + + // Lookup the expected payload texts for each group's extremum. + let g1_expected: String = sqlx::query_scalar(&format!( + "SELECT payload::text FROM {fixture} WHERE plaintext = {lit}", lit = g1_lit, + )).fetch_one(&mut *tx).await?; + let g2_expected: String = sqlx::query_scalar(&format!( + "SELECT payload::text FROM {fixture} WHERE plaintext = {lit}", lit = g2_lit, + )).fetch_one(&mut *tx).await?; + + let rows: Vec<(i32, String)> = sqlx::query_as(&format!( + "SELECT group_key, eql_v2.{agg}(value)::text \ +FROM group_test GROUP BY group_key ORDER BY group_key", + agg = $agg_fn, + )).fetch_all(&mut *tx).await?; + + anyhow::ensure!( + rows.len() == 2, + "GROUP BY must return 2 rows, got {}", + rows.len(), + ); + anyhow::ensure!( + rows[0].0 == 1 && rows[0].1 == g1_expected, + "group 1 eql_v2.{}({}) must yield payload for plaintext={:?}; \ +want ({}, {:?}), got {:?}", + $agg_fn, d, group1_extremum, 1, g1_expected, rows[0], + ); + anyhow::ensure!( + rows[1].0 == 2 && rows[1].1 == g2_expected, + "group 2 eql_v2.{}({}) must yield payload for plaintext={:?}; \ +want ({}, {:?}), got {:?}", + $agg_fn, d, group2_extremum, 2, g2_expected, rows[1], + ); + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// Aggregate type-safety category — for variants that do NOT support ord +// (Storage, Eq), `eql_v2.min()` / `eql_v2.max(...)` must +// resolve to "function does not exist" (SQLSTATE 42883). Pins that +// codegen correctly omits MIN/MAX wrappers for these variants — a +// SQL-level regression test complementing the codegen unit test. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_typecheck_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, + domains = [$(($dom_name:ident, $variant:ident)),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_aggregate_typecheck_dispatch! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, + } + )+ + }; +} + +// Dispatch on variant ident: ord-capable variants (Ord, OrdOre) emit no +// typecheck test — they DO declare min/max. Non-ord variants (Storage, +// Eq) emit one test per aggregate op asserting the call fails with +// SQLSTATE 42883. +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_typecheck_dispatch { + // Ord, OrdOre: no typecheck test — these variants declare min/max. + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = Ord $(,)? + ) => {}; + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = OrdOre $(,)? + ) => {}; + // Storage, Eq: emit min + max typecheck tests. + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::__scalar_matrix_aggregate_typecheck_case! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, + op_name = min, agg_fn = "min", + } + $crate::__scalar_matrix_aggregate_typecheck_case! { + suite = $suite, scalar = $scalar, + dom_name = $dom_name, variant = $variant, + op_name = max, agg_fn = "max", + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_aggregate_typecheck_case { + ( + suite = $suite:ident, scalar = $scalar:ty, + dom_name = $dom_name:ident, variant = $variant:ident, + op_name = $op_name:ident, agg_fn = $agg_fn:literal $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let payload = $crate::helpers::PLACEHOLDER_PAYLOAD; + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE typecheck_table (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO typecheck_table(value) VALUES ($1::jsonb::{d})", + )).bind(payload).execute(&mut *tx).await?; + + // Savepoint-isolate the probe so the failed lookup + // doesn't abort the outer transaction and tx.commit() + // can succeed cleanly. + sqlx::query("SAVEPOINT probe").execute(&mut *tx).await?; + let sql = format!( + "SELECT eql_v2.{agg}(value) FROM typecheck_table", + agg = $agg_fn, + ); + let err = sqlx::query_scalar::<_, String>(&sql) + .fetch_one(&mut *tx) + .await + .expect_err(&format!( + "eql_v2.{} on non-ord variant {} must raise but succeeded", + $agg_fn, d, + )); + // 42883 = undefined_function (no overload defined at all); + // 42725 = ambiguous_function (multiple overloads resolve, + // none specific to this variant). Either confirms the + // variant carries no MIN/MAX of its own — the generic + // eql_v2_encrypted overload is reachable via cast but + // can't be resolved unambiguously from a domain-typed + // column. Both outcomes are acceptable "not supported". + let db_err = err.as_database_error() + .expect("expected database error from typecheck probe"); + let code = db_err.code(); + anyhow::ensure!( + code.as_deref() == Some("42883") || code.as_deref() == Some("42725"), + "expected SQLSTATE 42883 (undefined_function) or 42725 \ +(ambiguous_function) for eql_v2.{}({}), got {:?} (message: {})", + $agg_fn, d, code, db_err.message(), + ); + sqlx::query("ROLLBACK TO SAVEPOINT probe").execute(&mut *tx).await?; + + tx.commit().await?; + Ok(()) + } + } + }; +} + +// ============================================================================ +// COUNT category — pins three forms per variant: plain COUNT(value) on a +// typed column, COUNT(payload::variant) on the fixture, and +// COUNT(DISTINCT extractor(value)) using the variant's own extractor. The +// DISTINCT case dispatches per-variant: Storage has no extractor and so +// emits no DISTINCT test; Eq uses eq_term, Ord/OrdOre use ord_term. +// +// This is net new coverage relative to the legacy aggregate_tests.rs file, +// which only covered plain COUNT and only against the eql_v2_encrypted +// type. Pinning per-variant DISTINCT catches the breakage class where +// picking the wrong extractor would fail at runtime ("function +// eql_v2.eq_term(eql_v2_int4_ord) does not exist") — exactly the kind of +// thing the variant-aware matrix is meant to surface mechanically. +// ============================================================================ + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_count_outer { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + domains = [$(($dom_name:ident, $variant:ident)),+ $(,)?] $(,)? + ) => { + $( + $crate::__scalar_matrix_count_case! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + } + $crate::__scalar_matrix_count_distinct_dispatch! { + suite = $suite, scalar = $scalar, script = $script, script_path = $script_path, + dom_name = $dom_name, variant = $variant, + } + )+ + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_count_case { + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + // COUNT(value) on a typed column — pins that PG's native COUNT + // works on a domain-typed column without an aggregate declaration. + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let fixture = <$scalar as ScalarType>::fixture_table_name(); + let expected = <$scalar as ScalarType>::FIXTURE_VALUES.len() as i64; + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE typed_count (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO typed_count(value) SELECT payload::{d} FROM {fixture}", + )).execute(&mut *tx).await?; + + let actual: i64 = sqlx::query_scalar( + "SELECT COUNT(value) FROM typed_count", + ).fetch_one(&mut *tx).await?; + anyhow::ensure!( + actual == expected, + "COUNT(value) on typed {} column: want {}, got {}", + d, expected, actual, + ); + + tx.commit().await?; + Ok(()) + } + + // COUNT(payload::variant) on the fixture — pins COUNT on a + // path-cast expression. No temp table; the cast happens inline. + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let fixture = <$scalar as ScalarType>::fixture_table_name(); + let expected = <$scalar as ScalarType>::FIXTURE_VALUES.len() as i64; + + let sql = format!( + "SELECT COUNT(payload::{d}) FROM {fixture}", + ); + let actual: i64 = sqlx::query_scalar(&sql).fetch_one(&pool).await?; + anyhow::ensure!( + actual == expected, + "COUNT(payload::{}) on {}: want {}, got {}; SQL={}", + d, fixture, expected, actual, sql, + ); + Ok(()) + } + } + }; +} + +// Dispatch on variant ident: Storage has no discriminating extractor, so +// emits no DISTINCT test. The other three (Eq, Ord, OrdOre) each emit one +// test that reads the extractor function name from the runtime +// `ScalarDomainSpec::extractor_fn()` accessor (Eq -> `eql_v2.eq_term`, +// Ord/OrdOre -> `eql_v2.ord_term`) and appends `(value)` at the call site. +#[macro_export] +#[doc(hidden)] +macro_rules! __scalar_matrix_count_distinct_dispatch { + // Storage: no DISTINCT case — no extractor to deduplicate by. + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = Storage $(,)? + ) => {}; + // Eq, Ord, OrdOre — emit the DISTINCT test. + ( + suite = $suite:ident, scalar = $scalar:ty, script = $script:literal, script_path = $script_path:literal, + dom_name = $dom_name:ident, variant = $variant:ident $(,)? + ) => { + $crate::paste::paste! { + #[sqlx::test(fixtures(path = $script_path, scripts($script)))] + async fn []( + pool: sqlx::PgPool, + ) -> anyhow::Result<()> { + use $crate::scalar_domains::ScalarType; + let spec = $crate::__scalar_matrix_spec!($scalar, $variant); + let d = &spec.sql_domain; + let extractor_fn = spec.extractor_fn() + .expect("non-Storage variant must expose an extractor"); + let extractor = format!("{extractor_fn}(value)"); + let fixture = <$scalar as ScalarType>::fixture_table_name(); + let expected = <$scalar as ScalarType>::FIXTURE_VALUES.len() as i64; + + let mut tx = pool.begin().await?; + sqlx::query(&format!( + "CREATE TEMP TABLE distinct_count (value {d}) ON COMMIT DROP", + )).execute(&mut *tx).await?; + sqlx::query(&format!( + "INSERT INTO distinct_count(value) SELECT payload::{d} FROM {fixture}", + )).execute(&mut *tx).await?; + + let sql = format!( + "SELECT COUNT(DISTINCT {extr}) FROM distinct_count", + extr = extractor, + ); + let actual: i64 = sqlx::query_scalar(&sql).fetch_one(&mut *tx).await?; + anyhow::ensure!( + actual == expected, + "COUNT(DISTINCT {}) on {}: want {} (one per FIXTURE_VALUES row), got {}; SQL={}", + extractor, d, expected, actual, sql, + ); + + tx.commit().await?; + Ok(()) + } + } + }; +} diff --git a/tests/sqlx/src/scalar_domains.rs b/tests/sqlx/src/scalar_domains.rs new file mode 100644 index 00000000..b31c1a55 --- /dev/null +++ b/tests/sqlx/src/scalar_domains.rs @@ -0,0 +1,308 @@ +//! Type-generic substrate for the encrypted-scalar-domain test matrix. +//! +//! Adding a new encrypted scalar type (e.g. `i64` for int8, `f64` for +//! float8) is a 4-line `impl ScalarType` plus a Proxy-encrypted fixture. +//! Everything else — the four `eql_v2_{,_eq,_ord,_ord_ore}` domains, +//! per-domain payload shapes, supported operators, index extractor +//! expressions, ground-truth result sets — is derived from +//! `T::PG_TYPE`, `T::FIXTURE_VALUES`, and the `Variant` enum. + +use anyhow::{bail, Context, Result}; +use sqlx::PgPool; +use std::fmt::{Debug, Display}; + +/// One impl per scalar type. Two `const`s and the rest defaults. +pub trait ScalarType: + Copy + + Ord + + Default + + Debug + + Display + + Send + + Sync + + Unpin + + 'static + + for<'r> sqlx::Decode<'r, sqlx::Postgres> + + sqlx::Type +{ + /// Postgres native type token — also the suffix in the SQL domain + /// name and the fixture script name. Examples: `"int4"`, `"int8"`. + const PG_TYPE: &'static str; + + /// Distinct plaintext values present in the fixture. Order doesn't + /// matter — `expected_forward` sorts before returning. + /// + /// For types driven by `ordered_numeric_matrix!`, the fixture MUST + /// include `MIN`, `MAX`, and zero (`Default::default()`): the matrix + /// uses those three as comparison pivots and fetches each one's + /// ciphertext via `fetch_fixture_payload`, which fails loudly if the + /// row is absent. + const FIXTURE_VALUES: &'static [Self]; + + /// `fixtures.eql_v2_`. + fn fixture_table_name() -> String { + format!("fixtures.eql_v2_{}", Self::PG_TYPE) + } + + /// SQL-literal rendering via `Display`. Override for types whose + /// `Display` form isn't a valid SQL literal (e.g. strings, dates). + fn to_sql_literal(value: Self) -> String { + value.to_string() + } + + /// Ground-truth result set for `WHERE col op pivot`. Default works + /// for any `Ord` scalar; override only for non-orderable types. + fn expected_forward(op: &str, pivot: Self) -> Vec { + let predicate: fn(Self, Self) -> bool = match op { + "=" => |a, b| a == b, + "<>" => |a, b| a != b, + "<" => |a, b| a < b, + "<=" => |a, b| a <= b, + ">" => |a, b| a > b, + ">=" => |a, b| a >= b, + other => panic!("expected_forward: unsupported operator {other}"), + }; + let mut values: Vec = Self::FIXTURE_VALUES + .iter() + .copied() + .filter(|v| predicate(*v, pivot)) + .collect(); + values.sort(); + values + } +} + +impl ScalarType for i32 { + const PG_TYPE: &'static str = "int4"; + /// Single-sourced from `tasks/codegen/types/int4.toml` `[fixture] values` + /// via the generated `fixtures::int4_values::VALUES` const — the same list + /// the fixture generator encrypts, so the oracle cannot drift from the + /// fixture. Spans the negative boundary, the i32 signed extremes, and zero. + const FIXTURE_VALUES: &'static [i32] = crate::fixtures::int4_values::VALUES; +} + +/// Per-domain capability + payload shape. Storage carries no terms, `Eq` +/// adds `hm`, `Ord`/`OrdOre` add `ob`. `Ord` and `OrdOre` are deliberate +/// twins — same operator surface, different SQL domain names — for the +/// scheme-explicit vs converged-name migration story. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Variant { + Storage, + Eq, + Ord, + OrdOre, +} + +impl Variant { + /// Every variant the family currently materialises, in declaration + /// order. Tests iterate over this rather than hand-listing variants + /// so adding a future variant requires no test edit. + pub const ALL: &'static [Variant] = + &[Variant::Storage, Variant::Eq, Variant::Ord, Variant::OrdOre]; + + pub const fn suffix(self) -> &'static str { + match self { + Variant::Storage => "", + Variant::Eq => "_eq", + Variant::Ord => "_ord", + Variant::OrdOre => "_ord_ore", + } + } + + /// Term key the variant requires on its CHECK constraint. `Storage` + /// requires nothing beyond the envelope; `Eq` requires `hm`; + /// `Ord` / `OrdOre` require `ob`. Read by tests that need to know + /// "what term does this variant carry?" — not by payload builders; + /// see `PLACEHOLDER_PAYLOAD`. + pub const fn required_term(self) -> Option<&'static str> { + match self { + Variant::Storage => None, + Variant::Eq => Some("hm"), + Variant::Ord | Variant::OrdOre => Some("ob"), + } + } + + /// Top-level JSONB keys the variant's domain CHECK requires. + /// Storage requires the EQL envelope (`v`, `i`, `c`); ord-capable + /// variants additionally require their term key (`hm` / `ob`). The + /// matrix `payload_check` arm iterates this to assert each key's + /// absence is rejected at the cast. + pub fn payload_required_keys(self) -> impl Iterator { + ["v", "i", "c"].into_iter().chain(self.required_term()) + } + + pub const fn supports_eq(self) -> bool { + !matches!(self, Variant::Storage) + } + + pub const fn supports_ord(self) -> bool { + matches!(self, Variant::Ord | Variant::OrdOre) + } + + /// Function name of the discriminating extractor for this variant, + /// or `None` if the variant carries no extractor (`Storage`). Returns + /// just the function name — call sites append `(column)` themselves so + /// the accessor is decoupled from any specific column-naming + /// convention. `Eq` resolves to `eql_v2.eq_term`; `Ord` and `OrdOre` + /// both resolve to `eql_v2.ord_term`. + pub const fn extractor_fn(self) -> Option<&'static str> { + match self { + Variant::Storage => None, + Variant::Eq => Some("eql_v2.eq_term"), + Variant::Ord | Variant::OrdOre => Some("eql_v2.ord_term"), + } + } +} + +/// Runtime spec built from `(T, Variant)`. The matrix macro consumes +/// this; nothing here is `const` because `sql_domain` is derived via +/// `format!` from `T::PG_TYPE`. +#[derive(Debug, Clone)] +pub struct ScalarDomainSpec { + pub sql_domain: String, + pub variant: Variant, +} + +impl ScalarDomainSpec { + pub fn new(variant: Variant) -> Self { + Self { + sql_domain: format!("eql_v2_{}{}", T::PG_TYPE, variant.suffix()), + variant, + } + } + + pub fn supports_eq(&self) -> bool { + self.variant.supports_eq() + } + + pub fn supports_ord(&self) -> bool { + self.variant.supports_ord() + } + + pub fn extractor_fn(&self) -> Option<&'static str> { + self.variant.extractor_fn() + } +} + +/// SQL string-literal escaping for direct interpolation. +pub fn sql_string_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +/// `a op b` and `b op' a` return the same row set when `op'` is the +/// commutator of `op`. Used by the cross-shape arm when the column moves +/// to the right operand. +pub fn commute_op(op: &str) -> &'static str { + match op { + "=" => "=", + "<>" => "<>", + "<" => ">", + "<=" => ">=", + ">" => "<", + ">=" => "<=", + other => panic!("commute_op: unsupported operator {other}"), + } +} + +/// Fetch the payload row keyed by `plaintext` from `T`'s fixture table. +pub async fn fetch_fixture_payload(pool: &PgPool, plaintext: T) -> Result { + let sql = format!( + "SELECT payload::text FROM {table} WHERE plaintext = {lit}", + table = T::fixture_table_name(), + lit = T::to_sql_literal(plaintext), + ); + sqlx::query_scalar(&sql) + .fetch_one(pool) + .await + .with_context(|| { + format!( + "fetching {} payload for plaintext={:?}", + T::fixture_table_name(), + plaintext + ) + }) +} + +/// Sorted plaintexts matching `predicate` against `T`'s fixture table. +async fn scalar_plaintexts_matching( + pool: &PgPool, + predicate: &str, +) -> Result> { + let sql = format!( + "SELECT plaintext FROM {table} WHERE {predicate} ORDER BY plaintext", + table = T::fixture_table_name(), + ); + let mut rows: Vec = sqlx::query_scalar(&sql) + .fetch_all(pool) + .await + .with_context(|| format!("running scalar plaintext query: {sql}"))?; + rows.sort(); + Ok(rows) +} + +/// Run `predicate` against `T`'s fixture; assert plaintexts equal `expected`. +pub async fn assert_scalar_plaintexts( + pool: &PgPool, + domain: &str, + op: &str, + predicate: &str, + expected: &[T], +) -> Result<()> { + let actual = scalar_plaintexts_matching::(pool, predicate).await?; + let mut want = expected.to_vec(); + want.sort(); + assert_eq!( + actual, want, + "domain={domain} operator={op} predicate={predicate} must match expected plaintexts" + ); + Ok(()) +} + +/// Unified raise-assertion: query must error and the message must contain +/// `expected_msg`. Covers blocker raises (`expected_msg = "operator X is +/// not supported for {domain}"`) and native-operator absence +/// (`"operator does not exist"`). Bind slots are `Option<&str>`: `Some` +/// = bind the payload, `None` = bind NULL. +pub async fn assert_raises( + pool: &PgPool, + sql: &str, + binds: &[Option<&str>], + expected_msg: &str, +) -> Result<()> { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + let result = q.fetch_one(pool).await; + let err = match result { + Ok(_) => bail!("SQL must raise: {sql}"), + Err(e) => e.to_string(), + }; + if !err.contains(expected_msg) { + bail!("SQL={sql} expected error containing {expected_msg:?}, got {err}"); + } + Ok(()) +} + +/// Unified NULL-result assertion: the query must succeed and return NULL. +/// Used for supported operators where STRICT semantics propagate NULL. +pub async fn assert_null(pool: &PgPool, sql: &str, binds: &[Option<&str>]) -> Result<()> { + let mut q = sqlx::query_scalar::<_, Option>(sql); + for b in binds { + q = q.bind(*b); + } + let result: Option = q + .fetch_one(pool) + .await + .with_context(|| format!("running null-result assertion: {sql}"))?; + if result.is_some() { + bail!("SQL={sql} with NULL operand must yield NULL, got {result:?}"); + } + Ok(()) +} + +/// Blocker error message — the contract every encrypted-domain blocker +/// must satisfy regardless of arg shape or NULL configuration. +pub fn blocker_msg(domain: &str, op: &str) -> String { + format!("operator {op} is not supported for {domain}") +} diff --git a/tests/sqlx/tests/aggregate_tests.rs b/tests/sqlx/tests/aggregate_tests.rs index 9306df26..f942a51e 100644 --- a/tests/sqlx/tests/aggregate_tests.rs +++ b/tests/sqlx/tests/aggregate_tests.rs @@ -1,14 +1,20 @@ //! Aggregate function tests //! -//! Tests COUNT, MAX, MIN with encrypted data including eql_v2.min() and eql_v2.max() +//! Covers native `COUNT` / `GROUP BY` on `eql_v2_encrypted` and the +//! `eql_v2.min(eql_v2_encrypted)` / `eql_v2.max(eql_v2_encrypted)` aggregates +//! on the composite type. Per-domain aggregates +//! (`eql_v2.min(eql_v2__ord)` etc.) are additionally covered by the +//! encrypted-domain test matrix (`tests/sqlx/src/matrix.rs`, instantiated per +//! scalar type from `tests/sqlx/tests/encrypted_domain/scalars/.rs`). use anyhow::Result; use sqlx::PgPool; #[sqlx::test] async fn count_aggregate_on_encrypted_column(pool: PgPool) -> Result<()> { - // Test: COUNT works on encrypted columns (counts non-NULL encrypted values) - + // COUNT on an `eql_v2_encrypted` column is PostgreSQL-native — no + // aggregate declaration is required. Pin that it still counts non-NULL + // encrypted rows on the legacy composite type. let count: i64 = sqlx::query_scalar("SELECT COUNT(e) FROM ore") .fetch_one(&pool) .await?; @@ -68,9 +74,9 @@ async fn min_aggregate_on_encrypted_column(pool: PgPool) -> Result<()> { #[sqlx::test(fixtures(path = "../fixtures", scripts("encrypted_json")))] async fn group_by_with_encrypted_column(pool: PgPool) -> Result<()> { - // Test: GROUP BY works with encrypted data - // Fixture creates 3 distinct encrypted records, each unique - + // GROUP BY on `eql_v2_encrypted` works natively against the fixture's + // distinct payloads. Pin that grouping by an encrypted column returns + // the expected number of groups. let group_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM ( SELECT e, COUNT(*) FROM encrypted GROUP BY e diff --git a/tests/sqlx/tests/constraint_tests.rs b/tests/sqlx/tests/constraint_tests.rs index baf51a13..7e2871c1 100644 --- a/tests/sqlx/tests/constraint_tests.rs +++ b/tests/sqlx/tests/constraint_tests.rs @@ -3,6 +3,7 @@ //! Tests UNIQUE, NOT NULL, CHECK constraints on encrypted columns use anyhow::Result; +use eql_tests::assert_db_error; use sqlx::PgPool; #[sqlx::test(fixtures(path = "../fixtures", scripts("constraint_tables")))] @@ -25,17 +26,15 @@ async fn unique_constraint_on_encrypted_column(pool: PgPool) -> Result<()> { assert_eq!(count, 1, "Should have 1 record after insert"); // Attempt duplicate insert - let result = sqlx::query( + let err = sqlx::query( "INSERT INTO constrained (unique_field, not_null_field, check_field) VALUES (create_encrypted_json(1, 'hm'), create_encrypted_json(2, 'hm'), create_encrypted_json(2, 'hm'))" ) .execute(&pool) - .await; + .await + .expect_err("UNIQUE constraint should prevent duplicate"); - assert!( - result.is_err(), - "UNIQUE constraint should prevent duplicate" - ); + assert_db_error(&err, "23505", Some("constrained_unique_field_key")); // Verify count unchanged after failed insert let count_after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM constrained") @@ -51,14 +50,17 @@ async fn unique_constraint_on_encrypted_column(pool: PgPool) -> Result<()> { async fn not_null_constraint_on_encrypted_column(pool: PgPool) -> Result<()> { // Test: NOT NULL constraint enforced (2 assertions) - let result = sqlx::query( + let err = sqlx::query( "INSERT INTO constrained (unique_field) VALUES (create_encrypted_json(2, 'hm'))", ) .execute(&pool) - .await; + .await + .expect_err("NOT NULL constraint should prevent NULL"); - assert!(result.is_err(), "NOT NULL constraint should prevent NULL"); + // NOT NULL is a column attribute, not a named constraint — `constraint()` + // returns None, so only pin the SQLSTATE. + assert_db_error(&err, "23502", None); // Verify no records were inserted let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM constrained") @@ -74,7 +76,7 @@ async fn not_null_constraint_on_encrypted_column(pool: PgPool) -> Result<()> { async fn check_constraint_on_encrypted_column(pool: PgPool) -> Result<()> { // Test: CHECK constraint enforced (2 assertions) - let result = sqlx::query( + let err = sqlx::query( "INSERT INTO constrained (unique_field, not_null_field, check_field) VALUES ( create_encrypted_json(3, 'hm'), @@ -83,9 +85,10 @@ async fn check_constraint_on_encrypted_column(pool: PgPool) -> Result<()> { )", ) .execute(&pool) - .await; + .await + .expect_err("CHECK constraint should prevent NULL"); - assert!(result.is_err(), "CHECK constraint should prevent NULL"); + assert_db_error(&err, "23514", Some("constrained_check_field_check")); // Verify no records were inserted let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM constrained") @@ -199,15 +202,13 @@ async fn foreign_key_constraint_with_encrypted(pool: PgPool) -> Result<()> { ); // Attempt to insert child with different encrypted value (should fail FK check) - let different_insert_result = + let err = sqlx::query("INSERT INTO child (id, parent_id) VALUES (2, create_encrypted_json(2, 'hm'))") .execute(&pool) - .await; + .await + .expect_err("FK constraint should reject non-existent parent reference"); - assert!( - different_insert_result.is_err(), - "FK constraint should reject non-existent parent reference" - ); + assert_db_error(&err, "23503", Some("child_parent_id_fkey")); // Verify child count unchanged let final_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM child") @@ -258,14 +259,17 @@ async fn add_encrypted_constraint_prevents_invalid_data(pool: PgPool) -> Result< .await?; // Now attempt to insert invalid data - should fail - let result = sqlx::query("INSERT INTO encrypted (e) VALUES ('{}'::jsonb::eql_v2_encrypted)") + let err = sqlx::query("INSERT INTO encrypted (e) VALUES ('{}'::jsonb::eql_v2_encrypted)") .execute(&pool) - .await; + .await + .expect_err("Constraint should prevent insert of invalid eql_v2_encrypted (empty JSONB)"); - assert!( - result.is_err(), - "Constraint should prevent insert of invalid eql_v2_encrypted (empty JSONB)" - ); + // `check_encrypted` RAISEs on invalid payloads, so the CHECK constraint + // propagates the underlying SQLSTATE (P0001 raise_exception) rather than + // 23514. The raise message identifies which check failed (missing v, + // invalid v, missing root c/sv, etc.) — that's the value over a bare + // `is_err()` check. + assert_db_error(&err, "P0001", None); // Verify count unchanged after failed insert let final_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM encrypted") @@ -290,14 +294,17 @@ async fn remove_encrypted_constraint_allows_invalid_data(pool: PgPool) -> Result .await?; // Verify constraint is working - invalid data should be rejected - let result = sqlx::query("INSERT INTO encrypted (e) VALUES ('{}'::jsonb::eql_v2_encrypted)") + let err = sqlx::query("INSERT INTO encrypted (e) VALUES ('{}'::jsonb::eql_v2_encrypted)") .execute(&pool) - .await; + .await + .expect_err("Constraint should prevent insert of invalid eql_v2_encrypted"); - assert!( - result.is_err(), - "Constraint should prevent insert of invalid eql_v2_encrypted" - ); + // `check_encrypted` RAISEs on invalid payloads, so the CHECK constraint + // propagates the underlying SQLSTATE (P0001 raise_exception) rather than + // 23514. The raise message identifies which check failed (missing v, + // invalid v, missing root c/sv, etc.) — that's the value over a bare + // `is_err()` check. + assert_db_error(&err, "P0001", None); // Remove the constraint sqlx::query("SELECT eql_v2.remove_encrypted_constraint('encrypted', 'e')") @@ -342,18 +349,21 @@ async fn version_metadata_validation_on_insert(pool: PgPool) -> Result<()> { .fetch_one(&pool) .await?; - // Attempt to insert without version field - should fail - let result = sqlx::query(&format!( - "INSERT INTO encrypted (e) VALUES ('{}'::jsonb::eql_v2_encrypted)", - encrypted_without_version - )) - .execute(&pool) - .await; + // Attempt to insert without version field - should fail. Bind the payload + // rather than format!-interpolate it — JSONB strings can carry quotes + // and would otherwise need hand-rolled escaping. + let err = sqlx::query("INSERT INTO encrypted (e) VALUES ($1::jsonb::eql_v2_encrypted)") + .bind(&encrypted_without_version) + .execute(&pool) + .await + .expect_err("Insert should fail when version field is missing"); - assert!( - result.is_err(), - "Insert should fail when version field is missing" - ); + // `check_encrypted` RAISEs on invalid payloads, so the CHECK constraint + // propagates the underlying SQLSTATE (P0001 raise_exception) rather than + // 23514. The raise message identifies which check failed (missing v, + // invalid v, missing root c/sv, etc.) — that's the value over a bare + // `is_err()` check. + assert_db_error(&err, "P0001", None); // Create encrypted value with invalid version (v=1 instead of v=2) let encrypted_invalid_version: String = @@ -362,17 +372,18 @@ async fn version_metadata_validation_on_insert(pool: PgPool) -> Result<()> { .await?; // Attempt to insert with invalid version - should fail - let result = sqlx::query(&format!( - "INSERT INTO encrypted (e) VALUES ('{}'::jsonb::eql_v2_encrypted)", - encrypted_invalid_version - )) - .execute(&pool) - .await; + let err = sqlx::query("INSERT INTO encrypted (e) VALUES ($1::jsonb::eql_v2_encrypted)") + .bind(&encrypted_invalid_version) + .execute(&pool) + .await + .expect_err("Insert should fail when version field is invalid (v=1)"); - assert!( - result.is_err(), - "Insert should fail when version field is invalid (v=1)" - ); + // `check_encrypted` RAISEs on invalid payloads, so the CHECK constraint + // propagates the underlying SQLSTATE (P0001 raise_exception) rather than + // 23514. The raise message identifies which check failed (missing v, + // invalid v, missing root c/sv, etc.) — that's the value over a bare + // `is_err()` check. + assert_db_error(&err, "P0001", None); // Insert with valid version (v=2) should succeed sqlx::query("INSERT INTO encrypted (e) VALUES (create_encrypted_json(1))") @@ -455,17 +466,17 @@ async fn check_encrypted_accepts_stevec_payload(pool: PgPool) -> Result<()> { ); // Sanity-check the negative path: a root that carries neither `c` nor - // `sv` is still rejected with the updated error message. - let neither: Result = sqlx::query_scalar( + // `sv` is still rejected with the updated error message. Calling + // `check_encrypted` directly RAISEs (not a CHECK constraint), so + // SQLSTATE P0001 (raise_exception) rather than 23514. + let err = sqlx::query_scalar::<_, bool>( "SELECT eql_v2.check_encrypted('{\"v\": 2, \"i\": {\"t\": \"users\", \"c\": \"x\"}}'::jsonb)", ) .fetch_one(&pool) - .await; + .await + .expect_err("payload with neither c nor sv at root must be rejected"); - assert!( - neither.is_err(), - "payload with neither c nor sv at root must be rejected" - ); + assert_db_error(&err, "P0001", None); Ok(()) } diff --git a/tests/sqlx/tests/encrypted_domain.rs b/tests/sqlx/tests/encrypted_domain.rs new file mode 100644 index 00000000..0ac40b22 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain.rs @@ -0,0 +1,12 @@ +//! Umbrella integration-test binary for the encrypted-domain type family. +//! +//! Cargo's default discovery picks this file up as a test binary; the +//! module tree under `encrypted_domain/` is pulled in via the `#[path]` +//! attributes below. Legacy tests under `tests/sqlx/tests/*.rs` continue +//! to compile as their own separate binaries. + +#[path = "encrypted_domain/family/mod.rs"] +mod family; + +#[path = "encrypted_domain/scalars/mod.rs"] +mod scalars; diff --git a/tests/sqlx/tests/encrypted_domain/family/inlinability.rs b/tests/sqlx/tests/encrypted_domain/family/inlinability.rs new file mode 100644 index 00000000..37347228 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/family/inlinability.rs @@ -0,0 +1,252 @@ +//! Global guard for the encrypted-domain inline-critical SQL surface. +//! +//! `tasks/pin_search_path.sql` runs after every build and pins a fixed +//! `search_path` on every `eql_v2` function — except the inline-critical +//! ones, which must stay unpinned so the planner can inline them and the +//! documented functional indexes (`eql_v2.eq_term(col)`, +//! `eql_v2.ord_term(col)`, …) engage. +//! +//! The encrypted-domain family is skipped by a structural rule anchored +//! on the *identity predicate*: a `LANGUAGE sql`, `IMMUTABLE` function +//! taking at least one argument typed as a jsonb-backed DOMAIN in +//! `public` named `eql_v2_*`. The identity predicate is +//! proconfig-independent — it describes what a function intrinsically +//! IS, not whether it has been pinned. +//! +//! This test is the global net for that rule. It uses the identity +//! predicate VERBATIM and appends one offender filter: +//! `proconfig IS NOT NULL` — a function matching the family shape that +//! nonetheless carries a pinned `search_path`. It asserts that offender +//! set is empty. Because the test and the pin-loop skip clause share the +//! identity predicate exactly (the guard only adds the offender filter), +//! they cannot drift apart on identity. +//! +//! A non-empty result means `pin_search_path.sql` pinned an +//! inline-critical encrypted-domain function — index engagement is +//! silently broken for that type. This is not int4-specific: a missed +//! skip for ANY encrypted-domain type — present or future — fails here, +//! so a new type's author does not have to remember to add a per-type +//! inlinability assertion. + +use anyhow::Result; +use sqlx::PgPool; + +#[sqlx::test] +async fn no_encrypted_domain_inline_critical_function_is_pinned(pool: PgPool) -> Result<()> { + // The identity predicate is shared verbatim with the structural skip + // clause in tasks/pin_search_path.sql: LANGUAGE sql, IMMUTABLE, and + // taking at least one argument typed as a `public.eql_v2_*` domain + // over jsonb. It is proconfig-independent. The ONLY addition here is + // the offender filter `p.proconfig IS NOT NULL` — a function that + // matches the identity predicate but DID get pinned. That set must be + // empty. + let offenders: Vec<(String, String)> = sqlx::query_as( + r#" + SELECT p.oid::regprocedure::text AS signature, + array_to_string(p.proconfig, ', ') AS proconfig + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = 'eql_v2' + AND l.lanname = 'sql' + AND p.provolatile = 'i' + AND p.proconfig IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(p.proargtypes::oid[]) AS arg(typ) + JOIN pg_catalog.pg_type dt ON dt.oid = arg.typ + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND dt.typname LIKE 'eql_v2\_%' + AND bt.typname = 'jsonb' + ) + ORDER BY signature + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + offenders.is_empty(), + "pin_search_path.sql pinned {} inline-critical encrypted-domain \ + SQL function(s) — index engagement is silently broken. \ + Offenders (signature → proconfig):\n{}", + offenders.len(), + offenders + .iter() + .map(|(sig, cfg)| format!(" {sig} → {cfg}")) + .collect::>() + .join("\n"), + ); + Ok(()) +} + +#[sqlx::test] +async fn every_inline_critical_eligible_domain_has_inline_critical_functions( + pool: PgPool, +) -> Result<()> { + // Stronger than a bare `count > 0`: if a future change accidentally + // narrows the structural predicate (e.g. hard-codes `eql_v2_int4_%`), + // a `count > 0` assertion would still pass while int8/bool/date + // domains silently lose inline-critical coverage. Instead, assert + // that EVERY inline-critical-eligible domain (any `public.eql_v2_*` + // domain over jsonb that carries a capability suffix — `_eq`, `_ord`, + // `_ord_ore`) appears as an argument type of at least one + // inline-critical function. + // + // Storage-only variants (the bare `eql_v2_` domain, with no + // capability suffix) intentionally have NO inline-critical surface + // and are excluded from the eligibility set. + let unbound: Vec = sqlx::query_scalar( + r#" + SELECT dt.typname + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND bt.typname = 'jsonb' + AND dt.typname LIKE 'eql_v2\_%' + AND ( + dt.typname LIKE '%\_eq' + OR dt.typname LIKE '%\_ord' + OR dt.typname LIKE '%\_ord\_ore' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = 'eql_v2' + AND l.lanname = 'sql' + AND p.provolatile = 'i' + AND dt.oid = ANY(p.proargtypes::oid[]) + ) + ORDER BY dt.typname + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + unbound.is_empty(), + "the following inline-critical-eligible domains have NO \ + inline-critical function bound — index engagement is broken \ + for them: {unbound:?}" + ); + Ok(()) +} + +/// Encrypted-domain blockers must be `LANGUAGE plpgsql` and **never** +/// `STRICT`. A LANGUAGE sql blocker is inlinable (the planner can elide +/// it when the result is provably unused); a STRICT blocker returns NULL +/// on a NULL argument, silently bypassing the RAISE. Either footgun +/// re-enables an operator the storage variant exists to block. +/// +/// This is a structural guard that does NOT depend on `eql_v2.lints()` — +/// a regression to the lint catalog itself cannot hide a regression to +/// the blocker surface from this test. +#[sqlx::test] +async fn encrypted_domain_blockers_are_plpgsql_and_non_strict(pool: PgPool) -> Result<()> { + let offenders: Vec<(String, String, bool)> = sqlx::query_as( + r#" + SELECT p.oid::regprocedure::text AS signature, + l.lanname, + p.proisstrict + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = 'eql_v2' + AND (p.prosrc LIKE '%encrypted_domain_unsupported_bool%' + OR p.prosrc LIKE '%is not supported for%') + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(p.proargtypes::oid[]) AS arg(typ) + JOIN pg_catalog.pg_type dt ON dt.oid = arg.typ + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND dt.typname LIKE 'eql_v2\_%' + AND bt.typname = 'jsonb' + ) + AND (l.lanname <> 'plpgsql' OR p.proisstrict) + ORDER BY signature + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + offenders.is_empty(), + "encrypted-domain blockers must be LANGUAGE plpgsql and non-STRICT. \ + Offenders (signature, language, isstrict): {offenders:#?}" + ); + Ok(()) +} + +/// No `eql_v2_*` domain may be derived from another `eql_v2_*` domain — +/// operators resolve against the ultimate base type, so a derived domain +/// inherits jsonb's operator surface and not the base domain's blockers. +/// All family domains must be defined directly over jsonb. +#[sqlx::test] +async fn no_eql_v2_domain_is_derived_from_another_eql_v2_domain(pool: PgPool) -> Result<()> { + let offenders: Vec<(String, String)> = sqlx::query_as( + r#" + SELECT format('%I.%I', dn.nspname, dt.typname) AS derived, + format('%I.%I', bn.nspname, bt.typname) AS base + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dn ON dn.oid = dt.typnamespace + JOIN pg_catalog.pg_type bt ON bt.oid = dt.typbasetype + JOIN pg_catalog.pg_namespace bn ON bn.oid = bt.typnamespace + WHERE dt.typtype = 'd' + AND dn.nspname = 'public' + AND dt.typname LIKE 'eql_v2\_%' + AND bt.typtype = 'd' + AND bt.typname LIKE 'eql_v2\_%' + ORDER BY derived + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + offenders.is_empty(), + "eql_v2_* domains must be defined directly over jsonb, not derived \ + from another eql_v2_* domain. Offenders (derived, base): {offenders:#?}" + ); + Ok(()) +} + +/// No operator class may be declared `FOR TYPE` on an `eql_v2_*` domain. +/// Opclasses on domains bypass the operator-resolution that storage +/// blockers depend on. The recommended index pattern is a functional +/// index on the extractor (e.g. `eql_v2.eq_term(col)`). +#[sqlx::test] +async fn no_opclass_targets_eql_v2_domain(pool: PgPool) -> Result<()> { + let offenders: Vec<(String, String)> = sqlx::query_as( + r#" + SELECT format('%I.%I', cn.nspname, oc.opcname) AS opclass, + format('%I.%I', tn.nspname, t.typname) AS for_type + FROM pg_catalog.pg_opclass oc + JOIN pg_catalog.pg_type t ON t.oid = oc.opcintype + JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace + JOIN pg_catalog.pg_namespace cn ON cn.oid = oc.opcnamespace + WHERE t.typtype = 'd' + AND tn.nspname = 'public' + AND t.typname LIKE 'eql_v2\_%' + ORDER BY opclass + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + offenders.is_empty(), + "no operator class may target an eql_v2_* domain — use a functional \ + index on the extractor instead. Offenders (opclass, for_type): {offenders:#?}" + ); + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs b/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs new file mode 100644 index 00000000..8dc2e049 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs @@ -0,0 +1,75 @@ +//! Structural guard for the blocked native-jsonb operator enumeration. +//! +//! The storage-only domains (`eql_v2_int4`, future scalars) promise that +//! *every* native jsonb operator is blocked, so an encrypted column can never +//! fall through to plaintext-jsonb semantics. That promise rests on three +//! hand-maintained lists in `tasks/codegen/operator_surface.py` +//! (`SYMMETRIC_OPERATORS`, `PATH_OPERATORS`, `BLOCKER_ONLY_OPERATORS`), whose +//! union is `KNOWN_JSONB_OPERATORS`. +//! +//! Those lists are an *enumeration*, not a structural guarantee: a future PG +//! version could add a jsonb operator that nobody adds here, and it would +//! silently route to native jsonb behaviour. This test closes that gap by +//! asking the live catalog which operators actually touch `jsonb` and failing +//! if any symbol is absent from the known union. +//! +//! Source of truth: `tasks/codegen/operator_surface.py::KNOWN_JSONB_OPERATORS` +//! (asserted complete by `tasks/codegen/test_operator_surface.py`). The set +//! below is hardcoded — the lowest-friction bridge from a Python constant to a +//! Rust test — and must be kept in sync with that module. If you add an +//! operator there, add it here; the Python test pins the union so the two can +//! only drift in this file. + +use anyhow::Result; +use sqlx::PgPool; + +/// Mirror of `KNOWN_JSONB_OPERATORS` in +/// `tasks/codegen/operator_surface.py`. Keep in sync with that module. +const KNOWN_JSONB_OPERATORS: &[&str] = &[ + // symmetric (supported wrappers) + "=", "<>", "<", "<=", ">", ">=", "@>", "<@", // + // path + "->", "->>", // + // blocker-only native jsonb fallbacks + "?", "?|", "?&", "@?", "@@", "#>", "#>>", "-", "#-", "||", +]; + +#[sqlx::test] +async fn every_native_jsonb_operator_is_known_to_the_generator(pool: PgPool) -> Result<()> { + // Distinct operator symbols whose left OR right argument is `jsonb`. This + // is the full surface a value typed as a jsonb-backed domain can reach via + // operator resolution against the ultimate base type. + let native: Vec = sqlx::query_scalar( + r#" + SELECT DISTINCT o.oprname + FROM pg_catalog.pg_operator o + WHERE o.oprleft = 'jsonb'::regtype + OR o.oprright = 'jsonb'::regtype + ORDER BY 1 + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + !native.is_empty(), + "expected pg_operator to expose jsonb operators; query returned none" + ); + + let missing: Vec<&String> = native + .iter() + .filter(|sym| !KNOWN_JSONB_OPERATORS.contains(&sym.as_str())) + .collect(); + + assert!( + missing.is_empty(), + "PostgreSQL exposes jsonb operator(s) not enumerated in \ + tasks/codegen/operator_surface.py (KNOWN_JSONB_OPERATORS): {missing:#?}. \ + A storage-only encrypted domain would route these to native \ + plaintext-jsonb semantics instead of an EQL blocker. Add each symbol \ + to the appropriate list in operator_surface.py (and to the mirror in \ + this test) and regenerate the SQL surface." + ); + + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_domain/family/mod.rs b/tests/sqlx/tests/encrypted_domain/family/mod.rs new file mode 100644 index 00000000..6622e0f8 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/family/mod.rs @@ -0,0 +1,7 @@ +//! Family-level tests: invariants that apply across every scalar type in +//! the encrypted-domain family (not int4-specific). + +pub mod inlinability; +pub mod jsonb_operator_surface; +pub mod mutations; +pub mod support; diff --git a/tests/sqlx/tests/encrypted_domain/family/mutations.rs b/tests/sqlx/tests/encrypted_domain/family/mutations.rs new file mode 100644 index 00000000..2745e1ae --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/family/mutations.rs @@ -0,0 +1,428 @@ +//! Negative controls (mutation tests) for the scalar-domain matrix. +//! +//! A green matrix proves the SUT behaves correctly *today*, but it cannot +//! prove the matrix arms would catch a regression — an arm could be +//! vacuous and still pass. Each test here applies one surgical mutation to +//! the installed `eql_v2` schema and asserts that the property a specific +//! matrix arm guards now flips. If a mutation does NOT flip the property, +//! that arm has no teeth. +//! +//! Mechanism: `CREATE OR REPLACE FUNCTION` keeps the function oid, so the +//! operators / aggregates that reference it keep resolving to the (now +//! mutated) body — that's what lets us re-route a comparison or disable a +//! blocker without touching operator definitions. Each `#[sqlx::test]` +//! gets its own fresh database (EQL pre-installed via the auto-applied +//! `migrations/001_install_eql.sql`), so the mutation is discarded when the +//! per-test DB is dropped — no cleanup, no rebuild. +//! +//! Pattern per test: assert the baseline property holds, mutate, assert it +//! now breaks. The baseline assertion is load-bearing — it proves the +//! probe is non-vacuous before the mutation. + +use anyhow::{ensure, Result}; +use eql_tests::{ + assert_null, assert_raises, blocker_msg, fetch_fixture_payload, ScalarType, PLACEHOLDER_PAYLOAD, +}; +use sqlx::PgPool; + +/// Apply one DDL mutation to the installed schema. +async fn mutate(pool: &PgPool, ddl: &str) -> Result<()> { + sqlx::query(ddl).execute(pool).await?; + Ok(()) +} + +// 1. Storage `=` blocker — disabling it lets the storage variant compare +// equal. Proves the `blocker` arm (and `typed_column_blocker`) would +// catch a blocker that silently stopped raising. +#[sqlx::test] +async fn disabling_storage_eq_blocker_flips_blocker_arm(pool: PgPool) -> Result<()> { + let sql = "SELECT $1::jsonb::eql_v2_int4 = $2::jsonb::eql_v2_int4"; + + // Baseline: the storage `=` blocker raises. + assert_raises( + &pool, + sql, + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + &blocker_msg("eql_v2_int4", "="), + ) + .await?; + + // Mutation: replace the plpgsql blocker with an inlinable SQL body that + // returns true. CREATE OR REPLACE keeps the oid, so the `=` operator on + // (eql_v2_int4, eql_v2_int4) now resolves to this no-raise body. + mutate( + &pool, + "CREATE OR REPLACE FUNCTION eql_v2.eq(a eql_v2_int4, b eql_v2_int4) \ + RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT true $$", + ) + .await?; + + // Post: the operator returns true instead of raising — arm has teeth. + let result: Option = sqlx::query_scalar(sql) + .bind(PLACEHOLDER_PAYLOAD) + .bind(PLACEHOLDER_PAYLOAD) + .fetch_one(&pool) + .await?; + ensure!( + result == Some(true), + "after disabling the storage `=` blocker, `=` must return true (got {result:?})" + ); + Ok(()) +} + +// 2. Planner-metadata RESTRICT selectivity — unsetting it makes the +// `planner_metadata` arm's `oprrest <> 0` check report false. (COMMUTATOR +// cannot be unset via ALTER, so RESTRICT is the pragmatic teeth probe for +// this arm.) +#[sqlx::test] +async fn unsetting_restrict_flips_planner_metadata_arm(pool: PgPool) -> Result<()> { + async fn restrict_present(pool: &PgPool) -> Result { + let present: bool = sqlx::query_scalar( + r#" + SELECT o.oprrest::oid <> 0 + FROM pg_catalog.pg_operator o + JOIN pg_catalog.pg_type lt ON lt.oid = o.oprleft + JOIN pg_catalog.pg_type rt ON rt.oid = o.oprright + WHERE o.oprname = '=' + AND lt.typname = 'eql_v2_int4_ord' + AND rt.typname = 'eql_v2_int4_ord' + "#, + ) + .fetch_one(pool) + .await?; + Ok(present) + } + + // Baseline: `=` on (ord, ord) declares a RESTRICT estimator. + ensure!( + restrict_present(&pool).await?, + "baseline: `=` on eql_v2_int4_ord must declare a RESTRICT estimator" + ); + + // Mutation: unset RESTRICT. DROP OPERATOR would hit COMMUTATOR/NEGATOR + // dependency links; ALTER ... SET (RESTRICT = NONE) avoids that. + mutate( + &pool, + "ALTER OPERATOR = (eql_v2_int4_ord, eql_v2_int4_ord) SET (RESTRICT = NONE)", + ) + .await?; + + // Post: the planner-metadata check now reports false — arm has teeth. + ensure!( + !restrict_present(&pool).await?, + "after SET (RESTRICT = NONE), the planner-metadata check must report false" + ); + Ok(()) +} + +// 3. `_ord` equality must route through `ord_term` (`ob`), never HMAC. +// Rerouting it through `hmac_256` (`hm`) over hm-stripped rows makes `=` +// stop matching. Proves the `ord_routes_through_ob` arm has teeth. +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn rerouting_ord_eq_through_hm_flips_ord_routes_arm(pool: PgPool) -> Result<()> { + // Strip `hm` per-row inline; the `_ord` CHECK only requires `ob`, so the + // cast still succeeds. The pivot is likewise hm-stripped. + let pivot: i32 = 42; + let pivot_payload: String = sqlx::query_scalar(&format!( + "SELECT (payload - 'hm')::text FROM fixtures.eql_v2_int4 WHERE plaintext = {pivot}", + )) + .fetch_one(&pool) + .await?; + + let count_sql = "SELECT count(*) FROM fixtures.eql_v2_int4 \ + WHERE (payload - 'hm')::eql_v2_int4_ord = $1::jsonb::eql_v2_int4_ord"; + + // Baseline: with `hm` stripped, `=` still matches the pivot via `ord_term` + // (the `ob` term survives) — exactly one row. + let baseline: i64 = sqlx::query_scalar(count_sql) + .bind(&pivot_payload) + .fetch_one(&pool) + .await?; + ensure!( + baseline == 1, + "baseline: `_ord` `=` must match exactly the pivot via ob with hm stripped (got {baseline})" + ); + + // Mutation: reroute `_ord` `=` through HMAC. `eql_v2.hmac_256(jsonb)` is + // STRICT and the `hm` key is absent, so it yields NULL and `=` matches + // nothing. + mutate( + &pool, + "CREATE OR REPLACE FUNCTION eql_v2.eq(a eql_v2_int4_ord, b eql_v2_int4_ord) \ + RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE \ + AS $$ SELECT eql_v2.hmac_256(a::jsonb) = eql_v2.hmac_256(b::jsonb) $$", + ) + .await?; + + // Post: routing through the absent `hm` matches zero rows — arm has teeth. + let mutated: i64 = sqlx::query_scalar(count_sql) + .bind(&pivot_payload) + .fetch_one(&pool) + .await?; + ensure!( + mutated == 0, + "after rerouting `_ord` `=` through hm, it must match zero hm-stripped rows (got {mutated})" + ); + Ok(()) +} + +// 4. Supported `=` on `_eq` is STRICT — it must propagate NULL. Dropping +// STRICT (and returning non-NULL) makes `x = NULL` return a value. Proves +// the `supported_null` arm has teeth. +#[sqlx::test] +async fn dropping_strict_on_eq_flips_supported_null_arm(pool: PgPool) -> Result<()> { + let sql = "SELECT $1::jsonb::eql_v2_int4_eq = $2::jsonb::eql_v2_int4_eq"; + + // Baseline: STRICT `=` propagates NULL when one side is NULL. + assert_null(&pool, sql, &[Some(PLACEHOLDER_PAYLOAD), None]).await?; + + // Mutation: drop STRICT and return a constant non-NULL. CREATE OR REPLACE + // keeps the oid; the operator now ignores NULL semantics. + mutate( + &pool, + "CREATE OR REPLACE FUNCTION eql_v2.eq(a eql_v2_int4_eq, b eql_v2_int4_eq) \ + RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT true $$", + ) + .await?; + + // Post: `x = NULL` returns true instead of NULL — arm has teeth. + let result: Option = sqlx::query_scalar(sql) + .bind(PLACEHOLDER_PAYLOAD) + .bind(Option::<&str>::None) + .fetch_one(&pool) + .await?; + ensure!( + result == Some(true), + "after dropping STRICT on `_eq` `=`, `x = NULL` must return true, not NULL (got {result:?})" + ); + Ok(()) +} + +// 5. Ord `<` correctness routes through `eql_v2.lt`. Turning `lt` into a +// blocker makes `<` raise — proving the ord `<` correctness arm has teeth. +// Crucially, ORDER BY routes through `ord_term`, NOT `<`, so it must stay +// green here. This is the #5-vs-#7 split: #5 attacks `<`, #7 attacks the +// sort key. Blocking `<` alone must not disturb ORDER BY. +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn blocking_lt_flips_lt_arm_but_not_order_by(pool: PgPool) -> Result<()> { + let lt_sql = "SELECT $1::jsonb::eql_v2_int4_ord < $2::jsonb::eql_v2_int4_ord"; + let order_by_sql = "SELECT plaintext FROM fixtures.eql_v2_int4 \ + ORDER BY eql_v2.ord_term(payload::eql_v2_int4_ord) ASC"; + + let mut ascending: Vec = ::FIXTURE_VALUES.to_vec(); + ascending.sort(); + + // Baseline: `<` works (no raise) and ORDER BY is plaintext-sorted. + let lt_baseline: Option = sqlx::query_scalar(lt_sql) + .bind(PLACEHOLDER_PAYLOAD) + .bind(PLACEHOLDER_PAYLOAD) + .fetch_one(&pool) + .await?; + ensure!( + lt_baseline.is_some(), + "baseline: `_ord` `<` must return a boolean (got {lt_baseline:?})" + ); + let order_baseline: Vec = sqlx::query_scalar(order_by_sql).fetch_all(&pool).await?; + ensure!( + order_baseline == ascending, + "baseline: ORDER BY ord_term ASC must be plaintext-sorted" + ); + + // Mutation: turn `eql_v2.lt(_ord, _ord)` into a blocker. Must be + // LANGUAGE plpgsql and non-STRICT so the RAISE always fires. + mutate( + &pool, + "CREATE OR REPLACE FUNCTION eql_v2.lt(a eql_v2_int4_ord, b eql_v2_int4_ord) \ + RETURNS boolean LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE \ + AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4_ord', '<'); END; $$", + ) + .await?; + + // Post: `<` now raises — the ord `<` arm has teeth. + assert_raises( + &pool, + lt_sql, + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + &blocker_msg("eql_v2_int4_ord", "<"), + ) + .await?; + + // Post: ORDER BY is UNCHANGED — it routes through ord_term, not `<`. + // This is the whole point of separating #5 from #7. + let order_after: Vec = sqlx::query_scalar(order_by_sql).fetch_all(&pool).await?; + ensure!( + order_after == ascending, + "blocking `<` must NOT disturb ORDER BY (it routes through ord_term); got {order_after:?}" + ); + Ok(()) +} + +// 6. `_eq` equality must route through `eq_term` (`hm`), never ORE — the +// mirror of #3 for the eq path. Rerouting it through +// `ore_block_u64_8_256` (`ob`) over ob-stripped rows breaks equality. +// +// Two notes on why this is shaped differently from the plan's literal +// "returns 0 where forward expects 1": +// - The fixture payloads carry BOTH `hm` and `ob`, so rerouting `_eq` +// `=` through ORE on the RAW fixture would still match (both terms are +// injective per plaintext) — vacuous. Stripping `ob` forces the +// rerouted operator onto an absent term, exactly as #3 strips `hm`. +// - `ore_block_u64_8_256(jsonb)` RAISES on an absent `ob` ("Expected an +// ore index (ob)"), whereas `hmac_256(jsonb)` returns NULL on an absent +// `hm`. So the eq path breaks via a raise, not a 0-count. Either way the +// correct hm-routed equality matches and the rerouted one does not. +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn rerouting_eq_eq_through_ob_flips_eq_arm(pool: PgPool) -> Result<()> { + // Strip `ob` per-row inline; the `_eq` CHECK only requires `hm`, so the + // cast still succeeds. The pivot is likewise ob-stripped. + let pivot: i32 = 42; + let pivot_payload: String = sqlx::query_scalar(&format!( + "SELECT (payload - 'ob')::text FROM fixtures.eql_v2_int4 WHERE plaintext = {pivot}", + )) + .fetch_one(&pool) + .await?; + + let count_sql = "SELECT count(*) FROM fixtures.eql_v2_int4 \ + WHERE (payload - 'ob')::eql_v2_int4_eq = $1::jsonb::eql_v2_int4_eq"; + + // Baseline: with `ob` stripped, `=` still matches the pivot via `eq_term` + // (the `hm` term survives) — exactly one row. + let baseline: i64 = sqlx::query_scalar(count_sql) + .bind(&pivot_payload) + .fetch_one(&pool) + .await?; + ensure!( + baseline == 1, + "baseline: `_eq` `=` must match exactly the pivot via hm with ob stripped (got {baseline})" + ); + + // Mutation: reroute `_eq` `=` through ORE. The `ob` key is absent, so + // `eql_v2.ore_block_u64_8_256(jsonb)` raises rather than matching. + mutate( + &pool, + "CREATE OR REPLACE FUNCTION eql_v2.eq(a eql_v2_int4_eq, b eql_v2_int4_eq) \ + RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE \ + AS $$ SELECT eql_v2.ore_block_u64_8_256(a::jsonb) = eql_v2.ore_block_u64_8_256(b::jsonb) $$", + ) + .await?; + + // Post: routing through the absent `ob` raises ("Expected an ore index") + // instead of matching the pivot — equality is broken, arm has teeth. + let err = sqlx::query_scalar::<_, i64>(count_sql) + .bind(&pivot_payload) + .fetch_one(&pool) + .await + .expect_err("rerouting `_eq` `=` through the absent ob term must fail") + .to_string(); + ensure!( + err.contains("Expected an ore index"), + "rerouted `_eq` `=` must fail on the absent ob term; got: {err}" + ); + Ok(()) +} + +// 7. ORDER BY routes through `ord_term` — the sort key, NOT `<` (see #5). +// Collapsing `ord_term` to a constant makes ORDER BY DESC no longer +// plaintext-sorted. Proves the ORDER BY arm has teeth independently of the +// `<` arm. +// +// A constant key collapses ASC and DESC to the same heap order. The +// fixture inserts rows in ascending plaintext (id 1..n), so a seq scan +// returns ascending order — which can never equal the descending +// expectation. Asserting against DESC therefore detects the collapse +// regardless of heap order (the ascending-fixture caveat from the plan). +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn collapsing_ord_term_flips_order_by_arm(pool: PgPool) -> Result<()> { + let order_by_desc = "SELECT plaintext FROM fixtures.eql_v2_int4 \ + ORDER BY eql_v2.ord_term(payload::eql_v2_int4_ord) DESC"; + + let mut descending: Vec = ::FIXTURE_VALUES.to_vec(); + descending.sort(); + descending.reverse(); + + // Baseline: ORDER BY ord_term DESC is plaintext-descending. + let baseline: Vec = sqlx::query_scalar(order_by_desc).fetch_all(&pool).await?; + ensure!( + baseline == descending, + "baseline: ORDER BY ord_term DESC must be plaintext-descending" + ); + + // Mutation: collapse ord_term to a constant ORE block. Use a REAL fixture + // payload as the source (guaranteed to construct a valid ore_block) and a + // unique dollar-quote tag so the embedded jsonb literal can't break the + // function body. + let const_payload = fetch_fixture_payload::(&pool, 0).await?; + let ddl = format!( + "CREATE OR REPLACE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord) \ + RETURNS eql_v2.ore_block_u64_8_256 LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE \ + AS $mutbody$ SELECT eql_v2.ore_block_u64_8_256('{esc}'::jsonb) $mutbody$", + esc = const_payload.replace('\'', "''"), + ); + mutate(&pool, &ddl).await?; + + // Post: every row now sorts equal, so DESC collapses to heap (ascending) + // order and can no longer equal the descending expectation — arm has teeth. + let mutated: Vec = sqlx::query_scalar(order_by_desc).fetch_all(&pool).await?; + ensure!( + mutated != descending, + "after collapsing ord_term to a constant, ORDER BY DESC must no longer be \ + plaintext-descending (got {mutated:?})" + ); + Ok(()) +} + +// 8. ORDER BY NULLS placement depends on `ord_term` being STRICT: a NULL domain +// value yields a NULL sort key, so `NULLS LAST` parks those rows at the tail. +// Dropping STRICT (coalescing a NULL input to a real payload) gives NULL-valued +// rows a concrete sort key, so they stop clustering at the end. Proves the +// ORDER BY NULLS arm has teeth on the NULL-placement dimension — one #5 (block +// `lt`) and #7 (collapse `ord_term`) do not exercise, since both run on the +// NULL-free fixture. A UNION ALL subquery supplies the NULL rows inline, so no +// session-local temp table is needed and the global `mutate()` stays valid. +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn making_ord_term_non_strict_flips_order_by_nulls_arm(pool: PgPool) -> Result<()> { + const NULL_ROWS: usize = 3; + let order_by = format!( + "SELECT plaintext FROM ( \ + SELECT plaintext, payload::eql_v2_int4_ord AS value FROM fixtures.eql_v2_int4 \ + UNION ALL \ + SELECT NULL::int4, NULL::eql_v2_int4_ord FROM generate_series(1, {NULL_ROWS}) \ + ) s \ + ORDER BY eql_v2.ord_term(value) ASC NULLS LAST" + ); + + let tail_all_none = + |rows: &[Option]| rows.iter().rev().take(NULL_ROWS).all(|x| x.is_none()); + + // Baseline: STRICT ord_term -> NULL value -> NULL sort key -> NULLS LAST + // parks the NULL-valued rows at the tail. + let baseline: Vec> = sqlx::query_scalar(&order_by).fetch_all(&pool).await?; + ensure!( + tail_all_none(&baseline), + "baseline: the {NULL_ROWS} NULL-valued rows must cluster at the tail under \ + NULLS LAST (got {baseline:?})" + ); + + // Mutation: drop STRICT and coalesce a NULL input to a REAL fixture payload, + // so NULL-valued rows gain a concrete (non-NULL) sort key; non-NULL rows are + // unchanged. Unique dollar-quote tag guards the embedded jsonb literal. + let const_payload = fetch_fixture_payload::(&pool, 0).await?; + let ddl = format!( + "CREATE OR REPLACE FUNCTION eql_v2.ord_term(a eql_v2_int4_ord) \ + RETURNS eql_v2.ore_block_u64_8_256 LANGUAGE sql IMMUTABLE PARALLEL SAFE \ + AS $mutbody$ SELECT eql_v2.ore_block_u64_8_256(\ + coalesce(a, '{esc}'::jsonb::eql_v2_int4_ord)::jsonb) $mutbody$", + esc = const_payload.replace('\'', "''"), + ); + mutate(&pool, &ddl).await?; + + // Post: NULL-valued rows now carry a concrete key, so they no longer park at + // the tail — the NULLS arm catches the lost STRICT contract. + let mutated: Vec> = sqlx::query_scalar(&order_by).fetch_all(&pool).await?; + ensure!( + !tail_all_none(&mutated), + "after dropping STRICT on ord_term, the NULL-valued rows must no longer \ + cluster at the tail (got {mutated:?})" + ); + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_domain/family/support.rs b/tests/sqlx/tests/encrypted_domain/family/support.rs new file mode 100644 index 00000000..b062b9ce --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/family/support.rs @@ -0,0 +1,329 @@ +//! Self-checks for the type-generic matrix substrate +//! (`tests/sqlx/src/scalar_domains.rs`). Each test pins one piece of the +//! `ScalarType` / `Variant` / assertion-helper API that the matrix +//! depends on. + +use anyhow::Result; +use eql_tests::{ + assert_null, assert_raises, assert_scalar_plaintexts, blocker_msg, fetch_fixture_payload, + sql_string_literal, ScalarDomainSpec, ScalarType, Variant, PLACEHOLDER_PAYLOAD, +}; +use sqlx::PgPool; + +#[test] +fn variant_derives_consistent_sql_domain_and_capabilities() { + let storage = ScalarDomainSpec::new::(Variant::Storage); + assert_eq!(storage.sql_domain, "eql_v2_int4"); + assert!(!storage.supports_eq()); + assert!(!storage.supports_ord()); + assert_eq!(storage.extractor_fn(), None); + assert_eq!(Variant::Storage.required_term(), None); + + let eq = ScalarDomainSpec::new::(Variant::Eq); + assert_eq!(eq.sql_domain, "eql_v2_int4_eq"); + assert!(eq.supports_eq()); + assert!(!eq.supports_ord()); + assert_eq!(eq.extractor_fn(), Some("eql_v2.eq_term")); + assert_eq!(Variant::Eq.required_term(), Some("hm")); + + let ord = ScalarDomainSpec::new::(Variant::Ord); + assert_eq!(ord.sql_domain, "eql_v2_int4_ord"); + assert!(ord.supports_ord()); + assert_eq!(ord.extractor_fn(), Some("eql_v2.ord_term")); + assert_eq!(Variant::Ord.required_term(), Some("ob")); + + let ord_ore = ScalarDomainSpec::new::(Variant::OrdOre); + assert_eq!(ord_ore.sql_domain, "eql_v2_int4_ord_ore"); + assert!(ord_ore.supports_ord()); + assert_eq!(ord_ore.extractor_fn(), Some("eql_v2.ord_term")); +} + +#[test] +fn expected_forward_default_is_numeric_ground_truth() { + // Pinned against the full 17-row fixture (extremes + zero + the + // original 14). The output is sorted-ascending by `expected_forward`, + // so a regression in the default impl's filter or sort shows up + // here. + assert_eq!(::expected_forward("=", 10), vec![10]); + assert_eq!( + ::expected_forward("<", 10), + vec![i32::MIN, -100, -1, 0, 1, 2, 5] + ); + assert_eq!( + ::expected_forward("<=", 10), + vec![i32::MIN, -100, -1, 0, 1, 2, 5, 10] + ); + assert_eq!( + ::expected_forward(">", 10), + vec![17, 25, 42, 50, 100, 250, 1000, 9999, i32::MAX] + ); + assert_eq!( + ::expected_forward(">=", 10), + vec![10, 17, 25, 42, 50, 100, 250, 1000, 9999, i32::MAX] + ); + assert_eq!( + ::expected_forward("<>", 42), + vec![ + i32::MIN, + -100, + -1, + 0, + 1, + 2, + 5, + 10, + 17, + 25, + 50, + 100, + 250, + 1000, + 9999, + i32::MAX + ] + ); +} + +#[test] +fn sql_string_literal_escapes_single_quotes() { + assert_eq!(sql_string_literal("abc'def"), "'abc''def'"); +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn fetch_fixture_payload_returns_keyed_row(pool: PgPool) -> Result<()> { + // Parse the payload as JSON rather than substring-matching — whitespace + // and key ordering in the serialised form are not contract. + let payload = fetch_fixture_payload::(&pool, 42).await?; + let value: serde_json::Value = serde_json::from_str(&payload)?; + assert_eq!(value["v"], serde_json::json!(2), "payload must carry v=2"); + assert!(value.get("c").is_some(), "payload must carry a c field"); + Ok(()) +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v2_int4")))] +async fn assert_scalar_plaintexts_reports_sql_context(pool: PgPool) -> Result<()> { + let lit = sql_string_literal(&fetch_fixture_payload::(&pool, 42).await?); + let predicate = format!("payload::eql_v2_int4_ord_ore = {lit}::jsonb::eql_v2_int4_ord_ore"); + assert_scalar_plaintexts::(&pool, "eql_v2_int4_ord_ore", "=", &predicate, &[42]).await?; + Ok(()) +} + +#[sqlx::test] +async fn placeholder_payload_satisfies_every_variant_check(pool: PgPool) -> Result<()> { + // The whole point of PLACEHOLDER_PAYLOAD: one sentinel that casts + // successfully to every domain in the family. If a variant CHECK + // tightens, this test fails and PLACEHOLDER_PAYLOAD needs updating. + // + // Iterates `Variant::ALL` against `::PG_TYPE` + // rather than hardcoding domain names — when `int8` (or any future + // scalar) lands, this test picks it up automatically by extending + // the type list below. + for variant in Variant::ALL { + let spec = ScalarDomainSpec::new::(*variant); + let sql = format!("SELECT $1::jsonb::{}", spec.sql_domain); + sqlx::query(&sql) + .bind(PLACEHOLDER_PAYLOAD) + .fetch_one(&pool) + .await + .map_err(|e| { + anyhow::anyhow!("PLACEHOLDER_PAYLOAD must cast to {}: {e}", spec.sql_domain) + })?; + } + Ok(()) +} + +#[sqlx::test] +async fn assert_raises_two_bind_blocker(pool: PgPool) -> Result<()> { + let msg = blocker_msg("eql_v2_int4", "="); + assert_raises( + &pool, + "SELECT $1::jsonb::eql_v2_int4 = $2::jsonb::eql_v2_int4", + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + &msg, + ) + .await +} + +#[sqlx::test] +async fn assert_raises_one_bind_path_blocker(pool: PgPool) -> Result<()> { + let msg = blocker_msg("eql_v2_int4", "->"); + assert_raises( + &pool, + "SELECT $1::jsonb::eql_v2_int4 -> 'field'::text", + &[Some(PLACEHOLDER_PAYLOAD)], + &msg, + ) + .await +} + +#[sqlx::test] +async fn assert_raises_native_operator_absent(pool: PgPool) -> Result<()> { + // ~~ (LIKE) isn't declared on int4 — error message is PG's native + // "operator does not exist", not an EQL blocker message. + assert_raises( + &pool, + "SELECT $1::jsonb::eql_v2_int4 ~~ $2::jsonb::eql_v2_int4", + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + "operator does not exist", + ) + .await +} + +#[sqlx::test] +async fn omitted_native_jsonb_operators_raise_eql_blockers(pool: PgPool) -> Result<()> { + let cases: &[(&str, &[Option<&str>], &str)] = &[ + ( + "SELECT $1::jsonb::eql_v2_int4 ? 'c'::text", + &[Some(PLACEHOLDER_PAYLOAD)], + "?", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 ?| ARRAY['c']", + &[Some(PLACEHOLDER_PAYLOAD)], + "?|", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 ?& ARRAY['c']", + &[Some(PLACEHOLDER_PAYLOAD)], + "?&", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 #> ARRAY['i']", + &[Some(PLACEHOLDER_PAYLOAD)], + "#>", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 #>> ARRAY['i', 'c']", + &[Some(PLACEHOLDER_PAYLOAD)], + "#>>", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 @? '$.c'::jsonpath", + &[Some(PLACEHOLDER_PAYLOAD)], + "@?", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 @@ '$.c == \"placeholder\"'::jsonpath", + &[Some(PLACEHOLDER_PAYLOAD)], + "@@", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 - 'c'::text", + &[Some(PLACEHOLDER_PAYLOAD)], + "-", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 - 0", + &[Some(PLACEHOLDER_PAYLOAD)], + "-", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 - ARRAY['c']", + &[Some(PLACEHOLDER_PAYLOAD)], + "-", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 #- ARRAY['i']", + &[Some(PLACEHOLDER_PAYLOAD)], + "#-", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 || $2::jsonb", + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + "||", + ), + ( + "SELECT $1::jsonb || $2::jsonb::eql_v2_int4", + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + "||", + ), + ( + "SELECT $1::jsonb::eql_v2_int4 || $2::jsonb::eql_v2_int4", + &[Some(PLACEHOLDER_PAYLOAD), Some(PLACEHOLDER_PAYLOAD)], + "||", + ), + ]; + + for (sql, binds, op) in cases { + assert_raises(&pool, sql, binds, &blocker_msg("eql_v2_int4", op)).await?; + } + Ok(()) +} + +#[sqlx::test] +async fn assert_raises_engages_on_all_null(pool: PgPool) -> Result<()> { + // Non-STRICT blocker proof — must raise even with NULL on both sides. + let msg = blocker_msg("eql_v2_int4", "="); + assert_raises( + &pool, + "SELECT $1::jsonb::eql_v2_int4 = $2::jsonb::eql_v2_int4", + &[None, None], + &msg, + ) + .await +} + +#[sqlx::test] +async fn assert_null_propagates_through_supported_op(pool: PgPool) -> Result<()> { + // STRICT supported op with one NULL operand yields NULL. + assert_null( + &pool, + "SELECT $1::jsonb::eql_v2_int4_eq = $2::jsonb::eql_v2_int4_eq", + &[Some(PLACEHOLDER_PAYLOAD), None], + ) + .await +} + +#[sqlx::test] +async fn neq_propagates_null_under_three_valued_logic(pool: PgPool) -> Result<()> { + // `<>` with a NULL operand must yield NULL (not true, not false). + // Three-valued logic is easy to get wrong in domain wrappers; a + // STRICT supported `<>` returns NULL on either NULL side. + for binds in [ + &[Some(PLACEHOLDER_PAYLOAD), None][..], + &[None, Some(PLACEHOLDER_PAYLOAD)][..], + &[None, None][..], + ] { + assert_null( + &pool, + "SELECT $1::jsonb::eql_v2_int4_eq <> $2::jsonb::eql_v2_int4_eq", + binds, + ) + .await?; + } + Ok(()) +} + +#[sqlx::test] +async fn no_cross_variant_equality_operator_is_declared(pool: PgPool) -> Result<()> { + // The family deliberately does NOT define operators that mix two + // different capability variants — `eql_v2_int4_eq = eql_v2_int4_ord` + // would resolve against jsonb (the ultimate base type) and silently + // bypass the per-variant blockers. If someone accidentally adds such + // an operator, this test fails. + // + // The check is structural (`pg_operator`) rather than dynamic + // ("invoke and see it raise") so a future PG version with stricter + // operator resolution doesn't mask the regression. + let cross_variant: Vec = sqlx::query_scalar( + r#" + SELECT format('%s(%s, %s)', + o.oprname, lt.typname, rt.typname) + FROM pg_catalog.pg_operator o + JOIN pg_catalog.pg_type lt ON lt.oid = o.oprleft + JOIN pg_catalog.pg_type rt ON rt.oid = o.oprright + WHERE lt.typname LIKE 'eql_v2\_%' + AND rt.typname LIKE 'eql_v2\_%' + AND lt.typname <> rt.typname + ORDER BY 1 + "#, + ) + .fetch_all(&pool) + .await?; + + assert!( + cross_variant.is_empty(), + "no operator should mix two different eql_v2_* domain types, but found: {cross_variant:#?}" + ); + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_domain/scalars/int4.rs b/tests/sqlx/tests/encrypted_domain/scalars/int4.rs new file mode 100644 index 00000000..6ec665d3 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/scalars/int4.rs @@ -0,0 +1,14 @@ +//! `eql_v2_int4` — the reference scalar implementation. +//! +//! Adding a new ordered numeric scalar (i64, f64, date, ...) is one +//! `impl ScalarType` in `tests/sqlx/src/scalar_domains.rs` plus an +//! `ordered_numeric_matrix!` invocation like this one. The matrix covers +//! everything generic over `T: ScalarType`. + +use eql_tests::ordered_numeric_matrix; + +ordered_numeric_matrix! { + suite = int4, + scalar = i32, + eql_type = "eql_v2_int4", +} diff --git a/tests/sqlx/tests/encrypted_domain/scalars/mod.rs b/tests/sqlx/tests/encrypted_domain/scalars/mod.rs new file mode 100644 index 00000000..8abc1857 --- /dev/null +++ b/tests/sqlx/tests/encrypted_domain/scalars/mod.rs @@ -0,0 +1,4 @@ +//! Per-scalar tests. Each subdirectory targets one scalar type; future +//! additions (`int8`, `bool`, `date`, …) become sibling modules here. + +pub mod int4; diff --git a/tests/sqlx/tests/eql_v2_int4_fixture_tests.rs b/tests/sqlx/tests/eql_v2_int4_fixture_tests.rs index 3cf73225..04233f15 100644 --- a/tests/sqlx/tests/eql_v2_int4_fixture_tests.rs +++ b/tests/sqlx/tests/eql_v2_int4_fixture_tests.rs @@ -8,28 +8,46 @@ use anyhow::Result; use sqlx::PgPool; -/// The 14 values from `src/fixtures/eql_v2_int4.rs`, in id order. Kept here +/// The 17 values from `src/fixtures/eql_v2_int4.rs`, in id order. Kept here /// only to assert the in-table `plaintext` oracle matches what was generated. /// If `plaintext_column_matches_the_generated_values` fails, the generator's /// `VALUES` and this constant have drifted — re-run /// `mise run fixture:generate eql_v2_int4` and update this list to match. -const EXPECTED_PLAINTEXTS: &[i32] = &[-100, -1, 1, 2, 5, 10, 17, 25, 42, 50, 100, 250, 1000, 9999]; +const EXPECTED_PLAINTEXTS: &[i32] = &[ + i32::MIN, + -100, + -1, + 0, + 1, + 2, + 5, + 10, + 17, + 25, + 42, + 50, + 100, + 250, + 1000, + 9999, + i32::MAX, +]; #[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v2_int4")))] -async fn fixture_has_fourteen_rows(pool: PgPool) -> Result<()> { +async fn fixture_has_seventeen_rows(pool: PgPool) -> Result<()> { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fixtures.eql_v2_int4") .fetch_one(&pool) .await?; - assert_eq!(count, 14, "eql_v2_int4 fixture should have 14 rows"); + assert_eq!(count, 17, "eql_v2_int4 fixture should have 17 rows"); Ok(()) } #[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v2_int4")))] -async fn ids_are_sequential_one_to_fourteen(pool: PgPool) -> Result<()> { +async fn ids_are_sequential_one_to_seventeen(pool: PgPool) -> Result<()> { let ids: Vec = sqlx::query_scalar("SELECT id FROM fixtures.eql_v2_int4 ORDER BY id") .fetch_all(&pool) .await?; - assert_eq!(ids, (1..=14).collect::>()); + assert_eq!(ids, (1..=17).collect::>()); Ok(()) } @@ -95,22 +113,22 @@ async fn plaintext_oracle_supports_value_filtering(pool: PgPool) -> Result<()> { .await?; assert_eq!( ids, - vec![9], - "expected exactly one row with plaintext = 42 at id 9" + vec![11], + "expected exactly one row with plaintext = 42 at id 11" ); Ok(()) } #[sqlx::test(fixtures(path = "../fixtures", scripts("eql_v2_int4")))] async fn hmac_equality_terms_are_distinct_for_distinct_values(pool: PgPool) -> Result<()> { - // All 14 plaintext values are distinct, so all 14 `hm` terms must be too. + // All 17 plaintext values are distinct, so all 17 `hm` terms must be too. let distinct_hm: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT payload->>'hm') FROM fixtures.eql_v2_int4") .fetch_one(&pool) .await?; assert_eq!( - distinct_hm, 14, - "14 distinct values -> 14 distinct hm terms" + distinct_hm, 17, + "17 distinct values -> 17 distinct hm terms" ); Ok(()) } diff --git a/tests/sqlx/tests/lint_tests.rs b/tests/sqlx/tests/lint_tests.rs index 1bd080c9..f9194b82 100644 --- a/tests/sqlx/tests/lint_tests.rs +++ b/tests/sqlx/tests/lint_tests.rs @@ -12,8 +12,15 @@ //! appropriate. use anyhow::Result; +use eql_tests::Variant; use sqlx::PgPool; +/// Pg-type tokens for the encrypted-scalar-domain families currently +/// materialised. Extending the family (e.g. when `int8`/`bool`/`date` +/// land) is a one-line array extension here — every downstream +/// parameterised test picks it up automatically. +const SCALAR_PG_TYPES: &[&str] = &["int4"]; + #[derive(Debug, sqlx::FromRow)] struct LintRow { severity: String, @@ -33,16 +40,13 @@ async fn fetch_lints(pool: &PgPool) -> Result> { } #[sqlx::test] -async fn lint_function_exists_and_returns_rows(pool: PgPool) -> Result<()> { - let rows = fetch_lints(&pool).await?; - // The current state of EQL has a non-trivial number of inlinability - // violations on the operator surface. Confirm the lint produces output - // and the columns parse correctly. - assert!( - !rows.is_empty(), - "Expected lint to surface at least one inlinability violation \ - against the current EQL surface; got 0 rows" - ); +async fn lint_function_exists_and_row_schema_parses(pool: PgPool) -> Result<()> { + // Schema-only check: `eql_v2.lints()` exists and its rows decode into + // `LintRow`. Previous incarnation asserted `!rows.is_empty()` and so + // would fail on a *cleaner* build (e.g. when Phase 1+ removes the + // current noisy violations), reading like a regression for a good + // reason. The rule-specific tests below pin actual behaviour. + let _rows = fetch_lints(&pool).await?; Ok(()) } @@ -70,6 +74,10 @@ async fn lint_categories_are_well_known(pool: PgPool) -> Result<()> { "inlinability_set_clause", "inlinability_secdef", "inlinability_transitive", + "blocker_language", + "blocker_strict", + "domain_over_domain", + "domain_opclass", ]; for row in rows { assert!( @@ -82,6 +90,175 @@ async fn lint_categories_are_well_known(pool: PgPool) -> Result<()> { Ok(()) } +/// A blocker rendered in `LANGUAGE sql` instead of `plpgsql` is the +/// inverse of the extractor/wrapper inlinability rule: a blocker's job is +/// to RAISE, and `LANGUAGE sql` bodies are inlinable — which means the +/// planner can fold or elide the call when the result is provably unused +/// (a dead CASE branch, a folded predicate), silently bypassing the RAISE +/// and re-enabling the operator. See CLAUDE.md footguns. This test plants +/// a fake LANGUAGE sql blocker on `eql_v2_int4` and asserts the lint +/// surfaces it under category `blocker_language`. +#[sqlx::test] +async fn lint_flags_blocker_in_language_sql(pool: PgPool) -> Result<()> { + sqlx::query( + r#" + CREATE FUNCTION eql_v2.test_bad_blocker_sql(a eql_v2_int4, b eql_v2_int4) + RETURNS boolean LANGUAGE sql IMMUTABLE + AS $$ SELECT eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '=') $$; + "#, + ) + .execute(&pool) + .await?; + + let rows = fetch_lints(&pool).await?; + let violations: Vec<&LintRow> = rows + .iter() + .filter(|r| { + r.category == "blocker_language" && r.object_name.contains("test_bad_blocker_sql") + }) + .collect(); + + assert!( + !violations.is_empty(), + "Expected `blocker_language` to flag the LANGUAGE sql fake blocker, \ + but got no matching row. All lint rows:\n{:#?}", + rows + ); + assert_eq!( + violations[0].severity, "error", + "blocker_language must be severity=error" + ); + Ok(()) +} + +/// A blocker marked `STRICT` lets PostgreSQL skip the body and return NULL +/// on a NULL argument — silently bypassing the "operator not supported" +/// RAISE. See CLAUDE.md footguns. This test plants a fake STRICT plpgsql +/// blocker on `eql_v2_int4` and asserts the lint surfaces it under +/// `blocker_strict`. +#[sqlx::test] +async fn lint_flags_strict_blocker(pool: PgPool) -> Result<()> { + sqlx::query( + r#" + CREATE FUNCTION eql_v2.test_bad_blocker_strict(a eql_v2_int4, b eql_v2_int4) + RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT + AS $$ BEGIN RETURN eql_v2.encrypted_domain_unsupported_bool('eql_v2_int4', '='); END; $$; + "#, + ) + .execute(&pool) + .await?; + + let rows = fetch_lints(&pool).await?; + let violations: Vec<&LintRow> = rows + .iter() + .filter(|r| { + r.category == "blocker_strict" && r.object_name.contains("test_bad_blocker_strict") + }) + .collect(); + + assert!( + !violations.is_empty(), + "Expected `blocker_strict` to flag the STRICT fake blocker, \ + but got no matching row. All lint rows:\n{:#?}", + rows + ); + assert_eq!( + violations[0].severity, "error", + "blocker_strict must be severity=error" + ); + Ok(()) +} + +/// Generated encrypted-domain blockers intentionally use non-inlinable +/// plpgsql functions. They should be checked by the blocker-specific lint +/// rules, not reported as normal operator inlinability failures. +#[sqlx::test] +async fn lint_does_not_report_generated_blockers_as_inlinability_errors( + pool: PgPool, +) -> Result<()> { + let rows = fetch_lints(&pool).await?; + let violations: Vec<&LintRow> = rows + .iter() + .filter(|r| { + matches!( + r.category.as_str(), + "inlinability_language" + | "inlinability_volatility" + | "inlinability_set_clause" + | "inlinability_secdef" + ) && r.object_name.contains("eql_v2_int4") + && (r.object_name.contains("operator =(") + || r.object_name.contains("operator ->(") + || r.object_name.contains("operator ?(")) + }) + .collect(); + + assert!( + violations.is_empty(), + "generated encrypted-domain blockers must not be reported by direct \ + inlinability rules; got: {violations:#?}" + ); + Ok(()) +} + +/// An `eql_v2_*` domain whose base type is another `eql_v2_*` domain (not +/// jsonb) silently bypasses the storage variant's blockers: operators +/// resolve against the ultimate base type, so a derived domain does not +/// inherit the base domain's operator surface. See CLAUDE.md footguns. +/// This test plants a domain-over-domain offender and asserts the lint +/// surfaces it under `domain_over_domain`. +#[sqlx::test] +async fn lint_flags_domain_over_domain(pool: PgPool) -> Result<()> { + sqlx::query(r#"CREATE DOMAIN public.eql_v2_test_baddom AS public.eql_v2_int4;"#) + .execute(&pool) + .await?; + + let rows = fetch_lints(&pool).await?; + let violations: Vec<&LintRow> = rows + .iter() + .filter(|r| { + r.category == "domain_over_domain" && r.object_name.contains("eql_v2_test_baddom") + }) + .collect(); + + assert!( + !violations.is_empty(), + "Expected `domain_over_domain` to flag the derived domain, \ + but got no matching row. All lint rows:\n{:#?}", + rows + ); + assert_eq!( + violations[0].severity, "error", + "domain_over_domain must be severity=error" + ); + Ok(()) +} + +/// An operator class declared `FOR TYPE` on an `eql_v2_*` domain bypasses +/// the operator-resolution that the storage blockers depend on. The +/// recommended pattern is a functional index on the extractor; opclasses +/// on domains must never appear. See CLAUDE.md footguns. The current +/// build emits zero opclasses on `eql_v2_*` domains, so this test is +/// negative: it asserts the rule category is well-known and surfaces no +/// rows. A positive test would require constructing a valid opclass on a +/// domain, which is non-trivial scaffolding — the `domain_opclass` +/// structural guard in `tests/encrypted_domain/family/inlinability.rs` is the +/// independent net for regressions. +#[sqlx::test] +async fn lint_domain_opclass_surface_is_clean(pool: PgPool) -> Result<()> { + let rows = fetch_lints(&pool).await?; + let violations: Vec<&LintRow> = rows + .iter() + .filter(|r| r.category == "domain_opclass") + .collect(); + assert!( + violations.is_empty(), + "domain_opclass surface should be empty in a clean build, got: {:#?}", + violations + ); + Ok(()) +} + /// Phase 1 regression: the operators rewritten in #193 (=, <>, ~~, ~~*, /// @>, <@ on eql_v2_encrypted) must report zero lint violations. If this /// test fails, an inlinability regression has been introduced into one @@ -119,3 +296,68 @@ async fn lint_phase_1_operators_are_clean(pool: PgPool) -> Result<()> { ); Ok(()) } + +/// Every encrypted-scalar-domain family's inlinable operator surface +/// must report zero lint violations. The supported operators on the +/// `_eq`, `_ord`, and `_ord_ore` variants are codegen-emitted SQL +/// wrappers (LANGUAGE sql, IMMUTABLE, no pinned `search_path`); the +/// planner can fold them into the documented functional indexes. A +/// regression to plpgsql or a pinned `search_path` breaks index +/// engagement. +/// +/// Storage-only variants (the bare `eql_v2_` domain with no +/// capability suffix) are intentionally excluded — every operator on +/// them is a non-STRICT plpgsql blocker, which doesn't need to be +/// inlinable. +/// +/// Discovers the eligible operator set from `pg_operator` rather than +/// hardcoding the int4 inventory — when `int8` (or `bool`, `date`, ...) +/// lands, this test picks it up automatically with no edit. The earlier +/// hardcoded list was a copy-paste hazard. +#[sqlx::test] +async fn scalar_family_inlinable_operators_are_clean(pool: PgPool) -> Result<()> { + // Build the inline-critical signature set Rust-side from + // `SCALAR_PG_TYPES × Variant::ALL × supported-operators`. Eq-only + // variants declare `<`/`<=`/`>`/`>=` as blockers (intentionally + // non-inlinable), so they must NOT be expected to be clean here — + // only the ops the variant actually supports as wrappers count. + // + // Storage variants contribute no inline-critical surface; their + // entire operator set is blockers by design. + let mut prefixes: Vec = Vec::new(); + for pg_type in SCALAR_PG_TYPES { + for variant in Variant::ALL { + if matches!(variant, Variant::Storage) { + continue; + } + let domain = format!("eql_v2_{pg_type}{}", variant.suffix()); + let supported_ops: &[&str] = if variant.supports_ord() { + &["=", "<>", "<", "<=", ">", ">="] + } else { + // Eq variants support equality only; ordering ops on `_eq` + // are blockers. + &["=", "<>"] + }; + for op in supported_ops { + // Domain-on-left and jsonb-on-left arg shapes both + // need to be inlinable; the domain-on-right shape is + // the `(jsonb, domain)` operator. + prefixes.push(format!("operator {op}({domain},")); + prefixes.push(format!("operator {op}(jsonb, {domain})")); + } + } + } + + let rows = fetch_lints(&pool).await?; + let violations: Vec<&LintRow> = rows + .iter() + .filter(|row| prefixes.iter().any(|p| row.object_name.starts_with(p))) + .collect(); + + assert!( + violations.is_empty(), + "scalar-family inline-critical operators should report zero \ + lint violations, but got: {violations:#?}" + ); + Ok(()) +} From 46fc825bb56891ce2731c8702323c8bb8ea96451 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:52 +1000 Subject: [PATCH 06/10] docs(encrypted-domain): implementation spec, generator reference & changelog Add encrypted-domain implementation spec and generator reference; document both aggregate paths in eql-functions / sql-support; record the int4 family under CHANGELOG Added. CLAUDE.md gains the encrypted-domain materializer guidance and footgun list. Part of PR #239. --- CHANGELOG.md | 5 + CLAUDE.md | 23 +- docs/development/documentation-inventory.md | 7 - docs/reference/encrypted-domain-generator.md | 398 ++++++++++++++++++ .../encrypted-domain-implementation-spec.md | 339 +++++++++++++++ docs/reference/eql-functions.md | 65 ++- docs/reference/sql-support.md | 24 +- tasks/docs/generate.sh | 2 + tasks/docs/validate.sh | 2 + tasks/docs/validate/coverage.sh | 2 + tasks/docs/validate/documented-sql.sh | 2 + tasks/docs/validate/required-tags.sh | 2 + 12 files changed, 851 insertions(+), 20 deletions(-) create mode 100644 docs/reference/encrypted-domain-generator.md create mode 100644 docs/reference/encrypted-domain-implementation-spec.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8c9568..e3d45c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ Each entry that ships in a published release links to the PR that introduced it. ## [Unreleased] +### Added + +- **`eql_v2_int4` encrypted-domain type family.** Four jsonb-backed domains for encrypted `int4` columns: `eql_v2_int4` (storage-only), `eql_v2_int4_eq` (`=` / `<>` via HMAC), and `eql_v2_int4_ord` / `eql_v2_int4_ord_ore` (also `<` `<=` `>` `>=` via ORE block terms). Supported comparisons resolve to inlinable wrappers; the native `jsonb` operator surface reachable through domain fallback is blocked (raises rather than silently mis-resolving). Each domain's `CHECK` requires the EQL envelope (`v`, `i`), the ciphertext (`c`), and the variant's index term(s), and pins the payload version (`VALUE->>'v' = '2'`, matching `eql_v2._encrypted_check_v`) — so a missing key or wrong-version payload is rejected on insert or cast rather than surfacing later at query time. Index via a functional index on the `eql_v2.eq_term` / `eql_v2.ord_term` extractors, not an operator class on the domain. Why: a type-safe, per-capability encrypted integer column instead of the untyped `eql_v2_encrypted`. This is the reference scalar implementation for the generated domain family. ([#239](https://github.com/cipherstash/encrypt-query-language/pull/239), supersedes [#225](https://github.com/cipherstash/encrypt-query-language/pull/225)) +- **Per-domain `MIN` / `MAX` aggregates for the encrypted-domain family.** `eql_v2.min(eql_v2__ord)` / `eql_v2.max(eql_v2__ord)` (and the `_ord_ore` twin) are generated for every ord-capable scalar variant, giving type-safe extrema on domain-typed columns — comparison routes through the variant's `<` / `>` operator (ORE block term, no decryption). The aggregates are declared `PARALLEL = SAFE` with a combine function (the state function itself — min/max are associative), so PostgreSQL can use partial/parallel aggregation on large `GROUP BY` workloads. Why: the new domain types previously had no equivalent of the composite-type aggregates. The existing `eql_v2.min(eql_v2_encrypted)` / `eql_v2.max(eql_v2_encrypted)` aggregates are **retained** and continue to work on `eql_v2_encrypted` columns; the per-domain aggregates are additive and coexist with them. ([#239](https://github.com/cipherstash/encrypt-query-language/pull/239)) + ## [2.3.1] — 2026-05-21 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 65ed6d58..361fd31b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search - `src/operators/` - SQL operators for encrypted data comparisons - `src/config/` - Configuration management functions - `src/blake3/`, `src/hmac_256/`, `src/bloom_filter/`, `src/ore_*` - Index implementations +- `src/encrypted_domain/` - Encrypted-domain type families (jsonb-backed PostgreSQL domains, one per operator/index capability) - `tasks/` - mise task scripts - `tests/sqlx/` - Rust/SQLx test framework (PostgreSQL 14-17 support) - `release/` - Generated SQL installation files @@ -72,6 +73,25 @@ This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for search - **Operators**: Support comparisons between encrypted and plain JSONB data - **CipherStash Proxy**: Required for encryption/decryption operations +### Encrypted-Domain Types + +`src/encrypted_domain/` holds **encrypted-domain type families** — jsonb-backed PostgreSQL domains, one domain per operator/index capability (`eql_v2_` storage-only, `eql_v2__eq`, `eql_v2__ord`). `eql_v2_int4` (PR #225) is the reference scalar implementation; future scalar types such as `int8`, `bool`, `date`, `float`, `numeric`, and `timestamp` follow this materializer pattern. `jsonb` needs a separate design and is out of scope for the scalar materializer. + +Adding a scalar encrypted-domain type is generated from a minimal manifest at `tasks/codegen/types/.toml`: the filename supplies ``, and the `[domain]` table maps each generated domain name to the fixed index terms it carries. Example: `int4_eq = ["hm"]`, `int4_ord = ["ore"]`. Term capabilities are fixed in `tasks/codegen/terms.py`: `hm` provides equality, and `ore` provides equality plus ordering. `mise run build` regenerates the scalar SQL surface into `src/encrypted_domain//` from every manifest at the start of every build; that surface includes supported comparison wrappers plus blockers for native `jsonb` operators that would otherwise be reachable through domain fallback. Use `mise run codegen:domain ` to refresh a single type manually while iterating on its manifest, or `mise run codegen:domain:all` to regenerate every type at once (the same enumeration `mise run build` uses). The generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` files are gitignored and never committed — the TOML manifest plus `tasks/codegen/terms.py` are the source of truth. Generated files carry an `AUTO-GENERATED — DO NOT EDIT` header; change the manifest or term catalog and rebuild, never hand-edit. Hand-written SQL beyond the fixed surface goes in `src/encrypted_domain//_extensions.sql` with no auto-generated header and explicit `-- REQUIRE:` edges — that file IS committed. `text` and `jsonb` are out of scope for this scalar materializer. + +**Adding a new encrypted-domain type: follow `docs/reference/encrypted-domain-implementation-spec.md`.** The mechanics are fixed for ordered scalar domains; the manifest only declares domain names and terms. New term behavior belongs in `tasks/codegen/terms.py` with tests, not in free-form TOML fields. + +Regeneration is deterministic: identical manifest + term catalog produce byte-identical SQL. If `mise run build` produces unexpected output, the change is in the manifest, `tasks/codegen/terms.py`, or `tasks/codegen/templates.py` — not in random run-to-run variation. + +Footguns the spec exists to prevent: + +- **Blockers must never be `STRICT`.** A `STRICT` blocker lets PostgreSQL skip the body and return `NULL` on a `NULL` argument, silently bypassing the "operator not supported" exception. +- **No domain-over-domain** (`CREATE DOMAIN a AS b`). Operators resolve against the ultimate base type (`jsonb`), so a derived domain does not inherit the base domain's operator surface — blockers stop engaging. +- **No operator class on a domain.** Index through a functional index on the extractor (`eq_term` / `ord_term`), whose return type already carries a default opclass. +- **Inlinable functions** (extractors, comparison wrappers) need `LANGUAGE sql`, a single-statement `SELECT`, `IMMUTABLE`, and **no `SET` clause** — a pinned `search_path` disables inlining. No per-type allowlist edit: the `pin_search_path.sql` structural rule recognises encrypted-domain functions intrinsically and `tasks/test/splinter.sh` covers the converged extractor/wrapper names. +- **Blockers must be `LANGUAGE plpgsql`, not `LANGUAGE sql`.** The inverse of the rule above. A blocker exists to always raise, but a `LANGUAGE sql` body is inlinable and the planner can elide the call when the result is provably unused (dead `CASE` branch, folded predicate). `LANGUAGE plpgsql` is opaque to the planner, so the call — and its `RAISE` — survives. The generator in `tasks/codegen/templates.py` enforces this; don't "simplify" the rendered blockers to `LANGUAGE sql` even though the body is a single expression. +- **Build with `mise run clean && mise run build`** — a bare build can leave stale `release/*.sql`. + ### Testing Infrastructure - Tests are written in Rust using SQLx, located in `tests/sqlx/` - Tests run against PostgreSQL 14, 15, 16, 17 using Docker containers @@ -199,6 +219,7 @@ Prefer `LANGUAGE SQL` over `LANGUAGE plpgsql` unless you need procedural feature - Exception handling (`BEGIN...EXCEPTION...END`) - Complex control flow (loops, early returns) - Dynamic SQL (`EXECUTE`) +- Functions that must remain opaque to the planner — typically blockers whose only job is to `RAISE`. `LANGUAGE sql` would be inlined and may be elided when the result is provably unused; `LANGUAGE plpgsql` is never inlined, so the body always runs. See the encrypted-domain footgun list above and the blocker renderers in `tasks/codegen/templates.py`. ## Release & changelog discipline @@ -222,7 +243,7 @@ What does *not* need an entry: Pick the right section (`Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`). Lead with the user-visible fact, then a short "Why." explanation, then a PR link in parentheses. Match the tone and density of existing entries — a single dense paragraph per entry, not a bullet list. -Example shape (real entry from `2.3.0`): +Example entry (real entry from `2.3.0`): > **`=`, `<>`, `~~` (`LIKE`), `~~*` (`ILIKE`) on `eql_v2_encrypted` are now inlinable SQL functions.** The planner can structurally match these operators against the documented functional indexes (`eql_v2.hmac_256(col)` for equality, `eql_v2.bloom_filter(col)` for `LIKE`/`ILIKE`), so bare-form queries (`WHERE col = $1`) engage the index without per-query rewriting. Previously these operators wrapped multi-branch PL/pgSQL bodies that the planner could not inline, forcing seq scans on Supabase / managed Postgres installations that lack operator-class indexes. ([#193](...), [#196](...)) diff --git a/docs/development/documentation-inventory.md b/docs/development/documentation-inventory.md index bdbe8fd8..e9e89eed 100644 --- a/docs/development/documentation-inventory.md +++ b/docs/development/documentation-inventory.md @@ -77,13 +77,6 @@ Generated: Mon 27 Oct 2025 11:39:50 AEDT ## src/crypto.sql -## src/encrypted/aggregates.sql - -- CREATE FUNCTION eql_v2.min(a eql_v2_encrypted, b eql_v2_encrypted) -- CREATE AGGREGATE eql_v2.min(eql_v2_encrypted) -- CREATE FUNCTION eql_v2.max(a eql_v2_encrypted, b eql_v2_encrypted) -- CREATE AGGREGATE eql_v2.max(eql_v2_encrypted) - ## src/encrypted/casts.sql - CREATE FUNCTION eql_v2.to_encrypted(data jsonb) diff --git a/docs/reference/encrypted-domain-generator.md b/docs/reference/encrypted-domain-generator.md new file mode 100644 index 00000000..9b502690 --- /dev/null +++ b/docs/reference/encrypted-domain-generator.md @@ -0,0 +1,398 @@ +# Encrypted-Domain Code Generator + +How `tasks/codegen/` turns a TOML manifest into the SQL surface for a +scalar encrypted-domain type. This document describes the generator +itself — its inputs, stages, outputs, and the invariants it enforces. +The contract those outputs must satisfy is in +[`encrypted-domain-implementation-spec.md`](./encrypted-domain-implementation-spec.md); +this file describes the machine that produces them. + +The reference type is `eql_v2_int4` (PR #239). `text` and `jsonb` are +outside scope. + +## 1. Why a generator + +A single scalar encrypted-domain type emits several hundred SQL +declarations across eleven files: four domains, three extractors, dozens +of comparison wrappers and blockers, 176 `CREATE OPERATOR` statements (44 +per domain), and MIN/MAX aggregates for every ordered domain. The shape +is mechanical and +the invariants are unforgiving — a `STRICT` blocker silently bypasses +its exception, a pinned `search_path` disables inlining and reverts +queries to seq scans. The generator exists so each new scalar type adds +one TOML file rather than ninety hand-written declarations that must +agree with each other and with `pin_search_path.sql`, +`tasks/test/splinter.sh`, and `src/encrypted_domain/functions.sql`. + +## 2. Pipeline + +`tasks/codegen/` is a small Python package. Entry point: +`python -m tasks.codegen.generate `, wrapped by +`mise run codegen:domain ` (`tasks/codegen/domain.sh:10`). +`tasks/build.sh` invokes the same entry point for every manifest at +the start of every `mise run build`, so the generated SQL is never +checked in — the TOML manifest is the source of truth. + +Stages, in order: + +1. **Load manifest** — `spec.load_spec(toml_path)` reads + `tasks/codegen/types/.toml`, validates the `[domain]` table, + validates the token and every domain name as SQL identifiers + (`_SQL_IDENTIFIER`, `spec.py:12`), checks each domain name starts with the + filename token, resolves every listed term against `terms.TERM_CATALOG`, + and parses the optional `[fixture]` table (`_load_fixture_values`, + `spec.py:36`). Returns a `TypeSpec` (`tasks/codegen/spec.py:98`). +2. **Resolve terms** — for each `DomainSpec`, `terms.require_terms` + maps catalog names (`hm`, `ore`) to `Term` records carrying the + extractor name, return type, JSON envelope key, supported + operators, and the SQL `-- REQUIRE:` edges those terms imply + (`tasks/codegen/terms.py:57-88`). +3. **Render** — `generate.render_types_file`, + `generate.render_functions_file`, `generate.render_operators_file`, + and `generate.render_aggregates_file` (the last only for ordered + domains) build SQL strings via the per-construct functions in + `templates.py`; when the manifest declares a `[fixture]` table, + `templates.render_fixture_values_rs` also renders the committed Rust + value const. No template engine — plain f-strings, with the structural + shape of each declaration encoded in code (`tasks/codegen/generate.py`). +4. **Write** — `writer.write_generated_file` prefixes every SQL output with + the `AUTO-GENERATED — DO NOT EDIT` header (`templates.py:13-17`) and + refuses to overwrite any pre-existing file that lacks that marker + (`tasks/codegen/writer.py:67`). The committed Rust value const is written + by `writer.write_generated_rs` (`writer.py:78`) with its own Rust + `AUTO-GENERATED` header. `generate_type` cleans stale generated files in + the target directory before rewriting so an abandoned domain disappears on + the next regeneration (`generate.py:221`). + +There is no caching layer, no incremental mode, and no rewriting of +hand-written files. Each invocation regenerates every output for one +type from a single manifest. + +## 3. Manifest format + +```toml +[domain] +int4 = [] +int4_eq = ["hm"] +int4_ord_ore = ["ore"] +int4_ord = ["ore"] +``` + +Rules enforced by `spec.load_spec`: + +- The filename stem is the **type token** (`int4` here). It must match + the CLI argument and prefix every domain name. +- The TOML must have a non-empty `[domain]` table at the top level. The + only other recognised top-level key is the optional `[fixture]` table + (see §3a). +- The filename token and every domain key must be valid lowercase SQL + identifiers (`^[a-z][a-z0-9_]*$`); anything else raises `SpecError`. +- Each domain key must equal the token or start with `_`. +- Each value must be a list of strings, and each string must be a key + in `terms.TERM_CATALOG`. Unknown terms raise `SpecError`. + +The `[domain]` table declares nothing else — no extractor names, no +operator lists, no REQUIRE edges. Every behavioural fact comes from the +term catalog. + +Domains may be **twinned** (`int4_ord` and `int4_ord_ore` both carry +`["ore"]`). The generator emits them as independent domains with +byte-identical SQL modulo type name. Twins exist so callers can choose +a name that documents intent ("ordered, regardless of mechanism" vs +"ordered via ORE block") without committing to one term family in a +future migration. + +Manifest order is significant. The generator iterates domains in their +declared TOML order (`generate.py:48`), and that order shows up in the +generated `_types.sql` `DO` block. + +### 3a. Optional `[fixture]` table + +```toml +[fixture] +values = ["MIN", "-1", "ZERO", "1", "MAX"] +``` + +A type may declare an ordered `[fixture] values` list — the single source +of truth for the committed Rust const +`tests/sqlx/src/fixtures/_values.rs`, consumed by the SQLx fixture +generator and the matrix oracle. `_load_fixture_values` (`spec.py:36`) +requires a non-empty list of string tokens; each resolves through the +scalar-kind catalog (`scalars.py`) — the sentinels `MIN` / `MAX` / `ZERO` +plus any numeric literal in the type's representable range. Validation +enforces a **distinct-plaintext contract**: duplicates are rejected against +the *resolved numeric* value, so both copy-paste token dups (`"1", "1"`) and +sentinel/literal aliases (`"MIN"` alongside the same number) raise +`SpecError` — and the set **must include MIN, MAX, and zero** (the matrix +comparison pivots). Unlike the gitignored SQL surface, `_values.rs` +**is committed** (its rendering is deterministic), and CI regenerates it and +runs `git diff --exit-code` to catch an un-regenerated manifest edit. See +implementation spec §9 for the authoring guidance. + +## 4. Term catalog + +`tasks/codegen/terms.py:25-49` defines every term the materializer +recognises. A term is a frozen dataclass: + +```python +Term( + name="hm", # manifest key + json_key="hm", # envelope payload key + extractor="eq_term", # SQL extractor function name + returns="eql_v2.hmac_256", # extractor return type + ctor="hmac_256", # eql_v2 constructor in jsonb + role="eq", # file-header phrasing + operators=("=", "<>"), # operators this term enables + requires=("src/hmac_256/functions.sql",) # SQL REQUIRE edges +) +``` + +Current catalog: + +| Term | JSON key | Extractor | Returns | Operators | +| ----- | -------- | ----------- | -------------------------------- | ---------------------------------- | +| `hm` | `hm` | `eq_term` | `eql_v2.hmac_256` | `=` `<>` | +| `ore` | `ob` | `ord_term` | `eql_v2.ore_block_u64_8_256` | `=` `<>` `<` `<=` `>` `>=` | + +Adding a term is a code change to `terms.py` with matching tests in +`test_terms.py` — never a free-form manifest field. The catalog is the +only source of operator support, extractor identity, and REQUIRE edges; +the manifest is a thin selector over it. + +## 5. The operator surface + +`tasks/codegen/operator_surface.py` enumerates the surface every generated +domain declares: + +- **Supported-capable comparisons**: `=` `<>` `<` `<=` `>` `>=` `@>` `<@` +- **Path blockers**: `->` `->>` +- **Native `jsonb` fallback blockers**: `?` `?|` `?&` `@?` `@@` `#>` `#>>` `-` `#-` `||` + +Comparison and path operators keep the historical three-argument shapes: + +- Symmetric: `(domain, domain)`, `(domain, jsonb)`, `(jsonb, domain)` +- Path: `(domain, text)`, `(domain, integer)`, `(jsonb, domain)` + +Native `jsonb` fallback blockers use only the shapes PostgreSQL exposes +for `jsonb` itself, for a total of **44 `CREATE OPERATOR` statements per +domain**. Supported operators are emitted with full planner metadata +(`COMMUTATOR`, `NEGATOR`, `RESTRICT`, `JOIN` selectivity estimators) and +back onto inlinable wrappers; unsupported operators carry minimal metadata +and back onto blockers. + +Path operators always back onto blockers — neither current term +enables them. The additional native `jsonb` operators are blocker-only. +Untyped string literals are a PostgreSQL resolver edge: `? 'c'` can still +select the built-in `jsonb` operator, while `? 'c'::text` and bound text +parameters select the generated blocker. + +The union of these three lists is `KNOWN_JSONB_OPERATORS`. A live-DB +structural guard +(`tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs`) +queries `pg_operator` for every operator with a `jsonb` argument and asserts +the set is a subset of this union, so a future PostgreSQL version that adds a +`jsonb` operator nobody enumerated here fails the test rather than silently +routing an encrypted column to native plaintext-`jsonb` semantics. +`test_operator_surface.py` pins the Python union; the Rust test mirrors it. + +## 6. Generated outputs + +For a manifest with `D` domains of which `A` are ordered (ord-capable), +the generator writes `1 + 2D + A` SQL files into +`src/encrypted_domain//`, plus — when the manifest carries a +`[fixture]` table — one committed Rust const at +`tests/sqlx/src/fixtures/_values.rs`. For `int4` (`D = 4`, `A = 2`): +eleven SQL files and one Rust file. The SQL outputs are gitignored — `tasks/build.sh` regenerates them at the +start of every build from each `tasks/codegen/types/.toml`, +`mise run codegen:domain ` refreshes a single type manually, and +`mise run codegen:domain:all` regenerates every type in one invocation (the +same `generate.py --all` enumeration the build uses). The manifest plus +`tasks/codegen/terms.py` are the source of truth. + +| File | Content | +| --------------------------------- | ---------------------------------------------------------------------------------------- | +| `_types.sql` | Single idempotent `DO` block creating every domain; each domain `CHECK` pins the payload version (`VALUE->>'v' = '2'`) and required envelope/ciphertext/term keys; one `--! @brief` per domain | +| `_functions.sql` | One extractor per unique term, then 44 wrappers-or-blockers covering the surface | +| `_operators.sql` | 44 `CREATE OPERATOR` statements with planner metadata on supported ops | +| `_aggregates.sql` | MIN/MAX state functions + `CREATE AGGREGATE`; emitted only for ordered (ord-capable) domains | + +Every file: + +- Opens with the `AUTO-GENERATED — DO NOT EDIT` header + (`templates.py:13-17`). +- Declares its `-- REQUIRE:` edges in dependency order — types files + require `src/schema.sql`; function files require schema, types, and + `src/encrypted_domain/functions.sql` plus each term's `requires` set; + operator files require schema, types, and their domain's function + file; aggregate files require schema, types, and their domain's + function and operator files. +- Carries Doxygen `--! @file` / `--! @brief` headers describing its + role. + +### Function-count totals per domain + +| Domain terms | Extractors | Wrappers | Blockers | Functions | Operators | +| ------------ | ---------: | -------: | -------: | --------: | --------: | +| none | 0 | 0 | 44 | 44 | 44 | +| `["hm"]` | 1 | 6 | 38 | 45 | 44 | +| `["ore"]` | 1 | 18 | 26 | 45 | 44 | + +Six wrappers for `hm` = `=` and `<>` × three shapes. Eighteen for `ore` += six operators × three shapes. The 44-operator total never moves; the +wrapper/blocker split is what shifts, and native `jsonb` fallback +operators are always blockers. + +The table above covers `_functions.sql` only. Ordered domains +additionally emit `_aggregates.sql` — two state functions +(`min_sfunc`, `max_sfunc`) and two `CREATE AGGREGATE` declarations +(`eql_v2.min`, `eql_v2.max`). Each aggregate declares +`combinefunc = ` and `parallel = safe`: min/max are associative, so +the state function doubles as the combine function, enabling partial and +parallel aggregation on large `GROUP BY` ORE workloads with no decryption. + +## 7. Invariants the generator enforces + +The generator's job is partly to write SQL and partly to make +incorrect SQL unreachable. Invariants encoded in code: + +- **Blockers are never `STRICT`.** `render_blocker_bool`, + `render_blocker_path`, and `render_blocker_native` emit + `IMMUTABLE PARALLEL SAFE` without the + `STRICT` qualifier (`templates.py:263-345`), so a `NULL` + argument still reaches the `RAISE` and the unsupported-operator + exception fires. There is no code path that produces a strict + blocker. +- **Wrappers are inlinable SQL.** `render_wrapper` and + `render_extractor` emit `LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE` + with a single-statement `SELECT` and no `SET search_path` + (`templates.py:218-260`). `pin_search_path.sql:265-290` + catches them structurally and leaves them unpinned. +- **Aggregate state functions are the deliberate exception.** + `render_aggregate` emits `min_sfunc` / `max_sfunc` as + `LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE` *with* a pinned + `SET search_path` (`templates.py:379-452`). They are aggregate transition + functions, not index expressions, so pinning is correct; the generated + `min` / `max` aggregates are allowlisted by name in `splinter.sh`. The + aggregates are `parallel = safe` with the sfunc reused as `combinefunc`. +- **SQL-literal injection is structurally prevented.** Every string + interpolated into a single-quoted SQL literal — payload keys, operator + symbols, domain names in `RAISE` messages — passes through `_sql_str` + (`templates.py:46`), which doubles embedded single quotes. Today's catalog + strings are all quote-free so it is a no-op, but it guarantees a future + quote-bearing catalog string cannot break out of its literal. +- **No domain-over-domain.** Every domain is `CREATE DOMAIN ... AS + jsonb`, never `AS ` (`templates.py:72`). PostgreSQL + resolves operators against the underlying base type; a derived domain + would silently bypass the fixed operator surface. +- **No operator class on a domain.** The generator emits operators, + not operator classes. Callers index through the extractor function + (e.g. `USING btree (eql_v2.ord_term(col))`), whose return type + already carries a default opclass. +- **Ownership boundary.** `writer.is_generated` recognises owned files + by their header line and refuses to overwrite anything else + (`writer.py:20-26`, `44-53`). A hand-written file at a generated + path is a hard error, not a silent clobber. Stale generated files + for removed domains are cleaned before the new files land + (`writer.py:29-41`). + +## 8. Extension files + +`_extensions.sql` is the hand-written sibling. The generator +never creates, lists, or cleans it; it has no auto-generated header +and must declare its own `-- REQUIRE:` edges. Use it for behaviour +that's specific to the type and not part of the fixed surface — e.g. +cross-domain casts, helper functions, type-specific constraints. + +`pin_search_path.sql:291-302` describes the fallback marker for +inline-critical extension functions that take no domain argument and +so escape the structural skip: + +```sql +COMMENT ON FUNCTION eql_v2.my_helper(...) IS 'eql-inline-critical: ...'; +``` + +The generator does **not** emit this marker; every function it +produces takes a domain argument and is covered by the structural skip +intrinsically. + +## 9. Lint and test integration + +The generator depends on two pieces of build tooling recognising its +output without per-type edits: + +- **`tasks/pin_search_path.sql:265-290`** — structural skip identifies + encrypted-domain functions by language (`sql`), volatility + (`IMMUTABLE`), and the presence of at least one argument typed as a + jsonb-backed `DOMAIN` in `public` named `eql_v2_*`. New scalar types + need no edit here. +- **`tasks/test/splinter.sh`** — name-based allowlist. The converged + wrapper names (`eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `eq_term`, + `ord_term`) are already covered by entries originally added for + `ste_vec_entry` and friends (`splinter.sh:87-104`). Splinter matches + by name only, so a new scalar type that uses the catalog extractors + inherits coverage. Adding a new term whose extractor has a new name + requires a splinter entry. + +## 10. Tests + +`mise run test:codegen` runs the generator test suite — `pytest +tasks/codegen` — with no database required: + +- `test_spec.py`, `test_terms.py`, `test_scalars.py`, + `test_operator_surface.py`, `test_templates.py`, `test_writer.py` — unit + tests per module. +- `test_generate.py` — end-to-end rendering tests asserting file + counts and structural shape. +- `test_against_reference.py` — byte-for-byte match of in-memory + `render_*_file` output against a hand-reviewed (header-stripped) + reference under `tests/codegen/reference/int4/`. Runs anywhere + without depending on materialised `src/encrypted_domain//`. The + reference fixture is the human-readable contract that survives + generator refactors. + +The codegen suite is a prerequisite of the PostgreSQL test matrix +(`tasks/test.sh`), so generated-SQL drift fails CI before any database +test runs. + +## 11. Adding a new scalar type + +The end-to-end shape from a generator perspective: + +1. **Author** `tasks/codegen/types/.toml`. Domain names must + start with the token; term names must already exist in + `terms.TERM_CATALOG`. If `` is a new scalar kind, first register + a `ScalarKind` in `scalars.py` — `load_spec` resolves the scalar before + anything else, so an unregistered token raises + `ScalarError: unknown scalar token ''`. +2. **Regenerate**. Either run `mise run codegen:domain ` while + iterating, or just `mise run build` — the build regenerates every + manifest first. The generator cleans stale generated files, writes + new ones, and refuses any hand-written file at a generated path. + Generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` are + gitignored and never committed. +3. **Hand-write** `_extensions.sql` if the type needs SQL + beyond the fixed surface. Add `eql-inline-critical` markers only on + inline-critical helpers that take no domain argument. This file IS + committed. +4. **Build picks it up automatically** — `tasks/build.sh` regenerates + before computing the `tsort` graph, so the new files appear in the + dependency walk via the `-- REQUIRE:` edges the generator emits. +5. **Baseline & test.** Create a hand-reviewed byte-parity baseline under + `tests/codegen/reference//` (each file marked `-- REFERENCE:` / + `// REFERENCE:`) so `test_against_reference.py` guards the new type — it + only covers types that have a baseline directory. Then run + `mise run test:codegen`, the relevant SQLx suites, and the PostgreSQL + matrix. + +Adding a new **term** is a bigger move — edit `terms.py`, add tests, +audit `splinter.sh` for a name collision, and update the reference +fixture under `tests/codegen/reference/`. + +## 12. Out of scope + +`text` and `jsonb` are not materialised through this generator. There +is no guard preventing a `text.toml` from being authored; the catalog +simply lacks the term shape those types would need. Text and JSONB +encrypted behaviour lives on the composite `eql_v2_encrypted` type and +its hand-written operator surface in `src/encrypted/` and +`src/operators/`, not the scalar materializer. diff --git a/docs/reference/encrypted-domain-implementation-spec.md b/docs/reference/encrypted-domain-implementation-spec.md new file mode 100644 index 00000000..4499c20d --- /dev/null +++ b/docs/reference/encrypted-domain-implementation-spec.md @@ -0,0 +1,339 @@ +# Encrypted Domain Type Implementation Spec + +This is the scalar encrypted-domain generator contract used by `int4`. +It applies to scalar domains whose searchable payloads are represented by +the fixed term catalog in `tasks/codegen/terms.py`. + +`text` and `jsonb` are outside this scalar materializer. + +## 1. Model + +Each generated public domain is a concrete `jsonb` domain named +`public.eql_v2_`. The manifest is intentionally small: + +```toml +[domain] +int4 = [] +int4_eq = ["hm"] +int4_ord_ore = ["ore"] +int4_ord = ["ore"] +``` + +The TOML filename supplies the type token. The `[domain]` table maps each +generated domain name to the fixed terms it carries. The generator +emits files in the manifest's declared order, so order keys in the TOML +in the order you want them to appear in generated output. Term capabilities +come only from `tasks/codegen/terms.py`: + +| Term | JSON key | Extractor | Return type | Supported operators | +|---|---|---|---|---| +| `hm` | `hm` | `eq_term` | `eql_v2.hmac_256` | `=` / `<>` | +| `ore` | `ob` | `ord_term` | `eql_v2.ore_block_u64_8_256` | `=` / `<>` / `<` / `<=` / `>` / `>=` | + +For current `int4`, domains carrying `ore` use JSON key `ob`, extractor +`ord_term`, and the ORE block supports equality plus ordering. A type +that needs a non-ORE equality term on an ordered domain needs a new +catalog term design, not a manifest flag. + +The manifest above declares two ordered domains, `int4_ord` and +`int4_ord_ore`, carrying the same term. They are intentional twins: the +generator emits byte-identical SQL (modulo type name) so callers can pick +a name that documents intent without committing to a term family in a +future migration. + +## 2. Checklist + +- [ ] Author `tasks/codegen/types/.toml`. The filename supplies ``. + The `[domain]` table maps generated domain names to fixed terms: + + ```toml + [domain] + int4 = [] + int4_eq = ["hm"] + int4_ord_ore = ["ore"] + int4_ord = ["ore"] + ``` + + Terms determine operator support: `hm` provides `=` / `<>`; `ore` + provides `=` / `<>` / `<` / `<=` / `>` / `>=`. +- [ ] Add or update catalog terms in `tasks/codegen/terms.py` with tests. +- [ ] **If `` is a new scalar kind, register a `ScalarKind` in + `tasks/codegen/scalars.py`** (use the `int4` entry as the template): its + `token`, `rust_type`, the `MIN` / `MAX` / `ZERO` Rust symbols, and the + numeric `min_value` / `max_value` bounds. This is a code change with + tests, exactly like a new catalog term in `terms.py` — not a manifest + field. `load_spec` resolves the scalar before it validates anything, so + without this entry `mise run codegen:domain ` raises + `ScalarError: unknown scalar token ''` and emits nothing. Then search + the codegen tests for any fixture using `` as a negative "unknown + scalar" example (e.g. `test_spec.py`) and update it — registering the + kind makes that token valid. +- [ ] Declare the fixture plaintext list once in the manifest's `[fixture]` + table (see §9). The list MUST include `MIN`, `MAX`, and zero. +- [ ] Run `mise run codegen:domain ` to materialise generated SQL and the + committed `tests/sqlx/src/fixtures/_values.rs` while iterating, or + just `mise run build` — every build regenerates from the manifest first. + Commit the regenerated `_values.rs` (CI diffs it). +- [ ] Generated `*_types.sql` / `*_functions.sql` / `*_operators.sql` / + `*_aggregates.sql` are gitignored and never committed. The TOML + manifest plus `tasks/codegen/terms.py` are the source of truth. + Change the manifest or catalog and rebuild; do not hand-edit + generated SQL. +- [ ] Put optional hand-written SQL in + `src/encrypted_domain//_extensions.sql` with explicit + `-- REQUIRE:` edges. This file IS committed. +- [ ] Create a hand-reviewed byte-parity baseline under + `tests/codegen/reference//` — one file per generated SQL output plus + `_values.rs`, each headed with the `-- REFERENCE:` / `// REFERENCE:` + marker. `tasks/codegen/test_against_reference.py` only guards types that + have a baseline directory, so without it the new type gets no + drift protection. The committed-fixture parity assertion is currently + `int4`-only; extend it to cover ``. +- [ ] Run `mise run test:codegen`, the relevant SQLx suites, and the + PostgreSQL matrix before merging. + +## 3. Domain Generation + +The generator emits `src/encrypted_domain//_types.sql` (gitignored; +materialised on every `mise run build` and on `mise run codegen:domain +`) with one idempotent `DO $$ ... $$` block. Domain `CHECK` +constraints always require: + +- fixed envelope keys `v` and `i`; +- ciphertext key `c`; +- catalog JSON keys for the listed terms; +- the envelope version value: `VALUE->>'v' = '2'`, matching the repo-wide + `eql_v2._encrypted_check_v` rule (`src/encrypted/constraints.sql`). + +For example, a domain with `["ore"]` requires `v`, `i`, `c`, and `ob` present, +with `v` pinned to `2`. Beyond key presence and the version value, a malformed +term can still fail later inside its extractor unless a future catalog design +adds stronger validation. + +Every generated domain is a concrete domain over `jsonb`. Do not define +one generated domain over another generated domain; PostgreSQL resolves +operators against the underlying base type in ways that bypass the fixed +operator surface. + +## 4. Extractors And Wrappers + +Extractor names and return types come from `tasks/codegen/terms.py`, not +from TOML. Generated extractors and supported comparison wrappers are +inline-friendly SQL functions: + +```sql +LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ SELECT ... $$; +``` + +Extractors and comparison wrappers must not carry a pinned `search_path` +— a `SET` clause disables inlining and reverts index-backed queries to +seq scans. The build tooling recognises these generated functions +structurally, so the generator does not emit `eql-inline-critical` +markers. Aggregate state functions are the one deliberate exception — see +§5 — because they are never index expressions. + +Unsupported operators route to blockers. Blockers are `plpgsql`, +`IMMUTABLE`, `PARALLEL SAFE`, and intentionally not `STRICT`. Both +choices are deliberate: + +- **`plpgsql`, not `sql`.** A `LANGUAGE sql` body would be inlinable, and + the planner could elide the call when the result is provably unused + (dead `CASE` branch, folded predicate), letting a blocked operator + appear to succeed. `plpgsql` is opaque to the planner, so the call — + and its `RAISE` — always survives. +- **Not `STRICT`.** A `STRICT` blocker lets PostgreSQL skip the body and + return `NULL` on a `NULL` argument, silently bypassing the + unsupported-operator exception. + +## 5. Operators + +Every generated domain declares supported scalar comparison operators plus +blockers for the native `jsonb` operator surface that PostgreSQL could +otherwise reach through domain-to-base-type fallback. Each domain emits +44 `CREATE OPERATOR` statements. Supported operators route to wrappers; +everything else routes to blockers. + +| Operators | Forms | +|---|---| +| `=` `<>` `<` `<=` `>` `>=` `@>` `<@` | `(domain, domain)` · `(domain, jsonb)` · `(jsonb, domain)` | +| `->` `->>` | `(domain, text)` · `(domain, integer)` · `(jsonb, domain)` | +| `?` | `(domain, text)` | +| `?\|` `?&` | `(domain, text[])` | +| `@?` `@@` | `(domain, jsonpath)` | +| `#>` `#>>` `#-` | `(domain, text[])` | +| `-` | `(domain, text)` · `(domain, integer)` · `(domain, text[])` | +| `\|\|` | `(domain, domain)` · `(domain, jsonb)` · `(jsonb, domain)` | + +Function counts: + +| Domain terms | Extractors | Wrappers | Blockers | Functions | Operators | +|---|---:|---:|---:|---:|---:| +| none | 0 | 0 | 44 | 44 | 44 | +| `hm` | 1 (`eq_term`) | 6 | 38 | 45 | 44 | +| `ore` | 1 (`ord_term`) | 18 | 26 | 45 | 44 | + +Supported comparison operators carry planner metadata such as +`COMMUTATOR`, `NEGATOR`, `RESTRICT`, and `JOIN`. Blocker operators keep +minimal metadata because they should never be planner-visible supported +paths. + +PostgreSQL's operator resolver still prefers the built-in `jsonb` operator +for untyped string literals in forms such as `payload::eql_v2_int4 ? 'c'`. +Use typed parameters or explicit casts (`'c'::text`) to route those forms +to the generated blocker. The generated surface blocks the typed native +operator shapes exposed by the catalog. + +### Aggregates + +Each ordered (ord-capable) domain additionally gets a generated +`_aggregates.sql` file declaring `MIN` / `MAX`: + +- two state functions, `eql_v2.min_sfunc` and `eql_v2.max_sfunc`, and +- two aggregates, `eql_v2.min()` and `eql_v2.max()`. + +Comparison routes through the domain's `<` / `>` operator (the ORE block +term — no decryption). The state functions are `LANGUAGE plpgsql +IMMUTABLE STRICT PARALLEL SAFE` **with** a pinned `SET search_path`. This is +the one place the "no pinned `search_path`" rule of §4 does not apply: +aggregate transition functions are never index expressions, so pinning is +correct. `STRICT` makes PostgreSQL seed the running state with the first +non-NULL value and skip NULLs, so an all-NULL group returns NULL. + +Each `CREATE AGGREGATE` declares `combinefunc = ` and +`parallel = safe`: min/max are associative, so the state function doubles as +the combine function, and with a `PARALLEL SAFE` sfunc/combinefunc +PostgreSQL can use partial and parallel aggregation on the large `GROUP BY` +ORE workloads these aggregates exist to serve — still with no decryption. +Storage-only and equality-only domains have no comparator and emit no +aggregate file. + +## 6. Extension Files + +Optional hand-written SQL beyond the fixed scalar surface belongs in: + +```text +src/encrypted_domain//_extensions.sql +``` + +The generator must not create this file, list it in TOML, add an +auto-generated header, or clean it during regeneration. The file must +declare its own `-- REQUIRE:` edges, usually to `_types.sql` and +whichever generated function or operator file it extends. Unlike the +generated siblings, `_extensions.sql` IS committed. + +## 7. Indexing + +Do not create operator classes on generated public domains. Index through +the extractor: + +```sql +CREATE INDEX ... ON table_name USING btree (eql_v2.ord_term(col)); +CREATE INDEX ... ON table_name USING hash (eql_v2.eq_term(col)); +``` + +The extractor return type must already have the needed PostgreSQL access +method support. `ore` depends on +`src/ore_block_u64_8_256/functions.sql` and +`src/ore_block_u64_8_256/operators.sql`; `hm` depends on +`src/hmac_256/functions.sql`. + +## 8. Tests + +Cover each generated domain with SQLx tests appropriate to its terms: + +- supported operators return correct rows for all argument forms; +- unsupported operators raise the expected error for all forms; +- blockers raise on `NULL` input; +- supported wrappers return `NULL` for `NULL` operands; +- functional indexes engage and return correct rows; +- constant-on-left comparisons engage the index where applicable; +- domain `CHECK` rejects non-object and under-populated payloads; +- real typed columns are tested, not only cast literals; +- generated ordered-domain twins remain byte-identical modulo type name + (verified by `tasks/codegen/test_against_reference.py` against the + hand-reviewed baseline in `tests/codegen/reference//`). + +For ordered numeric scalars this coverage is generated by the +`ordered_numeric_matrix!` convention wrapper in `tests/sqlx/src/matrix.rs`: +one `impl ScalarType` (`tests/sqlx/src/scalar_domains.rs`) plus a single +invocation taking `suite`, `scalar`, and `eql_type`. The matrix derives +its comparison pivots — the scalar's `MIN`, `MAX`, and zero +(`Default::default()`) — from the type rather than a hand-written list, so +the invocation carries no pivot argument. Equality-only scalars use the +sibling `eq_only_scalar_matrix!`. The `matrix.rs` module header is the +canonical, current list of the test categories the matrix emits (sanity, +correctness, cross-shape, supported-NULL, blocker raises, index engagement, +ORDER BY, ORDER BY USING) — read it rather than maintaining a duplicate +count here. + +For ordered `int4`, keep the assertion that distinct plaintext values +produce distinct ORE blocks. Do not add assertions for term behavior that +the catalog does not promise. + +## 9. Fixtures + +Fixture generation should use real encrypted payloads produced through +CipherStash Proxy. A single payload table may carry every term needed by +the generated domains for that type. For `int4`, the payloads carry `c`, +`hm`, and `ob`; the equality domain reads `hm`, and ordered domains read +`ob`. + +Choose values so range operators produce distinguishable result counts, +include useful boundaries, and cover omitted-term negative cases. For a +scalar driven by `ordered_numeric_matrix!`, the fixture **must** include +the type's `MIN`, `MAX`, and zero (`Default::default()`): the matrix uses +those three as comparison pivots and fetches each one's ciphertext from the +fixture via `fetch_fixture_payload`, which fails loudly if the row is +absent. + +### Single-sourcing the value list + +The plaintext value list is declared **once**, in the manifest's optional +`[fixture]` table, and generated into Rust — never hand-maintained in two +places: + +```toml +[fixture] +values = [ + "MIN", "-100", "-1", "ZERO", "1", "2", "5", "10", "17", "25", + "42", "50", "100", "250", "1000", "9999", "MAX", +] +``` + +Values are strings so the convention is type-agnostic. The sentinels `MIN`, +`MAX`, and `ZERO` map to the scalar's Rust named consts (for `int4`: +`i32::MIN`, `i32::MAX`, `0`); every other token is a numeric literal +validated against the type's representable range. The per-type rendering +rules live in `tasks/codegen/scalars.py` (mirroring `terms.py`), not in +free-form TOML fields. `load_spec` enforces the matrix invariant: the set +**must** include `MIN`, `MAX`, and zero, or the build fails. + +The generator emits `tests/sqlx/src/fixtures/_values.rs` exposing one +`pub const VALUES: &[]`. Both consumers reference that single +symbol — the fixture generator (`fixtures::eql_v2_::spec`) and the matrix +oracle (`impl ScalarType for { const FIXTURE_VALUES }`) — so the +oracle cannot drift from the values the generator encrypts. + +Unlike the gitignored `*_*.sql` surface and the gitignored encrypted +`tests/sqlx/fixtures/eql_v2_.sql` (whose ciphertext is non-deterministic +per-encrypt), `_values.rs` **is committed**: its rendering is +deterministic, so the CI `codegen` job regenerates it and runs +`git diff --exit-code` to catch a manifest edit that wasn't regenerated. +Regenerate with `mise run codegen:domain ` and commit the result; never +hand-edit it. + +## 10. Build And Verification + +- `mise run codegen:domain ` (optional; refreshes one type while + iterating on its manifest before a full build) +- `mise run test:codegen` +- `mise run clean && mise run build` (regenerates every type's SQL + from its manifest first, then builds the release artefacts) +- relevant SQLx suites +- `mise run test` across supported PostgreSQL versions +- `mise run --output prefix test:splinter --postgres 17` after a + PostgreSQL 17 install has built EQL + +The CI codegen job should remain a prerequisite of the PostgreSQL test +matrix so generated SQL drift is caught before database tests run. diff --git a/docs/reference/eql-functions.md b/docs/reference/eql-functions.md index 940cb1ae..5ee40c77 100644 --- a/docs/reference/eql-functions.md +++ b/docs/reference/eql-functions.md @@ -422,6 +422,33 @@ eql_v2.ste_vec(val eql_v2_encrypted) RETURNS eql_v2_encrypted[] eql_v2.ste_vec(val jsonb) RETURNS eql_v2_encrypted[] ``` +### `eql_v2.eq_term()` / `eql_v2.ord_term()` (encrypted-domain) + +Extract the equality (`hm`) or ordering (`ob`) index term from a scalar +encrypted-domain value. Generated per eq/ord-capable variant of every +scalar type — see [Encrypted-Domain Code Generator](./encrypted-domain-generator.md). +The argument type selects the overload, and both are inlinable so a +functional index built on the extractor engages. + +```sql +-- int4 — generated for every scalar type's eq / ord variants. +eql_v2.eq_term(a eql_v2_int4_eq) RETURNS eql_v2.hmac_256 +eql_v2.ord_term(a eql_v2_int4_ord) RETURNS eql_v2.ore_block_u64_8_256 +eql_v2.ord_term(a eql_v2_int4_ord_ore) RETURNS eql_v2.ore_block_u64_8_256 +``` + +**Example:** +```sql +-- Functional indexes on the extracted terms (see Database Indexes) +CREATE INDEX ON users USING hash (eql_v2.eq_term(salary_encrypted)); +CREATE INDEX ON users USING btree (eql_v2.ord_term(salary_encrypted)); +``` + +> The full per-domain operator/wrapper/blocker surface (and the +> `eql_v2_` / `_eq` / `_ord` / `_ord_ore` domain types themselves) is +> documented in [SQL support](./sql-support.md#encrypted-domain-scalar-types-eql_v2_t) +> and the [generator reference](./encrypted-domain-generator.md). + --- ## JSONB Path Functions @@ -540,10 +567,11 @@ eql_v2.meta_data(val jsonb) RETURNS jsonb ### `eql_v2.selector()` -Extract selector hash from encrypted value. +Extract selector hash from an encrypted payload (`jsonb`) or a ste_vec entry. ```sql -eql_v2.selector(val eql_v2_encrypted) RETURNS text +eql_v2.selector(val jsonb) RETURNS text +eql_v2.selector(entry eql_v2.ste_vec_entry) RETURNS text ``` ### `eql_v2.is_ste_vec_array()` @@ -624,34 +652,51 @@ FROM products GROUP BY eql_v2.jsonb_path_query_first(encrypted_json, 'color_selector'); ``` -### `eql_v2.min()` +### `eql_v2.min()` / `eql_v2.max()` (composite type) -Returns the minimum encrypted value in a set (requires `ore` index for ordering). +Returns the minimum or maximum encrypted value in a set on an `eql_v2_encrypted` column (requires `ore` index terms for ordering). ```sql eql_v2.min(eql_v2_encrypted) RETURNS eql_v2_encrypted +eql_v2.max(eql_v2_encrypted) RETURNS eql_v2_encrypted ``` +Comparison routes through the `<` / `>` operator on `eql_v2_encrypted`, which uses the ORE block term — no decryption. + **Example:** ```sql SELECT eql_v2.min(encrypted_date) FROM events; -SELECT eql_v2.min(encrypted_price) FROM products WHERE category = 'electronics'; +SELECT eql_v2.max(encrypted_price) FROM products WHERE category = 'electronics'; ``` -### `eql_v2.max()` +### `eql_v2.min()` / `eql_v2.max()` (per-domain) -Returns the maximum encrypted value in a set (requires `ore` index for ordering). +Returns the minimum or maximum encrypted value in a set on an ordered encrypted-domain column. Defined per ord-capable variant of every scalar type (`eql_v2__ord`, `eql_v2__ord_ore`); the input type selects the aggregate via PostgreSQL's overload resolution. These are type-safe alternatives to the composite-type aggregates above and coexist with them. ```sql -eql_v2.max(eql_v2_encrypted) RETURNS eql_v2_encrypted +-- int4 — generated for every ordered variant of every scalar type. +eql_v2.min(eql_v2_int4_ord) RETURNS eql_v2_int4_ord +eql_v2.max(eql_v2_int4_ord) RETURNS eql_v2_int4_ord +eql_v2.min(eql_v2_int4_ord_ore) RETURNS eql_v2_int4_ord_ore +eql_v2.max(eql_v2_int4_ord_ore) RETURNS eql_v2_int4_ord_ore ``` +Comparison routes through the variant's `<` / `>` operator, which uses the ORE block term — no decryption. The state function is `STRICT`, so `NULL` inputs are skipped and an all-`NULL` input set returns `NULL`. + **Example:** ```sql -SELECT eql_v2.max(encrypted_date) FROM events; -SELECT eql_v2.max(encrypted_price) FROM products WHERE category = 'electronics'; +-- ord-capable column (e.g. price_encrypted typed as eql_v2_int4_ord) +SELECT eql_v2.min(price_encrypted) FROM products; +SELECT eql_v2.max(price_encrypted) FROM products WHERE category = 'electronics'; + +-- Equivalent on a generic jsonb column (cast to the right domain) +SELECT eql_v2.min(price_jsonb::eql_v2_int4_ord) FROM products; ``` +`SUM` / `AVG` and other numeric aggregates are not supported on encrypted columns — decrypt at the application boundary. `MIN` / `MAX` only require comparator-revealing terms; arithmetic aggregates would require homomorphic encryption. + +**See also:** [`docs/reference/sql-support.md`](./sql-support.md) for the per-variant capability table. + --- ## Utility Functions diff --git a/docs/reference/sql-support.md b/docs/reference/sql-support.md index e1bf18e4..ff2de727 100644 --- a/docs/reference/sql-support.md +++ b/docs/reference/sql-support.md @@ -59,6 +59,25 @@ Use the equivalent [`jsonb_path_query`](#jsonb-functions-and-selectors-enabled-b --- +## Encrypted-domain scalar types (`eql_v2_`) + +Scalar encrypted-domain types (e.g. `eql_v2_int4`; see the [generator reference](./encrypted-domain-generator.md)) are a different access model from the matrix above. Instead of configuring a search index on an `eql_v2_encrypted` column, you type the column as a specific domain *variant* whose operator surface is fixed at generation time. The index terms travel in the payload; there is no `add_search_config` step. + +Each scalar type `` generates one storage-only variant plus eq/ord query variants: + +| Domain variant | Term carried | `=` `<>` | `<` `<=` `>` `>=` | `MIN` / `MAX` | `LIKE`/`ILIKE`, JSONB / ste_vec ops | +| ------------------------------- | ------------------- | :------: | :---------------: | :-----------: | :---------------------------------: | +| `eql_v2_` | none (storage only) | ❌ | ❌ | ❌ | ❌ | +| `eql_v2__eq` | `hm` (hmac_256) | ✅ | ❌ | ❌ | ❌ | +| `eql_v2__ord` / `_ord_ore` | `ob` (ore_block) | ✅ | ✅ | ✅ | ❌ | + +- The bare `eql_v2_` variant carries no index term and **blocks every comparison operator** — it is storage / decryption only. Type the column as `_eq` or `_ord` (or cast at the call site) when you need to query. +- Unsupported operators are not silent no-ops: they route to blocker functions that `RAISE` an "operator not supported" exception (a `NULL` operand still raises — the blockers are deliberately not `STRICT`). +- `LIKE` / `ILIKE` and the native JSONB operators (`@>`, `<@`, `->`, `->>`, `?`, `?|`, `?&`, `@?`, `@@`, `#>`, `#>>`, `-`, `#-`, `||`) are blocked on **every** scalar domain variant — they are meaningless on a scalar payload. +- `MIN` / `MAX` are exposed only on the ordered variants as `eql_v2.min(eql_v2__ord)` / `eql_v2.max(...)` — see [EQL Functions Reference](./eql-functions.md#eql_v2min--eql_v2max-per-domain). + +--- + ## SQL syntax / feature support This matrix covers higher-level SQL constructs rather than individual operators. As above, ✅ requires the listed index to be configured on the column; ❌ means the construct cannot be used against that column (without first decrypting via CipherStash Proxy or Protect.js). @@ -76,7 +95,7 @@ This matrix covers higher-level SQL constructs rather than individual operators. | `GROUP BY col` | requires `unique` on the whole column; `ore` / `ope` not yet supported (see note below). Extracted JSON paths have separate caveats — see [ste_vec section](#index-terms-by-json-node-type). | ✅ | ❌ | ❌ | ❌ | ❌ | | `DISTINCT` / `DISTINCT ON (col)` | `unique`, `ore`, or `ope` | ✅ | ✅ | ✅ | ❌ | ❌ | | `HAVING` | same index requirements as the predicates used in `HAVING` (see operator matrix) | varies | varies | varies | varies | varies | -| `MIN(col)` / `MAX(col)` | | ❌ | ✅ | ✅ | ❌ | ❌ | +| `MIN(col)` / `MAX(col)` | `eql_v2.min(eql_v2_encrypted)` / `max` work on any `eql_v2_encrypted` column with `ore` terms. The encrypted-domain family additionally exposes type-safe `eql_v2.min(eql_v2__ord)` / `max` (and the `_ord_ore` twin); `Storage` and `Eq` variants have no comparator and do not declare these aggregates. | ❌ | ✅ | ✅ | ❌ | ❌ | | `COUNT(col)` / `COUNT(DISTINCT col)` | `ore` / `ope` or `unique` for `DISTINCT`; none for plain `COUNT(col)` | ✅ | ✅ | ✅ | ✅ | ✅ | | `JOIN … ON lhs.col = rhs.col` | same index and keyset on both sides | ✅ | ✅ | ✅ | ❌ | ❌ | | `JOIN … ON lhs.col < rhs.col` etc. | same index and keyset on both sides | ❌ | ✅ | ✅ | ❌ | ❌ | @@ -89,7 +108,8 @@ Notes: - **Cross-column / cross-table comparisons** (joins, `IN (subquery)`, `UNION` dedup, etc.) require both sides to have been encrypted with the *same* keyset and the matching search index. Encrypted values from different `ste_vec` prefixes are deliberately incomparable. - **`GROUP BY`** on encrypted columns relies on an operator class which currently only supports encrypted values with a `unique` index term. This is a surprising limitation because it would be natural to expect `ore` / `ope` index terms to also work. This limitation will be lifted in the future. See [Database Indexes](./database-indexes.md#group-by) for performance considerations. - **`ORDER BY`** without an `ore` or `ope` index will still *run* (the EQL `compare` function has a deterministic literal fallback to avoid btree errors), but the resulting order is not meaningful. Configure `ore` (or `ope`) whenever ordering matters. -- **Aggregates beyond `MIN`/`MAX`** (e.g. `SUM`, `AVG`) are not supported on encrypted values — decrypt and perform those aggregate operations on the client-side instead. +- **`MIN(col)` / `MAX(col)`** is available two ways. The composite-type aggregates `eql_v2.min(eql_v2_encrypted)` / `eql_v2.max(eql_v2_encrypted)` work on any `eql_v2_encrypted` column carrying `ore` terms. The encrypted-domain family additionally exposes type-safe per-variant aggregates — see `eql_v2.min(eql_v2__ord)` / `eql_v2.max(eql_v2__ord)` (and the `_ord_ore` twin) in [EQL Functions Reference](./eql-functions.md#eql_v2min--eql_v2max-per-domain). For a domain-typed column, type it as the appropriate `_ord` variant or cast at the call site (`eql_v2.min(col::eql_v2_int4_ord)`). +- **Aggregates beyond `MIN`/`MAX`** (e.g. `SUM`, `AVG`) are not supported on encrypted values — they would require homomorphic encryption. Decrypt at the application boundary and perform those aggregates client-side. - **Parameter binding**: CipherStash Proxy rewrites bound parameters in `WHERE`, `JOIN`, and `RETURNING` clauses with `::JSONB::eql_v2_encrypted` casts so that the encrypted operator and any B-tree / GIN indexes are selected. Writing those casts yourself is only required when bypassing the proxy. --- diff --git a/tasks/docs/generate.sh b/tasks/docs/generate.sh index ea5a5658..033bdc20 100755 --- a/tasks/docs/generate.sh +++ b/tasks/docs/generate.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash #MISE description="Generate API documentation (with Doxygen)" +# Build first so generated encrypted-domain SQL exists under src/. +#MISE depends=["build"] set -e diff --git a/tasks/docs/validate.sh b/tasks/docs/validate.sh index 39275596..14b659af 100755 --- a/tasks/docs/validate.sh +++ b/tasks/docs/validate.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash #MISE description="Validate SQL documentation" +# Build first so generated encrypted-domain SQL exists under src/. +#MISE depends=["build"] set -e diff --git a/tasks/docs/validate/coverage.sh b/tasks/docs/validate/coverage.sh index 623f8f2f..4657ec76 100755 --- a/tasks/docs/validate/coverage.sh +++ b/tasks/docs/validate/coverage.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash #MISE description="Checks documentation coverage for SQL files" +# Build first so generated encrypted-domain SQL exists under src/. +#MISE depends=["build"] set -e diff --git a/tasks/docs/validate/documented-sql.sh b/tasks/docs/validate/documented-sql.sh index b7fd166d..9ce3fb34 100755 --- a/tasks/docs/validate/documented-sql.sh +++ b/tasks/docs/validate/documented-sql.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash #MISE description="Validates SQL syntax for all documented files" +# Build first so generated encrypted-domain SQL exists under src/. +#MISE depends=["build"] set -e diff --git a/tasks/docs/validate/required-tags.sh b/tasks/docs/validate/required-tags.sh index 55e59557..602c37c1 100755 --- a/tasks/docs/validate/required-tags.sh +++ b/tasks/docs/validate/required-tags.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash #MISE description="Validates required Doxygen tags are present" +# Build first so generated encrypted-domain SQL exists under src/. +#MISE depends=["build"] set -e From e26e3a57ffcd62a9342826a7337cc7d3673073b1 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 12:32:52 +1000 Subject: [PATCH 07/10] ci(test-eql): pin third-party actions to SHAs, scope permissions Pin third-party actions to commit SHAs, set permissions: contents: read and persist-credentials: false on checkouts, and add a codegen job gating the PG matrix. Part of PR #239. --- .github/workflows/test-eql.yml | 100 ++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-eql.yml b/.github/workflows/test-eql.yml index 844764b6..436df886 100644 --- a/.github/workflows/test-eql.yml +++ b/.github/workflows/test-eql.yml @@ -29,21 +29,26 @@ defaults: run: shell: bash -l {0} +permissions: + contents: read + jobs: schema: name: "JSON Schema validation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: jdx/mise-action@v4 + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 install: true cache: true - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: tests/sqlx shared-key: sqlx-tests @@ -52,10 +57,76 @@ jobs: run: | mise run test:schema + codegen: + name: "Encrypted-domain codegen" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + cache: true + + - name: Run codegen generator + drift tests + run: | + mise run test:codegen + + # Regenerate the committed Rust fixture-value consts for EVERY type from + # their manifests and fail if any differ from / are missing in the tree. + # The value lists are rendered deterministically (unlike the encrypted + # .sql fixtures, whose ciphertext is non-deterministic and gitignored), so + # a plain diff is the right guard — it catches a manifest edit that wasn't + # regenerated. `git add -N` registers any brand-new untracked const so a + # forgotten-to-commit file also trips the diff. No Postgres needed: this + # only runs the Python generator. + - name: Regenerate and verify fixture-value consts (all types) + run: | + mise run codegen:domain:all + git add -N tests/sqlx/src/fixtures + git diff --exit-code -- tests/sqlx/src/fixtures \ + || { echo "Fixture value const(s) stale or uncommitted — run 'mise run codegen:domain:all' and commit tests/sqlx/src/fixtures."; exit 1; } + + matrix-coverage: + name: "Matrix coverage inventory" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: tests/sqlx + shared-key: sqlx-tests + + # Regenerate the matrix test-name inventory with the SAME pinned feature + # set the local task uses (`--no-default-features`, scale excluded), then + # fail if it differs from the committed snapshot. A coverage change shows + # up as added/removed names in the PR diff — e.g. emptying `ord_domains` + # drops ~140 names, impossible to miss in review. No Postgres needed: + # `--list` only enumerates, the suite uses runtime queries. + - name: Regenerate and verify the matrix test-name inventory + run: | + mise run test:matrix:inventory + git diff --exit-code -- tests/sqlx/snapshots/int4_matrix_tests.txt \ + || { echo "Coverage inventory stale — run 'mise run test:matrix:inventory' and commit."; exit 1; } + test: name: "Test & Validate EQL (Postgres ${{ matrix.postgres-version }})" runs-on: ubuntu-latest-m - needs: schema + needs: [schema, codegen] strategy: fail-fast: false @@ -64,19 +135,28 @@ jobs: env: POSTGRES_VERSION: ${{ matrix.postgres-version }} + # CS_* are required for `mise run test:sqlx` to regenerate the + # cipherstash-client-encrypted fixtures before the suite runs. + # This repository does not accept fork PRs, so the secrets-on- + # `pull_request` constraint that breaks the fork CI flow does not + # apply here — leave the env block unconditional. CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: jdx/mise-action@v4 + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 install: true # [default: true] run `mise install` cache: true # [default: true] cache mise using GitHub's cache - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: tests/sqlx shared-key: sqlx-tests @@ -103,9 +183,11 @@ jobs: POSTGRES_VERSION: "17" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: jdx/mise-action@v4 + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 install: true From bf179b0f9d89eed4a56f8fd71f6c1a14ed7d718c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 14:12:43 +1000 Subject: [PATCH 08/10] test(encrypted-domain): scope jsonb-surface guard to native operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jsonb_operator_surface guard queried pg_operator for every operator with a jsonb operand and asserted the set is a subset of the 20 known native jsonb operators. It swept in EQL's own cross-type operators on the legacy eql_v2_encrypted composite (`eql_v2_encrypted ~~ jsonb`, `jsonb ~~ eql_v2_encrypted`) — they take a jsonb operand but are not native and are unreachable from a storage scalar domain — failing CI with ["~~", "~~*"]. Exclude operands typed eql_v2_encrypted so the guard tests only the native jsonb surface a domain can fall through to. The deliberate design is unchanged: int4 has no LIKE, operator_surface.py pins exactly 20 operators and excludes ~~/~~*, and the matrix native_absent_ops arm asserts ~~/~~* parse-error on storage domains. Verified: full encrypted_domain suite 239 passed / 0 failed (was 238/1); operator_surface Python tests 11 passed; no codegen change. --- .../family/jsonb_operator_surface.rs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs b/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs index 8dc2e049..1a2fb95e 100644 --- a/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs +++ b/tests/sqlx/tests/encrypted_domain/family/jsonb_operator_surface.rs @@ -10,8 +10,11 @@ //! Those lists are an *enumeration*, not a structural guarantee: a future PG //! version could add a jsonb operator that nobody adds here, and it would //! silently route to native jsonb behaviour. This test closes that gap by -//! asking the live catalog which operators actually touch `jsonb` and failing -//! if any symbol is absent from the known union. +//! asking the live catalog which *native* operators touch `jsonb` and failing +//! if any symbol is absent from the known union. EQL's own cross-type operators +//! on the legacy `eql_v2_encrypted` composite (which also take a jsonb operand, +//! e.g. `~~` / `~~*`) are excluded — they are not native and are unreachable +//! from a storage scalar domain. //! //! Source of truth: `tasks/codegen/operator_surface.py::KNOWN_JSONB_OPERATORS` //! (asserted complete by `tasks/codegen/test_operator_surface.py`). The set @@ -36,15 +39,25 @@ const KNOWN_JSONB_OPERATORS: &[&str] = &[ #[sqlx::test] async fn every_native_jsonb_operator_is_known_to_the_generator(pool: PgPool) -> Result<()> { - // Distinct operator symbols whose left OR right argument is `jsonb`. This - // is the full surface a value typed as a jsonb-backed domain can reach via + // Distinct operator symbols whose left OR right argument is `jsonb` — the + // native surface a value typed as a jsonb-backed domain can reach via // operator resolution against the ultimate base type. + // + // Exclude EQL's own cross-type operators on the legacy `eql_v2_encrypted` + // composite (e.g. `eql_v2_encrypted ~~ jsonb`, `jsonb ~~ eql_v2_encrypted`). + // They take a jsonb operand but are NOT native plaintext-jsonb operators and + // are unreachable from a storage scalar domain: a `eql_v2_int4` operand + // resolves to the domain / its jsonb base, never to `eql_v2_encrypted`, so + // `col ~~ x` finds no operator (asserted by the matrix `native_absent_ops` + // arm). Matching on `typname` is search_path-independent and a harmless + // no-op when the type is absent (e.g. the Protect build variant). let native: Vec = sqlx::query_scalar( r#" SELECT DISTINCT o.oprname FROM pg_catalog.pg_operator o - WHERE o.oprleft = 'jsonb'::regtype - OR o.oprright = 'jsonb'::regtype + WHERE (o.oprleft = 'jsonb'::regtype OR o.oprright = 'jsonb'::regtype) + AND o.oprleft NOT IN (SELECT oid FROM pg_catalog.pg_type WHERE typname = 'eql_v2_encrypted') + AND o.oprright NOT IN (SELECT oid FROM pg_catalog.pg_type WHERE typname = 'eql_v2_encrypted') ORDER BY 1 "#, ) From 4098c695a91eb2ca729438eba49967872ba0db0d Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 14:26:24 +1000 Subject: [PATCH 09/10] refactor(fixtures): collapse scalar fixture wrappers behind scalar_fixture! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovered from orphaned commit 0a60f71 (dropped by a reset during the stacked-PR shuffle). The eql_v2_int4 fixture file was ~95% boilerplate shared with future scalar types: the spec() builder, the fixture-gen generator test, and the property-test module differed only in name, the Rust plaintext type, and the value const. Add a scalar_fixture!(name, ty, values) macro that stamps out all three. MIN/MAX in the signed-extremes test derive from <$ty>. The int2 hunk from the original commit is dropped — int2 is not on this branch. Test-infra only; no caller-observable change. Fixture property tests pass (3/3); generate() still compiles under --features fixture-gen. --- tests/sqlx/src/fixtures/eql_v2_int4.rs | 47 +------------- tests/sqlx/src/fixtures/mod.rs | 3 + tests/sqlx/src/fixtures/scalar_fixture.rs | 74 +++++++++++++++++++++++ 3 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 tests/sqlx/src/fixtures/scalar_fixture.rs diff --git a/tests/sqlx/src/fixtures/eql_v2_int4.rs b/tests/sqlx/src/fixtures/eql_v2_int4.rs index 316eac48..429e47d9 100644 --- a/tests/sqlx/src/fixtures/eql_v2_int4.rs +++ b/tests/sqlx/src/fixtures/eql_v2_int4.rs @@ -6,51 +6,6 @@ //! no EQL dependency; #225 layers the `eql_v2_int4` domain on top by casting //! `payload` per query. -use super::index_kind::IndexKind; use super::int4_values::VALUES; -use super::spec::FixtureSpec; -/// The complete fixture definition. `IndexKind::Unique` drives `=` / `<>` -/// (HMAC); `IndexKind::Ore` drives `<` `<=` `>` `>=` (ORE block terms). -pub fn spec() -> FixtureSpec<'static, i32> { - FixtureSpec::new("eql_v2_int4") - .with_index(IndexKind::Unique) - .with_index(IndexKind::Ore) - .with_column_type("jsonb") - .with_values(VALUES) -} - -/// The generator. Gated by `fixture-gen` so `cargo test` never compiles it; -/// `#[ignore]` is a second guard. Run via `mise run fixture:generate eql_v2_int4`. -#[cfg(feature = "fixture-gen")] -#[tokio::test] -#[ignore = "generator — run via `mise run fixture:generate`"] -async fn generate() -> anyhow::Result<()> { - spec().run().await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn spec_is_complete() { - assert!(spec().check_complete().is_ok()); - } - - #[test] - fn spec_includes_signed_extremes() { - // i32::MIN / MAX exercise ORE block-encoding sign-bit edges - // that the smaller earlier list did not cover. - let spec = spec(); - let values = spec.values(); - assert!(values.contains(&i32::MIN), "spec must include i32::MIN"); - assert!(values.contains(&i32::MAX), "spec must include i32::MAX"); - assert!(values.contains(&0), "spec must include 0"); - } - - #[test] - fn spec_includes_negative_values() { - assert!(spec().values().iter().any(|&v| v < 0)); - } -} +crate::scalar_fixture!("eql_v2_int4", i32, VALUES); diff --git a/tests/sqlx/src/fixtures/mod.rs b/tests/sqlx/src/fixtures/mod.rs index 416a3b02..ee087d1d 100644 --- a/tests/sqlx/src/fixtures/mod.rs +++ b/tests/sqlx/src/fixtures/mod.rs @@ -19,6 +19,9 @@ pub mod spec; pub use spec::FixtureSpec; +#[macro_use] +pub mod scalar_fixture; + pub mod cipherstash; pub mod driver; diff --git a/tests/sqlx/src/fixtures/scalar_fixture.rs b/tests/sqlx/src/fixtures/scalar_fixture.rs new file mode 100644 index 00000000..00956cd6 --- /dev/null +++ b/tests/sqlx/src/fixtures/scalar_fixture.rs @@ -0,0 +1,74 @@ +//! `scalar_fixture!` — collapse a scalar fixture wrapper to one invocation. +//! +//! Every `eql_v2_` scalar fixture file (`eql_v2_int2`, `eql_v2_int4`, …) is +//! the same three items differing only in the fixture name, the Rust plaintext +//! type, and the generated value list: the `spec()` builder, the `fixture-gen` +//! generator test, and a small property-test module. This macro stamps all +//! three out, so a new scalar fixture is one `use` of the value const plus one +//! `scalar_fixture!(…)`. +//! +//! The per-file `//!` module docs still belong in each fixture file — they +//! describe *that* type's value choices and are not boilerplate. + +/// Stamp out the `spec()` builder, the `fixture-gen` generator test, and the +/// property-test module for a scalar fixture. +/// +/// - `$name` — the fixture name (`"eql_v2_int2"`), drives every derived path. +/// - `$ty` — the Rust plaintext type (`i16`); `<$ty>::MIN`/`MAX` supply the +/// signed-extreme assertions. +/// - `$values` — the generated value const (`int2_values::VALUES`). +/// +/// Indexes are fixed to `Unique` (HMAC, drives `=` / `<>`) and `Ore` (ORE +/// block terms, drives `<` `<=` `>` `>=`) with a committed `jsonb` payload — +/// the shape shared by every ordered scalar domain. +#[macro_export] +macro_rules! scalar_fixture { + ($name:literal, $ty:ty, $values:expr $(,)?) => { + /// The complete fixture definition. `IndexKind::Unique` drives `=` / + /// `<>` (HMAC); `IndexKind::Ore` drives `<` `<=` `>` `>=` (ORE block + /// terms). + pub fn spec() -> $crate::fixtures::FixtureSpec<'static, $ty> { + $crate::fixtures::FixtureSpec::new($name) + .with_index($crate::fixtures::IndexKind::Unique) + .with_index($crate::fixtures::IndexKind::Ore) + .with_column_type("jsonb") + .with_values($values) + } + + /// The generator. Gated by `fixture-gen` so `cargo test` never compiles + /// it; `#[ignore]` is a second guard. Run via + /// `mise run fixture:generate`. + #[cfg(feature = "fixture-gen")] + #[tokio::test] + #[ignore = "generator — run via `mise run fixture:generate`"] + async fn generate() -> anyhow::Result<()> { + spec().run().await + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn spec_is_complete() { + assert!(spec().check_complete().is_ok()); + } + + #[test] + fn spec_includes_signed_extremes() { + // MIN / MAX exercise ORE block-encoding sign-bit edges that a + // smaller list would not cover. + let spec = spec(); + let values = spec.values(); + assert!(values.contains(&<$ty>::MIN), "spec must include {}::MIN", stringify!($ty)); + assert!(values.contains(&<$ty>::MAX), "spec must include {}::MAX", stringify!($ty)); + assert!(values.contains(&0), "spec must include 0"); + } + + #[test] + fn spec_includes_negative_values() { + assert!(spec().values().iter().any(|&v| v < 0)); + } + } + }; +} From fab74be0ec58a05b7f258030125a30fefdcc7569 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 1 Jun 2026 14:35:29 +1000 Subject: [PATCH 10/10] style(fixtures): rustfmt scalar_fixture.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo fmt --check (tasks/test/lint.sh) flagged scalar_fixture.rs: two assert! lines in the generated property-test arm exceed the line width, so rustfmt wraps them. The file was committed unformatted in 4098c69 and CI lint catches it. Formatting only — the macro expands identically, no behaviour change. --- tests/sqlx/src/fixtures/scalar_fixture.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/sqlx/src/fixtures/scalar_fixture.rs b/tests/sqlx/src/fixtures/scalar_fixture.rs index 00956cd6..2394b049 100644 --- a/tests/sqlx/src/fixtures/scalar_fixture.rs +++ b/tests/sqlx/src/fixtures/scalar_fixture.rs @@ -60,8 +60,16 @@ macro_rules! scalar_fixture { // smaller list would not cover. let spec = spec(); let values = spec.values(); - assert!(values.contains(&<$ty>::MIN), "spec must include {}::MIN", stringify!($ty)); - assert!(values.contains(&<$ty>::MAX), "spec must include {}::MAX", stringify!($ty)); + assert!( + values.contains(&<$ty>::MIN), + "spec must include {}::MIN", + stringify!($ty) + ); + assert!( + values.contains(&<$ty>::MAX), + "spec must include {}::MAX", + stringify!($ty) + ); assert!(values.contains(&0), "spec must include 0"); }