QFQueryForge Create your config file here

QueryForge — Configuration Reference

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.

Prefer not to hand-write JSON? Open the config builder → It is a single self-contained page: every option below rendered as a form, validated live against the same rules the loader enforces, with the finished file a click away. It also imports an existing config for editing.
Read-only by design. QueryForge only ever produces GET / read queries (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.

What is the Query AST?

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:

One AST, every backend

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.

Why not just have the model write SQL?

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. Validation — every field, operator, and value is checked against your config. An invented field is rejected here, before anything is compiled.
  2. Generation — the approved AST becomes a real query, with values bound as parameters ($1, $2) rather than pasted into the statement, so a string like Robert'); DROP TABLE orders;-- arrives as data and can never execute.
This is how "read-only" is guaranteed. The AST has no node type that can write. There is no insert, update, or delete node to emit — so the guarantee does not rest on a check somebody might forget to run, it rests on there being no way to express a write at all.

The node types

The root object accepts these keys. Everything is optional except entity; omit what you do not need.

KeyTypeMeaning
entitystringWhat is being queried. Must match the config's entity.
versionstringAST schema version, currently "1.0". Defaulted when omitted.
filterobjectRoot of the predicate tree. Omitted = match everything.
sortarrayOrdering clauses, applied in order: {"field":"…","dir":"ASC"|"DESC"}.
limitnumberMaximum rows. Falls back to the config's defaults.limit, capped by maxLimit.
offsetnumberRows to skip, for pagination.
selectstring[]Which fields to return. Omitted = all returnable fields.

Inside filter, every node is one of exactly two shapes:

typeShapePurpose
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.

Seeing it yourself

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.

Top-level structure

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).

KeyTypeRequiredPurpose
entitystringyesLogical name of the thing being queried (e.g. Order). Must match the AST's entity.
versionnumbernoYour config's version, for your own schema-versioning/migrations.
modelobjectfor NLWhich AI model to call. See model.
backendsobjectnoPhysical source per backend. See backends.
fieldsarrayyesThe queryable attributes. See fields.
defaultsobjectnoDefault/max result window. See defaults.
policyobjectnoSafety 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).

model

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).

KeyTypeDefaultDescription
providerstring""Informational label (e.g. gemini, groq, ollama).
baseURLstring""Endpoint root. /chat/completions is appended. E.g. Gemini: https://generativelanguage.googleapis.com/v1beta/openai.
modelstring""Model id, e.g. gemini-3.1-flash-lite.
apiKeyEnvstring""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.
temperaturenumber0Sampling temperature; 0 is deterministic and recommended.
maxTokensnumber0Response 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.
jsonModebooleanfalseSends 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.
Security: the key value never appears in the config. QueryForge reads it from the named environment variable at runtime.

models — fallback chain (optional)

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.

apiKeyEnv holds a NAME, not a key

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.

backends

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.

BackendKeyExample
SQLtable"sql": { "table": "orders" }
MongoDBcollection"mongo": { "collection": "orders" }
Elasticsearch futureindex"es": { "index": "orders-v2" }
Custom pluginname"mybackend": { "name": "orders" }

fields

The heart of the config: each entry is one queryable attribute. This is the complete list of per-field keys.

KeyTypeDefaultDescription
namestringLogical field name used in the AST and natural language. Required.
typestringOne of string, number, boolean, enum, date, array. Required. See Field types.
valuesstring[]The allowed domain for enum fields. Required when type is enum.
itemTypestringstringElement type for array fields.
operatorsstring[]type defaultWhitelist of comparison operators this field permits. Empty = a sensible default set for the type.
synonymsstring[][]Alternate phrasings that resolve to this field (fed to the model and used for "did you mean" suggestions).
mappingobject{}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.
elemMatchstring""Mongo only. The array of sub-documents this field lives inside, e.g. "items" for a field mapped to "items.sku". See nested fields.
valueCasestring""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.
queryablebooleantrueInclude/exclude from the NL surface. false hides the field from the model and rejects any AST that references it.
filterablebooleantrueMay appear in filter predicates (WHERE / find).
searchablebooleanstring→true, else falseMay use text-search operators (contains, startsWith, endsWith, regex).
sortablebooleanarray→false, else trueMay appear in the sort list.
returnablebooleantrueMay appear in the result projection (select).
indexedbooleanfalseField is backed by a DB index. A hint — see indexed & priority.
prioritynumber0Relative importance; higher sorts earlier in predicates and the prompt. A hint.
validatorsobjectnoneNumeric bounds: {"min":0,"max":100}. Enforced during validation.

Field types

The type bounds which value kinds and default operators are legal.

