Documentation

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.

Build one interactively → Jump to a full example
Getting started

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"
QueryForge only builds the query — it never connects to your database. You run the SQL or Mongo query it hands back with whatever driver you already use.

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.

All three give back the same thing for the same question: a query string, its parameter values (never inlined — that's what stops injection), and a plain-English readback of what QueryForge understood. Bind the values with your own driver; QueryForge never runs the query for you.

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 it works

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

The big picture Your app sends a question to the QueryForge engine. The engine asks an AI model what it means, checks the answer, and sends back a query. Your app runs the query on your own database. QueryForge never connects to the database. Your app Go, Python or Java QueryForge engine checks the plan and builds the query AI model Gemini, OpenAI, Groq… question query + values what does it mean? a plan you run the query Your database SQL, Mongo, Elasticsearch QueryForge never connects to your database.

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.

The AI never writes the SQL. It only suggests a plan. QueryForge checks the plan and builds the query itself, with the values kept separate from the query text. That is what stops SQL injection.

Three ways to use the same engine

Three ways to use the same engine In a Go app the engine is a library inside the app. In a Python app and a Java app, a small wrapper starts a separate engine program for each call. All three use the same engine code, so they give the same query. Go app QueryForge engine a normal Go library No separate program. Nothing extra to start. Python app queryforge package small helper, no query logic starts it per call Engine program (Go) comes inside the pip package Java app queryforge jar small helper, no query logic starts it per call Engine program (Go) comes in the classifier jar Same engine code in all three → the same question gives the same query

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)

What happens in one call Your code asks the SDK for a query. The SDK finds the engine, starts it and sends one JSON message. The engine asks the AI model, gets a plan, checks it and builds the query. It sends one JSON message back and closes. The SDK gives your code the query and its values, or an error. Your code QueryForge SDK Python or Java Engine program Go · lives for one call AI model 1 to_sql() / toSql() 2 finds the right engine 3 starts it, sends 1 JSON message 4 what does it mean? a plan checks the plan with your config and builds the query 5 sends 1 JSON message back then the program closes 6 query + values, or an error
  1. You call to_sql() in Python or toSql() in Java.
  2. The SDK finds the engine program made for your computer.
  3. 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.
  4. The engine asks the AI model, checks the plan against your config, and builds the query.
  5. The engine sends one JSON message back, then closes.
  6. 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:

Java needs two dependencies Maven Central has one queryforge jar with the Java code, and five engine jars, one for each kind of computer. A Java app on a Linux Intel or AMD server uses the Java code jar plus the linux-amd64 engine jar, chosen with the classifier. Maven Central queryforge Java code Engine programs — pick one: linux-amd64 Linux · Intel/AMD linux-arm64 Linux · ARM darwin-amd64 Mac · Intel darwin-arm64 Mac · Apple chip windows-amd64 Windows · Intel/AMD Your Java app running on a Linux Intel/AMD server queryforge-<version>.jar the Java code — same for every computer queryforge-<version>-linux-amd64.jar the engine program, built for exactly this kind of computer the classifier picks this one
  • 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 onClassifier
Linux, Intel or AMD chip (most servers)linux-amd64
Linux, ARM chip (e.g. AWS Graviton)linux-arm64
Mac with an Intel chipdarwin-amd64
Mac with an Apple chip (M1 and newer)darwin-arm64
Windows, Intel or AMD chipwindows-amd64
Pick the classifier for the computer where your app runs, not where you build it. For example, if you build on a Mac but deploy to a Linux server, use 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.

The first call in Java The engine sits inside the jar. On the first call the SDK copies it to a temp folder, once. Then it runs it. Later calls reuse the copy. Engine inside the jar can't be run from there First call: copy it out to a temp folder, only once Run it later calls reuse the copy <temp folder>/queryforge-bin/ move it with QUERYFORGE_CACHE_DIR

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.

The same rule applies: install on the computer where the app runs. In Docker, run pip install inside the image, so pip picks the engine for the image, not for your laptop.

Why we built it this way

