diff --git a/content/docs/reference/eql/dates-and-times.mdx b/content/docs/reference/eql/dates-and-times.mdx index 659dce1..c8ffebf 100644 --- a/content/docs/reference/eql/dates-and-times.mdx +++ b/content/docs/reference/eql/dates-and-times.mdx @@ -95,49 +95,7 @@ Blocked *operator* cells raise an `operator … is not supported` exception — ## Functions -Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. The `MIN` / `MAX` aggregates only exist as functions: - -| Function | Equivalent | Available on | -| --- | --- | --- | -| `eql_v3.eq(a, b)` / `eql_v3.neq(a, b)` | `=` / `<>` | `_eq`, all `_ord` variants | -| `eql_v3.lt` / `lte` / `gt` / `gte` | `<` `<=` `>` `>=` | all `_ord` variants | -| `eql_v3.min(col)` / `eql_v3.max(col)` | aggregate `MIN` / `MAX` | all `_ord` variants | - -## Example queries - -### Time window - -```sql -SELECT * FROM audit_events -WHERE occurred_at BETWEEN $1::public.eql_v3_timestamp_ord AND $2::public.eql_v3_timestamp_ord; - -SELECT * FROM audit_events -WHERE review_due BETWEEN $1::public.eql_v3_date_ord AND $2::public.eql_v3_date_ord; -``` - -### Retention cutoff - -```sql -SELECT id FROM audit_events -WHERE occurred_at < $1::public.eql_v3_timestamp_ord; -``` - -### Newest-first listing - -Write the sort key in extractor form to stream rows out of the index already ordered — at large row counts this is the difference between seconds and milliseconds (see [Sorting](/reference/eql/sorting)): - -```sql -SELECT * FROM audit_events -WHERE occurred_at >= $1::public.eql_v3_timestamp_ord -ORDER BY eql_v3.ord_term(occurred_at) DESC -LIMIT 10; -``` - -### First and last event - -```sql -SELECT eql_v3.min(occurred_at), eql_v3.max(occurred_at) FROM audit_events; -``` +content/partials/eql/functions-dates-and-times.mdx ## Where to next diff --git a/content/docs/reference/eql/json.mdx b/content/docs/reference/eql/json.mdx index 7253a6f..e2aea00 100644 --- a/content/docs/reference/eql/json.mdx +++ b/content/docs/reference/eql/json.mdx @@ -111,85 +111,129 @@ Use containment (`@>` / `<@`), field access (`->` / `->>`), or the `eql_v3.jsonb **Operands must be typed** (`doc -> 'email'::text`, not `doc -> 'email'`) — an untyped operand resolves the native `jsonb` operator, bypassing both the encrypted operator and the blockers. See [Core concepts](/reference/eql/core-concepts). -## Containment: `@>` and `<@` +## Functions -`@>` tests whether the encrypted document contains a structure; `<@` is the reverse. Build the needle with the client and cast it to `eql_v3.query_jsonb` (a typed `public.eql_v3_json` or `public.eql_v3_jsonb_entry` operand also works): +Every JSON query addresses paths by **selector hash** — the deterministic identifier the client emits for a JSON path during encryption, not a plaintext path like `$.customer.tier`. Operands must be typed, or PostgreSQL resolves the native `jsonb` operator instead of the encrypted one. + +The examples below all query one encrypted `metadata` document. In plaintext: + +```json +{ + "customer": { + "tier": "premium", + "region": "apac" + }, + "total": 149.95, + "items": ["sku-1042", "sku-2210"] +} +``` + +The `*_selector` placeholders stand for the selector hash of each path: `region_selector` for `$.customer.region`, `total_selector` for `$.total`, and `items_selector` for `$.items`. + +### Containment [#fn-contains] + +`@>` tests whether the encrypted document contains a structure; `<@` is the reverse. Build the needle in the client and cast it to `eql_v3.query_jsonb`; `eql_v3.ste_vec_contains(a, b)` is the function form. + + ```sql SELECT * FROM orders WHERE metadata @> $1::eql_v3.query_jsonb; ``` -This is the encrypted equivalent of the plaintext `metadata @> '{"customer": {"tier": "premium"}}'`: containment checks that every encrypted term in the needle exists in the document's `sv` vector. `eql_v3.to_ste_vec_query(doc)` converts a stored document into the needle shape, and `eql_v3.ste_vec_contains(a, b)` is the function form backing `@>`. + -For large tables, back containment with a GIN index. The typed `@>` overload inlines to a native `jsonb @>` over `eql_v3.to_ste_vec_query(col)::jsonb`, so a GIN index on that same expression engages: +For large tables, back containment with a GIN index. The typed `@>` inlines to a native `jsonb @>` over `eql_v3.to_ste_vec_query(col)::jsonb`, so a GIN index on that same expression engages: ```sql CREATE INDEX orders_metadata_gin ON orders USING gin (eql_v3.to_ste_vec_query(metadata)::jsonb jsonb_path_ops); -ANALYZE orders; ``` -See [Indexes](/reference/eql/indexes) for the full recipes. +### Field access [#fn-field-access] -## Field access: `->` and `->>` +`->` returns a `public.eql_v3_jsonb_entry`; `->>` serializes that entry as ciphertext text. Fields are addressed by selector hash, and array elements by 0-based index. -Fields are addressed by **selector hash** — the deterministic identifier the client emits for a JSON path during encryption — not a plaintext path string like `$.customer.tier`. + ```sql --- Field access by selector (returns public.eql_v3_jsonb_entry) -SELECT metadata -> 'selector_hash'::text FROM orders; +SELECT metadata -> 'selector_hash'::text FROM orders; -- entry +SELECT metadata ->> 'selector_hash'::text FROM orders; -- entry as ciphertext text +SELECT metadata -> 0 FROM orders; -- array element by index +``` --- The entry serialized as text (ciphertext JSON, not decrypted plaintext) -SELECT metadata ->> 'selector_hash'::text FROM orders; + --- Array element by 0-based index -SELECT metadata -> 0 FROM orders; -``` +The extracted `public.eql_v3_jsonb_entry` is itself comparable. -The extracted `public.eql_v3_jsonb_entry` is itself comparable: +### eql_v3.eq_term [#fn-eq-term] -- `=` / `<>` resolve via `eql_v3.eq_term` — works on every node type -- `<` / `<=` / `>` / `>=` resolve via `eql_v3.ord_term` — String and Number leaves only -- `MIN` / `MAX` over an extracted ordered leaf use the `eql_v3.min` / `eql_v3.max` aggregates +Equality on an extracted leaf, via `eql_v3.eq_term`. Works on every node type, and drives `GROUP BY` / `DISTINCT`. -```sql --- Equality on an extracted leaf -SELECT * FROM orders -WHERE metadata -> 'email_selector'::text = $1::public.eql_v3_jsonb_entry; + --- Group by an extracted leaf's equality term +```sql SELECT eql_v3.eq_term(metadata -> 'region_selector'::text) AS region, COUNT(*) FROM orders GROUP BY eql_v3.eq_term(metadata -> 'region_selector'::text); ``` -A hash index on `eql_v3.eq_term(col -> ''::text)` engages the equality lookup; a btree on `eql_v3.ord_term(...)` engages range and `ORDER BY`. The OPE term is a `bytea` domain that orders under the default btree operator class, so this index needs no custom operator class. See [Indexes](/reference/eql/indexes). + + +### eql_v3.ord_term [#fn-ord-term] -## Path queries and array helpers +Range comparisons and `ORDER BY` on an extracted **String or Number** leaf, via `eql_v3.ord_term`. -The function forms take the same selector hashes: + ```sql --- All entries matching a selector -SELECT eql_v3.jsonb_path_query(metadata, 'selector_hash') FROM orders; +SELECT * FROM orders +WHERE (metadata -> 'total_selector'::text) > $1::public.eql_v3_jsonb_entry; +``` + + --- First match only -SELECT eql_v3.jsonb_path_query_first(metadata, 'selector_hash') FROM orders; +A hash index on `eql_v3.eq_term(col -> ''::text)` engages the equality lookup; a btree on `eql_v3.ord_term(...)` engages range and `ORDER BY`. See [Indexes](/reference/eql/indexes). --- Does the selector exist in this document? -SELECT eql_v3.jsonb_path_exists(metadata, 'selector_hash') FROM orders; +### eql_v3.min / max [#fn-min-max] + +`MIN` / `MAX` over an extracted ordered leaf. + + + +```sql +SELECT eql_v3.min(metadata -> 'total_selector'::text) FROM orders; ``` -For encrypted array nodes: + + +### eql_v3.jsonb_path_query [#fn-path-query] + +Path queries take the same selector hashes. `jsonb_path_query` returns every matching entry, `jsonb_path_query_first` the first, and `jsonb_path_exists` a boolean. + + + +```sql +SELECT eql_v3.jsonb_path_query(metadata, 'selector_hash') FROM orders; -- all matches +SELECT eql_v3.jsonb_path_query_first(metadata, 'selector_hash') FROM orders; -- first match +SELECT eql_v3.jsonb_path_exists(metadata, 'selector_hash') FROM orders; -- boolean +``` + + + +### eql_v3.jsonb_array_* [#fn-array] + +Helpers over an encrypted array node. `jsonb_array_elements` yields encrypted entries; `jsonb_array_elements_text` yields each element as ciphertext text. + + ```sql -SELECT eql_v3.jsonb_array_length(metadata -> 'items_selector'::text) FROM orders; -SELECT eql_v3.jsonb_array_elements(metadata -> 'items_selector'::text) FROM orders; +SELECT eql_v3.jsonb_array_length(metadata -> 'items_selector'::text) FROM orders; +SELECT eql_v3.jsonb_array_elements(metadata -> 'items_selector'::text) FROM orders; SELECT eql_v3.jsonb_array_elements_text(metadata -> 'items_selector'::text) FROM orders; ``` -`jsonb_array_elements` yields encrypted entries; `jsonb_array_elements_text` yields each element as ciphertext text. + ## Worked example diff --git a/content/docs/reference/eql/numbers.mdx b/content/docs/reference/eql/numbers.mdx index f0cdf81..b2f1cac 100644 --- a/content/docs/reference/eql/numbers.mdx +++ b/content/docs/reference/eql/numbers.mdx @@ -101,55 +101,10 @@ Blocked *operator* cells raise an `operator … is not supported` exception — ## Functions -Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. The `MIN` / `MAX` aggregates only exist as functions: - -| Function | Equivalent | Available on | -| --- | --- | --- | -| `eql_v3.eq(a, b)` / `eql_v3.neq(a, b)` | `=` / `<>` | `_eq`, all `_ord` variants | -| `eql_v3.lt` / `lte` / `gt` / `gte` | `<` `<=` `>` `>=` | all `_ord` variants | -| `eql_v3.min(col)` / `eql_v3.max(col)` | aggregate `MIN` / `MAX` | all `_ord` variants | +content/partials/eql/functions-numbers.mdx **`SUM`, `AVG`, and other arithmetic aggregates are not supported** on encrypted columns — they would require homomorphic encryption. `MIN` / `MAX` work because they only need comparison; for sums and averages, decrypt at the application boundary and aggregate client-side. -## Example queries - -### Range filter - -```sql -SELECT * FROM employees -WHERE salary >= $1::public.eql_v3_bigint_ord; - -SELECT * FROM employees -WHERE salary BETWEEN $1::public.eql_v3_bigint_ord AND $2::public.eql_v3_bigint_ord; -``` - -### MIN and MAX - -`eql_v3.min` / `eql_v3.max` compare ordering terms — no decryption happens in the database, and the encrypted result decrypts in the client. `NULL` inputs are skipped; an all-`NULL` input set returns `NULL`: - -```sql -SELECT eql_v3.min(salary) FROM employees; -SELECT eql_v3.max(salary) FROM employees; -``` - -### Sorted listing - -Write the sort key in extractor form to stream rows out of the index already ordered (see [Sorting](/reference/eql/sorting) for why): - -```sql -SELECT * FROM employees -ORDER BY eql_v3.ord_term(salary) DESC -LIMIT 10; -``` - -### Cast at the call site - -On a generic `jsonb` column whose payloads already carry the `op` term, cast to the right domain in the query: - -```sql -SELECT eql_v3.min(salary_jsonb::public.eql_v3_bigint_ord) FROM employees; -``` - ## Where to next diff --git a/content/docs/reference/eql/text.mdx b/content/docs/reference/eql/text.mdx index 810ff25..af48ec4 100644 --- a/content/docs/reference/eql/text.mdx +++ b/content/docs/reference/eql/text.mdx @@ -101,14 +101,7 @@ Blocked *operator* cells raise an `operator … is not supported` exception — ## Functions -Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. The `MIN` / `MAX` aggregates only exist as functions: - -| Function | Equivalent | Available on | -| --- | --- | --- | -| `eql_v3.eq(a, b)` / `eql_v3.neq(a, b)` | `=` / `<>` | `text_eq`, all `text_ord` variants, all `text_search` variants | -| `eql_v3.lt` / `lte` / `gt` / `gte` | `<` `<=` `>` `>=` | all `text_ord` variants, all `text_search` variants | -| `eql_v3.contains(a, b)` / `eql_v3.contained_by(a, b)` | `@>` / `<@` | `text_match`, all `text_search` variants | -| `eql_v3.min(col)` / `eql_v3.max(col)` | aggregate `MIN` / `MAX` | all `text_ord` variants, all `text_search` variants | +content/partials/eql/functions-text.mdx There are no `like` / `ilike` function forms — encrypted text matching is `eql_v3.contains` on a `text_match` value. @@ -126,58 +119,6 @@ SELECT * FROM users WHERE email @> $1::public.eql_v3_text_match; `@>` / `<@` here is **probabilistic ngram-bloom containment** — it tests whether the encrypted text contains the (encrypted) search terms. It is not JSONB containment and not `LIKE`. The client encrypts the search term into a bloom-filter query value; false positives are possible, false negatives are not. There are no `like` / `ilike` function forms either — text matching is `eql_v3.contains` on a `text_match` value. -## Example queries - -### Exact lookup - -Equality on a `text_eq` column compares HMAC terms. `IN` desugars to `=`: - -```sql -SELECT * FROM users WHERE tax_id = $1::public.eql_v3_text_eq; - -SELECT * FROM users -WHERE tax_id IN ($1::public.eql_v3_text_eq, $2::public.eql_v3_text_eq); -``` - -### Free-text match - -The client encrypts the search term into the bloom-filter needle: - -```sql -SELECT * FROM users WHERE name @> $1::public.eql_v3_text_match; - --- Function form, for platforms without custom operators -SELECT * FROM users WHERE eql_v3.contains(name, $1::public.eql_v3_text_match); -``` - -### The works: `text_search` - -A `text_search` column answers exact lookup, free-text match, and ordering — here, all three in one query: - -```sql -SELECT id, email FROM users -WHERE email @> $1::public.eql_v3_text_match -- token containment on bf - AND email <> $2::public.eql_v3_text_eq -- exclude an exact value via hm -ORDER BY eql_v3.ord_term(email) -- sort on ob -LIMIT 20; -``` - -### Sorting text - -Ordering terms are order-preserving, so `ORDER BY` sorts encrypted text correctly. Write the sort key in extractor form so a btree index can do the ordering instead of a `Sort` node — see [Sorting](/reference/eql/sorting): - -```sql -SELECT * FROM users -ORDER BY eql_v3.ord_term(email) -LIMIT 50; -``` - -`MIN` / `MAX` work on any ord-capable text column too: - -```sql -SELECT eql_v3.min(email) FROM users; -``` - ## Where to next diff --git a/content/partials/eql/functions-dates-and-times.mdx b/content/partials/eql/functions-dates-and-times.mdx new file mode 100644 index 0000000..581efbf --- /dev/null +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -0,0 +1,59 @@ +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */} + +Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions. + +### eql_v3.eq(a, b) [#fn-eq] + + + +```sql +SELECT * FROM events +WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + +### eql_v3.neq(a, b) [#fn-neq] + + + +```sql +SELECT * FROM events +WHERE eql_v3.neq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + +### eql_v3.lt / lte / gt / gte [#fn-comparison] + + + +```sql +-- a range uses two of the four +SELECT * FROM events +WHERE eql_v3.gte(occurred_at, $1::public.eql_v3_timestamp_ord) + AND eql_v3.lt(occurred_at, $2::public.eql_v3_timestamp_ord); +``` + + + +### eql_v3.min(col) [#fn-min] + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(occurred_at) FROM events; +``` + + + +### eql_v3.max(col) [#fn-max] + + + +```sql +SELECT eql_v3.max(occurred_at) FROM events; +``` + + diff --git a/content/partials/eql/functions-numbers.mdx b/content/partials/eql/functions-numbers.mdx new file mode 100644 index 0000000..357c8fa --- /dev/null +++ b/content/partials/eql/functions-numbers.mdx @@ -0,0 +1,59 @@ +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */} + +Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions. + +### eql_v3.eq(a, b) [#fn-eq] + + + +```sql +SELECT * FROM payments +WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq); +``` + + + +### eql_v3.neq(a, b) [#fn-neq] + + + +```sql +SELECT * FROM payments +WHERE eql_v3.neq(amount, $1::public.eql_v3_bigint_eq); +``` + + + +### eql_v3.lt / lte / gt / gte [#fn-comparison] + + + +```sql +-- a range uses two of the four +SELECT * FROM payments +WHERE eql_v3.gte(amount, $1::public.eql_v3_bigint_ord) + AND eql_v3.lt(amount, $2::public.eql_v3_bigint_ord); +``` + + + +### eql_v3.min(col) [#fn-min] + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(amount) FROM payments; +``` + + + +### eql_v3.max(col) [#fn-max] + + + +```sql +SELECT eql_v3.max(amount) FROM payments; +``` + + diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx new file mode 100644 index 0000000..ff415c7 --- /dev/null +++ b/content/partials/eql/functions-text.mdx @@ -0,0 +1,82 @@ +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */} + +Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions. + +### eql_v3.eq(a, b) [#fn-eq] + + + +```sql +SELECT * FROM users +WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq); +``` + + + +### eql_v3.neq(a, b) [#fn-neq] + + + +```sql +SELECT * FROM users +WHERE eql_v3.neq(email, $1::public.eql_v3_text_eq); +``` + + + +### eql_v3.lt / lte / gt / gte [#fn-comparison] + + + +```sql +-- any of the four; ordering is the usual reason to index text +SELECT id, email FROM users +WHERE eql_v3.gt(email, $1::public.eql_v3_text_ord) +ORDER BY eql_v3.ord_term(email); +``` + + + +### eql_v3.contains(a, b) [#fn-contains] + + + +```sql +-- token containment on the bloom-filter term +SELECT * FROM users +WHERE eql_v3.contains(email, $1::public.eql_v3_text_match); +``` + + + +### eql_v3.contained_by(a, b) [#fn-contained_by] + + + +```sql +SELECT * FROM users +WHERE eql_v3.contained_by(email, $1::public.eql_v3_text_match); +``` + + + +### eql_v3.min(col) [#fn-min] + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(email) FROM users; +``` + + + +### eql_v3.max(col) [#fn-max] + + + +```sql +SELECT eql_v3.max(email) FROM users; +``` + + diff --git a/scripts/generate-eql-api-docs.ts b/scripts/generate-eql-api-docs.ts index 727e050..609954d 100644 --- a/scripts/generate-eql-api-docs.ts +++ b/scripts/generate-eql-api-docs.ts @@ -43,6 +43,12 @@ const MANIFEST_PATH = (fs.existsSync(RELEASE_MANIFEST) ? RELEASE_MANIFEST : SAMPLE_MANIFEST); const EQL_DIR = path.join(process.cwd(), "content/docs/reference/eql"); const OUT_FILE = path.join(EQL_DIR, "functions.mdx"); +// Per-type function fragments embedded into the hand-written type pages via +// Fumadocs' `` directive. They live OUTSIDE the two content +// collections (content/docs, content/stack) so they never become routes, and +// are included cwd-relative (`content/partials/…`). Generated, so +// the per-type "which functions apply" tables can't drift from the manifest. +const FRAGMENT_DIR = path.join(process.cwd(), "content/partials/eql"); // Single source for the EQL version the whole reference is built against: the // release manifest's own `version`. Written here so the banner on // every EQL page reads the same release-derived value (no hardcoded constant). @@ -214,6 +220,183 @@ function render(manifest: Manifest): string { return `${body.join("\n").trimEnd()}\n`; } +// ── Per-type function fragments ────────────────────────────────────────────── +// Each hand-written type page (numbers, text, dates-and-times) carries a +// per-function reference: one card per EQL function, listing its operator +// equivalents, the domains it applies to, and a worked example. These are +// generated from the manifest and ``d into the page (via the `` +// component) rather than hand-maintained, where the domain lists silently +// drifted. The example is the one authored part — templated by capability, not +// pulled from the manifest — and the domain list, the drift-prone part, is not. +// +// json and booleans are intentionally NOT here: json's surface is containment / +// path functions (a bespoke story, not the eq/ord/min-max set), and booleans +// are storage-only with no query functions. +interface FragmentSpec { + page: string; + // Which manifest domain `type`s belong on this page. + match: (type: string) => boolean; + // Illustrative context for the generated examples. + table: string; + col: string; + // Representative concrete type the example casts to, e.g. `bigint` → + // `public.eql_v3_bigint_ord`. One of the page's family, for a realistic cast. + castType: string; + // On text, ranges are unusual and sorting is the point, so the comparison + // example demonstrates ORDER BY. Elsewhere a range filter reads best. + orderByExample: boolean; +} + +const FRAGMENT_SPECS: FragmentSpec[] = [ + { + page: "numbers", + match: (t) => + /(^|\b)(small|big)?int|integer|numeric|decimal|real|double|float/.test(t), + table: "payments", + col: "amount", + castType: "bigint", + orderByExample: false, + }, + { + page: "text", + match: (t) => t === "text", + table: "users", + col: "email", + castType: "text", + orderByExample: true, + }, + { + page: "dates-and-times", + match: (t) => /date|time/.test(t), + table: "events", + col: "occurred_at", + castType: "timestamp", + orderByExample: false, + }, +]; + +// The EQL function set, in reading order. `cap` is the domain capability that +// exposes the function, so a function only renders when the page has a domain +// with that capability. `lt`/`lte`/`gt`/`gte` are one card: same capability, +// same domains. `id` is the deep-link anchor. +interface FuncDef { + id: string; + name: string; + ops: string[]; + cap: string; + agg?: boolean; +} +const FUNCS: FuncDef[] = [ + { id: "fn-eq", name: "eql_v3.eq(a, b)", ops: ["="], cap: "equality" }, + { id: "fn-neq", name: "eql_v3.neq(a, b)", ops: ["<>"], cap: "equality" }, + { + id: "fn-comparison", + name: "eql_v3.lt / lte / gt / gte", + ops: ["<", "<=", ">", ">="], + cap: "order", + }, + { + id: "fn-contains", + name: "eql_v3.contains(a, b)", + ops: ["@>"], + cap: "match", + }, + { + id: "fn-contained_by", + name: "eql_v3.contained_by(a, b)", + ops: ["<@"], + cap: "match", + }, + { + id: "fn-min", + name: "eql_v3.min(col)", + ops: ["MIN"], + cap: "order", + agg: true, + }, + { + id: "fn-max", + name: "eql_v3.max(col)", + ops: ["MAX"], + cap: "order", + agg: true, + }, +]; + +// `public.eql_v3_text_eq` → `text_eq`. +const shortDomain = (name: string) => name.replace(/^public\.(eql_v3_)?/, ""); + +// The example for one function, keyed by its anchor id and templated from the +// page's illustrative context. The `::public.eql_v3__` casts use +// real domain names, so they stay correct; the table and column are illustrative. +function exampleFor(id: string, spec: FragmentSpec): string { + const { table, col, castType } = spec; + const dom = (variant: string) => `public.eql_v3_${castType}_${variant}`; + switch (id) { + case "fn-eq": + return `SELECT * FROM ${table}\nWHERE eql_v3.eq(${col}, $1::${dom("eq")});`; + case "fn-neq": + return `SELECT * FROM ${table}\nWHERE eql_v3.neq(${col}, $1::${dom("eq")});`; + case "fn-comparison": + return spec.orderByExample + ? `-- any of the four; ordering is the usual reason to index text\nSELECT id, ${col} FROM ${table}\nWHERE eql_v3.gt(${col}, $1::${dom("ord")})\nORDER BY eql_v3.ord_term(${col});` + : `-- a range uses two of the four\nSELECT * FROM ${table}\nWHERE eql_v3.gte(${col}, $1::${dom("ord")})\n AND eql_v3.lt(${col}, $2::${dom("ord")});`; + case "fn-contains": + return `-- token containment on the bloom-filter term\nSELECT * FROM ${table}\nWHERE eql_v3.contains(${col}, $1::${dom("match")});`; + case "fn-contained_by": + return `SELECT * FROM ${table}\nWHERE eql_v3.contained_by(${col}, $1::${dom("match")});`; + case "fn-min": + return `-- compares ordering terms; result decrypts client-side\nSELECT eql_v3.min(${col}) FROM ${table};`; + case "fn-max": + return `SELECT eql_v3.max(${col}) FROM ${table};`; + default: + return ""; + } +} + +function renderFragment(domains: Domain[], spec: FragmentSpec): string { + const scoped = domains.filter((d) => spec.match(d.type)); + const header = `{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */}`; + const intro = + "Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions."; + if (!scoped.length) { + return `${header}\n\n${intro}\n\n_No matching encrypted domains in this EQL manifest._\n`; + } + + const blocks: string[] = []; + for (const fn of FUNCS) { + const applies = scoped + .filter((d) => d.capabilities.includes(fn.cap) && d.variant) + .map((d) => shortDomain(d.name)); + if (!applies.length) continue; + const attrs = [ + `ops="${fn.ops.join(",")}"`, + fn.agg ? "agg" : "", + `domains="${applies.join(",")}"`, + ] + .filter(Boolean) + .join(" "); + const example = exampleFor(fn.id, spec); + // The name is a real `###` heading with an explicit id, so each function + // gets a table-of-contents entry (nested under "Functions") and a stable + // deep-link anchor. The `` card renders everything below it. + blocks.push( + `### ${fn.name} [#${fn.id}]\n\n\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`, + ); + } + + return `${header}\n\n${intro}\n\n${blocks.join("\n\n")}\n`; +} + +function writeFragments(manifest: Manifest): void { + fs.mkdirSync(FRAGMENT_DIR, { recursive: true }); + for (const spec of FRAGMENT_SPECS) { + const out = path.join(FRAGMENT_DIR, `functions-${spec.page}.mdx`); + fs.writeFileSync(out, renderFragment(manifest.domains ?? [], spec)); + console.log(`✓ Generated ${path.relative(process.cwd(), out)}`); + } +} + // ── Drift guard ────────────────────────────────────────────────────────────── // The known surface is fully schema-qualified: domains live in `public.`, // functions in `eql_v3.` (public) or `eql_v3_internal.` (private), and the @@ -285,6 +468,9 @@ function main() { fs.mkdirSync(EQL_DIR, { recursive: true }); fs.writeFileSync(OUT_FILE, render(manifest)); + // Per-type function fragments included into the hand-written type pages. + writeFragments(manifest); + // Emit the release version for the banner (shared by every EQL // reference page, hand-written and generated alike). fs.mkdirSync(path.dirname(VERSION_FILE), { recursive: true }); diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx new file mode 100644 index 0000000..5af3af7 --- /dev/null +++ b/src/components/eql-fn.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; + +interface EqlFnProps { + /** + * Comma-separated operator equivalents, e.g. `<,<=,>,>=`. Omit for pure + * functions with no operator form (e.g. `eql_v3.jsonb_path_query`). + */ + ops?: string; + /** Comma-separated short domain names the function applies to. */ + domains?: string; + /** Aggregate function (MIN/MAX): labels the row "Aggregate". */ + agg?: boolean; + /** How many domains to show before the "Show all" toggle. */ + initial?: number; + /** The worked example (a fenced code block). */ + children: React.ReactNode; +} + +/** + * The body of one entry in a generated EQL function reference (see the + * fragments under content/partials/eql, produced by + * scripts/generate-eql-api-docs.ts). + * + * The function name is a real Markdown heading in the fragment (so it appears + * in the page's table of contents and is deep-linkable); this component renders + * everything below it — the operator equivalents, the domains it applies to, + * and the example. The domain list is the drift-prone part, so it comes from + * the manifest via the `domains` prop; only the first few show, with the rest + * behind a reader-controlled toggle. The example is passed as children so it + * keeps the site's syntax highlighting and copy button. + */ +export function EqlFn({ + ops, + domains, + agg, + initial = 2, + children, +}: EqlFnProps) { + const opList = ops ? ops.split(",").filter(Boolean) : []; + const domainList = domains ? domains.split(",").filter(Boolean) : []; + const [expanded, setExpanded] = useState(false); + const hidden = Math.max(0, domainList.length - initial); + const visible = expanded ? domainList : domainList.slice(0, initial); + const label = agg ? "Aggregate" : "Operators"; + + return ( +
+ {opList.length > 0 ? ( +
+ + {label} + + {opList.map((op) => ( + + {op} + + ))} +
+ ) : null} + + {domainList.length > 0 ? ( +
+ + On + + {visible.map((d) => ( + + {d} + + ))} + {hidden > 0 ? ( + + ) : null} +
+ ) : null} + +
{children}
+
+ ); +} diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx index f05ae7c..17e7aa1 100644 --- a/src/mdx-components.tsx +++ b/src/mdx-components.tsx @@ -3,6 +3,7 @@ import { Step, Steps } from "fumadocs-ui/components/steps"; import defaultMdxComponents from "fumadocs-ui/mdx"; import type { MDXComponents } from "mdx/types"; import { TrackedCodeBlock } from "@/components/code-block"; +import { EqlFn } from "@/components/eql-fn"; import { EqlVersion } from "@/components/eql-version"; import { ZeroKmsRegions } from "@/components/zerokms-regions"; @@ -16,6 +17,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { Steps, Step, EqlVersion, + EqlFn, ZeroKmsRegions, ...components, };