diff --git a/src/engine/run.ts b/src/engine/run.ts index ec89d81..45a13d5 100644 --- a/src/engine/run.ts +++ b/src/engine/run.ts @@ -2,6 +2,7 @@ // written against this and never changes — each workshop step fills in the // internals so runQuery() starts returning real results instead of NotImplemented. import { synthesize } from "../synthesis/synthesize.js"; +import { validatePlan } from "../validation/validate.js"; import { executePlan } from "./execute.js"; /** A synthesized query plan: which collection to run against, and the aggregation pipeline. */ @@ -10,11 +11,17 @@ export interface QueryPlan { pipeline: Record[]; } +/** One synthesis attempt: the plan, and why it was rejected (empty errors = it passed validation). */ +export interface Attempt { + plan: QueryPlan; + errors: string[]; +} + /** The result of running a natural-language query through the engine. */ export type Outcome = - | { type: "Success"; rows: Record[]; plan: QueryPlan; attempts: number } - | { type: "NoResults"; plan: QueryPlan; attempts: number } - | { type: "MaxAttemptsExceeded"; errors: string[]; plan: QueryPlan; attempts: number } + | { type: "Success"; rows: Record[]; plan: QueryPlan; attempts: number; trace: Attempt[] } + | { type: "NoResults"; plan: QueryPlan; attempts: number; trace: Attempt[] } + | { type: "MaxAttemptsExceeded"; errors: string[]; plan: QueryPlan; attempts: number; trace: Attempt[] } | { type: "ResolutionFailed"; reason: string; attempts: number } | { type: "NotImplemented"; message: string }; @@ -29,7 +36,7 @@ export interface RunOptions { /** * Turn a natural-language question into a MongoDB query, run it, return the outcome. - * Step 3: synthesize -> execute -> format. (Validation and retry come in steps 4-5.) + * Step 4: synthesize -> validate -> execute. (The retry loop comes in step 5.) */ export async function runQuery(query: string, opts: RunOptions = {}): Promise { let plan: QueryPlan; @@ -40,10 +47,19 @@ export async function runQuery(query: string, opts: RunOptions = {}): Promise { + const card = document.createElement("div"); + card.className = `attempt ${a.errors.length ? "rejected" : "accepted"}`; + const head = document.createElement("div"); + head.className = "attempt-head"; + head.textContent = `Attempt ${i + 1} — ${a.errors.length ? "✗ rejected by validation" : "✓ valid"}`; + const pre = document.createElement("pre"); + pre.className = "output code"; + pre.textContent = JSON.stringify(a.plan.pipeline); + card.append(head, pre); + if (a.errors.length) { + const err = document.createElement("div"); + err.className = "attempt-err"; + err.textContent = a.errors.join(" · "); + card.appendChild(err); + } + queryTrace.appendChild(card); + }); +} queryForm.addEventListener("submit", async (e) => { e.preventDefault(); @@ -30,12 +60,14 @@ queryForm.addEventListener("submit", async (e) => { queryPlan.textContent = "…"; queryRows.textContent = ""; queryMeta.textContent = ""; + queryTrace.innerHTML = ""; const outcome = await (await fetch("/api/query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question }), })).json(); + renderTrace(outcome.trace); queryPlan.textContent = outcome.plan ? `${outcome.plan.collection}\n${JSON.stringify(outcome.plan.pipeline, null, 2)}` : "(no plan)"; diff --git a/src/public/index.html b/src/public/index.html index 2d67b2b..01d9eae 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -27,6 +27,7 @@

Document Query Engine

Discovered document schema — what the synthesizer is told
Loading…
+

Generated pipeline