ChoiceWhat you get
One engine for every languageThe same results everywhere. There are no separate Python and Java copies that slowly drift apart.
A small program, not a serverNothing to host, no port to open, nothing to keep alive or monitor.
The engine ships inside the packageNo Go to install. The Java library has zero other dependencies.
The engine runs as part of your appIt 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.

Overview

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.

KeyRequired?What it's for
entityrequiredThe name of the thing you're querying — "Order", "Customer". Every incoming query must name this exactly.
fieldsrequiredThe whole vocabulary. At least one field. Anything not listed here can't be asked about.
versionoptionalYour own revision number — for your logs, not read by the loader.
modeloptionalWhich AI service turns a sentence into a query plan.
modelsoptionalA fallback chain of extra models, tried in order if the first is unreachable.
backendsoptionalThe real table, collection, or index each database uses.
defaultsoptionalPage size when a question doesn't say, and the hard ceiling on any explicit page size.
policyoptionalSafety 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]
A config that fails any rule below is rejected at LoadConfig time, before your service ever starts handling questions — not discovered later on a live request.
Step 1 · the basics

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": 2

Bump it whenever you ship a meaningfully different config — nothing enforces this, it's a convention.

Step 2 · the AI planner

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.

KeyDefaultMeaning
providerA 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.
baseURLThe API root, without /chat/completions — that suffix is added for you. Required for every provider except anthropic, which resolves its own.
modelThe model id exactly as the provider spells it, e.g. gemini-3.5-flash. Swapping models is a config change, never a code change.
apiKeyEnvThe name of the environment variable holding your key — never the key itself. A pasted key is rejected at load.
temperatureprovider defaultSampling randomness. Set to 0 so the same question compiles the same way every time.
maxTokensprovider defaultCeiling on the reply. Too low and a reasoning model's hidden thinking tokens truncate the JSON mid-object.
jsonModefalseSends 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.
protocolautoThe 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.
timeoutSeconds30Ceiling on one request to the provider — per attempt, not per translate call.
maxRetries2Extra 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.
retryBackoffMs250First 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
}
Never write a real API key into the config. 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.

Step 3 · where the data lives

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 idDatabasePhysical keyNotes
sqlPostgreSQLtable$1, $2… placeholders, double-quoted identifiers. "sql" means Postgres specifically, not a generic SQL backend.
mysqlMySQL / MariaDBtable? placeholders, backtick identifiers. MySQL 8.0+ or MariaDB 10.5+.
mongoMongoDBcollectionDocument store — field mapping values may be dot paths into an embedded document.
elasticsearch / opensearchElasticsearch / OpenSearch— see belowNot 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".

Any other key (not 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.
Step 4 · the vocabulary

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.

KeyRequired?What it does
namerequiredThe logical name used in the AST and in every question. It never has to match the physical column — that's what mapping is for.
typerequiredOne of six kinds (below). Bounds which operators and which value shapes are legal for the field.
valuesrequired for enumThe complete enum domain. A value outside it is rejected before any query is built.
itemTypeoptionalFor a type: "array" field: the element type. Values are checked against this, not against "array".
synonymsoptionalAlternate phrasings that resolve to this field — put in what people actually type.
operatorsoptionalThe comparison whitelist. Leave it out and the field takes its type's defaults.
mappingoptionalThe 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.

Step 4 · the vocabulary

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.

Nothing here connects to a live database. The builder is a static page with no server of its own, so it can only read text you paste — never a hostname, a port, or a password. You run one read-only command yourself and copy its output in; the parsing happens entirely in your browser.
DatabaseWhat to pasteHow to get it
Elasticsearch / OpenSearchA _mapping responseGET /<index>/_mapping
MySQLA CREATE TABLE statementSHOW CREATE TABLE <table>;
PostgreSQLA CREATE TABLE statementpg_dump --schema-only -t <table>
MongoDBOne or more sample documentsdb.<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 no nestedPath).
  • elemMatch — from a Mongo array whose sample elements are sub-documents, not scalars.
  • indexed — from a SQL PRIMARY KEY or UNIQUE column.
  • values — from a MySQL ENUM(...) 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.

