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 seven 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. |
timezone | string | no | IANA zone (e.g. Asia/Kolkata) where calendar periods like "today" begin. Default UTC. See dates. |
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 | "" | Provider name (e.g. gemini, groq, ollama). For a known provider this supplies the endpoint, so baseURL can be omitted. Any other name is still valid — it is then just a label, and you set baseURL yourself. |
baseURL | string | "" | Endpoint root. /chat/completions is appended. E.g. Gemini: https://generativelanguage.googleapis.com/v1beta/openai. Always wins over a provider preset. |
protocol | string | inferred | Wire dialect: openai or anthropic. Usually omitted — a known provider name implies it, and everything else defaults to the OpenAI dialect. Set it when the URL does not advertise the dialect, such as an Anthropic-compatible gateway on your own hostname. An unimplemented value is rejected at load. |
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. |
timeoutSeconds | number | 30 | Ceiling on one request to the provider. The total bound on a translate is the caller's own deadline, so this is per attempt. |
maxRetries | number | 2 | Extra attempts for a failure that waiting could fix — a rate limit, a 5xx, a dropped connection. A bad key, an unknown model or a malformed request are never retried, because the next attempt would send exactly the same thing. Set 0 to fail over to your fallback chain instantly instead of waiting. |
retryBackoffMs | number | 250 | First retry delay, doubling and jittered thereafter (capped at 8s). A provider's own Retry-After header overrides it whenever one is sent. |
Authorization header back cannot leak it into your logs.Naming one of these as provider supplies the base URL, so a working model block can be two keys:
"model": { "provider": "groq", "model": "llama-3.3-70b-versatile", "apiKeyEnv": "GROQ_API_KEY" }
anthropic, cerebras, deepseek, fireworks, gemini, google, groq, lmstudio, mistral, nvidia, ollama, openai, openrouter, perplexity, together, vllm, xai
Three things to be clear about, because they are what keep this list from becoming a limitation:
baseURL always overrides the preset, for a regional endpoint, a corporate mirror, or a test server.baseURL and it works identically:
"model": { "provider": "acme", "baseURL": "https://api.acme.ai/v1", "model": "acme-ultra", "apiKeyEnv": "ACME_API_KEY" }
The local servers default to their documented ports, so { "provider": "ollama", "model": "qwen2.5" } is a complete config on a stock install — no URL, no key.
A model call that fails is retried only when time could plausibly fix it. The distinction matters because the two groups want opposite handling:
| Failure | Retried? | Why |
|---|---|---|
| Rate limit (429) | yes | The one failure where waiting genuinely works. Common on free tiers. |
| Provider fault (5xx) | yes | Transient by definition. |
| Dropped connection, per-attempt timeout | yes | The request never reached a verdict. |
| Bad or revoked key (401/403) | no | The same key would be resent. Retrying spends your rate limit to learn nothing. |
| Out of credit / billing cap (402, or 429 with a billing message) | no | No amount of waiting adds funds. Fails over to the next model at once. |
| Unknown model id (404) | no | Retrying cannot conjure the model. |
| Malformed request (400/422) | no | The request body is deterministic — a retry sends identical bytes. |
Reply truncated by maxTokens | no | An identical request truncates identically. Raise maxTokens. |
Transport retries are separate from the engine's validation-repair budget, and neither consumes the other. Retrying re-sends an identical request when the provider was unreachable; repairing changes the prompt when the model's answer did not validate.
Retries compose with the fallback chain: a chain of three models at the default two retries is up to nine requests in the worst case, so set maxRetries: 0 on entries where you would rather fail over immediately.
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 / OpenSearch v1.2.0 | index | "elasticsearch": { "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). |
customField | boolean | false | Marks a field whose name doesn't explain itself (e.g. txt01). Doesn't change how the field is queried — it just requires description (and, on a searchable string field, valueHint) to be filled in. See custom fields. |
displayName | string | "" | A friendly label shown to the model and used in the plain-English readback, instead of the raw name. Always optional. See custom fields. |
description | string | "" | A one-line note on what the field means, shown to the model. Optional — unless customField is true. See custom fields. |
valueHint | string | "" | What a free-text field usually contains. Only valid on a searchable string field. Optional — unless customField is true on one. See custom fields. |
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. |
caseInsensitive | boolean | false | Match this field's comparisons regardless of letter case: equals, notEquals, in, notIn, contains, startsWith, endsWith. String fields only, and mutually exclusive with valueCase. See case-insensitive search. |
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. |
Some databases have field names like txt01 or customField3 — leftovers from a generic
or per-tenant schema. The model can't guess what these hold just by reading the name, the way it can guess that
status is a status. Mark a field like this with customField: true, and QueryForge makes
you explain it: the config simply won't load until you do.
| Key | Required when? | What it's for |
|---|---|---|
customField | — | Turns the field into a "needs explaining" field. Querying still works exactly the same either way. |
description | Always, once customField is true. | One short line on what the field means. Shown to the model. |
valueHint | Only if the field is a searchable string, once customField is true. | What kind of text usually lives in this field. The model can't peek at real values the way it can look at an enum's values list, so this fills that gap. |
displayName | Never — always optional. | A friendly label for the field, shown to the model and used in the plain-English readback (Explain). The model still writes the real name in the query; this is just a nicer label, not another name for it. |
Example. A column called txt01 actually holds a passport's country of issue:
{
"name": "txt01",
"type": "string",
"customField": true,
"displayName": "Passport Country",
"description": "ISO country of issuance for the passport",
"valueHint": "Free-text notes — usually mentions visa type, renewal status, and issuing country."
}
Ask "passports issued in Germany" and the model now knows txt01 is the right field. The
readback also reads better: "Passport Country is Germany" instead of "txt01 is Germany".
status, amount, createdAt — needs no customField,
description, valueHint or displayName at all. Those four keys stay
optional on every field; checking customField just turns two of them into a requirement, so a
confusing name can't ship without an explanation.Length limits, checked at load: displayName up to 200 characters, description
up to 500, valueHint up to 1000.
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, relative_date and, with between, period 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".
valueCase fixes a column that stores one consistent case. It cannot fix a free-text column where the case is not consistent at all — "Black", "BLACK" and "black" sitting in the same column, or a model guess that just does not match whatever a given row holds. caseInsensitive solves that from the other end: instead of forcing the query's value to one case, it makes the comparison itself blind to case, so it matches whichever case the row happens to be in.
{ "name": "color", "type": "string", "caseInsensitive": true }
| Question | Postgres | MySQL | Mongo |
|---|---|---|---|
"black jackets" (column holds "Black") |
LOWER(color) = $1 · args ["black"] |
LOWER(color) = ? · args ["black"] |
{"color":{"$regex":"^black$","$options":"i"}} |
It applies to equals, notEquals, in, notIn, contains, startsWith, endsWith. Each backend uses its own idiomatic mechanism rather than one shared trick:
equals/notEquals/in/notIn fold both sides through LOWER(); contains/startsWith/endsWith use ILIKE, which needs no folding on the column at all.LOWER() on both sides; MySQL has no ILIKE, and a column's own collation is not something the generator can see or rely on.equals/notEquals/in/notIn compile to anchored /^…$/i patterns rather than exact matches. A per-query collation was deliberately not used: it applies to every string comparison in the operation, including sibling predicates that never asked to be case-blind, where an anchored regex stays scoped to this one field. contains is unaffected — Mongo has always matched it case-insensitively, flag or not.LOWER(column) and Mongo's anchored regex cannot use a plain index on that column — a B-tree index on color does not serve LOWER(color) = $1 — unless a matching expression index (Postgres) or functional index exists, or the query targets a case-insensitive collection collation (Mongo). An anchored regex with no leading wildcard can still use a plain index as a prefix scan, the same way SQL's LIKE 'prefix%' can, so startsWith fares better than equals does there. Turning this on for a high-traffic filter without an index to match is trading correctness for a table scan.Only a plain string field may set it — not enum (its exact case belongs in values instead), not array, not regex values (a pattern's escapes change meaning with case, same reasoning as valueCase). It is also mutually exclusive with valueCase on the same field: the two solve the same mismatch from opposite ends, and setting both is rejected at load.
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; with a period, the half-open calendar range it names | array of exactly 2, or a period |
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 |
period | {"kind":"period","unit":"day","from":-1,"to":-1} | "yesterday"; a whole calendar range, only with between |
A time in a question is one of two different things, and the AST keeps them apart:
between with a period value. The range follows the calendar, so the same question asked at 09:00 or 23:00 returns the same rows.after with a relative_date value: one instant, a fixed distance before now.from and to count whole units away from the current one: 0 is the current day/week/month, -1 the one before. Both ends are inclusive. Weeks start on Monday.
| Phrase | Period | Asked 2026-09-15 (UTC), selects |
|---|---|---|
| today | day 0..0 | 2026-09-15 00:00 → 2026-09-16 00:00 |
| yesterday | day -1..-1 | 2026-09-14 00:00 → 2026-09-15 00:00 |
| last 30 days | day -29..0 | 2026-08-17 00:00 → 2026-09-16 00:00 (30 days, includes today) |
| this week | week 0..0 | Mon 2026-09-14 00:00 → Mon 2026-09-21 00:00 |
| last month | month -1..-1 | 2026-08-01 00:00 → 2026-09-01 00:00 |
| this year | year 0..0 | 2026-01-01 00:00 → 2027-01-01 00:00 |
Every backend compiles a period to a half-open range — start included, end excluded — rather than an inclusive BETWEEN, so a row at 23:59:59.999 on the last day is counted and midnight of the next day is not:
SQL (created_at >= $1 AND created_at < $2)
MongoDB { "createdAt": { "$gte": ISODate(start), "$lt": ISODate(end) } }
Elasticsearch { "range": { "createdAt": { "gte": "…", "lt": "…" } } }
The validator rejects a period whose from is after its to, whose unit is not one of the six, whose offsets exceed 1,000,000, that is used with any operator other than between, or that is missing from or to (a missing bound is never read as 0 — {"from":-1} would otherwise mean "yesterday and today"). Errors carry the code invalid_relative_date. On Elasticsearch date-partitioned routing, a period selects exactly the partitions it overlaps.
Set the top-level timezone to the IANA zone your users live in, and periods are cut on their midnight. With "timezone": "Asia/Kolkata", "today" asked on 2026-09-15 selects 2026-09-14 18:30Z → 2026-09-15 18:30Z. The system prompt's Today (…) line is shown in the same zone, and the explanation names it: createdAt falls in yesterday (Asia/Kolkata time).
"" means UTC — exactly the behaviour before the key existed.IST, an offset such as +05:30, and the host-dependent Local all fail at load.relative_date values are not affected — "3 hours ago" is the same instant in every zone. Absolute date literals such as "2026-09-15" are still read as UTC.| 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. |
requires | object[] | Cross-field business rules — "using this field also requires using that one". See Field rules. |
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.
Every other rule in this file checks one field at a time: is the value the right type, in range, in the enum. policy.requires is different — it checks a pair of fields. Sometimes a question is perfectly legal field-by-field and still makes no sense on its own. "Passports that are expired" filters passportExpiry, which is a real field with a real operator — nothing about that AST is malformed. But without a country, "expired" is ambiguous: expired by which country's rules, returned to whom? The field is fine; the question is incomplete. requires is how a config says so.
"policy": {
"requires": [
{
"when": { "field": "passportExpiry", "operators": ["before", "after", "between"] },
"requireAlsoOneOf": ["country"],
"message": "Passport expiry needs a country to be meaningful"
}
]
}
| Key | Required? | What it's for |
|---|---|---|
when.field | yes | The field that triggers the rule. Must be a registered field name. |
when.operators | no | Which operators on that field trigger it. Omit it and any operator on the field triggers the rule. |
requireAlsoOneOf | yes | One or more field names. The question must filter at least one of them somewhere — anywhere in the filter tree, not necessarily next to the trigger. This is an "any of", not an "all of": list two fields and either one satisfies the rule. |
message | no | Shown to the caller when the rule fires. Omit it and QueryForge writes one for you naming both sides of the rule. |
Read the whole thing as one sentence: "when passportExpiry is filtered with before, after, or between, the question must also filter country somewhere." Ask "passports expiring this month for travelers from India" and both fields are present, so it runs normally. Ask just "passports that are expired" and it is refused, with the message above.
A rule like this is checked last, after every ordinary check (unknown fields, bad operators, out-of-domain values) has already passed. An AST with an unknown field is rejected for that reason and never reaches the business-rule check at all — you get one clear problem to fix, not two overlapping ones.
A rule violation is reported as its own error type, *qf.PolicyViolationError — deliberately not the same type as an ordinary validation failure (qf.ValidationErrors). The two mean different things: a validation failure means the question could not be turned into a legal query at all; a policy violation means it could, and would run, but the config's author decided it shouldn't without more information. Callers that want to show these two cases differently can:
res, err := engine.Translate(ctx, "passports that are expired", "sql", nil)
var policyErr *qf.PolicyViolationError
if errors.As(err, &policyErr) {
fmt.Println(policyErr.Message) // "Passport expiry needs a country to be meaningful"
fmt.Println(policyErr.Field) // "passportExpiry"
fmt.Println(policyErr.RequireAlsoOneOf) // ["country"]
return
}
qf.Classify(err) reports qf.FailurePolicy for this case, next to the existing qf.FailureValidation and qf.FailureUnsupported codes — see failure.go. Like an unsupported-request refusal, a policy violation is not retried: the model already answered correctly given the words it was given, and asking again would not add information the question never contained.
The stdio protocol (used by the Python and Java SDKs) carries the same distinction on the wire: a failed translate/generate call reports "code": "POLICY_VIOLATION" plus a structured policyError object, separate from the details array an ordinary VALIDATION_FAILED uses.
{
"success": false,
"code": "POLICY_VIOLATION",
"message": "Passport expiry needs a country to be meaningful",
"policyError": {
"field": "passportExpiry",
"requireAlsoOneOf": ["country"],
"message": "Passport expiry needs a country to be meaningful"
}
}
policy.requires key behaves exactly as it always has — nothing here changes what an existing config accepts. Add a rule only where a field genuinely doesn't stand on its own.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.