Every key a config file understands.
A QueryForge config is one JSON file. It names your data, tells the model what it's allowed to ask about, and tells the compiler how to turn a validated question into real SQL or a Mongo query. This page explains every key in that file — what it does, why it exists, and a working example of it — in plain language, matching exactly what the loader itself checks. No key here is guessed; each one traces back to the library's own source.
Go from nothing to a working query
Every language does the same three things: install the package, point it at a config file, and ask a question in plain English. Two things to have ready first:
1. A config file
One JSON file that names your data and what's allowed to be asked about it. Don't write it by hand — use the Config Builder and download the file it gives you. The smallest valid one is tiny; see the shape of a config file just below.
2. A model API key
A key for whichever AI provider your config's model block names (Gemini, OpenAI, Anthropic, Groq…). Export it under the exact name your config's apiKeyEnv gives — never paste the key itself into the config.
export QF_API_KEY="your-real-key-here"Go
No third-party dependencies — standard library only.
go get github.com/awsaman-ai/queryforge
package main
import (
"context"
"fmt"
qf "github.com/awsaman-ai/queryforge"
)
func main() {
cfg, err := qf.LoadConfig("orders.config.json")
if err != nil {
panic(err)
}
engine := qf.New(cfg)
res, err := engine.Translate(context.Background(), "cancelled orders over 200 dollars", "sql", nil)
if err != nil {
panic(err)
}
fmt.Println(res.Query.SQL) // SELECT ... WHERE (status = $1 AND amount > $2)
fmt.Println(res.Query.Args) // [CANCELLED 200]
fmt.Println(res.Explain) // plain-English readback of what it understood
}
Save this as main.go next to your config file and run go run main.go.
Python
The engine ships inside the wheel — no Go toolchain to install.
pip install queryforge-ai
Installs as queryforge-ai, imports as queryforge.
from queryforge import QueryForge
qf = QueryForge.postgres("orders.config.json")
pending = qf.query("cancelled orders over $200")
print(pending.to_sql()) # SELECT ... WHERE (status = $1 AND amount > $2)
print(pending.to_args()) # ('CANCELLED', 200)
print(pending.explain()) # plain-English readback of what it understood
Save this as app.py next to your config file and run python app.py.
Java
Two Maven dependencies: the classes, and the engine binary for the platform you run on (see the platform table for the classifier to use — e.g. darwin-arm64 for Apple Silicon, linux-amd64 for most servers). Why two?
<dependency>
<groupId>io.github.awsaman-ai</groupId>
<artifactId>queryforge</artifactId>
<version>LATEST</version>
</dependency>
<dependency>
<groupId>io.github.awsaman-ai</groupId>
<artifactId>queryforge</artifactId>
<version>LATEST</version>
<classifier>darwin-arm64</classifier>
</dependency>
import io.queryforge.QueryForge;
import io.queryforge.PendingQuery;
import java.nio.file.Paths;
public class App {
public static void main(String[] args) {
QueryForge forge = QueryForge.postgres(Paths.get("orders.config.json"));
PendingQuery pending = forge.query("cancelled orders over $200");
System.out.println(pending.toSql()); // SELECT ... WHERE (status = $1 AND amount > $2)
System.out.println(pending.toArgs()); // [CANCELLED, 200]
System.out.println(pending.explain()); // plain-English readback of what it understood
}
}
Requires Java 11 or later. Zero runtime dependencies — not even a JSON library.
Putting Python or Java behind a web server? Read Running in production for timeouts, logging, error codes and how to cap the number of queries running at once.
How QueryForge works
QueryForge has one engine, written in Go. The engine does all the real work. The Python and Java packages are small helpers that pass your question to that same engine. The pictures below show how the pieces fit together.
The big picture
Your app sends a question and your config. The engine asks an AI model what the question means, and the AI answers with a plan. The engine checks that plan against your config — only the fields and values you allowed can pass. Then the engine writes the query. Your app runs it with the database driver you already use.
Three ways to use the same engine
In Go, the engine is a normal library inside your program. In Python and Java, the engine is a separate small program that comes inside the package. The Python or Java code starts it when you make a call. You never see this happen, and you don't need Go installed.
Because every language uses the same engine, the same question and config give the exact same query in Go, Python and Java.
What happens in one call (Python and Java)
- You call
to_sql()in Python ortoSql()in Java. - The SDK finds the engine program made for your computer.
- It starts the program and sends one JSON message with your question and config. Your API key is not in the message — the engine reads it from the environment.
- The engine asks the AI model, checks the plan against your config, and builds the query.
- The engine sends one JSON message back, then closes.
- The SDK gives you the query and its values. If something went wrong, you get a clear error instead.
Nothing keeps running between calls. There is no server, no open port, and nothing to install besides the package. Starting the engine takes a few milliseconds; the AI model takes much longer. If your app makes many calls at the same time, read Limiting how many queries run at once.
Java: why two dependencies? (the classifier)
The engine is a real program, like any app on your computer. A program is built for one kind of computer: one operating system (Linux, Mac or Windows) and one type of chip (Intel/AMD or ARM). A program built for a Mac does not run on Linux.
Java code is different — the same jar runs everywhere. So the Java SDK comes in two parts:
queryforge— the Java code. One small file for everyone.queryforge+ a classifier — the engine program for one kind of computer. There are five, one for each computer type.
A classifier is just a label in Maven that picks one version of a file. You add the one that matches your computer. We don't put all five engines in one jar, because then everyone would download four engines they never use.
| Your app runs on | Classifier |
|---|---|
| Linux, Intel or AMD chip (most servers) | linux-amd64 |
| Linux, ARM chip (e.g. AWS Graviton) | linux-arm64 |
| Mac with an Intel chip | darwin-amd64 |
| Mac with an Apple chip (M1 and newer) | darwin-arm64 |
| Windows, Intel or AMD chip | windows-amd64 |
linux-amd64. If the classifier is wrong or missing, the first call fails with BinaryNotFoundException, and the message names the computer type it looked for.What happens on the first call. Java cannot run a program while it is still packed inside a jar. So the first time you make a call, the SDK copies the engine out to a temp folder and runs it from there. Every later call uses that same copy.
Each engine version gets its own copy, so two apps using different QueryForge versions never mix them up. If your temp folder does not allow running programs (it is mounted noexec), set QUERYFORGE_CACHE_DIR to a different folder.
Python: why only one install?
pip does the choosing for you. QueryForge publishes a separate package file (a "wheel") for each kind of computer, and pip install queryforge-ai downloads the one that matches your machine. The engine is already inside it, ready to run. There is nothing to copy and no label to pick.
pip install inside the image, so pip picks the engine for the image, not for your laptop.Why we built it this way
| Choice | What you get |
|---|---|
| One engine for every language | The same results everywhere. There are no separate Python and Java copies that slowly drift apart. |
| A small program, not a server | Nothing to host, no port to open, nothing to keep alive or monitor. |
| The engine ships inside the package | No Go to install. The Java library has zero other dependencies. |
| The engine runs as part of your app | It has your app's permissions and nothing more. It only talks to the AI model — never to your database. |
The trade-off: each Python or Java call starts a small program (a few milliseconds), and in Java you have to pick the right classifier for your computer.
The shape of a config file
Every config is one JSON object with the same eight possible top-level keys. Only two are ever required: entity and fields. Everything else has a sensible default, so the smallest valid config is genuinely tiny.
{
"entity": "Order",
"fields": [
{ "name": "status", "type": "string" }
]
}
This loads. No model, no backend, no capability flags — everything not shown here falls back to a type-aware default, explained section by section below.
| Key | Required? | What it's for |
|---|---|---|
entity | required | The name of the thing you're querying — "Order", "Customer". Every incoming query must name this exactly. |
fields | required | The whole vocabulary. At least one field. Anything not listed here can't be asked about. |
version | optional | Your own revision number — for your logs, not read by the loader. |
model | optional | Which AI service turns a sentence into a query plan. |
models | optional | A fallback chain of extra models, tried in order if the first is unreachable. |
backends | optional | The real table, collection, or index each database uses. |
defaults | optional | Page size when a question doesn't say, and the hard ceiling on any explicit page size. |
policy | optional | Safety limits — how deep filters may nest, which fields refuse pattern search, field rules ("using this also needs that"), and whether the tenant filter is compulsory. |
How it's loaded
In Go, the file is read once at startup:
cfg, err := qf.LoadConfig("orders.config.json")
engine := qf.New(cfg)
res, err := engine.Translate(ctx, "cancelled orders over 200 dollars", "sql", nil)
fmt.Println(res.Query.SQL) // SELECT * FROM orders WHERE (status = $1 AND amount > $2)
fmt.Println(res.Query.Args) // [CANCELLED 200]
LoadConfig time, before your service ever starts handling questions — not discovered later on a live request.entity & version
These two keys just name the file. They carry no query logic of their own.
entity required
The logical name of one record — Order, Customer, Ticket. A query is only accepted when its entity matches this string exactly, so it's also the name you pass in code.
"entity": "Order"qf.NewQuery("Order") is accepted. A query built for "Orders" (plural) is rejected — the match is exact.
version optional
Your own revision number for this file. QueryForge stores it and never acts on it — it's there purely so you can tell two deployed configs apart in a log line or a code review.
"version": 2Bump it whenever you ship a meaningfully different config — nothing enforces this, it's a convention.
model & models[]
The model block tells QueryForge which AI service turns a typed sentence into a query plan. Skip it entirely if you'll only ever send ready-made queries through GenerateFrom — that path never calls a model.
| Key | Default | Meaning |
|---|---|---|
provider | — | A label for which client to use. Anything other than "anthropic" uses the OpenAI-compatible client, so most hosts (Gemini, Groq, Ollama…) work under any label. |
baseURL | — | The API root, without /chat/completions — that suffix is added for you. Required for every provider except anthropic, which resolves its own. |
model | — | The model id exactly as the provider spells it, e.g. gemini-3.5-flash. Swapping models is a config change, never a code change. |
apiKeyEnv | — | The name of the environment variable holding your key — never the key itself. A pasted key is rejected at load. |
temperature | provider default | Sampling randomness. Set to 0 so the same question compiles the same way every time. |
maxTokens | provider default | Ceiling on the reply. Too low and a reasoning model's hidden thinking tokens truncate the JSON mid-object. |
jsonMode | false | Sends response_format=json_object. Off by default — some OpenAI-compatible endpoints (Gemini's, measurably) return broken JSON with it on. Only enable it for an endpoint you've tested. |
protocol | auto | The wire dialect, when the URL alone doesn't say. Leave on auto unless you're pointing at an Anthropic-compatible gateway on a hostname that doesn't give it away. |
timeoutSeconds | 30 | Ceiling on one request to the provider — per attempt, not per translate call. |
maxRetries | 2 | Extra attempts for a failure waiting could fix (rate limit, 5xx). A bad key or malformed request never retries — the next attempt would send the same thing. Set 0 to fall through to the next model instantly. |
retryBackoffMs | 250 | First retry delay, doubling and jittered after that. A provider's own Retry-After header overrides it. |
"model": {
"provider": "gemini",
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-3.5-flash",
"apiKeyEnv": "QF_API_KEY",
"temperature": 0,
"maxTokens": 4096
}
apiKeyEnv takes the name of an environment variable — export QF_API_KEY=<your key> — so the secret stays wherever you run the service, not wherever this file travels. The loader recognizes and rejects pasted keys from Google, OpenAI, Anthropic, Groq and GitHub outright.models[] — the fallback chain
An ordered list of backup models, same shape as model. QueryForge tries model first, then each entry here in order, and uses whichever answers first — so a rate limit, quota block or outage on one falls through to the next instead of failing the request. Entries may mix providers freely.
"models": [
{ "provider": "groq", "baseURL": "https://api.groq.com/openai/v1",
"model": "llama-3.3-70b-versatile", "apiKeyEnv": "GROQ_API_KEY" },
{ "provider": "ollama", "baseURL": "http://localhost:11434/v1",
"model": "qwen2.5" }
]
If Gemini is rate-limited today, Groq answers instead, and the response reports ProviderUsed: "groq" so you can see which one actually ran.
backends
Points your entity's name at the real table or collection in each database you use. Skip it and the entity name (Order) is used as-is. Every key here is a physical name, not a query rule — the actual compiling is done by the generator for that backend id.
| Backend id | Database | Physical key | Notes |
|---|---|---|---|
sql | PostgreSQL | table | $1, $2… placeholders, double-quoted identifiers. "sql" means Postgres specifically, not a generic SQL backend. |
mysql | MySQL / MariaDB | table | ? placeholders, backtick identifiers. MySQL 8.0+ or MariaDB 10.5+. |
mongo | MongoDB | collection | Document store — field mapping values may be dot paths into an embedded document. |
elasticsearch / opensearch | Elasticsearch / OpenSearch | — see below | Not a single physical name — an index, an alias, or business-rule routing between several indexes — and mutually exclusive with the three backends above. Its own shape gets its own section: Elasticsearch / OpenSearch. |
"backends": {
"sql": { "table": "orders" },
"mysql": { "table": "orders_tbl" },
"mongo": { "collection": "orders_v2" }
}
With the mapping above: entity "Order" + collection "orders_v2" compiles Mongo calls to db.orders_v2.find(…), while the entity name in every question and every AST stays just "Order".
sql, mysql, mongo, elasticsearch or opensearch) still loads fine — it's just a mapping namespace your own code can read. Nothing in QueryForge compiles a query to it.fields[] — the basics
This is the whole vocabulary the model is allowed to use. Anything listed here can be asked about; anything left out cannot be invented — the model is told to say so rather than guess. At least one field is required.
| Key | Required? | What it does |
|---|---|---|
name | required | The logical name used in the AST and in every question. It never has to match the physical column — that's what mapping is for. |
type | required | One of six kinds (below). Bounds which operators and which value shapes are legal for the field. |
values | required for enum | The complete enum domain. A value outside it is rejected before any query is built. |
itemType | optional | For a type: "array" field: the element type. Values are checked against this, not against "array". |
synonyms | optional | Alternate phrasings that resolve to this field — put in what people actually type. |
operators | optional | The comparison whitelist. Leave it out and the field takes its type's defaults. |
mapping | optional | The physical column or document path, per backend. Unmapped backends fall back to the logical name. |
{
"name": "status",
"type": "enum",
"values": ["PLACED", "DELIVERED", "CANCELLED", "REFUNDED"],
"synonyms": ["state", "order status", "delivery status"],
"mapping": { "sql": "status", "mongo": "status" }
}
With synonyms set like this, "what's the state of my order" and "delivery status" both resolve to status just as well as the word "status" itself.
Importing fields from a schema
This is a config builder feature, not a config file key — a real schema has dozens of fields, and adding each one by hand is the slow part. Open the builder, go to Step 4 (Describe your fields), and expand Import fields from a schema. Pick your database, paste its own schema artifact, and the builder turns it into ordinary fields[] entries — the exact same shape "Add a field" produces, just filled in for you.
| Database | What to paste | How to get it |
|---|---|---|
| Elasticsearch / OpenSearch | A _mapping response | GET /<index>/_mapping |
| MySQL | A CREATE TABLE statement | SHOW CREATE TABLE <table>; |
| PostgreSQL | A CREATE TABLE statement | pg_dump --schema-only -t <table> |
| MongoDB | One or more sample documents | db.<collection>.find().limit(5) |
Elasticsearch and SQL are read straight off the schema the database itself reports, so the type mapping is exact. Mongo has no fixed schema, so it's an educated guess from the sample's own value shapes — a date-looking string becomes date, a number becomes number, and so on; if two pasted documents disagree on a field's type, the row is flagged rather than silently picked for you.
A few config keys are detected automatically where the source schema states them outright:
keywordMapping— from Elasticsearch's own "text field with a keyword sub-field" convention.nestedPath— from an Elasticsearch mapping type of"nested"(a plain"object"is flattened to a dot-path name instead, with nonestedPath).elemMatch— from a Mongo array whose sample elements are sub-documents, not scalars.indexed— from a SQLPRIMARY KEYorUNIQUEcolumn.values— from a MySQLENUM(...)column's literal list.
Before anything is added, a review table shows every discovered field with an editable type dropdown and a notes column explaining what was detected (or flagged) — an unrecognized column/mapping type is never silently guessed at; it's mapped to string with a note to check it by hand. A field whose name already exists in your config is unchecked by default rather than overwritten. Only the rows you leave checked are added, as real field cards you can keep editing exactly like a hand-added one.
The six field types
Every field is exactly one of these. The type decides which operators can even be considered, and what a value has to look like to pass validation.
| type | Holds | Default operators |
|---|---|---|
string | Free text | equals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull |
number | Integers or decimals | equals, notEquals, gt, lt, gte, lte, between, in, notIn, isNull, isNotNull |
boolean | true / false | equals, notEquals, isNull, isNotNull |
enum | One value from a fixed, named list (values) | equals, notEquals, in, notIn, isNull, isNotNull |
date | A point in time | before, after, between, equals, isNull, isNotNull |
array | A list of values of one itemType | contains, containsAny, containsAll, isNull, isNotNull |
enum field must list values — the loader rejects an empty enum domain. An array of enums needs the same list, or every element in it fails validation forever.Capability flags
Five booleans that decide exactly what's allowed to happen to a field. Each one defaults sensibly by type, and only the flags you explicitly set are ever written to the file — an untouched field always inherits today's default rather than freezing it.
| Flag | Default | What it gates |
|---|---|---|
queryable | true | Whether the field is exposed to the model at all. false hides it from the prompt entirely and rejects any query naming it — this is how a tenancy column is declared. |
filterable | true | Whether it may appear in a filter (the WHERE clause / find document). |
searchable | true for string, else false | Whether text-search operators (contains, startsWith, endsWith, regex) may be used. Only means anything on string and enum. |
sortable | true except for array | Whether it may appear in sort[]. Ordering by an array is undefined, so arrays default to false. |
returnable | true | Whether it may appear in results. false also removes it from the default projection — SELECT * becomes an explicit allow-list, so the column can't leak even by omission. |
{ "name": "internalCost", "type": "number", "queryable": false }
{ "name": "description", "type": "string", "searchable": false }
{ "name": "salary", "type": "number", "returnable": false }
"orders where internalCost is over 50" → refused, the model never even learns the field exists. "description mentions x" → refused, but description can still be shown and sorted. SELECT * on the salary table becomes SELECT id, name, dept … automatically.
Custom fields — when a name doesn't explain itself
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. Check customField on a field like this, and QueryForge makes you explain it: the config simply won't load until you do.
| Key | Required? | What it's for |
|---|---|---|
customField | optional | Marks the field as one whose name needs explaining. Querying still works exactly the same either way — this only changes what the loader requires below. |
description | required once customField is true | One short line on what the field means. Shown to the model. |
valueHint | required 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 | 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. |
{
"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.Advanced field keys
The rest of what a field can carry — mostly nesting rules for MongoDB and Elasticsearch, plus small correctness and ranking hints.
- elemMatch Mongo only
- Names the Mongo array of sub-documents this field lives inside. Without it, two conditions on the same array are checked independently and can match two different elements — with it, they're folded into one
$elemMatch, meaning one element must satisfy them all. - keywordMapping Elasticsearch / OpenSearch only
- The exact-match "keyword" sub-field backing this string, per product. ES commonly indexes one string twice — an analyzed path for full-text search, and an unanalyzed
keywordpath for exact match, sort and aggregation, because a single field can't do both.containsreads the plainmapping; every other operator preferskeywordMappingwhen set. - nestedPath Elasticsearch / OpenSearch only
- The ES analogue of Mongo's
elemMatch, for the identical reason. Names the "nested" object/array this field lives inside, so two conditions on the same nested array are folded into one nested query instead of being checked independently. - valueCase
- Forces the letter case of the value the compiler writes into the final query — for a column that stores
SHIPPEDwhile people say "shipped". Only the compiled argument is recased; the model still sees and validates against the case you wrote invalues. - caseInsensitive string only
- Makes every string comparison on this field ignore case entirely, so "Black", "BLACK" and "black" all match the same row. Mutually exclusive with
valueCase— the two solve the same mismatch from opposite ends. - indexed
- Tells QueryForge this column is backed by a database index. Never rejects anything — indexed predicates are just ordered first, and filtering on a non-indexed field raises a soft warning.
- priority
- Relative importance. Higher sorts earlier among predicates and earlier in the prompt. A hint only — it gates nothing.
- routingField Elasticsearch / OpenSearch only
- Opts this field into ES business-rule index routing (see below). Deliberately separate from every capability flag — an ordinary filterable field doesn't thereby decide which physical index gets searched.
- validators number fields
- Deterministic
min/maxbounds, checked before any query is built.{"min": 0, "max": 5}onratingrejects "rating above 9" outright, instead of quietly returning nothing.
// items: [{sku:"ABC", price:20}, {sku:"XYZ", price:900}]
// "item ABC costing over 100"
// without elemMatch — WRONG, matches this order:
{"items.sku":"ABC", "items.price":{"$gt":100}}
// with elemMatch: "items" — correct, matches nothing:
{"items": {"$elemMatch": {"sku":"ABC", "price":{"$gt":100}}}}"mapping": { "elasticsearch": "customerName" }
"keywordMapping": { "elasticsearch": "customerName.keyword" }
// "customer name contains John" -> match on customerName (text)
// "customer name equals John Smith" -> term on customerName.keywordOperators
Nineteen comparisons exist in total; this is the complete, fixed catalogue — the loader rejects anything not in it. A field's operators list narrows what's allowed on it; leaving it out means "use my type's defaults" from the table above.
| Operator | Means | Typically used on |
|---|---|---|
equals / notEquals | Exact match | any type |
gt / lt / gte / lte | Greater / less than (or equal) | number, date |
between | Inclusive range | number, date |
in / notIn | Membership in a short list | string, number, enum, array |
contains / startsWith / endsWith | Substring match — needs searchable: true | string, enum |
containsAny / containsAll | Array membership: any / every listed value present | array |
regex | Pattern match — needs searchable: true, blockable via policy.denyRegexOn | string |
before / after | Strictly earlier / later | date |
isNull / isNotNull | Presence check | any type |
contains on a number) loads fine but is dead weight — no value ever passes the type check, so it never fires. The builder flags these; the loader does not.Elasticsearch / OpenSearch
Elasticsearch and OpenSearch share one compiler — the query DSL QueryForge emits is byte-identical either way. A config still names one product (backends.elasticsearch or backends.opensearch), because that's the id you pass to Translate. Elasticsearch/OpenSearch can't be mixed with SQL or Mongo backends in the same config — a config targets one search cluster, not several.
Which index(es) to search — four modes
| Mode | Config shape | Resolves to |
|---|---|---|
| Direct Index | { "index": "orders" } | Exactly one physical index: GET /orders/_search |
| Multiple Index | { "indexes": ["orders-2025","orders-2026"] } | Several concrete indexes, comma-joined: GET /orders-2025,orders-2026/_search |
| Alias | { "alias": "orders" } | An index alias — same request shape as Direct, tracked separately so a caller can still tell alias from index. |
| Business-rule routing | { "routing": {...} } | Which index to hit is computed from the query's own value for a routing field (below). |
Business-rule routing — four strategies
Routing never guesses: a routing field absent from the question, or a strategy that resolves nothing, falls back to a required default index list. QueryForge never searches an unrestricted wildcard.
pattern
Substitutes a field's literal value into an index template — for tenant- or region-sharded indexes.
"routing": {
"strategy": "pattern",
"field": "tenantId",
"indexPattern": "tenant-{tenantId}-orders",
"default": ["orders-shared"]
}
// tenantId = "acme" -> tenant-acme-ordersdate
The dedicated date-partition strategy. One value resolves one partition; a range expands to every partition it spans.
"routing": {
"strategy": "date",
"field": "createdAt",
"indexPattern": "orders-{yyyy-MM}",
"granularity": "MONTH",
"default": ["orders-legacy"]
}
// 2025-11-15..2026-02-10 -> 4 monthly indexesrules
An unordered list of when/then conditions. Highest priority match wins; two matches tied at the same priority is a resolution error, not a guess.
"routing": { "strategy": "rules", "rules": [
{ "when": {"field":"amount","operator":"gt","value":"1000"},
"indexes": ["orders-big"], "priority": 1 },
{ "when": {"field":"amount","operator":"gt","value":"500"},
"indexes": ["orders-medium"] }
], "default": ["orders-standard"] }ifElse
Ordered branches, evaluated top to bottom — first match wins. No notEquals: branch order already expresses "everything else."
"routing": { "strategy": "ifElse", "branches": [
{ "if": {"field":"createdAt","operator":"gte","value":"2026-01-01"},
"indexes": ["orders-2026"] },
{ "else": true, "indexes": ["orders-legacy"] }
], "default": ["orders-legacy"] }"routingField": true may be named in a routing strategy — an ordinary field referenced there is rejected at load. The indexPattern token must match the chosen granularity exactly: {yyyy}, {yyyy-MM}, or {yyyy-MM-dd}.How the compiler actually behaves
Everything above is the config. This is what the generator does with it once a question has already been validated — for anyone debugging a query that came back with unexpected results, or reviewing the compiled DSL for correctness. It reflects the actual Go implementation (gen_es.go, source_es.go, elastic_config.go), not just the config shape.
One compiler, read-only, no execution
Elasticsearch and OpenSearch are compiled by the same function. There's no dialect split today because the query-DSL surface QueryForge emits — bool/filter, term/terms/range/match/prefix/wildcard/regexp/exists/nested, sort, _source, size/from — hasn't diverged between the two products. Generate() is a pure function of the query and the config: no network call, nothing executed against a real cluster. Method is always "GET" — QueryForge only ever builds a search request, never anything that could write.
type ESQuery struct {
Method string // always "GET"
Index []string // resolved physical index/alias name(s)
Path string // "/<index0>,<index1>/_search", ready to use
SourceType string // DIRECT_INDEX | MULTIPLE_INDEX | ALIAS | BUSINESS_RULE_INDEX
Query map[string]any
Sort []map[string]any
Source any // projection — omitted means every field
Size, From int
}
A caller can use Path directly as GET <Path> with Query/Sort/_source/size/from as the body, or read Index and SourceType in structured form — both describe exactly the same resolution.
Which physical field a predicate actually touches
Every operator except contains reads a field's exact path — its keywordMapping when one is set, otherwise its ordinary mapping. Only contains on a string reads the plain mapping as analyzed text. This is the concrete mechanics behind the rule stated earlier: a field indexed twice (analyzed text + unanalyzed keyword) is searched on the right twin automatically, per operator — you never choose it yourself in the question.
| Operator | Path used | Compiles to |
|---|---|---|
equals / in | exact | term / terms |
notEquals / notIn | exact | term/terms in must_not, plus an exists guard (below) |
gt / gte / lt / lte / between | exact | range |
before / after | exact | range — inclusive on both, matching every other generator |
startsWith | exact | prefix |
endsWith | exact | wildcard — see the cost note below |
regex | exact | regexp — the raw pattern, gated upstream by policy.denyRegexOn |
contains on a string | text (plain mapping) | match — real full-text search |
contains on an array | exact | term — membership, same reading as equality on a multi-valued field |
containsAny | exact | terms |
containsAll | exact | bool/filter of one term per value (an AND, not an OR) |
isNull / isNotNull | exact | exists, negated for isNull |
endsWith has no native counterpart in Elasticsearch — a suffix match compiles to a leading wildcard (wildcard: "*value"), which can't use the term index and scans every value in the field. It's the correct translation, just an expensive one; treat it the same way you'd treat a filter on a non-indexed column.Case-insensitive comparisons
A field's caseInsensitive: true renders differently depending on the query type, because Elasticsearch's own case_insensitive parameter isn't available everywhere. term, prefix, wildcard and regexp all take it natively. terms (used for in/containsAny) does not — QueryForge renders that case as a bool/should of individually case-insensitive term queries instead, one per value, with minimum_should_match: 1.
Negation and SQL's NULL semantics — the fix behind notEquals/notIn/NOT
notIn/NOTIn SQL, status <> 'CANCELLED' evaluates to NULL — not true — for a row whose status column is NULL, so that row is excluded from the results. A bare Elasticsearch must_not doesn't have that three-valued logic: a document that's missing the field entirely still satisfies must_not, because there's nothing there to contradict it. Left alone, that mismatch means notEquals/notIn/a logical NOT would silently return more rows on Elasticsearch than the identical question returns on Postgres or Mongo — breaking the guarantee that the same question means the same rows on every backend.
QueryForge closes the gap by ANDing an exists filter alongside every such must_not, so a document missing the field is excluded from both the match and its negation — matching how SQL and Mongo already behave. The guard is skipped where it wouldn't mean anything: isNull/isNotNull (already about absence) and a NOT over a nested field (the nested object existing says nothing about the leaf value inside it). This exact gap was caught in review before the backend shipped and is pinned permanently by a dedicated test (TestESNegationRequiresExistence).
// "status" not "CANCELLED" -> without the guard, a document with no
// status field at all would incorrectly match:
{ "term": { "status.keyword": "CANCELLED" } } // naive must_not target
// what QueryForge actually compiles:
{ "bool": {
"must_not": [ { "term": { "status.keyword": "CANCELLED" } } ],
"filter": [ { "exists": { "field": "status.keyword" } } ]
} }
Nested query folding
Two conditions that are direct AND siblings and share the same nestedPath are merged into one nested query wrapping a single bool/filter, instead of becoming two separate nested queries that could each match a different array element. This is the Elasticsearch analogue of Mongo's elemMatch folding, structurally simpler because an ES nested query addresses fields by their full path even from inside the wrapper — no relative-path rewriting is needed, unlike Mongo. A lone predicate on a nested path is still wrapped in nested, just not merged with anything. Folding only ever looks at direct AND children, for the identical reason routing only trusts AND-reachable predicates (below): a condition inside an OR or a NOT isn't guaranteed to hold for the same element.
sku and price both mapped under items, nestedPath: "items". Question: "item ABC costing over 100, and not cancelled"{
"bool": { "filter": [
{ "nested": { "path": "items", "query": { "bool": { "filter": [
{ "term": { "items.sku": "ABC" } },
{ "range": { "items.price": { "gt": 100 } } }
] } } } },
{ "bool": {
"must_not": [ { "term": { "status.keyword": "CANCELLED" } } ],
"filter": [ { "exists": { "field": "status.keyword" } } ]
} }
] }
}
One nested query, not two — so a row with {sku: "ABC", price: 20} and a separate {sku: "XYZ", price: 900} line item correctly does not match, exactly like the Mongo elemMatch example earlier on this page. The negation still gets its exists guard alongside it.
Business-rule routing: what's actually trusted
The routing strategies documented above rest on one rule: only a condition reachable from the filter root through AND alone is ever used to pick an index. A condition inside an OR branch, or inside a NOT, doesn't have to hold for every row the query returns, so it's never trusted for routing — even if it looks like the "obvious" value. Two AND-reachable conditions on the same routing field are just as unusable as none at all: which one would even apply? Both cases fall back to default, exactly like an absent field.
date strategy: bounded, on purpose
Only a single equals (one partition) or a single between (an inclusive range, expanded to every partition it spans) ever resolves to a partition list. An unbounded before/after — "orders before 2020" — deliberately does not resolve: a finite bound the config never promised could span the entire index set, and silently expanding that into "search everything" is exactly the unrestricted-wildcard behavior the library guarantees against. It falls back to default instead.
rules strategy: ties are an error
The highest-priority match wins. Two matching rules tied at the same priority (including the implicit default of 0) is a hard runtime error, not a coin flip — "add distinct priorities to disambiguate." Two rules with the identical field/operator/value and priority are already rejected at load — they could never be told apart at query time.
Load-time checks specific to Elasticsearch
On top of the general field/operator rules from earlier on this page, an Elasticsearch/OpenSearch config is checked against these before it loads:
| Rule | Rejected when |
|---|---|
| One source mode per backend | index, indexes, alias and routing are mutually exclusive — more than one set on the same backend. |
| Valid index names | Any literal index/alias name (index, alias, indexes[], a routing default, a rule's or branch's indexes) isn't lowercase a-z0-9-_.+, starts with -/_/+, is empty, is ./.., or exceeds 255 characters. |
| Exactly one pattern token | indexPattern doesn't contain exactly one {token} placeholder, or — for the date strategy — the token doesn't exactly match the chosen granularity ({yyyy} / {yyyy-MM} / {yyyy-MM-dd}). |
| Routing needs an opted-in field | Any routing strategy references a field not marked routingField: true. |
| Strategy/type mismatch | pattern routing on an array or boolean field; date routing on anything but a date field. |
| Routing condition shape | An operator other than equals/notEquals/gt/gte/lt/lte; or gt/gte/lt/lte on a field that isn't number or date. |
ifElse branch shape | A branch sets both if and else, sets neither, or else isn't the last branch. |
keywordMapping / nestedPath need strings | Set on a field whose values aren't strings — not string/enum, or an array of them. |
nestedPath needs a mapping inside it | No ES mapping on the field actually sits inside the declared nestedPath — e.g. nestedPath: "items" with a mapping of plain "sku" instead of "items.sku". |
defaults & policy
Two small blocks that bound what an otherwise-valid question can do — page sizes and how deep a filter tree may nest.
defaults
"defaults": { "limit": 50, "maxLimit": 500 }
// "give me the first 10000 orders" -> ... LIMIT 500policy
0 means unlimited. Bounds the cost of one pathological question.regex operator is refused. A user-supplied pattern over a free-text column is a denial-of-service surface, so free-text fields belong here."policy": {
"maxNestingDepth": 5,
"denyRegexOn": ["customerName", "description"]
}Field rules — "using this also needs that"
This is a small, optional rule. It says: if the question uses field A, it must also use field B — somewhere. If it doesn't, QueryForge stops and explains why, instead of running a question that would give a confusing or misleading answer.
Why would I need this?
Most of the time, one field is enough on its own. "Orders placed after January 1st" makes sense by itself — no other field is needed to understand it. But sometimes one field, by itself, is not enough to make the question mean something clear. The example that started this feature: a passport's expiry date only means something once you know which country's passport it is. "Passports that are expired" sounds like a real question, and every part of it is a real, working field — but the answer would be wrong or misleading without a country attached to it. A field rule catches that before a query ever runs.
The two words to know
| Word | Plain-English meaning |
|---|---|
| trigger field | The field that "switches the rule on". In the passport example, this is passportExpiry. As soon as a question filters on this field, the rule checks the next thing. |
| companion field | The field the rule then looks for. In the passport example, this is country. It can be anywhere in the question — it does not need to sit right next to the trigger field. |
Building one in the config builder
You never write JSON by hand for this. Open the config builder, go to Step 6 (Guardrails), and open the Field rules box. Each rule is one sentence made from two dropdowns — pick the trigger field, pick the companion field, optionally type your own message. That's it:
When passportExpiry is filtered, also require country
Both dropdowns only ever show fields you've already added to the config — you can't typo a field name into a rule, because there is nothing to type. Leave the box empty and nothing changes about your config; the rule only appears in the downloaded file once you've filled one in.
What it looks like in the JSON file
"policy": {
"requires": [
{
"when": { "field": "passportExpiry" },
"requireAlsoOneOf": ["country"],
"message": "Passport expiry needs a country to be meaningful"
}
]
}
| Key | Do I need it? | What to put there |
|---|---|---|
when.field | yes | The name of the trigger field — must be a field name that already exists in this config. |
requireAlsoOneOf | yes | The name(s) of the companion field(s), as a list. If you list more than one, only one of them needs to be present — the builder's two dropdowns give you exactly one, which covers almost every real case. |
message | no | What to tell the user when the rule blocks a question. Leave it out and QueryForge writes a sensible default for you. |
Two worked examples
Example 1 — passports (the one above). Config has passportExpiry and country. The rule says: if passportExpiry is filtered, country must be filtered too.
- "Passports from India expiring this month" → runs normally. Both fields are present.
- "Passports that are expired" → blocked. Only
passportExpiryis present. The app gets back: "Passport expiry needs a country to be meaningful." - "Orders from India" → runs normally.
passportExpirywas never used, so the rule never switches on at all.
Example 2 — refunds. Config has refundReason and orderNumber. The rule says: if refundReason is filtered, orderNumber must be filtered too — a refund reason on its own can span thousands of unrelated orders, which is rarely what someone actually wants.
- "Why was order 4471 refunded" → runs normally.
- "Refunds because the item was damaged" → blocked — no order number anywhere in the question.
How a blocked question is reported
This is not treated as a broken question and it is not treated as an ordinary error either — it gets its own kind of answer, called a policy error, so your app can show a different, more helpful message than "something went wrong". It carries three things: which field triggered the rule, which field(s) it needed, and the message to show the user.
{
"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"
}
}
In the Go library this arrives as its own error type, *qf.PolicyViolationError — different from an ordinary "this question can't be answered" error, and different again from a plain validation mistake like a misspelled field name. QueryForge does not try the question a second time when this happens: rephrasing alone would not add the missing information, so it reports the problem once and stops, the same way it already does for a question outside the config's vocabulary.
Tenancy / scope fields
A scope field is a value your application already knows — subscriptionId, userId, enterpriseId — and forces onto every query. Declare it queryable: false so the model never even learns the field exists; you pass the value yourself, at call time, not in the question.
{ "name": "subscriptionId", "type": "string", "queryable": false }
engine.Translate(ctx, "delivered orders over 500 dollars", "sql", qf.Scope{
"subscriptionId": session.SubscriptionID,
})
// WHERE (subscriptionId = $1 AND status = $2 AND amount > $3)
Scope predicates are AND-ed onto the filter root after the model answers, and they're still fully parameterized — so a scope value can only narrow a query, never widen or replace one, and an injection payload in it stays a bound argument, not query text.
Making the tenant filter compulsory
Everything on this page so far makes a scope filter impossible to escape once you pass one. Nothing so far makes you pass it. policy.requiredScope is the missing half: it tells QueryForge to refuse any query that arrives without the tenant filter, instead of quietly running it against everybody's data.
The problem it solves
A scope filter is passed at the call site, in code — which means it can be left out at the call site, in code. Add a new endpoint, copy an existing call, forget the last argument, and you have a query that is valid, runs perfectly, returns rows, and is missing the one condition that kept each customer's data separate. Nothing errors. Nothing warns. The results look completely normal, because they are normal — just not filtered to one tenant.
There is a quieter version of the same bug. The filter is passed, but the value is empty — an environment variable that was never set, a claim missing from a token, a session field that was still null. An empty value becomes tenant_id = '', which matches no rows at all. The page comes back empty and looks exactly like a customer who has no data yet. QueryForge refuses a blank scope value for this reason, on every config, whether or not you turn the rule below on.
The three settings
In the config builder, Step 6 (Guardrails), the box is called Always require a tenant / user filter. Pick one of three:
| Setting | What it does | When to pick it |
|---|---|---|
| Off | Nothing changes. A query with no tenant filter runs. | The default, and correct for a single-tenant app or an internal tool where every user may see every row. |
| Require any filter | Some scope must be passed, no matter which keys. | You want a safety net against a call that forgot the argument entirely, but the keys differ between call sites. |
| Require these names | The keys you name must each be present, with a real value. | Almost always the right one. It is the only setting that catches a filter which was passed but is missing the tenant. |
The difference between the last two is worth being clear about, because it is the whole point of naming keys. "Require any filter" is satisfied by {"region": "eu"} — something was passed, so the check is happy, and the query still is not confined to a tenant. "Require these names" with tenantId is not satisfied by that, and says so.
What it looks like in the JSON file
"policy": {
"requiredScope": {
"mode": "fields",
"fields": ["tenantId"]
}
}
Use { "mode": "any" } for the middle setting. Leave the key out entirely for "Off" — a config without it behaves exactly as it did before this setting existed.
The names are scope keys, not fields
This is the one part that surprises people. The names you list here are the keys you pass at call time — they are not checked against the field list, and they usually should not appear in it at all. The recommended setup for a tenant column is to keep it out of the config entirely, or declare it queryable: false, precisely so the model never learns it exists. If these names had to be registered fields, the safest configuration would be the one that refused to load.
The builder does warn you about the reverse mistake: naming a key that is a queryable field. That is legal, but it usually means you meant to hide the column and forgot, so the model can currently see it and write its own filter on it.
What a refused query looks like
{
"success": false,
"code": "INVALID_SCOPE",
"message": "invalid scope: this config requires scope key(s) \"tenantId\",
which the scope did not provide; pass a value for each"
}
Every missing key is listed at once, so wiring up a new call site does not turn into one error per name. In the Go library this is an errors.Is(err, qf.ErrScope) match; the Python SDK raises InvalidScopeError and the Java SDK the equivalent — the same error type you already get for a malformed scope value, because this is the same kind of problem: a bug in the calling code, not in the user's question.
The check runs before the model is called. A missing tenant filter cannot be fixed by asking the AI again, so it costs no API call and no waiting — it fails immediately, at the call site where the mistake actually is. It applies to every entry point equally: Translate, GenerateFrom and ApplyScope, so it cannot be sidestepped by compiling a hand-built AST instead of asking a question.
"", or only spaces — is refused on every config, even with this setting Off. There is no reading of an empty tenant id that means anything useful, and the alternative is a query that silently matches nothing. Numbers and booleans are untouched: 0 and false are real values, and a tenant id of 0 keeps working.A complete, valid config
Every field type, mapping, synonym, capability flag and safety rule from this page, in one file that loads as-is — the same one the Config Builder starts from when you click "start with an example."
{
"entity": "Order",
"version": 1,
"model": {
"provider": "gemini",
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-3.5-flash",
"apiKeyEnv": "QF_API_KEY",
"temperature": 0,
"maxTokens": 4096
},
"backends": {
"sql": { "table": "orders" },
"mongo": { "collection": "orders" }
},
"fields": [
{
"name": "status", "type": "enum",
"values": ["PLACED", "DELIVERED", "CANCELLED", "REFUNDED"],
"operators": ["equals", "notEquals", "in", "notIn"],
"synonyms": ["state", "order status", "delivery status"],
"indexed": true, "priority": 10,
"mapping": { "sql": "status", "mongo": "status" }
},
{
"name": "refunded", "type": "boolean",
"synonyms": ["was refunded", "is refunded"],
"mapping": { "sql": "refunded", "mongo": "refunded" }
},
{
"name": "createdAt", "type": "date",
"operators": ["before", "after", "between"],
"synonyms": ["created", "order date", "placed on", "ordered"],
"indexed": true, "priority": 8,
"mapping": { "sql": "created_at", "mongo": "createdAt" }
},
{
"name": "amount", "type": "number",
"operators": ["gt", "lt", "gte", "lte", "between"],
"synonyms": ["total", "order value", "price", "cost"],
"validators": { "min": 0 },
"mapping": { "sql": "amount", "mongo": "amount" }
},
{
"name": "tags", "type": "array", "itemType": "string",
"operators": ["contains", "containsAny", "containsAll"],
"synonyms": ["labels"],
"mapping": { "sql": "tags", "mongo": "tags" }
},
{
"name": "customerName", "type": "string",
"operators": ["contains", "startsWith", "endsWith", "equals"],
"synonyms": ["customer", "buyer", "name"],
"searchable": true,
"mapping": { "sql": "customer_name", "mongo": "customerName" }
},
{
"name": "internalNote", "type": "string",
"queryable": false, "returnable": false
}
],
"defaults": { "limit": 50, "maxLimit": 500 },
"policy": { "maxNestingDepth": 5, "denyRegexOn": ["customerName"] }
}
What it does with a real question
Given this config, the question "cancelled orders over 200 dollars" resolves status and amount — both registered, both permitting the operators used — and compiles like this:
SELECT * FROM orders
WHERE (status = $1 AND amount > $2)
-- args: ["CANCELLED", 200]{ "status": "CANCELLED", "amount": { "$gt": 200 } }Same question, same validated plan, two different compiled outputs — nothing about the fields or the question changes between backends.
Common mistakes
Pulled directly from the loader's own validation rules — these are the ones that trip people up most, roughly ordered from "won't load at all" to "loads fine but quietly does the wrong thing."
| Mistake | What happens |
|---|---|
An enum field with no values | Rejected at load — an enum needs a domain to check against. |
Pasting a real API key into apiKeyEnv | Rejected at load. That field takes a variable name, e.g. QF_API_KEY — the key goes in the environment. |
Two fields with the same name | Rejected at load — names must be unique. |
contains/startsWith/regex listed without searchable: true | Rejected at load on an enum (defaults false); on a string it's still an error if you also explicitly set searchable: false. |
validators.min greater than validators.max | Rejected at load — no value could ever satisfy both. |
caseInsensitive together with valueCase on the same field | Rejected at load — they solve the same mismatch from opposite ends and can't both apply. |
Mongo elemMatch that isn't a prefix of the field's own path | Rejected at load — the array path must be a leading part of the field's path. |
A field and a synonym on another field that are the same word | Loads, but the later field silently wins the shared index — the earlier term stops resolving. No error is raised. |
A dotted Mongo path with no elemMatch, where the first segment actually is an array of sub-documents | Loads, but two conditions on that array can each match a different element instead of the same one. |
No backends declared at all | Loads — the entity name is used as the table/collection name. Fine for a prototype; often not what was intended once the physical name diverges. |
Settings you pass at run time
The config file describes your data. These settings describe how QueryForge runs on your machine. They are environment variables, so you can change them per deployment without touching code or the config file.
They apply to the Python and Java SDKs, which run the QueryForge engine as a small helper process for each call. The Go library runs inside your own program and reads none of them — in Go you set the same things in code: a context deadline for timeouts and qf.SlogObserver for logs.
| Environment variable | Java system property | Default | What it does |
|---|---|---|---|
Whatever your config's apiKeyEnv names, e.g. QF_API_KEY | — | none | The model API key. The config file holds only the variable's name, never the key itself. |
QUERYFORGE_MAX_CONCURRENT_PROCESSES | -Dqueryforge.maxConcurrentProcesses | unset — no limit | The most engine processes allowed to run at the same time. Extra calls wait in line. See Limiting how many queries run at once. |
QUERYFORGE_LOG_LEVEL | -Dqueryforge.logLevel | off | off | error | warn | info | debug. Turns on SDK and engine logs. See Timeouts, errors and logs. |
QUERYFORGE_BINARY | -Dqueryforge.binary | the engine bundled with the package | Run this engine executable instead. If it is missing or not executable you get a clear error — the SDK never quietly falls back to a different engine. |
QUERYFORGE_CACHE_DIR | -Dqueryforge.cacheDir | the system temp directory | Java only. Where the bundled engine is unpacked before it runs. Set it when your temp directory is mounted noexec. |
-D system property wins over the environment variable of the same meaning, so a JVM launch flag can override what a container image sets.Limiting how many queries run at once
Each Python or Java call starts one engine process, and that process stays alive for the whole call — including the seconds spent waiting for the AI model to answer. A burst of 200 web requests therefore means 200 engine processes running together, which is how a server runs out of memory, file handles or process slots. One setting puts a ceiling on it.
# Python or Java
export QUERYFORGE_MAX_CONCURRENT_PROCESSES=8
# Java, as a JVM flag instead
java -Dqueryforge.maxConcurrentProcesses=8 -jar app.jar
With a limit of 8, the first 8 calls start right away. Call number 9 waits in line and starts the moment one of the 8 finishes. Waiting uses no polling and no extra threads, and your code does not change — a call that had to wait simply returns a little later.
What happens in each situation
| Situation | What happens |
|---|---|
| Not set, or set to an empty value | No limit. Exactly the behaviour from before this setting existed. |
| A slot is free | The call starts immediately. The check costs about a microsecond. |
| All slots are busy | The call waits in line and starts as soon as a slot frees up. |
| The call's timeout runs out while it is still waiting | The call fails with the code SDK_BUSY — TimeoutError in Python, TimeoutException in Java. No engine process is started, so it is always safe to retry. |
| The call has no timeout | It waits as long as it takes. |
An invalid value: 0, -1, abc, 1.5 | Every call fails with InvalidConfigError (Python) / InvalidConfigException (Java), and the message names the setting. A mistyped limit is never treated as "no limit" — you would believe you were protected when you were not. |
Waiting counts against the timeout
Time spent in line is taken out of the call's timeout, not added on top of it. Give a query a 5-second timeout, let it wait 2 seconds for a slot, and the engine gets the remaining 3 seconds. A caller who asked for 5 seconds never waits much longer than 5 seconds, however busy the server is.
The limit is per process
The count is shared by every thread inside one program. In Java that means the whole JVM — every QueryForge instance shares one limit. In Python it means one interpreter process.
The setting is read once, on the first call. Changing the variable while the program is running has no effect; restart to apply a new value.
Choosing a number
Turn on logging at info and watch the wait_ms field (next section). If it is usually near zero, the limit is rarely reached. If it is often large, calls are queueing — raise the limit if the server has room, or add workers. If the server still runs short of memory during bursts, lower it. Your AI provider's rate limit is a sensible upper bound too: more simultaneous calls than it allows only turns waiting in line into rate-limit errors.
Timeouts, errors and logs
Every failure comes back as a specific error with a stable code. There is no path that returns an empty or partial query in place of an error.
Setting a timeout
# Python (seconds)
# for every query, or for one
qf = QueryForge.postgres("orders.config.json", timeout=10)
sql = qf.query("cancelled orders").timeout(5).to_sql()
// Java (milliseconds)
// for every query, or for one
QueryForge forge = QueryForge.postgres(Paths.get("orders.config.json")).withTimeout(10_000);
String sql = forge.query("cancelled orders").timeout(5_000).toSql();
// Go: a context deadline
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := engine.Translate(ctx, "cancelled orders", "sql", nil)
Error codes
The same codes appear everywhere: on the Python exception as e.code, on the Java exception as getCode(), and in Go from qf.Classify(err). Catch the base class (QueryForgeError / QueryForgeException) if you don't care why, or a specific one if you do.
| Code | Python / Java class | What it means | Retry? |
|---|---|---|---|
INVALID_REQUEST, UNKNOWN_OP | InvalidRequestError / InvalidRequestException | The call itself is malformed — e.g. an empty question. Fix the calling code. | No |
INVALID_CONFIG | InvalidConfigError / InvalidConfigException | The config file did not load, or a run-time setting such as QUERYFORGE_MAX_CONCURRENT_PROCESSES is invalid. | No |
UNKNOWN_BACKEND | UnknownBackendError / UnknownBackendException | No generator for that backend name. | No |
INVALID_SCOPE | InvalidScopeError / InvalidScopeException | A tenant or scope filter was missing, blank or not allowed — always an application bug. See the tenant filter. | No |
VALIDATION_FAILED | ValidationError / ValidationException | The query broke a rule the config declares, usually a field the config doesn't list. | No |
UNSUPPORTED_REQUEST | UnsupportedRequestError / UnsupportedRequestException | The question can't be answered with this config. The message is written to be shown to the person who asked. | No |
POLICY_VIOLATION | PolicyViolationError / PolicyViolationException | A field rule was broken. Also safe to show to the person who asked. | No |
MODEL_OUTPUT | ModelOutputError / ModelOutputException | The model answered, but never with usable output. | Yes |
MODEL_TRANSPORT | ModelTransportError / ModelTransportException | The model could not be reached: network, missing or rejected API key, or rate limit. | Yes |
GENERATE_FAILED | GenerateError / GenerateException | A valid query plan could not be compiled for this backend. | No |
TIMEOUT | TimeoutError / TimeoutException | The engine ran past the call's timeout. | Maybe |
SDK_BUSY | TimeoutError / TimeoutException | Python and Java only. The timeout ran out while waiting for a free slot under QUERYFORGE_MAX_CONCURRENT_PROCESSES. Nothing was started. | Yes |
BINARY_NOT_FOUND | BinaryNotFoundError / BinaryNotFoundException | Python and Java only. No engine for this platform — reinstall, or set QUERYFORGE_BINARY. | No |
PROTOCOL_ERROR | ProtocolError / ProtocolException | Python and Java only. The engine crashed or is the wrong version — a broken install. | No |
INTERNAL | QueryForgeError / QueryForgeException | An error QueryForge has no name for. Worth reporting as a bug. | No |
TIMEOUT and SDK_BUSY share a class on purpose, so one except TimeoutError / catch (TimeoutException e) handles both. Check the code when you need to tell "the engine was slow" apart from "the server was too busy to start it".
Logs
Logging is off until you turn it on. Set QUERYFORGE_LOG_LEVEL=info and every call writes one line when it finishes:
engine request completed
library=queryforge language=java operation=translate request_id=e63571548915
backend=sql entity=Order outcome=ok duration_ms=2310 wait_ms=410
duration_ms is how long the call took in total. wait_ms appears only when QUERYFORGE_MAX_CONCURRENT_PROCESSES is set, and is the part of that time spent waiting for a slot. A failed call writes one error line instead, carrying error_code.
debug. Only the shape is logged: the entity, the backend, and the names of the scope keys. The full field list is in docs/OBSERVABILITY.md.Quick reference
Every key on one page, for when you already know what you're looking for.
Top level
entity | string, required |
version | number, optional |
model | object, optional |
models | array of model objects, optional |
backends | object keyed by backend id, optional |
fields | array of field objects, required, min length 1 |
defaults | { limit, maxLimit }, optional |
policy | { maxNestingDepth, denyRegexOn, requires }, optional |
Every field key
name | string, required |
type | string | number | boolean | enum | date | array, required |
values | string[], required if type is enum (or itemType is enum) |
itemType | a field type, used when type is array |
operators | string[] from the operator catalogue |
synonyms | string[] |
customField | boolean — makes description (and valueHint, on a searchable string) required |
displayName | string, always optional |
description | string, required once customField is true |
valueHint | string — searchable string fields only |
mapping | { backendId: physicalPath } |
elemMatch | string — Mongo array path |
keywordMapping | { elasticsearch/opensearch: path } |
nestedPath | string — ES nested object path |
routingField | boolean |
valueCase | "lower" | "upper" |
caseInsensitive | boolean, string fields only |
queryable / filterable / searchable / sortable / returnable | boolean, each independently defaulted |
indexed | boolean |
priority | number |
validators | { min, max }, number fields |