Fields

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.

typeHoldsDefault operators
stringFree textequals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull
numberIntegers or decimalsequals, notEquals, gt, lt, gte, lte, between, in, notIn, isNull, isNotNull
booleantrue / falseequals, notEquals, isNull, isNotNull
enumOne value from a fixed, named list (values)equals, notEquals, in, notIn, isNull, isNotNull
dateA point in timebefore, after, between, equals, isNull, isNotNull
arrayA list of values of one itemTypecontains, containsAny, containsAll, isNull, isNotNull
An 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.
Fields

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.

FlagDefaultWhat it gates
queryabletrueWhether 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.
filterabletrueWhether it may appear in a filter (the WHERE clause / find document).
searchabletrue for string, else falseWhether text-search operators (contains, startsWith, endsWith, regex) may be used. Only means anything on string and enum.
sortabletrue except for arrayWhether it may appear in sort[]. Ordering by an array is undefined, so arrays default to false.
returnabletrueWhether 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.

Fields

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.

KeyRequired?What it's for
customFieldoptionalMarks the field as one whose name needs explaining. Querying still works exactly the same either way — this only changes what the loader requires below.
descriptionrequired once customField is trueOne short line on what the field means. Shown to the model.
valueHintrequired if the field is a searchable string, once customField is trueWhat 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.
displayNamealways optionalA 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".

Ordinary fields don't need any of this. A field already named clearly — 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.
Fields

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.
// 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}}}}
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 keyword path for exact match, sort and aggregation, because a single field can't do both. contains reads the plain mapping; every other operator prefers keywordMapping when set.
"mapping":        { "elasticsearch": "customerName" }
"keywordMapping": { "elasticsearch": "customerName.keyword" }

// "customer name contains John"        -> match on customerName (text)
// "customer name equals John Smith"    -> term on customerName.keyword
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 SHIPPED while people say "shipped". Only the compiled argument is recased; the model still sees and validates against the case you wrote in values.
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 / max bounds, checked before any query is built. {"min": 0, "max": 5} on rating rejects "rating above 9" outright, instead of quietly returning nothing.
Fields

Operators

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.

OperatorMeansTypically used on
equals / notEqualsExact matchany type
gt / lt / gte / lteGreater / less than (or equal)number, date
betweenInclusive rangenumber, date
in / notInMembership in a short liststring, number, enum, array
contains / startsWith / endsWithSubstring match — needs searchable: truestring, enum
containsAny / containsAllArray membership: any / every listed value presentarray
regexPattern match — needs searchable: true, blockable via policy.denyRegexOnstring
before / afterStrictly earlier / laterdate
isNull / isNotNullPresence checkany type
Listing an operator that can never be satisfied on a field's type (say, 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.
Search backends

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

ModeConfig shapeResolves 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-orders

date

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 indexes

rules

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"] }
Only fields marked "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}.
Search backends

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.

OperatorPath usedCompiles to
equals / inexactterm / terms
notEquals / notInexactterm/terms in must_not, plus an exists guard (below)
gt / gte / lt / lte / betweenexactrange
before / afterexactrange — inclusive on both, matching every other generator
startsWithexactprefix
endsWithexactwildcard — see the cost note below
regexexactregexp — the raw pattern, gated upstream by policy.denyRegexOn
contains on a stringtext (plain mapping)match — real full-text search
contains on an arrayexactterm — membership, same reading as equality on a multi-valued field
containsAnyexactterms
containsAllexactbool/filter of one term per value (an AND, not an OR)
isNull / isNotNullexactexists, 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

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

Config: 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:

RuleRejected when
One source mode per backendindex, indexes, alias and routing are mutually exclusive — more than one set on the same backend.
Valid index namesAny 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 tokenindexPattern 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 fieldAny routing strategy references a field not marked routingField: true.
Strategy/type mismatchpattern routing on an array or boolean field; date routing on anything but a date field.
Routing condition shapeAn 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 shapeA branch sets both if and else, sets neither, or else isn't the last branch.
keywordMapping / nestedPath need stringsSet on a field whose values aren't strings — not string/enum, or an array of them.
nestedPath needs a mapping inside itNo 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".
Step 6 · guardrails

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

