From 56fd18bcf54cc1ba884342d05b7f117a597e0931 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 14 Jul 2026 23:14:05 +1000 Subject: [PATCH 1/7] docs(eql): generate per-type function tables as included partials The "Functions available on this type" tables on the numbers, text, and dates-and-times reference pages were hand-maintained and had silently drifted from the EQL surface before. Generate them from the EQL manifest instead and embed them via Fumadocs' `` directive, so the per-type operator-function matrix can't drift from the catalog it documents. - generate-eql-api-docs.ts emits content/partials/eql/functions-.mdx from domains[].capabilities + variant, collapsing variants per capability (e.g. all `_ord` variants) and naming them concretely for single-type pages (text_eq) vs generically for type families (_eq). - Partials live outside the content collections so Fumadocs never routes them; they're included cwd-relative and committed so dev/types:check (which skip prebuild) resolve them. - Verified: output is byte-identical to the prior hand tables against the real EQL 3.0.0 manifest; full build produces no stray routes and inlines the tables into HTML, copy-markdown, and llms output. json (bespoke containment/path functions) and booleans (storage-only) are left as hand-written prose, not part of the per-type matrix. --- .../docs/reference/eql/dates-and-times.mdx | 8 +- content/docs/reference/eql/numbers.mdx | 8 +- content/docs/reference/eql/text.mdx | 9 +- .../eql/functions-dates-and-times.mdx | 9 ++ content/partials/eql/functions-numbers.mdx | 9 ++ content/partials/eql/functions-text.mdx | 10 ++ scripts/generate-eql-api-docs.ts | 132 ++++++++++++++++++ 7 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 content/partials/eql/functions-dates-and-times.mdx create mode 100644 content/partials/eql/functions-numbers.mdx create mode 100644 content/partials/eql/functions-text.mdx diff --git a/content/docs/reference/eql/dates-and-times.mdx b/content/docs/reference/eql/dates-and-times.mdx index 659dce1..ef1d7f0 100644 --- a/content/docs/reference/eql/dates-and-times.mdx +++ b/content/docs/reference/eql/dates-and-times.mdx @@ -95,13 +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 | +content/partials/eql/functions-dates-and-times.mdx ## Example queries diff --git a/content/docs/reference/eql/numbers.mdx b/content/docs/reference/eql/numbers.mdx index f0cdf81..2c6da9e 100644 --- a/content/docs/reference/eql/numbers.mdx +++ b/content/docs/reference/eql/numbers.mdx @@ -101,13 +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)` | `=` / `<>` | `_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. diff --git a/content/docs/reference/eql/text.mdx b/content/docs/reference/eql/text.mdx index 810ff25..98c144d 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. 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..8bb19bf --- /dev/null +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -0,0 +1,9 @@ +{/* 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. 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 | diff --git a/content/partials/eql/functions-numbers.mdx b/content/partials/eql/functions-numbers.mdx new file mode 100644 index 0000000..8bb19bf --- /dev/null +++ b/content/partials/eql/functions-numbers.mdx @@ -0,0 +1,9 @@ +{/* 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. 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 | diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx new file mode 100644 index 0000000..d500395 --- /dev/null +++ b/content/partials/eql/functions-text.mdx @@ -0,0 +1,10 @@ +{/* 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. 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 | diff --git a/scripts/generate-eql-api-docs.ts b/scripts/generate-eql-api-docs.ts index 727e050..7b2ddfe 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,129 @@ 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 +// "Functions available on this type" table. Those tables are mechanical — one +// row per operator-function, and an "Available on" column listing the variants +// that expose it — so they're generated from the manifest and ``d into +// the page rather than hand-maintained (where they silently drifted). +// +// json and booleans are intentionally NOT here: json's surface is containment / +// path functions (a bespoke story, not the eq/ord/min-max matrix), 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; + // Variant naming in the "Available on" column. A single-type page names the + // concrete domain (`text_eq`); a type-family page uses the generic suffix + // (`_eq`) that stands for the eq variant of whichever numeric/date type. + prefix: string; +} + +const FRAGMENT_SPECS: FragmentSpec[] = [ + { + page: "numbers", + prefix: "", + match: (t) => + /(^|\b)(small|big)?int|integer|numeric|decimal|real|double|float/.test(t), + }, + { page: "text", prefix: "text", match: (t) => t === "text" }, + { page: "dates-and-times", prefix: "", match: (t) => /date|time/.test(t) }, +]; + +// One row per operator-function, keyed by the domain capability that enables it. +// Order matches the hand-written tables (equality, range, containment, aggregate). +const FUNCTION_ROWS: { fns: string; equiv: string; capability: string }[] = [ + { + fns: "`eql_v3.eq(a, b)` / `eql_v3.neq(a, b)`", + equiv: "`=` / `<>`", + capability: "equality", + }, + { + fns: "`eql_v3.lt` / `lte` / `gt` / `gte`", + equiv: "`<` `<=` `>` `>=`", + capability: "order", + }, + { + fns: "`eql_v3.contains(a, b)` / `eql_v3.contained_by(a, b)`", + equiv: "`@>` / `<@`", + capability: "match", + }, + { + fns: "`eql_v3.min(col)` / `eql_v3.max(col)`", + equiv: "aggregate `MIN` / `MAX`", + capability: "order", + }, +]; + +// Collapse the variants that expose a capability into a readable "Available on" +// cell: variants sharing a base (`ord`, `ord_ope`, `ord_ore`) render as "all +// `_ord` variants"; a lone variant renders as its own token. +function availabilityCell( + domains: Domain[], + capability: string, + prefix: string, +): string { + const variants = new Set( + domains + .filter((d) => d.capabilities.includes(capability) && d.variant) + .map((d) => d.variant), + ); + const baseOf = (v: string) => + v === "eq" + ? "eq" + : v.startsWith("ord") + ? "ord" + : v.startsWith("search") + ? "search" + : v === "match" + ? "match" + : v; + const byBase = new Map>(); + for (const v of variants) { + const b = baseOf(v); + (byBase.get(b) ?? byBase.set(b, new Set()).get(b))?.add(v); + } + const parts: string[] = []; + for (const base of ["eq", "ord", "match", "search"]) { + const members = byBase.get(base); + if (!members) continue; + const token = prefix ? `${prefix}_${base}` : `_${base}`; + parts.push(members.size > 1 ? `all \`${token}\` variants` : `\`${token}\``); + } + return parts.join(", "); +} + +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. 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 rows = FUNCTION_ROWS.map((r) => ({ + ...r, + cell: availabilityCell(scoped, r.capability, spec.prefix), + })).filter((r) => r.cell); + const table = [ + "| Function | Equivalent | Available on |", + "| --- | --- | --- |", + ...rows.map((r) => `| ${r.fns} | ${r.equiv} | ${r.cell} |`), + ].join("\n"); + return `${header}\n\n${intro}\n\n${table}\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 +414,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 }); From 68eeb93237175178f8aa8135046db25bcc046197 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 09:32:19 +1000 Subject: [PATCH 2/7] docs(eql): per-function cards for the EQL function reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generated function table with a per-function layout: one card per function (lt/lte/gt/gte grouped into one), showing its operator equivalents, the domains it applies to, and a worked example. - New client component renders the card, operator badges, and the applies-to domain list. The domain list shows the first two variants with a "Show all N variants" toggle, so the 24-domain numbers cases don't flood the page. Styled with Fumadocs fd-* tokens so it tracks the theme. - generate-eql-api-docs.ts emits blocks into the partials, with the domain list pulled from the manifest (drift-proof). The example is passed as the code-fence child so it keeps the site's highlighting and copy button; examples are templated by capability — the one authored part. - Text ordering functions demonstrate ORDER BY (sorting is the point on text); numbers/dates keep the range form. contains/contained_by only render where a match-capable domain exists, so they're absent on numbers and dates. Verified: full build renders every card, operators, pills, and highlighted examples with no stray tags; types:check and lint pass. --- .../eql/functions-dates-and-times.mdx | 52 ++++- content/partials/eql/functions-numbers.mdx | 52 ++++- content/partials/eql/functions-text.mdx | 74 ++++++- scripts/generate-eql-api-docs.ts | 201 +++++++++++------- src/components/eql-fn.tsx | 107 ++++++++++ src/mdx-components.tsx | 2 + 6 files changed, 395 insertions(+), 93 deletions(-) create mode 100644 src/components/eql-fn.tsx diff --git a/content/partials/eql/functions-dates-and-times.mdx b/content/partials/eql/functions-dates-and-times.mdx index 8bb19bf..d56fdff 100644 --- a/content/partials/eql/functions-dates-and-times.mdx +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -1,9 +1,49 @@ {/* 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. The `MIN` / `MAX` aggregates only exist as functions: +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. -| 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 | + + +```sql +SELECT * FROM events +WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + + + +```sql +SELECT * FROM events +WHERE eql_v3.neq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + + + +```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); +``` + + + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(occurred_at) FROM events; +``` + + + + + +```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 index 8bb19bf..c6affda 100644 --- a/content/partials/eql/functions-numbers.mdx +++ b/content/partials/eql/functions-numbers.mdx @@ -1,9 +1,49 @@ {/* 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. The `MIN` / `MAX` aggregates only exist as functions: +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. -| 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 | + + +```sql +SELECT * FROM payments +WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq); +``` + + + + + +```sql +SELECT * FROM payments +WHERE eql_v3.neq(amount, $1::public.eql_v3_bigint_eq); +``` + + + + + +```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); +``` + + + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(amount) FROM payments; +``` + + + + + +```sql +SELECT eql_v3.max(amount) FROM payments; +``` + + diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx index d500395..c5439d8 100644 --- a/content/partials/eql/functions-text.mdx +++ b/content/partials/eql/functions-text.mdx @@ -1,10 +1,68 @@ {/* 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. 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 | +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. + + + +```sql +SELECT * FROM users +WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq); +``` + + + + + +```sql +SELECT * FROM users +WHERE eql_v3.neq(email, $1::public.eql_v3_text_eq); +``` + + + + + +```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); +``` + + + + + +```sql +-- token containment on the bloom-filter term +SELECT * FROM users +WHERE eql_v3.contains(email, $1::public.eql_v3_text_match); +``` + + + + + +```sql +SELECT * FROM users +WHERE eql_v3.contained_by(email, $1::public.eql_v3_text_match); +``` + + + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(email) FROM users; +``` + + + + + +```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 7b2ddfe..e1d7f4b 100644 --- a/scripts/generate-eql-api-docs.ts +++ b/scripts/generate-eql-api-docs.ts @@ -222,116 +222,171 @@ function render(manifest: Manifest): string { // ── Per-type function fragments ────────────────────────────────────────────── // Each hand-written type page (numbers, text, dates-and-times) carries a -// "Functions available on this type" table. Those tables are mechanical — one -// row per operator-function, and an "Available on" column listing the variants -// that expose it — so they're generated from the manifest and ``d into -// the page rather than hand-maintained (where they silently drifted). +// 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 matrix), and booleans +// 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; - // Variant naming in the "Available on" column. A single-type page names the - // concrete domain (`text_eq`); a type-family page uses the generic suffix - // (`_eq`) that stands for the eq variant of whichever numeric/date type. - prefix: string; + // 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", - prefix: "", 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, }, - { page: "text", prefix: "text", match: (t) => t === "text" }, - { page: "dates-and-times", prefix: "", match: (t) => /date|time/.test(t) }, ]; -// One row per operator-function, keyed by the domain capability that enables it. -// Order matches the hand-written tables (equality, range, containment, aggregate). -const FUNCTION_ROWS: { fns: string; equiv: string; capability: string }[] = [ +// 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 grouped card: same +// capability, same domains, same example shape. +interface FuncDef { + kind: string; + name: string; + ops: string[]; + cap: string; + agg?: boolean; + grouped?: boolean; +} +const FUNCS: FuncDef[] = [ + { kind: "eq", name: "eql_v3.eq(a, b)", ops: ["="], cap: "equality" }, + { kind: "neq", name: "eql_v3.neq(a, b)", ops: ["<>"], cap: "equality" }, + { + kind: "cmp", + name: "eql_v3.lt / lte / gt / gte", + ops: ["<", "<=", ">", ">="], + cap: "order", + grouped: true, + }, { - fns: "`eql_v3.eq(a, b)` / `eql_v3.neq(a, b)`", - equiv: "`=` / `<>`", - capability: "equality", + kind: "contains", + name: "eql_v3.contains(a, b)", + ops: ["@>"], + cap: "match", }, { - fns: "`eql_v3.lt` / `lte` / `gt` / `gte`", - equiv: "`<` `<=` `>` `>=`", - capability: "order", + kind: "contained_by", + name: "eql_v3.contained_by(a, b)", + ops: ["<@"], + cap: "match", }, { - fns: "`eql_v3.contains(a, b)` / `eql_v3.contained_by(a, b)`", - equiv: "`@>` / `<@`", - capability: "match", + kind: "min", + name: "eql_v3.min(col)", + ops: ["MIN"], + cap: "order", + agg: true, }, { - fns: "`eql_v3.min(col)` / `eql_v3.max(col)`", - equiv: "aggregate `MIN` / `MAX`", - capability: "order", + kind: "max", + name: "eql_v3.max(col)", + ops: ["MAX"], + cap: "order", + agg: true, }, ]; -// Collapse the variants that expose a capability into a readable "Available on" -// cell: variants sharing a base (`ord`, `ord_ope`, `ord_ore`) render as "all -// `_ord` variants"; a lone variant renders as its own token. -function availabilityCell( - domains: Domain[], - capability: string, - prefix: string, -): string { - const variants = new Set( - domains - .filter((d) => d.capabilities.includes(capability) && d.variant) - .map((d) => d.variant), - ); - const baseOf = (v: string) => - v === "eq" - ? "eq" - : v.startsWith("ord") - ? "ord" - : v.startsWith("search") - ? "search" - : v === "match" - ? "match" - : v; - const byBase = new Map>(); - for (const v of variants) { - const b = baseOf(v); - (byBase.get(b) ?? byBase.set(b, new Set()).get(b))?.add(v); - } - const parts: string[] = []; - for (const base of ["eq", "ord", "match", "search"]) { - const members = byBase.get(base); - if (!members) continue; - const token = prefix ? `${prefix}_${base}` : `_${base}`; - parts.push(members.size > 1 ? `all \`${token}\` variants` : `\`${token}\``); +// `public.eql_v3_text_eq` → `text_eq`. +const shortDomain = (name: string) => name.replace(/^public\.(eql_v3_)?/, ""); + +// The example for one function, templated from the page's illustrative context. +// The `::public.eql_v3__` casts use real domain names, so they +// stay correct; the table and column names are illustrative. +function exampleFor(kind: string, spec: FragmentSpec): string { + const { table, col, castType } = spec; + const dom = (variant: string) => `public.eql_v3_${castType}_${variant}`; + switch (kind) { + case "eq": + return `SELECT * FROM ${table}\nWHERE eql_v3.eq(${col}, $1::${dom("eq")});`; + case "neq": + return `SELECT * FROM ${table}\nWHERE eql_v3.neq(${col}, $1::${dom("eq")});`; + case "cmp": + 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 "contains": + return `-- token containment on the bloom-filter term\nSELECT * FROM ${table}\nWHERE eql_v3.contains(${col}, $1::${dom("match")});`; + case "contained_by": + return `SELECT * FROM ${table}\nWHERE eql_v3.contained_by(${col}, $1::${dom("match")});`; + case "min": + return `-- compares ordering terms; result decrypts client-side\nSELECT eql_v3.min(${col}) FROM ${table};`; + case "max": + return `SELECT eql_v3.max(${col}) FROM ${table};`; + default: + return ""; } - return parts.join(", "); } 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. The `MIN` / `MAX` aggregates only exist as functions:"; + "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 rows = FUNCTION_ROWS.map((r) => ({ - ...r, - cell: availabilityCell(scoped, r.capability, spec.prefix), - })).filter((r) => r.cell); - const table = [ - "| Function | Equivalent | Available on |", - "| --- | --- | --- |", - ...rows.map((r) => `| ${r.fns} | ${r.equiv} | ${r.cell} |`), - ].join("\n"); - return `${header}\n\n${intro}\n\n${table}\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 = [ + `name="${fn.name}"`, + `ops="${fn.ops.join(",")}"`, + fn.agg ? "agg" : "", + fn.grouped ? "grouped" : "", + `domains="${applies.join(",")}"`, + ] + .filter(Boolean) + .join(" "); + const example = exampleFor(fn.kind, spec); + blocks.push( + `\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 { diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx new file mode 100644 index 0000000..83b3d3c --- /dev/null +++ b/src/components/eql-fn.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; + +interface EqlFnProps { + /** Function name or grouped signature, e.g. `eql_v3.eq(a, b)`. */ + name: string; + /** Comma-separated operator equivalents, e.g. `<,<=,>,>=`. */ + ops: string; + /** Comma-separated short domain names the function applies to. */ + domains?: string; + /** Aggregate function (MIN/MAX): renders an "aggregate" tag, no operator. */ + agg?: boolean; + /** One card standing in for several related functions (the comparison set). */ + grouped?: boolean; + /** How many domains to show before the "Show all" toggle. */ + initial?: number; + /** The example code block. */ + children: React.ReactNode; +} + +/** + * One entry in a generated EQL function reference (see the fragments under + * content/partials/eql, produced by scripts/generate-eql-api-docs.ts). + * + * Renders the function signature, its operator equivalents, and the domains it + * applies to as a card. 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 normal syntax highlighting and copy button. + */ +export function EqlFn({ + name, + ops, + domains, + agg, + grouped, + initial = 2, + children, +}: EqlFnProps) { + const opList = 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); + + return ( +
+
+ + {name} + + + {agg ? ( + + aggregate + + ) : null} + {opList.map((op) => ( + + {op} + + ))} + +
+ + {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, }; From 47a2cf70795039f3869fb602b5135c198480d32e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 09:41:43 +1000 Subject: [PATCH 3/7] docs(eql): drop per-card examples, add deep-link anchors, flatten cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the worked example from each function card; each page's own "Example queries" section already covers examples, so the per-card ones only duplicated them. no longer takes children. - Add a stable anchor id per function (fn-eq, fn-comparison, …) with a hover link affordance, so individual functions are deep-linkable. - Drop the accent border/tint on the grouped comparison card; every card now uses the same neutral card styling. --- .../eql/functions-dates-and-times.mdx | 46 +-------- content/partials/eql/functions-numbers.mdx | 46 +-------- content/partials/eql/functions-text.mdx | 65 ++---------- scripts/generate-eql-api-docs.ts | 98 ++++--------------- src/components/eql-fn.tsx | 38 +++---- 5 files changed, 55 insertions(+), 238 deletions(-) diff --git a/content/partials/eql/functions-dates-and-times.mdx b/content/partials/eql/functions-dates-and-times.mdx index d56fdff..b61d70f 100644 --- a/content/partials/eql/functions-dates-and-times.mdx +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -2,48 +2,12 @@ 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. - + -```sql -SELECT * FROM events -WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq); -``` + - + - + -```sql -SELECT * FROM events -WHERE eql_v3.neq(occurred_at, $1::public.eql_v3_timestamp_eq); -``` - - - - - -```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); -``` - - - - - -```sql --- compares ordering terms; result decrypts client-side -SELECT eql_v3.min(occurred_at) FROM events; -``` - - - - - -```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 index c6affda..870cbc4 100644 --- a/content/partials/eql/functions-numbers.mdx +++ b/content/partials/eql/functions-numbers.mdx @@ -2,48 +2,12 @@ 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. - + -```sql -SELECT * FROM payments -WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq); -``` + - + - + -```sql -SELECT * FROM payments -WHERE eql_v3.neq(amount, $1::public.eql_v3_bigint_eq); -``` - - - - - -```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); -``` - - - - - -```sql --- compares ordering terms; result decrypts client-side -SELECT eql_v3.min(amount) FROM payments; -``` - - - - - -```sql -SELECT eql_v3.max(amount) FROM payments; -``` - - + diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx index c5439d8..894c740 100644 --- a/content/partials/eql/functions-text.mdx +++ b/content/partials/eql/functions-text.mdx @@ -2,67 +2,16 @@ 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. - + -```sql -SELECT * FROM users -WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq); -``` + - + - + -```sql -SELECT * FROM users -WHERE eql_v3.neq(email, $1::public.eql_v3_text_eq); -``` + - + - - -```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); -``` - - - - - -```sql --- token containment on the bloom-filter term -SELECT * FROM users -WHERE eql_v3.contains(email, $1::public.eql_v3_text_match); -``` - - - - - -```sql -SELECT * FROM users -WHERE eql_v3.contained_by(email, $1::public.eql_v3_text_match); -``` - - - - - -```sql --- compares ordering terms; result decrypts client-side -SELECT eql_v3.min(email) FROM users; -``` - - - - - -```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 e1d7f4b..addd7c7 100644 --- a/scripts/generate-eql-api-docs.ts +++ b/scripts/generate-eql-api-docs.ts @@ -223,11 +223,11 @@ function render(manifest: Manifest): string { // ── 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. +// equivalents and the domains it applies to. These are generated from the +// manifest and ``d into the page (via the `` component) rather +// than hand-maintained, where the domain lists silently drifted. Worked +// examples are NOT here — each page's own "Example queries" section covers +// those, so a per-card example would only duplicate them. // // 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 @@ -236,15 +236,6 @@ 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[] = [ @@ -252,72 +243,52 @@ 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, }, + { page: "text", match: (t) => t === "text" }, + { page: "dates-and-times", match: (t) => /date|time/.test(t) }, ]; // 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 grouped card: same -// capability, same domains, same example shape. +// with that capability. `lt`/`lte`/`gt`/`gte` are one card: same capability, +// same domains. `id` is the deep-link anchor. interface FuncDef { - kind: string; + id: string; name: string; ops: string[]; cap: string; agg?: boolean; - grouped?: boolean; } const FUNCS: FuncDef[] = [ - { kind: "eq", name: "eql_v3.eq(a, b)", ops: ["="], cap: "equality" }, - { kind: "neq", name: "eql_v3.neq(a, b)", ops: ["<>"], cap: "equality" }, + { id: "fn-eq", name: "eql_v3.eq(a, b)", ops: ["="], cap: "equality" }, + { id: "fn-neq", name: "eql_v3.neq(a, b)", ops: ["<>"], cap: "equality" }, { - kind: "cmp", + id: "fn-comparison", name: "eql_v3.lt / lte / gt / gte", ops: ["<", "<=", ">", ">="], cap: "order", - grouped: true, }, { - kind: "contains", + id: "fn-contains", name: "eql_v3.contains(a, b)", ops: ["@>"], cap: "match", }, { - kind: "contained_by", + id: "fn-contained_by", name: "eql_v3.contained_by(a, b)", ops: ["<@"], cap: "match", }, { - kind: "min", + id: "fn-min", name: "eql_v3.min(col)", ops: ["MIN"], cap: "order", agg: true, }, { - kind: "max", + id: "fn-max", name: "eql_v3.max(col)", ops: ["MAX"], cap: "order", @@ -328,34 +299,6 @@ const FUNCS: FuncDef[] = [ // `public.eql_v3_text_eq` → `text_eq`. const shortDomain = (name: string) => name.replace(/^public\.(eql_v3_)?/, ""); -// The example for one function, templated from the page's illustrative context. -// The `::public.eql_v3__` casts use real domain names, so they -// stay correct; the table and column names are illustrative. -function exampleFor(kind: string, spec: FragmentSpec): string { - const { table, col, castType } = spec; - const dom = (variant: string) => `public.eql_v3_${castType}_${variant}`; - switch (kind) { - case "eq": - return `SELECT * FROM ${table}\nWHERE eql_v3.eq(${col}, $1::${dom("eq")});`; - case "neq": - return `SELECT * FROM ${table}\nWHERE eql_v3.neq(${col}, $1::${dom("eq")});`; - case "cmp": - 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 "contains": - return `-- token containment on the bloom-filter term\nSELECT * FROM ${table}\nWHERE eql_v3.contains(${col}, $1::${dom("match")});`; - case "contained_by": - return `SELECT * FROM ${table}\nWHERE eql_v3.contained_by(${col}, $1::${dom("match")});`; - case "min": - return `-- compares ordering terms; result decrypts client-side\nSELECT eql_v3.min(${col}) FROM ${table};`; - case "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. */}`; @@ -372,18 +315,15 @@ function renderFragment(domains: Domain[], spec: FragmentSpec): string { .map((d) => shortDomain(d.name)); if (!applies.length) continue; const attrs = [ + `id="${fn.id}"`, `name="${fn.name}"`, `ops="${fn.ops.join(",")}"`, fn.agg ? "agg" : "", - fn.grouped ? "grouped" : "", `domains="${applies.join(",")}"`, ] .filter(Boolean) .join(" "); - const example = exampleFor(fn.kind, spec); - blocks.push( - `\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`, - ); + blocks.push(``); } return `${header}\n\n${intro}\n\n${blocks.join("\n\n")}\n`; diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx index 83b3d3c..d1d7d11 100644 --- a/src/components/eql-fn.tsx +++ b/src/components/eql-fn.tsx @@ -1,22 +1,21 @@ "use client"; +import { Link2 } from "lucide-react"; import { useState } from "react"; interface EqlFnProps { /** Function name or grouped signature, e.g. `eql_v3.eq(a, b)`. */ name: string; + /** Stable anchor id for deep linking, e.g. `fn-eq`. */ + id: string; /** Comma-separated operator equivalents, e.g. `<,<=,>,>=`. */ ops: string; /** Comma-separated short domain names the function applies to. */ domains?: string; /** Aggregate function (MIN/MAX): renders an "aggregate" tag, no operator. */ agg?: boolean; - /** One card standing in for several related functions (the comparison set). */ - grouped?: boolean; /** How many domains to show before the "Show all" toggle. */ initial?: number; - /** The example code block. */ - children: React.ReactNode; } /** @@ -24,19 +23,18 @@ interface EqlFnProps { * content/partials/eql, produced by scripts/generate-eql-api-docs.ts). * * Renders the function signature, its operator equivalents, and the domains it - * applies to as a card. 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 normal syntax highlighting and copy button. + * applies to. 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. Worked examples live in each page's own + * "Example queries" section, not here. */ export function EqlFn({ name, + id, ops, domains, agg, - grouped, initial = 2, - children, }: EqlFnProps) { const opList = ops.split(",").filter(Boolean); const domainList = domains ? domains.split(",").filter(Boolean) : []; @@ -46,16 +44,20 @@ export function EqlFn({ return (
- + {name} - + + {agg ? ( @@ -100,8 +102,6 @@ export function EqlFn({ ) : null}
) : null} - -
{children}
); } From b1f5b162088e2ae67d67d6d104471658ec6e900e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 09:50:11 +1000 Subject: [PATCH 4/7] docs(eql): keep examples in the function cards, drop the page Examples section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting the previous commit, which removed the examples from the wrong place. The per-function example belongs in each card; it's the page-level "## Example queries" section that was redundant with them. - Restore the worked example inside (passed as children, so it keeps the site's highlighting and copy button); the generator templates it by capability again. - Remove the "## Example queries" section from the text, numbers, and dates-and-times pages — the per-function cards now carry the examples. --- .../docs/reference/eql/dates-and-times.mdx | 36 --------- content/docs/reference/eql/numbers.mdx | 39 ---------- content/docs/reference/eql/text.mdx | 52 ------------- .../eql/functions-dates-and-times.mdx | 46 ++++++++++-- content/partials/eql/functions-numbers.mdx | 46 ++++++++++-- content/partials/eql/functions-text.mdx | 65 ++++++++++++++-- scripts/generate-eql-api-docs.ts | 74 +++++++++++++++++-- src/components/eql-fn.tsx | 15 ++-- 8 files changed, 216 insertions(+), 157 deletions(-) diff --git a/content/docs/reference/eql/dates-and-times.mdx b/content/docs/reference/eql/dates-and-times.mdx index ef1d7f0..c8ffebf 100644 --- a/content/docs/reference/eql/dates-and-times.mdx +++ b/content/docs/reference/eql/dates-and-times.mdx @@ -97,42 +97,6 @@ Blocked *operator* cells raise an `operator … is not supported` exception — content/partials/eql/functions-dates-and-times.mdx -## 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; -``` - ## Where to next diff --git a/content/docs/reference/eql/numbers.mdx b/content/docs/reference/eql/numbers.mdx index 2c6da9e..b2f1cac 100644 --- a/content/docs/reference/eql/numbers.mdx +++ b/content/docs/reference/eql/numbers.mdx @@ -105,45 +105,6 @@ Blocked *operator* cells raise an `operator … is not supported` exception — **`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 98c144d..af48ec4 100644 --- a/content/docs/reference/eql/text.mdx +++ b/content/docs/reference/eql/text.mdx @@ -119,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 index b61d70f..295c995 100644 --- a/content/partials/eql/functions-dates-and-times.mdx +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -2,12 +2,48 @@ 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. - + - +```sql +SELECT * FROM events +WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` - + - + - +```sql +SELECT * FROM events +WHERE eql_v3.neq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + + + +```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); +``` + + + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(occurred_at) FROM events; +``` + + + + + +```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 index 870cbc4..89d9b24 100644 --- a/content/partials/eql/functions-numbers.mdx +++ b/content/partials/eql/functions-numbers.mdx @@ -2,12 +2,48 @@ 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. - + - +```sql +SELECT * FROM payments +WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq); +``` - + - + - +```sql +SELECT * FROM payments +WHERE eql_v3.neq(amount, $1::public.eql_v3_bigint_eq); +``` + + + + + +```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); +``` + + + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(amount) FROM payments; +``` + + + + + +```sql +SELECT eql_v3.max(amount) FROM payments; +``` + + diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx index 894c740..6a50b0c 100644 --- a/content/partials/eql/functions-text.mdx +++ b/content/partials/eql/functions-text.mdx @@ -2,16 +2,67 @@ 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. - + - +```sql +SELECT * FROM users +WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq); +``` - + - + - +```sql +SELECT * FROM users +WHERE eql_v3.neq(email, $1::public.eql_v3_text_eq); +``` - + - + + +```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); +``` + + + + + +```sql +-- token containment on the bloom-filter term +SELECT * FROM users +WHERE eql_v3.contains(email, $1::public.eql_v3_text_match); +``` + + + + + +```sql +SELECT * FROM users +WHERE eql_v3.contained_by(email, $1::public.eql_v3_text_match); +``` + + + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(email) FROM users; +``` + + + + + +```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 addd7c7..b6c5656 100644 --- a/scripts/generate-eql-api-docs.ts +++ b/scripts/generate-eql-api-docs.ts @@ -223,11 +223,11 @@ function render(manifest: Manifest): string { // ── 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 and the domains it applies to. These are generated from the -// manifest and ``d into the page (via the `` component) rather -// than hand-maintained, where the domain lists silently drifted. Worked -// examples are NOT here — each page's own "Example queries" section covers -// those, so a per-card example would only duplicate them. +// 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 @@ -236,6 +236,15 @@ 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[] = [ @@ -243,9 +252,27 @@ 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, }, - { page: "text", match: (t) => t === "text" }, - { page: "dates-and-times", match: (t) => /date|time/.test(t) }, ]; // The EQL function set, in reading order. `cap` is the domain capability that @@ -299,6 +326,34 @@ const FUNCS: FuncDef[] = [ // `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. */}`; @@ -323,7 +378,10 @@ function renderFragment(domains: Domain[], spec: FragmentSpec): string { ] .filter(Boolean) .join(" "); - blocks.push(``); + const example = exampleFor(fn.id, spec); + blocks.push( + `\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`, + ); } return `${header}\n\n${intro}\n\n${blocks.join("\n\n")}\n`; diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx index d1d7d11..d32c431 100644 --- a/src/components/eql-fn.tsx +++ b/src/components/eql-fn.tsx @@ -16,17 +16,19 @@ interface EqlFnProps { agg?: boolean; /** How many domains to show before the "Show all" toggle. */ initial?: number; + /** The worked example (a fenced code block). */ + children: React.ReactNode; } /** * One entry in a generated EQL function reference (see the fragments under * content/partials/eql, produced by scripts/generate-eql-api-docs.ts). * - * Renders the function signature, its operator equivalents, and the domains it - * applies to. 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. Worked examples live in each page's own - * "Example queries" section, not here. + * Renders the function signature, its operator equivalents, the domains it + * applies to, and a worked 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({ name, @@ -35,6 +37,7 @@ export function EqlFn({ domains, agg, initial = 2, + children, }: EqlFnProps) { const opList = ops.split(",").filter(Boolean); const domainList = domains ? domains.split(",").filter(Boolean) : []; @@ -102,6 +105,8 @@ export function EqlFn({ ) : null} ) : null} + +
{children}
); } From f6a126521e181bc50deeb50d0bdfde4b032d1bdf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 10:04:24 +1000 Subject: [PATCH 5/7] docs(eql): make each function a heading so it appears in the page nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each function's name is now a real `###` heading (with an explicit `[#fn-*]` id) instead of text inside the card. The table of contents only sees Markdown headings, so this lists every function as a sub-item under "Functions" in the right-hand nav, and keeps the stable deep-link anchor. The card renders everything below the heading — operator equivalents (now under an "Operators"/"Aggregate" label), the applies-to domains, and the example. It no longer takes name/id props or renders its own anchor, since the heading owns both. --- .../eql/functions-dates-and-times.mdx | 20 ++++-- content/partials/eql/functions-numbers.mdx | 20 ++++-- content/partials/eql/functions-text.mdx | 28 ++++++-- scripts/generate-eql-api-docs.ts | 7 +- src/components/eql-fn.tsx | 68 +++++++------------ 5 files changed, 79 insertions(+), 64 deletions(-) diff --git a/content/partials/eql/functions-dates-and-times.mdx b/content/partials/eql/functions-dates-and-times.mdx index 295c995..581efbf 100644 --- a/content/partials/eql/functions-dates-and-times.mdx +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -2,7 +2,9 @@ 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 @@ -11,7 +13,9 @@ WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq); - +### eql_v3.neq(a, b) [#fn-neq] + + ```sql SELECT * FROM events @@ -20,7 +24,9 @@ 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 @@ -31,7 +37,9 @@ WHERE eql_v3.gte(occurred_at, $1::public.eql_v3_timestamp_ord) - +### eql_v3.min(col) [#fn-min] + + ```sql -- compares ordering terms; result decrypts client-side @@ -40,7 +48,9 @@ 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 index 89d9b24..357c8fa 100644 --- a/content/partials/eql/functions-numbers.mdx +++ b/content/partials/eql/functions-numbers.mdx @@ -2,7 +2,9 @@ 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 @@ -11,7 +13,9 @@ WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq); - +### eql_v3.neq(a, b) [#fn-neq] + + ```sql SELECT * FROM payments @@ -20,7 +24,9 @@ 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 @@ -31,7 +37,9 @@ WHERE eql_v3.gte(amount, $1::public.eql_v3_bigint_ord) - +### eql_v3.min(col) [#fn-min] + + ```sql -- compares ordering terms; result decrypts client-side @@ -40,7 +48,9 @@ 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 index 6a50b0c..ff415c7 100644 --- a/content/partials/eql/functions-text.mdx +++ b/content/partials/eql/functions-text.mdx @@ -2,7 +2,9 @@ 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 @@ -11,7 +13,9 @@ WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq); - +### eql_v3.neq(a, b) [#fn-neq] + + ```sql SELECT * FROM users @@ -20,7 +24,9 @@ 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 @@ -31,7 +37,9 @@ ORDER BY eql_v3.ord_term(email); - +### eql_v3.contains(a, b) [#fn-contains] + + ```sql -- token containment on the bloom-filter term @@ -41,7 +49,9 @@ WHERE eql_v3.contains(email, $1::public.eql_v3_text_match); - +### eql_v3.contained_by(a, b) [#fn-contained_by] + + ```sql SELECT * FROM users @@ -50,7 +60,9 @@ 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 @@ -59,7 +71,9 @@ 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 b6c5656..609954d 100644 --- a/scripts/generate-eql-api-docs.ts +++ b/scripts/generate-eql-api-docs.ts @@ -370,8 +370,6 @@ function renderFragment(domains: Domain[], spec: FragmentSpec): string { .map((d) => shortDomain(d.name)); if (!applies.length) continue; const attrs = [ - `id="${fn.id}"`, - `name="${fn.name}"`, `ops="${fn.ops.join(",")}"`, fn.agg ? "agg" : "", `domains="${applies.join(",")}"`, @@ -379,8 +377,11 @@ function renderFragment(domains: Domain[], spec: FragmentSpec): string { .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( - `\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`, + `### ${fn.name} [#${fn.id}]\n\n\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`, ); } diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx index d32c431..98d5cd6 100644 --- a/src/components/eql-fn.tsx +++ b/src/components/eql-fn.tsx @@ -1,18 +1,13 @@ "use client"; -import { Link2 } from "lucide-react"; import { useState } from "react"; interface EqlFnProps { - /** Function name or grouped signature, e.g. `eql_v3.eq(a, b)`. */ - name: string; - /** Stable anchor id for deep linking, e.g. `fn-eq`. */ - id: string; /** Comma-separated operator equivalents, e.g. `<,<=,>,>=`. */ ops: string; /** Comma-separated short domain names the function applies to. */ domains?: string; - /** Aggregate function (MIN/MAX): renders an "aggregate" tag, no operator. */ + /** Aggregate function (MIN/MAX): labels the row "Aggregate". */ agg?: boolean; /** How many domains to show before the "Show all" toggle. */ initial?: number; @@ -21,18 +16,19 @@ interface EqlFnProps { } /** - * One entry in a generated EQL function reference (see the fragments under - * content/partials/eql, produced by scripts/generate-eql-api-docs.ts). + * 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). * - * Renders the function signature, its operator equivalents, the domains it - * applies to, and a worked 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. + * 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({ - name, - id, ops, domains, agg, @@ -44,38 +40,22 @@ export function EqlFn({ 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 ( -
-
- - {name} - - - - {agg ? ( - - aggregate - - ) : null} - {opList.map((op) => ( - - {op} - - ))} +
+
+ + {label} + {opList.map((op) => ( + + {op} + + ))}
{domainList.length > 0 ? ( From f3af667ff8442152be9c09a4adf925805555d7c0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 10:26:34 +1000 Subject: [PATCH 6/7] docs(eql): give the JSON page the per-function card treatment The JSON page's query surface (containment, field access, leaf comparisons, path queries, array helpers) was prose spread across three sections. Restructure it into a "## Functions" section of cards under `###` headings, matching the scalar type pages: each function is now a TOC sub-item under Functions and a deep-link anchor. Unlike the scalar pages, JSON's functions aren't derivable from domain capabilities (the jsonb domains are storage-only), so these cards are authored directly in json.mdx rather than generated. json.mdx is scanned by the drift guard, so every eql_v3.* symbol referenced is still validated against the manifest. - Make 's `ops` optional so pure functions (jsonb_path_query, the array helpers) render without an operator row. - Preserve the GIN containment index recipe and the selector-hash / typed-operand notes as prose within the new section. --- content/docs/reference/eql/json.mdx | 103 ++++++++++++++++++---------- src/components/eql-fn.tsx | 35 ++++++---- 2 files changed, 86 insertions(+), 52 deletions(-) diff --git a/content/docs/reference/eql/json.mdx b/content/docs/reference/eql/json.mdx index 7253a6f..d49cca2 100644 --- a/content/docs/reference/eql/json.mdx +++ b/content/docs/reference/eql/json.mdx @@ -111,85 +111,114 @@ 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. + +### 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] + +Range comparisons and `ORDER BY` on an extracted **String or Number** leaf, via `eql_v3.ord_term`. + + + +```sql +SELECT * FROM orders +WHERE (metadata -> 'total_selector'::text) > $1::public.eql_v3_jsonb_entry; +``` + + + +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). -## Path queries and array helpers +### eql_v3.min / max [#fn-min-max] -The function forms take the same selector hashes: +`MIN` / `MAX` over an extracted ordered leaf. + + ```sql --- All entries matching a selector -SELECT eql_v3.jsonb_path_query(metadata, 'selector_hash') FROM orders; +SELECT eql_v3.min(metadata -> 'total_selector'::text) FROM orders; +``` + + --- First match only -SELECT eql_v3.jsonb_path_query_first(metadata, 'selector_hash') FROM orders; +### eql_v3.jsonb_path_query [#fn-path-query] --- Does the selector exist in this document? -SELECT eql_v3.jsonb_path_exists(metadata, 'selector_hash') FROM orders; +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 ``` -For encrypted array nodes: + + +### 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/src/components/eql-fn.tsx b/src/components/eql-fn.tsx index 98d5cd6..5af3af7 100644 --- a/src/components/eql-fn.tsx +++ b/src/components/eql-fn.tsx @@ -3,8 +3,11 @@ import { useState } from "react"; interface EqlFnProps { - /** Comma-separated operator equivalents, e.g. `<,<=,>,>=`. */ - ops: string; + /** + * 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". */ @@ -35,7 +38,7 @@ export function EqlFn({ initial = 2, children, }: EqlFnProps) { - const opList = ops.split(",").filter(Boolean); + 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); @@ -44,19 +47,21 @@ export function EqlFn({ return (
-
- - {label} - - {opList.map((op) => ( - - {op} + {opList.length > 0 ? ( +
+ + {label} - ))} -
+ {opList.map((op) => ( + + {op} + + ))} +
+ ) : null} {domainList.length > 0 ? (
From 36dcbedf5422acc2cd6881711387e024c302931c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 11:01:49 +1000 Subject: [PATCH 7/7] docs(eql): add a shared plaintext document to the JSON functions section Show the plaintext JSON the function examples query against, right under the Functions heading, and map the `*_selector` placeholders to their paths. Readers can see what each function operates on instead of inferring it from the selector names. --- content/docs/reference/eql/json.mdx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/content/docs/reference/eql/json.mdx b/content/docs/reference/eql/json.mdx index d49cca2..e2aea00 100644 --- a/content/docs/reference/eql/json.mdx +++ b/content/docs/reference/eql/json.mdx @@ -115,6 +115,21 @@ Use containment (`@>` / `<@`), field access (`->` / `->>`), or the `eql_v3.jsonb 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.