A QueryForge configuration is a single JSON file. It does four jobs at once: it is the prompt context the model sees, the validation rulebook, the logical→physical field mapping per backend, and the model selector. This page documents every option so you can write your own.
SELECT, find). There is no operator or config option that can write, update, or delete data. The library returns a query string/document; it never connects to or executes against your database.AST stands for Abstract Syntax Tree. In QueryForge it is a plain JSON object describing what the user asked for, sitting between the English sentence and the finished database query. You will see the term throughout this page and in the API responses, so it is worth two minutes.
The name decodes literally:
A AND (B OR C) is a branching structure, not a flat list, so a logical node holds children, and each child may itself be another logical node."operator": "gt", never > and never $gt. It carries the meaning, stripped of any one database's syntax.Ask: "orders over 500 dollars in the last 30 days that were not cancelled, newest first, top 20". The model produces this — and nothing else:
{
"entity": "Order",
"filter": {
"type": "logical", "op": "AND",
"children": [
{ "type": "comparison", "field": "amount", "operator": "gt", "value": {"kind":"number","v":500} },
{ "type": "comparison", "field": "createdAt", "operator": "after", "value": {"kind":"relative_date","unit":"day","amount":-30} },
{ "type": "comparison", "field": "status", "operator": "notEquals", "value": {"kind":"enum","v":"CANCELLED"} }
]
},
"sort": [{ "field": "createdAt", "dir": "DESC" }],
"limit": 20
}
That single object then compiles deterministically into both of these:
SELECT … FROM orders
WHERE (status <> $1 AND created_at >= $2 AND amount > $3)
ORDER BY created_at DESC LIMIT 20
{ amount: {$gt: 500}, createdAt: {$gte: "…"}, status: {$ne: "CANCELLED"} }
Same AST, two databases, no second prompt. Adding a backend means writing one generator — it never means rewriting the prompt.
This is the central design decision, so the alternative deserves a straight answer. A model asked for SQL directly can invent a column, invent a table, or emit a statement you cannot check before running it. There is no good place to ask "is this user even allowed to filter on that field?", and every new dialect means a new prompt.
With an AST in the middle, the model's job shrinks to filling in a form. Everything after that is ordinary, testable Go:
$1, $2) rather than pasted into the statement, so a string like Robert'); DROP TABLE orders;-- arrives as data and can never execute.The root object accepts these keys. Everything is optional except entity; omit what you do not need.
| Key | Type | Meaning |
|---|---|---|
entity | string | What is being queried. Must match the config's entity. |
version | string | AST schema version, currently "1.0". Defaulted when omitted. |
filter | object | Root of the predicate tree. Omitted = match everything. |
sort | array | Ordering clauses, applied in order: {"field":"…","dir":"ASC"|"DESC"}. |
limit | number | Maximum rows. Falls back to the config's defaults.limit, capped by maxLimit. |
offset | number | Rows to skip, for pagination. |
select | string[] | Which fields to return. Omitted = all returnable fields. |
Inside filter, every node is one of exactly two shapes:
type | Shape | Purpose |
|---|---|---|
comparison | {"type":"comparison","field":…,"operator":…,"value":{"kind":…,"v":…}} | A leaf: one test against one field. See operators and value kinds. |
logical | {"type":"logical","op":"AND"|"OR"|"NOT","children":[…]} | A branch: combines other nodes. Children may be either shape, which is what makes it a tree. |
Every /api/translate response includes the AST it produced, in the "ast" field. The companion /api/generate endpoint accepts one directly and makes no model call at all — so it needs no API key and always returns the same query for the same input. Take the ast from a translate response, post it to generate with a different backend, and watch one object become the other dialect.
A config has six top-level keys. Only entity and fields are strictly required, but a real config also sets model (to translate natural language) and backends (to map to physical tables/collections).
| Key | Type | Required | Purpose |
|---|---|---|---|
entity | string | yes | Logical name of the thing being queried (e.g. Order). Must match the AST's entity. |
version | number | no | Your config's version, for your own schema-versioning/migrations. |
model | object | for NL | Which AI model to call. See model. |
backends | object | no | Physical source per backend. See backends. |
fields | array | yes | The queryable attributes. See fields. |
defaults | object | no | Default/max result window. See defaults. |
policy | object | no | Safety guardrails. See policy. |
The loader is strict: an unknown key anywhere in the file is rejected, so typos surface immediately rather than being silently ignored. JSON only (no comments).
Selects the AI planner target. Switching provider or model is a config change, never a code change — any OpenAI-compatible /chat/completions endpoint works (hosted or self-hosted).
| Key | Type | Default | Description |
|---|---|---|---|
provider | string | "" | Informational label (e.g. gemini, groq, ollama). |
baseURL | string | "" | Endpoint root. /chat/completions is appended. E.g. Gemini: https://generativelanguage.googleapis.com/v1beta/openai. |
model | string | "" | Model id, e.g. gemini-3.1-flash-lite. |
apiKeyEnv | string | "" | The NAME of the environment variable holding your key — never the key itself. E.g. QF_API_KEY. Empty is fine for keyless local servers (Ollama). Pasting the key here is rejected at load. |
temperature | number | 0 | Sampling temperature; 0 is deterministic and recommended. |
maxTokens | number | 0 | Response cap; 0 lets the server decide. Note that reasoning models charge their hidden thinking tokens against this budget, so leave generous headroom — 4096 is a safe starting point. |
jsonMode | boolean | false | Sends the OpenAI response_format: json_object flag. Leave this off unless you know your endpoint handles it well. Gemini's OpenAI-compatibility endpoint returns brace-unbalanced JSON with it enabled (measured: 2/5 replies parseable with it, 5/5 without). QueryForge already instructs the model to return a bare JSON object and tolerates code fences, so this flag is rarely needed. |
Beyond the single model block, an optional models array lets you list several models in priority order. QueryForge tries them in order and uses the first that answers, so a rate-limit, quota, or billing block on one provider transparently falls through to the next. model is the primary; models are the ordered fallbacks, and a chain can freely mix providers (e.g. Gemini → Groq → Anthropic → local Ollama). Each entry is a full model block with its own apiKeyEnv.
"model": { "provider": "gemini", "baseURL": "…/v1beta/openai", "model": "gemini-3.1-flash-lite", "apiKeyEnv": "QF_API_KEY" },
"models": [
{ "provider": "groq", "baseURL": "https://api.groq.com/openai/v1", "model": "llama-3.3-70b-versatile", "apiKeyEnv": "GROQ_API_KEY" },
{ "provider": "anthropic", "baseURL": "https://api.anthropic.com", "model": "claude-opus-4-8", "apiKeyEnv": "ANTHROPIC_API_KEY" },
{ "provider": "ollama", "baseURL": "http://localhost:11434/v1", "model": "qwen2.5" }
]
Omit models entirely for a single model. The provider: "anthropic" label (or an api.anthropic.com base URL) selects the native Anthropic Messages API; everything else uses the OpenAI-compatible dialect.
This is the single easiest thing to get wrong, so it is worth stating twice. apiKeyEnv names the environment variable that holds your key; the key lives in the environment, never in the file.
// Correct — the field names a variable, and the key stays outside the file
"apiKeyEnv": "QF_API_KEY" → export QF_API_KEY=AIza…
// Wrong — the key pasted into the field
"apiKeyEnv": "AIzaSy…" → rejected at load
Getting this wrong used to fail in a way that pointed nowhere near the cause: the library would look up an environment variable literally named AIzaSy…, find nothing, send no Authorization header, and the provider would answer "Missing or invalid Authorization header" — an error about a header you never wrote, describing a key you were certain you had configured. The pasted secret would also travel wherever the config travelled, including into version control and startup logs.
QueryForge now rejects it when the config loads, in the primary model block and in every models fallback entry. A value is refused if it opens with a known key prefix (AIza, sk-, sk-ant-, gsk_, ghp_, xox) or is not shaped like a variable name (letters, digits and underscores, not starting with a digit). The error explains the fix and never repeats the value back — echoing it would put the secret straight into your logs.
Maps the logical entity to a physical source per backend. Each backend uses its idiomatic key. If omitted, the entity name is used as the source.
| Backend | Key | Example |
|---|---|---|
| SQL | table | "sql": { "table": "orders" } |
| MongoDB | collection | "mongo": { "collection": "orders" } |
| Elasticsearch future | index | "es": { "index": "orders-v2" } |
| Custom plugin | name | "mybackend": { "name": "orders" } |
The heart of the config: each entry is one queryable attribute. This is the complete list of per-field keys.
| Key | Type | Default | Description |
|---|---|---|---|
name | string | — | Logical field name used in the AST and natural language. Required. |
type | string | — | One of string, number, boolean, enum, date, array. Required. See Field types. |
values | string[] | — | The allowed domain for enum fields. Required when type is enum. |
itemType | string | string | Element type for array fields. |
operators | string[] | type default | Whitelist of comparison operators this field permits. Empty = a sensible default set for the type. |
synonyms | string[] | [] | Alternate phrasings that resolve to this field (fed to the model and used for "did you mean" suggestions). |
mapping | object | {} | Backend → physical column/field name, e.g. {"sql":"customer_name","mongo":"customerName"}. Falls back to name. For Mongo the value may be a dot path — see nested fields. |
elemMatch | string | "" | Mongo only. The array of sub-documents this field lives inside, e.g. "items" for a field mapped to "items.sku". See nested fields. |
valueCase | string | "" | Force the case of this field's values in the built query: "upper", "lower", or omit for none. String-valued fields only. See value case. |
queryable | boolean | true | Include/exclude from the NL surface. false hides the field from the model and rejects any AST that references it. |
filterable | boolean | true | May appear in filter predicates (WHERE / find). |
searchable | boolean | string→true, else false | May use text-search operators (contains, startsWith, endsWith, regex). |
sortable | boolean | array→false, else true | May appear in the sort list. |
returnable | boolean | true | May appear in the result projection (select). |
indexed | boolean | false | Field is backed by a DB index. A hint — see indexed & priority. |
priority | number | 0 | Relative importance; higher sorts earlier in predicates and the prompt. A hint. |
validators | object | none | Numeric bounds: {"min":0,"max":100}. Enforced during validation. |
The type bounds which value kinds and default operators are legal.
| Type | Default operators (when operators omitted) | Notes |
|---|---|---|
string | equals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull | Searchable by default. |
number | equals, notEquals, gt, lt, gte, lte, between, in, notIn, isNull, isNotNull | Supports validators min/max. |
boolean | equals, notEquals, isNull, isNotNull | Value kind boolean. |
enum | equals, notEquals, in, notIn, isNull, isNotNull | values required; out-of-domain values are rejected. |
date | before, after, between, equals, isNull, isNotNull | Accepts date and relative_date values. after/before are inclusive. |
array | contains, containsAny, containsAll, isNull, isNotNull | Not sortable by default. Elements typed by itemType. |
Mongo documents are rarely flat. QueryForge handles nesting entirely in the mapping layer, so the
logical vocabulary stays flat: the model still asks about itemSku and never learns that the physical
path is items.sku. Two shapes need different treatment.
When the parent is a single sub-document, a dot path in mapping.mongo is exact. Nothing else is
needed.
{ "name": "city", "type": "string", "mapping": { "mongo": "shippingAddress.city" } }
"orders shipping to Pune"
→ { "shippingAddress.city": "Pune" }
elemMatchWhen the parent is an array, dot paths alone are silently wrong. Mongo satisfies each dot-path predicate independently, so two conditions can be met by two different elements. Given this order:
{ "_id": 1, "items": [ { "sku": "ABC", "price": 20 },
{ "sku": "XYZ", "price": 900 } ] }
the question "orders containing item ABC costing over 100" compiles two ways:
| Generated filter | Matches the order above? | |
|---|---|---|
Without elemMatch |
{"items.sku":"ABC","items.price":{"$gt":100}} |
Yes — sku from one element, price from another. Wrong. |
With elemMatch |
{"items":{"$elemMatch":{"sku":"ABC","price":{"$gt":100}}}} |
No — no single item is both. Correct. |
Declaring the array on each field is what selects the second form:
{ "name": "itemSku", "type": "string", "mapping": { "mongo": "items.sku" }, "elemMatch": "items" },
{ "name": "itemPrice", "type": "number", "mapping": { "mongo": "items.price" }, "elemMatch": "items" }
elemMatch must be a leading dot-path segment of the field's Mongo path, and cannot be the whole
path. elemMatch: "items" with mongo: "items.sku" is valid; with
mongo: "lines.sku" the config is rejected at load.mongo: "items.dims.w" with elemMatch: "items"
produces {"items":{"$elemMatch":{"dims.w":…}}}.items and on payments produce two independent
$elemMatch documents.OR each branch is satisfied independently
already, so the branches stay separate — folding them would change the question.price >= 100 AND price <= 500 becomes
{"price":{"$gte":100,"$lte":500}}. Predicates that cannot merge without dropping one are kept in an
$and inside the $elemMatch, so the same-element guarantee holds either way.items.sku). $elemMatch is a filter construct;
a projection keyed sku would name a field that does not exist at the document root.items..sku), a leading or trailing dot, or a segment starting
with $ — is rejected at load. Left in, it would build a filter key that simply matches nothing, with
no error to explain the empty result.Explain says which reading it picked: "Conditions on items apply to the same array element."elemMatch. Nesting is a Mongo concept. For SQL,
give the field a flat mapping.sql column ("item_sku"); leave it out and the logical name is
used as the column, exactly as for any other field.Five boolean flags gate what a field can do. They sit alongside operators: operators bounds which comparison operators are legal; the flags bound where the field may appear.
| Flag | Gate | If violated |
|---|---|---|
queryable | Exposed to the model & referenceable at all. | Field hidden from prompt; any reference is rejected. |
filterable | Usable in filter predicates. | Predicate on the field is rejected. |
searchable | Usable with text-search operators. | A contains/startsWith/endsWith/regex on a string/enum field is rejected. |
sortable | Usable in sort. | Sort on the field is rejected. |
returnable | May appear in results at all. | Selecting the field is rejected, and it is excluded from the default projection. |
Use queryable: false + returnable: false together to fully hide a sensitive column (e.g. an SSN) from both querying and results.
returnable: false is enforced. It is not only a guard on explicit select lists. As soon as any field in the config is non-returnable, QueryForge stops emitting the wide form (SELECT *, or an unprojected Mongo find) and instead emits an explicit allow-list of the returnable columns — because SELECT * would return the hidden column straight from the database and silently defeat the exclusion. Configs that hide nothing keep the compact SELECT *. If every field is non-returnable the projection would be empty, which is invalid SQL and means "return everything" in Mongo, so QueryForge reports an error instead.Databases and people rarely agree on case. A status column holding SHIPPED will never match the shipped someone typed, and asking the model to shout is both unreliable and beside the point: the case belongs to the storage, not to the question. valueCase states it once, per field:
{ "name": "status", "type": "enum", "values": ["shipped", "cancelled"], "valueCase": "upper" }
| Question | AST | SQL | Mongo |
|---|---|---|---|
| "orders that shipped" | status equals "shipped" |
status = $1 · args ["SHIPPED"] |
{"status":"SHIPPED"} |
The conversion happens only when the query is built. Everything above the generator keeps the spelling you wrote:
values exactly as listed."SHIPPED" is rejected as out of domain. Your enum stays one readable vocabulary rather than two.It applies to every string a predicate carries — equals, notEquals, in, notIn, between, contains, startsWith, endsWith, containsAny, containsAll — on both backends, including fields inside an elemMatch array. Values that are not text are never touched: numbers, booleans and dates pass through unchanged.
regex is deliberately exempt. A pattern is not a word. Upper-casing \d gives \D — "any digit" silently becomes "anything but a digit" — so a raw regex value reaches the query exactly as written, whatever the field declares.Only fields whose values are text may set it: string, enum, or an array of either. On a number, date or boolean the key would do nothing, so the config is rejected at load rather than loading with a setting that quietly never fires. A misspelt setting ("uppercase", "UPPER") is rejected for the same reason — the only accepted values are "upper" and "lower".
These two are hints, not gates — they never reject a query. They steer deterministic behavior:
AND/OR, predicates on indexed fields are emitted first, then higher priority first. Because AND/OR are commutative, this is safe and improves how the database plans the query."filtering on non-indexed field … may be slow") that your app may surface or ignore.The fixed set of operators. Which ones a field may use is bounded by its operators list (or the type default).
| Operator | Meaning | Value shape |
|---|---|---|
equals / notEquals | Exact (in)equality | scalar |
gt / lt / gte / lte | Numeric comparison | scalar (number) |
between | Inclusive range | array of exactly 2 |
in / notIn | Membership in a set | array |
contains | Substring (string) or element membership (array) | scalar |
containsAny / containsAll | Array overlap / superset | array |
startsWith / endsWith | Prefix / suffix match | scalar (string) |
regex | Regular-expression match | scalar (string) |
before / after | Date comparison (inclusive) | date or relative_date |
isNull / isNotNull | Presence check | no value |
Logical connectives AND, OR, NOT combine predicates in the AST.
Each comparison value in the AST is tagged with a kind so the validator can enforce type legality before any query is built.
| Kind | Shape | Example |
|---|---|---|
string | {"kind":"string","v":"abc"} | text |
number | {"kind":"number","v":42} | numeric |
boolean | {"kind":"boolean","v":false} | true/false |
enum | {"kind":"enum","v":"DELIVERED"} | member of values |
array | {"kind":"array","v":["a","b"]} | for in/between/contains* |
date | {"kind":"date","v":"2026-01-31"} | absolute date |
relative_date | {"kind":"relative_date","unit":"day","amount":-30} | "30 days ago"; units: minute, hour, day, week, month, year |
| Key | Type | Description |
|---|---|---|
limit | number | Default row limit applied when the query omits one. |
maxLimit | number | Ceiling; a query requesting more is rejected. |
| Key | Type | Description |
|---|---|---|
maxNestingDepth | number | Maximum filter-tree depth; 0 = unlimited. Guards against pathological nesting. Checked as the tree is walked, so an over-deep filter is not fully traversed first. |
denyRegexOn | string[] | Field names on which regex is forbidden (ReDoS / PII safety). |
allowRegexOn | string[] | Turns regex into an opt-in capability: when this lists any field, regex is legal on those fields and no others. Preferred over denyRegexOn for new configs — a deny-list leaves regex on for every field it forgets to name, and the cost of a pattern like (a+)+$ is paid by the database server. |
The remaining keys bound the work one request can cause, which depth alone does not: a filter can be flat, enormous, and entirely legal. Each has a built-in default, so leaving it unset is already bounded; set a negative value to switch a bound off.
| Key | Type | Default | Description |
|---|---|---|---|
maxFilterNodes | number | 500 | Total condition nodes in the filter tree. |
maxListLength | number | 500 | Elements in an in/notIn/containsAny/containsAll value. Each becomes one bound placeholder. |
maxValueLength | number | 4096 | Characters in a single string literal. |
maxRegexLength | number | 256 | Characters in a regex pattern. |
maxSuggestCalls | number | 10 | How many unknown fields get "did you mean" suggestions. Each suggestion runs an edit-distance pass over every field and synonym, so an AST full of nonsense is the most expensive thing to reject. |
One regex rule has no key and cannot be switched off. A pattern that nests an unbounded quantifier inside a quantified group — (a+)+, (x+x+)+y, ^(\w+\s?)*$ — is rejected with regex_unsafe, whatever allowRegexOn says. Those take exponential time in the database process, once per row, and are reachable from an ordinary English sentence; unlike the two gates above, there is no legitimate use to weigh against. Rewrite with a bounded repetition such as {1,20}. The check is structural and narrow on purpose — it does not catch alternation overlap like (a|ab)*, so set a statement timeout at the database as well.
SQL uses three-valued logic: status <> 'CANCELLED' is NULL — not TRUE — for a row whose status is NULL, so that row is excluded. MongoDB has no such rule: $ne, $nin and $nor all match a document that lacks the field.
QueryForge follows the SQL reading on both, because it is the one that never over-returns. The Mongo generator adds "$exists": true to notEquals, to notIn, and to a NOT whose single child is a value comparison, so the same AST selects the same logical rows on either backend.
Two deliberate exceptions. isNull/isNotNull are about absence and already agree, so they are left alone. And a NOT over a whole logical subtree keeps plain $nor: there is no single field to require, and requiring all of them would be wrong — SQL's NOT(A AND B) is TRUE when A is NULL and B is FALSE, so the row comes back even though A's column is empty. Negate individual predicates rather than groups if you need the two backends to agree exactly there.
Some predicates are not the user's to choose. Your application already knows the caller's subscription, user, and enterprise before any question is asked, and every query must be confined to them whatever the question says. Those values are not query vocabulary — nobody should be able to phrase, widen, or omit them — so they do not belong in fields and must never be shown to the model.
Pass them as an extra map argument instead. Every entry is AND-ed onto the query:
res, err := engine.Translate(ctx, "delivered orders over 500 dollars", "sql", qf.Scope{
"subscriptionId": session.SubscriptionID, // "SUB-42"
"userId": session.UserID, // 9
"enterpriseId": session.EnterpriseIDs, // []string{"E-1", "E-2"}
})
SELECT ... FROM orders
WHERE (subscriptionId = $1 AND userId = $2 AND enterpriseId IN ($3, $4)
AND status = $5 AND amount > $6)
ORDER BY created_at DESC LIMIT 50
-- args: ["SUB-42", 9, "E-1", "E-2", "DELIVERED", 500]
The same map works on the deterministic path, which takes the argument too — otherwise a caller could sidestep tenancy just by building the AST themselves:
q, err := engine.GenerateFrom(ast, "sql", scope)
Pass nil for no scope; that is exactly the pre-v0.0.2 behaviour.
| Property | How |
|---|---|
| The model never sees these fields | Scope is applied after the model has answered. The prompt never names the fields, so the model cannot reference, relax, or negate them — it does not learn they exist. Asked "show me orders for subscription SUB-99 instead", it declines: there is no such field in its vocabulary. |
| A scope can only narrow | Predicates are AND-ed at the root of the filter tree. A model filter of A OR B becomes scope AND (A OR B), never scope OR …. No AST the model can emit escapes the scope. |
| Values are still parameterized | A scope value is data like any other: bound as $1, $2, … in SQL and as a typed map element in Mongo. An injection payload in a scope value stays an argument. |
| Still read-only | Scope adds comparison predicates only. There is no shape it can take that produces anything but a SELECT / find. |
| Applied even when the user filters the same field | Both predicates survive and are AND-ed. Letting either one win would be a widening you did not authorize. |
| You pass | You get | Example |
|---|---|---|
a scalar — string, bool, any int/uint/float, time.Time, or a pointer to one | equals | "subscriptionId": "SUB-42" → subscriptionId = $1 |
| a slice or array of those | in | "enterpriseId": []string{"E-1","E-2"} → enterpriseId IN ($1, $2) |
a scalar, on a field declared type: "array" | contains | "tags": "premium" → membership |
a slice, on a field declared type: "array" | containsAny | "tags": []string{"a","b"} → any-of membership |
Keys are applied in alphabetical order, so the generated query and its argument order are identical run to run.
qf.ErrScope so you can answer 400: a nil value (ambiguous — omit the key, or pass a value); an empty list (invalid as SQL, and matches nothing); an empty or whitespace-only key; two keys that trim to the same name; a struct, map, or nested list; and, on a field your config does declare, a value that breaks its declared type, enum domain, or validators bounds. A bad scope fails before the model call, so a bug in your code never burns API quota.Not declared in fields | Declared with queryable: false | |
|---|---|---|
| Physical name | the key is used verbatim as the column/field name | taken from the field's mapping, per backend |
| Value checking | type inferred from the Go value | declared type, enum domain and validators enforced |
| Visible to the model | no | no — queryable:false hides it from the prompt and rejects it in a model AST |
| Best for | getting started; one backend | recommended when you target SQL and Mongo, or want the value checked |
The second form matters as soon as the physical names differ. Declare the column once and one scope map compiles correctly everywhere:
{
"name": "tenantId",
"type": "string",
"queryable": false, // hidden from the model, still scopable
"mapping": { "sql": "tenant_id", "mongo": "tenantId" }
}
engine.GenerateFrom(ast, "sql", qf.Scope{"tenantId": "T-1"}) // ... WHERE tenant_id = $1
engine.GenerateFrom(ast, "mongo", qf.Scope{"tenantId": "T-1"}) // { tenantId: "T-1", ... }
TranslateResult.Scope always lists the filters that were applied, normalized — the record an audit log should keep. The explanation states them separately, so a readback never presents a forced predicate as something the user asked for:
Return all fields from Order where status equals "DELIVERED".
Always scoped to subscriptionId equals "SUB-42", userId equals 9.
The AST itself is controlled by engine.ScopeInAST. The compiled query and the explanation always include the scope; only this one field changes:
ScopeInAST | TranslateResult.AST is | Use when |
|---|---|---|
false default | exactly what the model produced, in the config's vocabulary. It round-trips: hand it back to GenerateFrom with the same scope and you get the identical query. | pipelines that re-compile or fan out ASTs |
true | the effective AST, scope predicates included — one object proving exactly what ran. It then names fields your config need not declare, so feeding it back through GenerateFrom fails validation unless they are registered. | audit logs that store a single record |
Your config defines the entire vocabulary. When a user asks for something outside it — a field you never configured — the honest answer is "I can't", and QueryForge is built to give that answer rather than to improvise.
Left to itself a model will happily map an unknown concept onto whichever configured field looks closest. Asked for "orders where the shipping warehouse is in Berlin and the courier is DHL" against a config with neither field, one model produced tags contains "Berlin" AND tags contains "DHL". That AST is structurally legal, so it passes validation, and the caller receives confident, silently wrong rows. For a correctness-critical library that is worse than an error.
So the planner gives the model an explicit way to decline. If a request needs a field, operator, or enum value that the config does not define, the model returns a refusal marker instead of an AST, and QueryForge surfaces it as a typed *UnsupportedRequestError carrying the reason. No query is generated and no retry is attempted — a refusal is a definitive answer, not a failure. Callers should branch on it with errors.As:
res, err := engine.Translate(ctx, text, "sql", nil)
var unsupported *qf.UnsupportedRequestError
if errors.As(err, &unsupported) {
// Not a bug: this config simply cannot express the request.
fmt.Println("Cannot answer:", unsupported.Reason)
return
}
Declining is reserved for genuinely missing vocabulary. A phrasing that matches a field's synonyms still resolves normally — "buyer" and "order value" map to customerName and amount without complaint. If you find legitimate questions being refused, the fix is usually to add synonyms to the field rather than to change the query.
Four complete configs ship in examples/. Study them to see the patterns for each database family.
examples/orders.config.json — an e-commerce Order, mapped to both SQL and Mongo from one config.examples/sql_employees.config.json — relational patterns: indexed + prioritized columns, snake_case mappings, RBAC on salary, an excluded ssn, numeric validators.examples/nosql_products.config.json — document patterns: array fields (categories, tags) with contains operators, text search on title/description, rating bounds.examples/mongo_nested.config.json — nested document patterns: an embedded address on a dot path, plus two arrays of sub-documents (items, payments) declared with elemMatch.{
"name": "salary", // logical name used in NL and the AST
"type": "number", // bounds operators + value kinds
"operators": ["gt","lt","gte","lte","between"],
"synonyms": ["pay","compensation","wage"],
"validators": { "min": 0, "max": 10000000 }, // rejected outside this range
"sortable": true, // may appear in ORDER BY
"searchable": false, // no text-search operators
"mapping": { "sql": "salary" } // physical column name
}
The config builder walks these steps as a form and flags each mistake as you make it. If you would rather write the file by hand, this is the order that works:
entity name and set the model block (start with a free Gemini/Groq tier; put your key in the env var you name in apiKeyEnv).backends entry for each database you target, mapping to the physical table/collection.fields entry. Choose the right type, list synonyms people actually say, and set mapping if the physical name differs.indexed: true and a priority on the columns your database indexes.queryable: false / returnable: false on anything sensitive.queryable: false with a mapping, then pass them per call as a scope.defaults (limit/maxLimit) and policy (maxNestingDepth, denyRegexOn) to bound the blast radius.queryforge.LoadConfig("your.config.json") — the loader validates structure and rejects typos immediately.QueryForge · configuration reference · this document is generated as part of the library and lives at docs/config.html.