limit
The page size used when a question names none. Without it, a query can come back unbounded.
maxLimit
The hard ceiling on any explicit limit. A larger request is clamped down, so no phrasing can pull an entire table.
"defaults": { "limit": 50, "maxLimit": 500 }
// "give me the first 10000 orders" -> ... LIMIT 500

policy

maxNestingDepth
Caps how deeply the filter tree may nest. 0 means unlimited. Bounds the cost of one pathological question.
denyRegexOn
Fields on which the 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"]
}
Step 6 · guardrails

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

WordPlain-English meaning
trigger fieldThe 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 fieldThe 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"
    }
  ]
}
KeyDo I need it?What to put there
when.fieldyesThe name of the trigger field — must be a field name that already exists in this config.
requireAlsoOneOfyesThe 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.
messagenoWhat 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 passportExpiry is present. The app gets back: "Passport expiry needs a country to be meaningful."
  • "Orders from India" → runs normally. passportExpiry was 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.

Completely optional. A config with no field rules behaves exactly as it always has. Add one only for a field that genuinely doesn't mean anything on its own — most fields never need this.
Multi-tenancy

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.

Step 6 · guardrails

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:

SettingWhat it doesWhen to pick it
OffNothing 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 filterSome 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 namesThe 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.

A blank value — "", 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.
Putting it together

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:

PostgreSQL
SELECT * FROM orders
WHERE (status = $1 AND amount > $2)
-- args: ["CANCELLED", 200]
MongoDB
{ "status": "CANCELLED", "amount": { "$gt": 200 } }

Same question, same validated plan, two different compiled outputs — nothing about the fields or the question changes between backends.

Putting it together

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

MistakeWhat happens
An enum field with no valuesRejected at load — an enum needs a domain to check against.
Pasting a real API key into apiKeyEnvRejected at load. That field takes a variable name, e.g. QF_API_KEY — the key goes in the environment.
Two fields with the same nameRejected at load — names must be unique.
contains/startsWith/regex listed without searchable: trueRejected 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.maxRejected at load — no value could ever satisfy both.
caseInsensitive together with valueCase on the same fieldRejected 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 pathRejected 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 wordLoads, 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-documentsLoads, but two conditions on that array can each match a different element instead of the same one.
No backends declared at allLoads — the entity name is used as the table/collection name. Fine for a prototype; often not what was intended once the physical name diverges.
The Config Builder runs every one of these checks live as you type, and tells you exactly which key is affected — it's the fastest way to catch these before they reach a real load. Open it →
Running in production

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 variableJava system propertyDefaultWhat it does
Whatever your config's apiKeyEnv names, e.g. QF_API_KEYnoneThe model API key. The config file holds only the variable's name, never the key itself.
QUERYFORGE_MAX_CONCURRENT_PROCESSES-Dqueryforge.maxConcurrentProcessesunset — no limitThe 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.logLeveloffoff | error | warn | info | debug. Turns on SDK and engine logs. See Timeouts, errors and logs.
QUERYFORGE_BINARY-Dqueryforge.binarythe engine bundled with the packageRun 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.cacheDirthe system temp directoryJava only. Where the bundled engine is unpacked before it runs. Set it when your temp directory is mounted noexec.
In Java, a -D system property wins over the environment variable of the same meaning, so a JVM launch flag can override what a container image sets.
Running in production

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

SituationWhat happens
Not set, or set to an empty valueNo limit. Exactly the behaviour from before this setting existed.
A slot is freeThe call starts immediately. The check costs about a microsecond.
All slots are busyThe call waits in line and starts as soon as a slot frees up.
The call's timeout runs out while it is still waitingThe call fails with the code SDK_BUSYTimeoutError in Python, TimeoutException in Java. No engine process is started, so it is always safe to retry.
The call has no timeoutIt waits as long as it takes.
An invalid value: 0, -1, abc, 1.5Every 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.