diff --git a/src/public/styles.css b/src/public/styles.css index 2b832ba..a99f4c9 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -98,6 +98,22 @@ label { color: var(--muted); font-size: 13px; display: flex; gap: 8px; align-ite .schema[open] summary { color: var(--accent); } .schema .output { margin-top: 8px; } +.trace { margin-bottom: 16px; } +.trace h3 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin: 0 0 8px; } +.attempt { + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 8px; + background: var(--panel); +} +.attempt.rejected { border-left-color: #f85149; } +.attempt.accepted { border-left-color: var(--green); } +.attempt-head { font-size: 13px; color: var(--fg); margin-bottom: 6px; } +.attempt .output { margin: 0; max-height: 120px; padding: 8px 10px; } +.attempt-err { color: #f85149; font-size: 12.5px; font-family: "SF Mono", ui-monospace, monospace; margin-top: 6px; } + .query-result { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .query-result h3 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } #query-meta { color: var(--green); text-transform: none; letter-spacing: 0; } diff --git a/src/validation/validate.ts b/src/validation/validate.ts new file mode 100644 index 0000000..2f4c552 --- /dev/null +++ b/src/validation/validate.ts @@ -0,0 +1,138 @@ +import { COLLECTIONS, type CollectionName } from "../models/index.js"; +import { collectFieldPaths } from "../schema/describe.js"; +import type { QueryPlan } from "../engine/run.js"; + +const ALLOWED_STAGES = new Set([ + "$match", + "$sort", + "$limit", + "$count", + "$group", + "$project", + "$unwind", +]); + +const ALLOWED_OPERATORS = new Set([ + // query operators + "$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", + "$and", "$or", "$not", "$nor", "$exists", "$size", "$elemMatch", + // accumulators / group expressions + "$sum", "$avg", "$min", "$max", "$first", "$last", "$push", "$addToSet", +]); + +/** Field paths a query may never reference. */ +function isBlocked(path: string): boolean { + return path === "_id" || path === "metadata" || path.startsWith("metadata."); +} + +export type ValidationResult = { ok: true } | { ok: false; errors: string[] }; + +/** + * Statically validates a query plan before execution: known collection, only + * allow-listed stages and operators, and every referenced field exists in the + * schema and isn't blocked. Returns structured errors so the retry loop (Step 5) + * can feed them back to the model. + */ +export async function validatePlan(plan: QueryPlan): Promise { + const errors: string[] = []; + + if (!(plan.collection in COLLECTIONS)) { + return { ok: false, errors: [`Unknown collection "${plan.collection}".`] }; + } + if (!Array.isArray(plan.pipeline) || plan.pipeline.length === 0) { + return { ok: false, errors: ["Pipeline must be a non-empty array of stages."] }; + } + + const fields = await collectFieldPaths(plan.collection as CollectionName); + + // $group/$project rebuild the document shape, so downstream stages reference + // computed fields (e.g. a $sum alias) that aren't in the source schema. We only + // validate field existence against the schema up to the first reshaping stage. + let reshaped = false; + + for (const stage of plan.pipeline) { + const keys = Object.keys(stage); + if (keys.length !== 1) { + errors.push(`Each stage must have exactly one operator; got [${keys.join(", ")}].`); + continue; + } + const stageName = keys[0]!; + if (!ALLOWED_STAGES.has(stageName)) { + errors.push(`Stage "${stageName}" is not allowed.`); + continue; + } + checkOperators(stage[stageName], errors); + if (!reshaped) checkFields(stageName, stage[stageName], fields, errors); + if (stageName === "$group" || stageName === "$project") reshaped = true; + } + + return errors.length ? { ok: false, errors } : { ok: true }; +} + +/** Recursively rejects any $-prefixed key that isn't an allow-listed operator/accumulator. */ +function checkOperators(value: unknown, errors: string[]): void { + if (Array.isArray(value)) { + value.forEach((v) => checkOperators(v, errors)); + return; + } + if (value && typeof value === "object") { + for (const [key, v] of Object.entries(value)) { + if (key.startsWith("$") && !ALLOWED_OPERATORS.has(key)) { + errors.push(`Operator "${key}" is not allowed.`); + } + checkOperators(v, errors); + } + } +} + +/** Validates field references for the field-bearing stages. */ +function checkFields(stage: string, value: unknown, fields: Set, errors: string[]): void { + const check = (path: string) => { + if (isBlocked(path)) errors.push(`Field "${path}" is not accessible.`); + else if (!fields.has(path)) errors.push(`Unknown field "${path}" in ${stage}.`); + }; + + switch (stage) { + case "$match": + collectMatchFields(value).forEach(check); + break; + case "$sort": + // _id is always a valid sort key (document order). Other keys must be real fields. + if (value && typeof value === "object") Object.keys(value).filter((k) => k !== "_id").forEach(check); + break; + // $project is intentionally not field-checked: it renames/computes output fields + // (e.g. {login: "$user.login"}), so its keys are new names, not source paths. + case "$unwind": + if (typeof value === "string") check(value.replace(/^\$/, "")); + else if (value && typeof value === "object" && "path" in value) + check(String((value as { path: unknown }).path).replace(/^\$/, "")); + break; + default: + break; // $limit, $count, $group keys are not document field paths + } +} + +/** Pulls dotted document field paths out of a $match expression, descending through $and/$or/$not and nested docs. */ +function collectMatchFields(value: unknown): string[] { + const out: string[] = []; + const isPlainDoc = (v: unknown): v is Record => + !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).every((k) => !k.startsWith("$")); + + const visit = (v: unknown, prefix: string) => { + if (Array.isArray(v)) return v.forEach((x) => visit(x, prefix)); + if (v && typeof v === "object") { + for (const [key, child] of Object.entries(v)) { + if (key.startsWith("$")) { + visit(child, prefix); // logical operator — recurse, keep prefix + } else { + const path = prefix ? `${prefix}.${key}` : key; + // A nested doc value (e.g. {user: {login: "x"}}) extends the dotted path. + if (isPlainDoc(child)) visit(child, path); + else out.push(path); + } + } + } + }; + visit(value, ""); + return out; +}