TypeDefault operators (when operators omitted)Notes
stringequals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNullSearchable by default.
numberequals, notEquals, gt, lt, gte, lte, between, in, notIn, isNull, isNotNullSupports validators min/max.
booleanequals, notEquals, isNull, isNotNullValue kind boolean.
enumequals, notEquals, in, notIn, isNull, isNotNullvalues required; out-of-domain values are rejected.
datebefore, after, between, equals, isNull, isNotNullAccepts date and relative_date values. after/before are inclusive.
arraycontains, containsAny, containsAll, isNull, isNotNullNot sortable by default. Elements typed by itemType.

Nested fields (MongoDB)

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.

Embedded documents — a dot path is enough

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" }

Arrays of sub-documents — declare the array with elemMatch

When 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 filterMatches 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" }

Rules

Other backends ignore 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.

Capability flags

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.

FlagGateIf violated
queryableExposed to the model & referenceable at all.Field hidden from prompt; any reference is rejected.
filterableUsable in filter predicates.Predicate on the field is rejected.
searchableUsable with text-search operators.A contains/startsWith/endsWith/regex on a string/enum field is rejected.
sortableUsable in sort.Sort on the field is rejected.
returnableMay 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.

How 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.

Value case — matching a column that stores UPPERCASE v0.0.9

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" }
QuestionASTSQLMongo
"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:

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".

indexed & priority

These two are hints, not gates — they never reject a query. They steer deterministic behavior:

Operator catalogue

The fixed set of operators. Which ones a field may use is bounded by its operators list (or the type default).

OperatorMeaningValue shape
equals / notEqualsExact (in)equalityscalar
gt / lt / gte / lteNumeric comparisonscalar (number)
betweenInclusive rangearray of exactly 2
in / notInMembership in a setarray
containsSubstring (string) or element membership (array)scalar
containsAny / containsAllArray overlap / supersetarray
startsWith / endsWithPrefix / suffix matchscalar (string)
regexRegular-expression matchscalar (string)
before / afterDate comparison (inclusive)date or relative_date
isNull / isNotNullPresence checkno value

Logical connectives AND, OR, NOT combine predicates in the AST.

Value kinds

Each comparison value in the AST is tagged with a kind so the validator can enforce type legality before any query is built.

KindShapeExample
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

defaults & policy

defaults

KeyTypeDescription
limitnumberDefault row limit applied when the query omits one.
maxLimitnumberCeiling; a query requesting more is rejected.

policy

KeyTypeDescription
maxNestingDepthnumberMaximum 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.
denyRegexOnstring[]Field names on which regex is forbidden (ReDoS / PII safety).
allowRegexOnstring[]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.

KeyTypeDefaultDescription
maxFilterNodesnumber500Total condition nodes in the filter tree.
maxListLengthnumber500Elements in an in/notIn/containsAny/containsAll value. Each becomes one bound placeholder.
maxValueLengthnumber4096Characters in a single string literal.
maxRegexLengthnumber256Characters in a regex pattern.
maxSuggestCallsnumber10How 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.

NULL and missing fields across backends

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.

Scope filters — extra filters you supply per call v0.0.2

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.

Why this is safe

PropertyHow
The model never sees these fieldsScope 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 narrowPredicates 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 parameterizedA 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-onlyScope 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 fieldBoth predicates survive and are AND-ed. Letting either one win would be a widening you did not authorize.

Accepted values

You passYou getExample
a scalar — string, bool, any int/uint/float, time.Time, or a pointer to oneequals"subscriptionId": "SUB-42"subscriptionId = $1
a slice or array of thosein"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.

These are rejected, each with a message naming the key and tagged 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.

Two ways to name a scope field

Not declared in fieldsDeclared with queryable: false
Physical namethe key is used verbatim as the column/field nametaken from the field's mapping, per backend
Value checkingtype inferred from the Go valuedeclared type, enum domain and validators enforced
Visible to the modelnono — queryable:false hides it from the prompt and rejects it in a model AST
Best forgetting started; one backendrecommended 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", ... }

What you get back

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:

ScopeInASTTranslateResult.AST isUse when
false defaultexactly 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
truethe 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

Requests the config cannot express

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.

Full examples

Four complete configs ship in examples/. Study them to see the patterns for each database family.

Annotated field

{
  "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
}

Author your own config

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:

  1. Pick an 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).
  2. Add a backends entry for each database you target, mapping to the physical table/collection.
  3. For every attribute users should be able to filter/sort/search on, add a fields entry. Choose the right type, list synonyms people actually say, and set mapping if the physical name differs.
  4. Mark indexed: true and a priority on the columns your database indexes.
  5. Set queryable: false / returnable: false on anything sensitive.
  6. For tenancy columns (subscription, user, enterprise), do not make them queryable. Either leave them out of the config entirely or declare them queryable: false with a mapping, then pass them per call as a scope.
  7. Add defaults (limit/maxLimit) and policy (maxNestingDepth, denyRegexOn) to bound the blast radius.
  8. Load it with 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.