Separate processes each get their own limit. A Python app running 4 gunicorn or uvicorn workers with a limit of 8 can run up to 32 engine processes in total, and 3 Java replicas with a limit of 8 can run 24. Choose the number per worker.

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.

Running in production

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.

CodePython / Java classWhat it meansRetry?
INVALID_REQUEST, UNKNOWN_OPInvalidRequestError / InvalidRequestExceptionThe call itself is malformed — e.g. an empty question. Fix the calling code.No
INVALID_CONFIGInvalidConfigError / InvalidConfigExceptionThe config file did not load, or a run-time setting such as QUERYFORGE_MAX_CONCURRENT_PROCESSES is invalid.No
UNKNOWN_BACKENDUnknownBackendError / UnknownBackendExceptionNo generator for that backend name.No
INVALID_SCOPEInvalidScopeError / InvalidScopeExceptionA tenant or scope filter was missing, blank or not allowed — always an application bug. See the tenant filter.No
VALIDATION_FAILEDValidationError / ValidationExceptionThe query broke a rule the config declares, usually a field the config doesn't list.No
UNSUPPORTED_REQUESTUnsupportedRequestError / UnsupportedRequestExceptionThe question can't be answered with this config. The message is written to be shown to the person who asked.No
POLICY_VIOLATIONPolicyViolationError / PolicyViolationExceptionA field rule was broken. Also safe to show to the person who asked.No
MODEL_OUTPUTModelOutputError / ModelOutputExceptionThe model answered, but never with usable output.Yes
MODEL_TRANSPORTModelTransportError / ModelTransportExceptionThe model could not be reached: network, missing or rejected API key, or rate limit.Yes
GENERATE_FAILEDGenerateError / GenerateExceptionA valid query plan could not be compiled for this backend.No
TIMEOUTTimeoutError / TimeoutExceptionThe engine ran past the call's timeout.Maybe
SDK_BUSYTimeoutError / TimeoutExceptionPython and Java only. The timeout ran out while waiting for a free slot under QUERYFORGE_MAX_CONCURRENT_PROCESSES. Nothing was started.Yes
BINARY_NOT_FOUNDBinaryNotFoundError / BinaryNotFoundExceptionPython and Java only. No engine for this platform — reinstall, or set QUERYFORGE_BINARY.No
PROTOCOL_ERRORProtocolError / ProtocolExceptionPython and Java only. The engine crashed or is the wrong version — a broken install.No
INTERNALQueryForgeError / QueryForgeExceptionAn 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.

Logs never contain the question text, scope or tenant values, the config's contents, or API keys — at any level, including 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.
Cheat sheet

Quick reference

Every key on one page, for when you already know what you're looking for.

Top level

entitystring, required
versionnumber, optional
modelobject, optional
modelsarray of model objects, optional
backendsobject keyed by backend id, optional
fieldsarray of field objects, required, min length 1
defaults{ limit, maxLimit }, optional
policy{ maxNestingDepth, denyRegexOn, requires }, optional

Every field key

namestring, required
typestring | number | boolean | enum | date | array, required
valuesstring[], required if type is enum (or itemType is enum)
itemTypea field type, used when type is array
operatorsstring[] from the operator catalogue
synonymsstring[]
customFieldboolean — makes description (and valueHint, on a searchable string) required
displayNamestring, always optional
descriptionstring, required once customField is true
valueHintstring — searchable string fields only
mapping{ backendId: physicalPath }
elemMatchstring — Mongo array path
keywordMapping{ elasticsearch/opensearch: path }
nestedPathstring — ES nested object path
routingFieldboolean
valueCase"lower" | "upper"
caseInsensitiveboolean, string fields only
queryable / filterable / searchable / sortable / returnableboolean, each independently defaulted
indexedboolean
prioritynumber
validators{ min, max }, number fields
Build a config now → View the Go source